Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aa7b4a8
fix: restore multi-shape history at recorded indices
devmobasa Aug 14, 2026
3fb90ff
fix: keep original text when persisting during an in-place edit
devmobasa Aug 14, 2026
d405c69
fix: crop portal freeze/zoom using desktop screenshot origin
devmobasa Aug 14, 2026
25d481e
fix: hit-test one-point pressure strokes as painted cap circles
devmobasa Aug 14, 2026
b6e86b7
fix: keep capture and export saves inside the target directory
devmobasa Aug 14, 2026
7aef054
fix: confine portal screenshot reads to trusted temp files
devmobasa Aug 14, 2026
d625afe
fix: open clipboard file pastes without following symlinks
devmobasa Aug 14, 2026
0b346dd
fix: refuse oversized config.toml loads
devmobasa Aug 14, 2026
111ad01
fix: preserve board pan settings when saving from the configurator
devmobasa Aug 14, 2026
37b4ab3
fix: refuse out-of-range configurator values instead of clamping them
devmobasa Aug 14, 2026
bc355e8
fix: validate configurator keybinding text with the real parser
devmobasa Aug 14, 2026
d682a77
fix: reject an empty daemon shortcut instead of inventing one
devmobasa Aug 14, 2026
9107258
fix: reveal UI subtabs before hiding the current stack child
devmobasa Aug 14, 2026
a84b2aa
fix: take configurator keybinding labels from action_meta
devmobasa Aug 14, 2026
7c2dcc5
fix: name every configurator tab as an --open destination
devmobasa Aug 14, 2026
c806f6e
fix: do not detach the GTK toolbar thread on shutdown timeout
devmobasa Aug 14, 2026
ecd2e33
fix: fail closed on portal output mismatch and crop overflow
devmobasa Aug 14, 2026
67a2bd2
fix: log onboarding save failures and document remaining unsafe buffers
devmobasa Aug 14, 2026
1732761
fix: gate the tablet configurator destination on tablet-input
devmobasa Aug 14, 2026
0273d40
test: assert a parser-rejected shortcut cannot save
devmobasa Aug 14, 2026
31b73b4
fix: read portal screenshots from any regular file:// URI
devmobasa Aug 14, 2026
d2f7770
fix: detach the GTK toolbar thread when shutdown times out
devmobasa Aug 14, 2026
5ff5a91
fix: refresh freeze/zoom crop origin on every output change
devmobasa Aug 14, 2026
18abc65
fix: reject portal freeze/zoom captures after the layout changes
devmobasa Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1381,10 +1381,11 @@ enabled = true
# Directory where PNG files are saved (supports ~ expansion)
save_directory = "~/Pictures/Wayscriber"

# Filename template (strftime-like subset: %Y, %m, %d, %H, %M, %S)
# Filename template (strftime-like subset: %Y, %m, %d, %H, %M, %S).
# Must be a single file name, not a path.
filename_template = "screenshot_%Y-%m-%d_%H%M%S"

# Image format for saved screenshots
# Image format for saved screenshots: png, jpg, or jpeg
format = "png"

# Copy screenshots to clipboard by default
Expand Down
12 changes: 7 additions & 5 deletions configurator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ wayscriber-configurator --open 'drawing?search=Quick Colors'
```

Destinations are `ui/toolbar`, `ui/toolbar-visibility`, `ui/status-bar`, `ui/click-highlight`,
`ui/input-hud`, `drawing`, `presets`, `boards`, `history`, `session`, `keybindings`, and
`keybindings/<section>` for `general`, `drawing`, `tools`, `selection`, `history`, `boards`,
`ui-modes`, `capture-view`, and `presets`. Append `?search=<TERM>` to any of them to open with the
search box filled. `--help` prints the same list. An unknown destination falls back to the normal
initial screen and says so in the status banner.
`ui/input-hud`, `ui/help-overlay`, `ui/presenter-mode`, `drawing`, `presets`, `boards`, `history`,
`session`, `capture`, `performance`, `daemon`, `arrow`, `render-profiles`, `keybindings`,
and `keybindings/<section>` for `general`, `drawing`, `tools`, `selection`, `history`, `boards`,
`ui-modes`, `capture-view`, and `presets`. Builds with tablet input also accept `tablet`.
Append `?search=<TERM>` to any of them to open with the search box filled. `--help` prints the
same list. An unknown destination falls back to the normal initial screen and says so in the
status banner.

Toolbar pin, top placement offsets, the top strip's display form, item visibility/order, and board
pin controls are labeled as configured defaults because the running overlay can store later
Expand Down
18 changes: 13 additions & 5 deletions configurator/src/app/daemon_setup/shortcut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,7 @@ fn normalize_shortcut_for_portal(input: &str) -> Result<String, String> {
fn normalize_shortcut(input: &str, gnome_style: bool) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(if gnome_style {
"<Super>g".to_string()
} else {
"<Ctrl><Shift>g".to_string()
});
return Err("Shortcut cannot be empty.".to_string());
}
if trimmed.contains('<') && trimmed.contains('>') {
return Ok(trimmed.to_string());
Expand Down Expand Up @@ -461,6 +457,18 @@ mod tests {
assert!(error.contains("Unsupported modifier"));
}

#[test]
fn normalize_shortcut_rejects_empty_input() {
assert_eq!(
normalize_shortcut_for_gnome("").expect_err("empty gnome shortcut"),
"Shortcut cannot be empty."
);
assert_eq!(
normalize_shortcut_for_portal(" ").expect_err("blank portal shortcut"),
"Shortcut cannot be empty."
);
}

#[test]
fn shell_quote_leaves_simple_paths_unquoted() {
assert_eq!(shell_quote("/usr/bin/wayscriber"), "/usr/bin/wayscriber");
Expand Down
22 changes: 22 additions & 0 deletions configurator/src/app/pages/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use relm4::{adw, gtk};
use adw::prelude::*;

use crate::messages::Message;
use crate::models::keybindings::parse_keybinding_list;
use crate::models::{KeybindingField, KeybindingsTabId, TabId};

use super::super::search::AppSearchSummary;
Expand Down Expand Up @@ -136,6 +137,27 @@ fn binding_row(
// value back as a user edit clears that load's diagnostics.
set_text_blocked(&row, &handler, value);

let parse_error = parse_keybinding_list(value).err();
let has_error_class = row.has_css_class("error");
match parse_error {
Some(message) => {
if !has_error_class {
row.add_css_class("error");
}
if row.tooltip_text().as_deref() != Some(message.as_str()) {
row.set_tooltip_text(Some(&message));
}
}
None => {
if has_error_class {
row.remove_css_class("error");
}
if row.tooltip_text().is_some() {
row.set_tooltip_text(None);
}
}
}

let default = app
.defaults
.keybindings
Expand Down
16 changes: 12 additions & 4 deletions configurator/src/app/pages/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,14 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
let ui_summary = summary.tab(TabId::Ui);
let searching = summary.is_active();
let mut any_visible = false;
// Reveal before switching and hide after: the stack drops a
// visible child that goes invisible, and picking the section the
// model asks for first keeps that fallback out of the way.
for (tab, stack_page) in &stack_pages {
// A hidden `GtkStackPage` also drops its switcher button,
// which is how the Iced view narrowed the sub-tab row.
let visible =
!searching || ui_summary.is_some_and(|summary| summary.ui_tab_visible(*tab));
if stack_page.is_visible() != visible {
stack_page.set_visible(visible);
if visible && !stack_page.is_visible() {
stack_page.set_visible(true);
}
any_visible |= visible;
}
Expand All @@ -126,6 +127,13 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
if stack.visible_child_name().as_deref() != Some(name) {
stack.set_visible_child_name(name);
}
for (tab, stack_page) in &stack_pages {
let visible =
!searching || ui_summary.is_some_and(|summary| summary.ui_tab_visible(*tab));
if !visible && stack_page.is_visible() {
stack_page.set_visible(false);
}
}
}));
}

Expand Down
14 changes: 12 additions & 2 deletions configurator/src/app/search/summary.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::app::state::ConfiguratorApp;
use crate::models::{KeybindingsTabId, SearchQuery, TabId, UiTabId};
use wayscriber::config::{
PERFORMANCE_FIELD_METADATA, PerformanceFieldGroup, toolbar_item_definitions,
PERFORMANCE_FIELD_METADATA, PerformanceFieldGroup, action_meta, toolbar_item_definitions,
};

use super::terms::*;
Expand Down Expand Up @@ -369,13 +369,23 @@ fn keybinding_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut
.keybindings
.value_for(entry.field)
.unwrap_or("");
let text = format!(
let mut text = format!(
"keybindings keybinding shortcut hotkey keyboard shortcut list {} {} {} {}",
entry.field.tab().title(),
entry.field.label(),
entry.field.field_key(),
entry.value,
);
if let Some(meta) = entry.field.action().and_then(action_meta) {
text.push(' ');
text.push_str(meta.description);
text.push(' ');
text.push_str(meta.short_label());
for alias in meta.search_aliases {
text.push(' ');
text.push_str(alias);
}
}
if query.matches_text(&text) || query.matches_text(default_value) {
summary.add_keybinding_field(entry.field);
}
Expand Down
28 changes: 28 additions & 0 deletions configurator/src/app/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,20 @@ impl ConfiguratorApp {
ConfiguratorScreen::UiStatusBar => self.show_ui_tab(UiTabId::StatusBar),
ConfiguratorScreen::UiClickHighlight => self.show_ui_tab(UiTabId::ClickHighlight),
ConfiguratorScreen::UiInputHud => self.show_ui_tab(UiTabId::InputHud),
ConfiguratorScreen::UiHelpOverlay => self.show_ui_tab(UiTabId::HelpOverlay),
ConfiguratorScreen::UiPresenterMode => self.show_ui_tab(UiTabId::PresenterMode),
ConfiguratorScreen::Drawing => self.active_tab = TabId::Drawing,
ConfiguratorScreen::Presets => self.active_tab = TabId::Presets,
ConfiguratorScreen::Boards => self.active_tab = TabId::Boards,
ConfiguratorScreen::History => self.active_tab = TabId::History,
ConfiguratorScreen::Session => self.active_tab = TabId::Session,
ConfiguratorScreen::Capture => self.active_tab = TabId::Capture,
ConfiguratorScreen::Performance => self.active_tab = TabId::Performance,
ConfiguratorScreen::Daemon => self.active_tab = TabId::Daemon,
ConfiguratorScreen::Arrow => self.active_tab = TabId::Arrow,
ConfiguratorScreen::RenderProfiles => self.active_tab = TabId::RenderProfiles,
#[cfg(feature = "tablet-input")]
ConfiguratorScreen::Tablet => self.active_tab = TabId::Tablet,
ConfiguratorScreen::Keybindings(section) => {
self.active_tab = TabId::Keybindings;
// No section means the tab, on whichever subtab it already
Expand Down Expand Up @@ -163,6 +172,8 @@ mod tests {
("ui/status-bar", UiTabId::StatusBar),
("ui/click-highlight", UiTabId::ClickHighlight),
("ui/input-hud", UiTabId::InputHud),
("ui/help-overlay", UiTabId::HelpOverlay),
("ui/presenter-mode", UiTabId::PresenterMode),
] {
let (app, _dir, _path) = app_launched_with(&["--open", argument]);

Expand All @@ -179,6 +190,13 @@ mod tests {
("presets", TabId::Presets),
("drawing", TabId::Drawing),
("session", TabId::Session),
("capture", TabId::Capture),
("performance", TabId::Performance),
("daemon", TabId::Daemon),
("arrow", TabId::Arrow),
("render-profiles", TabId::RenderProfiles),
#[cfg(feature = "tablet-input")]
("tablet", TabId::Tablet),
("keybindings", TabId::Keybindings),
] {
let (app, _dir, _path) = app_launched_with(&["--open", argument]);
Expand Down Expand Up @@ -293,6 +311,16 @@ mod tests {
assert!(!app.startup_search_focus_pending);
}

#[test]
#[cfg(not(feature = "tablet-input"))]
fn tablet_is_an_unknown_destination_without_tablet_input() {
let (app, _dir, _path) = app_launched_with(&["--open", "tablet"]);

assert_eq!(app.active_tab, TabId::Daemon);
let text = status_text(&app.status);
assert!(text.contains("Unknown destination: tablet"), "{text}");
}

#[test]
fn an_unrecognized_argument_still_opens_its_destination() {
let (app, _dir, _path) = app_launched_with(&["--frobnicate", "--open", "session"]);
Expand Down
14 changes: 14 additions & 0 deletions configurator/src/app/update/config/save.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ impl ConfiguratorApp {
document: &ConfigDocument,
) -> Result<Config, Vec<FormError>> {
let mut config = self.draft.to_config(document.config())?;
let before_clamp = config.clone();
self.pending_save_validation = config.validate_and_clamp();
if save_clamped_non_keybinding_fields(&before_clamp, &config) {
self.pending_save_validation = Default::default();
return Err(vec![FormError::new(
"config",
"Some values are outside their allowed ranges and would be changed on save. Fix them before saving.",
)]);
}
Ok(config)
}

Expand Down Expand Up @@ -138,3 +146,9 @@ impl ConfiguratorApp {
Vec::new()
}
}

fn save_clamped_non_keybinding_fields(before: &Config, after: &Config) -> bool {
let mut before = before.clone();
before.keybindings = after.keybindings.clone();
format!("{before:?}") != format!("{after:?}")
}
42 changes: 33 additions & 9 deletions configurator/src/app/update/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,27 @@ fn a_draft_the_converter_rejects_keeps_the_document() {
));
}

/// Out-of-range numbers used to parse, then `validate_and_clamp` wrote the
/// clamped value. The converter must refuse them so Save cannot change them.
#[test]
fn a_draft_with_out_of_range_numbers_keeps_the_document() {
let (mut app, _dir, _path) = app_with_config_file("");
app.draft.drawing_default_thickness = "99".to_string();

let effects = app.handle_save_requested();

assert!(effects.is_empty());
assert!(!app.is_saving);
assert!(
app.base_document.is_some(),
"a refused save must not take the document with it"
);
assert!(status_contains(
&app.status,
"drawing.default_thickness: Expected 1-50"
));
}

/// Hex text the parser rejects was never applied to the draft, so a save
/// would write the last value that did parse and the reload would replace
/// the text with it. The Save is refused instead.
Expand Down Expand Up @@ -747,8 +768,7 @@ fn a_save_without_shortcut_trouble_stays_a_plain_success() {
}

/// A shortcut the editor accepts as text but the parser rejects never
/// reaches the file either, so the save status is the only place it can be
/// reported.
/// reaches a write. Save reports the field error instead of asking for one.
#[test]
fn a_typed_shortcut_the_parser_rejects_is_reported_by_the_save() {
let (mut app, _dir, _path) = app_with_config_file(&format!(
Expand All @@ -758,16 +778,20 @@ fn a_typed_shortcut_the_parser_rejects_is_reported_by_the_save() {
.keybindings
.set(KeybindingField::ClearCanvas, "Ctrl+Shift".to_string());

let _ = save_draft(&mut app);
let effects = app.handle_save_requested();

assert!(effects.is_empty());
assert!(!app.is_saving);
assert!(
matches!(app.status, StatusMessage::Warning(_)),
"unexpected status: {:?}",
app.status
app.base_document.is_some(),
"a refused save must not take the document with it"
);
assert!(status_contains(&app.status, "Ctrl+Shift"));
assert!(status_contains(&app.status, "Clear Canvas"));
assert!(status_contains(&app.status, "could not be parsed"));
assert!(status_contains(
&app.status,
"Cannot save due to validation"
));
assert!(status_contains(&app.status, "keybindings.clear_canvas"));
assert!(status_contains(&app.status, "No key specified"));
}

/// A reload replaces the draft and base document when it lands, so a save
Expand Down
2 changes: 2 additions & 0 deletions configurator/src/models/config/boards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub struct BoardsDraft {
pub max_count: String,
pub auto_create: bool,
pub show_board_badge: bool,
pub pan_enabled: bool,
pub show_pan_badge: bool,
pub persist_customizations: bool,
pub default_board: String,
pub items: Vec<BoardItemDraft>,
Expand Down
4 changes: 4 additions & 0 deletions configurator/src/models/config/boards/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ impl BoardsDraft {
max_count: boards.max_count.to_string(),
auto_create: boards.auto_create,
show_board_badge: boards.show_board_badge,
pan_enabled: boards.pan_enabled,
show_pan_badge: boards.show_pan_badge,
persist_customizations: boards.persist_customizations,
default_board: boards.default_board.clone(),
items: boards
Expand All @@ -133,6 +135,8 @@ impl BoardsDraft {
});
config.auto_create = self.auto_create;
config.show_board_badge = self.show_board_badge;
config.pan_enabled = self.pan_enabled;
config.show_pan_badge = self.show_pan_badge;
config.persist_customizations = self.persist_customizations;

let default_board = self.default_board.trim();
Expand Down
Loading
Loading