From 124c9601e4689f2fcfff45f5615ac3612c7169f8 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:53:56 +0200 Subject: [PATCH 01/28] fix(configurator): freeze draft actions during document operations --- configurator/src/app/component/view.rs | 7 ++- configurator/src/app/document_workflow.rs | 3 + configurator/src/app/update/config/tests.rs | 62 +++++++++++++++++++++ configurator/src/app/update/mod.rs | 17 ++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/configurator/src/app/component/view.rs b/configurator/src/app/component/view.rs index 3f6f89244..31dd4426e 100644 --- a/configurator/src/app/component/view.rs +++ b/configurator/src/app/component/view.rs @@ -23,7 +23,12 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { if widgets.save_button.is_sensitive() != save_enabled { widgets.save_button.set_sensitive(save_enabled); } - let busy = app.document.is_loading() || app.document.is_saving(); + let busy = !app.document.allows_editing(); + widgets.stack.set_sensitive(!busy); + widgets.defaults_button.set_sensitive(!busy); + widgets.defaults_confirm_button.set_sensitive(!busy); + widgets.defaults_cancel_button.set_sensitive(!busy); + widgets.migration_revealer.set_sensitive(!busy); if widgets.reload_button.is_sensitive() == busy { widgets.reload_button.set_sensitive(!busy); } diff --git a/configurator/src/app/document_workflow.rs b/configurator/src/app/document_workflow.rs index aec490ff6..4cb5f95dc 100644 --- a/configurator/src/app/document_workflow.rs +++ b/configurator/src/app/document_workflow.rs @@ -34,6 +34,9 @@ impl DocumentWorkflow { pub(crate) fn is_saving(&self) -> bool { self.phase == DocumentPhase::Saving } + pub(crate) fn allows_editing(&self) -> bool { + self.phase == DocumentPhase::Idle + } pub(crate) fn begin_reload(&mut self) -> bool { if self.phase != DocumentPhase::Idle { return false; diff --git a/configurator/src/app/update/config/tests.rs b/configurator/src/app/update/config/tests.rs index b0f20c591..fc904853a 100644 --- a/configurator/src/app/update/config/tests.rs +++ b/configurator/src/app/update/config/tests.rs @@ -1334,3 +1334,65 @@ fn applying_marks_the_draft_dirty_when_only_the_revision_changes() { load_config_file(&mut app, &path); assert!(app.pending_migration().is_none()); } + +#[test] +fn document_operations_freeze_queued_edits_until_completion() { + use crate::messages::{CommandMessage, Message}; + + for saving in [false, true] { + for success in [false, true] { + let (mut app, _) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("busy-edits", ""); + app.update_command(CommandMessage::ConfigLoaded(Ok((document, None)))); + let original = app.draft.clone(); + let effects = app.update_message(if saving { + Message::SaveRequested + } else { + Message::ReloadRequested + }); + assert_eq!(effects.len(), 1); + assert!(!app.document.allows_editing()); + for message in [ + Message::ToggleChanged(ToggleField::CaptureEnabled, !original.capture_enabled), + Message::BoardsAddItem, + Message::QuickColorAdded, + Message::PresetSlotCountChanged(2), + Message::ResetToDefaultsConfirmed, + Message::MigrationApplyRequested, + Message::ShortcutTextEditChanged("F9".into()), + Message::ColorPickerHexChanged(ColorPickerId::DrawingColor, "invalid".into()), + ] { + assert!(app.update_message(message).is_empty()); + assert_eq!(app.draft, original); + assert_eq!(app.invalid_color_hex_count(), 0); + } + let effect = effects.into_iter().next().unwrap(); + let completion = match effect { + Effect::SaveConfig { document, .. } => CommandMessage::ConfigSaved(if success { + Ok((None, document)) + } else { + Err((Some(document), "write failed".into())) + }), + Effect::LoadConfig => CommandMessage::ConfigLoaded(if success { + Ok(( + Box::new(ConfigDocument::load_from_path(&path).unwrap()), + None, + )) + } else { + Err("load failed".into()) + }), + _ => panic!("unexpected effect"), + }; + app.update_command(completion); + assert!(app.document.allows_editing()); + assert!(app.document.loaded().is_some()); + assert_eq!(app.draft, original); + app.update_message(Message::ToggleChanged( + ToggleField::CaptureEnabled, + !original.capture_enabled, + )); + assert_ne!(app.draft, original); + std::fs::remove_file(path).unwrap(); + } + } +} diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index feaec0587..6a7acd88a 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -42,6 +42,23 @@ impl ConfiguratorApp { } pub(crate) fn update_message(&mut self, message: Message) -> Vec { + // Freeze all page actions, including queued widget signals and open + // shortcut/color editors, while a document replacement is in flight. + // Effect completions use update_command and must always be processed. + if !self.document.allows_editing() + && !matches!( + message, + Message::SearchChanged(_) + | Message::SearchCleared + | Message::SearchFocusRequested + | Message::StartupInteractionObserved + | Message::TabSelected(_) + | Message::UiTabSelected(_) + | Message::KeybindingsTabSelected(_) + ) + { + return Vec::new(); + } match message { Message::ReloadRequested => self.handle_reload_requested(), Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), From 0b9bca6775fd4bf658c67f31f9222d0a6435d20d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:56:57 +0200 Subject: [PATCH 02/28] fix(configurator): protect unsaved drafts on reload and close --- configurator/README.md | 4 +- configurator/src/app/component.rs | 14 ++- configurator/src/app/component/effects.rs | 8 +- configurator/src/app/component/shell.rs | 33 +++++++ configurator/src/app/component/view.rs | 24 ++++- configurator/src/app/document_workflow.rs | 8 ++ configurator/src/app/effects.rs | 1 + configurator/src/app/state.rs | 6 ++ configurator/src/app/update/config.rs | 1 + configurator/src/app/update/config/leave.rs | 68 +++++++++++++ configurator/src/app/update/config/load.rs | 4 + configurator/src/app/update/config/save.rs | 10 ++ configurator/src/app/update/config/tests.rs | 101 ++++++++++++++++++++ configurator/src/app/update/mod.rs | 25 +++++ configurator/src/messages.rs | 4 + 15 files changed, 300 insertions(+), 11 deletions(-) create mode 100644 configurator/src/app/update/config/leave.rs diff --git a/configurator/README.md b/configurator/README.md index f7b14578d..3c9fd7278 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -65,7 +65,7 @@ if its UI task is no longer observed. ### Handy actions -- **Reload** – re-read `config.toml` from disk and refresh the guarded source revision. A transient load error leaves the last good document and current draft in place. +- **Reload** – asks you to Save, Discard, or Cancel if there are unsaved changes, including unfinished color or shortcut input. Closing the window uses the same choice. Save must succeed before the requested reload or close continues. Re-read `config.toml` from disk and refresh the guarded source revision. A transient load error leaves the last good document and current draft in place. - **Configuration update available** – shown when the file's `config_revision` predates this build's keybinding defaults. The banner lists every proposed shortcut change as before → after; **Apply Update** edits the draft only, and **Dismiss** hides the offer for this run. Nothing reaches disk until you Save, and saving an unrelated setting without applying leaves both the old bindings and the old revision on disk. - **Defaults** – drop in the built-in defaults without saving. Pressing it asks first: **Confirm Defaults** replaces the draft and **Cancel** withdraws the question, and editing anything withdraws it too. Pressing **Defaults** again changes nothing. - **Save** – validate inputs (including numeric ranges and color arrays), merge known changes into the source TOML, and write it atomically. An existing file is backed up with a timestamp. Save is refused if the file was created, deleted, retargeted through a symlink, or changed byte-for-byte after loading; reload before retrying. If a readable file cannot be parsed, the configurator offers a warning-marked defaults-based repair draft and backs up the unreadable source before saving it. Unknown settings are retained only when the TOML structure is parseable and safely separable; malformed content remains in the backup. @@ -78,7 +78,7 @@ if its UI task is no longer observed. - **Default color** – toggle between named colors and custom RGB triples. - **Keybindings** – a bulk shortcut manager over the same per-action chips, recorder, and conflict flow. Filter by All / Changed / Conflicts / Unbound / Device / Sequences, sort by category, name, or changed status, and reset visible or all keybindings with confirmation (draft-only until Save). Review Conflicts walks each collision without picking a winner. `--open keybindings/
?search=...` still opens that category and now selects the matching action. Press-to-bind recording covers keys, auxiliary mouse buttons, and stylus barrel buttons, plus **Record Sequence** for two- or three-chord keyboard sequences (`Ctrl+K then Ctrl+C`). Per-row reset and a raw comma-separated text editor remain available (`F5, Ctrl+K > Ctrl+C`). Super/Meta chords record when the desktop delivers them. Legacy `[tablet.stylus_button]` assignments can be moved into the keybinding list with an explicit confirmation. Source badges mark Default, Authored, Legacy Tablet, and Unavailable shortcuts. - **Session** – persistence settings plus named-session catalog management. Rename display labels, reveal files, and forget metadata without touching files. Clear Tool State preserves boards/history while removing persisted tool defaults. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. -- Live dirty-state indicator plus status banner for success/error details. +- Live dirty-state indicator plus status banner for success/error details. Editing is temporarily disabled during loading and saving; failed operations restore editing and retain the draft. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. ## Building Releases diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index fd5f5d60c..307e7b46e 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -64,6 +64,10 @@ pub(crate) fn run(startup: StartupRequest) { pub(crate) struct AppWidgets { window_title: adw::WindowTitle, status_label: gtk::Label, + leave_actions: gtk::Box, + leave_cancel: gtk::Button, + root: adw::ApplicationWindow, + leave_previous_focus: Option, status_revealer: gtk::Revealer, migration_revealer: gtk::Revealer, migration_label: gtk::Label, @@ -108,16 +112,16 @@ impl Component for ConfiguratorApp { ) -> ComponentParts { let (model, effects) = ConfiguratorApp::new_app_with_startup(startup); for effect in effects { - spawn_effect(effect, &sender); + spawn_effect(effect, &sender, &root); } let widgets = shell::build(&root, &sender); ComponentParts { model, widgets } } - fn update(&mut self, message: Message, sender: ComponentSender, _root: &Self::Root) { + fn update(&mut self, message: Message, sender: ComponentSender, root: &Self::Root) { for effect in self.update_message(message) { - spawn_effect(effect, &sender); + spawn_effect(effect, &sender, root); } } @@ -125,10 +129,10 @@ impl Component for ConfiguratorApp { &mut self, message: Self::CommandOutput, sender: ComponentSender, - _root: &Self::Root, + root: &Self::Root, ) { for effect in self.update_command(message) { - spawn_effect(effect, &sender); + spawn_effect(effect, &sender, root); } } diff --git a/configurator/src/app/component/effects.rs b/configurator/src/app/component/effects.rs index 6786204ae..3ab97af54 100644 --- a/configurator/src/app/component/effects.rs +++ b/configurator/src/app/component/effects.rs @@ -8,8 +8,14 @@ use super::super::{daemon_setup, io, session_catalog}; /// Runs one effect as a Relm4 command; its result re-enters the component /// as an ordinary message through `update_cmd`. -pub(super) fn spawn_effect(effect: Effect, sender: &ComponentSender) { +pub(super) fn spawn_effect( + effect: Effect, + sender: &ComponentSender, + root: &relm4::adw::ApplicationWindow, +) { + use relm4::gtk::prelude::*; match effect { + Effect::CloseWindow => root.destroy(), Effect::LoadConfig => sender.oneshot_command(async { CommandMessage::ConfigLoaded(io::load_config_from_disk().await) }), diff --git a/configurator/src/app/component/shell.rs b/configurator/src/app/component/shell.rs index 0959521be..e6888d0f7 100644 --- a/configurator/src/app/component/shell.rs +++ b/configurator/src/app/component/shell.rs @@ -14,6 +14,13 @@ pub(super) fn build( root: &adw::ApplicationWindow, sender: &ComponentSender, ) -> AppWidgets { + { + let sender = sender.clone(); + root.connect_close_request(move |_| { + sender.input(Message::CloseRequested); + gtk::glib::Propagation::Stop + }); + } // ---- Sidebar ---------------------------------------------------- let search_entry = gtk::SearchEntry::builder() .placeholder_text("Search settings") @@ -226,8 +233,30 @@ pub(super) fn build( )); } + let leave_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + leave_actions.set_visible(false); + leave_actions.set_margin_start(12); + leave_actions.set_margin_bottom(6); + let leave_cancel = gtk::Button::with_label("Cancel"); + for (button, message) in [ + ( + gtk::Button::with_label("Save changes"), + Message::LeaveSaveRequested, + ), + ( + gtk::Button::with_label("Discard changes"), + Message::LeaveDiscardRequested, + ), + (leave_cancel.clone(), Message::LeaveCanceled), + ] { + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); + leave_actions.append(&button); + } + let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); content_box.append(&status_revealer); + content_box.append(&leave_actions); content_box.append(&migration_revealer); content_box.append(&stack); @@ -291,6 +320,10 @@ pub(super) fn build( } AppWidgets { + leave_actions, + leave_cancel, + root: root.clone(), + leave_previous_focus: None, window_title, status_label, status_revealer, diff --git a/configurator/src/app/component/view.rs b/configurator/src/app/component/view.rs index 31dd4426e..cec255698 100644 --- a/configurator/src/app/component/view.rs +++ b/configurator/src/app/component/view.rs @@ -8,7 +8,11 @@ use super::AppWidgets; pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { // Header chrome. - let subtitle = if app.is_dirty { "Unsaved changes" } else { "" }; + let subtitle = if app.has_unsaved_work() { + "Unsaved changes" + } else { + "" + }; if widgets.window_title.subtitle() != subtitle { widgets.window_title.set_subtitle(subtitle); } @@ -18,8 +22,7 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { let save_enabled = app.is_dirty && !app.document.is_saving() && !app.document.is_loading() - && app.invalid_color_hex_count() == 0 - && app.shortcuts.conflict().is_none(); + && !app.has_unresolved_editor(); if widgets.save_button.is_sensitive() != save_enabled { widgets.save_button.set_sensitive(save_enabled); } @@ -55,6 +58,21 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { widgets.defaults_button.grab_focus(); } + let leave_pending = app.pending_leave().is_some(); + let leave_was_pending = widgets.leave_actions.get_visible(); + if leave_pending && !leave_was_pending { + widgets.leave_previous_focus = gtk::prelude::GtkWindowExt::focus(&widgets.root); + } + set_visible(&widgets.leave_actions, leave_pending); + if leave_pending && !leave_was_pending { + widgets.leave_cancel.grab_focus(); + } else if !leave_pending + && leave_was_pending + && let Some(previous) = widgets.leave_previous_focus.take() + { + previous.grab_focus(); + } + // Status strip. let (status_text, status_class) = match &app.status { StatusMessage::Idle => ("", None), diff --git a/configurator/src/app/document_workflow.rs b/configurator/src/app/document_workflow.rs index 4cb5f95dc..cc38c8e7c 100644 --- a/configurator/src/app/document_workflow.rs +++ b/configurator/src/app/document_workflow.rs @@ -9,12 +9,19 @@ enum DocumentPhase { Saving, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LeaveAction { + Reload, + Close, +} + #[derive(Debug)] pub(crate) struct DocumentWorkflow { loaded: Option, phase: DocumentPhase, pub(crate) pending_validation: ConfigValidationReport, pub(crate) last_backup_path: Option, + pub(crate) after_save: Option, } impl DocumentWorkflow { pub(crate) fn loading() -> Self { @@ -23,6 +30,7 @@ impl DocumentWorkflow { phase: DocumentPhase::Loading, pending_validation: Default::default(), last_backup_path: None, + after_save: None, } } pub(crate) fn loaded(&self) -> Option<&ConfigDocument> { diff --git a/configurator/src/app/effects.rs b/configurator/src/app/effects.rs index f76c68e4c..aec06b8ea 100644 --- a/configurator/src/app/effects.rs +++ b/configurator/src/app/effects.rs @@ -16,6 +16,7 @@ use crate::models::DaemonAction; /// the job needs, so running it reads nothing back out of the model. #[derive(Debug)] pub(crate) enum Effect { + CloseWindow, LoadConfig, /// The document leaves the model with the write and comes back with its /// result, so exactly one owner holds it at any moment. diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 454744631..fb513c330 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -54,6 +54,7 @@ pub(crate) struct ConfiguratorApp { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConfirmationPrompt { + LeaveDraft, DefaultsReset, SessionClear, ShortcutResetVisible, @@ -62,6 +63,7 @@ pub(crate) enum ConfirmationPrompt { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PendingConfirmation { + LeaveDraft(super::document_workflow::LeaveAction), DefaultsReset, SessionClear(String), ShortcutResetVisible(Vec), @@ -71,6 +73,7 @@ pub(crate) enum PendingConfirmation { impl PendingConfirmation { pub(crate) fn prompt(&self) -> ConfirmationPrompt { match self { + PendingConfirmation::LeaveDraft(_) => ConfirmationPrompt::LeaveDraft, PendingConfirmation::DefaultsReset => ConfirmationPrompt::DefaultsReset, PendingConfirmation::SessionClear(_) => ConfirmationPrompt::SessionClear, PendingConfirmation::ShortcutResetVisible(_) => { @@ -84,6 +87,9 @@ impl PendingConfirmation { impl ConfirmationPrompt { pub(crate) fn message(self) -> &'static str { match self { + ConfirmationPrompt::LeaveDraft => { + "You have unsaved changes. Save them, discard them, or cancel to keep editing." + } ConfirmationPrompt::DefaultsReset => { "Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue." } diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index a5056a534..7ca0931cf 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -1,4 +1,5 @@ mod defaults; +mod leave; mod load; mod migration; mod save; diff --git a/configurator/src/app/update/config/leave.rs b/configurator/src/app/update/config/leave.rs new file mode 100644 index 000000000..716426e06 --- /dev/null +++ b/configurator/src/app/update/config/leave.rs @@ -0,0 +1,68 @@ +use crate::app::document_workflow::LeaveAction; +use crate::app::effects::Effect; +use crate::app::state::{ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, StatusMessage}; + +impl ConfiguratorApp { + pub(crate) fn has_unresolved_editor(&self) -> bool { + self.invalid_color_hex_count() > 0 + || self.shortcuts.editor().is_some() + || self.shortcuts.recorder().is_some() + || self.shortcuts.conflict().is_some() + } + + pub(crate) fn has_unsaved_work(&self) -> bool { + self.is_dirty || self.has_unresolved_editor() + } + + pub(crate) fn pending_leave(&self) -> Option { + match self.pending_confirmation { + Some(PendingConfirmation::LeaveDraft(action)) => Some(action), + _ => None, + } + } + + pub(in crate::app::update) fn request_leave(&mut self, action: LeaveAction) -> Vec { + if !self.document.allows_editing() { + return Vec::new(); + } + if self.has_unsaved_work() { + self.pending_confirmation = Some(PendingConfirmation::LeaveDraft(action)); + self.status = StatusMessage::confirmation(ConfirmationPrompt::LeaveDraft); + Vec::new() + } else { + self.continue_leave(action) + } + } + + pub(in crate::app::update) fn continue_leave(&mut self, action: LeaveAction) -> Vec { + self.pending_confirmation = None; + match action { + LeaveAction::Reload => self.begin_config_reload(), + LeaveAction::Close => vec![Effect::CloseWindow], + } + } + + pub(in crate::app::update) fn save_before_leave(&mut self) -> Vec { + let Some(action) = self.pending_leave() else { + return Vec::new(); + }; + self.pending_confirmation = None; + let effects = self.handle_save_requested(); + if self.document.is_saving() { + self.document.after_save = Some(action); + } + effects + } + + pub(in crate::app::update) fn discard_before_leave(&mut self) -> Vec { + self.pending_leave() + .map_or_else(Vec::new, |action| self.continue_leave(action)) + } + + pub(in crate::app::update) fn cancel_leave(&mut self) -> Vec { + if self.pending_leave().is_some() { + self.handle_active_confirmation_canceled(); + } + Vec::new() + } +} diff --git a/configurator/src/app/update/config/load.rs b/configurator/src/app/update/config/load.rs index 84a10bc71..c2a12a6db 100644 --- a/configurator/src/app/update/config/load.rs +++ b/configurator/src/app/update/config/load.rs @@ -51,6 +51,10 @@ impl ConfiguratorApp { } pub(in crate::app::update) fn handle_reload_requested(&mut self) -> Vec { + self.request_leave(crate::app::document_workflow::LeaveAction::Reload) + } + + pub(in crate::app::update) fn begin_config_reload(&mut self) -> Vec { if self.document.begin_reload() { self.clear_defaults_confirmation(); self.status = StatusMessage::info("Reloading configuration..."); diff --git a/configurator/src/app/update/config/save.rs b/configurator/src/app/update/config/save.rs index c828c46e3..0b194f7fe 100644 --- a/configurator/src/app/update/config/save.rs +++ b/configurator/src/app/update/config/save.rs @@ -29,6 +29,12 @@ impl ConfiguratorApp { return Vec::new(); } + if self.shortcuts.editor().is_some() || self.shortcuts.recorder().is_some() { + self.status = + StatusMessage::error("Apply or cancel the unfinished shortcut edit before saving."); + return Vec::new(); + } + if self.shortcuts.conflict().is_some() { self.status = StatusMessage::error("Resolve the shortcut conflict before saving."); return Vec::new(); @@ -102,6 +108,7 @@ impl ConfiguratorApp { ) -> Vec { // Either outcome answers this write; a failed one wrote nothing, so // there is no resolution to report for it. + let after_save = self.document.after_save.take(); let validation = std::mem::take(&mut self.document.pending_validation); match result { Ok((backup, saved_document)) => { @@ -128,6 +135,9 @@ impl ConfiguratorApp { } self.status = status; self.document.finish_save(Some(*saved_document)); + if let Some(action) = after_save { + return self.continue_leave(action); + } } Err((document, err)) => { // The write borrowed the model's only document; a failure hands diff --git a/configurator/src/app/update/config/tests.rs b/configurator/src/app/update/config/tests.rs index fc904853a..7a9c5a11f 100644 --- a/configurator/src/app/update/config/tests.rs +++ b/configurator/src/app/update/config/tests.rs @@ -1396,3 +1396,104 @@ fn document_operations_freeze_queued_edits_until_completion() { } } } + +#[test] +fn leave_dirty_draft_requires_a_current_decision_and_successful_save() { + use crate::messages::{CommandMessage, Message}; + + for close in [false, true] { + for save_succeeds in [false, true] { + let (mut app, _) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("leave-draft", ""); + app.update_command(CommandMessage::ConfigLoaded(Ok((document, None)))); + let request = if close { + Message::CloseRequested + } else { + Message::ReloadRequested + }; + let clean = app.update_message(request.clone()); + assert!(matches!( + clean.as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + if !close { + app.update_command(CommandMessage::ConfigLoaded(Err("failed reload".into()))); + } + app.update_message(Message::ToggleChanged( + ToggleField::CaptureEnabled, + !app.draft.capture_enabled, + )); + assert!(app.update_message(request.clone()).is_empty()); + assert!(app.pending_leave().is_some()); + app.update_message(Message::LeaveCanceled); + assert!(app.pending_leave().is_none()); + assert!(app.is_dirty); + + app.update_message(request.clone()); + app.update_message(Message::BoardsAddItem); + assert!(app.pending_leave().is_none()); + assert!( + app.update_message(Message::LeaveDiscardRequested) + .is_empty() + ); + app.update_message(request.clone()); + let effects = app.update_message(Message::LeaveSaveRequested); + let [Effect::SaveConfig { document, .. }]: [Effect; 1] = effects.try_into().unwrap() + else { + panic!("expected a save before leaving"); + }; + let effects = app.update_command(CommandMessage::ConfigSaved(if save_succeeds { + Ok((None, document)) + } else { + Err((Some(document), "failed save".into())) + })); + if save_succeeds { + assert!(matches!( + effects.as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + } else { + assert!(effects.is_empty()); + assert!(app.is_dirty); + assert!(app.document.after_save.is_none()); + app.update_message(request); + assert!(matches!( + app.update_message(Message::LeaveDiscardRequested) + .as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + } + std::fs::remove_file(path).unwrap(); + } + } +} + +#[test] +fn leave_protects_raw_color_and_unapplied_shortcut_input() { + use crate::messages::{CommandMessage, Message}; + + for shortcut in [false, true] { + let (mut app, _) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("leave-editor", ""); + app.update_command(CommandMessage::ConfigLoaded(Ok((document, None)))); + if shortcut { + app.update_message(Message::ShortcutTextEditStarted( + KeybindingField::ToggleToolbar, + )); + app.update_message(Message::ShortcutTextEditChanged("unfinished".into())); + } else { + app.update_message(Message::ColorPickerHexChanged( + ColorPickerId::DrawingColor, + "#12".into(), + )); + } + assert!(app.has_unsaved_work()); + assert!(app.update_message(Message::CloseRequested).is_empty()); + assert!(app.pending_leave().is_some()); + assert!(app.update_message(Message::LeaveSaveRequested).is_empty()); + assert!(app.has_unresolved_editor()); + assert!(!app.document.is_saving()); + assert!(matches!(app.status, StatusMessage::Error(_))); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 6a7acd88a..d872382d9 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -59,8 +59,33 @@ impl ConfiguratorApp { { return Vec::new(); } + // A queued answer must never apply to an edit made after the question. + // Navigation preserves it; other page actions conservatively withdraw it. + if !matches!( + message, + Message::ReloadRequested + | Message::CloseRequested + | Message::LeaveSaveRequested + | Message::LeaveDiscardRequested + | Message::LeaveCanceled + | Message::SearchChanged(_) + | Message::SearchCleared + | Message::SearchFocusRequested + | Message::StartupInteractionObserved + | Message::TabSelected(_) + | Message::UiTabSelected(_) + | Message::KeybindingsTabSelected(_) + ) { + self.cancel_leave(); + } match message { Message::ReloadRequested => self.handle_reload_requested(), + Message::CloseRequested => { + self.request_leave(super::document_workflow::LeaveAction::Close) + } + Message::LeaveSaveRequested => self.save_before_leave(), + Message::LeaveDiscardRequested => self.discard_before_leave(), + Message::LeaveCanceled => self.cancel_leave(), Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), Message::ResetToDefaultsConfirmed => self.handle_reset_to_defaults_confirmed(), Message::ResetToDefaultsCanceled => self.handle_reset_to_defaults_canceled(), diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 84cc6de09..e2e99f3e1 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -54,6 +54,10 @@ pub enum CommandMessage { #[derive(Debug, Clone)] pub enum Message { ReloadRequested, + CloseRequested, + LeaveSaveRequested, + LeaveDiscardRequested, + LeaveCanceled, /// Asks for the reset and arms the confirmation; it never replaces the /// draft on its own. ResetToDefaultsRequested, From ae2a48697076636b81ce2c6405c49d2a132003c6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:01:24 +0200 Subject: [PATCH 03/28] fix(ui): fit command palette rows and navigation to viewport --- src/input/state/core/command_palette/input.rs | 26 +++++-- .../state/core/command_palette/layout.rs | 70 ++++++++++++++++--- src/input/state/core/command_palette/mod.rs | 26 +++++++ src/input/state/core/utility/interaction.rs | 3 + src/ui/command_palette.rs | 26 ++++--- src/ui/command_palette/command_palette_row.rs | 37 ++++++---- src/ui/command_palette/tests/engine.rs | 1 + 7 files changed, 151 insertions(+), 38 deletions(-) diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index f2631d4dd..32d4db57d 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -1,7 +1,6 @@ use super::super::base::{ InputState, KeybindingEditOperation, KeybindingEditRequest, Toast, ToastPriority, }; -use super::layout; use super::search::{CommandPaletteListRow, command_palette_display_index}; use super::{ CommandPaletteCursorHint, @@ -213,7 +212,7 @@ impl InputState { self.command_palette.scroll = self .command_palette_rows() .len() - .saturating_sub(layout::COMMAND_PALETTE_MAX_VISIBLE); + .saturating_sub(self.command_palette_row_capacity()); self.needs_redraw = true; } true @@ -318,6 +317,20 @@ impl InputState { self.command_palette.repeat.clear(); } + pub(crate) fn reconcile_command_palette_scroll(&mut self) { + let rows = self.command_palette_rows(); + let capacity = self.command_palette_row_capacity(); + let selected = command_palette_display_index(&rows, self.command_palette.selected); + self.command_palette.scroll = self + .command_palette + .scroll + .min(rows.len().saturating_sub(capacity)) + .min(selected); + if selected >= self.command_palette.scroll + capacity { + self.command_palette.scroll = selected + 1 - capacity; + } + } + fn move_command_palette_selection(&mut self, key: Key) -> bool { match key { Key::Up => { @@ -353,14 +366,15 @@ impl InputState { let display_index = command_palette_display_index(&rows, self.command_palette.selected); if display_index - >= self.command_palette.scroll + layout::COMMAND_PALETTE_MAX_VISIBLE + >= self.command_palette.scroll + self.command_palette_row_capacity() { self.command_palette.scroll = - display_index - layout::COMMAND_PALETTE_MAX_VISIBLE + 1; + display_index - self.command_palette_row_capacity() + 1; } } _ => return false, } + self.reconcile_command_palette_scroll(); self.needs_redraw = true; true } @@ -374,7 +388,7 @@ impl InputState { let rows = self.command_palette_rows(); let max_scroll = rows .len() - .saturating_sub(layout::COMMAND_PALETTE_MAX_VISIBLE); + .saturating_sub(self.command_palette_row_capacity()); if direction > 0 { if self.command_palette.scroll >= max_scroll { return; @@ -388,7 +402,7 @@ impl InputState { } let window_start = self.command_palette.scroll; - let window_end = window_start + layout::COMMAND_PALETTE_MAX_VISIBLE; + let window_end = window_start + self.command_palette_row_capacity(); let selected_display = command_palette_display_index(&rows, self.command_palette.selected); if selected_display < window_start { if let Some(command_index) = rows[window_start..window_end.min(rows.len())] diff --git a/src/input/state/core/command_palette/layout.rs b/src/input/state/core/command_palette/layout.rs index c980e0f99..356090ebf 100644 --- a/src/input/state/core/command_palette/layout.rs +++ b/src/input/state/core/command_palette/layout.rs @@ -147,8 +147,14 @@ impl CommandPaletteGeometry { } } -pub(crate) fn command_palette_visible_count(total_items: usize) -> usize { - total_items.min(COMMAND_PALETTE_MAX_VISIBLE) +pub(crate) fn command_palette_visible_count(total_items: usize, screen_height: u32) -> usize { + let available = (screen_height as f64 + - 2.0 * COMMAND_PALETTE_HORIZONTAL_MARGIN + - command_palette_height(0)) + .max(0.0); + total_items + .min(COMMAND_PALETTE_MAX_VISIBLE) + .min((available / COMMAND_PALETTE_ITEM_HEIGHT) as usize) } pub(crate) fn command_palette_height(visible_count: usize) -> f64 { @@ -159,6 +165,16 @@ pub(crate) fn command_palette_height(visible_count: usize) -> f64 { } impl InputState { + pub(crate) fn command_palette_row_capacity(&self) -> usize { + let height = self.view.screen_height(); + // Before the first output configure, retain the normal navigation window. + if height == 0 { + COMMAND_PALETTE_MAX_VISIBLE + } else { + command_palette_visible_count(usize::MAX, height).max(1) + } + } + pub fn command_palette_width(&self, screen_width: u32) -> f64 { let commands = self.filtered_commands(); self.command_palette_width_for_commands(screen_width, commands) @@ -217,7 +233,7 @@ impl InputState { let requested_width = required_inner_width + COMMAND_PALETTE_PADDING * 2.0; let max_available = - (screen_width as f64 - COMMAND_PALETTE_HORIZONTAL_MARGIN * 2.0).max(240.0); + (screen_width as f64 - COMMAND_PALETTE_HORIZONTAL_MARGIN * 2.0).max(0.0); requested_width.clamp( COMMAND_PALETTE_MIN_WIDTH.min(max_available), COMMAND_PALETTE_MAX_WIDTH.min(max_available), @@ -270,8 +286,16 @@ fn command_palette_geometry_for_width( total_items: usize, ) -> CommandPaletteGeometry { let x = (screen_width as f64 - width) / 2.0; - let y = screen_height as f64 * COMMAND_PALETTE_TOP_RATIO; - let visible_count = command_palette_visible_count(total_items); + let visible_count = command_palette_visible_count(total_items, screen_height); + let content_rows = if total_items == 0 { + command_palette_visible_count(2, screen_height) + } else { + visible_count + }; + let height = command_palette_height(content_rows).min(screen_height as f64); + let margin = COMMAND_PALETTE_HORIZONTAL_MARGIN.min((screen_height as f64 - height) / 2.0); + let y = (screen_height as f64 * COMMAND_PALETTE_TOP_RATIO) + .clamp(margin, (screen_height as f64 - height - margin).max(margin)); let input_top = COMMAND_PALETTE_PADDING; let input_bottom = input_top + COMMAND_PALETTE_INPUT_HEIGHT; @@ -281,9 +305,9 @@ fn command_palette_geometry_for_width( x, y, width, - height: command_palette_height(visible_count), + height, inner_x: COMMAND_PALETTE_PADDING, - inner_width: width - COMMAND_PALETTE_PADDING * 2.0, + inner_width: (width - COMMAND_PALETTE_PADDING * 2.0).max(0.0), input_top, input_bottom, items_top, @@ -312,12 +336,38 @@ mod tests { } } + #[test] + fn viewport_bounds_include_every_row_and_its_hit_target() { + for (width, height) in [(800, 480), (1024, 600), (1280, 720), (240, 320), (80, 80)] { + let geometry = command_palette_geometry_for_width( + (width as f64 - 24.0).max(0.0), + width, + height, + 100, + ); + assert!(geometry.x >= 0.0 && geometry.y >= 0.0); + assert!(geometry.x + geometry.width <= width as f64); + assert!(geometry.y + geometry.height <= height as f64); + for row in 0..geometry.visible_count { + let bottom = geometry.items_top + (row + 1) as f64 * COMMAND_PALETTE_ITEM_HEIGHT; + assert!(bottom <= geometry.height - COMMAND_PALETTE_PADDING_BOTTOM); + assert_eq!( + geometry.visible_item_at(geometry.inner_x + 1.0, bottom - 1.0), + Some(row) + ); + } + } + assert_eq!(command_palette_visible_count(100, 480), 8); + assert_eq!(command_palette_visible_count(100, 320), 4); + assert_eq!(command_palette_visible_count(100, 80), 0); + } + #[test] fn visible_count_caps_at_max_visible() { - assert_eq!(command_palette_visible_count(0), 0); - assert_eq!(command_palette_visible_count(3), 3); + assert_eq!(command_palette_visible_count(0, 1080), 0); + assert_eq!(command_palette_visible_count(3, 1080), 3); assert_eq!( - command_palette_visible_count(99), + command_palette_visible_count(99, 1080), COMMAND_PALETTE_MAX_VISIBLE ); } diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 06ebe5cc7..12f5de0e5 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -62,6 +62,32 @@ mod tests { (x, y) } + #[test] + fn navigation_and_resize_keep_selection_in_the_viewport() { + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = make_state(); + state.toggle_command_palette(); + for (width, height) in [(800, 480), (1024, 600), (1280, 720), (240, 320), (240, 180)] { + state.update_screen_dimensions(width, height); + for _ in 0..24 { + state.handle_command_palette_key_with_resources( + resources, + crate::input::events::Key::Down, + ); + let rows = state.command_palette_rows(); + let geometry = state.command_palette_geometry_for_rows(width, height, &rows); + let display = command_palette_display_index(&rows, state.command_palette.selected); + assert!(display >= state.command_palette.scroll); + assert!(display < state.command_palette.scroll + geometry.visible_count); + } + } + } + fn make_state() -> InputState { let keybindings = KeybindingsConfig::default(); let _action_map = keybindings diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 1c0ae9cdf..b0079a1b2 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -132,6 +132,9 @@ impl InputState { /// screen dimensions from the display server. pub fn update_screen_dimensions(&mut self, width: u32, height: u32) { self.view.set_screen_dimensions(width, height); + if self.command_palette.open { + self.reconcile_command_palette_scroll(); + } // A surface resize is painted with full damage by the backend. Make // that newly painted geometry the picker's damage baseline, or the // next narrowing query clears the panel from before the resize instead diff --git a/src/ui/command_palette.rs b/src/ui/command_palette.rs index 1cb826bb2..97c89cab3 100644 --- a/src/ui/command_palette.rs +++ b/src/ui/command_palette.rs @@ -4,8 +4,8 @@ use crate::config::action_label; use crate::input::InputState; use crate::input::state::{ COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP, - COMMAND_PALETTE_MAX_VISIBLE, COMMAND_PALETTE_PADDING, COMMAND_PALETTE_QUERY_PLACEHOLDER, - COMMAND_PALETTE_TOP_RATIO, CommandPaletteListRow, + COMMAND_PALETTE_PADDING, COMMAND_PALETTE_QUERY_PLACEHOLDER, COMMAND_PALETTE_TOP_RATIO, + CommandPaletteListRow, }; use crate::ui_text::{UiTextEngine, UiTextStyle}; @@ -81,6 +81,7 @@ pub(crate) struct PaletteListView { rows: Vec, geometry: (f64, f64, f64, f64), scroll: usize, + visible_count: usize, selected: usize, bindings: std::collections::HashMap>, tooltip: Option<(String, i32, i32)>, @@ -116,6 +117,7 @@ impl CommandPaletteView { rows, geometry: (geometry.x, geometry.y, geometry.width, geometry.height), scroll: state.command_palette.scroll, + visible_count: geometry.visible_count, selected: state.command_palette.selected, bindings, tooltip, @@ -178,6 +180,9 @@ pub(crate) fn paint_command_palette( height, ); + let _ = ctx.save(); + ctx.rectangle(x, y, palette_width, height); + ctx.clip(); let inner_x = x + COMMAND_PALETTE_PADDING; let inner_width = palette_width - COMMAND_PALETTE_PADDING * 2.0; let mut cursor_y = y + COMMAND_PALETTE_PADDING; @@ -204,8 +209,12 @@ pub(crate) fn paint_command_palette( cursor_y, rows.len(), view.scroll, + view.visible_count, ); + draw_command_palette_escape_hint(engine, ctx, x, y, palette_width, height); + let _ = ctx.restore(); + if let Some((tooltip, pointer_x, pointer_y)) = view.tooltip.as_ref() { draw_command_palette_action_tooltip( engine, @@ -217,8 +226,6 @@ pub(crate) fn paint_command_palette( screen_height as f64, ); } - - draw_command_palette_escape_hint(engine, ctx, x, y, palette_width, height); } /// Bounds of every pixel the command palette may change this frame, excluding @@ -416,7 +423,7 @@ fn render_command_palette_rows( for (visible_idx, row) in rows .iter() .skip(scroll) - .take(COMMAND_PALETTE_MAX_VISIBLE) + .take(view.visible_count) .enumerate() { let item_y = start_y + (visible_idx as f64 * COMMAND_PALETTE_ITEM_HEIGHT); @@ -546,13 +553,14 @@ fn render_command_palette_scroll_indicator( start_y: f64, total_items: usize, scroll: usize, + visible_count: usize, ) { - if total_items <= COMMAND_PALETTE_MAX_VISIBLE { + if visible_count == 0 || total_items <= visible_count { return; } let scroll_track_x = x + palette_width - 8.0; - let scroll_track_h = (COMMAND_PALETTE_MAX_VISIBLE as f64) * COMMAND_PALETTE_ITEM_HEIGHT - 4.0; + let scroll_track_h = (visible_count as f64) * COMMAND_PALETTE_ITEM_HEIGHT - 4.0; let scroll_track_w = 4.0; constants::set_color(ctx, SCROLL_TRACK); @@ -566,9 +574,9 @@ fn render_command_palette_scroll_indicator( ); let _ = ctx.fill(); - let thumb_ratio = COMMAND_PALETTE_MAX_VISIBLE as f64 / total_items as f64; + let thumb_ratio = visible_count as f64 / total_items as f64; let thumb_h = (scroll_track_h * thumb_ratio).max(20.0); - let scroll_range = total_items - COMMAND_PALETTE_MAX_VISIBLE; + let scroll_range = total_items - visible_count; let scroll_progress = if scroll_range > 0 { scroll as f64 / scroll_range as f64 } else { diff --git a/src/ui/command_palette/command_palette_row.rs b/src/ui/command_palette/command_palette_row.rs index 1dcfa5553..8f980e94f 100644 --- a/src/ui/command_palette/command_palette_row.rs +++ b/src/ui/command_palette/command_palette_row.rs @@ -87,6 +87,27 @@ pub(super) fn render_command_row( } let label_x = inner_x + 10.0 + COMMAND_PALETTE_ROW_ICON_SIZE + COMMAND_PALETTE_ROW_ICON_GAP; + let configurable = KeybindingsConfig::default() + .bindings_for_action(cmd.action) + .is_some(); + let actions_width = if configurable { + (COMMAND_PALETTE_ROW_ACTION_SIZE + COMMAND_PALETTE_ROW_ACTION_GAP) + * COMMAND_PALETTE_ROW_ACTION_COUNT as f64 + } else { + 0.0 + }; + let content_right = inner_x + inner_width - 8.0 - actions_width; + + let label = ellipsize_to_width( + engine, + ctx, + cmd.label, + COMMAND_PALETTE_FONT_FAMILY, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + styles.label.size, + (content_right - label_x).max(0.0), + ); // Accent backdrop behind the label characters the query matched, so the // user sees why a command surfaced. Drawn before the text so the glyphs // sit on top; fuzzy-only (subsequence) matches draw nothing. @@ -94,14 +115,14 @@ pub(super) fn render_command_row( engine, ctx, query, - cmd.label, + &label, label_x, label_y, styles.label.size, ); constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, text_alpha)); - render_command_row_label(engine, ctx, cmd.label, label_x, label_y, styles); + render_command_row_label(engine, ctx, &label, label_x, label_y, styles); let label_extents = text_extents_for_with_engine( engine, @@ -110,19 +131,9 @@ pub(super) fn render_command_row( cairo::FontSlant::Normal, cairo::FontWeight::Normal, styles.label.size, - cmd.label, + &label, ); let desc_x = label_x + label_extents.width() + 12.0; - let configurable = KeybindingsConfig::default() - .bindings_for_action(cmd.action) - .is_some(); - let actions_width = if configurable { - (COMMAND_PALETTE_ROW_ACTION_SIZE + COMMAND_PALETTE_ROW_ACTION_GAP) - * COMMAND_PALETTE_ROW_ACTION_COUNT as f64 - } else { - 0.0 - }; - let content_right = inner_x + inner_width - 8.0 - actions_width; let badge_left_edge = render_command_row_shortcut_badge( engine, diff --git a/src/ui/command_palette/tests/engine.rs b/src/ui/command_palette/tests/engine.rs index e0eeac596..a7a631730 100644 --- a/src/ui/command_palette/tests/engine.rs +++ b/src/ui/command_palette/tests/engine.rs @@ -126,6 +126,7 @@ fn prepared_palette_paints_without_application_state() { rows: Vec::new(), geometry: (100.0, 100.0, 520.0, 180.0), scroll: 0, + visible_count: 8, selected: 0, bindings: Default::default(), tooltip: None, From 6676205c4acca160f992c7ba536b95a263d97e8d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:04:29 +0200 Subject: [PATCH 04/28] ci: run canonical local validation for both feature configurations --- .github/workflows/ci.yml | 34 ++-------------------------------- CONTRIBUTING.md | 5 +++++ tools/lint-and-test.sh | 37 ++++++++++++++++++++++--------------- 3 files changed, 29 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba9bd7e8f..631f08277 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,43 +69,13 @@ jobs: workspaces: | . - - name: Check version metadata - run: bash tools/check-version-consistency.sh - - - name: Check package repository layout - run: bash tools/test-package-repo-layout.sh - - - name: Check release packaging contract - run: bash tools/test-release-packaging.sh - - name: Check dynamic gtk4-layer-shell linkage run: | cargo build --locked --bin wayscriber readelf -d target/debug/wayscriber | grep -Eq 'Shared library: \[libgtk4-layer-shell\.so' - - name: Check nixpkgs recipe dependencies - run: ./tools/check-nixpkgs-recipe.py - - - name: Check Rust source coverage - run: ./tools/check-rust-source-coverage.py - - - name: Check process spawn sites - run: ./tools/check-process-sites.py - - - name: Check config writers - run: ./tools/check-config-writers.py - - - name: cargo fmt - run: cargo fmt --all -- --check - - - name: cargo clippy (all features) - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - - name: cargo test (all features) - run: cargo test --workspace --all-features - - - name: cargo test (no default features) - run: cargo test --workspace --no-default-features + - name: Run canonical workspace validation + run: ./tools/lint-and-test.sh - name: Check static gtk4-layer-shell linkage env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6b17596f..03d613210 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,3 +212,8 @@ not as a reason for mechanical splitting. `wayscriber 0.9.x` and look identical. See [tools/README.md](tools/README.md) for build, install, packaging, version, and release helpers. + +Local checks and the Rust CI job both run `./tools/lint-and-test.sh`. It lints, +builds binaries, and tests the whole workspace with all features and with no +default features, alongside source and packaging checks. CI additionally checks +dynamic and static gtk4-layer-shell linkage and uploads its code-health report. diff --git a/tools/lint-and-test.sh b/tools/lint-and-test.sh index 21b92fe05..ebfb11921 100755 --- a/tools/lint-and-test.sh +++ b/tools/lint-and-test.sh @@ -1,24 +1,31 @@ #!/usr/bin/env bash -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "$REPO_ROOT" -bash tools/check-version-consistency.sh -bash tools/test-package-repo-layout.sh -bash tools/test-release-packaging.sh -./tools/check-nixpkgs-recipe.py -./tools/check-rust-source-coverage.py -./tools/check-process-sites.py -./tools/check-config-writers.py -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo build --workspace --all-features --bins -cargo test --workspace --all-features +run_check() { + printf '\nRunning:' + printf ' %s' "$@" + printf '\n' + "$@" +} + +run_check bash tools/check-version-consistency.sh +run_check bash tools/test-package-repo-layout.sh +run_check bash tools/test-release-packaging.sh +run_check ./tools/check-nixpkgs-recipe.py +run_check ./tools/check-rust-source-coverage.py +run_check ./tools/check-process-sites.py +run_check ./tools/check-config-writers.py +run_check cargo fmt --all -- --check +run_check cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +run_check cargo build --locked --workspace --all-features --bins +run_check cargo test --locked --workspace --all-features # Linted as strictly as the all-features build: code reachable only behind an # optional feature leaves its callers dead without it, and building alone does # not promote that to an error. -cargo clippy --workspace --all-targets --no-default-features -- -D warnings -cargo build --workspace --no-default-features --bins -cargo test --workspace --no-default-features +run_check cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings +run_check cargo build --locked --workspace --no-default-features --bins +run_check cargo test --locked --workspace --no-default-features From a96393d5dec98505fec131f055b8533d213d6c9c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:04:43 +0200 Subject: [PATCH 05/28] feat(toolbar): add keyboard and accessibility support to GTK sliders --- src/toolbar_gtk/view/sections/step_undo.rs | 32 ++- src/toolbar_gtk/view/top_bar/style_pill.rs | 40 ++- src/toolbar_gtk/widgets.rs | 170 +----------- src/toolbar_gtk/widgets/slider.rs | 297 +++++++++++++++++++++ src/toolbar_gtk/widgets/tests.rs | 3 + 5 files changed, 361 insertions(+), 181 deletions(-) create mode 100644 src/toolbar_gtk/widgets/slider.rs diff --git a/src/toolbar_gtk/view/sections/step_undo.rs b/src/toolbar_gtk/view/sections/step_undo.rs index 78c04c768..4c57da53d 100644 --- a/src/toolbar_gtk/view/sections/step_undo.rs +++ b/src/toolbar_gtk/view/sections/step_undo.rs @@ -164,16 +164,27 @@ fn custom_row(ctx: &mut SectionCtx, body: >k4::Box, is_undo: bool) { let spec = model::ToolbarSliderSpec::DELAY_SECONDS; let initial_secs = row_delay_secs(snapshot, is_undo); let slider_sender = ctx.feedback.clone(); - let slider = SliderRow::new(ctx.scale, spec, initial_secs, format_secs, move |value| { - send_event( - &slider_sender, - if is_undo { - ToolbarEvent::SetCustomUndoDelay(value) - } else { - ToolbarEvent::SetCustomRedoDelay(value) - }, - ); - }); + let slider = SliderRow::new( + ctx.scale, + if is_undo { + "Custom undo delay" + } else { + "Custom redo delay" + }, + spec, + initial_secs, + format_secs, + move |value| { + send_event( + &slider_sender, + if is_undo { + ToolbarEvent::SetCustomUndoDelay(value) + } else { + ToolbarEvent::SetCustomRedoDelay(value) + }, + ); + }, + ); slider .root .set_tooltip_text(Some(&row_delay_tooltip(is_undo, initial_secs))); @@ -241,6 +252,7 @@ fn global_delay_slider(ctx: &SectionCtx, is_undo: bool, delay_ms: u64) -> Slider let sender = ctx.feedback.clone(); let slider = SliderRow::new( ctx.scale, + if is_undo { "Undo delay" } else { "Redo delay" }, spec, delay_ms as f64 / 1000.0, format_secs, diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index ca4593600..ef9f8dae4 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -184,21 +184,35 @@ impl TopBar { _ => format_pt, }; let sender = self.feedback.clone(); - let slider = SliderRow::new(scale, slider_spec, value, format, move |value| { - let event = match control { - model::StylePillControl::ThicknessSlider => { - ToolbarEvent::SetThickness(value) - } - model::StylePillControl::OpacitySlider => { - ToolbarEvent::SetMarkerOpacity(value) - } + let slider = SliderRow::new( + scale, + match control { + model::StylePillControl::ThicknessSlider => "Stroke thickness", + model::StylePillControl::OpacitySlider => "Marker opacity", model::StylePillControl::SpotlightMagnificationSlider => { - ToolbarEvent::SetSpotlightMagnification(value) + "Spotlight magnification" } - _ => ToolbarEvent::SetFontSize(value), - }; - send_event(&sender, event); - }); + _ => "Font size", + }, + slider_spec, + value, + format, + move |value| { + let event = match control { + model::StylePillControl::ThicknessSlider => { + ToolbarEvent::SetThickness(value) + } + model::StylePillControl::OpacitySlider => { + ToolbarEvent::SetMarkerOpacity(value) + } + model::StylePillControl::SpotlightMagnificationSlider => { + ToolbarEvent::SetSpotlightMagnification(value) + } + _ => ToolbarEvent::SetFontSize(value), + }; + send_event(&sender, event); + }, + ); // Thickness/text-size use distinct numeral controls. The // other readouts sit beside a full-width track, matching // the built-in toolbar instead of borrowing track space. diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index 344095fe0..0f5f7a4a5 100644 --- a/src/toolbar_gtk/widgets.rs +++ b/src/toolbar_gtk/widgets.rs @@ -11,15 +11,12 @@ use super::bridge::FeedbackPublisher; use super::icons::{IconPainter, IconWidget}; use crate::config::ToolbarRebindModifier; use crate::draw::Color; -use crate::ui::theme::{ACCENT_RGB, Rgba, rgba, set_color}; -use crate::ui::toolbar::{ToolbarEvent, model::ToolbarSliderSpec}; +use crate::ui::theme::set_color; +use crate::ui::toolbar::ToolbarEvent; +mod slider; +pub(super) use slider::SliderRow; pub(super) use crate::ui::theme::toolbar::COLOR_SWATCH_HAIRLINE; -/// Filled (dragged) portion of the slider track: the accent at reduced -/// alpha so it stays quieter than the knob (same tint as -/// COLOR_SEGMENT_ACTIVE). -const COLOR_TRACK_FILL: Rgba = rgba(ACCENT_RGB, 0.55); - /// Sender the view hands to every control closure. Clones share the configured /// rebind chord and modifier state captured from the actual GTK click. #[derive(Clone)] @@ -261,7 +258,8 @@ fn release_window_keyboard_focus(widget: &impl IsA) { fn focus_change_releases_keyboard(focus: >k4::Widget) -> bool { let entry_focused = focus.ancestor(gtk4::Entry::static_type()).is_some(); let popover_focused = focus.ancestor(gtk4::Popover::static_type()).is_some(); - !entry_focused && !popover_focused + let slider_focused = focus.accessible_role() == gtk4::AccessibleRole::Slider; + !entry_focused && !popover_focused && !slider_focused } /// Drop keyboard ownership when GTK focuses any non-entry control. Buttons @@ -302,7 +300,9 @@ pub(super) fn install_shortcut_focus_policy(window: >k4::Window, feedback: &Fe let entry_clicked = window .pick(x, y, gtk4::PickFlags::DEFAULT) .is_some_and(|target| { - target.is::() || target.ancestor(gtk4::Entry::static_type()).is_some() + target.is::() + || target.ancestor(gtk4::Entry::static_type()).is_some() + || target.accessible_role() == gtk4::AccessibleRole::Slider }); if !entry_clicked { release_window_keyboard_focus(&window); @@ -320,7 +320,9 @@ pub(super) fn install_shortcut_focus_policy(window: >k4::Window, feedback: &Fe .upgrade() .and_then(|window| window.pick(x, y, gtk4::PickFlags::DEFAULT)) .is_some_and(|target| { - target.is::() || target.ancestor(gtk4::Entry::static_type()).is_some() + target.is::() + || target.ancestor(gtk4::Entry::static_type()).is_some() + || target.accessible_role() == gtk4::AccessibleRole::Slider }); begin_entry_drag.set(entry_target); }); @@ -511,154 +513,6 @@ impl SwatchButton { } } -/// Custom slider matching the built-in track + knob (a `DrawingArea` with -/// a drag gesture), so a live backend update never fights an in-flight -/// drag: incoming values are ignored while `dragging` is set. -pub(super) struct SliderRow { - pub(super) root: gtk4::Box, - value_label: gtk4::Label, - area: gtk4::DrawingArea, - state: Rc, - format: fn(f64) -> String, -} - -pub(super) struct SliderState { - spec: ToolbarSliderSpec, - value: Cell, - dragging: Cell, -} - -impl SliderRow { - /// `on_change` fires continuously during a drag with the new value. - pub(super) fn new( - scale: f64, - spec: ToolbarSliderSpec, - initial: f64, - format: fn(f64) -> String, - on_change: impl Fn(f64) + 'static, - ) -> Self { - let root = gtk4::Box::new(gtk4::Orientation::Horizontal, (6.0 * scale).round() as i32); - // Backend/config values are valid throughout the continuous range and - // stay visible exactly as stored. Snapping begins only when the user - // interacts with the slider. - let initial = spec.clamp(initial); - let state = Rc::new(SliderState { - spec, - value: Cell::new(initial), - dragging: Cell::new(false), - }); - - let area = gtk4::DrawingArea::new(); - area.set_content_height((16.0 * scale).round() as i32); - area.set_hexpand(true); - area.set_valign(gtk4::Align::Center); - let draw_state = state.clone(); - area.set_draw_func(move |_, ctx, width, height| { - let w = width as f64; - let h = height as f64; - let track_h = (h * 0.5).min(8.0); - let track_y = (h - track_h) / 2.0; - let radius = track_h / 2.0; - let t = draw_state.spec.t_from_value(draw_state.value.get()); - // Track - rounded_rect_path(ctx, 0.0, track_y, w, track_h, radius); - set_color(ctx, super::css::TRACK_BACKGROUND); - let _ = ctx.fill(); - // Filled portion (accent at reduced alpha) - rounded_rect_path(ctx, 0.0, track_y, (w * t).max(track_h), track_h, radius); - set_color(ctx, COLOR_TRACK_FILL); - let _ = ctx.fill(); - // Knob - let knob_r = (h / 2.0).min(7.0); - let knob_x = knob_r + t * (w - knob_r * 2.0); - ctx.arc(knob_x, h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0); - set_color(ctx, super::css::TRACK_KNOB); - let _ = ctx.fill(); - }); - - let value_label = gtk4::Label::new(Some(&format(initial))); - value_label.set_width_chars(5); - value_label.set_xalign(1.0); - - let drag = gtk4::GestureDrag::new(); - let drag_state = state.clone(); - let drag_area = area.clone(); - let start_value = Rc::new(Cell::new((0.0f64, 0.0f64))); - let begin_start = start_value.clone(); - let begin_label = value_label.clone(); - drag.connect_drag_begin(move |gesture, x, _| { - drag_state.dragging.set(true); - // Jump the knob to the pressed position, like the built-in track. - let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; - let t = (x / width).clamp(0.0, 1.0); - let value = drag_state.spec.value_from_t(t); - drag_state.value.set(value); - begin_label.set_text(&format(value)); - begin_start.set((x, value)); - drag_area.queue_draw(); - }); - let update_state = state.clone(); - let update_area = area.clone(); - let update_start = start_value.clone(); - let update_label = value_label.clone(); - let change = Rc::new(on_change); - let update_change = change.clone(); - drag.connect_drag_update(move |gesture, dx, _| { - let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; - let (sx, _) = update_start.get(); - let t = ((sx + dx) / width).clamp(0.0, 1.0); - let value = update_state.spec.value_from_t(t); - update_state.value.set(value); - update_label.set_text(&format(value)); - update_area.queue_draw(); - update_change(value); - }); - let end_state = state.clone(); - let end_change = change.clone(); - drag.connect_drag_end(move |_, _, _| { - end_state.dragging.set(false); - end_change(end_state.value.get()); - }); - area.add_controller(drag); - - root.append(&area); - root.append(&value_label); - Self { - root, - value_label, - area, - state, - format, - } - } - - /// Show an inline readout in a fixed slot immediately after the track. - /// Other slider rows keep their natural five-character, right-aligned - /// readout; the style pill instead mirrors the built-in toolbar's track + - /// readout geometry. - pub(super) fn configure_inline_readout(&self, visible: bool, width: i32) { - self.value_label.set_visible(visible); - if visible { - self.value_label.set_width_chars(-1); - self.value_label.set_size_request(width, -1); - self.value_label.set_xalign(0.0); - } - } - - /// Applies a backend value unless the user is mid-drag. - pub(super) fn set_value(&self, value: f64) { - if self.state.dragging.get() { - return; - } - let clamped = self.state.spec.clamp(value); - if (self.state.value.get() - clamped).abs() > f64::EPSILON { - self.state.value.set(clamped); - self.area.queue_draw(); - } - self.value_label.set_text(&(self.format)(clamped)); - } -} - pub(super) fn rounded_rect_path(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, r: f64) { use std::f64::consts::PI; let r = r.min(w / 2.0).min(h / 2.0).max(0.0); diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs new file mode 100644 index 000000000..728a43bca --- /dev/null +++ b/src/toolbar_gtk/widgets/slider.rs @@ -0,0 +1,297 @@ +//! Slider interaction retains the Cairo geometry and the active-drag update barrier. +use super::rounded_rect_path; +use crate::ui::theme::{ACCENT_RGB, Rgba, rgba, set_color}; +use crate::ui::toolbar::model::ToolbarSliderSpec; +use gtk4::prelude::*; +use std::{cell::Cell, rc::Rc}; + +const COLOR_TRACK_FILL: Rgba = rgba(ACCENT_RGB, 0.55); + +/// Custom slider matching the built-in track + knob (a `DrawingArea` with +/// a drag gesture), so a live backend update never fights an in-flight +/// drag: incoming values are ignored while `dragging` is set. +pub(in crate::toolbar_gtk) struct SliderRow { + pub(in crate::toolbar_gtk) root: gtk4::Box, + value_label: gtk4::Label, + area: gtk4::DrawingArea, + state: Rc, + format: fn(f64) -> String, +} + +struct SliderState { + spec: ToolbarSliderSpec, + value: Cell, + dragging: Cell, +} + +impl SliderRow { + /// `on_change` fires continuously during a drag with the new value. + pub(in crate::toolbar_gtk) fn new( + scale: f64, + name: &str, + spec: ToolbarSliderSpec, + initial: f64, + format: fn(f64) -> String, + on_change: impl Fn(f64) + 'static, + ) -> Self { + let root = gtk4::Box::new(gtk4::Orientation::Horizontal, (6.0 * scale).round() as i32); + // Backend/config values are valid throughout the continuous range and + // stay visible exactly as stored. Snapping begins only when the user + // interacts with the slider. + let initial = spec.clamp(initial); + let state = Rc::new(SliderState { + spec, + value: Cell::new(initial), + dragging: Cell::new(false), + }); + + let area = gtk4::DrawingArea::builder() + .accessible_role(gtk4::AccessibleRole::Slider) + .focusable(true) + .build(); + area.update_property(&[ + gtk4::accessible::Property::Label(name), + gtk4::accessible::Property::ValueMin(spec.min), + gtk4::accessible::Property::ValueMax(spec.max), + gtk4::accessible::Property::Orientation(gtk4::Orientation::Horizontal), + ]); + update_accessible_value(&area, initial, format); + area.set_content_height((16.0 * scale).round() as i32); + area.set_hexpand(true); + area.set_valign(gtk4::Align::Center); + let draw_state = state.clone(); + area.set_draw_func(move |widget, ctx, width, height| { + let w = width as f64; + let h = height as f64; + let track_h = (h * 0.5).min(8.0); + let track_y = (h - track_h) / 2.0; + let radius = track_h / 2.0; + let t = draw_state.spec.t_from_value(draw_state.value.get()); + if widget.has_focus() { + set_color(ctx, crate::toolbar_gtk::css::ACCENT); + ctx.set_line_width(1.5); + rounded_rect_path(ctx, 1.0, 1.0, w - 2.0, h - 2.0, 3.0); + let _ = ctx.stroke(); + } + // Track + rounded_rect_path(ctx, 0.0, track_y, w, track_h, radius); + set_color(ctx, crate::toolbar_gtk::css::TRACK_BACKGROUND); + let _ = ctx.fill(); + // Filled portion (accent at reduced alpha) + rounded_rect_path(ctx, 0.0, track_y, (w * t).max(track_h), track_h, radius); + set_color(ctx, COLOR_TRACK_FILL); + let _ = ctx.fill(); + // Knob + let knob_r = (h / 2.0).min(7.0); + let knob_x = knob_r + t * (w - knob_r * 2.0); + ctx.arc(knob_x, h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0); + set_color(ctx, crate::toolbar_gtk::css::TRACK_KNOB); + let _ = ctx.fill(); + }); + + let value_label = gtk4::Label::new(Some(&format(initial))); + value_label.set_width_chars(5); + value_label.set_xalign(1.0); + + let drag = gtk4::GestureDrag::new(); + let drag_state = state.clone(); + let drag_area = area.clone(); + let start_value = Rc::new(Cell::new((0.0f64, 0.0f64))); + let begin_start = start_value.clone(); + let begin_label = value_label.clone(); + drag.connect_drag_begin(move |gesture, x, _| { + drag_state.dragging.set(true); + drag_area.grab_focus(); + // Jump the knob to the pressed position, like the built-in track. + let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; + let t = (x / width).clamp(0.0, 1.0); + let value = drag_state.spec.value_from_t(t); + drag_state.value.set(value); + begin_label.set_text(&format(value)); + begin_start.set((x, value)); + update_accessible_value(&drag_area, value, format); + drag_area.queue_draw(); + }); + let update_state = state.clone(); + let update_area = area.clone(); + let update_start = start_value.clone(); + let update_label = value_label.clone(); + let change = Rc::new(on_change); + let update_change = change.clone(); + drag.connect_drag_update(move |gesture, dx, _| { + let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; + let (sx, _) = update_start.get(); + let t = ((sx + dx) / width).clamp(0.0, 1.0); + let value = update_state.spec.value_from_t(t); + update_state.value.set(value); + update_label.set_text(&format(value)); + update_accessible_value(&update_area, value, format); + update_area.queue_draw(); + update_change(value); + }); + let end_state = state.clone(); + let end_change = change.clone(); + drag.connect_drag_end(move |_, _, _| { + end_state.dragging.set(false); + end_change(end_state.value.get()); + }); + let cancel_state = state.clone(); + drag.connect_cancel(move |_, _| cancel_state.dragging.set(false)); + area.add_controller(drag); + + let key = gtk4::EventControllerKey::new(); + let key_state = state.clone(); + let key_area = area.clone(); + let key_label = value_label.clone(); + key.connect_key_pressed(move |_, key, _, _| { + let Some(value) = keyboard_value(key_state.spec, key_state.value.get(), key) else { + return gtk4::glib::Propagation::Proceed; + }; + if !key_state.dragging.get() { + key_state.value.set(value); + key_label.set_text(&format(value)); + update_accessible_value(&key_area, value, format); + key_area.queue_draw(); + change(value); + } + gtk4::glib::Propagation::Stop + }); + area.add_controller(key); + area.connect_has_focus_notify(|area| area.queue_draw()); + + root.append(&area); + root.append(&value_label); + Self { + root, + value_label, + area, + state, + format, + } + } + + /// Show an inline readout in a fixed slot immediately after the track. + /// Other slider rows keep their natural five-character, right-aligned + /// readout; the style pill instead mirrors the built-in toolbar's track + + /// readout geometry. + pub(in crate::toolbar_gtk) fn configure_inline_readout(&self, visible: bool, width: i32) { + self.value_label.set_visible(visible); + if visible { + self.value_label.set_width_chars(-1); + self.value_label.set_size_request(width, -1); + self.value_label.set_xalign(0.0); + } + } + + /// Applies a backend value unless the user is mid-drag. + pub(in crate::toolbar_gtk) fn set_value(&self, value: f64) { + if self.state.dragging.get() { + return; + } + let clamped = self.state.spec.clamp(value); + if (self.state.value.get() - clamped).abs() > f64::EPSILON { + self.state.value.set(clamped); + self.area.queue_draw(); + } + self.value_label.set_text(&(self.format)(clamped)); + update_accessible_value(&self.area, clamped, self.format); + } +} + +fn update_accessible_value(area: >k4::DrawingArea, value: f64, format: fn(f64) -> String) { + area.update_property(&[ + gtk4::accessible::Property::ValueNow(value), + gtk4::accessible::Property::ValueText(&format(value)), + ]); +} + +fn keyboard_value(spec: ToolbarSliderSpec, value: f64, key: gtk4::gdk::Key) -> Option { + use gtk4::gdk::Key; + let step = spec.step.unwrap_or((spec.max - spec.min) / 100.0); + let value = match key { + Key::Left | Key::Down => value - step, + Key::Right | Key::Up => value + step, + Key::Page_Down => value - 10.0 * step, + Key::Page_Up => value + 10.0 * step, + Key::Home => spec.min, + Key::End => spec.max, + _ => return None, + }; + Some(spec.normalize_value(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn keyboard_uses_shared_snapping_and_clamps_endpoints() { + use gtk4::gdk::Key; + let spec = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION; + assert_eq!(keyboard_value(spec, spec.min, Key::Left), Some(spec.min)); + assert_eq!(keyboard_value(spec, spec.max, Key::Up), Some(spec.max)); + assert_eq!(keyboard_value(spec, spec.min, Key::End), Some(spec.max)); + assert_eq!(keyboard_value(spec, spec.max, Key::Home), Some(spec.min)); + let next = keyboard_value(spec, spec.min, Key::Right).unwrap(); + assert_eq!(next, spec.value_from_t(spec.t_from_value(next))); + assert_eq!( + keyboard_value(spec, spec.min, Key::Page_Up), + Some(spec.normalize_value(spec.min + spec.step.unwrap() * 10.0)) + ); + assert_eq!(keyboard_value(spec, spec.min, Key::Escape), None); + } +} + +/// Called by the isolated GTK widget test after initialization. +#[cfg(test)] +pub(super) fn assert_widget_contract() { + let changes = Rc::new(std::cell::RefCell::new(Vec::new())); + let observed = changes.clone(); + let slider = SliderRow::new( + 1.0, + "Thickness", + ToolbarSliderSpec::THICKNESS, + 4.5, + |value| format!("{value} pt"), + move |value| observed.borrow_mut().push(value), + ); + assert!(slider.area.is_focusable()); + assert_eq!(slider.area.accessible_role(), gtk4::AccessibleRole::Slider); + for property in [ + gtk4::AccessibleProperty::Label, + gtk4::AccessibleProperty::ValueMin, + gtk4::AccessibleProperty::ValueMax, + gtk4::AccessibleProperty::ValueNow, + gtk4::AccessibleProperty::ValueText, + ] { + assert!(gtk4::test_accessible_has_property(&slider.area, property)); + } + assert!(!super::focus_change_releases_keyboard( + slider.area.upcast_ref() + )); + let controllers = slider.area.observe_controllers(); + let key = (0..controllers.n_items()) + .filter_map(|i| controllers.item(i)) + .find_map(|item| item.downcast::().ok()) + .unwrap(); + key.emit_by_name::( + "key-pressed", + &[ + >k4::gdk::Key::Right, + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + assert_eq!(slider.state.value.get(), 5.5); + assert_eq!(changes.borrow().as_slice(), &[5.5]); + slider.state.dragging.set(true); + slider.set_value(10.0); + assert_eq!(slider.state.value.get(), 5.5); + slider.state.dragging.set(false); + slider.set_value(6.25); + assert_eq!(slider.state.value.get(), 6.25); + assert_eq!( + changes.borrow().as_slice(), + &[5.5], + "backend updates emit no user event" + ); +} diff --git a/src/toolbar_gtk/widgets/tests.rs b/src/toolbar_gtk/widgets/tests.rs index fc39fea25..c12973a81 100644 --- a/src/toolbar_gtk/widgets/tests.rs +++ b/src/toolbar_gtk/widgets/tests.rs @@ -20,6 +20,7 @@ fn popover_internal_focus_keeps_the_keyboard_grab() { .arg("--exact") .arg("--test-threads=1") .env(CHILD_ENV, "1") + .env("GTK_A11Y", "test") .status() .expect("run isolated GTK focus-policy test"); assert!(status.success(), "isolated GTK focus-policy test failed"); @@ -31,6 +32,8 @@ fn popover_internal_focus_keeps_the_keyboard_grab() { return; } + super::slider::assert_widget_contract(); + let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let bar_button = gtk4::Button::new(); let bar_entry = gtk4::Entry::new(); From d1c5743f8cdf1c65338793427d6ce5f2b0367676 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:06:58 +0200 Subject: [PATCH 06/28] test(render): preserve and revalidate native font crash reproducer --- tools/diagnostics/native-fonts/README.md | 49 ++++++++ tools/diagnostics/native-fonts/stress.c | 138 +++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tools/diagnostics/native-fonts/README.md create mode 100644 tools/diagnostics/native-fonts/stress.c diff --git a/tools/diagnostics/native-fonts/README.md b/tools/diagnostics/native-fonts/README.md new file mode 100644 index 000000000..d25622d29 --- /dev/null +++ b/tools/diagnostics/native-fonts/README.md @@ -0,0 +1,49 @@ +# Native font concurrency diagnostic + +This standalone C program exercises Pango/Cairo with fresh per-thread layouts, +surfaces and contexts, overlapping font-map teardown with other threads' draws. +It uses more than ten distinct font files to exercise native font cache eviction. +It does not initialize GTK, connect to Wayland, or start Wayscriber. + +Prerequisites: a C compiler, pthreads, pkg-config, Cairo, Pango/PangoFT2, +Fontconfig development files, fontconfig tools, GNU timeout, and at least eleven +installed scalable font files. Fonts and their order affect reproduction; the +harness records both selected descriptions and resolved files. A fontconfig +package version is not a complete font-set description. + +From any working directory, run: + +```sh +/path/to/wayscriber/tools/diagnostics/native-fonts/run.sh /tmp/native-font-check +``` + +Optional arguments after the results directory are threads, waves, iterations +per thread, and maximum font files. Defaults are `24 20 96 64`, bounded to 45 +seconds. Extra iterations vary by thread to overlap teardown. Results retain +the executable, versions, complete font inventory, draw log and exit status. +Status 0 means this workload completed, 124 means timeout, 139 typically means +SIGSEGV, and 2 means a missing font/argument prerequisite. Preserve the executable +and matching system libraries alongside any core before debugging it. On +systemd machines, `coredumpctl info` and `coredumpctl debug` can inspect the core. + +## Evidence on 2026-09-07 + +Recovered the original 2026-09-05 harness, previously kept only in temporary +files, without changing its workload. A fresh `-O1 -g` build with GCC reproduced +SIGSEGV in the first wave at `24 20 96 64`, before the 45-second limit: + +- Cairo 1.18.4, Pango 1.58.2, FreeType 2.14.3 (header version), Fontconfig 2.18.3. + FreeType's pkg-config/libtool version was 26.6.20; it is not the release version. +- 64 distinct source font files, including Noto families and locally installed + Maple Mono. Resolved files were logged before the crash. +- The current crashing stack starts inside `libcairo.so.2`, through + `cairo_scaled_font_glyph_extents`, `cairo_scaled_font_text_extents`, PangoCairo, + HarfBuzz shaping, and Pango layout. The core reports signal 11, SEGV_MAPERR. +- No Wayscriber code or shared application drawing objects are in this harness. + +This reproduces a current native-library failure. Earlier cores implicated +Cairo font-cache eviction and a temporary locking patch was explored, but this +run does not establish that mechanism or validate that patch. No library or +application fix is claimed. A passing rerun or serial run would not prove the +race fixed. Keep native diagnosis separate from Rust regression results and +from compositor verification. diff --git a/tools/diagnostics/native-fonts/stress.c b/tools/diagnostics/native-fonts/stress.c new file mode 100644 index 000000000..f03a85a44 --- /dev/null +++ b/tools/diagnostics/native-fonts/stress.c @@ -0,0 +1,138 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Headless native-only reproducer: no Rust, retained PangoLayout, custom font + * map, raw FT_Face use, global shutdown, or shared drawing objects. Only + * immutable font-description strings and a start barrier cross threads. */ +static char **descriptions; +static size_t font_count; +static pthread_barrier_t start_barrier; +static atomic_ulong completed; +static const char *samples[] = { + "Cache eviction: ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789", + "你好 Καλημέρα onboarding — café שלום العربية", + "Wrapped body text with café and שלום repeated across the narrow card. More words occupy another line.", + "A long Unicode checklist label 你好 café 1234567890" +}; +struct job { unsigned id, wave, iterations; }; + +static void *worker(void *arg) { + struct job *job = arg; + pthread_barrier_wait(&start_barrier); + /* Different lifetimes overlap default per-thread font-map teardown with + * other threads still creating fonts, shaping, and drawing. */ + unsigned count = job->iterations + (job->id % 7) * 3; + for (unsigned i = 0; i < count; i++) { + unsigned density = 1 + ((i + job->id) % 2); + size_t index = (job->id * 17u + job->wave * 11u + i) % font_count; + cairo_surface_t *surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 420 * density, 240 * density); + cairo_t *cr = cairo_create(surface); + cairo_scale(cr, density, density); + cairo_translate(cr, (double)(i % 3), (double)(i % 5)); + cairo_set_source_rgb(cr, 0.08, 0.1, 0.12); + cairo_paint(cr); + cairo_set_source_rgb(cr, 0.9, 0.9, 0.9); + PangoLayout *layout = pango_cairo_create_layout(cr); + PangoFontDescription *desc = pango_font_description_from_string(descriptions[index]); + pango_font_description_set_absolute_size(desc, (12.0 + (i % 9) * 1.3) * PANGO_SCALE); + pango_layout_set_font_description(layout, desc); + if (job->wave == 0 && job->id == 0 && i < font_count) { + PangoFont *font = pango_context_load_font(pango_layout_get_context(layout), desc); + if (font && PANGO_IS_FC_FONT(font)) { + FcChar8 *resolved; + FcPattern *pattern = pango_fc_font_get_pattern(PANGO_FC_FONT(font)); + if (FcPatternGetString(pattern, FC_FILE, 0, &resolved) == FcResultMatch) + fprintf(stderr, "resolved[%zu] %s\n", index, resolved); + } + if (font) g_object_unref(font); + } + pango_layout_set_text(layout, samples[(i + job->id) % 4], -1); + pango_layout_set_width(layout, (290 + (i % 5) * 10) * PANGO_SCALE); + pango_layout_set_wrap(layout, PANGO_WRAP_WORD_CHAR); + /* Metrics followed immediately by show mirrors the failing call chain. + * Both operations use the same fresh layout and unchanged target. */ + PangoRectangle ink, logical; + pango_layout_get_extents(layout, &ink, &logical); + cairo_move_to(cr, 10, 35 - (double)pango_layout_get_baseline(layout) / PANGO_SCALE); + pango_cairo_show_layout(cr, layout); + cairo_surface_flush(surface); + if (cairo_status(cr) != CAIRO_STATUS_SUCCESS || cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) { + fprintf(stderr, "Cairo error wave=%u thread=%u iteration=%u\n", job->wave, job->id, i); + abort(); + } + pango_font_description_free(desc); + g_object_unref(layout); + cairo_destroy(cr); + cairo_surface_destroy(surface); + atomic_fetch_add_explicit(&completed, 1, memory_order_relaxed); + } + return NULL; +} + +static unsigned argument(const char *s, unsigned fallback, unsigned maximum) { + if (!s) return fallback; + char *end; + unsigned long n = strtoul(s, &end, 10); + if (*end || n == 0 || n > maximum) { fprintf(stderr, "Invalid numeric argument: %s\n", s); exit(2); } + return (unsigned)n; +} + +int main(int argc, char **argv) { + if (argc > 5) { fprintf(stderr, "usage: %s [threads=24] [waves=100] [iterations=96] [fonts=64]\n", argv[0]); return 2; } + unsigned threads = argument(argc > 1 ? argv[1] : NULL, 24, 256); + unsigned waves = argument(argc > 2 ? argv[2] : NULL, 100, 100000); + unsigned iterations = argument(argc > 3 ? argv[3] : NULL, 96, 100000); + unsigned limit = argument(argc > 4 ? argv[4] : NULL, 64, 1024); + if (!FcInit()) return 2; + FcPattern *query = FcPatternCreate(); + FcPatternAddBool(query, FC_SCALABLE, FcTrue); + FcObjectSet *objects = FcObjectSetBuild(FC_FAMILY, FC_STYLE, FC_WEIGHT, FC_SLANT, FC_WIDTH, FC_FILE, FC_INDEX, NULL); + FcFontSet *fonts = FcFontList(NULL, query, objects); + descriptions = calloc(limit, sizeof(*descriptions)); + char **files = calloc(limit, sizeof(*files)); + if (!fonts || !descriptions || !files) abort(); + for (int i = 0; i < fonts->nfont && font_count < limit; i++) { + FcChar8 *file; + if (FcPatternGetString(fonts->fonts[i], FC_FILE, 0, &file) != FcResultMatch) continue; + int duplicate = 0; + for (size_t j = 0; j < font_count; j++) if (!strcmp(files[j], (char *)file)) duplicate = 1; + if (duplicate) continue; + PangoFontDescription *desc = pango_fc_font_description_from_pattern(fonts->fonts[i], FALSE); + if (!desc) continue; + descriptions[font_count] = pango_font_description_to_string(desc); + files[font_count] = strdup((char *)file); + fprintf(stderr, "font[%zu] %s | %s\n", font_count, descriptions[font_count], files[font_count]); + pango_font_description_free(desc); + font_count++; + } + FcFontSetDestroy(fonts); FcObjectSetDestroy(objects); FcPatternDestroy(query); + if (font_count < 11) { fprintf(stderr, "Need more than ten distinct installed font files; found %zu\n", font_count); return 2; } + fprintf(stderr, "Pango %s Cairo %s; threads=%u waves=%u iterations=%u distinct-source-files=%zu\n", pango_version_string(), cairo_version_string(), threads, waves, iterations, font_count); + pthread_t *ids = calloc(threads, sizeof(*ids)); + struct job *jobs = calloc(threads, sizeof(*jobs)); + if (!ids || !jobs) abort(); + for (unsigned wave = 0; wave < waves; wave++) { + if (pthread_barrier_init(&start_barrier, NULL, threads)) abort(); + for (unsigned t = 0; t < threads; t++) { + jobs[t] = (struct job){t, wave, iterations}; + if (pthread_create(&ids[t], NULL, worker, &jobs[t])) abort(); + } + for (unsigned t = 0; t < threads; t++) if (pthread_join(ids[t], NULL)) abort(); + pthread_barrier_destroy(&start_barrier); + fprintf(stderr, "completed wave=%u draws=%lu\n", wave + 1, atomic_load(&completed)); + } + free(ids); free(jobs); + for (size_t i = 0; i < font_count; i++) { g_free(descriptions[i]); free(files[i]); } + free(descriptions); free(files); + /* Deliberately no FcFini/cairo_debug_reset_static_data/global Pango reset. */ + return 0; +} From 4389050eec8dbdadd5baee376835bfe9bab7dbc4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:07:13 +0200 Subject: [PATCH 07/28] test(render): retain bounded diagnostic runner and result artifacts --- tools/diagnostics/native-fonts/README.md | 2 +- tools/diagnostics/native-fonts/run-stress.sh | 32 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100755 tools/diagnostics/native-fonts/run-stress.sh diff --git a/tools/diagnostics/native-fonts/README.md b/tools/diagnostics/native-fonts/README.md index d25622d29..5b6657298 100644 --- a/tools/diagnostics/native-fonts/README.md +++ b/tools/diagnostics/native-fonts/README.md @@ -14,7 +14,7 @@ package version is not a complete font-set description. From any working directory, run: ```sh -/path/to/wayscriber/tools/diagnostics/native-fonts/run.sh /tmp/native-font-check +/path/to/wayscriber/tools/diagnostics/native-fonts/run-stress.sh /tmp/native-font-check ``` Optional arguments after the results directory are threads, waves, iterations diff --git a/tools/diagnostics/native-fonts/run-stress.sh b/tools/diagnostics/native-fonts/run-stress.sh new file mode 100755 index 000000000..63469cfa0 --- /dev/null +++ b/tools/diagnostics/native-fonts/run-stress.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Keep the executable and logs together so a core can be symbolicated later. +set -euo pipefail +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +RESULT_DIR=${1:?usage: run-stress.sh RESULT_DIR [threads waves iterations fonts]} +shift +mkdir -p "$RESULT_DIR" +RESULT_DIR=$(cd "$RESULT_DIR" && pwd) +{ + date -u +'%Y-%m-%dT%H:%M:%SZ' + uname -srmo + cc --version | head -1 + for library in cairo pango freetype2 fontconfig; do + printf '%s pkg-config: ' "$library" + pkg-config --modversion "$library" + done +} > "$RESULT_DIR/versions.txt" +fc-list --format='%{family}\t%{style}\t%{file}\n' | sort > "$RESULT_DIR/fonts.txt" +# pkg-config produces compiler/linker arguments, intentionally split here. +# shellcheck disable=SC2046 +cc -std=c11 -O1 -g -Wall -Wextra -pthread "$SCRIPT_DIR/stress.c" \ + -o "$RESULT_DIR/stress" $(pkg-config --cflags --libs pangocairo pangoft2 fontconfig) +if [[ $# -eq 0 ]]; then set -- 24 20 96 64; fi +printf 'timeout 45s stress' > "$RESULT_DIR/result.txt" +printf ' %s' "$@" >> "$RESULT_DIR/result.txt" +printf '\n' >> "$RESULT_DIR/result.txt" +set +e +timeout 45s "$RESULT_DIR/stress" "$@" > "$RESULT_DIR/stress.log" 2>&1 +DIAGNOSTIC_STATUS=$? +set -e +printf 'exit_status=%s\n' "$DIAGNOSTIC_STATUS" | tee -a "$RESULT_DIR/result.txt" +exit "$DIAGNOSTIC_STATUS" From 1508d4d87fb510a4d3ef884a515678e3100e4a10 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:09:58 +0200 Subject: [PATCH 08/28] test(ui): require executed GTK widget coverage on isolated display --- .github/workflows/ci.yml | 68 +++++++++++++++++++++++++++ CONTRIBUTING.md | 7 +++ src/toolbar_gtk/view/top_bar/tests.rs | 6 +++ src/toolbar_gtk/widgets/tests.rs | 6 +++ tests/ui.rs | 25 ++++++++++ tools/test-gtk-widgets.sh | 38 +++++++++++++++ 6 files changed, 150 insertions(+) create mode 100755 tools/test-gtk-widgets.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 631f08277..3a4f82118 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,74 @@ jobs: path: code-health-report.txt if-no-files-found: warn + gtk-widgets: + name: Required GTK widget contracts + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + weston \ + dbus-x11 \ + fonts-dejavu-core \ + build-essential \ + pkg-config \ + clang \ + cmake \ + libssl-dev \ + libwayland-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libegl1-mesa-dev \ + libgles2-mesa-dev \ + libdbus-1-dev \ + libinput-dev \ + libudev-dev \ + libgtk-3-dev \ + libgtk-4-dev \ + libadwaita-1-dev \ + meson \ + ninja-build \ + wayland-protocols \ + libpango1.0-dev \ + libcairo2-dev \ + libpixman-1-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxcb-render0-dev \ + libxcb-randr0-dev \ + libxcb1-dev \ + libx11-dev + + - name: Build gtk4-layer-shell + # Build both forms privately: normal CI exercises the dynamic source + # build used by AUR/Nix, and a dedicated step checks release linkage. + run: | + GTK4_LAYER_SHELL_PREFIX="$RUNNER_TEMP/gtk4-layer-shell" \ + GTK4_LAYER_SHELL_LIBRARY_MODE=both \ + bash tools/install-gtk4-layer-shell.sh + echo "PKG_CONFIG_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$RUNNER_TEMP/gtk4-layer-shell/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@1.98.1 + with: + components: clippy, rustfmt + + - name: Cache cargo builds + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + + - name: Exercise widgets on an isolated display + run: ./tools/test-gtk-widgets.sh + nix-flake: name: Nix flake runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03d613210..f603ba778 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -217,3 +217,10 @@ Local checks and the Rust CI job both run `./tools/lint-and-test.sh`. It lints, builds binaries, and tests the whole workspace with all features and with no default features, alongside source and packaging checks. CI additionally checks dynamic and static gtk4-layer-shell linkage and uploads its code-health report. + +GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston and +`dbus-run-session` required). It creates a private headless display and requires +GTK initialization; an unavailable display fails this check. Successful widget +bodies print `EXECUTED` markers. Ordinary tests without a display report an +optional skip. Neither route proves layer-shell focus or screen capture behavior +on a user's compositor. diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 535c82a46..430ad4028 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -2516,6 +2516,7 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { .arg(TEST_NAME) .arg("--exact") .arg("--test-threads=1") + .arg("--nocapture") .env(CHILD_ENV, "1") .status() .expect("run isolated GTK widget contract test"); @@ -2524,6 +2525,10 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { } if let Err(error) = gtk4::init() { + assert!( + std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none(), + "Required GTK widget coverage could not initialize: {error}" + ); eprintln!("skipping GTK widget contract test: {error}"); return; } @@ -2548,6 +2553,7 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { assert_style_pill_interactions(®ular); assert_menu_popover_contracts(®ular); + eprintln!("EXECUTED: GTK widget contract assertions"); } fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { diff --git a/src/toolbar_gtk/widgets/tests.rs b/src/toolbar_gtk/widgets/tests.rs index c12973a81..4172cefe2 100644 --- a/src/toolbar_gtk/widgets/tests.rs +++ b/src/toolbar_gtk/widgets/tests.rs @@ -19,6 +19,7 @@ fn popover_internal_focus_keeps_the_keyboard_grab() { .arg(TEST_NAME) .arg("--exact") .arg("--test-threads=1") + .arg("--nocapture") .env(CHILD_ENV, "1") .env("GTK_A11Y", "test") .status() @@ -28,6 +29,10 @@ fn popover_internal_focus_keeps_the_keyboard_grab() { } if let Err(error) = gtk4::init() { + assert!( + std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none(), + "Required GTK widget coverage could not initialize: {error}" + ); eprintln!("skipping GTK focus-policy test: {error}"); return; } @@ -67,6 +72,7 @@ fn popover_internal_focus_keeps_the_keyboard_grab() { ); popover.unparent(); + eprintln!("EXECUTED: GTK focus and slider assertions"); } #[test] diff --git a/tests/ui.rs b/tests/ui.rs index db5c51aef..38c639a12 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -370,3 +370,28 @@ fn help_result_owns_rendered_footer_hits_and_matches_drawing_only_pixels() { "another paint must not replace the owned result" ); } + +#[test] +fn small_palette_surface_and_shadow_fit_the_screen() { + // These samples are away from glyphs, so native font rasterization does + // not hide a panel-size regression behind a platform-specific image diff. + for (width, height) in [(800, 480), (1024, 600), (1280, 720)] { + let mut input = make_input_state(); + input.update_screen_dimensions(width, height); + input.command_palette.open = true; + let (x, y, panel_width, panel_height) = + wayscriber::ui::command_palette_visual_geometry(&input, width, height).unwrap(); + assert!(x >= 0.0 && y >= 0.0); + assert!(x + panel_width <= width as f64); + assert!(y + panel_height <= height as f64); + let (mut surface, ctx) = surface_with_context(width as i32, height as i32); + wayscriber::ui::render_command_palette(&ctx, &input, width, height); + drop(ctx); + let dimmer = alpha_at(&mut surface, 0, 0); + let panel = alpha_at(&mut surface, (x + 20.0) as i32, (y + 20.0) as i32); + assert!( + panel > dimmer + 80, + "panel must remain distinct from its dimmer" + ); + } +} diff --git a/tools/test-gtk-widgets.sh b/tools/test-gtk-widgets.sh new file mode 100755 index 000000000..577449f5f --- /dev/null +++ b/tools/test-gtk-widgets.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# A private headless compositor: never connects to the user's desktop/display. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +command -v weston >/dev/null +GTK_TEST_RUNTIME=$(mktemp -d) +chmod 700 "$GTK_TEST_RUNTIME" +cleanup() { + if [[ -n "${GTK_TEST_WESTON_PID:-}" ]]; then + kill "$GTK_TEST_WESTON_PID" 2>/dev/null || true + wait "$GTK_TEST_WESTON_PID" 2>/dev/null || true + fi + rm -rf "$GTK_TEST_RUNTIME" +} +trap cleanup EXIT +export XDG_RUNTIME_DIR="$GTK_TEST_RUNTIME" +export WAYLAND_DISPLAY=wayscriber-widget-tests +unset DISPLAY +export GDK_BACKEND=wayland +export GTK_A11Y=test +export WAYSCRIBER_REQUIRE_GTK_TESTS=1 +weston --backend=headless-backend.so --renderer=pixman --no-config \ + --socket="$WAYLAND_DISPLAY" --idle-time=0 \ + --log="$GTK_TEST_RUNTIME/weston.log" >"$GTK_TEST_RUNTIME/weston-output.log" 2>&1 & +GTK_TEST_WESTON_PID=$! +for ((attempt=0; attempt<100; attempt++)); do + [[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] && break + if ! kill -0 "$GTK_TEST_WESTON_PID" 2>/dev/null; then + cat "$GTK_TEST_RUNTIME"/*.log + exit 1 + fi + sleep 0.1 +done +[[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] || { cat "$GTK_TEST_RUNTIME"/*.log; exit 1; } +dbus-run-session -- cargo test --locked -p wayscriber --all-features --lib \ + toolbar_gtk:: -- --test-threads=1 --nocapture 2>&1 | tee "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK focus and slider assertions' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK widget contract assertions' "$GTK_TEST_RUNTIME/tests.log" From 4b4ea7ef773a69ac49a8ef1ba6284227e3777d45 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:15:23 +0200 Subject: [PATCH 09/28] perf(session): clone only history retained by snapshot policy --- src/draw/frame/core.rs | 14 ++++ src/draw/frame/tests/history/limits.rs | 101 +++++++++++++++++++++++++ src/session/snapshot/capture.rs | 13 ++-- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index dadf3f907..db15b82d8 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -72,6 +72,20 @@ impl Frame { frame } + /// Copy exactly the newest undo/redo entries retained by clamp_history_depth, + /// without first allocating copies of discarded actions or their payloads. + pub fn clone_with_history_limit(&self, limit: usize) -> Self { + if limit == usize::MAX { + return self.clone(); + } + let mut frame = self.clone_without_history(); + frame.next_shape_id = self.next_shape_id; + frame.shape_order_generation = self.shape_order_generation; + frame.undo_stack = self.undo_stack[self.undo_stack.len().saturating_sub(limit)..].to_vec(); + frame.redo_stack = self.redo_stack[self.redo_stack.len().saturating_sub(limit)..].to_vec(); + frame + } + pub fn page_name(&self) -> Option<&str> { self.page_name.as_deref() } diff --git a/src/draw/frame/tests/history/limits.rs b/src/draw/frame/tests/history/limits.rs index c8cc03aa0..0bb62527b 100644 --- a/src/draw/frame/tests/history/limits.rs +++ b/src/draw/frame/tests/history/limits.rs @@ -69,3 +69,104 @@ fn clamp_history_depth_clears_both_stacks() { assert_eq!(frame.undo_stack_len(), 0); assert_eq!(frame.redo_stack_len(), 0); } + +#[test] +fn limited_clone_matches_clamp_and_preserves_live_history() { + let mut frame = Frame::new(); + frame.set_page_name(Some("History page".into())); + frame.set_view_offset(15, -20); + for i in 0..8 { + let id = frame.add_shape(Shape::Line { + x1: i, + y1: 0, + x2: i + 10, + y2: 10, + color: BLACK, + thick: 2.0, + }); + let snapshot = frame.shape(id).unwrap().clone(); + frame.push_undo_action( + UndoAction::Compound { + actions: vec![UndoAction::Create { + shapes: vec![(i as usize, snapshot)], + }], + }, + 0, + ); + } + frame.undo_last(); + frame.undo_last(); + let live = serde_json::to_value(&frame).unwrap(); + for limit in [0, 1, 3, usize::MAX] { + let mut expected = frame.clone(); + expected.clamp_history_depth(limit); + let mut actual = frame.clone_with_history_limit(limit); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + for _ in 0..8 { + expected.undo_last(); + actual.undo_last(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + } + for _ in 0..8 { + expected.redo_last(); + actual.redo_last(); + assert_eq!( + serde_json::to_value(&actual).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + } + assert_eq!(serde_json::to_value(&frame).unwrap(), live); + } +} + +#[test] +#[ignore = "release measurement: cargo test --release limited_history_copy_cost -- --ignored --nocapture"] +fn limited_history_copy_cost() { + use std::{hint::black_box, time::Instant}; + let mut frame = Frame::new(); + let shape = Shape::Freehand { + points: (0..4096).map(|i| (i, i % 100)).collect(), + color: BLACK, + thick: 2.0, + }; + for _ in 0..200 { + let id = frame.add_shape(shape.clone()); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(frame.len() - 1, frame.shape(id).unwrap().clone())], + }, + 0, + ); + } + for _ in 0..50 { + frame.undo_last(); + } + let pages = vec![frame; 4]; + for limit in [0, 10, usize::MAX] { + let start = Instant::now(); + for _ in 0..20 { + for page in &pages { + let mut copy = page.clone(); + copy.clamp_history_depth(limit); + black_box(copy); + } + } + let before = start.elapsed(); + let start = Instant::now(); + for _ in 0..20 { + for page in &pages { + black_box(page.clone_with_history_limit(limit)); + } + } + eprintln!( + "4 pages, 4096-point strokes, 150 undo + 50 redo, 20 captures; limit={limit}: clone/clamp={before:?}, retained copy={:?}", + start.elapsed() + ); + } +} diff --git a/src/session/snapshot/capture.rs b/src/session/snapshot/capture.rs index 48f608ff7..5a16de41d 100644 --- a/src/session/snapshot/capture.rs +++ b/src/session/snapshot/capture.rs @@ -20,14 +20,11 @@ pub fn snapshot_from_input( let history_limit = options.effective_history_limit(input.history_limits.undo_stack_limit()); let capture_pages = |pages: &crate::draw::BoardPages| -> Option { - let mut cloned_pages = pages.pages().to_vec(); - for page in &mut cloned_pages { - if history_limit == 0 { - page.clamp_history_depth(0); - } else if history_limit < usize::MAX { - page.clamp_history_depth(history_limit); - } - } + let cloned_pages = pages + .pages() + .iter() + .map(|page| page.clone_with_history_limit(history_limit)) + .collect(); let snapshot = BoardPagesSnapshot { pages: cloned_pages, active: pages.active_index(), From fae62fc6498644bd0a635c855550894f81680d2a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:15:23 +0200 Subject: [PATCH 10/28] perf(configurator): share shortcut analysis across each view refresh --- configurator/src/app/pages/keybindings/mod.rs | 9 ++-- .../src/app/pages/keybindings/toolbar.rs | 17 +++++-- configurator/src/app/update/shortcuts.rs | 26 +++++++--- .../src/app/update/shortcuts/tests.rs | 51 +++++++++++++++++++ configurator/src/models/keybindings/mod.rs | 3 ++ configurator/src/models/keybindings/parse.rs | 9 ++++ 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/configurator/src/app/pages/keybindings/mod.rs b/configurator/src/app/pages/keybindings/mod.rs index 10fae2a1b..f606d022c 100644 --- a/configurator/src/app/pages/keybindings/mod.rs +++ b/configurator/src/app/pages/keybindings/mod.rs @@ -30,11 +30,8 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let refresh: ManagerRefresh = Rc::new(RefCell::new(None)); { let refresh = refresh.clone(); - bindings.push(Box::new(move |app, _summary| { - *refresh.borrow_mut() = Some(( - app.shortcut_manager_summary(), - app.visible_keybinding_fields(), - )); + bindings.push(Box::new(move |app, search| { + *refresh.borrow_mut() = Some(app.prepare_shortcut_manager_refresh(search)); })); } @@ -113,7 +110,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { } let conflict = conflict_banner(sender, &mut bindings); - let chrome = build_chrome(sender, &mut bindings); + let chrome = build_chrome(sender, &mut bindings, refresh); let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append(&chrome); diff --git a/configurator/src/app/pages/keybindings/toolbar.rs b/configurator/src/app/pages/keybindings/toolbar.rs index 3d4a43882..b87795f71 100644 --- a/configurator/src/app/pages/keybindings/toolbar.rs +++ b/configurator/src/app/pages/keybindings/toolbar.rs @@ -14,6 +14,7 @@ use super::widgets::{connect_clicked, set_accessible_label, set_sensitive, set_v pub(super) fn build_chrome( sender: &ComponentSender, bindings: &mut Vec, + refresh: super::row::ManagerRefresh, ) -> gtk::Box { let chrome = gtk::Box::new(gtk::Orientation::Vertical, 8); chrome.set_margin_top(8); @@ -22,7 +23,7 @@ pub(super) fn build_chrome( chrome.append(&category_bar(sender, bindings)); chrome.append(&filter_bar(sender, bindings)); - chrome.append(&actions_bar(sender, bindings)); + chrome.append(&actions_bar(sender, bindings, refresh)); chrome.append(&reset_banner(sender, bindings)); chrome } @@ -90,7 +91,11 @@ fn filter_bar( filters } -fn actions_bar(sender: &ComponentSender, bindings: &mut Vec) -> gtk::Box { +fn actions_bar( + sender: &ComponentSender, + bindings: &mut Vec, + refresh: super::row::ManagerRefresh, +) -> gtk::Box { let labels: Vec<&str> = ShortcutManagerSort::ALL .iter() .map(|sort| sort.title()) @@ -136,7 +141,11 @@ fn actions_bar(sender: &ComponentSender, bindings: &mut Vec, bindings: &mut Vec Vec { let search = self.search_summary(); + self.visible_keybinding_fields_with(&self.shortcut_manager_summary(), &search) + } + + pub(crate) fn prepare_shortcut_manager_refresh( + &self, + search: &crate::app::search::AppSearchSummary, + ) -> (ShortcutManagerSummary, Vec) { + let summary = self.shortcut_manager_summary(); + let fields = self.visible_keybinding_fields_with(&summary, search); + (summary, fields) + } + + fn visible_keybinding_fields_with( + &self, + summary: &ShortcutManagerSummary, + search: &crate::app::search::AppSearchSummary, + ) -> Vec { let scope = (!self.keybindings_show_all).then_some(self.active_keybindings_tab); - self.shortcut_manager_summary().visible_fields( - self.shortcut_filter, - self.shortcut_sort, - scope, - |field| keybinding_row_visible(&search, field), - ) + summary.visible_fields(self.shortcut_filter, self.shortcut_sort, scope, |field| { + keybinding_row_visible(search, field) + }) } pub(crate) fn select_keybinding_field(&mut self, field: KeybindingField) { diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index 8cde91995..e3f4a991f 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -1,3 +1,4 @@ +use crate::models::{ShortcutManagerFilter, ShortcutManagerSort}; use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, Shortcut}; use crate::app::effects::Effect; @@ -743,3 +744,53 @@ fn jump_to_conflict_selects_the_other_claimant() { assert_eq!(app.active_keybindings_tab, keybinding_tab(jump)); assert!(!app.is_dirty); } + +#[test] +fn prepared_manager_refresh_reuses_analysis_for_filters() { + let (mut app, _) = ConfiguratorApp::new_app(); + for filter in ShortcutManagerFilter::ALL { + for sort in ShortcutManagerSort::ALL { + app.shortcut_filter = filter; + app.shortcut_sort = sort; + let search = app.search_summary(); + let expected = app.visible_keybinding_fields(); + let (summary, fields) = app.prepare_shortcut_manager_refresh(&search); + assert_eq!(fields, expected); + assert_eq!(summary, app.shortcut_manager_summary()); + } + } +} + +#[test] +#[ignore = "release measurement: cargo test --release shortcut_refresh_cost -- --ignored --nocapture"] +fn shortcut_refresh_cost() { + use crate::models::keybindings::take_parse_calls; + use std::{hint::black_box, time::Instant}; + let (mut app, _) = ConfiguratorApp::new_app(); + for query in ["", "tool", "Ctrl"] { + app.handle_search_changed(query.into()); + let search = app.search_summary(); + take_parse_calls(); + let start = Instant::now(); + for _ in 0..20 { + black_box(app.shortcut_manager_summary()); + black_box(app.visible_keybinding_fields()); + black_box(app.visible_keybinding_fields().is_empty()); + black_box(app.shortcut_manager_summary().has_conflicts()); + } + let before = start.elapsed(); + let before_calls = take_parse_calls(); + let start = Instant::now(); + for _ in 0..20 { + let (summary, fields) = app.prepare_shortcut_manager_refresh(&search); + black_box(fields.is_empty()); + black_box(summary.has_conflicts()); + black_box((summary, fields)); + } + eprintln!( + "20 model refreshes, query={query:?}: old={before:?}/{before_calls} parser calls, shared={:?}/{} parser calls", + start.elapsed(), + take_parse_calls() + ); + } +} diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index 201bd4f0a..81a99d58e 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -33,3 +33,6 @@ pub use recording::{ #[cfg(test)] mod tests; + +#[cfg(test)] +pub(crate) use parse::take_parse_calls; diff --git a/configurator/src/models/keybindings/parse.rs b/configurator/src/models/keybindings/parse.rs index d01afb734..6c57954f4 100644 --- a/configurator/src/models/keybindings/parse.rs +++ b/configurator/src/models/keybindings/parse.rs @@ -1,3 +1,10 @@ +#[cfg(test)] +thread_local! { static PARSE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[cfg(test)] +pub(crate) fn take_parse_calls() -> usize { + PARSE_CALLS.with(|calls| calls.replace(0)) +} + use wayscriber::config::Shortcut; pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { @@ -18,6 +25,8 @@ pub(crate) fn parse_keybinding_list(value: &str) -> Result, String> } pub(crate) fn parse_keybindings(value: &str) -> Result, String> { + #[cfg(test)] + PARSE_CALLS.with(|calls| calls.set(calls.get() + 1)); let mut entries = Vec::new(); for part in authored_shortcut_parts(value) { entries.push(Shortcut::parse(part)?); From de652dfcde1a69b2fe2d03f76d8d051c5b808dfa Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:18:11 +0200 Subject: [PATCH 11/28] refactor(input): let command palette own query navigation and repeat transitions --- src/backend/wayland/backend/event_loop/mod.rs | 2 +- src/backend/wayland/handlers/keyboard/mod.rs | 2 +- src/backend/wayland/handlers/pointer/axis.rs | 2 +- .../wayland/handlers/pointer/cursor.rs | 2 +- .../wayland/handlers/pointer/motion.rs | 2 +- .../wayland/handlers/pointer/release.rs | 2 +- src/backend/wayland/handlers/touch.rs | 6 +- src/backend/wayland/state/boards.rs | 4 +- src/backend/wayland/state/onboarding.rs | 4 +- .../wayland/state/onboarding/first_run.rs | 2 +- .../wayland/state/toolbar/inline/focus.rs | 2 +- src/input/state/core/command_palette/input.rs | 251 +++-------------- .../state/core/command_palette/search.rs | 26 +- src/input/state/core/command_palette/state.rs | 260 +++++++++++++++++- src/input/state/core/help_overlay/tests.rs | 2 +- src/input/state/core/modal.rs | 4 +- src/input/state/core/status_hud.rs | 2 +- src/input/state/core/utility/interaction.rs | 2 +- src/input/state/tests/status_hud.rs | 4 +- src/input/state/text_resources/tests.rs | 2 +- src/ui/command_palette.rs | 6 +- src/ui/command_palette/tests/engine.rs | 2 +- tests/ui.rs | 6 +- 23 files changed, 327 insertions(+), 270 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 753966396..8acb4a6bb 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -239,7 +239,7 @@ fn advance_post_dispatch_state( fn persist_post_dispatch_state(state: &mut WaylandState) { if state.input_state.command_palette_recents_dirty() { - let recents = state.input_state.command_palette.recent.clone(); + let recents = state.input_state.command_palette.recents().to_vec(); if state.preferences.palette_recents_mut().request(&recents) { state.input_state.clear_command_palette_recents_dirty(); } diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index d24e1396d..b27b730cc 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -529,7 +529,7 @@ impl WaylandState { _ => {} } } - if self.input_state.command_palette.open && matches!(key, Key::Up | Key::Down) { + if self.input_state.command_palette.is_open() && matches!(key, Key::Up | Key::Down) { return; } let modal_capture = self.input_state.modal_owns_text_input(); diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index b834d78cf..70c5fe052 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -160,7 +160,7 @@ impl WaylandState { // Handle command palette scrolling (display-row space; selection is // kept inside the window, skipping group headers). - if self.input_state.command_palette.open { + if self.input_state.command_palette.is_open() { if scroll_direction != 0 { self.input_state .command_palette_wheel_scroll(scroll_direction); diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index dde91e23d..99cf22fb2 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -279,7 +279,7 @@ impl WaylandState { { return Some(hint.icon()); } - if self.input_state.command_palette.open + if self.input_state.command_palette.is_open() && let Some(hint) = self.input_state.command_palette_cursor_hint_at( mx, my, diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 537919acd..357fe2c1d 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -228,7 +228,7 @@ impl WaylandState { let previous = self.pointer.position(); let next = (sx as i32, sy as i32); self.pointer.set_position(next); - if self.input_state.command_palette.open { + if self.input_state.command_palette.is_open() { let (wx, wy) = self.zoomed_world_coords(sx, sy); self.input_state .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index 2d04a903a..d1cdaf0c9 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -51,7 +51,7 @@ impl WaylandState { } // Block pointer input when modal overlays are active - if self.input_state.command_palette.open || self.input_state.tour.is_active() { + if self.input_state.command_palette.is_open() || self.input_state.tour.is_active() { // For command palette, press handles the click - release is a no-op self.pointer.clear_chrome_press(); return; diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index fea93f230..d9b3b2468 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -253,7 +253,7 @@ impl WaylandState { ); return Some(target); } - if !self.input_state.command_palette.open { + if !self.input_state.command_palette.is_open() { return None; } if self @@ -429,7 +429,7 @@ impl WaylandState { } if self.input_state.help_overlay.is_visible() - || self.input_state.command_palette.open + || self.input_state.command_palette.is_open() || self.input_state.tour.is_active() { return; @@ -507,7 +507,7 @@ impl WaylandState { self.pointer.clear_chrome_press(); return true; } - if !self.input_state.command_palette.open && !self.input_state.tour.is_active() { + if !self.input_state.command_palette.is_open() && !self.input_state.tour.is_active() { return false; } self.pointer.clear_chrome_press(); diff --git a/src/backend/wayland/state/boards.rs b/src/backend/wayland/state/boards.rs index ac5d7374d..43937f6b1 100644 --- a/src/backend/wayland/state/boards.rs +++ b/src/backend/wayland/state/boards.rs @@ -56,7 +56,7 @@ impl WaylandState { && !self.input_state.board_is_transparent() && !self.zoom.active && !self.input_state.tour.is_active() - && !self.input_state.command_palette.open + && !self.input_state.command_palette.is_open() && !self.input_state.is_board_picker_open() && !self.input_state.is_color_picker_popup_open() && !self.input_state.is_context_menu_open() @@ -97,7 +97,7 @@ impl WaylandState { && !self.zoom.active && !self.input_state.tour.is_active() && !self.input_state.help_overlay.is_visible() - && !self.input_state.command_palette.open + && !self.input_state.command_palette.is_open() && !self.input_state.is_board_picker_open() && !self.input_state.is_color_picker_popup_open() && !self.input_state.is_context_menu_open() diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index fec912da8..ee48582e2 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -216,7 +216,7 @@ impl WaylandState { } if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() - || self.input_state.command_palette.open + || self.input_state.command_palette.is_open() || self.input_state.tour.is_active() { return; @@ -281,7 +281,7 @@ impl WaylandState { } if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() - || self.input_state.command_palette.open + || self.input_state.command_palette.is_open() || self.input_state.tour.is_active() { return; diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 0a47dfb8c..e6420c088 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -218,7 +218,7 @@ impl WaylandState { } !first_run_card_hidden_by_ui_state( self.input_state.presenter_mode_active(), - self.input_state.command_palette.open, + self.input_state.command_palette.is_open(), self.input_state.help_overlay.is_visible(), self.input_state.is_radial_menu_open(), self.input_state.is_context_menu_open(), diff --git a/src/backend/wayland/state/toolbar/inline/focus.rs b/src/backend/wayland/state/toolbar/inline/focus.rs index 4e3bc1e42..9645746fd 100644 --- a/src/backend/wayland/state/toolbar/inline/focus.rs +++ b/src/backend/wayland/state/toolbar/inline/focus.rs @@ -56,7 +56,7 @@ impl WaylandState { key, self.toolbar.is_visible(), matches!(self.input_state.state, DrawingState::TextInput { .. }), - self.input_state.command_palette.open, + self.input_state.command_palette.is_open(), ) { return false; } diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index 32d4db57d..b66345c17 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -1,7 +1,7 @@ use super::super::base::{ InputState, KeybindingEditOperation, KeybindingEditRequest, Toast, ToastPriority, }; -use super::search::{CommandPaletteListRow, command_palette_display_index}; +use super::search::CommandPaletteListRow; use super::{ CommandPaletteCursorHint, layout::{CommandPaletteGeometry, CommandPaletteRowAction}, @@ -13,9 +13,6 @@ use crate::input::events::Key; use crate::input::state::actions::key_press::bindings::key_to_action_label; use std::time::{Duration, Instant}; -const COMMAND_PALETTE_REPEAT_INITIAL_DELAY: Duration = Duration::from_millis(280); -const COMMAND_PALETTE_REPEAT_INTERVAL: Duration = Duration::from_millis(55); - impl InputState { pub fn keybinding_capture_action(&self) -> Option { self.keymap.capture_action() @@ -102,7 +99,7 @@ impl InputState { ); return false; }; - self.command_palette.open = false; + self.command_palette.close(); self.clear_command_palette_repeat(); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -112,14 +109,11 @@ impl InputState { fn open_command_palette_internal(&mut self, track_usage: bool) { self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::CommandPalette); - self.command_palette.open = true; + self.command_palette.open(); self.clear_command_palette_repeat(); if track_usage { self.pending_onboarding_usage.used_command_palette = true; } - self.command_palette.query.clear(); - self.command_palette.selected = 0; - self.command_palette.scroll = 0; self.dirty_tracker.mark_full(); self.needs_redraw = true; } @@ -127,7 +121,7 @@ impl InputState { /// Toggle the command palette visibility. pub(crate) fn toggle_command_palette(&mut self) { if self.command_palette.open { - self.command_palette.open = false; + self.command_palette.close(); self.clear_command_palette_repeat(); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -165,7 +159,7 @@ impl InputState { true } Key::Escape => { - self.command_palette.open = false; + self.command_palette.close(); self.clear_command_palette_repeat(); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -173,7 +167,7 @@ impl InputState { } Key::Return => { if let Some(command) = self.selected_command() { - self.command_palette.open = false; + self.command_palette.close(); self.clear_command_palette_repeat(); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -192,44 +186,21 @@ impl InputState { self.move_command_palette_selection(Key::Down); true } - Key::Home => { - self.clear_command_palette_repeat(); - if self.command_palette.selected != 0 || self.command_palette.scroll != 0 { - self.command_palette.selected = 0; - self.command_palette.scroll = 0; - self.needs_redraw = true; - } - true - } - Key::End => { + Key::Home | Key::End => { self.clear_command_palette_repeat(); - let filtered = self.filtered_commands(); - if let Some(last_index) = filtered.len().checked_sub(1) - && self.command_palette.selected != last_index - { - self.command_palette.selected = last_index; - // Scroll is in display-row space (headers included). - self.command_palette.scroll = self - .command_palette_rows() - .len() - .saturating_sub(self.command_palette_row_capacity()); - self.needs_redraw = true; - } + self.move_command_palette_selection(key); true } Key::Backspace if self.modifiers.ctrl => { - self.delete_previous_command_palette_word(); + self.needs_redraw |= self.command_palette.delete_previous_word(); true } Key::Backspace => { - if !self.command_palette.query.is_empty() { - self.command_palette.query.pop(); - self.mark_command_palette_query_changed(); - } + self.needs_redraw |= self.command_palette.backspace(); true } Key::Char('u' | 'U') if self.modifiers.ctrl => { - self.clear_command_palette_query(); + self.needs_redraw |= self.command_palette.set_query(""); true } // Shift is checked first: the configurator route and the in-place @@ -260,13 +231,13 @@ impl InputState { true } Key::Char(ch) if !self.modifiers.ctrl && !ch.is_control() => { - self.command_palette.query.push(ch); - self.mark_command_palette_query_changed(); + self.command_palette.append(ch); + self.needs_redraw = true; true } Key::Space if !self.modifiers.ctrl => { - self.command_palette.query.push(' '); - self.mark_command_palette_query_changed(); + self.command_palette.append(' '); + self.needs_redraw = true; true } _ => true, // Consume all other keys while palette is open @@ -306,190 +277,44 @@ impl InputState { } fn start_command_palette_repeat(&mut self, key: Key) { - self.command_palette.repeat.start( - key, - Instant::now(), - COMMAND_PALETTE_REPEAT_INITIAL_DELAY, - ); + self.command_palette.start_repeat(key, Instant::now()); } - pub(crate) fn clear_command_palette_repeat(&mut self) { - self.command_palette.repeat.clear(); + self.command_palette.clear_repeat(); } - pub(crate) fn reconcile_command_palette_scroll(&mut self) { let rows = self.command_palette_rows(); let capacity = self.command_palette_row_capacity(); - let selected = command_palette_display_index(&rows, self.command_palette.selected); - self.command_palette.scroll = self - .command_palette - .scroll - .min(rows.len().saturating_sub(capacity)) - .min(selected); - if selected >= self.command_palette.scroll + capacity { - self.command_palette.scroll = selected + 1 - capacity; - } + self.command_palette.reconcile_scroll(&rows, capacity); } - fn move_command_palette_selection(&mut self, key: Key) -> bool { - match key { - Key::Up => { - if self.command_palette.selected == 0 { - return false; - } - self.command_palette.selected -= 1; - let rows = self.command_palette_rows(); - let display_index = - command_palette_display_index(&rows, self.command_palette.selected); - // Keep the group header visible when the selection sits - // directly beneath it. - let target_top = if display_index > 0 - && matches!( - rows.get(display_index - 1), - Some(CommandPaletteListRow::Header(_)) - ) { - display_index - 1 - } else { - display_index - }; - if target_top < self.command_palette.scroll { - self.command_palette.scroll = target_top; - } - } - Key::Down => { - let filtered = self.filtered_commands(); - if self.command_palette.selected + 1 >= filtered.len() { - return false; - } - self.command_palette.selected += 1; - let rows = self.command_palette_rows(); - let display_index = - command_palette_display_index(&rows, self.command_palette.selected); - if display_index - >= self.command_palette.scroll + self.command_palette_row_capacity() - { - self.command_palette.scroll = - display_index - self.command_palette_row_capacity() + 1; - } - } - _ => return false, - } - self.reconcile_command_palette_scroll(); - self.needs_redraw = true; - true + let rows = self.command_palette_rows(); + let capacity = self.command_palette_row_capacity(); + let changed = self.command_palette.navigate(key, &rows, capacity); + self.needs_redraw |= changed; + changed } - - /// Scroll the palette list by one display row (mouse wheel). Keeps the - /// selection inside the visible window, skipping header rows. pub fn command_palette_wheel_scroll(&mut self, direction: i32) { - if direction == 0 || !self.command_palette.open { - return; - } let rows = self.command_palette_rows(); - let max_scroll = rows - .len() - .saturating_sub(self.command_palette_row_capacity()); - if direction > 0 { - if self.command_palette.scroll >= max_scroll { - return; - } - self.command_palette.scroll += 1; - } else { - if self.command_palette.scroll == 0 { - return; - } - self.command_palette.scroll -= 1; - } - - let window_start = self.command_palette.scroll; - let window_end = window_start + self.command_palette_row_capacity(); - let selected_display = command_palette_display_index(&rows, self.command_palette.selected); - if selected_display < window_start { - if let Some(command_index) = rows[window_start..window_end.min(rows.len())] - .iter() - .find_map(|row| row.command_index()) - { - self.command_palette.selected = command_index; - } - } else if selected_display >= window_end - && let Some(command_index) = rows[window_start..window_end.min(rows.len())] - .iter() - .rev() - .find_map(|row| row.command_index()) - { - self.command_palette.selected = command_index; - } - self.needs_redraw = true; + let capacity = self.command_palette_row_capacity(); + self.needs_redraw |= self + .command_palette + .wheel_scroll(direction, &rows, capacity); } - pub(crate) fn release_command_palette_repeat_key(&mut self, key: Key) { - self.command_palette.repeat.release(key); + self.command_palette.release_repeat(key); } - pub(crate) fn command_palette_repeat_timeout(&self, now: Instant) -> Option { - if !self.command_palette.open { - return None; - } - self.command_palette.repeat.timeout(now) + self.command_palette.repeat_timeout(now) } - pub(crate) fn tick_command_palette_repeat(&mut self, now: Instant) -> bool { - if !self.command_palette.open { - self.clear_command_palette_repeat(); - return false; - } - let Some(key) = self.command_palette.repeat.due_key(now) else { - return false; - }; - - let changed = self.move_command_palette_selection(key); - self.command_palette - .repeat - .schedule_fixed(now, COMMAND_PALETTE_REPEAT_INTERVAL); + let rows = self.command_palette_rows(); + let capacity = self.command_palette_row_capacity(); + let changed = self.command_palette.tick_repeat(now, &rows, capacity); + self.needs_redraw |= changed; changed } - fn mark_command_palette_query_changed(&mut self) { - self.command_palette.selected = 0; - self.command_palette.scroll = 0; - self.needs_redraw = true; - } - - fn clear_command_palette_query(&mut self) { - if self.command_palette.query.is_empty() { - return; - } - self.command_palette.query.clear(); - self.mark_command_palette_query_changed(); - } - - fn delete_previous_command_palette_word(&mut self) { - if self.command_palette.query.is_empty() { - return; - } - - while self - .command_palette - .query - .chars() - .last() - .is_some_and(command_palette_token_separator) - { - self.command_palette.query.pop(); - } - while self - .command_palette - .query - .chars() - .last() - .is_some_and(|ch| !command_palette_token_separator(ch)) - { - self.command_palette.query.pop(); - } - - self.mark_command_palette_query_changed(); - } - /// Handle a mouse click while the command palette is open. /// Returns true if the click was handled (either on an item or to close the palette). pub fn handle_command_palette_click( @@ -536,7 +361,7 @@ impl InputState { // Check if click is outside palette bounds - close it. if !geometry.contains_local(local_x, local_y) { - self.command_palette.open = false; + self.command_palette.close(); self.dirty_tracker.mark_full(); self.needs_redraw = true; return true; @@ -557,7 +382,7 @@ impl InputState { return true; }; let (command, actual_index) = command_entry; - self.command_palette.selected = actual_index; + self.command_palette.select_command(actual_index); if let Some((_, row_action)) = geometry.row_action_at(local_x, local_y) && default_keybindings() @@ -586,7 +411,7 @@ impl InputState { } // Execute the command. - self.command_palette.open = false; + self.command_palette.close(); self.dirty_tracker.mark_full(); self.needs_redraw = true; self.record_command_palette_action(command.action); @@ -659,10 +484,6 @@ impl InputState { } } -fn command_palette_token_separator(ch: char) -> bool { - ch.is_whitespace() || ch == '+' || ch == '/' -} - fn command_palette_cursor_hint_from_local( geometry: CommandPaletteGeometry, rows: &[CommandPaletteListRow], diff --git a/src/input/state/core/command_palette/search.rs b/src/input/state/core/command_palette/search.rs index fe1663b47..9c5f93b02 100644 --- a/src/input/state/core/command_palette/search.rs +++ b/src/input/state/core/command_palette/search.rs @@ -38,10 +38,10 @@ impl CommandPaletteListRow { /// from are unchanged. #[derive(Debug)] pub(in crate::input::state::core) struct CommandPaletteResults { - query: String, - recents: Vec, - keymap_revision: u64, - results: Vec<&'static CommandEntry>, + pub(super) query: String, + pub(super) recents: Vec, + pub(super) keymap_revision: u64, + pub(super) results: Vec<&'static CommandEntry>, } impl InputState { @@ -53,20 +53,12 @@ impl InputState { /// the query, the recents that bias it, and the keymap revision behind the /// shortcut labels it folds in. pub fn filtered_commands(&self) -> Vec<&'static CommandEntry> { - if let Some(cached) = self.command_palette.results.borrow().as_ref() - && cached.query == self.command_palette.query - && cached.keymap_revision == self.keymap.revision() - && cached.recents == self.command_palette.recent - { - return cached.results.clone(); + if let Some(results) = self.command_palette.cached_results(self.keymap.revision()) { + return results; } let results = self.score_filtered_commands(); - *self.command_palette.results.borrow_mut() = Some(CommandPaletteResults { - query: self.command_palette.query.clone(), - recents: self.command_palette.recent.clone(), - keymap_revision: self.keymap.revision(), - results: results.clone(), - }); + self.command_palette + .cache_results(self.keymap.revision(), results.clone()); results } @@ -185,7 +177,7 @@ impl InputState { /// backend can retain the pending write when persistence fails and only /// [`Self::clear_command_palette_recents_dirty`] once the write succeeds. pub fn command_palette_recents_dirty(&self) -> bool { - self.command_palette.recents_dirty + self.command_palette.recents_dirty() } /// Clear the pending-persist flag after the recents were durably written. diff --git a/src/input/state/core/command_palette/state.rs b/src/input/state/core/command_palette/state.rs index 82ed25739..375e5ac4d 100644 --- a/src/input/state/core/command_palette/state.rs +++ b/src/input/state/core/command_palette/state.rs @@ -6,17 +6,108 @@ use std::cell::RefCell; /// Mutable state and memoized search results owned by the command palette. pub struct CommandPaletteState { - pub open: bool, - pub query: String, - pub selected: usize, - pub scroll: usize, - pub(crate) repeat: OverlayKeyRepeat, - pub(crate) recent: Vec, - pub(crate) recents_dirty: bool, - pub(in crate::input::state::core) results: RefCell>, + pub(super) open: bool, + pub(super) query: String, + pub(super) selected: usize, + pub(super) scroll: usize, + repeat: OverlayKeyRepeat, + pub(super) recent: Vec, + recents_dirty: bool, + results: RefCell>, } impl CommandPaletteState { + pub fn is_open(&self) -> bool { + self.open + } + pub fn query(&self) -> &str { + &self.query + } + pub fn selected(&self) -> usize { + self.selected + } + pub fn scroll(&self) -> usize { + self.scroll + } + pub(crate) fn recents(&self) -> &[Action] { + &self.recent + } + pub fn open(&mut self) { + self.open = true; + self.query.clear(); + self.query_changed(); + self.repeat.clear(); + } + pub fn close(&mut self) { + self.open = false; + self.repeat.clear(); + } + pub fn set_query(&mut self, query: impl Into) -> bool { + let query = query.into(); + if self.query == query { + return false; + } + self.query = query; + self.query_changed(); + true + } + fn query_changed(&mut self) { + self.selected = 0; + self.scroll = 0; + self.results.borrow_mut().take(); + } + pub(super) fn append(&mut self, ch: char) { + self.query.push(ch); + self.query_changed(); + } + pub(super) fn backspace(&mut self) -> bool { + if self.query.pop().is_none() { + return false; + } + self.query_changed(); + true + } + pub(super) fn delete_previous_word(&mut self) -> bool { + if self.query.is_empty() { + return false; + } + let separator = |ch: char| ch.is_whitespace() || ch == '+' || ch == '/'; + while self.query.chars().last().is_some_and(separator) { + self.query.pop(); + } + while self.query.chars().last().is_some_and(|ch| !separator(ch)) { + self.query.pop(); + } + self.query_changed(); + true + } + + pub(super) fn recents_dirty(&self) -> bool { + self.recents_dirty + } + pub(super) fn cached_results( + &self, + revision: u64, + ) -> Option> { + let cached = self.results.borrow(); + let cached = cached.as_ref()?; + (cached.query == self.query + && cached.keymap_revision == revision + && cached.recents == self.recent) + .then(|| cached.results.clone()) + } + pub(super) fn cache_results(&self, revision: u64, results: Vec<&'static super::CommandEntry>) { + *self.results.borrow_mut() = Some(CommandPaletteResults { + query: self.query.clone(), + recents: self.recent.clone(), + keymap_revision: revision, + results, + }); + } + pub(super) fn select_command(&mut self, index: usize) { + self.selected = index; + } + pub(super) fn recent_bonus(&self, action: Action) -> i32 { self.recent .iter() @@ -30,10 +121,12 @@ impl CommandPaletteState { self.recent.retain(|recent| *recent != action); self.recent.insert(0, action); self.recent.truncate(PALETTE_RECENTS_CAP); + self.results.borrow_mut().take(); self.recents_dirty = true; } pub(super) fn set_recents(&mut self, recents: Vec) { + self.results.borrow_mut().take(); self.recent = recents; self.recent.truncate(PALETTE_RECENTS_CAP); self.recents_dirty = false; @@ -62,3 +155,154 @@ impl Default for CommandPaletteState { } } } + +impl CommandPaletteState { + pub(super) fn reconcile_scroll( + &mut self, + rows: &[super::CommandPaletteListRow], + capacity: usize, + ) { + let capacity = capacity.max(1); + let selected = super::search::command_palette_display_index(rows, self.selected); + self.scroll = self + .scroll + .min(rows.len().saturating_sub(capacity)) + .min(selected); + if selected >= self.scroll + capacity { + self.scroll = selected + 1 - capacity; + } + } + + pub(super) fn navigate( + &mut self, + key: crate::input::Key, + rows: &[super::CommandPaletteListRow], + capacity: usize, + ) -> bool { + use crate::input::Key; + let Some(last) = rows.iter().rev().find_map(|row| row.command_index()) else { + return false; + }; + let before = (self.selected, self.scroll); + match key { + Key::Up => self.selected = self.selected.saturating_sub(1), + Key::Down => self.selected = (self.selected + 1).min(last), + Key::Home => { + self.selected = 0; + self.scroll = 0; + } + Key::End => { + self.selected = last; + self.scroll = rows.len().saturating_sub(capacity); + } + _ => return false, + } + let display = super::search::command_palette_display_index(rows, self.selected); + if matches!(key, Key::Up) + && capacity > 1 + && display > 0 + && matches!(rows[display - 1], super::CommandPaletteListRow::Header(_)) + { + self.scroll = self.scroll.min(display - 1); + } + self.reconcile_scroll(rows, capacity); + before != (self.selected, self.scroll) + } + + pub(super) fn wheel_scroll( + &mut self, + direction: i32, + rows: &[super::CommandPaletteListRow], + capacity: usize, + ) -> bool { + if direction == 0 || !self.open { + return false; + } + let capacity = capacity.max(1); + let next = if direction > 0 { + (self.scroll + 1).min(rows.len().saturating_sub(capacity)) + } else { + self.scroll.saturating_sub(1) + }; + if next == self.scroll { + return false; + } + self.scroll = next; + let end = (next + capacity).min(rows.len()); + let selected = super::search::command_palette_display_index(rows, self.selected); + if selected < next { + if let Some(index) = rows[next..end].iter().find_map(|row| row.command_index()) { + self.selected = index; + } + } else if selected >= end + && let Some(index) = rows[next..end] + .iter() + .rev() + .find_map(|row| row.command_index()) + { + self.selected = index; + } + // At one row, skip an isolated group header to keep a command reachable. + if capacity == 1 + && matches!( + rows.get(self.scroll), + Some(super::CommandPaletteListRow::Header(_)) + ) + { + if direction > 0 { + if let Some((offset, index)) = rows[self.scroll..] + .iter() + .enumerate() + .find_map(|(i, row)| row.command_index().map(|index| (i, index))) + { + self.scroll += offset; + self.selected = index; + } + } else if let Some((index, command)) = rows[..self.scroll] + .iter() + .enumerate() + .rev() + .find_map(|(i, row)| row.command_index().map(|index| (i, index))) + { + self.scroll = index; + self.selected = command; + } + } + true + } + pub(super) fn start_repeat(&mut self, key: crate::input::Key, now: std::time::Instant) { + self.repeat + .start(key, now, std::time::Duration::from_millis(280)); + } + pub(crate) fn clear_repeat(&mut self) { + self.repeat.clear(); + } + pub(crate) fn release_repeat(&mut self, key: crate::input::Key) { + self.repeat.release(key); + } + pub(super) fn repeat_timeout(&self, now: std::time::Instant) -> Option { + if self.open { + self.repeat.timeout(now) + } else { + None + } + } + pub(super) fn tick_repeat( + &mut self, + now: std::time::Instant, + rows: &[super::CommandPaletteListRow], + capacity: usize, + ) -> bool { + if !self.open { + self.repeat.clear(); + return false; + } + let Some(key) = self.repeat.due_key(now) else { + return false; + }; + let changed = self.navigate(key, rows, capacity); + self.repeat + .schedule_fixed(now, std::time::Duration::from_millis(55)); + changed + } +} diff --git a/src/input/state/core/help_overlay/tests.rs b/src/input/state/core/help_overlay/tests.rs index 1e23da957..191af1410 100644 --- a/src/input/state/core/help_overlay/tests.rs +++ b/src/input/state/core/help_overlay/tests.rs @@ -421,7 +421,7 @@ fn opening_the_command_palette_routes_help_close_through_the_canonical_closer() state.toggle_command_palette(); assert!(!state.help_overlay.visible); - assert!(state.command_palette.open); + assert!(state.command_palette.is_open()); assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); } diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 40adcb284..9936d66df 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -96,7 +96,7 @@ impl InputState { pub(crate) fn modal_is_open(&self, surface: ModalSurface) -> bool { match surface { ModalSurface::Tour => self.tour.is_active(), - ModalSurface::CommandPalette => self.command_palette.open, + ModalSurface::CommandPalette => self.command_palette.is_open(), ModalSurface::HelpOverlay => self.help_overlay.visible, ModalSurface::RadialMenu => self.is_radial_menu_open(), ModalSurface::PrecisionEntry => self.is_precision_entry_open(), @@ -134,7 +134,7 @@ impl InputState { // hidden. ModalSurface::Tour => self.end_tour(), ModalSurface::CommandPalette => { - self.command_palette.open = false; + self.command_palette.close(); self.clear_command_palette_repeat(); self.dirty_tracker.mark_full(); self.needs_redraw = true; diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index ea13c9550..442cbeed5 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -237,7 +237,7 @@ impl InputState { || self.is_board_picker_open() || self.is_properties_panel_open() || self.is_context_menu_open() - || self.command_palette.open + || self.command_palette.is_open() || self.tour.is_active() } diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index b0079a1b2..0275cedc1 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -132,7 +132,7 @@ impl InputState { /// screen dimensions from the display server. pub fn update_screen_dimensions(&mut self, width: u32, height: u32) { self.view.set_screen_dimensions(width, height); - if self.command_palette.open { + if self.command_palette.is_open() { self.reconcile_command_palette_scroll(); } // A surface resize is painted with full damage by the backend. Make diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index 2eafaa563..9916b5cea 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -576,9 +576,9 @@ fn status_hud_ignored_while_other_eclipsing_overlays_are_open() { // Command palette and tour (belt-and-braces: the backend intercepts // these earlier for pointer/touch, but direct routing paths do not). - input.command_palette.open = true; + input.command_palette.open(); assert!(!input.status_hud_contains(x, y)); - input.command_palette.open = false; + input.command_palette.close(); input.tour.active = true; assert!(!input.status_hud_contains(x, y)); diff --git a/src/input/state/text_resources/tests.rs b/src/input/state/text_resources/tests.rs index 26056b241..7b66e9d9c 100644 --- a/src/input/state/text_resources/tests.rs +++ b/src/input/state/text_resources/tests.rs @@ -168,7 +168,7 @@ fn explicit_palette_enter_and_menu_command_preserve_text_undo_and_selection() { assert!(state.selected_shape_ids().is_empty()); state.toggle_command_palette(); - state.command_palette.query = "undo".into(); + state.command_palette.set_query("undo"); state.on_key_press_with_resources(resources, Key::Return); assert!(!state.command_palette_is_engaged()); assert_restored(&state, id, &original, &measurer); diff --git a/src/ui/command_palette.rs b/src/ui/command_palette.rs index 97c89cab3..d7bcba2bf 100644 --- a/src/ui/command_palette.rs +++ b/src/ui/command_palette.rs @@ -113,12 +113,12 @@ impl CommandPaletteView { .command_palette_action_tooltip_for_layout(&rows, geometry) .map(|(text, x, y)| (text.to_string(), x, y)); Self::List(PaletteListView { - query: state.command_palette.query.clone(), + query: state.command_palette.query().to_string(), rows, geometry: (geometry.x, geometry.y, geometry.width, geometry.height), - scroll: state.command_palette.scroll, + scroll: state.command_palette.scroll(), visible_count: geometry.visible_count, - selected: state.command_palette.selected, + selected: state.command_palette.selected(), bindings, tooltip, }) diff --git a/src/ui/command_palette/tests/engine.rs b/src/ui/command_palette/tests/engine.rs index a7a631730..40fe658d5 100644 --- a/src/ui/command_palette/tests/engine.rs +++ b/src/ui/command_palette/tests/engine.rs @@ -16,7 +16,7 @@ fn retained_palette_engine_matches_public_paint_and_geometry_across_targets() { let mut input = crate::input::state::test_support::make_test_input_state(); input.toggle_command_palette(); for query in ["", "capture", "No match 測試 🖌️"] { - input.command_palette.query = query.into(); + input.command_palette.set_query(query); for density in [1, 2, 1] { let bounds = command_palette_visual_geometry_with_engine(&engine, &input, 800, 600).unwrap(); diff --git a/tests/ui.rs b/tests/ui.rs index 38c639a12..567541fd7 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -130,8 +130,8 @@ fn render_command_palette_with_query_draws_content() { // and draw pixels (highlight boxes + rows). let (mut surface, ctx) = surface_with_context(900, 700); let mut input = make_input_state(); - input.command_palette.open = true; - input.command_palette.query = "tool".to_string(); + input.command_palette.open(); + input.command_palette.set_query("tool".to_string()); wayscriber::ui::render_command_palette(&ctx, &input, 900, 700); @@ -378,7 +378,7 @@ fn small_palette_surface_and_shadow_fit_the_screen() { for (width, height) in [(800, 480), (1024, 600), (1280, 720)] { let mut input = make_input_state(); input.update_screen_dimensions(width, height); - input.command_palette.open = true; + input.command_palette.open(); let (x, y, panel_width, panel_height) = wayscriber::ui::command_palette_visual_geometry(&input, width, height).unwrap(); assert!(x >= 0.0 && y >= 0.0); From 333ca1bf1da600ec17596179de78db45c3232c38 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:19:00 +0200 Subject: [PATCH 12/28] refactor(capture): derive portal lifecycle from one owned operation --- src/backend/wayland/frozen/capture.rs | 3 +- src/backend/wayland/frozen/portal.rs | 119 +++++++++++--------------- src/backend/wayland/frozen/state.rs | 22 ++--- src/backend/wayland/portal_task.rs | 36 ++++++++ src/backend/wayland/zoom/capture.rs | 4 +- src/backend/wayland/zoom/portal.rs | 93 ++++++++------------ src/backend/wayland/zoom/state.rs | 26 ++---- 7 files changed, 142 insertions(+), 161 deletions(-) diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index b237c2b3e..487fb4f34 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -84,7 +84,8 @@ impl CaptureSession { impl FrozenState { /// Start a screencopy capture for the active output. pub fn start_capture(&mut self) -> Result<()> { - if self.direct_capture.is_some() || self.portal_in_progress || self.preflight.is_pending() { + if self.direct_capture.is_some() || self.portal.is_running() || self.preflight.is_pending() + { warn!("Frozen-mode capture already in progress; ignoring toggle"); return Ok(()); } diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index de0fa02fc..7304b2546 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -21,7 +21,7 @@ impl FrozenState { &mut self, tokio_handle: &tokio::runtime::Handle, ) -> Result<()> { - if self.portal_in_progress { + if self.portal.is_running() { warn!("Portal capture already running; ignoring new request"); return Ok(()); } @@ -36,8 +36,6 @@ impl FrozenState { "portal freeze capture", ) .map_err(anyhow::Error::msg)?; - self.portal_in_progress = true; - self.portal_target_output_id = Some(target_output_id); let layout_generation = self.output_layout_generation; // Notify user that portal fallback is in progress @@ -47,43 +45,41 @@ impl FrozenState { "Requesting screen capture...".to_string(), Some("camera-photo".to_string()), ); - self.portal_task = Some(PortalTask::spawn(tokio_handle, runtime_wake, async move { - async { - let bytes = capture_via_portal_fullscreen_bytes().await?; - - let (data, width, height) = decode_image_to_argb(&bytes) - .map_err(|error| CaptureError::ImageError(format!("Decode failed: {error}")))?; - - Ok(( - Some(target_output_id), - layout_generation, - Some(source_geometry), - FrozenImage { - width, - height, - stride: (width * 4) as i32, - data, - }, - )) - } - .await - })); + self.portal + .start(PortalTask::spawn(tokio_handle, runtime_wake, async move { + async { + let bytes = capture_via_portal_fullscreen_bytes().await?; + + let (data, width, height) = decode_image_to_argb(&bytes).map_err(|error| { + CaptureError::ImageError(format!("Decode failed: {error}")) + })?; + + Ok(( + Some(target_output_id), + layout_generation, + Some(source_geometry), + FrozenImage { + width, + height, + stride: (width * 4) as i32, + data, + }, + )) + } + .await + })); Ok(()) } /// Check for completed portal capture and apply result if present. pub fn poll_portal_capture(&mut self, input_state: &mut InputState, now: Instant) { - if !self.portal_in_progress { + if !self.portal.is_running() { return; } // Timeout safeguard to avoid overlay staying hidden forever - if self - .portal_task - .as_ref() - .is_some_and(|task| task.timed_out(now)) - { + if self.portal.timed_out(now) { warn!("Portal frozen capture timed out; restoring overlay"); if self.has_acquisition_attempt() { self.finish_acquisition( @@ -105,11 +101,7 @@ impl FrozenState { return; } - let poll = self - .portal_task - .as_mut() - .map(PortalTask::poll) - .unwrap_or(PortalPoll::Disconnected); + let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, source_geometry, image))) => { let output_matches = portal_output_matches(target_output, self.active_output_id); @@ -208,13 +200,11 @@ impl FrozenState { } pub fn portal_timeout(&self, now: Instant) -> Option { - self.portal_task.as_ref().map(|task| task.timeout(now)) + self.portal.timeout(now) } fn finish_portal_task(&mut self) { - self.portal_in_progress = false; - self.portal_task.take(); - self.portal_target_output_id = None; + self.portal.finish(); } } @@ -261,7 +251,7 @@ mod tests { ) -> anyhow::Result<()> { for _ in 0..100 { frozen.poll_portal_capture(input, Instant::now()); - if !frozen.portal_in_progress { + if !frozen.portal.is_running() { return Ok(()); } tokio::task::yield_now().await; @@ -278,16 +268,16 @@ mod tests { .capture_via_portal(&tokio::runtime::Handle::current()) .expect_err("missing geometry must fail closed"); assert!(error.to_string().contains("geometry is unavailable")); - assert!(!frozen.portal_in_progress); - assert!(frozen.portal_task.is_none()); + assert!(!frozen.portal.is_running()); + assert!(!frozen.portal.is_running()); frozen.set_active_geometry(Some(crop_geometry((0, 0)))); let error = frozen .capture_via_portal(&tokio::runtime::Handle::current()) .expect_err("missing output identity must fail closed"); assert!(error.to_string().contains("identity is unavailable")); - assert!(!frozen.portal_in_progress); - assert!(frozen.portal_task.is_none()); + assert!(!frozen.portal.is_running()); + assert!(!frozen.portal.is_running()); Ok(()) } @@ -298,18 +288,17 @@ mod tests { let mut input = make_test_input_state(); frozen.set_active_output(None, Some(1)); - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { Ok((Some(1), 0, Some(crop_geometry((0, 0))), image(0))) }, )); - frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; assert!(!input.frozen_active()); assert!(frozen.has_pending_image()); - assert!(!frozen.portal_in_progress); - assert!(frozen.portal_task.is_none()); + assert!(!frozen.portal.is_running()); + assert!(!frozen.portal.is_running()); assert!(!frozen.take_capture_done()); frozen @@ -328,7 +317,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); - frozen.portal_task = Some(if panic_task { + frozen.portal.start(if panic_task { PortalTask::spawn(&tokio::runtime::Handle::current(), wake.handle(), async { panic!("expected frozen portal panic") }) @@ -337,12 +326,11 @@ mod tests { Err(CaptureError::PermissionDenied) }) }); - frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; assert!(!frozen.is_in_progress()); - assert!(frozen.portal_task.is_none()); + assert!(!frozen.portal.is_running()); assert!(frozen.take_capture_done()); assert!(!input.frozen_active()); } @@ -357,7 +345,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); - frozen.portal_task = Some(if timed_out { + frozen.portal.start(if timed_out { PortalTask::spawn_at_for_test( &tokio::runtime::Handle::current(), wake.handle(), @@ -369,12 +357,11 @@ mod tests { } else { PortalTask::disconnected_for_test(now) }); - frozen.portal_in_progress = true; frozen.poll_portal_capture(&mut input, now); assert!(!frozen.is_in_progress()); - assert!(frozen.portal_task.is_none()); + assert!(!frozen.portal.is_running()); assert!(frozen.take_capture_done()); assert!(!input.frozen_active()); } @@ -386,7 +373,7 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { @@ -395,7 +382,6 @@ mod tests { )) }, )); - frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; @@ -416,7 +402,7 @@ mod tests { .request(ScreenAcquisitionOwner::UserFreeze) .expect("id"); frozen.start_capture_for(id, ScreenAcquisitionOwner::UserFreeze)?; - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { @@ -425,7 +411,6 @@ mod tests { )) }, )); - frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; @@ -448,12 +433,11 @@ mod tests { let mut input = make_test_input_state(); input.set_frozen_active(true); frozen.set_active_output(None, Some(2)); - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { Ok((Some(1), 0, None, image(9))) }, )); - frozen.portal_in_progress = true; poll_until_finished(&mut frozen, &mut input).await?; @@ -471,12 +455,11 @@ mod tests { let mut input = make_test_input_state(); frozen.set_active_geometry(Some(crop_geometry((0, 0)))); let layout_generation = frozen.output_layout_generation; - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { Ok((None, layout_generation, None, image(9))) }, )); - frozen.portal_in_progress = true; frozen.set_active_geometry(Some(crop_geometry((6, 0)))); poll_until_finished(&mut frozen, &mut input).await?; @@ -496,12 +479,11 @@ mod tests { let geometry = crop_geometry((0, 0)); frozen.set_active_geometry(Some(geometry.clone())); let layout_generation = frozen.output_layout_generation; - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { Ok((None, layout_generation, None, image(0))) }, )); - frozen.portal_in_progress = true; frozen.set_active_geometry(Some(geometry)); poll_until_finished(&mut frozen, &mut input).await?; @@ -517,18 +499,17 @@ mod tests { let wake = crate::backend::wayland::RuntimeWakeSource::new()?; let mut frozen = FrozenState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); - frozen.portal_task = Some(PortalTask::spawn( + frozen.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), std::future::pending(), )); - frozen.portal_in_progress = true; frozen.capture_via_portal(&tokio::runtime::Handle::current())?; - assert!(frozen.portal_task.is_some()); + assert!(frozen.portal.is_running()); frozen.cancel(&mut input); - assert!(frozen.portal_task.is_none()); - assert!(!frozen.portal_in_progress); + assert!(!frozen.portal.is_running()); + assert!(!frozen.portal.is_running()); assert!(frozen.take_capture_done()); Ok(()) } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index b3401c20c..feabc3773 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -14,7 +14,7 @@ use crate::backend::wayland::capture::CaptureLayoutContext; use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::OutputGeometry; use crate::backend::wayland::portal_capture::{crop_argb, layout_token_matches}; -use crate::backend::wayland::portal_task::PortalTask; +use crate::backend::wayland::portal_task::PortalOperation; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; @@ -129,9 +129,7 @@ pub struct FrozenState { image_provenance: Option, image_target_dimensions: Option<(u32, u32)>, image_generation: u64, - pub(super) portal_task: Option>, - pub(super) portal_in_progress: bool, - pub(super) portal_target_output_id: Option, + pub(super) portal: PortalOperation, pub(super) runtime_wake: Option, pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, @@ -195,9 +193,7 @@ impl FrozenState { image_provenance: None, image_target_dimensions: None, image_generation: 0, - portal_task: None, - portal_in_progress: false, - portal_target_output_id: None, + portal: PortalOperation::default(), runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, @@ -346,7 +342,7 @@ impl FrozenState { pub fn is_in_progress(&self) -> bool { self.direct_capture.is_some() - || self.portal_in_progress + || self.portal.is_running() || self.preflight.is_pending() || self.pending_image.is_some() } @@ -453,11 +449,7 @@ impl FrozenState { capture.destroy(); } self.preflight = CapturePreflight::Idle; - self.portal_in_progress = false; - if let Some(mut task) = self.portal_task.take() { - task.cancel(); - } - self.portal_target_output_id = None; + self.portal.finish(); self.pending_image = None; self.capture_done = true; } @@ -1507,7 +1499,9 @@ mod tests { fn cancel_clears_an_in_flight_portal_capture() { let mut state = FrozenState::new(None); let mut input_state = make_test_input_state(); - state.portal_in_progress = true; + state.portal.start( + crate::backend::wayland::portal_task::PortalTask::disconnected_for_test(Instant::now()), + ); state.cancel(&mut input_state); diff --git a/src/backend/wayland/portal_task.rs b/src/backend/wayland/portal_task.rs index 956d12847..ce0217814 100644 --- a/src/backend/wayland/portal_task.rs +++ b/src/backend/wayland/portal_task.rs @@ -22,6 +22,42 @@ pub(super) enum PortalPoll { Disconnected, } +/// Single-flight portal phase. A running operation is exactly one task; its +/// immutable output/layout identity travels inside that task's result. There +/// is no separately writable busy bit or stale target left after cancellation. +pub(super) struct PortalOperation { + active: Option>, +} +impl Default for PortalOperation { + fn default() -> Self { + Self { active: None } + } +} +impl PortalOperation { + pub(super) fn is_running(&self) -> bool { + self.active.is_some() + } + pub(super) fn start(&mut self, task: PortalTask) { + assert!(self.active.is_none(), "portal operation already running"); + self.active = Some(task); + } + pub(super) fn finish(&mut self) { + self.active.take(); + } + pub(super) fn poll(&mut self) -> PortalPoll { + self.active + .as_mut() + .map(PortalTask::poll) + .unwrap_or(PortalPoll::Disconnected) + } + pub(super) fn timeout(&self, now: Instant) -> Option { + self.active.as_ref().map(|task| task.timeout(now)) + } + pub(super) fn timed_out(&self, now: Instant) -> bool { + self.active.as_ref().is_some_and(|task| task.timed_out(now)) + } +} + pub(super) struct PortalTask { receiver: Receiver>, worker: Option>, diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index 1115d186f..d637202ab 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -126,7 +126,7 @@ impl ZoomState { self.source_terminal.is_none(), "a zoom capture terminal is still pending" ); - if self.capture.is_some() || self.portal_in_progress || self.preflight.is_pending() { + if self.capture.is_some() || self.portal.is_running() || self.preflight.is_pending() { warn!("Zoom capture already in progress; ignoring request"); return Ok(()); } @@ -474,7 +474,7 @@ mod tests { .expect("queue portal zoom capture"); assert!(state.preflight_pending()); - assert!(!state.portal_in_progress); + assert!(!state.portal.is_running()); assert_eq!(state.take_preflight_pending(), Some(true)); } diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index ec3cfbbfd..7013eade9 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -19,7 +19,7 @@ impl ZoomState { &mut self, tokio_handle: &tokio::runtime::Handle, ) -> Result<()> { - if self.portal_in_progress { + if self.portal.is_running() { warn!("Zoom portal capture already running; ignoring new request"); return Ok(()); } @@ -34,8 +34,6 @@ impl ZoomState { "portal zoom capture", ) .map_err(anyhow::Error::msg)?; - self.portal_in_progress = true; - self.portal_target_output_id = Some(target_output_id); let layout_generation = self.output_layout_generation; let provenance = ScreenImageProvenance::new( @@ -51,18 +49,20 @@ impl ZoomState { "Requesting screen capture...".to_string(), Some("camera-photo".to_string()), ); - self.portal_task = Some(PortalTask::spawn(tokio_handle, runtime_wake, async move { - async { - let bytes = capture_via_portal_fullscreen_bytes().await?; + self.portal + .start(PortalTask::spawn(tokio_handle, runtime_wake, async move { + async { + let bytes = capture_via_portal_fullscreen_bytes().await?; - let (data, width, height) = decode_image_to_argb(&bytes) - .map_err(|error| CaptureError::ImageError(format!("Decode failed: {error}")))?; - let image = crop_portal_image(data, width, height, &geo)?; + let (data, width, height) = decode_image_to_argb(&bytes).map_err(|error| { + CaptureError::ImageError(format!("Decode failed: {error}")) + })?; + let image = crop_portal_image(data, width, height, &geo)?; - Ok((Some(target_output_id), layout_generation, provenance, image)) - } - .await - })); + Ok((Some(target_output_id), layout_generation, provenance, image)) + } + .await + })); Ok(()) } @@ -73,15 +73,11 @@ impl ZoomState { now: Instant, live_output_count: Option, ) { - if !self.portal_in_progress { + if !self.portal.is_running() { return; } - if self - .portal_task - .as_ref() - .is_some_and(|task| task.timed_out(now)) - { + if self.portal.timed_out(now) { warn!("Portal zoom capture timed out; restoring overlay"); self.finish_failed_portal_task( input_state, @@ -90,11 +86,7 @@ impl ZoomState { return; } - let poll = self - .portal_task - .as_mut() - .map(PortalTask::poll) - .unwrap_or(PortalPoll::Disconnected); + let poll = self.portal.poll(); match poll { PortalPoll::Ready(Ok((target_output, layout_generation, provenance, image))) => { let output_matches = portal_output_matches(target_output, self.active_output_id); @@ -174,13 +166,11 @@ impl ZoomState { } pub fn portal_timeout(&self, now: Instant) -> Option { - self.portal_task.as_ref().map(|task| task.timeout(now)) + self.portal.timeout(now) } fn finish_portal_task(&mut self) { - self.portal_in_progress = false; - self.portal_task.take(); - self.portal_target_output_id = None; + self.portal.finish(); } fn finish_failed_portal_task( @@ -312,16 +302,16 @@ mod tests { .capture_via_portal(&tokio::runtime::Handle::current()) .expect_err("missing geometry must fail closed"); assert!(error.to_string().contains("geometry is unavailable")); - assert!(!zoom.portal_in_progress); - assert!(zoom.portal_task.is_none()); + assert!(!zoom.portal.is_running()); + assert!(!zoom.portal.is_running()); zoom.set_active_geometry(Some(crop_geometry((0, 0)))); let error = zoom .capture_via_portal(&tokio::runtime::Handle::current()) .expect_err("missing output identity must fail closed"); assert!(error.to_string().contains("identity is unavailable")); - assert!(!zoom.portal_in_progress); - assert!(zoom.portal_task.is_none()); + assert!(!zoom.portal.is_running()); + assert!(!zoom.portal.is_running()); Ok(()) } @@ -387,7 +377,7 @@ mod tests { ) { for _ in 0..100 { zoom.poll_portal_capture(input, Instant::now(), live_output_count); - if !zoom.portal_in_progress { + if !zoom.portal.is_running() { return; } tokio::task::yield_now().await; @@ -403,12 +393,11 @@ mod tests { let id = zoom.begin_identified_capture(); zoom.set_active_output(None, Some(1)); zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { Ok((Some(1), 0, provenance(1, 0), image(3))) }, )); - zoom.portal_in_progress = true; poll_until_finished(&mut zoom, &mut input).await; @@ -437,7 +426,7 @@ mod tests { let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); zoom.request_activation(); - zoom.portal_task = Some(if panic_task { + zoom.portal.start(if panic_task { PortalTask::spawn(&tokio::runtime::Handle::current(), wake.handle(), async { panic!("expected zoom portal panic") }) @@ -446,14 +435,13 @@ mod tests { Err(CaptureError::PermissionDenied) }) }); - zoom.portal_in_progress = true; poll_until_finished(&mut zoom, &mut input).await; assert!(!zoom.is_in_progress()); assert!(!zoom.active); assert!(!zoom.pending_activation); - assert!(zoom.portal_task.is_none()); + assert!(!zoom.portal.is_running()); assert!(zoom.take_capture_done()); assert!(matches!( zoom.take_source_terminal(), @@ -472,12 +460,11 @@ mod tests { let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { Err(CaptureError::Cancelled("dismissed".to_string())) }, )); - zoom.portal_in_progress = true; poll_until_finished(&mut zoom, &mut input).await; @@ -501,7 +488,7 @@ mod tests { let mut input = make_test_input_state(); let id = zoom.begin_identified_capture(); zoom.request_activation(); - zoom.portal_task = Some(if timed_out { + zoom.portal.start(if timed_out { PortalTask::spawn_at_for_test( &tokio::runtime::Handle::current(), wake.handle(), @@ -511,14 +498,13 @@ mod tests { } else { PortalTask::disconnected_for_test(now) }); - zoom.portal_in_progress = true; zoom.poll_portal_capture(&mut input, now, None); assert!(!zoom.is_in_progress()); assert!(!zoom.active); assert!(!zoom.pending_activation); - assert!(zoom.portal_task.is_none()); + assert!(!zoom.portal.is_running()); assert!(zoom.take_capture_done()); assert!(matches!( zoom.take_source_terminal(), @@ -541,12 +527,11 @@ mod tests { let generation = zoom.image_generation(); zoom.set_active_output(None, Some(2)); zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async { Ok((Some(1), 0, provenance(1, 0), image(9))) }, )); - zoom.portal_in_progress = true; poll_until_finished(&mut zoom, &mut input).await; @@ -578,7 +563,7 @@ mod tests { zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { @@ -590,7 +575,6 @@ mod tests { )) }, )); - zoom.portal_in_progress = true; zoom.set_active_geometry(Some(crop_geometry((6, 0)))); poll_until_finished(&mut zoom, &mut input).await; @@ -622,7 +606,7 @@ mod tests { zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { @@ -634,7 +618,6 @@ mod tests { )) }, )); - zoom.portal_in_progress = true; zoom.set_active_geometry(Some(geometry)); poll_until_finished(&mut zoom, &mut input).await; @@ -667,7 +650,7 @@ mod tests { zoom.set_active_output(None, Some(1)); let layout_generation = zoom.output_layout_generation; zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { @@ -679,7 +662,6 @@ mod tests { )) }, )); - zoom.portal_in_progress = true; poll_until_finished_with_live_outputs(&mut zoom, &mut input, Some(2)).await; @@ -705,19 +687,18 @@ mod tests { let mut zoom = ZoomState::new_with_runtime_wake(None, wake.handle()); let id = zoom.begin_identified_capture(); zoom.request_activation(); - zoom.portal_task = Some(PortalTask::spawn( + zoom.portal.start(PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), std::future::pending(), )); - zoom.portal_in_progress = true; zoom.capture_via_portal(&tokio::runtime::Handle::current()) .unwrap(); - assert!(zoom.portal_task.is_some()); + assert!(zoom.portal.is_running()); assert!(zoom.abort_capture()); - assert!(zoom.portal_task.is_none()); - assert!(!zoom.portal_in_progress); + assert!(!zoom.portal.is_running()); + assert!(!zoom.portal.is_running()); assert_eq!( zoom.take_source_terminal(), Some(super::super::state::ZoomSourceTerminal { diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 57ee7b4b2..ddb3ee654 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -6,7 +6,7 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::Z use crate::backend::wayland::RuntimeWakeHandle; use crate::backend::wayland::frozen::{FrozenImage, ScreenImageProvenance}; use crate::backend::wayland::frozen_geometry::OutputGeometry; -use crate::backend::wayland::portal_task::PortalTask; +use crate::backend::wayland::portal_task::PortalOperation; use crate::input::InputState; use super::capture::CaptureSession; @@ -113,9 +113,7 @@ pub struct ZoomState { image_provenance: Option, pub(super) image_target_dimensions: Option<(u32, u32)>, image_generation: u64, - pub(super) portal_task: Option>, - pub(super) portal_in_progress: bool, - pub(super) portal_target_output_id: Option, + pub(super) portal: PortalOperation, pub(super) runtime_wake: Option, pub(super) preflight: CapturePreflight, pub(super) capture_done: bool, @@ -159,9 +157,7 @@ impl ZoomState { image_provenance: None, image_target_dimensions: None, image_generation: 0, - portal_task: None, - portal_in_progress: false, - portal_target_output_id: None, + portal: PortalOperation::default(), runtime_wake, preflight: CapturePreflight::Idle, capture_done: false, @@ -273,7 +269,7 @@ impl ZoomState { } pub fn is_in_progress(&self) -> bool { - self.capture.is_some() || self.portal_in_progress || self.preflight.is_pending() + self.capture.is_some() || self.portal.is_running() || self.preflight.is_pending() } #[cfg(test)] @@ -386,15 +382,11 @@ impl ZoomState { capture.frame.destroy(); changed = true; } - if self.preflight.is_pending() || self.portal_in_progress { + if self.preflight.is_pending() || self.portal.is_running() { changed = true; } self.preflight = CapturePreflight::Idle; - self.portal_in_progress = false; - if let Some(mut task) = self.portal_task.take() { - task.cancel(); - } - self.portal_target_output_id = None; + self.portal.finish(); self.pending_activation = false; if changed { self.finish_source_capture(ZoomSourceOutcome::Aborted); @@ -470,11 +462,7 @@ impl ZoomState { } self.preflight = CapturePreflight::Idle; self.capture_done = true; - self.portal_in_progress = false; - if let Some(mut task) = self.portal_task.take() { - task.cancel(); - } - self.portal_target_output_id = None; + self.portal.finish(); self.pending_activation = false; self.finish_source_capture_with_report(outcome, report); From 355872269307456b8d6128691ce5b1421184baaa Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:19:00 +0200 Subject: [PATCH 13/28] refactor(domain): share stroke limits without input dependencies --- configurator/src/models/config/presets.rs | 2 +- .../src/models/config/to_config/drawing.rs | 4 +-- configurator/src/models/fields/eraser.rs | 2 +- configurator/src/models/fields/tool.rs | 2 +- src/config/validate/drawing.rs | 2 +- src/config/validate/presets.rs | 2 +- src/config/validate/tablet.rs | 2 +- src/domain/drawing.rs | 3 ++ src/domain/mod.rs | 2 ++ src/input/state/core/base/types.rs | 3 +- src/ui/toolbar/model/activation.rs | 2 +- tools/check-shared-dependencies.py | 31 +++++++++++++++++++ tools/lint-and-test.sh | 1 + 13 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/domain/drawing.rs create mode 100755 tools/check-shared-dependencies.py diff --git a/configurator/src/models/config/presets.rs b/configurator/src/models/config/presets.rs index 7a3f27e10..c4638f8f7 100644 --- a/configurator/src/models/config/presets.rs +++ b/configurator/src/models/config/presets.rs @@ -9,7 +9,7 @@ use wayscriber::config::{ Config, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, PresetToolStatesConfig, ToolPresetConfig, }; -use wayscriber::input::Tool; +use wayscriber::domain::Tool; #[derive(Debug, Clone, PartialEq)] pub struct PresetSlotDraft { diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 889a2c559..de1645e7a 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -4,9 +4,9 @@ use super::super::parse::{ }; use crate::models::error::FormError; use wayscriber::config::Config; +use wayscriber::domain::{DragBindableTool, DragTool}; +use wayscriber::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use wayscriber::draw::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; -use wayscriber::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; -use wayscriber::input::{DragBindableTool, DragTool}; impl ConfigDraft { pub(super) fn apply_drawing(&self, config: &mut Config, errors: &mut Vec) { diff --git a/configurator/src/models/fields/eraser.rs b/configurator/src/models/fields/eraser.rs index 40e7d5b36..737fbf2d6 100644 --- a/configurator/src/models/fields/eraser.rs +++ b/configurator/src/models/fields/eraser.rs @@ -1,5 +1,5 @@ +use wayscriber::domain::EraserMode; use wayscriber::draw::EraserKind; -use wayscriber::input::EraserMode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EraserModeOption { diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs index 6aa7aa1bc..c1e736626 100644 --- a/configurator/src/models/fields/tool.rs +++ b/configurator/src/models/fields/tool.rs @@ -1,5 +1,5 @@ use wayscriber::config::ColorSpec; -use wayscriber::input::{DragBindableTool, DragTool, Tool}; +use wayscriber::domain::{DragBindableTool, DragTool, Tool}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DragMouseButton { diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index 6df2c03dd..c31044ab9 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -1,7 +1,7 @@ use super::Config; use crate::config::types::DEFAULT_HIT_TEST_TOLERANCE; +use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::draw::shape::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; -use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; impl Config { pub(super) fn validate_drawing(&mut self) { diff --git a/src/config/validate/presets.rs b/src/config/validate/presets.rs index 8f7f2785b..1130db488 100644 --- a/src/config/validate/presets.rs +++ b/src/config/validate/presets.rs @@ -1,6 +1,6 @@ use super::Config; +use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::draw::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides}; -use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use super::super::types::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, ToolPresetConfig}; diff --git a/src/config/validate/tablet.rs b/src/config/validate/tablet.rs index 0dd98ba5b..3b983a0e2 100644 --- a/src/config/validate/tablet.rs +++ b/src/config/validate/tablet.rs @@ -1,5 +1,5 @@ use super::Config; -use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; impl Config { pub(super) fn validate_tablet(&mut self) { diff --git a/src/domain/drawing.rs b/src/domain/drawing.rs new file mode 100644 index 000000000..3453226ee --- /dev/null +++ b/src/domain/drawing.rs @@ -0,0 +1,3 @@ +//! Drawing limits shared by validation, input, and presentation. +pub const MIN_STROKE_THICKNESS: f64 = 1.0; +pub const MAX_STROKE_THICKNESS: f64 = 50.0; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index ecaa19062..587436dd5 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -7,6 +7,7 @@ mod action; mod board; pub mod color; +mod drawing; mod onboarding; mod tool; @@ -15,6 +16,7 @@ pub use board::{ BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, BoardSpec, }; pub use color::Color; +pub use drawing::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; pub use onboarding::OnboardingTip; pub use tool::{DragBindableTool, DragTool, EraserMode, Tool}; diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 92f95a7a7..aa36b9e71 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -1,7 +1,6 @@ //! Drawing state machine and input state management. -pub const MIN_STROKE_THICKNESS: f64 = 1.0; -pub const MAX_STROKE_THICKNESS: f64 = 50.0; +pub use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; pub const PRESET_FEEDBACK_DURATION_MS: u64 = 450; pub const PRESET_TOAST_DURATION_MS: u64 = 1300; pub const UI_TOAST_DURATION_MS: u64 = 5000; diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index 18b4ff947..26a5b7500 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -1,4 +1,4 @@ -use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use super::super::ToolbarEvent; diff --git a/tools/check-shared-dependencies.py b/tools/check-shared-dependencies.py new file mode 100755 index 000000000..198c1724a --- /dev/null +++ b/tools/check-shared-dependencies.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Guard explicit upward Rust paths in the agreed shared-layer source boundary. + +This is a source-path guard, not a full Rust dependency graph. Domain compatibility +path tests deliberately mention old public paths. Board validation still uses its +reviewed ID/color validation helpers; moving board lifecycle is outside this rule. +""" +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parent.parent +errors = [] +for directory, forbidden in [ + ("src/domain", "config|input|draw|backend|ui|session"), + ("src/config/validate", "input|backend"), +]: + for path in (ROOT / directory).rglob("*.rs"): + if path == ROOT / "src/domain/tests.rs": + continue # #[cfg(test)] public-path compatibility assertions. + source = re.sub(r"/\*.*?\*/|//[^\n]*", "", path.read_text(), flags=re.S) + if path == ROOT / "src/config/validate/boards.rs": + source = source.replace("use crate::input::boards::{BoundaryBoardIdSet, clamp_board_rgb};", "") + # Also catch multiline grouped imports such as use crate::{input::...}. + pattern = rf"crate\s*::\s*(?:{forbidden})\b|use\s+crate\s*::\s*\{{[^;]*\b(?:{forbidden})\s*::" + if re.search(pattern, source): + errors.append(f"{path.relative_to(ROOT)}: upward dependency in shared layer") +if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) +print("Shared domain and configuration-validation dependency paths passed.") diff --git a/tools/lint-and-test.sh b/tools/lint-and-test.sh index ebfb11921..123812836 100755 --- a/tools/lint-and-test.sh +++ b/tools/lint-and-test.sh @@ -19,6 +19,7 @@ run_check ./tools/check-nixpkgs-recipe.py run_check ./tools/check-rust-source-coverage.py run_check ./tools/check-process-sites.py run_check ./tools/check-config-writers.py +run_check ./tools/check-shared-dependencies.py run_check cargo fmt --all -- --check run_check cargo clippy --locked --workspace --all-targets --all-features -- -D warnings run_check cargo build --locked --workspace --all-features --bins From 3904c18c550221a88e44119efcc07caea464ed6f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:24:45 +0200 Subject: [PATCH 14/28] refactor(configurator): group history draft conversion and validation --- configurator/src/app/pages/history.rs | 21 ++-- .../src/models/config/draft/from_config.rs | 8 +- configurator/src/models/config/draft/mod.rs | 8 +- configurator/src/models/config/history.rs | 109 ++++++++++++++++++ configurator/src/models/config/mod.rs | 1 + configurator/src/models/config/setters.rs | 14 +-- configurator/src/models/config/tests.rs | 2 +- .../src/models/config/to_config/history.rs | 63 ---------- .../src/models/config/to_config/mod.rs | 3 +- 9 files changed, 132 insertions(+), 97 deletions(-) create mode 100644 configurator/src/models/config/history.rs delete mode 100644 configurator/src/models/config/to_config/history.rs diff --git a/configurator/src/app/pages/history.rs b/configurator/src/app/pages/history.rs index 4ca1bd488..dd90060f0 100644 --- a/configurator/src/app/pages/history.rs +++ b/configurator/src/app/pages/history.rs @@ -30,11 +30,11 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { page.group_in_area("Delays", SearchArea::HistoryMain) .entry_row_validated( "Undo all delay (ms)", - |app| app.draft.history_undo_all_delay_ms.clone(), + |app| app.draft.history.undo_all_delay_ms.clone(), |value| Message::TextChanged(TextField::HistoryUndoAllDelayMs, value), |app| { validate_u32_range( - &app.draft.history_undo_all_delay_ms, + &app.draft.history.undo_all_delay_ms, DELAY_RANGE.0, DELAY_RANGE.1, ) @@ -42,11 +42,11 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { ) .entry_row_validated( "Redo all delay (ms)", - |app| app.draft.history_redo_all_delay_ms.clone(), + |app| app.draft.history.redo_all_delay_ms.clone(), |value| Message::TextChanged(TextField::HistoryRedoAllDelayMs, value), |app| { validate_u32_range( - &app.draft.history_redo_all_delay_ms, + &app.draft.history.redo_all_delay_ms, DELAY_RANGE.0, DELAY_RANGE.1, ) @@ -60,7 +60,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { §ion, "Custom undo delay (ms)", TextField::HistoryCustomUndoDelayMs, - |app| app.draft.history_custom_undo_delay_ms.clone(), + |app| app.draft.history.custom_undo_delay_ms.clone(), DELAY_RANGE, ); custom_field( @@ -68,7 +68,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { §ion, "Custom redo delay (ms)", TextField::HistoryCustomRedoDelayMs, - |app| app.draft.history_custom_redo_delay_ms.clone(), + |app| app.draft.history.custom_redo_delay_ms.clone(), DELAY_RANGE, ); custom_field( @@ -76,7 +76,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { §ion, "Custom undo steps", TextField::HistoryCustomUndoSteps, - |app| app.draft.history_custom_undo_steps.clone(), + |app| app.draft.history.custom_undo_steps.clone(), STEPS_RANGE, ); custom_field( @@ -84,7 +84,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { §ion, "Custom redo steps", TextField::HistoryCustomRedoSteps, - |app| app.draft.history_custom_redo_steps.clone(), + |app| app.draft.history.custom_redo_steps.clone(), STEPS_RANGE, ); @@ -113,7 +113,7 @@ fn custom_section(page: &mut PageBuilder) -> adw::ExpanderRow { { let row = row.clone(); page.bind(move |app, _summary| { - let enabled = app.draft.history_custom_section_enabled; + let enabled = app.draft.history.custom_section_enabled; if row.enables_expansion() != enabled { // Blocked: the draft decided this, and reporting it back as a // user toggle resets the status line — which after a load is @@ -155,7 +155,8 @@ fn custom_field( set_text_blocked(&row, &handler, &value); let error = app .draft - .history_custom_section_enabled + .history + .custom_section_enabled .then(|| validate_u32_range(&value, range.0, range.1)) .flatten(); show_error(&row, error); diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 4292b3784..e2b120a79 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -106,13 +106,7 @@ impl ConfigDraft { arrow_head_at_end: config.arrow.head_at_end, arrow_style: ArrowStyleOption::from_style(config.arrow.style), - history_undo_all_delay_ms: config.history.undo_all_delay_ms.to_string(), - history_redo_all_delay_ms: config.history.redo_all_delay_ms.to_string(), - history_custom_section_enabled: config.history.custom_section_enabled, - history_custom_undo_delay_ms: config.history.custom_undo_delay_ms.to_string(), - history_custom_redo_delay_ms: config.history.custom_redo_delay_ms.to_string(), - history_custom_undo_steps: config.history.custom_undo_steps.to_string(), - history_custom_redo_steps: config.history.custom_redo_steps.to_string(), + history: super::super::history::HistoryDraft::from_config(&config.history), performance_buffer_count: config.performance.buffer_count, performance_enable_vsync: config.performance.enable_vsync, diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 9ec666ffb..bd55f527d 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -69,13 +69,7 @@ pub struct ConfigDraft { pub arrow_head_at_end: bool, pub arrow_style: ArrowStyleOption, - pub history_undo_all_delay_ms: String, - pub history_redo_all_delay_ms: String, - pub history_custom_section_enabled: bool, - pub history_custom_undo_delay_ms: String, - pub history_custom_redo_delay_ms: String, - pub history_custom_undo_steps: String, - pub history_custom_redo_steps: String, + pub history: super::history::HistoryDraft, pub performance_buffer_count: u32, pub performance_enable_vsync: bool, diff --git a/configurator/src/models/config/history.rs b/configurator/src/models/config/history.rs new file mode 100644 index 000000000..b5b045adf --- /dev/null +++ b/configurator/src/models/config/history.rs @@ -0,0 +1,109 @@ +use super::parse::{parse_u64_in_range, parse_usize_in_range}; +use crate::models::error::FormError; +use wayscriber::config::HistoryConfig; + +const HISTORY_DELAY_MS_MIN: u64 = 50; +const HISTORY_DELAY_MS_MAX: u64 = 5_000; +const HISTORY_STEPS_MIN: usize = 1; +const HISTORY_STEPS_MAX: usize = 500; + +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryDraft { + pub undo_all_delay_ms: String, + pub redo_all_delay_ms: String, + pub custom_section_enabled: bool, + pub custom_undo_delay_ms: String, + pub custom_redo_delay_ms: String, + pub custom_undo_steps: String, + pub custom_redo_steps: String, +} + +impl HistoryDraft { + pub(super) fn from_config(config: &HistoryConfig) -> Self { + Self { + undo_all_delay_ms: config.undo_all_delay_ms.to_string(), + redo_all_delay_ms: config.redo_all_delay_ms.to_string(), + custom_section_enabled: config.custom_section_enabled, + custom_undo_delay_ms: config.custom_undo_delay_ms.to_string(), + custom_redo_delay_ms: config.custom_redo_delay_ms.to_string(), + custom_undo_steps: config.custom_undo_steps.to_string(), + custom_redo_steps: config.custom_redo_steps.to_string(), + } + } + + pub(super) fn apply_to(&self, config: &mut HistoryConfig, errors: &mut Vec) { + parse_u64_in_range( + &self.undo_all_delay_ms, + "history.undo_all_delay_ms", + HISTORY_DELAY_MS_MIN, + HISTORY_DELAY_MS_MAX, + errors, + |value| config.undo_all_delay_ms = value, + ); + parse_u64_in_range( + &self.redo_all_delay_ms, + "history.redo_all_delay_ms", + HISTORY_DELAY_MS_MIN, + HISTORY_DELAY_MS_MAX, + errors, + |value| config.redo_all_delay_ms = value, + ); + config.custom_section_enabled = self.custom_section_enabled; + parse_u64_in_range( + &self.custom_undo_delay_ms, + "history.custom_undo_delay_ms", + HISTORY_DELAY_MS_MIN, + HISTORY_DELAY_MS_MAX, + errors, + |value| config.custom_undo_delay_ms = value, + ); + parse_u64_in_range( + &self.custom_redo_delay_ms, + "history.custom_redo_delay_ms", + HISTORY_DELAY_MS_MIN, + HISTORY_DELAY_MS_MAX, + errors, + |value| config.custom_redo_delay_ms = value, + ); + parse_usize_in_range( + &self.custom_undo_steps, + "history.custom_undo_steps", + HISTORY_STEPS_MIN, + HISTORY_STEPS_MAX, + errors, + |value| config.custom_undo_steps = value, + ); + parse_usize_in_range( + &self.custom_redo_steps, + "history.custom_redo_steps", + HISTORY_STEPS_MIN, + HISTORY_STEPS_MAX, + errors, + |value| config.custom_redo_steps = value, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_section_round_trips_and_retains_invalid_text() { + let original = HistoryConfig::default(); + let mut draft = HistoryDraft::from_config(&original); + let mut saved = original.clone(); + let mut errors = Vec::new(); + draft.apply_to(&mut saved, &mut errors); + assert!(errors.is_empty()); + assert_eq!(HistoryDraft::from_config(&saved), draft); + + draft.custom_undo_delay_ms = "-".into(); + draft.custom_redo_steps = "0".into(); + draft.apply_to(&mut saved, &mut errors); + assert_eq!(errors.len(), 2); + assert_eq!(draft.custom_undo_delay_ms, "-"); + assert_eq!(saved.custom_undo_delay_ms, original.custom_undo_delay_ms); + assert_eq!(saved.custom_redo_steps, original.custom_redo_steps); + } +} diff --git a/configurator/src/models/config/mod.rs b/configurator/src/models/config/mod.rs index 05b7a9019..b37da195a 100644 --- a/configurator/src/models/config/mod.rs +++ b/configurator/src/models/config/mod.rs @@ -1,6 +1,7 @@ mod boards; mod draft; mod font_cycle; +mod history; mod parse; mod performance_fields; mod presets; diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index a97b26a6e..51b271927 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -294,7 +294,7 @@ impl ConfigDraft { self.session_autosave_enabled = value; } ToggleField::HistoryCustomSectionEnabled => { - self.history_custom_section_enabled = value; + self.history.custom_section_enabled = value; } ToggleField::ArrowHeadAtEnd => { self.arrow_head_at_end = value; @@ -351,12 +351,12 @@ impl ConfigDraft { TextField::PerformanceUiAnimationFps => { self.set_performance_text(PerformanceFieldId::UiAnimationFps, value); } - TextField::HistoryUndoAllDelayMs => self.history_undo_all_delay_ms = value, - TextField::HistoryRedoAllDelayMs => self.history_redo_all_delay_ms = value, - TextField::HistoryCustomUndoDelayMs => self.history_custom_undo_delay_ms = value, - TextField::HistoryCustomRedoDelayMs => self.history_custom_redo_delay_ms = value, - TextField::HistoryCustomUndoSteps => self.history_custom_undo_steps = value, - TextField::HistoryCustomRedoSteps => self.history_custom_redo_steps = value, + TextField::HistoryUndoAllDelayMs => self.history.undo_all_delay_ms = value, + TextField::HistoryRedoAllDelayMs => self.history.redo_all_delay_ms = value, + TextField::HistoryCustomUndoDelayMs => self.history.custom_undo_delay_ms = value, + TextField::HistoryCustomRedoDelayMs => self.history.custom_redo_delay_ms = value, + TextField::HistoryCustomUndoSteps => self.history.custom_undo_steps = value, + TextField::HistoryCustomRedoSteps => self.history.custom_redo_steps = value, TextField::UiPreferredOutput => self.ui_preferred_output = value, TextField::StatusFontSize => self.status_font_size = value, TextField::StatusPadding => self.status_padding = value, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 5cb79e9ac..c49155ec5 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -266,7 +266,7 @@ fn config_draft_to_config_rejects_out_of_range_numbers() { let mut draft = ConfigDraft::from_config(&Config::default()); draft.drawing_default_thickness = "99".to_string(); draft.arrow_angle = "90".to_string(); - draft.history_undo_all_delay_ms = "1".to_string(); + draft.history.undo_all_delay_ms = "1".to_string(); let errors = draft .to_config(&Config::default()) diff --git a/configurator/src/models/config/to_config/history.rs b/configurator/src/models/config/to_config/history.rs deleted file mode 100644 index 6543950e8..000000000 --- a/configurator/src/models/config/to_config/history.rs +++ /dev/null @@ -1,63 +0,0 @@ -use super::super::draft::ConfigDraft; -use super::super::parse::{parse_u64_in_range, parse_usize_in_range}; -use crate::models::error::FormError; -use wayscriber::config::Config; - -const HISTORY_DELAY_MS_MIN: u64 = 50; -const HISTORY_DELAY_MS_MAX: u64 = 5_000; -const HISTORY_STEPS_MIN: usize = 1; -const HISTORY_STEPS_MAX: usize = 500; - -impl ConfigDraft { - pub(super) fn apply_history(&self, config: &mut Config, errors: &mut Vec) { - parse_u64_in_range( - &self.history_undo_all_delay_ms, - "history.undo_all_delay_ms", - HISTORY_DELAY_MS_MIN, - HISTORY_DELAY_MS_MAX, - errors, - |value| config.history.undo_all_delay_ms = value, - ); - parse_u64_in_range( - &self.history_redo_all_delay_ms, - "history.redo_all_delay_ms", - HISTORY_DELAY_MS_MIN, - HISTORY_DELAY_MS_MAX, - errors, - |value| config.history.redo_all_delay_ms = value, - ); - config.history.custom_section_enabled = self.history_custom_section_enabled; - parse_u64_in_range( - &self.history_custom_undo_delay_ms, - "history.custom_undo_delay_ms", - HISTORY_DELAY_MS_MIN, - HISTORY_DELAY_MS_MAX, - errors, - |value| config.history.custom_undo_delay_ms = value, - ); - parse_u64_in_range( - &self.history_custom_redo_delay_ms, - "history.custom_redo_delay_ms", - HISTORY_DELAY_MS_MIN, - HISTORY_DELAY_MS_MAX, - errors, - |value| config.history.custom_redo_delay_ms = value, - ); - parse_usize_in_range( - &self.history_custom_undo_steps, - "history.custom_undo_steps", - HISTORY_STEPS_MIN, - HISTORY_STEPS_MAX, - errors, - |value| config.history.custom_undo_steps = value, - ); - parse_usize_in_range( - &self.history_custom_redo_steps, - "history.custom_redo_steps", - HISTORY_STEPS_MIN, - HISTORY_STEPS_MAX, - errors, - |value| config.history.custom_redo_steps = value, - ); - } -} diff --git a/configurator/src/models/config/to_config/mod.rs b/configurator/src/models/config/to_config/mod.rs index ca5b9c995..44292e84a 100644 --- a/configurator/src/models/config/to_config/mod.rs +++ b/configurator/src/models/config/to_config/mod.rs @@ -2,7 +2,6 @@ mod boards; mod capture; mod drawing; mod export; -mod history; mod keybindings; mod performance; mod presenter_mode; @@ -27,7 +26,7 @@ impl ConfigDraft { config.config_revision = revision; } self.apply_drawing(&mut config, &mut errors); - self.apply_history(&mut config, &mut errors); + self.history.apply_to(&mut config.history, &mut errors); self.apply_performance(&mut config, &mut errors); self.apply_ui(&mut config, &mut errors); self.apply_presenter_mode(&mut config); From 21eccf2b7c786f9ef0d6550e621f001078560fa1 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:25:56 +0200 Subject: [PATCH 15/28] refactor(toolbar): share slider readouts values and event policy --- src/toolbar_gtk/view/top_bar/style_pill.rs | 56 ++-------------------- src/ui/toolbar/model/style_pill/control.rs | 43 ++++++++++++----- 2 files changed, 35 insertions(+), 64 deletions(-) diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index ef9f8dae4..f57434e47 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -9,18 +9,6 @@ use super::*; -fn format_px(value: f64) -> String { - format!("{value:.0}px") -} - -fn format_percent(value: f64) -> String { - format!("{:.0}%", value * 100.0) -} - -fn format_pt(value: f64) -> String { - format!("{value:.0}pt") -} - /// Pill button on the shared `sized_button` chassis: non-focusable and /// releasing window keyboard focus on click, like every other top-bar /// control. The GTK bars must never retain keyboard focus — the popups the @@ -175,43 +163,14 @@ impl TopBar { | model::StylePillControl::SpotlightMagnificationSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); - let format = match control { - model::StylePillControl::ThicknessSlider => format_px as fn(f64) -> String, - model::StylePillControl::OpacitySlider => format_percent, - model::StylePillControl::SpotlightMagnificationSlider => { - crate::draw::format_spotlight_magnification - } - _ => format_pt, - }; let sender = self.feedback.clone(); let slider = SliderRow::new( scale, - match control { - model::StylePillControl::ThicknessSlider => "Stroke thickness", - model::StylePillControl::OpacitySlider => "Marker opacity", - model::StylePillControl::SpotlightMagnificationSlider => { - "Spotlight magnification" - } - _ => "Font size", - }, + control.label(snapshot).as_ref(), slider_spec, value, - format, - move |value| { - let event = match control { - model::StylePillControl::ThicknessSlider => { - ToolbarEvent::SetThickness(value) - } - model::StylePillControl::OpacitySlider => { - ToolbarEvent::SetMarkerOpacity(value) - } - model::StylePillControl::SpotlightMagnificationSlider => { - ToolbarEvent::SetSpotlightMagnification(value) - } - _ => ToolbarEvent::SetFontSize(value), - }; - send_event(&sender, event); - }, + control.slider_formatter(), + move |value| send_event(&sender, control.slider_event(value)), ); // Thickness/text-size use distinct numeral controls. The // other readouts sit beside a full-width track, matching @@ -238,14 +197,7 @@ impl TopBar { slider.root.set_valign(gtk4::Align::Center); append_gap(&pill, slider.root.upcast_ref(), gap); self.updaters.borrow_mut().push(Box::new(move |snapshot| { - let value = match control { - model::StylePillControl::ThicknessSlider => snapshot.thickness, - model::StylePillControl::OpacitySlider => snapshot.marker_opacity, - model::StylePillControl::SpotlightMagnificationSlider => { - snapshot.spotlight_magnification - } - _ => snapshot.font_size, - }; + let value = control.slider_value(snapshot).1; slider.set_value(value); })); self.append_style_status_label(&pill, control, snapshot, px(gap)); diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index ddbec385f..7b46f0a22 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -68,13 +68,11 @@ impl StylePillControl { index, } } - Self::ThicknessSlider => ToolbarEvent::SetThickness(snapshot.thickness), - Self::OpacitySlider => ToolbarEvent::SetMarkerOpacity(snapshot.marker_opacity), - Self::SpotlightMagnificationSlider => { - ToolbarEvent::SetSpotlightMagnification(snapshot.spotlight_magnification) - } + Self::ThicknessSlider + | Self::OpacitySlider + | Self::SpotlightMagnificationSlider + | Self::FontSizeSlider => self.slider_event(self.slider_value(snapshot).1), Self::FontFamilyPicker => ToolbarEvent::OpenFontPicker, - Self::FontSizeSlider => ToolbarEvent::SetFontSize(snapshot.font_size), Self::FillToggle => ToolbarEvent::ToggleFill(!snapshot.fill_enabled), Self::FontWeightToggle => { ToolbarEvent::SetFontBold(!snapshot.font_bold_target_is_bold()) @@ -162,6 +160,27 @@ impl StylePillControl { .expect("this style-pill control is a slider") } + /// Value policy shared by the live GTK callback and snapshot event adapters. + pub(crate) fn slider_event(self, value: f64) -> ToolbarEvent { + match self { + Self::ThicknessSlider => ToolbarEvent::SetThickness(value), + Self::OpacitySlider => ToolbarEvent::SetMarkerOpacity(value), + Self::SpotlightMagnificationSlider => ToolbarEvent::SetSpotlightMagnification(value), + Self::FontSizeSlider => ToolbarEvent::SetFontSize(value), + _ => panic!("this style-pill control is a slider"), + } + } + + pub(crate) fn slider_formatter(self) -> fn(f64) -> String { + match self { + Self::ThicknessSlider | Self::ThicknessValue => |value| format!("{value:.0}px"), + Self::OpacitySlider => |value| format!("{:.0}%", value * 100.0), + Self::SpotlightMagnificationSlider => crate::draw::format_spotlight_magnification, + Self::FontSizeSlider | Self::FontSizeValue => |value| format!("{value:.0}pt"), + _ => panic!("this style-pill control has a numeric readout"), + } + } + /// Live readout for sliders and their numeral buttons. The unit follows /// the tool context: px for thickness/size targets (the snapshot /// already routes eraser/marker sizes through `thickness`), pt for @@ -169,12 +188,12 @@ impl StylePillControl { pub(crate) fn value_text(self, snapshot: &ToolbarSnapshot) -> Option { match self { Self::ThicknessSlider | Self::ThicknessValue => { - Some(format!("{:.0}px", snapshot.thickness)) + Some(self.slider_formatter()(snapshot.thickness)) + } + Self::OpacitySlider => Some(self.slider_formatter()(snapshot.marker_opacity)), + Self::SpotlightMagnificationSlider => { + Some(self.slider_formatter()(snapshot.spotlight_magnification)) } - Self::OpacitySlider => Some(format!("{:.0}%", snapshot.marker_opacity * 100.0)), - Self::SpotlightMagnificationSlider => Some( - crate::draw::format_spotlight_magnification(snapshot.spotlight_magnification), - ), // "Off" rather than "0": the number is a count of passes, and zero // of them is a state worth naming rather than a quantity. Self::PenSmoothingStepper => Some(if snapshot.pen_smoothing == 0 { @@ -183,7 +202,7 @@ impl StylePillControl { snapshot.pen_smoothing.to_string() }), Self::FontSizeSlider | Self::FontSizeValue => { - Some(format!("{:.0}pt", snapshot.font_size)) + Some(self.slider_formatter()(snapshot.font_size)) } Self::ArrowStyleCycle => Some(snapshot.arrow_style.label().to_string()), Self::FontFamilyPicker => Some(short_family_label(&snapshot.font.family)), From 4fe3467900f34b1e567b211e30fed5f6b3d47487 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:30:36 +0200 Subject: [PATCH 16/28] feat(ui): theme the complete command palette surface --- configurator/src/app/pages/ui/general.rs | 2 +- docs/CONFIG.md | 2 +- src/backend/wayland/state/render/ui.rs | 9 +- src/ui/command_palette.rs | 111 ++++++++++++------ src/ui/command_palette/command_palette_row.rs | 33 ++++-- src/ui/command_palette/modals.rs | 16 ++- src/ui/command_palette/tests/engine.rs | 107 ++++++++++++++++- 7 files changed, 224 insertions(+), 56 deletions(-) diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs index 0f2c6b921..a08c0d2ec 100644 --- a/configurator/src/app/pages/ui/general.rs +++ b/configurator/src/app/pages/ui/general.rs @@ -16,7 +16,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { page.group_in_area("General UI", SearchArea::UiGeneral) .combo_row( "Theme", - "\"Auto\" currently uses the dark theme; \"Light\" takes effect as overlay surfaces adopt the runtime theme.", + "\"Auto\" uses dark. \"Light\" supports the command palette, including shortcut editing and tooltips; other popups still use dark.", themes, theme_labels, |app| app.draft.ui_theme, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2b67f55c0..073e39b66 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1012,7 +1012,7 @@ enabled = true - `"bottom-right"`: Lower right corner **Theme & Motion:** -- **Theme**: `theme` selects the overlay chrome theme — `"auto"` (default), `"dark"`, or `"light"`. `"auto"` currently resolves to dark chrome; `"light"` takes effect progressively as overlay surfaces adopt the runtime theme (until then it also renders dark). +- **Theme**: `theme` selects the overlay chrome theme — `"auto"` (default), `"dark"`, or `"light"`. `"auto"` currently resolves to dark chrome. The command palette supports `"light"` throughout its search, results, shortcut editing, and tooltips. Other popups retain their complete dark palette until migrated. - **Reduced motion**: `reduced_motion = "on"` disables overlay chrome animations (toast and flash fades render instantly; coverage extends to more surfaces as they adopt the shared animation envelopes). `"off"` keeps full motion. `"auto"` (default) is reserved for a future desktop-portal query of the system reduce-motion preference and currently behaves like `"off"` (full motion). **UI Styling:** diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 17471777f..00c8851b9 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -432,7 +432,14 @@ impl WaylandState { ); } let palette_view = crate::ui::CommandPaletteView::prepare(&self.input_state, width, height); - crate::ui::paint_command_palette(self.render.ui_text(), ctx, &palette_view, width, height); + crate::ui::paint_command_palette( + self.render.theme(), + self.render.ui_text(), + ctx, + &palette_view, + width, + height, + ); crate::ui::render_tour_with_engine( self.render.ui_text(), ctx, diff --git a/src/ui/command_palette.rs b/src/ui/command_palette.rs index d7bcba2bf..2d30f43c6 100644 --- a/src/ui/command_palette.rs +++ b/src/ui/command_palette.rs @@ -10,12 +10,10 @@ use crate::input::state::{ use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ - self, EMPTY_COMMAND_PALETTE, EMPTY_COMMAND_SUGGESTIONS, INPUT_BG, INPUT_BORDER_FOCUSED, - OVERLAY_DIM_MEDIUM, RADIUS_LG, RADIUS_STD, SHADOW, TEXT_DESCRIPTION, TEXT_PLACEHOLDER, - TEXT_WHITE, + self, EMPTY_COMMAND_PALETTE, EMPTY_COMMAND_SUGGESTIONS, OVERLAY_DIM_MEDIUM, RADIUS_LG, + RADIUS_STD, }; use super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; -use super::theme::Rgba; mod command_palette_row; mod modals; @@ -57,15 +55,6 @@ const FRAME_SHADOW_SOFT: (f64, f64, f64, f64) = (0.0, 0.0, 0.0, 0.22); const TOOLTIP_PADDING_X: f64 = 8.0; const TOOLTIP_PADDING_Y: f64 = 5.0; const TOOLTIP_POINTER_OFFSET: f64 = 12.0; -/// Action tooltip surface: darker than crate::ui::theme::popup::bg_command_palette() so the -/// tooltip reads above the palette (no matching theme token; kept). -const TOOLTIP_BG: Rgba = (0.04, 0.05, 0.07, 0.98); -/// Scrollbar track/thumb white-alpha ladder. -/// TODO(theme-consolidation): thumb duplicates -/// `theme::toolbar::COLOR_SCROLLBAR_SLIDER`; track has no token. -const SCROLL_TRACK: Rgba = (1.0, 1.0, 1.0, 0.1); -const SCROLL_THUMB: Rgba = (1.0, 1.0, 1.0, 0.35); - /// Prepared application values consumed by the painter. pub(crate) enum CommandPaletteView { Closed, @@ -149,10 +138,18 @@ pub(crate) fn render_command_palette_with_engine( screen_height: u32, ) { let view = CommandPaletteView::prepare(input_state, screen_width, screen_height); - paint_command_palette(engine, ctx, &view, screen_width, screen_height); + paint_command_palette( + &super::theme::Theme::dark(), + engine, + ctx, + &view, + screen_width, + screen_height, + ); } pub(crate) fn paint_command_palette( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, view: &CommandPaletteView, @@ -162,7 +159,15 @@ pub(crate) fn paint_command_palette( let view = match view { CommandPaletteView::Closed => return, CommandPaletteView::Capture { action, bindings } => { - render_keybinding_capture(engine, ctx, bindings, *action, screen_width, screen_height); + render_keybinding_capture( + theme, + engine, + ctx, + bindings, + *action, + screen_width, + screen_height, + ); return; } CommandPaletteView::List(view) => view, @@ -171,6 +176,7 @@ pub(crate) fn paint_command_palette( let (x, y, palette_width, height) = view.geometry; draw_command_palette_frame( + theme, ctx, screen_width as f64, screen_height as f64, @@ -187,12 +193,30 @@ pub(crate) fn paint_command_palette( let inner_width = palette_width - COMMAND_PALETTE_PADDING * 2.0; let mut cursor_y = y + COMMAND_PALETTE_PADDING; - cursor_y = draw_command_palette_input(engine, ctx, inner_x, cursor_y, inner_width, &view.query); + cursor_y = draw_command_palette_input( + theme, + engine, + ctx, + inner_x, + cursor_y, + inner_width, + &view.query, + ); - render_command_palette_rows(engine, ctx, view, rows, inner_x, inner_width, cursor_y); + render_command_palette_rows( + theme, + engine, + ctx, + view, + rows, + inner_x, + inner_width, + cursor_y, + ); if rows.is_empty() && !view.query.is_empty() { draw_command_palette_empty_state( + theme, engine, ctx, inner_x, @@ -202,6 +226,7 @@ pub(crate) fn paint_command_palette( } render_command_palette_scroll_indicator( + theme, ctx, x, y, @@ -212,11 +237,12 @@ pub(crate) fn paint_command_palette( view.visible_count, ); - draw_command_palette_escape_hint(engine, ctx, x, y, palette_width, height); + draw_command_palette_escape_hint(theme, engine, ctx, x, y, palette_width, height); let _ = ctx.restore(); if let Some((tooltip, pointer_x, pointer_y)) = view.tooltip.as_ref() { draw_command_palette_action_tooltip( + theme, engine, ctx, tooltip, @@ -313,7 +339,9 @@ fn command_palette_text_style( } } +#[allow(clippy::too_many_arguments)] fn draw_command_palette_frame( + theme: &super::theme::Theme, ctx: &cairo::Context, screen_width: f64, screen_height: f64, @@ -339,7 +367,7 @@ fn draw_command_palette_frame( ); let _ = ctx.fill(); - constants::set_color(ctx, SHADOW); + constants::set_color(ctx, theme.shadow); draw_rounded_rect( ctx, x + FRAME_SHADOW_OFFSET * 0.5, @@ -350,17 +378,18 @@ fn draw_command_palette_frame( ); let _ = ctx.fill(); - constants::set_color(ctx, crate::ui::theme::popup::bg_command_palette()); + constants::set_color(ctx, theme.surface_popover); draw_rounded_rect(ctx, x, y, palette_width, height, RADIUS_LG); let _ = ctx.fill(); - constants::set_color(ctx, crate::ui::theme::popup::border_command_palette()); + constants::set_color(ctx, theme.border_hairline); draw_rounded_rect(ctx, x, y, palette_width, height, RADIUS_LG); ctx.set_line_width(1.0); let _ = ctx.stroke(); } fn draw_command_palette_input( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, inner_x: f64, @@ -376,9 +405,9 @@ fn draw_command_palette_input( COMMAND_PALETTE_INPUT_HEIGHT, RADIUS_STD, ); - constants::set_color(ctx, INPUT_BG); + constants::set_color(ctx, theme.surface_card); let _ = ctx.fill_preserve(); - constants::set_color(ctx, INPUT_BORDER_FOCUSED); + constants::set_color(ctx, theme.accent); ctx.set_line_width(1.5); let _ = ctx.stroke(); @@ -390,7 +419,7 @@ fn draw_command_palette_input( let text_y = cursor_y + COMMAND_PALETTE_INPUT_HEIGHT / 2.0 + input_style.size / 3.0; if query.is_empty() { - constants::set_color(ctx, TEXT_PLACEHOLDER); + constants::set_color(ctx, theme.text_secondary); engine.draw_baseline( ctx, input_style, @@ -400,7 +429,7 @@ fn draw_command_palette_input( None, ); } else { - constants::set_color(ctx, TEXT_WHITE); + constants::set_color(ctx, theme.text_primary); engine.draw_baseline(ctx, input_style, query, inner_x + 10.0, text_y, None); } @@ -408,7 +437,9 @@ fn draw_command_palette_input( cursor_y } +#[allow(clippy::too_many_arguments)] fn render_command_palette_rows( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, view: &PaletteListView, @@ -429,7 +460,15 @@ fn render_command_palette_rows( let item_y = start_y + (visible_idx as f64 * COMMAND_PALETTE_ITEM_HEIGHT); match row { CommandPaletteListRow::Header(label) => { - render_command_group_header(engine, ctx, label, inner_x, inner_width, item_y); + render_command_group_header( + theme, + engine, + ctx, + label, + inner_x, + inner_width, + item_y, + ); } CommandPaletteListRow::Command { command, @@ -437,6 +476,7 @@ fn render_command_palette_rows( } => { let is_selected = *command_index == view.selected; render_command_row( + theme, engine, ctx, &view.query, @@ -459,6 +499,7 @@ fn render_command_palette_rows( /// Group header row: small uppercase label with a hairline rule filling the /// remaining width. Occupies a full item row so hit-testing stays uniform. fn render_command_group_header( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, label: &str, @@ -473,13 +514,13 @@ fn render_command_group_header( ); let text = label.to_uppercase(); let baseline = item_y + COMMAND_PALETTE_ITEM_HEIGHT / 2.0 + style.size / 3.0; - constants::set_color(ctx, constants::TEXT_HINT); + constants::set_color(ctx, theme.text_secondary); let extents = engine.draw_baseline(ctx, style, &text, inner_x + 10.0, baseline, None); let rule_start = inner_x + 10.0 + extents.width() + 10.0; let rule_end = inner_x + inner_width - 8.0; if rule_end > rule_start { - constants::set_color(ctx, constants::DIVIDER_LIGHT); + constants::set_color(ctx, theme.border_hairline); ctx.set_line_width(1.0); ctx.move_to(rule_start, baseline - style.size / 3.0); ctx.line_to(rule_end, baseline - style.size / 3.0); @@ -488,6 +529,7 @@ fn render_command_group_header( } fn draw_command_palette_empty_state( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, inner_x: f64, @@ -501,7 +543,7 @@ fn draw_command_palette_empty_state( cairo::FontWeight::Bold, cairo::FontSlant::Normal, ); - constants::set_color(ctx, TEXT_DESCRIPTION); + constants::set_color(ctx, theme.text_secondary); let msg_extents = text_extents_for_with_engine( engine, ctx, @@ -525,7 +567,7 @@ fn draw_command_palette_empty_state( cairo::FontWeight::Normal, cairo::FontSlant::Italic, ); - constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, 0.7)); + constants::set_color(ctx, theme.text_secondary); let suggest_extents = text_extents_for_with_engine( engine, ctx, @@ -545,7 +587,9 @@ fn draw_command_palette_empty_state( ); } +#[allow(clippy::too_many_arguments)] fn render_command_palette_scroll_indicator( + theme: &super::theme::Theme, ctx: &cairo::Context, x: f64, _y: f64, @@ -563,7 +607,7 @@ fn render_command_palette_scroll_indicator( let scroll_track_h = (visible_count as f64) * COMMAND_PALETTE_ITEM_HEIGHT - 4.0; let scroll_track_w = 4.0; - constants::set_color(ctx, SCROLL_TRACK); + constants::set_color(ctx, theme.surface_card); draw_rounded_rect( ctx, scroll_track_x, @@ -584,12 +628,13 @@ fn render_command_palette_scroll_indicator( }; let thumb_y = start_y + scroll_progress * (scroll_track_h - thumb_h); - constants::set_color(ctx, SCROLL_THUMB); + constants::set_color(ctx, theme.text_secondary); draw_rounded_rect(ctx, scroll_track_x, thumb_y, scroll_track_w, thumb_h, 2.0); let _ = ctx.fill(); } fn draw_command_palette_escape_hint( + theme: &super::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, x: f64, @@ -602,7 +647,7 @@ fn draw_command_palette_escape_hint( cairo::FontWeight::Normal, cairo::FontSlant::Normal, ); - constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, 0.6)); + constants::set_color(ctx, theme.text_secondary); let hint_y = y + height - HINT_BASELINE_BOTTOM_OFFSET; let hint_extents = text_extents_for_with_engine( engine, diff --git a/src/ui/command_palette/command_palette_row.rs b/src/ui/command_palette/command_palette_row.rs index 8f980e94f..79931590c 100644 --- a/src/ui/command_palette/command_palette_row.rs +++ b/src/ui/command_palette/command_palette_row.rs @@ -9,7 +9,7 @@ use crate::input::state::{ use crate::ui::text_highlight::{HighlightStyle, draw_highlight_with_engine, find_match_range}; use crate::ui_text::{UiTextEngine, UiTextStyle}; -use super::super::constants::{self, BG_INPUT_SELECTION, RADIUS_SM, TEXT_DESCRIPTION, TEXT_WHITE}; +use super::super::constants::{self, RADIUS_SM}; use super::super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::{ COMMAND_PALETTE_FONT_FAMILY, COMMAND_PALETTE_SHORTCUT_BADGE_GAP, @@ -46,6 +46,7 @@ pub(super) fn command_palette_row_styles() -> CommandPaletteRowStyle { #[allow(clippy::too_many_arguments)] pub(super) fn render_command_row( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, query: &str, @@ -66,7 +67,7 @@ pub(super) fn render_command_row( COMMAND_PALETTE_ITEM_HEIGHT - 2.0, RADIUS_SM, ); - constants::set_color(ctx, BG_INPUT_SELECTION); + constants::set_color(ctx, constants::with_alpha(theme.accent, 0.20)); let _ = ctx.fill(); } @@ -77,7 +78,7 @@ pub(super) fn render_command_row( // whether or not the action has a glyph. if let Some(icon) = cmd.icon { let icon_alpha = if is_selected { 0.95 } else { 0.7 }; - constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, icon_alpha)); + constants::set_color(ctx, constants::with_alpha(theme.text_primary, icon_alpha)); icon( ctx, inner_x + 10.0, @@ -112,6 +113,7 @@ pub(super) fn render_command_row( // user sees why a command surfaced. Drawn before the text so the glyphs // sit on top; fuzzy-only (subsequence) matches draw nothing. draw_label_match_highlights( + theme, engine, ctx, query, @@ -121,7 +123,7 @@ pub(super) fn render_command_row( styles.label.size, ); - constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, text_alpha)); + constants::set_color(ctx, constants::with_alpha(theme.text_primary, text_alpha)); render_command_row_label(engine, ctx, &label, label_x, label_y, styles); let label_extents = text_extents_for_with_engine( @@ -136,6 +138,7 @@ pub(super) fn render_command_row( let desc_x = label_x + label_extents.width() + 12.0; let badge_left_edge = render_command_row_shortcut_badge( + theme, engine, ctx, item_y, @@ -149,6 +152,7 @@ pub(super) fn render_command_row( let max_desc_width = (badge_left_edge - 12.0 - desc_x).max(0.0); let desc_alpha = if is_selected { 0.9 } else { 0.75 }; render_command_row_description( + theme, engine, ctx, &styles.desc, @@ -159,11 +163,12 @@ pub(super) fn render_command_row( desc_alpha, ); if configurable { - render_command_row_actions(ctx, inner_x + inner_width, item_y, is_selected); + render_command_row_actions(theme, ctx, inner_x + inner_width, item_y, is_selected); } } fn render_command_row_actions( + theme: &crate::ui::theme::Theme, ctx: &cairo::Context, content_right: f64, item_y: f64, @@ -173,7 +178,7 @@ fn render_command_row_actions( let left = content_right - stride * COMMAND_PALETTE_ROW_ACTION_COUNT as f64; let icon_y = item_y + (COMMAND_PALETTE_ITEM_HEIGHT - COMMAND_PALETTE_ROW_ACTION_SIZE) / 2.0; let alpha = if selected { 0.95 } else { 0.62 }; - constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, alpha)); + constants::set_color(ctx, constants::with_alpha(theme.text_primary, alpha)); crate::toolbar_icons::draw_icon_pencil( ctx, left + 3.0, @@ -208,7 +213,9 @@ fn render_command_row_label( /// Draw the accent match backdrop for each query token that appears in the /// label as a literal (case-insensitive) substring. The label text itself is /// drawn by the caller afterwards, over these boxes. +#[allow(clippy::too_many_arguments)] fn draw_label_match_highlights( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, query: &str, @@ -222,7 +229,7 @@ fn draw_label_match_highlights( return; } let query_lower = query.to_ascii_lowercase(); - let (hr, hg, hb, ha) = constants::with_alpha(constants::ACCENT_PRIMARY, 0.30); + let (hr, hg, hb, ha) = constants::with_alpha(theme.accent, 0.30); let style = HighlightStyle { font_family: COMMAND_PALETTE_FONT_FAMILY, font_size: label_size, @@ -238,6 +245,7 @@ fn draw_label_match_highlights( #[allow(clippy::too_many_arguments)] pub(super) fn render_command_row_shortcut_badge( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, item_y: f64, @@ -288,8 +296,7 @@ pub(super) fn render_command_row_shortcut_badge( // White-alpha ladder: badge fill and text both derive from // the white root, brighter when the row is selected. - let badge_alpha = if is_selected { 0.35 } else { 0.25 }; - constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, badge_alpha)); + constants::set_color(ctx, theme.surface_card); draw_rounded_rect( ctx, badge_x, @@ -301,7 +308,10 @@ pub(super) fn render_command_row_shortcut_badge( let _ = ctx.fill(); let shortcut_alpha = if is_selected { 0.95 } else { 0.8 }; - constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, shortcut_alpha)); + constants::set_color( + ctx, + constants::with_alpha(theme.text_primary, shortcut_alpha), + ); engine.draw_baseline( ctx, *shortcut_style, @@ -318,6 +328,7 @@ pub(super) fn render_command_row_shortcut_badge( #[allow(clippy::too_many_arguments)] pub(super) fn render_command_row_description( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, desc_style: &UiTextStyle, @@ -327,7 +338,7 @@ pub(super) fn render_command_row_description( max_desc_width: f64, desc_alpha: f64, ) { - constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, desc_alpha)); + constants::set_color(ctx, constants::with_alpha(theme.text_secondary, desc_alpha)); if max_desc_width > 6.0 { let desc_display = ellipsize_to_width( engine, diff --git a/src/ui/command_palette/modals.rs b/src/ui/command_palette/modals.rs index deffb31f0..a621e730b 100644 --- a/src/ui/command_palette/modals.rs +++ b/src/ui/command_palette/modals.rs @@ -25,7 +25,9 @@ pub(super) fn command_palette_action_tooltip_geometry( Some((x, y, width, height)) } +#[allow(clippy::too_many_arguments)] pub(super) fn draw_command_palette_action_tooltip( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, text: &str, @@ -50,10 +52,10 @@ pub(super) fn draw_command_palette_action_tooltip( return; }; - constants::set_color(ctx, TOOLTIP_BG); + constants::set_color(ctx, theme.surface_popover); draw_rounded_rect(ctx, x, y, width, height, 5.0); let _ = ctx.fill(); - constants::set_color(ctx, TEXT_WHITE); + constants::set_color(ctx, theme.text_primary); engine.draw_baseline( ctx, style, @@ -77,6 +79,7 @@ pub(super) fn keybinding_capture_geometry( } pub(super) fn render_keybinding_capture( + theme: &crate::ui::theme::Theme, engine: &UiTextEngine, ctx: &cairo::Context, current: &[String], @@ -86,6 +89,7 @@ pub(super) fn render_keybinding_capture( ) { let (x, y, width, height) = keybinding_capture_geometry(screen_width, screen_height); draw_command_palette_frame( + theme, ctx, screen_width as f64, screen_height as f64, @@ -99,7 +103,7 @@ pub(super) fn render_keybinding_capture( command_palette_text_style(18.0, cairo::FontWeight::Bold, cairo::FontSlant::Normal); let body_style = command_palette_text_style(13.0, cairo::FontWeight::Normal, cairo::FontSlant::Normal); - constants::set_color(ctx, TEXT_WHITE); + constants::set_color(ctx, theme.text_primary); engine.draw_baseline( ctx, title_style, @@ -108,7 +112,7 @@ pub(super) fn render_keybinding_capture( y + 38.0, None, ); - constants::set_color(ctx, TEXT_DESCRIPTION); + constants::set_color(ctx, theme.text_secondary); engine.draw_baseline( ctx, body_style, @@ -124,7 +128,7 @@ pub(super) fn render_keybinding_capture( y + 70.0, None, ); - constants::set_color(ctx, TEXT_WHITE); + constants::set_color(ctx, theme.text_primary); engine.draw_baseline( ctx, body_style, @@ -133,7 +137,7 @@ pub(super) fn render_keybinding_capture( y + 108.0, None, ); - constants::set_color(ctx, TEXT_DESCRIPTION); + constants::set_color(ctx, theme.text_secondary); engine.draw_baseline( ctx, body_style, diff --git a/src/ui/command_palette/tests/engine.rs b/src/ui/command_palette/tests/engine.rs index 40fe658d5..d8b3b5fbe 100644 --- a/src/ui/command_palette/tests/engine.rs +++ b/src/ui/command_palette/tests/engine.rs @@ -51,10 +51,20 @@ fn retained_palette_tooltip_geometry_contains_pixels_and_unicode_highlight_is_vi command_palette_action_tooltip_geometry(&engine, text, 740.0, 560.0, 800.0, 600.0) .unwrap(); let actual = pixels(density, |ctx| { - draw_command_palette_action_tooltip(&engine, ctx, text, 740.0, 560.0, 800.0, 600.0) + draw_command_palette_action_tooltip( + &crate::ui::theme::Theme::dark(), + &engine, + ctx, + text, + 740.0, + 560.0, + 800.0, + 600.0, + ) }); let expected = pixels(density, |ctx| { draw_command_palette_action_tooltip( + &crate::ui::theme::Theme::dark(), &UiTextEngine::default(), ctx, text, @@ -118,7 +128,14 @@ fn retained_palette_tooltip_geometry_contains_pixels_and_unicode_highlight_is_vi fn prepared_palette_paints_without_application_state() { let engine = UiTextEngine::default(); let closed = pixels(1, |ctx| { - paint_command_palette(&engine, ctx, &CommandPaletteView::Closed, 800, 600) + paint_command_palette( + &crate::ui::theme::Theme::dark(), + &engine, + ctx, + &CommandPaletteView::Closed, + 800, + 600, + ) }); assert!(closed.iter().all(|byte| *byte == 0)); let view = CommandPaletteView::List(PaletteListView { @@ -132,7 +149,14 @@ fn prepared_palette_paints_without_application_state() { tooltip: None, }); let painted = pixels(1, |ctx| { - paint_command_palette(&engine, ctx, &view, 800, 600) + paint_command_palette( + &crate::ui::theme::Theme::dark(), + &engine, + ctx, + &view, + 800, + 600, + ) }); assert!(painted.iter().any(|byte| *byte != 0)); // The dimmer and the panel have different opacity, so this checks the @@ -140,3 +164,80 @@ fn prepared_palette_paints_without_application_state() { let alpha = |x: usize, y: usize| painted[(y * 800 + x) * 4 + 3]; assert!(alpha(200, 150) > alpha(20, 20)); } + +#[test] +fn palette_theme_refresh_covers_list_capture_and_tooltip() { + let engine = UiTextEngine::default(); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.toggle_command_palette(); + for query in ["", "capture", "No match 測試 🖌️"] { + input.command_palette.set_query(query); + let view = CommandPaletteView::prepare(&input, 800, 480); + let paint = |theme: &crate::ui::theme::Theme| { + pixels(1, |ctx| { + paint_command_palette(theme, &engine, ctx, &view, 800, 480); + }) + }; + let dark = paint(&crate::ui::theme::Theme::dark()); + let light = paint(&crate::ui::theme::Theme::light()); + assert_ne!(dark, light, "query {query}"); + assert_eq!(dark, paint(&crate::ui::theme::Theme::dark())); + if let Some(dir) = std::env::var_os("WAYSCRIBER_PALETTE_REFERENCES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + for (name, theme) in [ + ("dark", crate::ui::theme::Theme::dark()), + ("light", crate::ui::theme::Theme::light()), + ] { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 480).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + paint_command_palette(&theme, &engine, &ctx, &view, 800, 480); + let name = format!( + "palette-{name}-{}.png", + if query.is_empty() { + "groups" + } else if query == "capture" { + "selected" + } else { + "empty" + } + ); + surface + .write_to_png(&mut std::fs::File::create(dir.join(name)).unwrap()) + .unwrap(); + } + } + } + for capture in [false, true] { + let paint = |theme: &crate::ui::theme::Theme| { + pixels(1, |ctx| { + if capture { + render_keybinding_capture( + theme, + &engine, + ctx, + &["Ctrl+C".into()], + crate::config::Action::CopySelection, + 800, + 600, + ); + } else { + draw_command_palette_action_tooltip( + theme, + &engine, + ctx, + "Edit shortcut", + 100.0, + 100.0, + 800.0, + 600.0, + ); + } + }) + }; + let dark = paint(&crate::ui::theme::Theme::dark()); + let light = paint(&crate::ui::theme::Theme::light()); + assert_ne!(dark, light); + assert_eq!(dark, paint(&crate::ui::theme::Theme::dark())); + } +} From 1af696ba3b8181c3bf22a3ea51fb5873d0019702 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:30:36 +0200 Subject: [PATCH 17/28] docs: map configurator workflows and clarify Cairo rendering --- configurator/README.md | 27 +++++++++++++++++++++++---- docs/codebase-overview.md | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/configurator/README.md b/configurator/README.md index 3c9fd7278..51b1c67ac 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -25,9 +25,9 @@ cd configurator cargo run ``` -The configurator renders through GTK 4 and libadwaita. It does not compile the -overlay's GPU renderer or the portal D-Bus implementation into the configurator -binary. +The configurator renders through GTK 4 and libadwaita. The overlay paints with Cairo +into Wayland shared-memory buffers. The configurator depends on the core crate with +`default-features = false`, so it does not enable the optional portal or tray runtime. The window loads the current config, lets you tweak values across the tabbed sections, and writes changes back through the guarded `ConfigDocument` save interface when you press Save. Loading, @@ -98,7 +98,26 @@ Each workflow module owns a related set of operations: - `app/shortcut_workflow.rs` keeps shortcut recording, text editing, and conflict resolution separate. Only one can be active at a time. - `app/daemon_workflow.rs` manages background setup actions, status request identities, and typed feedback. -App update handlers coordinate draft changes and UI effects. +The GTK shell and refresh code live in [component/](src/app/component/); page builders in +[pages/](src/app/pages/) bind widgets to draft values and emit [messages](src/messages.rs). +[Update handlers](src/app/update/) change the model and return typed [effects](src/app/effects.rs). +[Effect execution](src/app/component/effects.rs) schedules work; [I/O](src/app/io.rs) uses +[blocking jobs](src/app/blocking_jobs.rs) for filesystem operations. Page callbacks do not write files. +[HistoryDraft](src/models/config/history.rs) shows the section-owned conversion and validation pattern. + +For a save, the handler validates the draft, transfers the guarded `ConfigDocument` from +`DocumentWorkflow` to `SaveConfig`, and freezes editing. The blocking job merges known settings +and performs the guarded atomic write. `ConfigSaved` returns the document on success or failure; +success establishes the clean baseline, while failure restores editing and keeps useful errors. +A pending Reload or close continues only after success. Canceling the unsaved-changes prompt +keeps the draft; it does not attempt to cancel a durable write already in progress. + +See the [application map](../docs/codebase-overview.md) for shape edits and capture, and +[CONTRIBUTING](../CONTRIBUTING.md) for setup. From the repository root, use +`cargo test -p wayscriber-configurator` for focused coverage and `./tools/lint-and-test.sh` +for the canonical workspace checks used by CI. `./tools/test-gtk-widgets.sh` runs required +GTK toolbar assertions on a private headless compositor. These checks do not prove behavior +on an installed desktop or screen-reader announcements. Saves use `Config::validate_for_save` from the core crate. It compares persisted typed values to detect changes outside keybindings and rejects those changes. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index dcf9a8e21..e56c646e8 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -1,6 +1,6 @@ -# Wayscriber Codebase Overview (Except Configurator) +# Wayscriber Codebase Overview -This document explains how the application boots, how user input travels through the system, and how the major modules fit together. Use it as a map when adding features or debugging. The configurator binary lives in `configurator/` and is intentionally excluded here. +This document explains how the application boots, how user input travels through the system, and how the major modules fit together. Use it as a map when adding features or debugging. The configurator binary lives in `configurator/`; its [workflow map](../configurator/README.md#workflow-ownership) covers GTK bindings, messages, updates, jobs, and document saves. --- @@ -26,7 +26,7 @@ This document explains how the application boots, how user input travels through ## 2. Daemon Mode Lifecycle -**Modules:** `src/daemon/` (control, core, overlay, tray, shortcuts, and setup), plus the public +**Modules:** `src/daemon/` (control, core, overlay, tray, shortcuts, and setup), plus the internal backend entry in `src/backend/mod.rs`. 1. The app creates the authenticated process broker before any singleton lock. `Daemon::run` then @@ -58,7 +58,7 @@ Daemon mode therefore provides a persistent background service that reacts to us ## 3. Active Mode / Wayland Backend **Modules:** -- `src/backend/mod.rs`: exported API (`run_wayland`) +- `src/backend/mod.rs`: internal runtime entry (`run_wayland`); the public application facade is `wayscriber::run_from_env()` - `src/backend/wayland/backend/`: high-level bootstrap, setup, and event loop - `src/backend/wayland/state.rs`: runtime state (surfaces, buffers, runtime handles) - `src/backend/wayland/handlers/`: Smithay trait implementations and protocol handlers @@ -604,3 +604,30 @@ capture suppression operates on the paired resources without runtime pairing che 8. **Config** module ensures user preferences are honored everywhere. Use this document to trace any feature: locate the entry point (CLI, tray, keybinding), follow it through the backend/input/capture stacks, and consult the relevant modules listed above for details. + + +## Operation traces and validation + +A selected-shape edit captures originals through [CanvasEdit](../src/input/state/core/editing.rs). +Motion may update the preview, but commit records one compound undo action for the gesture. +The resulting edit effects invalidate the affected drawing regions and mark the session dirty. +Undo/redo restores the grouped shapes; session snapshot capture later copies the configured +history retention without mutating the live frame. Rendering and persistence are separate effects. + +Capture crosses [CapturePreflight](../src/backend/wayland/capture_preflight.rs), which coordinates +suppression and the capture barrier before acquisition. Freeze and zoom own their pending portal +operation, task result, deadline, and restore state in [frozen/](../src/backend/wayland/frozen/) +and [zoom/](../src/backend/wayland/zoom/). Cancellation, task failure, and stale output geometry +restore or reject the operation through those owners; a late result does not activate a newer request. +The backend owns protocol and surface effects, while capture/export owners handle delivery. + +A configurator save follows the [document workflow trace](../configurator/README.md#workflow-ownership). +Its editable draft is distinct from the guarded source document and from a durable write in flight. +Raw invalid editor input remains visible and must be resolved before saving. + +Use `./tools/lint-and-test.sh` for package/source checks, formatting, linting, binary builds, +and tests under both workspace feature configurations. Required GTK widget coverage is +`./tools/test-gtk-widgets.sh`. For the native font crash, use the separate reproducer in +[tools/diagnostics](../tools/diagnostics/); a passing application suite or serial run does not +establish a native-library fix. Live Wayland focus, layer-shell, capture, and installed-binary +checks remain separate from these automated checks. From 5585ad25855da7a65d4316e3a411e97d5403df9c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:32:41 +0200 Subject: [PATCH 18/28] perf(render): measure sparse damage scans and retain linear replay --- .../state/render/canvas/resource_tests.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs index afd084588..bdef6ff16 100644 --- a/src/backend/wayland/state/render/canvas/resource_tests.rs +++ b/src/backend/wayland/state/render/canvas/resource_tests.rs @@ -336,3 +336,92 @@ fn each_scene_key_rebakes_without_shape_identity_changes() { ); } } + +#[test] +#[ignore = "release timing workload; run with --release --ignored --nocapture"] +fn measure_sparse_damage_scan() { + let measurer = crate::draw::TextMeasurer::default(); + let layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1920, 1080).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let geometry = FrameGeometry::new(1920, 1080, 1); + let frame = CanvasFrame { + draw_committed: true, + render_transients: false, + transform_active: false, + origin: (0.0, 0.0), + zoom_scale: None, + text_halo_enabled: true, + layer_cache_eligible: false, + }; + let damage = [crate::util::Rect { + x: 0, + y: 0, + width: 32, + height: 32, + }]; + let canvas = CanvasRenderCtx { + cairo: &ctx, + geometry: &geometry, + canvas: &frame, + damage_world: &damage, + now: Instant::now(), + }; + let replay = crate::draw::EraserReplayContext { + pattern: None, + surface: None, + backdrop_cache_key: None, + bg_color: None, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + }; + for count in [100, 1_000, 10_000] { + let shapes: Vec<_> = (0..count) + .map(|i| { + let x = (i % 100) * 18; + let y = (i / 100) * 10; + DrawnShape::with_metadata( + i as u64, + Shape::Line { + x1: x, + y1: y, + x2: x + 8, + y2: y + 5, + color: Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + thick: 2.0, + }, + 0, + false, + ) + }) + .collect(); + let mut perf = PerfRenderBreakdown::default(); + let start = Instant::now(); + for _ in 0..500 { + render_committed_canvas_shapes( + &measurer, + &shapes, + &layer, + &mut caches, + &canvas, + false, + &replay, + Some(&mut perf), + ); + } + eprintln!( + "P03 shapes={count} tested={} rendered={} mean_us={:.2}", + perf.shapes_tested, + perf.shapes_rendered, + start.elapsed().as_micros() as f64 / 500.0 + ); + } +} From 7897a4c34a88bce7fe156cdcd286a2b8f466f2ee Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:32:42 +0200 Subject: [PATCH 19/28] perf(ui): measure thumbnail replay and document cache prerequisites --- .../page_panel/thumbnail/content.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 16cd1d669..8fd817567 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -507,3 +507,73 @@ mod tests { ); } } + +#[cfg(test)] +mod measurement { + use super::*; + use crate::draw::{Color, DrawnShape, Frame, Shape}; + + #[test] + #[ignore = "release timing workload; run with --release --ignored --nocapture"] + fn measure_unchanged_thumbnail_replay() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 240, 160).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + let mut caches = crate::draw::RenderCaches::default(); + let mut render = crate::draw::RenderCtx { + cairo: &ctx, + caches: &mut caches, + }; + for count in [100, 1_000, 10_000] { + let mut frame = Frame::new(); + frame.shapes = (0..count) + .map(|i| { + let x = (i % 100) * 18; + let y = (i / 100) * 10; + DrawnShape::with_metadata( + i as u64, + Shape::Line { + x1: x, + y1: y, + x2: x + 8, + y2: y + 5, + color: Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + thick: 2.0, + }, + 0, + false, + ) + }) + .collect(); + let start = std::time::Instant::now(); + for _ in 0..100 { + render_page_content( + &engine, + &measurer, + PageContentArgs { + render: &mut render, + frame: &frame, + background: &BoardBackground::Transparent, + x: 0.0, + y: 0.0, + width: 240.0, + height: 160.0, + screen_width: 1920, + screen_height: 1080, + text_halo_enabled: true, + }, + ); + } + eprintln!( + "P04 shapes={count} replays=100 mean_us={:.2}", + start.elapsed().as_micros() as f64 / 100.0 + ); + } + } +} From dc63d2728638bcc29dfe4873fa4bf4ecfbd2d586 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:32:42 +0200 Subject: [PATCH 20/28] perf(toolbar): measure unchanged snapshot construction before caching --- src/ui/toolbar/snapshot/build.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 7e5898609..1e56b1e9e 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -301,3 +301,26 @@ mod tests { } } } + +#[cfg(test)] +mod measurement { + use super::*; + + #[test] + #[ignore = "release timing workload; run with --release --ignored --nocapture"] + fn measure_unchanged_toolbar_snapshot() { + let state = crate::input::state::test_support::make_test_input_state(); + let start = Instant::now(); + let mut changes = 0; + let mut previous = ToolbarSnapshot::from_input(&state); + for _ in 0..10_000 { + let snapshot = ToolbarSnapshot::from_input(&state); + changes += usize::from(snapshot != previous); + previous = std::hint::black_box(snapshot); + } + eprintln!( + "P05 constructions=10000 changed={changes} mean_us={:.2}", + start.elapsed().as_micros() as f64 / 10_000.0 + ); + } +} From 729a93a2560c7b54a6e7f954e0de6314ee2e9bd0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:35:14 +0200 Subject: [PATCH 21/28] refactor(capture): exercise cancellation through task ownership --- src/backend/wayland/portal_task.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/backend/wayland/portal_task.rs b/src/backend/wayland/portal_task.rs index ce0217814..738fea3a3 100644 --- a/src/backend/wayland/portal_task.rs +++ b/src/backend/wayland/portal_task.rs @@ -146,13 +146,6 @@ where pub(super) fn timed_out(&self, now: Instant) -> bool { self.timeout(now).is_zero() } - - pub(super) fn cancel(&mut self) { - self.expected_cancel.store(true, Ordering::Release); - if let Some(worker) = self.worker.take() { - worker.abort(); - } - } } impl Drop for PortalTask { @@ -263,7 +256,7 @@ mod tests { #[tokio::test] async fn expected_cancel_aborts_without_waking_failure() { let wake = RuntimeWakeSource::new().unwrap(); - let mut task = PortalTask::spawn( + let task = PortalTask::spawn( &tokio::runtime::Handle::current(), wake.handle(), async move { @@ -271,7 +264,7 @@ mod tests { 1 }, ); - task.cancel(); + drop(task); tokio::task::yield_now().await; let mut pollfd = libc::pollfd { fd: wake.poll_fd().as_raw_fd(), From f7dbf2c314eff720add87ed39aea41effeb275fa Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:35:14 +0200 Subject: [PATCH 22/28] test(configurator): separate leave-flow scenarios for strict linting --- configurator/src/app/update/config/tests.rs | 128 ++++++++++---------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/configurator/src/app/update/config/tests.rs b/configurator/src/app/update/config/tests.rs index 7a9c5a11f..d71577add 100644 --- a/configurator/src/app/update/config/tests.rs +++ b/configurator/src/app/update/config/tests.rs @@ -1399,75 +1399,77 @@ fn document_operations_freeze_queued_edits_until_completion() { #[test] fn leave_dirty_draft_requires_a_current_decision_and_successful_save() { - use crate::messages::{CommandMessage, Message}; - for close in [false, true] { for save_succeeds in [false, true] { - let (mut app, _) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document("leave-draft", ""); - app.update_command(CommandMessage::ConfigLoaded(Ok((document, None)))); - let request = if close { - Message::CloseRequested - } else { - Message::ReloadRequested - }; - let clean = app.update_message(request.clone()); - assert!(matches!( - clean.as_slice(), - [Effect::CloseWindow] | [Effect::LoadConfig] - )); - if !close { - app.update_command(CommandMessage::ConfigLoaded(Err("failed reload".into()))); - } - app.update_message(Message::ToggleChanged( - ToggleField::CaptureEnabled, - !app.draft.capture_enabled, - )); - assert!(app.update_message(request.clone()).is_empty()); - assert!(app.pending_leave().is_some()); - app.update_message(Message::LeaveCanceled); - assert!(app.pending_leave().is_none()); - assert!(app.is_dirty); - - app.update_message(request.clone()); - app.update_message(Message::BoardsAddItem); - assert!(app.pending_leave().is_none()); - assert!( - app.update_message(Message::LeaveDiscardRequested) - .is_empty() - ); - app.update_message(request.clone()); - let effects = app.update_message(Message::LeaveSaveRequested); - let [Effect::SaveConfig { document, .. }]: [Effect; 1] = effects.try_into().unwrap() - else { - panic!("expected a save before leaving"); - }; - let effects = app.update_command(CommandMessage::ConfigSaved(if save_succeeds { - Ok((None, document)) - } else { - Err((Some(document), "failed save".into())) - })); - if save_succeeds { - assert!(matches!( - effects.as_slice(), - [Effect::CloseWindow] | [Effect::LoadConfig] - )); - } else { - assert!(effects.is_empty()); - assert!(app.is_dirty); - assert!(app.document.after_save.is_none()); - app.update_message(request); - assert!(matches!( - app.update_message(Message::LeaveDiscardRequested) - .as_slice(), - [Effect::CloseWindow] | [Effect::LoadConfig] - )); - } - std::fs::remove_file(path).unwrap(); + check_leave_dirty_draft(close, save_succeeds); } } } +fn check_leave_dirty_draft(close: bool, save_succeeds: bool) { + use crate::messages::{CommandMessage, Message}; + let (mut app, _) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("leave-draft", ""); + app.update_command(CommandMessage::ConfigLoaded(Ok((document, None)))); + let request = if close { + Message::CloseRequested + } else { + Message::ReloadRequested + }; + let clean = app.update_message(request.clone()); + assert!(matches!( + clean.as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + if !close { + app.update_command(CommandMessage::ConfigLoaded(Err("failed reload".into()))); + } + app.update_message(Message::ToggleChanged( + ToggleField::CaptureEnabled, + !app.draft.capture_enabled, + )); + assert!(app.update_message(request.clone()).is_empty()); + assert!(app.pending_leave().is_some()); + app.update_message(Message::LeaveCanceled); + assert!(app.pending_leave().is_none()); + assert!(app.is_dirty); + + app.update_message(request.clone()); + app.update_message(Message::BoardsAddItem); + assert!(app.pending_leave().is_none()); + assert!( + app.update_message(Message::LeaveDiscardRequested) + .is_empty() + ); + app.update_message(request.clone()); + let effects = app.update_message(Message::LeaveSaveRequested); + let [Effect::SaveConfig { document, .. }]: [Effect; 1] = effects.try_into().unwrap() else { + panic!("expected a save before leaving"); + }; + let effects = app.update_command(CommandMessage::ConfigSaved(if save_succeeds { + Ok((None, document)) + } else { + Err((Some(document), "failed save".into())) + })); + if save_succeeds { + assert!(matches!( + effects.as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + } else { + assert!(effects.is_empty()); + assert!(app.is_dirty); + assert!(app.document.after_save.is_none()); + app.update_message(request); + assert!(matches!( + app.update_message(Message::LeaveDiscardRequested) + .as_slice(), + [Effect::CloseWindow] | [Effect::LoadConfig] + )); + } + std::fs::remove_file(path).unwrap(); +} + #[test] fn leave_protects_raw_color_and_unapplied_shortcut_input() { use crate::messages::{CommandMessage, Message}; From 860795aaaea6ee9393b22f5102e15ca96a07a66e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:35:14 +0200 Subject: [PATCH 23/28] test(toolbar): order slider contract helper before test module --- src/toolbar_gtk/widgets/slider.rs | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs index 728a43bca..3e4dcb042 100644 --- a/src/toolbar_gtk/widgets/slider.rs +++ b/src/toolbar_gtk/widgets/slider.rs @@ -220,27 +220,6 @@ fn keyboard_value(spec: ToolbarSliderSpec, value: f64, key: gtk4::gdk::Key) -> O Some(spec.normalize_value(value)) } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn keyboard_uses_shared_snapping_and_clamps_endpoints() { - use gtk4::gdk::Key; - let spec = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION; - assert_eq!(keyboard_value(spec, spec.min, Key::Left), Some(spec.min)); - assert_eq!(keyboard_value(spec, spec.max, Key::Up), Some(spec.max)); - assert_eq!(keyboard_value(spec, spec.min, Key::End), Some(spec.max)); - assert_eq!(keyboard_value(spec, spec.max, Key::Home), Some(spec.min)); - let next = keyboard_value(spec, spec.min, Key::Right).unwrap(); - assert_eq!(next, spec.value_from_t(spec.t_from_value(next))); - assert_eq!( - keyboard_value(spec, spec.min, Key::Page_Up), - Some(spec.normalize_value(spec.min + spec.step.unwrap() * 10.0)) - ); - assert_eq!(keyboard_value(spec, spec.min, Key::Escape), None); - } -} - /// Called by the isolated GTK widget test after initialization. #[cfg(test)] pub(super) fn assert_widget_contract() { @@ -295,3 +274,24 @@ pub(super) fn assert_widget_contract() { "backend updates emit no user event" ); } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn keyboard_uses_shared_snapping_and_clamps_endpoints() { + use gtk4::gdk::Key; + let spec = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION; + assert_eq!(keyboard_value(spec, spec.min, Key::Left), Some(spec.min)); + assert_eq!(keyboard_value(spec, spec.max, Key::Up), Some(spec.max)); + assert_eq!(keyboard_value(spec, spec.min, Key::End), Some(spec.max)); + assert_eq!(keyboard_value(spec, spec.max, Key::Home), Some(spec.min)); + let next = keyboard_value(spec, spec.min, Key::Right).unwrap(); + assert_eq!(next, spec.value_from_t(spec.t_from_value(next))); + assert_eq!( + keyboard_value(spec, spec.min, Key::Page_Up), + Some(spec.normalize_value(spec.min + spec.step.unwrap() * 10.0)) + ); + assert_eq!(keyboard_value(spec, spec.min, Key::Escape), None); + } +} From c963be91aecbb497adeada3c06ee7847462cc738 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:37:14 +0200 Subject: [PATCH 24/28] test(domain): include shared stroke limits in source inventory --- src/domain/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/tests.rs b/src/domain/tests.rs index bf697d2b5..d9dc64d19 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -396,7 +396,7 @@ fn production_domain_sources_have_no_upward_crate_dependencies() { } assert_eq!( - checked, 6, + checked, 7, "architecture test must cover every domain source" ); } From 127f7a1ce25c8b93f54b77be73bbc2f82a2d8539 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:37:14 +0200 Subject: [PATCH 25/28] docs(render): record native stack from final concurrent validation --- tools/diagnostics/native-fonts/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/diagnostics/native-fonts/README.md b/tools/diagnostics/native-fonts/README.md index 5b6657298..724122809 100644 --- a/tools/diagnostics/native-fonts/README.md +++ b/tools/diagnostics/native-fonts/README.md @@ -47,3 +47,12 @@ run does not establish that mechanism or validate that patch. No library or application fix is claimed. A passing rerun or serial run would not prove the race fixed. Keep native diagnosis separate from Rust regression results and from compositor verification. + +The final 2026-09-07 canonical all-feature test run also terminated with signal +11 in `retained_context_menu_owner_preserves_layout_pixels_and_row_hits`. +The captured stack passed through HarfBuzz `hb_shape_full`, FreeType +`FT_Load_Glyph`, Cairo `cairo_show_glyphs`, and Pango rendering. This is additional +current native-path evidence, not proof that this crash and the standalone +harness have the same root cause. Serial regression checks are reported +separately in the implementation status; they do not turn this failure into a +passing canonical run. From 4b12f31a02ff041362c0780a784edff9a6c20631 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:16:43 +0200 Subject: [PATCH 26/28] docs: fold toolbar compatibility guidance into maintained guides --- docs/CONFIG.md | 2 +- docs/codebase-overview.md | 7 +- docs/toolbar-item-id-compatibility.md | 135 -------------------------- 3 files changed, 6 insertions(+), 138 deletions(-) delete mode 100644 docs/toolbar-item-id-compatibility.md diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 073e39b66..83808c9a2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1413,7 +1413,7 @@ top_controls = [ - **Shown items**: `ui.toolbar.items.shown` pins sections visible against the layout-mode baseline. Together with `hidden` these are the single visibility store: the `show_*` booleans are written as read-only mirrors for older versions, and legacy configs fold into explicit overrides at load. - **Layout modes are non-destructive presets**: switching Simple/Regular/Advanced re-baselines section visibility without erasing your explicit toggles; Advanced is selectable from the overlay's Settings popover, and the top strip's chrome-island layout button cycles Simple → Regular → Advanced one click at a time. The section ids `side.group.actions-advanced`, `side.group.zoom-actions`, and `side.group.text-controls` carry the advanced/zoom/persistent-text overrides. Switching modes from the overlay re-baselines the current run only; set the durable `layout_mode` in the configurator. Sections you pinned through `items.shown`/`items.hidden` keep their override under every mode. - **Item order**: `ui.toolbar.items.order.top_tools` and `top_controls` reorder supported top-strip items. Unknown future IDs and wrong-group IDs are ignored at runtime but preserved across saves. Panel-era order lists (`actions`, `pages`, `boards`, `presets`, `tool_options`, `sessions`, `side_sections`) are retired: authored `config.toml` values stay as retired settings, while matching keys under `runtime-ui.toml`'s recognized `item_order` map are pruned on rewrite. -- **Historical item IDs**: retained and removed `side.*` IDs are classified in the [toolbar item ID compatibility inventory](toolbar-item-id-compatibility.md). Retained spellings remain active configuration contracts even though the side palette is gone. +- **Historical item IDs**: some `side.*` IDs still control sections and commands in the unified top toolbar. Their serialized spellings remain configuration contracts even though the side palette is gone. Removed IDs in `items.hidden` and `items.shown` are preserved as unknown values across saves but have no runtime effect. - **Live customization**: the overlay Customize surface supports show/hide, move up/down, and drag reorder for supported top groups. The configurator supports the same saved order with up/down controls. - **Top strip items**: `top.group.quick-colors` (the swatch row + current-color chip) and `top.utility.undo`/`top.utility.redo` are hideable ids. `top.chrome.overflow` is a structural affordance that appears whenever its menu has content — which is always: the menu anchors Clear (`top.utility.clear-canvas`, unless that item is hidden), anything width pressure moves into it, and the non-hideable "Session..." / "Settings..." popover entries. The icon/text mode toggle lives in the Settings popover. - **Clear canvas**: Clear lives in the top strip's overflow (⋯) menu. A plain click clears and shows a short "Cleared — Undo?" toast; Shift+click clears instantly without the toast. The `clear_canvas` keyboard action is always instant and shows no toast. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index e56c646e8..4a9ce3f79 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -270,8 +270,11 @@ phase. Their backend selection policies and acquisition identities remain separa retaining GTK sizing, CSS, updater closures, drag gestures, and popover lifecycle. - Shape-picker compound rows remain frontend-specific adapters over the shared top model. Canvas, Session, and Settings live in top popovers built from the same shared models; there is no side - palette layout path. The [toolbar item ID compatibility inventory](toolbar-item-id-compatibility.md) - records which historical `side.*` serialized IDs still feed those models. + palette layout path. Retained historical `side.*` IDs are defined in + [`ids.rs`](../src/config/types/toolbar/ids.rs) and + [`items/definitions.rs`](../src/config/types/toolbar/items/definitions.rs). + Changes to their serialized spellings require compatibility review and updates to the + owning model tests. --- diff --git a/docs/toolbar-item-id-compatibility.md b/docs/toolbar-item-id-compatibility.md deleted file mode 100644 index 73e58535f..000000000 --- a/docs/toolbar-item-id-compatibility.md +++ /dev/null @@ -1,135 +0,0 @@ -# Toolbar Item ID Compatibility Inventory - -This inventory records the consumer-based decision for every historical -`side.*` toolbar item ID that existed when the legacy side palette was removed. -The serialized spelling is a configuration contract: an ID remains recognized -only when the unified top toolbar still consumes it. - -The code sources of truth are -[`ids.rs`](../src/config/types/toolbar/ids.rs) and -[`items/definitions.rs`](../src/config/types/toolbar/items/definitions.rs). -Do not add, remove, or rename a historical ID without updating this inventory -and the owning model test. - -Proof references used below: - -- **Definitions** — `toolbar_item_definitions_are_unique_parseable_and_labeled` - proves every retained ID is defined and parsed. -- **Sections** — `config::types::toolbar::visibility::tests` proves explicit - section visibility overrides beat layout-mode baselines. -- **Models** — the action, session, and settings model tests exhaustively map - commands to retained IDs; visibility tests also prove that retained IDs hide - their corresponding unified-top controls. -- **Document** — `retired_toolbar_settings_are_preserved_and_diagnosed` proves - representative retained overrides survive an unrelated document save. - -## Retained IDs - -| Serialized ID | Unified-top consumer | Proof | -| --- | --- | --- | -| `side.group.step-undo` | Canvas popover Step Undo/Redo section | Definitions, Sections | -| `side.group.actions` | Canvas popover basic Actions section | Definitions, Sections, Models | -| `side.group.pages` | Canvas popover Pages section | Definitions, Sections, Models | -| `side.group.boards` | Canvas popover Boards section | Definitions, Sections, Models | -| `side.group.presets` | Top-strip presets island | Definitions, Sections, Document | -| `side.group.actions-advanced` | Canvas popover Advanced section | Definitions, Sections, Models | -| `side.group.zoom-actions` | Canvas popover Zoom section | Definitions, Sections, Models | -| `side.group.text-controls` | Contextual style-pill text controls | Definitions, Sections | -| `side.actions.zoom-in` | Canvas Zoom command | Definitions, Models, Document | -| `side.actions.zoom-out` | Canvas Zoom command | Definitions, Models | -| `side.actions.reset-zoom` | Canvas Zoom command | Definitions, Models | -| `side.actions.toggle-zoom-lock` | Canvas Zoom command | Definitions, Models | -| `side.actions.undo-all` | Canvas Advanced command | Definitions, Models | -| `side.actions.redo-all` | Canvas Advanced command | Definitions, Models | -| `side.actions.undo-all-delayed` | Canvas Advanced command | Definitions, Models | -| `side.actions.redo-all-delayed` | Canvas Advanced command | Definitions, Models | -| `side.actions.freeze` | Canvas Advanced command | Definitions, Models, Document | -| `side.pages.previous` | Canvas Pages command | Definitions, Models | -| `side.pages.next` | Canvas Pages command | Definitions, Models | -| `side.pages.new` | Canvas Pages command | Definitions, Models | -| `side.pages.duplicate` | Canvas Pages command | Definitions, Models | -| `side.pages.delete` | Canvas Pages command | Definitions, Models, Document | -| `side.boards.picker` | Canvas Boards picker command | Definitions, Models, Document | -| `side.boards.previous` | Canvas Boards command | Definitions, Models | -| `side.boards.next` | Canvas Boards command | Definitions, Models | -| `side.boards.new` | Canvas Boards command | Definitions, Models | -| `side.boards.duplicate` | Canvas Boards command | Definitions, Models | -| `side.boards.delete` | Canvas Boards command | Definitions, Models, Document | -| `side.settings.context-aware-ui` | Settings popover toggle | Definitions, Models | -| `side.settings.text-controls` | Settings popover toggle | Definitions, Models | -| `side.settings.status-bar` | Settings popover toggle | Definitions, Models | -| `side.settings.status-board-badge` | Settings popover toggle | Definitions, Models | -| `side.settings.status-page-badge` | Settings popover toggle | Definitions, Models | -| `side.settings.floating-badge-always` | Settings popover toggle | Definitions, Models | -| `side.settings.preset-toasts` | Settings popover toggle | Definitions, Models | -| `side.settings.presets` | Settings popover toggle | Definitions, Models | -| `side.settings.actions` | Settings popover toggle | Definitions, Models | -| `side.settings.zoom-actions` | Settings popover toggle | Definitions, Models | -| `side.settings.advanced-actions` | Settings popover toggle | Definitions, Models | -| `side.settings.boards` | Settings popover toggle | Definitions, Models | -| `side.settings.pages` | Settings popover toggle | Definitions, Models | -| `side.settings.step-controls` | Settings popover toggle | Definitions, Models | -| `side.settings.command-palette` | Settings popover command | Definitions, Models | -| `side.settings.configurator` | Settings popover command | Definitions, Models | -| `side.settings.config-file` | Settings popover command | Definitions, Models | -| `side.settings.about` | Settings popover command | Definitions, Models, Document | -| `side.session.open` | Session popover command | Definitions, Models | -| `side.session.save-as` | Session popover command | Definitions, Models | -| `side.session.info` | Session popover command | Definitions, Models, Document | -| `side.session.clear` | Session popover command | Definitions, Models | -| `side.session.manager` | Session popover configurator command | Definitions, Models | - -## Removed IDs - -Removed IDs are deliberately unknown to the active parser. Authored strings in -`items.hidden` and `items.shown` still round-trip as unknown raw values; they do -not control runtime UI. - -| Serialized ID | Consumer trace and disposition | -| --- | --- | -| `side.group.colors` | Panel Draw card only; the style pill uses `top.group.quick-colors`. | -| `side.group.thickness` | Panel Draw card only; thickness is contextual style-pill state. | -| `side.group.eraser-mode` | Panel Draw card only; eraser mode is contextual style-pill state. | -| `side.group.polygon-sides` | Panel Draw card only; polygon sides are contextual style-pill state. | -| `side.group.arrow-labels` | Panel Draw card only; arrow labels are contextual style-pill state. | -| `side.group.step-markers` | Panel Draw card only; step-marker reset is contextual style-pill state. | -| `side.group.marker-opacity` | Panel Draw card only; opacity is contextual style-pill state. | -| `side.group.text-size` | Panel Draw card only; text size is contextual style-pill state. | -| `side.group.font` | Panel Draw card only; font controls are contextual style-pill state. | -| `side.group.settings` | Panel container only; Settings is an unconditional top popover host. | -| `side.group.session` | Panel container only; Session is an unconditional top popover host. | -| `side.actions.undo` | Panel button only; the top control uses `top.utility.undo`. | -| `side.actions.redo` | Panel button only; the top control uses `top.utility.redo`. | -| `side.actions.clear-canvas` | Panel button only; the top control uses `top.utility.clear-canvas`. | -| `side.boards.rename` | No unified-top button or toolbar event consumer. | -| `side.tool-options.color` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.thickness` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.marker-opacity` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.eraser-mode` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.font-size` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.font-family` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.polygon-sides` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.arrow-labels` | Panel Draw control only; the style pill uses its typed tool model. | -| `side.tool-options.step-marker-reset` | Panel Draw control only; the style pill uses its typed tool model. | - -## Retired order fields - -The unified toolbar has only the `top_tools` and `top_controls` order groups. -The following panel-era fields are preserved as authored raw TOML, reported by -their exact path as `RetiredSetting`, and omitted from the typed schema and -configurator. They have no aliases. - -| Source TOML path | Former consumer | Disposition and proof | -| --- | --- | --- | -| `ui.toolbar.items.order.side_sections` | Side-palette section renderer and its reorder UI | Retired with the panel; covered before and after an unrelated save by `retired_toolbar_settings_are_preserved_and_diagnosed`. | -| `ui.toolbar.items.order.actions` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw; covered by the same preservation/diagnostic fixture. | -| `ui.toolbar.items.order.pages` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw; covered by the same preservation/diagnostic fixture. | -| `ui.toolbar.items.order.boards` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw; covered by the same preservation/diagnostic fixture. | -| `ui.toolbar.items.order.presets` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw; `side.group.presets` remains a separate active visibility ID. Covered by the same preservation/diagnostic fixture. | -| `ui.toolbar.items.order.tool_options` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw with the unused `side.tool-options.*` namespace; covered by the same preservation/diagnostic fixture. | -| `ui.toolbar.items.order.sessions` | Typed order resolver and runtime preference map; no unified-top renderer or reorder UI consumed it | Retired raw; covered by the same preservation/diagnostic fixture. | - -The exact-path classifier lives in -[`document.rs`](../src/config/document.rs). The document fixture also proves -that active `top_tools` and `top_controls` orders retain their runtime effect; -the shared-model test proves both orders reach their unified-top controls. From 3d9ca724a7e62ac5d8302b0fad25a23e7b2f5ba0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:50:12 +0200 Subject: [PATCH 27/28] Fix review regressions and tighten state ownership --- configurator/README.md | 7 +- configurator/src/app/document_workflow.rs | 95 ++++-- configurator/src/app/pages/capture.rs | 26 +- configurator/src/app/pages/keybindings/row.rs | 17 +- configurator/src/app/state.rs | 2 +- configurator/src/app/update/config/leave.rs | 6 +- configurator/src/app/update/config/save.rs | 63 ++-- configurator/src/app/update/config/tests.rs | 30 +- configurator/src/app/update/fields.rs | 2 +- configurator/src/models/config/capture.rs | 98 ++++++ .../src/models/config/draft/from_config.rs | 17 +- configurator/src/models/config/draft/mod.rs | 17 +- configurator/src/models/config/mod.rs | 1 + configurator/src/models/config/setters.rs | 22 +- configurator/src/models/config/tests.rs | 16 +- .../src/models/config/to_config/capture.rs | 40 --- .../src/models/config/to_config/mod.rs | 3 +- .../src/models/keybindings/analysis.rs | 94 ++++++ .../src/models/keybindings/conflicts.rs | 48 +-- configurator/src/models/keybindings/edit.rs | 39 +-- .../src/models/keybindings/manager.rs | 60 ++-- configurator/src/models/keybindings/mod.rs | 9 +- docs/codebase-overview.md | 20 ++ src/backend/wayland/state/render/runtime.rs | 8 +- src/backend/wayland/state/render/ui.rs | 3 +- src/backend/wayland/toolbar/view/top/build.rs | 17 +- src/config/validate/boards.rs | 2 +- src/domain/board_validation.rs | 97 ++++++ src/domain/mod.rs | 4 + src/domain/tests.rs | 2 +- src/draw/frame/core.rs | 9 +- src/draw/frame/mod.rs | 2 + src/draw/frame/serde.rs | 2 +- src/draw/frame/shapes.rs | 123 ++++++++ src/input/boards/color.rs | 11 +- src/input/boards/identity.rs | 85 +----- src/input/state/core/command_palette/input.rs | 28 +- .../state/core/command_palette/layout.rs | 2 +- src/input/state/core/command_palette/mod.rs | 29 ++ .../state/core/command_palette/search.rs | 85 ++++-- src/input/state/core/command_palette/state.rs | 81 ++++- src/input/state/core/editing/tests.rs | 4 + src/input/state/core/utility/actions.rs | 6 + src/toolbar_gtk/view/top_bar/style_pill.rs | 13 +- src/toolbar_gtk/widgets/slider.rs | 46 +++ src/ui/board_picker.rs | 12 + src/ui/board_picker/page_panel.rs | 4 + src/ui/board_picker/page_panel/thumbnail.rs | 2 + .../page_panel/thumbnail/cache.rs | 279 ++++++++++++++++++ .../page_panel/thumbnail/cache/tests.rs | 224 ++++++++++++++ .../page_panel/thumbnail/cards.rs | 11 +- src/ui/board_picker/tests.rs | 1 + src/ui/render_context.rs | 1 + src/ui/toolbar/model/mod.rs | 4 +- src/ui/toolbar/model/style_pill.rs | 20 +- src/ui/toolbar/model/style_pill/control.rs | 114 +++---- src/ui/toolbar/model/style_pill/slider.rs | 42 +++ .../model/style_pill/tests/tool_states.rs | 30 +- tools/check-shared-dependencies.py | 6 +- 59 files changed, 1601 insertions(+), 540 deletions(-) create mode 100644 configurator/src/models/config/capture.rs delete mode 100644 configurator/src/models/config/to_config/capture.rs create mode 100644 configurator/src/models/keybindings/analysis.rs create mode 100644 src/domain/board_validation.rs create mode 100644 src/draw/frame/shapes.rs create mode 100644 src/ui/board_picker/page_panel/thumbnail/cache.rs create mode 100644 src/ui/board_picker/page_panel/thumbnail/cache/tests.rs create mode 100644 src/ui/toolbar/model/style_pill/slider.rs diff --git a/configurator/README.md b/configurator/README.md index 51b1c67ac..fe97a046d 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -103,12 +103,17 @@ The GTK shell and refresh code live in [component/](src/app/component/); page bu [Update handlers](src/app/update/) change the model and return typed [effects](src/app/effects.rs). [Effect execution](src/app/component/effects.rs) schedules work; [I/O](src/app/io.rs) uses [blocking jobs](src/app/blocking_jobs.rs) for filesystem operations. Page callbacks do not write files. -[HistoryDraft](src/models/config/history.rs) shows the section-owned conversion and validation pattern. +[HistoryDraft](src/models/config/history.rs) and [CaptureDraft](src/models/config/capture.rs) +own their section values, conversion and validation, including intermediate text. +Shortcut summaries parse each field once per draft for an analysis pass, then reuse those values +for conflict detection, row flags and default comparisons. No parsed cache survives the refresh. For a save, the handler validates the draft, transfers the guarded `ConfigDocument` from `DocumentWorkflow` to `SaveConfig`, and freezes editing. The blocking job merges known settings and performs the guarded atomic write. `ConfigSaved` returns the document on success or failure; success establishes the clean baseline, while failure restores editing and keeps useful errors. +`DocumentWorkflow` owns the active save's validation report and optional leave continuation in +one private context. Success consumes that context once; failure discards the continuation. A pending Reload or close continues only after success. Canceling the unsaved-changes prompt keeps the draft; it does not attempt to cancel a durable write already in progress. diff --git a/configurator/src/app/document_workflow.rs b/configurator/src/app/document_workflow.rs index cc38c8e7c..1c4b760a1 100644 --- a/configurator/src/app/document_workflow.rs +++ b/configurator/src/app/document_workflow.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use wayscriber::config::{ConfigDocument, ConfigValidationReport}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug)] enum DocumentPhase { Idle, Loading, - Saving, + Saving(SaveContext), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -19,34 +19,30 @@ pub(crate) enum LeaveAction { pub(crate) struct DocumentWorkflow { loaded: Option, phase: DocumentPhase, - pub(crate) pending_validation: ConfigValidationReport, - pub(crate) last_backup_path: Option, - pub(crate) after_save: Option, + last_backup_path: Option, } impl DocumentWorkflow { pub(crate) fn loading() -> Self { Self { loaded: None, phase: DocumentPhase::Loading, - pending_validation: Default::default(), last_backup_path: None, - after_save: None, } } pub(crate) fn loaded(&self) -> Option<&ConfigDocument> { self.loaded.as_ref() } pub(crate) fn is_loading(&self) -> bool { - self.phase == DocumentPhase::Loading + matches!(self.phase, DocumentPhase::Loading) } pub(crate) fn is_saving(&self) -> bool { - self.phase == DocumentPhase::Saving + matches!(self.phase, DocumentPhase::Saving(_)) } pub(crate) fn allows_editing(&self) -> bool { - self.phase == DocumentPhase::Idle + matches!(self.phase, DocumentPhase::Idle) } pub(crate) fn begin_reload(&mut self) -> bool { - if self.phase != DocumentPhase::Idle { + if !matches!(self.phase, DocumentPhase::Idle) { return false; } self.phase = DocumentPhase::Loading; @@ -58,18 +54,57 @@ impl DocumentWorkflow { self.loaded = document; } } - pub(crate) fn begin_save(&mut self) -> Option { - if self.phase != DocumentPhase::Idle { + pub(crate) fn begin_save( + &mut self, + validation: ConfigValidationReport, + after_save: Option, + ) -> Option { + if !self.allows_editing() { return None; } let document = self.loaded.take()?; - self.phase = DocumentPhase::Saving; + self.phase = DocumentPhase::Saving(SaveContext { + validation, + after_save, + }); Some(document) } - pub(crate) fn finish_save(&mut self, document: Option) { - self.loaded = document; + + pub(crate) fn save_succeeded( + &mut self, + document: ConfigDocument, + backup: Option, + ) -> Option { + if !self.is_saving() { + return None; + } + let DocumentPhase::Saving(context) = + std::mem::replace(&mut self.phase, DocumentPhase::Idle) + else { + return None; + }; + self.loaded = Some(document); + self.last_backup_path = backup; + Some(SaveCompletion { + validation: context.validation, + after_save: context.after_save, + }) + } + + pub(crate) fn save_failed(&mut self, document: Option) { + if !self.is_saving() { + return; + } + // Dropping the active context discards both validation and continuation. self.phase = DocumentPhase::Idle; + self.loaded = document; } + + #[cfg(test)] + pub(crate) fn last_backup_path(&self) -> Option<&PathBuf> { + self.last_backup_path.as_ref() + } + #[cfg(test)] pub(crate) fn set_loading_for_test(&mut self, loading: bool) { self.phase = if loading { @@ -81,9 +116,35 @@ impl DocumentWorkflow { #[cfg(test)] pub(crate) fn set_saving_for_test(&mut self, saving: bool) { self.phase = if saving { - DocumentPhase::Saving + DocumentPhase::Saving(SaveContext { + validation: Default::default(), + after_save: None, + }) } else { DocumentPhase::Idle }; } } + +#[derive(Debug)] +struct SaveContext { + validation: ConfigValidationReport, + after_save: Option, +} + +pub(crate) struct SaveCompletion { + pub(crate) validation: ConfigValidationReport, + pub(crate) after_save: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn stale_failure_cannot_finish_a_reload() { + let mut workflow = DocumentWorkflow::loading(); + workflow.save_failed(None); + assert!(workflow.is_loading()); + assert!(!workflow.allows_editing()); + } +} diff --git a/configurator/src/app/pages/capture.rs b/configurator/src/app/pages/capture.rs index 700b4ff11..ade2e2ac3 100644 --- a/configurator/src/app/pages/capture.rs +++ b/configurator/src/app/pages/capture.rs @@ -29,48 +29,48 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { .switch_row( "Enable capture shortcuts", "", - |app| app.draft.capture_enabled, + |app| app.draft.capture.enabled, |value| Message::ToggleChanged(ToggleField::CaptureEnabled, value), ) .entry_row( "Save directory", - |app| app.draft.capture_save_directory.clone(), + |app| app.draft.capture.save_directory.clone(), |value| Message::TextChanged(TextField::CaptureSaveDirectory, value), ) .entry_row( "Filename template", - |app| app.draft.capture_filename_template.clone(), + |app| app.draft.capture.filename_template.clone(), |value| Message::TextChanged(TextField::CaptureFilename, value), ) .entry_row( "Format (png, jpg, ...)", - |app| app.draft.capture_format.clone(), + |app| app.draft.capture.format.clone(), |value| Message::TextChanged(TextField::CaptureFormat, value), ) .switch_row( "Copy to clipboard", "", - |app| app.draft.capture_copy_to_clipboard, + |app| app.draft.capture.copy_to_clipboard, |value| Message::ToggleChanged(ToggleField::CaptureCopyToClipboard, value), ) .switch_row( "Include drawings in screenshots", "Composites the active board's committed annotations into full-screen and region captures by default. Interactive region capture can override this per screenshot.", - |app| app.draft.capture_include_drawings, + |app| app.draft.capture.include_drawings, |value| Message::ToggleChanged(ToggleField::CaptureIncludeDrawings, value), ) .switch_row( "Always exit overlay after capture", "", - |app| app.draft.capture_exit_after, + |app| app.draft.capture.exit_after, |value| Message::ToggleChanged(ToggleField::CaptureExitAfter, value), ) .entry_row_validated( "OCR languages (e.g. eng, eng+deu \u{2014} needs the matching Tesseract packages)", - |app| app.draft.capture_ocr_languages.clone(), + |app| app.draft.capture.ocr_languages.clone(), |value| Message::TextChanged(TextField::CaptureOcrLanguages, value), |app| { - validate_ocr_languages(&app.draft.capture_ocr_languages) + validate_ocr_languages(&app.draft.capture.ocr_languages) .err() .map(|reason| { format!("{reason}. Use Tesseract codes joined by '+', such as eng+deu.") @@ -84,25 +84,25 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { "Native draws the picker over a frozen desktop image. Slurp uses the external selector.", RegionPickerOption::list(), labels(RegionPickerOption::list(), RegionPickerOption::label), - |app| app.draft.capture_region_picker, + |app| app.draft.capture.region_picker, Message::CaptureRegionPickerChanged, ) .switch_row( "Show pointer position and selection size", "Shows coordinates while idle and the selected export size while dragging.", - |app| app.draft.capture_region_show_size_readout, + |app| app.draft.capture.region_show_size_readout, |value| Message::ToggleChanged(ToggleField::CaptureRegionShowSizeReadout, value), ) .switch_row( "Show magnified pixel loupe", "Shows a magnified pixel grid beside the pointer while selecting or reviewing.", - |app| app.draft.capture_region_show_loupe, + |app| app.draft.capture.region_show_loupe, |value| Message::ToggleChanged(ToggleField::CaptureRegionShowLoupe, value), ) .switch_row( "Show hotkey legend", "Shows a short picker hotkey guide until the first drag.", - |app| app.draft.capture_region_show_legend, + |app| app.draft.capture.region_show_legend, |value| Message::ToggleChanged(ToggleField::CaptureRegionShowLegend, value), ); diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index 414f83c55..9024b21c2 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -9,10 +9,7 @@ use wayscriber::config::Shortcut; use crate::messages::Message; use crate::models::KeybindingField; -use crate::models::keybindings::{ - ShortcutManagerSummary, field_has_internal_duplicate, field_matches_defaults, - parse_keybindings, reset_tooltip, serialize_bindings, -}; +use crate::models::keybindings::{ShortcutManagerSummary, reset_tooltip, serialize_bindings}; use super::super::super::state::ConfiguratorApp; use super::super::Binding; @@ -198,7 +195,10 @@ pub(super) fn binding_row( set_visible(&row_for_bind, row_is_visible); let value = app.draft.keybindings.value_for(field).unwrap_or_default(); - let parsed = parse_keybindings(value); + let Some(manager_row) = summary.row(field) else { + return; + }; + let parsed = &manager_row.bindings; let parse_error = parsed.as_ref().err().cloned(); match &parsed { Ok(bindings) => { @@ -228,15 +228,14 @@ pub(super) fn binding_row( let default_tooltip = reset_tooltip(&app.defaults.keybindings, field); set_tooltip(&reset, Some(&default_tooltip)); set_accessible_label(&reset, &default_tooltip); - let at_defaults = - field_matches_defaults(&app.draft.keybindings, &app.defaults.keybindings, field); + let at_defaults = !manager_row.changed; set_sensitive(&add, parse_error.is_none()); set_sensitive(&record_sequence, parse_error.is_none()); set_sensitive(&reset, !at_defaults); let caption_text = match &parse_error { Some(message) => message.clone(), - None if field_has_internal_duplicate(&app.draft.keybindings, field) => { + None if manager_row.internal_conflict => { "This action lists the same shortcut twice.".to_string() } None => { @@ -253,7 +252,7 @@ pub(super) fn binding_row( } }; set_label(&caption, &caption_text); - if parse_error.is_some() || field_has_internal_duplicate(&app.draft.keybindings, field) { + if parse_error.is_some() || manager_row.internal_conflict { if !caption.has_css_class("error") { caption.add_css_class("error"); } diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index fb513c330..266f9022d 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -332,7 +332,7 @@ mod tests { app.refresh_dirty_flag(); assert!(!app.is_dirty); - app.draft.capture_enabled = !app.draft.capture_enabled; + app.draft.capture.enabled = !app.draft.capture.enabled; app.refresh_dirty_flag(); assert!(app.is_dirty); } diff --git a/configurator/src/app/update/config/leave.rs b/configurator/src/app/update/config/leave.rs index 716426e06..90875da23 100644 --- a/configurator/src/app/update/config/leave.rs +++ b/configurator/src/app/update/config/leave.rs @@ -47,11 +47,7 @@ impl ConfiguratorApp { return Vec::new(); }; self.pending_confirmation = None; - let effects = self.handle_save_requested(); - if self.document.is_saving() { - self.document.after_save = Some(action); - } - effects + self.save_with_continuation(Some(action)) } pub(in crate::app::update) fn discard_before_leave(&mut self) -> Vec { diff --git a/configurator/src/app/update/config/save.rs b/configurator/src/app/update/config/save.rs index 0b194f7fe..dace9121f 100644 --- a/configurator/src/app/update/config/save.rs +++ b/configurator/src/app/update/config/save.rs @@ -1,4 +1,5 @@ -use wayscriber::config::{Config, ConfigDocument}; +use crate::app::document_workflow::LeaveAction; +use wayscriber::config::{Config, ConfigDocument, ConfigValidationReport}; use crate::messages::ConfigSaveResult; use crate::models::ConfigDraft; @@ -10,6 +11,13 @@ use super::status::{config_document_status, invalid_color_hex_message, save_vali impl ConfiguratorApp { pub(in crate::app::update) fn handle_save_requested(&mut self) -> Vec { + self.save_with_continuation(None) + } + + pub(in crate::app::update) fn save_with_continuation( + &mut self, + continuation: Option, + ) -> Vec { // `is_loading` counts too: a reload replaces the draft and base // document when it lands, so a save started underneath it would write // the pre-reload draft and then be judged against a document it never @@ -40,19 +48,19 @@ impl ConfiguratorApp { return Vec::new(); } - // The write needs the document itself, so the model gives up its only - // copy here and gets one back from `handle_config_saved` either way. - // Taking it is also the "nothing loaded" check: there is one `Option` - // to read, and reading it is what moves the value. - let Some(document) = self.document.begin_save() else { + // Validate against the loaded document before starting any transfer. + let Some(document) = self.document.loaded() else { self.status = StatusMessage::error( "Configuration has not loaded successfully. Reload before saving.", ); return Vec::new(); }; - match self.prepare_config_to_save(&document) { - Ok(config) => { + match self.prepare_config_to_save(document) { + Ok((config, validation)) => { + let Some(document) = self.document.begin_save(validation, continuation) else { + return Vec::new(); + }; self.status = StatusMessage::info("Saving configuration..."); vec![Effect::SaveConfig { document: Box::new(document), @@ -60,9 +68,6 @@ impl ConfiguratorApp { }] } Err(errors) => { - // No write starts, so the document goes straight back: this - // handler must not be a way to lose it. - self.document.finish_save(Some(document)); let message = errors .into_iter() .map(|err| format!("{}: {}", err.field, err.message)) @@ -86,20 +91,13 @@ impl ConfiguratorApp { /// file then spells both lists out — leaving nothing for the reloaded /// document to rediscover — so this is the only place the loss can be seen. fn prepare_config_to_save( - &mut self, + &self, document: &ConfigDocument, - ) -> Result> { + ) -> Result<(Config, ConfigValidationReport), Vec> { let config = self.draft.to_config(document.config())?; - match config.validate_for_save() { - Ok((config, report)) => { - self.document.pending_validation = report; - Ok(config) - } - Err(error) => { - self.document.pending_validation = Default::default(); - Err(vec![FormError::new("config", error.to_string())]) - } - } + config + .validate_for_save() + .map_err(|error| vec![FormError::new("config", error.to_string())]) } pub(in crate::app::update) fn handle_config_saved( @@ -108,12 +106,12 @@ impl ConfiguratorApp { ) -> Vec { // Either outcome answers this write; a failed one wrote nothing, so // there is no resolution to report for it. - let after_save = self.document.after_save.take(); - let validation = std::mem::take(&mut self.document.pending_validation); + if !self.document.is_saving() { + return Vec::new(); + } match result { Ok((backup, saved_document)) => { let draft = ConfigDraft::from_config(saved_document.config()); - self.document.last_backup_path = backup.clone(); self.draft = draft.clone(); self.baseline = draft; self.boards_collapsed = vec![false; self.draft.boards.items.len()]; @@ -126,16 +124,19 @@ impl ConfiguratorApp { // and an unrelated save leaves the same proposal standing. self.refresh_migration_preview(&saved_document); let mut msg = "Configuration saved successfully.".to_string(); - if let Some(path) = backup { + if let Some(path) = &backup { msg.push_str(&format!("\nBackup created at {}", path.display())); } let mut status = config_document_status(&saved_document, &msg); - if let Some(note) = save_validation_note(&validation) { + let completion = self + .document + .save_succeeded(*saved_document, backup) + .expect("active save checked above"); + if let Some(note) = save_validation_note(&completion.validation) { status = status.with_note(¬e); } self.status = status; - self.document.finish_save(Some(*saved_document)); - if let Some(action) = after_save { + if let Some(action) = completion.after_save { return self.continue_leave(action); } } @@ -146,7 +147,7 @@ impl ConfiguratorApp { // returned, which leaves a reload as the way forward. let restored = document.is_some(); self.document - .finish_save(document.map(|document| *document)); + .save_failed(document.map(|document| *document)); let mut message = format!("Failed to save configuration: {err}"); if !restored { message.push_str( diff --git a/configurator/src/app/update/config/tests.rs b/configurator/src/app/update/config/tests.rs index d71577add..3e9027192 100644 --- a/configurator/src/app/update/config/tests.rs +++ b/configurator/src/app/update/config/tests.rs @@ -70,7 +70,7 @@ fn handle_config_loaded_error_preserves_the_last_good_document_and_draft() { let (path, document) = temp_config_document("before-reload-error", ""); let destination = document.destination().to_path_buf(); let _ = app.handle_config_loaded(Ok((document, None))); - app.draft.capture_enabled = !app.draft.capture_enabled; + app.draft.capture.enabled = !app.draft.capture.enabled; let draft = app.draft.clone(); let _ = app.handle_config_loaded(Err("broken".to_string())); @@ -511,7 +511,7 @@ fn every_invalid_color_field_is_counted_for_the_refusal() { fn reset_to_defaults_requires_confirmation() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_loading_for_test(false); - app.draft.capture_enabled = !app.defaults.capture_enabled; + app.draft.capture.enabled = !app.defaults.capture.enabled; let changed_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_requested(); @@ -528,7 +528,7 @@ fn reset_to_defaults_requires_confirmation() { fn reset_to_defaults_repeated_request_is_a_no_op() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_loading_for_test(false); - app.draft.capture_enabled = !app.defaults.capture_enabled; + app.draft.capture.enabled = !app.defaults.capture.enabled; let changed_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_requested(); @@ -545,8 +545,8 @@ fn reset_to_defaults_repeated_request_is_a_no_op() { fn reset_to_defaults_confirmed_applies_the_defaults() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_loading_for_test(false); - app.draft.capture_enabled = !app.defaults.capture_enabled; - app.baseline.capture_enabled = !app.defaults.capture_enabled; + app.draft.capture.enabled = !app.defaults.capture.enabled; + app.baseline.capture.enabled = !app.defaults.capture.enabled; let _ = app.handle_reset_to_defaults_requested(); let _ = app.handle_reset_to_defaults_confirmed(); @@ -566,7 +566,7 @@ fn reset_to_defaults_confirmed_applies_the_defaults() { fn reset_to_defaults_confirmed_without_a_request_changes_nothing() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_loading_for_test(false); - app.draft.capture_enabled = !app.defaults.capture_enabled; + app.draft.capture.enabled = !app.defaults.capture.enabled; let changed_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_confirmed(); @@ -585,7 +585,7 @@ fn reset_to_defaults_confirmed_without_a_request_changes_nothing() { fn reset_to_defaults_canceled_disarms_and_clears_the_hint() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_loading_for_test(false); - app.draft.capture_enabled = !app.defaults.capture_enabled; + app.draft.capture.enabled = !app.defaults.capture.enabled; let changed_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_requested(); @@ -661,7 +661,7 @@ fn reset_to_defaults_confirmation_is_canceled_by_draft_edit() { app.document.set_loading_for_test(false); let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); + let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture.enabled); assert!(!app.defaults_reset_pending()); assert!(matches!(app.status, StatusMessage::Idle)); @@ -675,7 +675,7 @@ fn a_draft_edit_between_request_and_confirm_refuses_the_confirm() { app.document.set_loading_for_test(false); let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); + let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture.enabled); let edited_draft = app.draft.clone(); let _ = app.handle_reset_to_defaults_confirmed(); @@ -829,7 +829,7 @@ fn handle_config_saved_success_clears_dirty_and_records_backup() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.document.set_saving_for_test(true); app.is_dirty = true; - app.draft.capture_enabled = !app.draft.capture_enabled; + app.draft.capture.enabled = !app.draft.capture.enabled; let backup = PathBuf::from("/tmp/wayscriber-config.bak"); let (path, document) = temp_config_document("saved", ""); @@ -838,7 +838,7 @@ fn handle_config_saved_success_clears_dirty_and_records_backup() { assert!(!app.document.is_saving()); assert!(!app.is_dirty); - assert_eq!(app.document.last_backup_path, Some(backup)); + assert_eq!(app.document.last_backup_path(), Some(&backup)); assert_eq!(app.draft, app.baseline); assert!(status_contains( &app.status, @@ -1353,7 +1353,7 @@ fn document_operations_freeze_queued_edits_until_completion() { assert_eq!(effects.len(), 1); assert!(!app.document.allows_editing()); for message in [ - Message::ToggleChanged(ToggleField::CaptureEnabled, !original.capture_enabled), + Message::ToggleChanged(ToggleField::CaptureEnabled, !original.capture.enabled), Message::BoardsAddItem, Message::QuickColorAdded, Message::PresetSlotCountChanged(2), @@ -1389,7 +1389,7 @@ fn document_operations_freeze_queued_edits_until_completion() { assert_eq!(app.draft, original); app.update_message(Message::ToggleChanged( ToggleField::CaptureEnabled, - !original.capture_enabled, + !original.capture.enabled, )); assert_ne!(app.draft, original); std::fs::remove_file(path).unwrap(); @@ -1426,7 +1426,7 @@ fn check_leave_dirty_draft(close: bool, save_succeeds: bool) { } app.update_message(Message::ToggleChanged( ToggleField::CaptureEnabled, - !app.draft.capture_enabled, + !app.draft.capture.enabled, )); assert!(app.update_message(request.clone()).is_empty()); assert!(app.pending_leave().is_some()); @@ -1459,7 +1459,7 @@ fn check_leave_dirty_draft(close: bool, save_succeeds: bool) { } else { assert!(effects.is_empty()); assert!(app.is_dirty); - assert!(app.document.after_save.is_none()); + assert!(app.document.allows_editing()); app.update_message(request); assert!(matches!( app.update_message(Message::LeaveDiscardRequested) diff --git a/configurator/src/app/update/fields.rs b/configurator/src/app/update/fields.rs index 80d762cfb..e671d7909 100644 --- a/configurator/src/app/update/fields.rs +++ b/configurator/src/app/update/fields.rs @@ -77,7 +77,7 @@ impl ConfiguratorApp { option: RegionPickerOption, ) -> Vec { self.status = StatusMessage::idle(); - self.draft.capture_region_picker = option; + self.draft.capture.region_picker = option; self.refresh_dirty_flag(); Vec::new() } diff --git a/configurator/src/models/config/capture.rs b/configurator/src/models/config/capture.rs new file mode 100644 index 000000000..2f69988d7 --- /dev/null +++ b/configurator/src/models/config/capture.rs @@ -0,0 +1,98 @@ +use crate::models::error::FormError; +use crate::models::fields::RegionPickerOption; +use wayscriber::config::{ + CaptureConfig, validate_capture_format, validate_filename_template, validate_ocr_languages, +}; + +/// Editable capture values, including intermediate or invalid text. +#[derive(Debug, Clone, PartialEq)] +pub struct CaptureDraft { + pub enabled: bool, + pub save_directory: String, + pub filename_template: String, + pub format: String, + pub copy_to_clipboard: bool, + pub include_drawings: bool, + pub exit_after: bool, + pub ocr_languages: String, + pub region_picker: RegionPickerOption, + pub region_show_size_readout: bool, + pub region_show_loupe: bool, + pub region_show_legend: bool, +} + +impl CaptureDraft { + pub(super) fn from_config(config: &CaptureConfig) -> Self { + Self { + enabled: config.enabled, + save_directory: config.save_directory.clone(), + filename_template: config.filename_template.clone(), + format: config.format.clone(), + copy_to_clipboard: config.copy_to_clipboard, + include_drawings: config.include_drawings, + exit_after: config.exit_after_capture, + ocr_languages: config.ocr_languages.clone(), + region_picker: RegionPickerOption::from_picker(config.region.picker), + region_show_size_readout: config.region.show_size_readout, + region_show_loupe: config.region.show_loupe, + region_show_legend: config.region.show_legend, + } + } + + pub(super) fn apply_to(&self, config: &mut CaptureConfig, errors: &mut Vec) { + config.enabled = self.enabled; + config.save_directory = self.save_directory.clone(); + match validate_filename_template(&self.filename_template) { + Ok(()) => config.filename_template = self.filename_template.clone(), + Err(reason) => errors.push(FormError::new( + "capture.filename_template", + format!("Filename template: {reason}."), + )), + } + match validate_capture_format(&self.format) { + Ok(format) => config.format = format, + Err(reason) => errors.push(FormError::new( + "capture.format", + format!("Image format: {reason}."), + )), + } + config.copy_to_clipboard = self.copy_to_clipboard; + config.include_drawings = self.include_drawings; + config.exit_after_capture = self.exit_after; + config.region.picker = self.region_picker.to_picker(); + config.region.show_size_readout = self.region_show_size_readout; + config.region.show_loupe = self.region_show_loupe; + config.region.show_legend = self.region_show_legend; + match validate_ocr_languages(&self.ocr_languages) { + Ok(languages) => config.ocr_languages = languages, + Err(reason) => errors.push(FormError::new( + "capture.ocr_languages", + format!("OCR languages: {reason}."), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn capture_round_trip_and_invalid_text_preserve_input() { + let original = CaptureConfig::default(); + let mut draft = CaptureDraft::from_config(&original); + let mut saved = original.clone(); + let mut errors = Vec::new(); + draft.apply_to(&mut saved, &mut errors); + assert!(errors.is_empty()); + assert_eq!(CaptureDraft::from_config(&saved), draft); + draft.format = "bad-format".into(); + draft.filename_template = "../escape".into(); + draft.ocr_languages = "!invalid!".into(); + draft.apply_to(&mut saved, &mut errors); + assert_eq!(errors.len(), 3); + assert_eq!(draft.format, "bad-format"); + assert_eq!(saved.format, original.format); + assert_eq!(saved.filename_template, original.filename_template); + assert_eq!(saved.ocr_languages, original.ocr_languages); + } +} diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index e2b120a79..1f28920d6 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -5,8 +5,8 @@ use super::super::super::fields::{ InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, - RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, - ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, + ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::super::fields::{ @@ -243,18 +243,7 @@ impl ConfigDraft { render_profiles: RenderProfilesDraft::from_config(config), - capture_enabled: config.capture.enabled, - capture_save_directory: config.capture.save_directory.clone(), - capture_filename_template: config.capture.filename_template.clone(), - capture_format: config.capture.format.clone(), - capture_copy_to_clipboard: config.capture.copy_to_clipboard, - capture_include_drawings: config.capture.include_drawings, - capture_exit_after: config.capture.exit_after_capture, - capture_ocr_languages: config.capture.ocr_languages.clone(), - capture_region_picker: RegionPickerOption::from_picker(config.capture.region.picker), - capture_region_show_size_readout: config.capture.region.show_size_readout, - capture_region_show_loupe: config.capture.region.show_loupe, - capture_region_show_legend: config.capture.region.show_legend, + capture: super::super::capture::CaptureDraft::from_config(&config.capture), export_pdf_filename_template: config .export .pdf diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index bd55f527d..2d8109cd2 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -7,8 +7,8 @@ use super::super::fields::{ InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, - RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, - ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, + ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -179,18 +179,7 @@ pub struct ConfigDraft { pub render_profiles: RenderProfilesDraft, - pub capture_enabled: bool, - pub capture_save_directory: String, - pub capture_filename_template: String, - pub capture_format: String, - pub capture_copy_to_clipboard: bool, - pub capture_include_drawings: bool, - pub capture_exit_after: bool, - pub capture_ocr_languages: String, - pub capture_region_picker: RegionPickerOption, - pub capture_region_show_size_readout: bool, - pub capture_region_show_loupe: bool, - pub capture_region_show_legend: bool, + pub capture: super::capture::CaptureDraft, pub export_pdf_filename_template: String, pub export_pdf_all_boards_filename_template: String, pub export_pdf_page_size: PdfPageSizeOption, diff --git a/configurator/src/models/config/mod.rs b/configurator/src/models/config/mod.rs index b37da195a..600be3501 100644 --- a/configurator/src/models/config/mod.rs +++ b/configurator/src/models/config/mod.rs @@ -1,4 +1,5 @@ mod boards; +mod capture; mod draft; mod font_cycle; mod history; diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 51b271927..12e3cad65 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -259,15 +259,15 @@ impl ConfigDraft { ToggleField::BoardsPersistCustomizations => { self.boards.persist_customizations = value; } - ToggleField::CaptureEnabled => self.capture_enabled = value, - ToggleField::CaptureCopyToClipboard => self.capture_copy_to_clipboard = value, - ToggleField::CaptureIncludeDrawings => self.capture_include_drawings = value, - ToggleField::CaptureExitAfter => self.capture_exit_after = value, + ToggleField::CaptureEnabled => self.capture.enabled = value, + ToggleField::CaptureCopyToClipboard => self.capture.copy_to_clipboard = value, + ToggleField::CaptureIncludeDrawings => self.capture.include_drawings = value, + ToggleField::CaptureExitAfter => self.capture.exit_after = value, ToggleField::CaptureRegionShowSizeReadout => { - self.capture_region_show_size_readout = value; + self.capture.region_show_size_readout = value; } - ToggleField::CaptureRegionShowLoupe => self.capture_region_show_loupe = value, - ToggleField::CaptureRegionShowLegend => self.capture_region_show_legend = value, + ToggleField::CaptureRegionShowLoupe => self.capture.region_show_loupe = value, + ToggleField::CaptureRegionShowLegend => self.capture.region_show_legend = value, ToggleField::ExportPdfLabelsEnabled => self.export_pdf_labels_enabled = value, ToggleField::ExportPdfLabelBackgroundEnabled => { self.export_pdf_label_background_enabled = value; @@ -376,10 +376,10 @@ impl ConfigDraft { TextField::UiCommandPaletteToastDurationMs => { self.ui_command_palette_toast_duration_ms = value } - TextField::CaptureSaveDirectory => self.capture_save_directory = value, - TextField::CaptureFilename => self.capture_filename_template = value, - TextField::CaptureFormat => self.capture_format = value, - TextField::CaptureOcrLanguages => self.capture_ocr_languages = value, + TextField::CaptureSaveDirectory => self.capture.save_directory = value, + TextField::CaptureFilename => self.capture.filename_template = value, + TextField::CaptureFormat => self.capture.format = value, + TextField::CaptureOcrLanguages => self.capture.ocr_languages = value, TextField::ExportPdfFilenameTemplate => self.export_pdf_filename_template = value, TextField::ExportPdfAllBoardsFilenameTemplate => { self.export_pdf_all_boards_filename_template = value diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index c49155ec5..02e316bd6 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -123,12 +123,12 @@ fn config_draft_round_trips_text_halo_preference() { fn config_draft_round_trips_region_capture_settings() { let config = Config::default(); let mut draft = ConfigDraft::from_config(&config); - assert_eq!(draft.capture_region_picker, RegionPickerOption::Native); - assert!(draft.capture_region_show_size_readout); - assert!(!draft.capture_region_show_loupe); - assert!(draft.capture_region_show_legend); + assert_eq!(draft.capture.region_picker, RegionPickerOption::Native); + assert!(draft.capture.region_show_size_readout); + assert!(!draft.capture.region_show_loupe); + assert!(draft.capture.region_show_legend); - draft.capture_region_picker = RegionPickerOption::Slurp; + draft.capture.region_picker = RegionPickerOption::Slurp; draft.set_toggle(ToggleField::CaptureRegionShowSizeReadout, false); draft.set_toggle(ToggleField::CaptureRegionShowLoupe, true); draft.set_toggle(ToggleField::CaptureRegionShowLegend, false); @@ -219,7 +219,7 @@ fn blank_font_cycle_entries_are_dropped_on_save() { fn config_draft_round_trips_capture_drawing_preference() { let config = Config::default(); let mut draft = ConfigDraft::from_config(&config); - assert!(draft.capture_include_drawings); + assert!(draft.capture.include_drawings); draft.set_toggle(ToggleField::CaptureIncludeDrawings, false); let round_trip = draft @@ -281,8 +281,8 @@ fn config_draft_to_config_rejects_out_of_range_numbers() { #[test] fn config_draft_rejects_path_escaping_save_names() { let mut draft = ConfigDraft::from_config(&Config::default()); - draft.capture_filename_template = "../evil_%Y".to_string(); - draft.capture_format = "png/../../x".to_string(); + draft.capture.filename_template = "../evil_%Y".to_string(); + draft.capture.format = "png/../../x".to_string(); draft.export_pdf_filename_template = "foo/bar".to_string(); let errors = draft diff --git a/configurator/src/models/config/to_config/capture.rs b/configurator/src/models/config/to_config/capture.rs deleted file mode 100644 index 4f9125852..000000000 --- a/configurator/src/models/config/to_config/capture.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::super::draft::ConfigDraft; -use crate::models::error::FormError; -use wayscriber::config::{ - Config, validate_capture_format, validate_filename_template, validate_ocr_languages, -}; - -impl ConfigDraft { - pub(super) fn apply_capture(&self, config: &mut Config, errors: &mut Vec) { - config.capture.enabled = self.capture_enabled; - config.capture.save_directory = self.capture_save_directory.clone(); - match validate_filename_template(&self.capture_filename_template) { - Ok(()) => config.capture.filename_template = self.capture_filename_template.clone(), - Err(reason) => errors.push(FormError::new( - "capture.filename_template", - format!("Filename template: {reason}."), - )), - } - match validate_capture_format(&self.capture_format) { - Ok(format) => config.capture.format = format, - Err(reason) => errors.push(FormError::new( - "capture.format", - format!("Image format: {reason}."), - )), - } - config.capture.copy_to_clipboard = self.capture_copy_to_clipboard; - config.capture.include_drawings = self.capture_include_drawings; - config.capture.exit_after_capture = self.capture_exit_after; - config.capture.region.picker = self.capture_region_picker.to_picker(); - config.capture.region.show_size_readout = self.capture_region_show_size_readout; - config.capture.region.show_loupe = self.capture_region_show_loupe; - config.capture.region.show_legend = self.capture_region_show_legend; - match validate_ocr_languages(&self.capture_ocr_languages) { - Ok(languages) => config.capture.ocr_languages = languages, - Err(reason) => errors.push(FormError::new( - "capture.ocr_languages", - format!("OCR languages: {reason}."), - )), - } - } -} diff --git a/configurator/src/models/config/to_config/mod.rs b/configurator/src/models/config/to_config/mod.rs index 44292e84a..03bd55a3f 100644 --- a/configurator/src/models/config/to_config/mod.rs +++ b/configurator/src/models/config/to_config/mod.rs @@ -1,5 +1,4 @@ mod boards; -mod capture; mod drawing; mod export; mod keybindings; @@ -33,7 +32,7 @@ impl ConfigDraft { self.apply_boards(&mut config, &mut errors); self.render_profiles .apply_to_config(&mut config, &mut errors); - self.apply_capture(&mut config, &mut errors); + self.capture.apply_to(&mut config.capture, &mut errors); self.apply_export(&mut config, &mut errors); self.apply_session(&mut config, &mut errors); self.apply_tablet(&mut config, &mut errors); diff --git a/configurator/src/models/keybindings/analysis.rs b/configurator/src/models/keybindings/analysis.rs new file mode 100644 index 000000000..ae7779345 --- /dev/null +++ b/configurator/src/models/keybindings/analysis.rs @@ -0,0 +1,94 @@ +//! Parsed values live for one analysis pass; edits never invalidate a persistent cache. +use super::conflicts::{ShortcutClaim, legacy_action_for, shortcuts_conflict}; +use super::{KeybindingField, KeybindingsDraft, parse_keybindings}; +use wayscriber::config::Shortcut; + +pub(super) struct ParsedShortcuts<'a> { + pub(super) draft: &'a KeybindingsDraft, + entries: Vec<(KeybindingField, Result, String>)>, +} + +impl<'a> ParsedShortcuts<'a> { + pub(super) fn new(draft: &'a KeybindingsDraft) -> Self { + Self { + draft, + entries: draft + .entries + .iter() + .map(|entry| (entry.field, parse_keybindings(&entry.value))) + .collect(), + } + } + + pub(super) fn bindings(&self, field: KeybindingField) -> Result<&[Shortcut], &str> { + self.entries + .iter() + .find(|(candidate, _)| *candidate == field) + .map(|(_, parsed)| parsed.as_ref().map(Vec::as_slice).map_err(String::as_str)) + .unwrap_or(Ok(&[])) + } + + pub(super) fn claimants(&self, binding: &Shortcut) -> Vec { + let mut claims = Vec::new(); + for (field, parsed) in &self.entries { + if let Ok(parsed) = parsed { + for candidate in parsed { + if shortcuts_conflict(candidate, binding) + && !claims.iter().any(|claim: &ShortcutClaim| { + claim.field == Some(*field) && claim.held == *candidate + }) + { + claims.push(ShortcutClaim::from_field(*field, candidate.clone())); + } + } + } + } + if let Some(action) = legacy_action_for(self.draft, binding) { + claims.push(ShortcutClaim::legacy_tablet(action, binding.clone())); + } + claims + } + + pub(super) fn other_claimants( + &self, + field: KeybindingField, + binding: &Shortcut, + ) -> Vec { + self.claimants(binding) + .into_iter() + .filter(|claim| claim.field != Some(field) || claim.held != *binding) + .collect() + } + + pub(super) fn has_internal_duplicate(&self, field: KeybindingField) -> bool { + self.bindings(field).is_ok_and(|bindings| { + bindings.iter().enumerate().any(|(index, binding)| { + bindings[index + 1..] + .iter() + .any(|other| shortcuts_conflict(binding, other)) + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::keybinding_fields; + use super::*; + #[test] + fn summary_parses_each_field_once_per_draft_even_with_many_conflicts() { + let defaults = + KeybindingsDraft::from_config(&wayscriber::config::KeybindingsConfig::default()); + let mut draft = defaults.clone(); + for field in keybinding_fields() { + draft.set(field, "Ctrl+Q, Ctrl+Q > C, Ctrl+Q".into()); + } + super::super::take_parse_calls(); + let summary = super::super::ShortcutManagerSummary::from_drafts(&draft, &defaults); + assert_eq!( + super::super::take_parse_calls(), + 2 * keybinding_fields().len() + ); + assert!(summary.rows().iter().all(|row| row.has_conflict)); + } +} diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index 802629ea3..b32d8b507 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -31,7 +31,7 @@ impl ShortcutClaim { } } - fn legacy_tablet(action: Action, held: Shortcut) -> Self { + pub(super) fn legacy_tablet(action: Action, held: Shortcut) -> Self { Self { field: None, label: format!("{} (legacy tablet barrel button)", action_label(action)), @@ -106,31 +106,14 @@ pub struct TextShortcutConflict { pub claimants: Vec, } -fn shortcuts_conflict(left: &Shortcut, right: &Shortcut) -> bool { +pub(super) fn shortcuts_conflict(left: &Shortcut, right: &Shortcut) -> bool { left == right || left.prefix_conflicts_with(right) } /// Every action in the draft that currently claims `binding`, including the /// target when it already lists that chord (a duplicate inside one action). pub fn claimants_for(draft: &KeybindingsDraft, binding: &Shortcut) -> Vec { - let mut claims = Vec::new(); - for entry in &draft.entries { - if let Ok(parsed) = parse_keybindings(&entry.value) { - for candidate in parsed { - if shortcuts_conflict(&candidate, binding) - && !claims.iter().any(|claim: &ShortcutClaim| { - claim.field == Some(entry.field) && claim.held == candidate - }) - { - claims.push(ShortcutClaim::from_field(entry.field, candidate)); - } - } - } - } - if let Some(action) = legacy_action_for(draft, binding) { - claims.push(ShortcutClaim::legacy_tablet(action, binding.clone())); - } - claims + super::analysis::ParsedShortcuts::new(draft).claimants(binding) } pub fn other_claimants( @@ -144,21 +127,6 @@ pub fn other_claimants( .collect() } -pub fn field_has_internal_duplicate(draft: &KeybindingsDraft, field: KeybindingField) -> bool { - let Some(value) = draft.value_for(field) else { - return false; - }; - let Ok(parsed) = parse_keybindings(value) else { - return false; - }; - parsed.iter().enumerate().any(|(index, binding)| { - parsed - .iter() - .skip(index + 1) - .any(|other| shortcuts_conflict(other, binding)) - }) -} - pub fn text_conflicts_for( draft: &KeybindingsDraft, target: KeybindingField, @@ -324,7 +292,7 @@ pub fn recorded_conflict_prompt(binding: &Shortcut, claimants: &[ShortcutClaim]) lines.join("\n") } -fn legacy_action_for(draft: &KeybindingsDraft, binding: &Shortcut) -> Option { +pub(super) fn legacy_action_for(draft: &KeybindingsDraft, binding: &Shortcut) -> Option { let trigger = binding.as_trigger()?; let ShortcutTrigger::Stylus(trigger) = trigger else { return None; @@ -425,10 +393,10 @@ mod tests { fn duplicate_inside_one_action_is_a_conflict() { let mut draft = draft(); draft.set(KeybindingField::ClearCanvas, "E, e".to_string()); - assert!(field_has_internal_duplicate( - &draft, - KeybindingField::ClearCanvas - )); + assert!( + super::super::analysis::ParsedShortcuts::new(&draft) + .has_internal_duplicate(KeybindingField::ClearCanvas) + ); let binding = Shortcut::parse("E").expect("parses"); let claimants = claimants_for(&draft, &binding); assert_eq!(claimants.len(), 1); diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs index 8c8b3110b..6687bc021 100644 --- a/configurator/src/models/keybindings/edit.rs +++ b/configurator/src/models/keybindings/edit.rs @@ -118,23 +118,6 @@ fn apply_parsed_text( Ok(parsed) } -pub fn parsed_lists_equal(left: &str, right: &str) -> bool { - match (parse_keybindings(left), parse_keybindings(right)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -pub fn field_matches_defaults( - draft: &KeybindingsDraft, - defaults: &KeybindingsDraft, - field: KeybindingField, -) -> bool { - let current = draft.value_for(field).unwrap_or(""); - let default = defaults.value_for(field).unwrap_or(""); - parsed_lists_equal(current, default) -} - pub fn reset_tooltip(defaults: &KeybindingsDraft, field: KeybindingField) -> String { let default = defaults.value_for(field).unwrap_or("").trim(); if default.is_empty() { @@ -192,11 +175,12 @@ mod tests { let binding = Shortcut::parse("E").expect("parses"); remove_binding(&mut draft, KeybindingField::ClearCanvas, &binding).expect("remove"); assert_eq!(draft.value_for(KeybindingField::ClearCanvas), Some("")); - assert!(!field_matches_defaults( - &draft, - &defaults, - KeybindingField::ClearCanvas - )); + assert!( + super::super::ShortcutManagerSummary::from_drafts(&draft, &defaults) + .row(KeybindingField::ClearCanvas) + .unwrap() + .changed + ); } #[test] @@ -303,11 +287,12 @@ mod tests { fn whitespace_differences_are_not_a_change_from_defaults() { let (mut draft, defaults) = draft(); draft.set(KeybindingField::Redo, "Ctrl+Shift+Z,Ctrl+Y".to_string()); - assert!(field_matches_defaults( - &draft, - &defaults, - KeybindingField::Redo - )); + assert!( + !super::super::ShortcutManagerSummary::from_drafts(&draft, &defaults) + .row(KeybindingField::Redo) + .unwrap() + .changed + ); } #[test] diff --git a/configurator/src/models/keybindings/manager.rs b/configurator/src/models/keybindings/manager.rs index e1540b52c..3159bbd98 100644 --- a/configurator/src/models/keybindings/manager.rs +++ b/configurator/src/models/keybindings/manager.rs @@ -6,13 +6,10 @@ use wayscriber::config::{Action, Shortcut, ShortcutTrigger}; -use super::conflicts::{ - ShortcutClaim, claimants_for, field_has_internal_duplicate, other_claimants, -}; +use super::analysis::ParsedShortcuts; +use super::conflicts::ShortcutClaim; use super::draft::KeybindingsDraft; -use super::edit::field_matches_defaults; use super::field::{KeybindingField, keybinding_fields, keybinding_tab}; -use super::parse::parse_keybindings; use crate::models::KeybindingsTabId; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -122,6 +119,8 @@ pub struct ShortcutManagerRow { pub has_sequence: bool, pub has_unavailable: bool, pub parse_error: bool, + pub bindings: Result, String>, + pub internal_conflict: bool, } impl ShortcutManagerRow { @@ -153,10 +152,12 @@ pub struct ShortcutManagerSummary { impl ShortcutManagerSummary { pub fn from_drafts(draft: &KeybindingsDraft, defaults: &KeybindingsDraft) -> Self { - let contested = contested_fields(draft); + let parsed = ParsedShortcuts::new(draft); + let parsed_defaults = ParsedShortcuts::new(defaults); + let contested = contested_fields(&parsed); let rows = keybinding_fields() .into_iter() - .map(|field| row_for(draft, defaults, field, contested.contains(&field))) + .map(|field| row_for(&parsed, &parsed_defaults, field, contested.contains(&field))) .collect(); Self { rows } } @@ -220,15 +221,13 @@ pub fn next_review_conflict( Shortcut, Vec, )> { + let analysis = ParsedShortcuts::new(draft); for field in keybinding_fields() { - let Some(value) = draft.value_for(field) else { + let Ok(parsed) = analysis.bindings(field) else { continue; }; - let Ok(parsed) = parse_keybindings(value) else { - continue; - }; - for binding in &parsed { - let mut claimants = other_claimants(draft, field, binding); + for binding in parsed { + let mut claimants = analysis.other_claimants(field, binding); if claimants.is_empty() { let extras = parsed.iter().filter(|other| *other == binding).count(); if extras <= 1 { @@ -243,16 +242,17 @@ pub fn next_review_conflict( } fn row_for( - draft: &KeybindingsDraft, - defaults: &KeybindingsDraft, + analysis: &ParsedShortcuts<'_>, + defaults: &ParsedShortcuts<'_>, field: KeybindingField, has_conflict: bool, ) -> ShortcutManagerRow { - let value = draft.value_for(field).unwrap_or_default(); - let default_value = defaults.value_for(field).unwrap_or_default(); - let parsed = parse_keybindings(value); + let draft = analysis.draft; + let default_value = defaults.draft.value_for(field).unwrap_or_default(); + let parsed = analysis.bindings(field); let parse_error = parsed.is_err(); - let changed = !field_matches_defaults(draft, defaults, field); + let changed = + !matches!((parsed, defaults.bindings(field)), (Ok(left), Ok(right)) if left == right); let (unbound, has_device, has_sequence, has_unavailable) = match &parsed { Ok(bindings) => flags_for_bindings(bindings), Err(_) => (false, false, false, false), @@ -299,6 +299,10 @@ fn row_for( has_sequence, has_unavailable, parse_error, + bindings: parsed + .map(|bindings| bindings.to_vec()) + .map_err(str::to_owned), + internal_conflict: analysis.has_internal_duplicate(field), } } @@ -325,7 +329,7 @@ fn field_has_legacy_tablet(draft: &KeybindingsDraft, field: KeybindingField) -> || draft.legacy_tablet.stylus_secondary == Some(action) } -fn contested_fields(draft: &KeybindingsDraft) -> Vec { +fn contested_fields(analysis: &ParsedShortcuts<'_>) -> Vec { let mut contested = Vec::new(); let mut mark = |field: KeybindingField| { if !contested.contains(&field) { @@ -333,26 +337,24 @@ fn contested_fields(draft: &KeybindingsDraft) -> Vec { } }; for field in keybinding_fields() { - if field_has_internal_duplicate(draft, field) { + if analysis.has_internal_duplicate(field) { mark(field); } - let Some(value) = draft.value_for(field) else { - continue; - }; - let Ok(parsed) = parse_keybindings(value) else { + let Ok(parsed) = analysis.bindings(field) else { continue; }; for binding in parsed { - if !other_claimants(draft, field, &binding).is_empty() { + if !analysis.other_claimants(field, binding).is_empty() { mark(field); } } } - mark_legacy_claimants(draft, &mut mark); + mark_legacy_claimants(analysis, &mut mark); contested } -fn mark_legacy_claimants(draft: &KeybindingsDraft, mark: &mut impl FnMut(KeybindingField)) { +fn mark_legacy_claimants(analysis: &ParsedShortcuts<'_>, mark: &mut impl FnMut(KeybindingField)) { + let draft = analysis.draft; for (action, name) in [ (draft.legacy_tablet.stylus_primary, "StylusPrimary"), (draft.legacy_tablet.stylus_secondary, "StylusSecondary"), @@ -363,7 +365,7 @@ fn mark_legacy_claimants(draft: &KeybindingsDraft, mark: &mut impl FnMut(Keybind let Ok(binding) = Shortcut::parse(name) else { continue; }; - let claims = claimants_for(draft, &binding); + let claims = analysis.claimants(&binding); if claims.len() < 2 { continue; } diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index 81a99d58e..23dddffd6 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -1,3 +1,4 @@ +mod analysis; mod conflicts; mod draft; mod edit; @@ -7,13 +8,13 @@ mod parse; mod recording; pub use conflicts::{ - PendingShortcutConflict, apply_recorded_replace, apply_text_replace, - field_has_internal_duplicate, other_claimants, text_conflicts_for, + PendingShortcutConflict, apply_recorded_replace, apply_text_replace, other_claimants, + text_conflicts_for, }; pub use draft::KeybindingsDraft; pub use edit::{ - AppendOutcome, ShortcutTextEditor, append_binding, field_matches_defaults, remove_binding, - reset_field, reset_fields, reset_tooltip, serialize_bindings, + AppendOutcome, ShortcutTextEditor, append_binding, remove_binding, reset_field, reset_fields, + reset_tooltip, serialize_bindings, }; pub use field::{KeybindingField, keybinding_fields, keybinding_tab}; pub use manager::{ diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 4a9ce3f79..405f11b5f 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -617,6 +617,26 @@ The resulting edit effects invalidate the affected drawing regions and mark the Undo/redo restores the grouped shapes; session snapshot capture later copies the configured history retention without mutating the live frame. Rendering and persistence are separate effects. +`Frame::shapes` uses `FrameShapes`, whose mutable borrows advance a runtime content token. +Read-only access leaves the token unchanged; cloned frames diverge on their next mutation. +The serialized shape array is unchanged. `Frame::content_revision()` covers shape edits, +previews, rollback and history application. View offsets and board backgrounds are separate +render inputs. + +The UI render owner retains up to 24 page-content rasters and 16 MiB of raster/backdrop bytes. +Keys include content, view, background, target geometry and scale, text halo, font-map serial, +Cairo settings, and the exact card pixels beneath transparent content. Removed page content is +pruned when the picker prepares a frame. Partial clips, unsupported targets and oversized cards +use direct replay. Hover controls, labels and selection borders remain live. Pixel tests compare +fresh and reused rasters with direct replay; fractional-scale Spotlight gradients allow at most +one byte of rounding on less than 0.1% of channels. + +The command palette owns search freshness, result preparation, selection reconciliation and +repeat timing. Idle repeat ticks do no row preparation. A one-row viewport omits headings; +when no row fits, Enter does not execute an invisible command. GTK toolbar sliders retain arrow +and endpoint keys; Escape releases a top-level slider to the canvas or dismisses its popover. + + Capture crosses [CapturePreflight](../src/backend/wayland/capture_preflight.rs), which coordinates suppression and the capture barrier before acquisition. Freeze and zoom own their pending portal operation, task result, deadline, and restore state in [frozen/](../src/backend/wayland/frozen/) diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 9bf6fa6f8..6516b1e09 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -202,10 +202,16 @@ impl RenderRuntime { &mut self, ) -> ( &mut crate::draw::RenderCaches, + &mut crate::ui::UiRenderCaches, &crate::ui_text::UiTextEngine, &crate::draw::TextMeasurer, ) { - (&mut self.draw_caches, &self.ui_text, &self.text_measurer) + ( + &mut self.draw_caches, + &mut self.ui_caches, + &self.ui_text, + &self.text_measurer, + ) } pub(in crate::backend::wayland::state) fn canvas_draw_parts_mut( diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 00c8851b9..1c4a148cf 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -238,11 +238,12 @@ impl WaylandState { if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); - let (caches, engine, measurer) = self.render.draw_ui_text_parts_mut(); + let (caches, ui_caches, engine, measurer) = self.render.draw_ui_text_parts_mut(); let mut render = crate::draw::RenderCtx::new(ctx, caches); crate::ui::render_board_picker_with_halo( engine, measurer, + &mut ui_caches.thumbnails, &mut render, &self.input_state, width, diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 240e6421b..b37db6850 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -713,25 +713,22 @@ fn push_style_pill( let is_last = index + 1 == swatch_count; x += TOP_SWATCH_SIZE + if is_last { gap } else { TOP_SWATCH_GAP }; } - model::StylePillControl::ThicknessSlider - | model::StylePillControl::OpacitySlider - | model::StylePillControl::SpotlightMagnificationSlider - | model::StylePillControl::FontSizeSlider => { - let (slider_spec, value) = control.slider_value(snapshot); + model::StylePillControl::Slider(slider_kind) => { + let (slider_spec, value) = slider_kind.value(snapshot); let event = control.click_event(snapshot); - let kind = match control { - model::StylePillControl::ThicknessSlider => HitKind::DragSetThickness { + let kind = match slider_kind { + model::StylePillSlider::Thickness => HitKind::DragSetThickness { min: slider_spec.min, max: slider_spec.max, }, - model::StylePillControl::OpacitySlider => HitKind::DragSetMarkerOpacity { + model::StylePillSlider::Opacity => HitKind::DragSetMarkerOpacity { min: slider_spec.min, max: slider_spec.max, }, - model::StylePillControl::SpotlightMagnificationSlider => { + model::StylePillSlider::SpotlightMagnification => { HitKind::DragSetSpotlightMagnification } - _ => HitKind::DragSetFontSize, + model::StylePillSlider::FontSize => HitKind::DragSetFontSize, }; let rect = ( x, diff --git a/src/config/validate/boards.rs b/src/config/validate/boards.rs index 52b99dbf0..336050bee 100644 --- a/src/config/validate/boards.rs +++ b/src/config/validate/boards.rs @@ -1,5 +1,5 @@ use crate::config::types::{BoardBackgroundConfig, BoardColorConfig, BoardsConfig}; -use crate::input::boards::{BoundaryBoardIdSet, clamp_board_rgb}; +use crate::domain::{BoundaryBoardIdSet, clamp_board_rgb}; use log::warn; use super::Config; diff --git a/src/domain/board_validation.rs b/src/domain/board_validation.rs new file mode 100644 index 000000000..6832de620 --- /dev/null +++ b/src/domain/board_validation.rs @@ -0,0 +1,97 @@ +//! Pure normalization at configuration and persistence boundaries. +use std::collections::HashSet; + +pub fn clamp_board_rgb(mut rgb: [f64; 3]) -> ([f64; 3], bool) { + let mut clamped = false; + for component in &mut rgb { + if !(0.0..=1.0).contains(component) { + *component = (*component).clamp(0.0, 1.0); + clamped = true; + } + } + (rgb, clamped) +} + +pub struct BoundaryBoardId { + pub value: String, + pub changes: BoardIdChangeSet, +} + +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoardIdChangeSet { + pub trimmed: bool, + pub lowercased: bool, + pub defaulted_empty: bool, + pub deduplicated: bool, +} + +#[derive(Default)] +pub struct BoundaryBoardIdSet { + seen: HashSet, +} + +impl BoundaryBoardIdSet { + pub fn new() -> Self { + Self::default() + } + + pub fn normalize_unique(&mut self, raw: &str, index: usize) -> BoundaryBoardId { + let trimmed = raw.trim(); + let lowercased = trimmed.to_lowercase(); + let mut changes = BoardIdChangeSet { + trimmed: trimmed != raw, + lowercased: lowercased != trimmed, + ..BoardIdChangeSet::default() + }; + + let mut candidate = lowercased; + if candidate.is_empty() { + candidate = format!("board-{}", index + 1); + changes.defaulted_empty = true; + } + + let base = candidate.clone(); + let mut suffix = 2; + while self.seen.contains(&candidate) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + changes.deduplicated = true; + } + + self.seen.insert(candidate.clone()); + BoundaryBoardId { + value: candidate, + changes, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boundary_board_ids_trim_lowercase_default_and_dedupe() { + let mut ids = BoundaryBoardIdSet::new(); + + let first = ids.normalize_unique(" Board-A ", 0); + assert_eq!(first.value, "board-a"); + assert_eq!( + first.changes, + BoardIdChangeSet { + trimmed: true, + lowercased: true, + defaulted_empty: false, + deduplicated: false, + } + ); + + let second = ids.normalize_unique("board-a", 1); + assert_eq!(second.value, "board-a-2"); + assert!(second.changes.deduplicated); + + let third = ids.normalize_unique(" ", 2); + assert_eq!(third.value, "board-3"); + assert!(third.changes.defaulted_empty); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 587436dd5..d7cae2860 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -6,6 +6,7 @@ mod action; mod board; +mod board_validation; pub mod color; mod drawing; mod onboarding; @@ -15,6 +16,9 @@ pub use action::Action; pub use board::{ BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, BoardSpec, }; +pub use board_validation::{ + BoardIdChangeSet, BoundaryBoardId, BoundaryBoardIdSet, clamp_board_rgb, +}; pub use color::Color; pub use drawing::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; pub use onboarding::OnboardingTip; diff --git a/src/domain/tests.rs b/src/domain/tests.rs index d9dc64d19..989789b28 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -396,7 +396,7 @@ fn production_domain_sources_have_no_upward_crate_dependencies() { } assert_eq!( - checked, 7, + checked, 8, "architecture test must cover every domain source" ); } diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index db15b82d8..f8334c651 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -11,7 +11,7 @@ std::thread_local! { #[derive(Debug, Clone, Serialize)] pub struct Frame { #[serde(with = "frame_storage")] - pub shapes: Vec, + pub shapes: super::FrameShapes, #[serde(default, skip_serializing_if = "Option::is_none")] pub page_name: Option, #[serde(default, skip_serializing_if = "is_origin_offset")] @@ -39,7 +39,7 @@ impl Frame { /// Creates a new empty frame. pub fn new() -> Self { Self { - shapes: Vec::new(), + shapes: super::FrameShapes::default(), page_name: None, view_offset: (0, 0), undo_stack: Vec::new(), @@ -130,6 +130,11 @@ impl Frame { || (history_limit > 0 && (!self.undo_stack.is_empty() || !self.redo_stack.is_empty())) } + /// Runtime token for the shape content; view/background are separate cache inputs. + pub fn content_revision(&self) -> u64 { + self.shapes.revision() + } + pub fn view_offset(&self) -> (i32, i32) { self.view_offset } diff --git a/src/draw/frame/mod.rs b/src/draw/frame/mod.rs index 3acece7f8..444eb2f76 100644 --- a/src/draw/frame/mod.rs +++ b/src/draw/frame/mod.rs @@ -4,12 +4,14 @@ mod core; mod frame_storage; mod history; mod serde; +mod shapes; mod types; #[cfg(test)] mod tests; pub use core::Frame; +pub use shapes::FrameShapes; #[allow(unused_imports)] pub use types::{ DrawnShape, HistoryTrimStats, ImageBoundsSnapshot, MAX_COMPOUND_DEPTH, ShapeId, ShapeSnapshot, diff --git a/src/draw/frame/serde.rs b/src/draw/frame/serde.rs index cfca6cf21..1a1e0e10c 100644 --- a/src/draw/frame/serde.rs +++ b/src/draw/frame/serde.rs @@ -69,7 +69,7 @@ impl<'de> Deserialize<'de> for Frame { let helper = FrameHelper::deserialize(deserializer)?; let mut frame = Frame { - shapes: helper.shapes, + shapes: helper.shapes.into(), page_name: helper.page_name, view_offset: helper.view_offset, undo_stack: helper.undo_stack, diff --git a/src/draw/frame/shapes.rs b/src/draw/frame/shapes.rs new file mode 100644 index 000000000..5a8bf2c54 --- /dev/null +++ b/src/draw/frame/shapes.rs @@ -0,0 +1,123 @@ +use super::DrawnShape; +use std::ops::{Deref, DerefMut}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_CONTENT_REVISION: AtomicU64 = AtomicU64::new(1); + +fn fresh_revision() -> u64 { + NEXT_CONTENT_REVISION.fetch_add(1, Ordering::Relaxed) +} + +/// Shape storage whose mutable borrows invalidate visible-content caches. +/// +/// Clones initially share a content token; every subsequent mutable borrow gets +/// a globally distinct token, including undo, previews, rollback, and direct +/// index/iterator edits. Read-only access does not advance it. The token is never +/// serialized, and replacing this collection carries the replacement's token. +#[derive(Debug, Clone)] +pub struct FrameShapes { + values: Vec, + revision: u64, +} + +impl Default for FrameShapes { + fn default() -> Self { + Vec::new().into() + } +} + +impl FrameShapes { + pub(super) fn revision(&self) -> u64 { + self.revision + } +} + +impl From> for FrameShapes { + fn from(values: Vec) -> Self { + Self { + values, + revision: fresh_revision(), + } + } +} + +impl FromIterator for FrameShapes { + fn from_iter>(iter: T) -> Self { + iter.into_iter().collect::>().into() + } +} + +impl Deref for FrameShapes { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.values + } +} + +impl DerefMut for FrameShapes { + fn deref_mut(&mut self) -> &mut Self::Target { + self.revision = fresh_revision(); + &mut self.values + } +} + +impl<'a> IntoIterator for &'a FrameShapes { + type Item = &'a DrawnShape; + type IntoIter = std::slice::Iter<'a, DrawnShape>; + fn into_iter(self) -> Self::IntoIter { + self.values.iter() + } +} + +impl<'a> IntoIterator for &'a mut FrameShapes { + type Item = &'a mut DrawnShape; + type IntoIter = std::slice::IterMut<'a, DrawnShape>; + fn into_iter(self) -> Self::IntoIter { + self.deref_mut().iter_mut() + } +} + +impl IntoIterator for FrameShapes { + type Item = DrawnShape; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.values.into_iter() + } +} + +#[cfg(test)] +mod tests { + use crate::draw::{Color, Frame, Shape}; + #[test] + fn content_tokens_cover_mutable_access_and_diverging_clones_without_serializing() { + let mut frame = Frame::new(); + let id = frame.add_shape(Shape::Line { + x1: 0, + y1: 0, + x2: 20, + y2: 30, + color: Color::new(1.0, 1.0, 1.0, 1.0), + thick: 2.0, + }); + let original = frame.content_revision(); + assert_eq!(frame.shapes.len(), 1); + assert!(frame.shape(id).is_some()); + assert_eq!(frame.content_revision(), original); + let mut copy = frame.clone(); + assert_eq!(copy.content_revision(), original); + frame.shape_mut(id).unwrap().shape.translate(5, 0); + copy.shapes[0].shape.translate(10, 0); + assert_ne!(frame.content_revision(), original); + assert_ne!(copy.content_revision(), frame.content_revision()); + let before = copy.content_revision(); + for shape in &mut copy.shapes { + shape.shape.translate(5, 0); + } + assert_ne!(copy.content_revision(), before); + let json = serde_json::to_string(©).unwrap(); + assert!(!json.contains("revision")); + let restored: Frame = serde_json::from_str(&json).unwrap(); + assert_ne!(restored.content_revision(), copy.content_revision()); + assert_eq!(serde_json::to_string(&restored).unwrap(), json); + } +} diff --git a/src/input/boards/color.rs b/src/input/boards/color.rs index d70696cfb..2c1a12b26 100644 --- a/src/input/boards/color.rs +++ b/src/input/boards/color.rs @@ -33,16 +33,7 @@ pub fn board_color_to_config(color: Color) -> BoardColorConfig { BoardColorConfig::Rgb([color.r, color.g, color.b]) } -pub fn clamp_board_rgb(mut rgb: [f64; 3]) -> ([f64; 3], bool) { - let mut clamped = false; - for component in &mut rgb { - if !(0.0..=1.0).contains(component) { - *component = (*component).clamp(0.0, 1.0); - clamped = true; - } - } - (rgb, clamped) -} +pub use crate::domain::clamp_board_rgb; #[cfg(test)] mod tests { diff --git a/src/input/boards/identity.rs b/src/input/boards/identity.rs index d2ef65128..e3d7016a8 100644 --- a/src/input/boards/identity.rs +++ b/src/input/boards/identity.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_BOARD_IDENTITY_GENERATION: AtomicU64 = AtomicU64::new(1); @@ -12,86 +11,4 @@ impl BoardIdentityGeneration { } } -pub struct BoundaryBoardId { - pub value: String, - pub changes: BoardIdChangeSet, -} - -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] -pub struct BoardIdChangeSet { - pub trimmed: bool, - pub lowercased: bool, - pub defaulted_empty: bool, - pub deduplicated: bool, -} - -#[derive(Default)] -pub struct BoundaryBoardIdSet { - seen: HashSet, -} - -impl BoundaryBoardIdSet { - pub fn new() -> Self { - Self::default() - } - - pub fn normalize_unique(&mut self, raw: &str, index: usize) -> BoundaryBoardId { - let trimmed = raw.trim(); - let lowercased = trimmed.to_lowercase(); - let mut changes = BoardIdChangeSet { - trimmed: trimmed != raw, - lowercased: lowercased != trimmed, - ..BoardIdChangeSet::default() - }; - - let mut candidate = lowercased; - if candidate.is_empty() { - candidate = format!("board-{}", index + 1); - changes.defaulted_empty = true; - } - - let base = candidate.clone(); - let mut suffix = 2; - while self.seen.contains(&candidate) { - candidate = format!("{base}-{suffix}"); - suffix += 1; - changes.deduplicated = true; - } - - self.seen.insert(candidate.clone()); - BoundaryBoardId { - value: candidate, - changes, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn boundary_board_ids_trim_lowercase_default_and_dedupe() { - let mut ids = BoundaryBoardIdSet::new(); - - let first = ids.normalize_unique(" Board-A ", 0); - assert_eq!(first.value, "board-a"); - assert_eq!( - first.changes, - BoardIdChangeSet { - trimmed: true, - lowercased: true, - defaulted_empty: false, - deduplicated: false, - } - ); - - let second = ids.normalize_unique("board-a", 1); - assert_eq!(second.value, "board-a-2"); - assert!(second.changes.deduplicated); - - let third = ids.normalize_unique(" ", 2); - assert_eq!(third.value, "board-3"); - assert!(third.changes.defaulted_empty); - } -} +pub use crate::domain::{BoardIdChangeSet, BoundaryBoardId, BoundaryBoardIdSet}; diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index b66345c17..21100f8e3 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -110,6 +110,7 @@ impl InputState { fn open_command_palette_internal(&mut self, track_usage: bool) { self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::CommandPalette); self.command_palette.open(); + self.reconcile_command_palette_scroll(); self.clear_command_palette_repeat(); if track_usage { self.pending_onboarding_usage.used_command_palette = true; @@ -145,7 +146,7 @@ impl InputState { return self.handle_keybinding_capture_key(action, key); } - match key { + let handled = match key { // Every modifier a `KeyBinding` can carry is tracked here, because // the shortcut controls and the capture modal read all four: // without this arm the palette would swallow the press, Ctrl+Shift+E @@ -241,7 +242,11 @@ impl InputState { true } _ => true, // Consume all other keys while palette is open + }; + if self.command_palette.is_open() { + self.reconcile_command_palette_scroll(); } + handled } fn handle_keybinding_capture_key(&mut self, action: Action, key: Key) -> bool { @@ -283,19 +288,25 @@ impl InputState { self.command_palette.clear_repeat(); } pub(crate) fn reconcile_command_palette_scroll(&mut self) { - let rows = self.command_palette_rows(); + self.prepare_command_palette(); + } + fn prepare_command_palette(&mut self) -> Vec { let capacity = self.command_palette_row_capacity(); - self.command_palette.reconcile_scroll(&rows, capacity); + self.command_palette.prepare( + self.keymap.revision(), + |action| self.keymap.action_binding_labels(action), + capacity, + ) } fn move_command_palette_selection(&mut self, key: Key) -> bool { - let rows = self.command_palette_rows(); + let rows = self.prepare_command_palette(); let capacity = self.command_palette_row_capacity(); let changed = self.command_palette.navigate(key, &rows, capacity); self.needs_redraw |= changed; changed } pub fn command_palette_wheel_scroll(&mut self, direction: i32) { - let rows = self.command_palette_rows(); + let rows = self.prepare_command_palette(); let capacity = self.command_palette_row_capacity(); self.needs_redraw |= self .command_palette @@ -308,9 +319,12 @@ impl InputState { self.command_palette.repeat_timeout(now) } pub(crate) fn tick_command_palette_repeat(&mut self, now: Instant) -> bool { - let rows = self.command_palette_rows(); let capacity = self.command_palette_row_capacity(); - let changed = self.command_palette.tick_repeat(now, &rows, capacity); + let changed = + self.command_palette + .tick_repeat(now, capacity, self.keymap.revision(), |action| { + self.keymap.action_binding_labels(action) + }); self.needs_redraw |= changed; changed } diff --git a/src/input/state/core/command_palette/layout.rs b/src/input/state/core/command_palette/layout.rs index 356090ebf..b0a5bb9f6 100644 --- a/src/input/state/core/command_palette/layout.rs +++ b/src/input/state/core/command_palette/layout.rs @@ -171,7 +171,7 @@ impl InputState { if height == 0 { COMMAND_PALETTE_MAX_VISIBLE } else { - command_palette_visible_count(usize::MAX, height).max(1) + command_palette_visible_count(usize::MAX, height) } } diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 12f5de0e5..c6d97eee3 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -62,6 +62,35 @@ mod tests { (x, y) } + #[test] + fn first_one_row_frame_is_visible_and_clickable_after_query_resets() { + let mut state = make_state(); + state.update_screen_dimensions(240, 180); + state.toggle_command_palette(); + for query in ["", "zoom", ""] { + state.command_palette.set_query(query); + let rows = state.command_palette_rows(); + let geometry = state.command_palette_geometry_for_rows(240, 180, &rows); + assert_eq!(geometry.visible_count, 1); + assert_eq!( + rows[state.command_palette.scroll()].command_index(), + Some(state.command_palette.selected()) + ); + let x = (geometry.x + geometry.inner_x + 4.0) as i32; + let y = (geometry.y + geometry.items_top + 10.0) as i32; + assert_eq!( + state.command_palette_cursor_hint_at(x, y, 240, 180), + Some(CommandPaletteCursorHint::Pointer) + ); + } + state.update_screen_dimensions(80, 80); + assert_eq!(state.command_palette_row_capacity(), 0); + assert!( + state.selected_command().is_none(), + "Enter cannot run an invisible command when no row fits" + ); + } + #[test] fn navigation_and_resize_keep_selection_in_the_viewport() { let measurer = crate::draw::TextMeasurer::default(); diff --git a/src/input/state/core/command_palette/search.rs b/src/input/state/core/command_palette/search.rs index 9c5f93b02..d414424b4 100644 --- a/src/input/state/core/command_palette/search.rs +++ b/src/input/state/core/command_palette/search.rs @@ -1,5 +1,5 @@ use super::super::base::InputState; -use super::{CommandEntry, command_palette_entries}; +use super::{CommandEntry, CommandPaletteState, command_palette_entries}; use crate::config::action_meta::{ActionCategory, ActionMeta}; use crate::domain::Action; use crate::input::state::core::search::fuzzy_score; @@ -44,7 +44,7 @@ pub(in crate::input::state::core) struct CommandPaletteResults { pub(super) results: Vec<&'static CommandEntry>, } -impl InputState { +impl CommandPaletteState { /// Get the filtered list of commands matching the current query. /// /// Memoized: scoring walks the whole registry and allocates per entry, @@ -52,24 +52,30 @@ impl InputState { /// keystroke and repeat tick. The key is everything `score_command` reads: /// the query, the recents that bias it, and the keymap revision behind the /// shortcut labels it folds in. - pub fn filtered_commands(&self) -> Vec<&'static CommandEntry> { - if let Some(results) = self.command_palette.cached_results(self.keymap.revision()) { + pub(super) fn filtered_commands( + &self, + revision: u64, + labels: impl Fn(Action) -> Vec, + ) -> Vec<&'static CommandEntry> { + if let Some(results) = self.cached_results(revision) { return results; } - let results = self.score_filtered_commands(); - self.command_palette - .cache_results(self.keymap.revision(), results.clone()); + let results = self.score_filtered_commands(&labels); + self.cache_results(revision, results.clone()); results } - fn score_filtered_commands(&self) -> Vec<&'static CommandEntry> { - let query = normalize_query(&self.command_palette.query); + fn score_filtered_commands( + &self, + labels: &impl Fn(Action) -> Vec, + ) -> Vec<&'static CommandEntry> { + let query = normalize_query(&self.query); let tokens = query_tokens(&query); let mut results: Vec = command_palette_entries() .enumerate() .filter_map(|(index, command)| { - self.score_command(command, &query, &tokens) + self.score_command(command, &query, &tokens, labels) .map(|score| CommandMatch { command, score, @@ -91,23 +97,28 @@ impl InputState { /// registry-ordered categories), so headers always show. With a query, /// headers show only when every category present forms exactly one /// contiguous run in score order; any interleaving renders flat. - pub fn command_palette_rows(&self) -> Vec { - let filtered = self.filtered_commands(); - let query_empty = normalize_query(&self.command_palette.query).is_empty(); + pub(super) fn rows( + &self, + revision: u64, + labels: impl Fn(Action) -> Vec, + capacity: usize, + ) -> Vec { + let filtered = self.filtered_commands(revision, labels); + let query_empty = normalize_query(&self.query).is_empty(); let recent_len = if query_empty { filtered .iter() - .take_while(|command| self.command_palette.recent.contains(&command.action)) + .take_while(|command| self.recent.contains(&command.action)) .count() } else { 0 }; - build_command_palette_rows(&filtered, query_empty, recent_len) - } - - pub(super) fn selected_command(&self) -> Option<&'static CommandEntry> { - let filtered = self.filtered_commands(); - filtered.get(self.command_palette.selected).copied() + let mut rows = build_command_palette_rows(&filtered, query_empty, recent_len); + // A heading must never occupy the sole clickable row. + if capacity <= 1 { + rows.retain(|row| row.command_index().is_some()); + } + rows } fn score_command( @@ -115,13 +126,14 @@ impl InputState { command: &'static CommandEntry, query: &str, tokens: &[&str], + labels: &impl Fn(Action) -> Vec, ) -> Option { - let recent_bonus = self.command_palette.recent_bonus(command.action); + let recent_bonus = self.recent_bonus(command.action); if query.is_empty() { return Some(recent_bonus); } - let shortcuts = self.action_binding_labels(command.action).join(" "); + let shortcuts = labels(command.action).join(" "); let mut score = 0; // Require all tokens to match somewhere for cleaner result sets. The @@ -146,6 +158,32 @@ impl InputState { Some(score + (recent_bonus / 2)) } +} + +impl InputState { + pub fn filtered_commands(&self) -> Vec<&'static CommandEntry> { + self.command_palette + .filtered_commands(self.keymap.revision(), |action| { + self.keymap.action_binding_labels(action) + }) + } + + pub fn command_palette_rows(&self) -> Vec { + self.command_palette.rows( + self.keymap.revision(), + |action| self.keymap.action_binding_labels(action), + self.command_palette_row_capacity(), + ) + } + + pub(super) fn selected_command(&self) -> Option<&'static CommandEntry> { + if self.command_palette_row_capacity() == 0 { + return None; + } + self.filtered_commands() + .get(self.command_palette.selected) + .copied() + } pub(super) fn record_command_palette_action(&mut self, action: Action) { self.command_palette.record_action(action); @@ -164,6 +202,9 @@ impl InputState { /// Seed the in-memory recents from the persisted store at startup. pub fn set_command_palette_recents(&mut self, recents: Vec) { self.command_palette.set_recents(recents); + if self.command_palette.is_open() { + self.reconcile_command_palette_scroll(); + } } /// True (and reset) when the recents changed since the last drain; the diff --git a/src/input/state/core/command_palette/state.rs b/src/input/state/core/command_palette/state.rs index 375e5ac4d..68f2d5fbf 100644 --- a/src/input/state/core/command_palette/state.rs +++ b/src/input/state/core/command_palette/state.rs @@ -157,6 +157,24 @@ impl Default for CommandPaletteState { } impl CommandPaletteState { + /// Single preparation seam for transitions and viewport changes. + pub(super) fn prepare( + &mut self, + revision: u64, + labels: impl Fn(Action) -> Vec, + capacity: usize, + ) -> Vec { + let rows = self.rows(revision, labels, capacity); + self.selected = self.selected.min( + rows.iter() + .rev() + .find_map(|row| row.command_index()) + .unwrap_or(0), + ); + self.reconcile_scroll(&rows, capacity); + rows + } + pub(super) fn reconcile_scroll( &mut self, rows: &[super::CommandPaletteListRow], @@ -180,6 +198,9 @@ impl CommandPaletteState { capacity: usize, ) -> bool { use crate::input::Key; + if capacity == 0 { + return false; + } let Some(last) = rows.iter().rev().find_map(|row| row.command_index()) else { return false; }; @@ -215,7 +236,7 @@ impl CommandPaletteState { rows: &[super::CommandPaletteListRow], capacity: usize, ) -> bool { - if direction == 0 || !self.open { + if direction == 0 || !self.open || capacity == 0 { return false; } let capacity = capacity.max(1); @@ -290,8 +311,9 @@ impl CommandPaletteState { pub(super) fn tick_repeat( &mut self, now: std::time::Instant, - rows: &[super::CommandPaletteListRow], capacity: usize, + revision: u64, + labels: impl Fn(Action) -> Vec, ) -> bool { if !self.open { self.repeat.clear(); @@ -300,9 +322,62 @@ impl CommandPaletteState { let Some(key) = self.repeat.due_key(now) else { return false; }; - let changed = self.navigate(key, rows, capacity); + let rows = self.prepare(revision, labels, capacity); + let changed = self.navigate(key, &rows, capacity); self.repeat .schedule_fixed(now, std::time::Duration::from_millis(55)); changed } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Key; + use std::time::{Duration, Instant}; + + #[test] + fn idle_repeat_ticks_do_not_prepare_results() { + let mut palette = CommandPaletteState::default(); + let now = Instant::now(); + let unexpected = |_| panic!("idle tick prepared search results"); + assert!(!palette.tick_repeat(now, 8, 0, unexpected)); + assert!(palette.results.borrow().is_none()); + palette.open(); + palette.set_query("zoom"); + assert!(!palette.tick_repeat(now, 8, 0, unexpected)); + palette.start_repeat(Key::Down, now); + assert!(!palette.tick_repeat(now + Duration::from_millis(279), 8, 0, unexpected)); + assert!(palette.results.borrow().is_none()); + let due = now + Duration::from_millis(280); + assert!(palette.tick_repeat(due, 8, 0, |_| Vec::new())); + assert_eq!(palette.selected(), 1); + assert_eq!(palette.repeat_timeout(due), Some(Duration::from_millis(55))); + assert!(!palette.tick_repeat(due, 8, 0, unexpected)); + assert_eq!(palette.selected(), 1); + } + + #[test] + fn preparation_keeps_first_command_visible_after_open_query_and_resize() { + let mut palette = CommandPaletteState::default(); + palette.open(); + for query in ["", "zoom", ""] { + palette.set_query(query); + let rows = palette.prepare(0, |_| Vec::new(), 1); + assert_eq!( + rows[palette.scroll()].command_index(), + Some(palette.selected()) + ); + } + let rows = palette.prepare(0, |_| Vec::new(), 8); + palette.navigate(Key::End, &rows, 8); + let rows = palette.prepare(0, |_| Vec::new(), 1); + assert_eq!( + rows[palette.scroll()].command_index(), + Some(palette.selected()) + ); + palette.set_query("no_matching_command_987654"); + assert!(palette.prepare(0, |_| Vec::new(), 0).is_empty()); + assert_eq!((palette.selected(), palette.scroll()), (0, 0)); + } +} diff --git a/src/input/state/core/editing/tests.rs b/src/input/state/core/editing/tests.rs index 5a3d1ad38..293f7b413 100644 --- a/src/input/state/core/editing/tests.rs +++ b/src/input/state/core/editing/tests.rs @@ -20,6 +20,7 @@ fn rollback_restores_preview_without_touching_history_or_locked_shapes() { frame.shape_mut(locked).unwrap().locked = true; let history = frame.undo_stack_len(); let measurer = TextMeasurer::default(); + let revision = frame.content_revision(); let edit = CanvasEdit::capture(&frame, &[first, locked]); let effects = edit.preview(&mut frame, &measurer, |shape, _| { shape.translate(25, 0); @@ -29,7 +30,10 @@ fn rollback_restores_preview_without_touching_history_or_locked_shapes() { assert!(!effects.committed); assert_eq!(frame.shape(first).unwrap().shape, rectangle(35)); assert_eq!(frame.shape(locked).unwrap().shape, rectangle(50)); + assert_ne!(frame.content_revision(), revision); + let preview_revision = frame.content_revision(); let effects = edit.rollback(&mut frame, &measurer); + assert_ne!(frame.content_revision(), preview_revision); assert_eq!(effects.regions.len(), 1); assert!(!effects.committed); assert_ne!(effects.regions[0].1, effects.regions[0].2); diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index 9bbca6d22..12f47592f 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -49,6 +49,9 @@ impl InputState { pub fn set_action_bindings(&mut self, action_bindings: HashMap>) { self.keymap.set_action_bindings(action_bindings); + if self.command_palette.is_open() { + self.reconcile_command_palette_scroll(); + } } pub(crate) fn keymap_revision(&self) -> u64 { @@ -66,6 +69,9 @@ impl InputState { action_bindings: HashMap>, ) { self.keymap.set_maps(action_map, action_bindings); + if self.command_palette.is_open() { + self.reconcile_command_palette_scroll(); + } self.needs_redraw = true; } diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index f57434e47..b0dd755aa 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -158,19 +158,16 @@ impl TopBar { swatch.set_selected(entry_color == snapshot.color); })); } - model::StylePillControl::ThicknessSlider - | model::StylePillControl::OpacitySlider - | model::StylePillControl::SpotlightMagnificationSlider - | model::StylePillControl::FontSizeSlider => { - let (slider_spec, value) = control.slider_value(snapshot); + model::StylePillControl::Slider(slider_kind) => { + let (slider_spec, value) = slider_kind.value(snapshot); let sender = self.feedback.clone(); let slider = SliderRow::new( scale, control.label(snapshot).as_ref(), slider_spec, value, - control.slider_formatter(), - move |value| send_event(&sender, control.slider_event(value)), + slider_kind.formatter(), + move |value| send_event(&sender, slider_kind.event(value)), ); // Thickness/text-size use distinct numeral controls. The // other readouts sit beside a full-width track, matching @@ -197,7 +194,7 @@ impl TopBar { slider.root.set_valign(gtk4::Align::Center); append_gap(&pill, slider.root.upcast_ref(), gap); self.updaters.borrow_mut().push(Box::new(move |snapshot| { - let value = control.slider_value(snapshot).1; + let value = slider_kind.value(snapshot).1; slider.set_value(value); })); self.append_style_status_label(&pill, control, snapshot, px(gap)); diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs index 3e4dcb042..3ebf0cbdc 100644 --- a/src/toolbar_gtk/widgets/slider.rs +++ b/src/toolbar_gtk/widgets/slider.rs @@ -144,6 +144,20 @@ impl SliderRow { let key_area = area.clone(); let key_label = value_label.clone(); key.connect_key_pressed(move |_, key, _, _| { + if key == gtk4::gdk::Key::Escape { + if !key_state.dragging.get() { + if let Some(popover) = key_area + .ancestor(gtk4::Popover::static_type()) + .and_downcast::() + { + // Dismiss the grabbed native before changing layer focus. + popover.popdown(); + } else { + super::release_window_keyboard_focus(&key_area); + } + } + return gtk4::glib::Propagation::Stop; + } let Some(value) = keyboard_value(key_state.spec, key_state.value.get(), key) else { return gtk4::glib::Propagation::Proceed; }; @@ -262,10 +276,42 @@ pub(super) fn assert_widget_contract() { ); assert_eq!(slider.state.value.get(), 5.5); assert_eq!(changes.borrow().as_slice(), &[5.5]); + let window = gtk4::Window::new(); + window.set_child(Some(&slider.root)); + gtk4::prelude::GtkWindowExt::set_focus(&window, Some(&slider.area)); + let escape = || { + key.emit_by_name::( + "key-pressed", + &[ + >k4::gdk::Key::Escape, + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ) + }; slider.state.dragging.set(true); + assert!(escape()); + assert!(gtk4::prelude::GtkWindowExt::focus(&window).is_some()); slider.set_value(10.0); assert_eq!(slider.state.value.get(), 5.5); slider.state.dragging.set(false); + assert!(escape()); + assert!(gtk4::prelude::GtkWindowExt::focus(&window).is_none()); + assert_eq!(slider.state.value.get(), 5.5); + window.set_child(None::<>k4::Widget>); + let anchor = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + window.set_child(Some(&anchor)); + let popover = gtk4::Popover::new(); + popover.set_parent(&anchor); + popover.set_child(Some(&slider.root)); + popover.set_visible(true); + assert!(escape()); + assert!( + !popover.is_visible(), + "Escape dismisses the slider's popover" + ); + popover.set_child(None::<>k4::Widget>); + popover.unparent(); slider.set_value(6.25); assert_eq!(slider.state.value.get(), 6.25); assert_eq!( diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index 1e30eb3c9..aaff15562 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -12,6 +12,7 @@ mod page_panel; mod palette; mod rows; +pub(crate) use page_panel::ThumbnailCache; use page_panel::render_page_panel; use palette::render_board_palette; use rows::render_board_rows; @@ -30,6 +31,7 @@ pub fn render_board_picker( render_board_picker_with_halo( &engine, &measurer, + &mut ThumbnailCache::default(), &mut crate::draw::RenderCtx::new(ctx, &mut caches), input_state, screen_width, @@ -38,9 +40,11 @@ pub fn render_board_picker( ); } +#[allow(clippy::too_many_arguments)] pub(crate) fn render_board_picker_with_halo( engine: &UiTextEngine, measurer: &crate::draw::TextMeasurer, + cache: &mut ThumbnailCache, render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, screen_width: u32, @@ -52,6 +56,13 @@ pub(crate) fn render_board_picker_with_halo( return; } + cache.retain_revisions(input_state.boards.board_states().iter().flat_map(|board| { + board + .pages + .pages() + .iter() + .map(crate::draw::Frame::content_revision) + })); let layout = match input_state.board_picker_layout() { Some(layout) => layout, None => return, @@ -167,6 +178,7 @@ pub(crate) fn render_board_picker_with_halo( engine, measurer, render, + cache, input_state, layout, screen_width, diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 11ad5c0d2..ebaf7d79f 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -1,4 +1,5 @@ mod thumbnail; +pub(crate) use thumbnail::ThumbnailCache; use crate::input::InputState; use crate::input::state::{ @@ -31,6 +32,7 @@ pub(super) fn render_page_panel( engine: &UiTextEngine, measurer: &crate::draw::TextMeasurer, render: &mut crate::draw::RenderCtx<'_, '_>, + cache: &mut ThumbnailCache, input_state: &InputState, layout: &BoardPickerLayout, screen_width: u32, @@ -144,6 +146,7 @@ pub(super) fn render_page_panel( render_page_thumbnail( engine, measurer, + cache, PageThumbnailArgs { render, frame: page, @@ -183,6 +186,7 @@ pub(super) fn render_page_panel( render_page_preview( engine, measurer, + cache, PagePreviewArgs { render, frame: page, diff --git a/src/ui/board_picker/page_panel/thumbnail.rs b/src/ui/board_picker/page_panel/thumbnail.rs index 2b21e8702..6d9e3d40d 100644 --- a/src/ui/board_picker/page_panel/thumbnail.rs +++ b/src/ui/board_picker/page_panel/thumbnail.rs @@ -1,5 +1,7 @@ +mod cache; mod cards; mod content; +pub(crate) use cache::ThumbnailCache; mod icons; mod types; diff --git a/src/ui/board_picker/page_panel/thumbnail/cache.rs b/src/ui/board_picker/page_panel/thumbnail/cache.rs new file mode 100644 index 000000000..be01866bc --- /dev/null +++ b/src/ui/board_picker/page_panel/thumbnail/cache.rs @@ -0,0 +1,279 @@ +//! Bounded raster reuse keyed by content and the exact card pixels beneath it. +//! Retaining the backdrop preserves transparent paint and eraser semantics. +use super::content::render_page_content; +use super::types::PageContentArgs; +use crate::draw::{RenderCtx, TextMeasurer}; +use crate::input::BoardBackground; +use crate::ui_text::UiTextEngine; +use pango::prelude::*; +use std::collections::{HashSet, VecDeque}; + +const MAX_ENTRIES: usize = 24; +const MAX_BYTES: usize = 16 * 1024 * 1024; + +#[derive(PartialEq)] +struct ThumbnailKey { + content: u64, + view: (i32, i32), + background: Option<[u64; 3]>, + backdrop: Vec, + geometry: [u64; 4], + matrix: [u64; 6], + device_scale: [u64; 2], + screen: (u32, u32), + halo: bool, + font_map: usize, + font_serial: u32, + font_options: cairo::FontOptions, + antialias: cairo::Antialias, + line_cap: cairo::LineCap, + line_join: cairo::LineJoin, + fill_rule: cairo::FillRule, + line_values: [u64; 4], + dashes: Vec, +} + +struct Entry { + key: ThumbnailKey, + surface: cairo::ImageSurface, + bytes: usize, +} + +#[derive(Default)] +pub(crate) struct ThumbnailCache { + entries: VecDeque, + bytes: usize, + #[cfg(test)] + hits: usize, +} + +impl ThumbnailCache { + pub(crate) fn retain_revisions(&mut self, revisions: impl Iterator) { + let live: HashSet<_> = revisions.collect(); + self.entries + .retain(|entry| live.contains(&entry.key.content)); + self.bytes = self.entries.iter().map(|entry| entry.bytes).sum(); + } + + fn get(&mut self, key: &ThumbnailKey) -> Option { + let index = self.entries.iter().position(|entry| entry.key == *key)?; + let entry = self.entries.remove(index)?; + let surface = entry.surface.clone(); + self.entries.push_back(entry); + #[cfg(test)] + { + self.hits += 1; + } + Some(surface) + } + + fn insert(&mut self, key: ThumbnailKey, surface: cairo::ImageSurface) { + let bytes = surface.stride() as usize * surface.height() as usize + key.backdrop.len(); + if bytes > MAX_BYTES { + return; + } + while self.entries.len() >= MAX_ENTRIES || self.bytes + bytes > MAX_BYTES { + if let Some(entry) = self.entries.pop_front() { + self.bytes -= entry.bytes; + } + } + self.bytes += bytes; + self.entries.push_back(Entry { + key, + surface, + bytes, + }); + } +} + +pub(super) fn render_cached_page_content( + engine: &UiTextEngine, + measurer: &TextMeasurer, + cache: &mut ThumbnailCache, + mut args: PageContentArgs<'_, '_, '_>, +) { + if try_render_cached(engine, measurer, cache, &mut args).is_none() { + render_page_content(engine, measurer, args); + } +} + +fn try_render_cached( + engine: &UiTextEngine, + measurer: &TextMeasurer, + cache: &mut ThumbnailCache, + args: &mut PageContentArgs<'_, '_, '_>, +) -> Option<()> { + let ctx = args.render.cairo; + let matrix = ctx.matrix(); + let (dx, dy) = ctx.group_target().device_scale(); + // Native thumbnails use positive, axis-aligned transforms. Other callers + // retain the direct renderer without changing its transform semantics. + if matrix.xy() != 0.0 + || matrix.yx() != 0.0 + || matrix.xx() <= 0.0 + || matrix.yy() <= 0.0 + || ctx.operator() != cairo::Operator::Over + { + return None; + } + let clip = ctx.copy_clip_rectangle_list().ok()?; + if !clip.iter().any(|rect| { + rect.x() <= args.x + && rect.y() <= args.y + && rect.x() + rect.width() >= args.x + args.width + && rect.y() + rect.height() >= args.y + args.height + }) { + return None; + } + let left = ((args.x * matrix.xx() + matrix.x0()) * dx).floor(); + let top = ((args.y * matrix.yy() + matrix.y0()) * dy).floor(); + let width = (((args.x + args.width) * matrix.xx() + matrix.x0()) * dx).ceil() - left; + let height = (((args.y + args.height) * matrix.yy() + matrix.y0()) * dy).ceil() - top; + if ![left, top, width, height, dx, dy] + .iter() + .all(|value| value.is_finite()) + || width <= 0.0 + || height <= 0.0 + || dx <= 0.0 + || dy <= 0.0 + || width * height * 8.0 > MAX_BYTES as f64 + { + return None; + } + let destination = cairo::ImageSurface::try_from(ctx.group_target()).ok()?; + if destination.device_offset() != (0.0, 0.0) + || !matches!( + destination.format(), + cairo::Format::ARgb32 | cairo::Format::Rgb24 + ) + || left < 0.0 + || top < 0.0 + || left + width > f64::from(destination.width()) + || top + height > f64::from(destination.height()) + { + return None; + } + // Exact bytes avoid a hash-collision or ambient-background assumption for + // transparent boards and eraser strokes. This read is bounded by card size. + let mut backdrop = Vec::with_capacity(width as usize * height as usize * 4); + destination + .with_data(|pixels| { + for row in top as usize..(top + height) as usize { + let start = row * destination.stride() as usize + left as usize * 4; + backdrop.extend_from_slice(&pixels[start..start + width as usize * 4]); + } + }) + .ok()?; + let font_map = pangocairo::FontMap::default(); + let key = ThumbnailKey { + content: args.frame.content_revision(), + view: args.frame.view_offset(), + background: match args.background { + BoardBackground::Solid(color) => { + Some([color.r.to_bits(), color.g.to_bits(), color.b.to_bits()]) + } + BoardBackground::Transparent => None, + }, + backdrop, + geometry: [ + args.x.to_bits(), + args.y.to_bits(), + args.width.to_bits(), + args.height.to_bits(), + ], + matrix: [ + matrix.xx().to_bits(), + matrix.yx().to_bits(), + matrix.xy().to_bits(), + matrix.yy().to_bits(), + matrix.x0().to_bits(), + matrix.y0().to_bits(), + ], + device_scale: [dx.to_bits(), dy.to_bits()], + screen: (args.screen_width, args.screen_height), + halo: args.text_halo_enabled, + font_map: font_map.as_ptr() as usize, + font_serial: font_map.serial(), + font_options: ctx.font_options().ok()?, + antialias: ctx.antialias(), + line_cap: ctx.line_cap(), + line_join: ctx.line_join(), + fill_rule: ctx.fill_rule(), + line_values: [ + ctx.line_width().to_bits(), + ctx.miter_limit().to_bits(), + ctx.tolerance().to_bits(), + ctx.dash_offset().to_bits(), + ], + dashes: ctx.dash_dashes().into_iter().map(f64::to_bits).collect(), + }; + let surface = if let Some(surface) = cache.get(&key) { + surface + } else { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, width as i32, height as i32).ok()?; + surface.set_device_scale(dx, dy); + let target = cairo::Context::new(&surface).ok()?; + target.set_operator(cairo::Operator::Source); + target + .set_source_surface(&destination, -left / dx, -top / dy) + .ok()?; + target.paint().ok()?; + target.set_operator(cairo::Operator::Over); + target.set_matrix(cairo::Matrix::new( + matrix.xx(), + matrix.yx(), + matrix.xy(), + matrix.yy(), + matrix.x0() - left / dx, + matrix.y0() - top / dy, + )); + target.set_font_options(&key.font_options); + target.set_antialias(key.antialias); + target.set_line_cap(key.line_cap); + target.set_line_join(key.line_join); + target.set_fill_rule(key.fill_rule); + target.set_line_width(ctx.line_width()); + target.set_miter_limit(ctx.miter_limit()); + target.set_tolerance(ctx.tolerance()); + target.set_dash(&ctx.dash_dashes(), ctx.dash_offset()); + render_page_content( + engine, + measurer, + PageContentArgs { + render: &mut RenderCtx::new(&target, args.render.caches), + frame: args.frame, + background: args.background, + x: args.x, + y: args.y, + width: args.width, + height: args.height, + screen_width: args.screen_width, + screen_height: args.screen_height, + text_halo_enabled: args.text_halo_enabled, + }, + ); + target.status().ok()?; + drop(target); + cache.insert(key, surface.clone()); + surface + }; + ctx.save().ok()?; + ctx.identity_matrix(); + ctx.rectangle(left / dx, top / dy, width / dx, height / dy); + ctx.clip(); + ctx.set_operator(cairo::Operator::Source); + let painted = ctx + .set_source_surface(&surface, left / dx, top / dy) + .and_then(|()| { + ctx.source().set_filter(cairo::Filter::Nearest); + ctx.paint() + }); + let restored = ctx.restore(); + painted.ok()?; + restored.ok()?; + Some(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs new file mode 100644 index 000000000..decddab72 --- /dev/null +++ b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs @@ -0,0 +1,224 @@ +use super::*; +use crate::draw::frame::UndoAction; +use crate::draw::{Color, FontDescriptor, Frame, Shape}; + +fn rect(x: i32, color: Color) -> Shape { + Shape::Rect { + x, + y: 60, + w: 130, + h: 95, + fill: true, + color, + thick: 3.0, + } +} + +fn sample_frame() -> Frame { + let mut frame = Frame::new(); + frame.add_shape(rect(30, Color::new(1.0, 0.1, 0.2, 0.65))); + frame.add_shape(Shape::Text { + x: 40, + y: 220, + text: "Page α".into(), + color: Color::new(0.9, 0.8, 0.1, 1.0), + size: 32.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + frame +} + +fn pixels( + frame: &Frame, + background: &BoardBackground, + density: f64, + halo: bool, + cache: Option<&mut ThumbnailCache>, +) -> Vec { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + (160.0 * density).ceil() as i32, + (120.0 * density).ceil() as i32, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgba(0.15, 0.23, 0.35, 0.9); + ctx.paint().unwrap(); + ctx.scale(density, density); + let engine = UiTextEngine::default(); + let measurer = TextMeasurer::default(); + let mut draw = crate::draw::RenderCaches::default(); + let mut render = RenderCtx::new(&ctx, &mut draw); + let args = PageContentArgs { + render: &mut render, + frame, + background, + x: 8.25, + y: 10.5, + width: 128.0, + height: 96.0, + screen_width: 400, + screen_height: 300, + text_halo_enabled: halo, + }; + if let Some(cache) = cache { + render_cached_page_content(&engine, &measurer, cache, args); + } else { + render_page_content(&engine, &measurer, args); + } + } + surface.data().unwrap().to_vec() +} + +fn assert_parity(frame: &Frame, background: &BoardBackground, cache: &mut ThumbnailCache) { + for density in [1.0, 1.25, 2.0] { + let expected = pixels(frame, background, density, true, None); + let actual = pixels(frame, background, density, true, Some(cache)); + let mismatches = actual.iter().zip(&expected).filter(|(a, b)| a != b).count(); + let max_delta = actual + .iter() + .zip(&expected) + .map(|(a, b)| a.abs_diff(*b)) + .max() + .unwrap_or(0); + assert!( + max_delta <= 1 && mismatches <= expected.len() / 1000, + "fresh raster at density {density}: {mismatches} bytes differ, maximum {max_delta}" + ); + let hits = cache.hits; + let actual = pixels(frame, background, density, true, Some(cache)); + assert_eq!(cache.hits, hits + 1, "unchanged content must reuse pixels"); + assert_eq!( + actual.iter().zip(&expected).filter(|(a, b)| a != b).count(), + mismatches, + "cached raster at density {density}" + ); + } +} + +#[test] +fn cached_pixels_follow_edits_rollback_history_and_render_inputs() { + let mut frame = sample_frame(); + let mut cache = ThumbnailCache::default(); + let transparent = BoardBackground::Transparent; + let solid = BoardBackground::Solid(Color::new(0.8, 0.9, 1.0, 1.0)); + assert_parity(&frame, &transparent, &mut cache); + assert_parity(&frame, &solid, &mut cache); + frame.add_shape(Shape::EraserStroke { + points: vec![(50, 100), (180, 140)], + brush: crate::draw::EraserBrush { + size: 20.0, + kind: crate::draw::EraserKind::Circle, + }, + }); + assert_parity(&frame, &transparent, &mut cache); + assert_parity(&frame, &solid, &mut cache); + let first = frame.shapes[0].id; + let before = frame.shape(first).unwrap().shape.clone(); + frame.shape_mut(first).unwrap().shape.translate(70, 30); + assert_parity(&frame, &transparent, &mut cache); + frame.shape_mut(first).unwrap().shape = before; + assert_parity(&frame, &solid, &mut cache); + let last = frame.add_shape(rect(140, Color::new(0.1, 0.9, 0.3, 1.0))); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(frame.len() - 1, frame.shape(last).unwrap().clone())], + }, + 10, + ); + assert_parity(&frame, &solid, &mut cache); + frame.undo_last().unwrap(); + assert_parity(&frame, &solid, &mut cache); + frame.redo_last().unwrap(); + assert_parity(&frame, &solid, &mut cache); + frame.move_shape(2, 0).unwrap(); + assert_parity(&frame, &solid, &mut cache); + frame.remove_shape_by_id(first).unwrap(); + assert_parity(&frame, &transparent, &mut cache); + frame.set_view_offset(20, 10); + assert_parity(&frame, &solid, &mut cache); + assert_eq!( + pixels(&frame, &solid, 1.0, false, None), + pixels(&frame, &solid, 1.0, false, Some(&mut cache)) + ); + frame.clear(); + assert_parity(&frame, &transparent, &mut cache); +} + +#[test] +fn cache_prunes_removed_pages_and_stays_within_entry_and_byte_budgets() { + let mut cache = ThumbnailCache::default(); + for index in 0..MAX_ENTRIES + 5 { + let mut frame = sample_frame(); + frame.shapes[0].shape.translate(index as i32, 0); + pixels( + &frame, + &BoardBackground::Transparent, + 1.0, + true, + Some(&mut cache), + ); + } + assert_eq!(cache.entries.len(), MAX_ENTRIES); + assert!(cache.bytes <= MAX_BYTES); + cache.retain_revisions(std::iter::empty()); + assert!(cache.entries.is_empty()); + assert_eq!(cache.bytes, 0); +} + +#[test] +fn spotlight_cache_has_the_same_pixels_as_direct_replay() { + let mut frame = sample_frame(); + frame.add_shape(Shape::Spotlight { + cx: 140, + cy: 120, + rx: 85, + ry: 65, + magnification: 2.0, + }); + let mut cache = ThumbnailCache::default(); + assert_parity(&frame, &BoardBackground::Transparent, &mut cache); + assert_parity( + &frame, + &BoardBackground::Solid(Color::new(0.8, 0.9, 1.0, 1.0)), + &mut cache, + ); +} + +#[test] +#[ignore = "release timing workload; run with --release --ignored --nocapture"] +fn measure_cached_thumbnail_hover() { + let mut frame = Frame::new(); + for index in 0..10_000 { + frame.add_shape(Shape::Line { + x1: index % 400, + y1: index / 40, + x2: index % 400 + 4, + y2: index / 40 + 3, + color: Color::new(1.0, 1.0, 1.0, 1.0), + thick: 1.0, + }); + } + let background = BoardBackground::Transparent; + let mut cache = ThumbnailCache::default(); + pixels(&frame, &background, 1.0, true, Some(&mut cache)); + for cached in [false, true] { + let start = std::time::Instant::now(); + for _ in 0..100 { + std::hint::black_box(pixels( + &frame, + &background, + 1.0, + true, + cached.then_some(&mut cache), + )); + } + eprintln!( + "10k shapes, 100 hover paints, cached={cached}: {:?}", + start.elapsed() + ); + } +} diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 207425250..795f33d46 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -16,7 +16,8 @@ const ADD_CARD_BG_HOVER: Rgba = (1.0, 1.0, 1.0, 0.10); const PREVIEW_LABEL_BG: Rgba = (0.0, 0.0, 0.0, 0.55); use super::super::super::helpers::draw_drag_handle; -use super::content::{render_page_content, render_page_name_label}; +use super::cache::{ThumbnailCache, render_cached_page_content}; +use super::content::render_page_name_label; use super::icons::{ draw_delete_icon, draw_duplicate_icon, draw_plus_icon, draw_rename_icon, icon_alpha, }; @@ -25,6 +26,7 @@ use super::types::{PREVIEW_SCALE, PageContentArgs, PagePreviewArgs, PageThumbnai pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( engine: &UiTextEngine, measurer: &crate::draw::TextMeasurer, + cache: &mut ThumbnailCache, args: PageThumbnailArgs<'_, '_, '_>, ) { let PageThumbnailArgs { @@ -62,9 +64,10 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( ctx.set_line_width(1.0); let _ = ctx.stroke(); - render_page_content( + render_cached_page_content( engine, measurer, + cache, PageContentArgs { render, frame, @@ -307,6 +310,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( pub(in crate::ui::board_picker::page_panel) fn render_page_preview( engine: &UiTextEngine, measurer: &crate::draw::TextMeasurer, + cache: &mut ThumbnailCache, args: PagePreviewArgs<'_, '_, '_>, ) { let PagePreviewArgs { @@ -356,9 +360,10 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( ctx.set_line_width(1.2); let _ = ctx.stroke(); - render_page_content( + render_cached_page_content( engine, measurer, + cache, PageContentArgs { render, frame, diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 6b174aa43..3797fdd3d 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -17,6 +17,7 @@ fn pixels( render_board_picker_with_halo( engine, measurer, + &mut ThumbnailCache::default(), &mut crate::draw::RenderCtx::new(&ctx, caches), state, size.0 as u32, diff --git a/src/ui/render_context.rs b/src/ui/render_context.rs index 234652a01..361f1b84e 100644 --- a/src/ui/render_context.rs +++ b/src/ui/render_context.rs @@ -7,6 +7,7 @@ use super::theme::Theme; pub(crate) struct UiRenderCaches { help: HelpLayoutCache, radial: RadialBaseCache, + pub(crate) thumbnails: super::board_picker::ThumbnailCache, } impl UiRenderCaches { diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index c462f0449..5988981be 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -43,8 +43,8 @@ pub(crate) use settings::{ }; #[allow(unused_imports)] pub(crate) use style_pill::{ - StylePillControl, StylePillCounter, StylePillRole, StylePillSegment, StylePillSpec, - StylePillState, + StylePillControl, StylePillCounter, StylePillRole, StylePillSegment, StylePillSlider, + StylePillSpec, StylePillState, }; #[allow(unused_imports)] pub(crate) use tools::{ diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 2871d9b30..5c841bff1 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -32,6 +32,8 @@ use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnap use super::{ToolbarSliderSpec, TopStripPlan, toolbar_item_visible}; mod control; +mod slider; +pub(crate) use slider::StylePillSlider; /// Morph state of the style pill, derived from the active tool's options /// kind. `Hidden` covers Select without a selection plus the @@ -86,19 +88,15 @@ pub(crate) enum StylePillControl { QuickSwatch(usize), /// Thickness/size slider. The snapshot's `thickness` already targets /// the eraser or marker size when those are active. - ThicknessSlider, + Slider(StylePillSlider), /// Live thickness numeral; clicking opens the precise-entry popup. ThicknessValue, - /// Marker opacity slider. - OpacitySlider, /// Pen/marker smoothing, as a −/value/+ stepper. /// /// A stepper rather than a slider: the range is seven whole passes, which /// on a 110px track is 18px of travel per step and fiddly to land on. It /// also keeps the pill from reading as a row of near-identical bars. PenSmoothingStepper, - /// Spotlight magnification slider. - SpotlightMagnificationSlider, /// Shape fill toggle. FillToggle, /// Arrow style cycle button, showing the style the next arrow will use. @@ -110,8 +108,6 @@ pub(crate) enum StylePillControl { AutoNumberToggle, /// Reset the arrow/step counter; tooltip carries the next number. CounterReset(StylePillCounter), - /// Text size slider. - FontSizeSlider, /// Live text-size numeral; clicking opens the precise-entry popup. FontSizeValue, /// Bold on/off for selected text, or for the next label when no text is @@ -268,17 +264,19 @@ impl StylePillSpec { } } if context.needs_thickness { - controls.push(StylePillControl::ThicknessSlider); + controls.push(StylePillControl::Slider(StylePillSlider::Thickness)); controls.push(StylePillControl::ThicknessValue); } if context.show_marker_opacity { - controls.push(StylePillControl::OpacitySlider); + controls.push(StylePillControl::Slider(StylePillSlider::Opacity)); } if context.show_pen_smoothing && !plan.drop_style_extras { controls.push(StylePillControl::PenSmoothingStepper); } if context.tool_options_kind == ToolOptionsKind::Spotlight { - controls.push(StylePillControl::SpotlightMagnificationSlider); + controls.push(StylePillControl::Slider( + StylePillSlider::SpotlightMagnification, + )); } if context.show_fill_toggle { controls.push(StylePillControl::FillToggle); @@ -296,7 +294,7 @@ impl StylePillSpec { controls.push(StylePillControl::CounterReset(StylePillCounter::Step)); } if context.show_font_controls { - controls.push(StylePillControl::FontSizeSlider); + controls.push(StylePillControl::Slider(StylePillSlider::FontSize)); controls.push(StylePillControl::FontSizeValue); if !plan.drop_style_extras { controls.push(StylePillControl::FontWeightToggle); diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 7b46f0a22..75757a942 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -5,11 +5,11 @@ impl StylePillControl { match self { Self::ColorChip => Cow::Borrowed("top.style.color-chip"), Self::QuickSwatch(index) => Cow::Owned(format!("top.style.swatch.{index}")), - Self::ThicknessSlider => Cow::Borrowed("top.style.thickness"), + Self::Slider(StylePillSlider::Thickness) => Cow::Borrowed("top.style.thickness"), Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), - Self::OpacitySlider => Cow::Borrowed("top.style.opacity"), + Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("top.style.opacity"), Self::PenSmoothingStepper => Cow::Borrowed("top.style.pen-smoothing"), - Self::SpotlightMagnificationSlider => { + Self::Slider(StylePillSlider::SpotlightMagnification) => { Cow::Borrowed("top.style.spotlight-magnification") } Self::FillToggle => Cow::Borrowed("top.style.fill"), @@ -24,7 +24,7 @@ impl StylePillControl { Self::CounterReset(StylePillCounter::Step) => { Cow::Borrowed("top.style.counter-reset.step") } - Self::FontSizeSlider => Cow::Borrowed("top.style.font-size"), + Self::Slider(StylePillSlider::FontSize) => Cow::Borrowed("top.style.font-size"), Self::FontSizeValue => Cow::Borrowed("top.style.font-size-value"), Self::FontWeightToggle => Cow::Borrowed("top.style.font-bold"), Self::FontFamilyPicker => Cow::Borrowed("top.style.font-family-picker"), @@ -38,10 +38,10 @@ impl StylePillControl { pub(crate) fn role(self) -> StylePillRole { match self { Self::ColorChip | Self::QuickSwatch(_) => StylePillRole::Swatch, - Self::ThicknessSlider - | Self::OpacitySlider - | Self::SpotlightMagnificationSlider - | Self::FontSizeSlider => StylePillRole::Slider, + Self::Slider(StylePillSlider::Thickness) + | Self::Slider(StylePillSlider::Opacity) + | Self::Slider(StylePillSlider::SpotlightMagnification) + | Self::Slider(StylePillSlider::FontSize) => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, Self::FillToggle | Self::AutoNumberToggle | Self::FontWeightToggle => { StylePillRole::Toggle @@ -68,10 +68,7 @@ impl StylePillControl { index, } } - Self::ThicknessSlider - | Self::OpacitySlider - | Self::SpotlightMagnificationSlider - | Self::FontSizeSlider => self.slider_event(self.slider_value(snapshot).1), + Self::Slider(slider) => slider.event(slider.value(snapshot).1), Self::FontFamilyPicker => ToolbarEvent::OpenFontPicker, Self::FillToggle => ToolbarEvent::ToggleFill(!snapshot.fill_enabled), Self::FontWeightToggle => { @@ -137,48 +134,12 @@ impl StylePillControl { } } - /// Slider range plus current value for the slider controls. + /// Checked conversion for callers inspecting a heterogeneous control list. pub(crate) fn slider(self, snapshot: &ToolbarSnapshot) -> Option<(ToolbarSliderSpec, f64)> { - match self { - Self::ThicknessSlider => Some((ToolbarSliderSpec::THICKNESS, snapshot.thickness)), - Self::OpacitySlider => { - Some((ToolbarSliderSpec::MARKER_OPACITY, snapshot.marker_opacity)) - } - Self::SpotlightMagnificationSlider => Some(( - ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, - snapshot.spotlight_magnification, - )), - Self::FontSizeSlider => Some((ToolbarSliderSpec::FONT_SIZE, snapshot.font_size)), - _ => None, - } - } - - /// Slider range plus current value. Callers that already matched a slider - /// variant use this instead of skipping the control. - pub(crate) fn slider_value(self, snapshot: &ToolbarSnapshot) -> (ToolbarSliderSpec, f64) { - self.slider(snapshot) - .expect("this style-pill control is a slider") - } - - /// Value policy shared by the live GTK callback and snapshot event adapters. - pub(crate) fn slider_event(self, value: f64) -> ToolbarEvent { - match self { - Self::ThicknessSlider => ToolbarEvent::SetThickness(value), - Self::OpacitySlider => ToolbarEvent::SetMarkerOpacity(value), - Self::SpotlightMagnificationSlider => ToolbarEvent::SetSpotlightMagnification(value), - Self::FontSizeSlider => ToolbarEvent::SetFontSize(value), - _ => panic!("this style-pill control is a slider"), - } - } - - pub(crate) fn slider_formatter(self) -> fn(f64) -> String { - match self { - Self::ThicknessSlider | Self::ThicknessValue => |value| format!("{value:.0}px"), - Self::OpacitySlider => |value| format!("{:.0}%", value * 100.0), - Self::SpotlightMagnificationSlider => crate::draw::format_spotlight_magnification, - Self::FontSizeSlider | Self::FontSizeValue => |value| format!("{value:.0}pt"), - _ => panic!("this style-pill control has a numeric readout"), - } + let Self::Slider(slider) = self else { + return None; + }; + Some(slider.value(snapshot)) } /// Live readout for sliders and their numeral buttons. The unit follows @@ -187,12 +148,16 @@ impl StylePillControl { /// text size, % for marker opacity. pub(crate) fn value_text(self, snapshot: &ToolbarSnapshot) -> Option { match self { - Self::ThicknessSlider | Self::ThicknessValue => { - Some(self.slider_formatter()(snapshot.thickness)) + Self::Slider(StylePillSlider::Thickness) | Self::ThicknessValue => { + Some(StylePillSlider::Thickness.formatter()(snapshot.thickness)) } - Self::OpacitySlider => Some(self.slider_formatter()(snapshot.marker_opacity)), - Self::SpotlightMagnificationSlider => { - Some(self.slider_formatter()(snapshot.spotlight_magnification)) + Self::Slider(StylePillSlider::Opacity) => Some(StylePillSlider::Opacity.formatter()( + snapshot.marker_opacity, + )), + Self::Slider(StylePillSlider::SpotlightMagnification) => { + Some(StylePillSlider::SpotlightMagnification.formatter()( + snapshot.spotlight_magnification, + )) } // "Off" rather than "0": the number is a count of passes, and zero // of them is a state worth naming rather than a quantity. @@ -201,8 +166,8 @@ impl StylePillControl { } else { snapshot.pen_smoothing.to_string() }), - Self::FontSizeSlider | Self::FontSizeValue => { - Some(self.slider_formatter()(snapshot.font_size)) + Self::Slider(StylePillSlider::FontSize) | Self::FontSizeValue => { + Some(StylePillSlider::FontSize.formatter()(snapshot.font_size)) } Self::ArrowStyleCycle => Some(snapshot.arrow_style.label().to_string()), Self::FontFamilyPicker => Some(short_family_label(&snapshot.font.family)), @@ -230,7 +195,8 @@ impl StylePillControl { pub(crate) fn carries_inline_readout(self) -> bool { matches!( self, - Self::OpacitySlider | Self::SpotlightMagnificationSlider + Self::Slider(StylePillSlider::Opacity) + | Self::Slider(StylePillSlider::SpotlightMagnification) ) } @@ -240,7 +206,7 @@ impl StylePillControl { pub(crate) fn has_status_slot(self) -> bool { matches!( self, - Self::SpotlightMagnificationSlider + Self::Slider(StylePillSlider::SpotlightMagnification) | Self::SelectionCycle(SelectionPropertyKind::SpotlightMagnification) | Self::SelectionStepper(SelectionPropertyKind::SpotlightMagnification) ) @@ -256,7 +222,9 @@ impl StylePillControl { /// leave a magnified selection with no explanation. pub(crate) fn status_text(self, snapshot: &ToolbarSnapshot) -> Option<&'static str> { let displayed_magnification = match self { - Self::SpotlightMagnificationSlider => Some(snapshot.spotlight_magnification), + Self::Slider(StylePillSlider::SpotlightMagnification) => { + Some(snapshot.spotlight_magnification) + } Self::SelectionCycle(SelectionPropertyKind::SpotlightMagnification) | Self::SelectionStepper(SelectionPropertyKind::SpotlightMagnification) => { snapshot.selection_spotlight_magnification @@ -277,13 +245,15 @@ impl StylePillControl { .label .clone(), ), - Self::ThicknessSlider => { + Self::Slider(StylePillSlider::Thickness) => { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } - Self::OpacitySlider => Cow::Borrowed("Marker opacity"), + Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("Marker opacity"), Self::PenSmoothingStepper => Cow::Borrowed("Smoothing"), - Self::SpotlightMagnificationSlider => Cow::Borrowed("Spotlight magnification"), - Self::FontSizeSlider => Cow::Borrowed("Text size"), + Self::Slider(StylePillSlider::SpotlightMagnification) => { + Cow::Borrowed("Spotlight magnification") + } + Self::Slider(StylePillSlider::FontSize) => Cow::Borrowed("Text size"), Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), Self::FontSizeValue => Cow::Owned(format!("{:.0}pt", snapshot.font_size)), Self::FillToggle => Cow::Borrowed(action_short_label(Action::ToggleFill)), @@ -318,7 +288,7 @@ impl StylePillControl { .to_string(), ), Self::FontSizeValue => Some("Text size".to_string()), - Self::SpotlightMagnificationSlider => { + Self::Slider(StylePillSlider::SpotlightMagnification) => { Some("Magnification; Freeze first when using a transparent board.".to_string()) } Self::FillToggle => Some(format_binding_label( @@ -358,16 +328,18 @@ impl StylePillControl { // The context decides the thickness wording because one slider // targets the pen, the marker, or the eraser depending on what is // active. - Self::ThicknessSlider => { + Self::Slider(StylePillSlider::Thickness) => { Some(match ToolContext::from_snapshot(snapshot).thickness_label { "Eraser size" => "How wide the eraser rubs out.".to_string(), label => format!("{label} of the next stroke you draw."), }) } - Self::OpacitySlider => { + Self::Slider(StylePillSlider::Opacity) => { Some("How much of the page shows through a highlighter stroke.".to_string()) } - Self::FontSizeSlider => Some("Point size of the next label you type.".to_string()), + Self::Slider(StylePillSlider::FontSize) => { + Some("Point size of the next label you type.".to_string()) + } Self::EraserModeSegment | Self::SelectionStepper(_) => None, } } diff --git a/src/ui/toolbar/model/style_pill/slider.rs b/src/ui/toolbar/model/style_pill/slider.rs new file mode 100644 index 000000000..01f4be916 --- /dev/null +++ b/src/ui/toolbar/model/style_pill/slider.rs @@ -0,0 +1,42 @@ +use super::{ToolbarEvent, ToolbarSliderSpec, ToolbarSnapshot}; + +/// Slider-only policy shared by the GTK and Cairo adapters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StylePillSlider { + Thickness, + Opacity, + SpotlightMagnification, + FontSize, +} + +impl StylePillSlider { + pub(crate) fn value(self, snapshot: &ToolbarSnapshot) -> (ToolbarSliderSpec, f64) { + match self { + Self::Thickness => (ToolbarSliderSpec::THICKNESS, snapshot.thickness), + Self::Opacity => (ToolbarSliderSpec::MARKER_OPACITY, snapshot.marker_opacity), + Self::SpotlightMagnification => ( + ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, + snapshot.spotlight_magnification, + ), + Self::FontSize => (ToolbarSliderSpec::FONT_SIZE, snapshot.font_size), + } + } + + pub(crate) fn event(self, value: f64) -> ToolbarEvent { + match self { + Self::Thickness => ToolbarEvent::SetThickness(value), + Self::Opacity => ToolbarEvent::SetMarkerOpacity(value), + Self::SpotlightMagnification => ToolbarEvent::SetSpotlightMagnification(value), + Self::FontSize => ToolbarEvent::SetFontSize(value), + } + } + + pub(crate) fn formatter(self) -> fn(f64) -> String { + match self { + Self::Thickness => |value| format!("{value:.0}px"), + Self::Opacity => |value| format!("{:.0}%", value * 100.0), + Self::SpotlightMagnification => crate::draw::format_spotlight_magnification, + Self::FontSize => |value| format!("{value:.0}pt"), + } + } +} diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs index 750fe74bb..de45c4163 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -42,7 +42,7 @@ fn spotlight_state_is_a_magnification_slider_without_stroke_controls() { assert_eq!(spec.state(), StylePillState::Spotlight); assert_eq!(control_ids(&spec), ["top.style.spotlight-magnification"]); - let slider = StylePillControl::SpotlightMagnificationSlider; + let slider = StylePillControl::Slider(StylePillSlider::SpotlightMagnification); assert_eq!( slider.event(&snapshot), Some(ToolbarEvent::SetSpotlightMagnification(2.25)) @@ -91,10 +91,10 @@ fn every_slider_says_what_it_does_and_carries_a_name() { snapshot.show_text_controls = true; for control in [ - StylePillControl::ThicknessSlider, - StylePillControl::OpacitySlider, - StylePillControl::FontSizeSlider, - StylePillControl::SpotlightMagnificationSlider, + StylePillControl::Slider(StylePillSlider::Thickness), + StylePillControl::Slider(StylePillSlider::Opacity), + StylePillControl::Slider(StylePillSlider::FontSize), + StylePillControl::Slider(StylePillSlider::SpotlightMagnification), ] { assert_eq!(control.role(), StylePillRole::Slider); assert!( @@ -116,12 +116,12 @@ fn the_thickness_tooltip_follows_what_the_slider_is_actually_sizing() { // is active, so a fixed wording would be wrong two thirds of the time. let mut snapshot = snapshot_for_tool(Tool::Eraser); snapshot.thickness_targets_eraser = true; - let eraser = StylePillControl::ThicknessSlider + let eraser = StylePillControl::Slider(StylePillSlider::Thickness) .tooltip(&snapshot) .expect("a tooltip"); assert!(eraser.to_lowercase().contains("eraser"), "got {eraser:?}"); - let pen = StylePillControl::ThicknessSlider + let pen = StylePillControl::Slider(StylePillSlider::Thickness) .tooltip(&snapshot_for_tool(Tool::Pen)) .expect("a tooltip"); assert!(!pen.to_lowercase().contains("eraser"), "got {pen:?}"); @@ -275,7 +275,7 @@ fn spotlight_state_exposes_an_inline_missing_source_hint() { snapshot.spotlight_magnifier_source = Some(crate::draw::SpotlightMagnifierSource::IncompleteTransparent); - let slider = StylePillControl::SpotlightMagnificationSlider; + let slider = StylePillControl::Slider(StylePillSlider::SpotlightMagnification); assert_eq!( slider.status_text(&snapshot), Some("Freeze screen to preview") @@ -377,7 +377,7 @@ fn stroke_state_orders_chip_swatches_slider_and_numeral() { }) ); - let slider = StylePillControl::ThicknessSlider; + let slider = StylePillControl::Slider(StylePillSlider::Thickness); assert_eq!( slider.event(&snapshot), Some(ToolbarEvent::SetThickness(snapshot.thickness)) @@ -575,7 +575,7 @@ fn marker_state_adds_the_opacity_slider() { "thickness before opacity: {ids:?}" ); - let opacity = StylePillControl::OpacitySlider; + let opacity = StylePillControl::Slider(StylePillSlider::Opacity); assert_eq!( opacity.event(&snapshot), Some(ToolbarEvent::SetMarkerOpacity(snapshot.marker_opacity)) @@ -658,7 +658,7 @@ fn text_state_is_swatches_size_and_one_font_control() { "typing text draws no stroke for smoothing to reach" ); - let slider = StylePillControl::FontSizeSlider; + let slider = StylePillControl::Slider(StylePillSlider::FontSize); assert_eq!( slider.event(&snapshot), Some(ToolbarEvent::SetFontSize(snapshot.font_size)) @@ -756,7 +756,7 @@ fn the_docked_selection_control_reports_on_the_selected_shape_not_the_tool_defau // The slider tracks the tool default, which is not magnified, so it stays quiet. assert_eq!( - StylePillControl::SpotlightMagnificationSlider.status_text(&snapshot), + StylePillControl::Slider(StylePillSlider::SpotlightMagnification).status_text(&snapshot), None ); @@ -768,9 +768,9 @@ fn the_docked_selection_control_reports_on_the_selected_shape_not_the_tool_defau #[test] fn controls_without_a_magnification_readout_have_no_status_slot() { for control in [ - StylePillControl::ThicknessSlider, - StylePillControl::OpacitySlider, - StylePillControl::FontSizeSlider, + StylePillControl::Slider(StylePillSlider::Thickness), + StylePillControl::Slider(StylePillSlider::Opacity), + StylePillControl::Slider(StylePillSlider::FontSize), StylePillControl::ColorChip, ] { assert!(!control.has_status_slot(), "{control:?}"); diff --git a/tools/check-shared-dependencies.py b/tools/check-shared-dependencies.py index 198c1724a..b5073bef6 100755 --- a/tools/check-shared-dependencies.py +++ b/tools/check-shared-dependencies.py @@ -2,8 +2,8 @@ """Guard explicit upward Rust paths in the agreed shared-layer source boundary. This is a source-path guard, not a full Rust dependency graph. Domain compatibility -path tests deliberately mention old public paths. Board validation still uses its -reviewed ID/color validation helpers; moving board lifecycle is outside this rule. +path tests deliberately mention old public paths. Pure board normalization lives +in the domain layer; runtime board lifecycle stays in input. """ from pathlib import Path import re @@ -19,8 +19,6 @@ if path == ROOT / "src/domain/tests.rs": continue # #[cfg(test)] public-path compatibility assertions. source = re.sub(r"/\*.*?\*/|//[^\n]*", "", path.read_text(), flags=re.S) - if path == ROOT / "src/config/validate/boards.rs": - source = source.replace("use crate::input::boards::{BoundaryBoardIdSet, clamp_board_rgb};", "") # Also catch multiline grouped imports such as use crate::{input::...}. pattern = rf"crate\s*::\s*(?:{forbidden})\b|use\s+crate\s*::\s*\{{[^;]*\b(?:{forbidden})\s*::" if re.search(pattern, source): From e5f0d343ee04f087c208b51bde3a35392b20672f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:14:44 +0200 Subject: [PATCH 28/28] Fix thumbnail Spotlight sampling across page edges --- docs/codebase-overview.md | 6 +- .../page_panel/thumbnail/cache.rs | 17 +++ .../thumbnail/cache/spotlight_tests.rs | 126 ++++++++++++++++++ .../page_panel/thumbnail/cache/tests.rs | 7 +- 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 405f11b5f..78e2d0e40 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -626,8 +626,10 @@ render inputs. The UI render owner retains up to 24 page-content rasters and 16 MiB of raster/backdrop bytes. Keys include content, view, background, target geometry and scale, text halo, font-map serial, Cairo settings, and the exact card pixels beneath transparent content. Removed page content is -pruned when the picker prepares a frame. Partial clips, unsupported targets and oversized cards -use direct replay. Hover controls, labels and selection borders remain live. Pixel tests compare +pruned when the picker prepares a frame. Partial clips, unsupported targets, oversized cards, +and solid pages with magnified Spotlights use direct replay. Magnifiers need the original target's +sampling area beyond the card; transparent pages only show a magnification label and remain +cacheable. Hover controls, labels and selection borders remain live. Pixel tests compare fresh and reused rasters with direct replay; fractional-scale Spotlight gradients allow at most one byte of rounding on less than 0.1% of channels. diff --git a/src/ui/board_picker/page_panel/thumbnail/cache.rs b/src/ui/board_picker/page_panel/thumbnail/cache.rs index be01866bc..abf657ea7 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache.rs @@ -103,6 +103,20 @@ fn try_render_cached( cache: &mut ThumbnailCache, args: &mut PageContentArgs<'_, '_, '_>, ) -> Option<()> { + // A magnifier samples the original target beyond the card's bounds. The + // card-sized raster and backdrop cannot preserve that sampling extent. + // Transparent previews only show a magnification label, so remain cacheable. + if matches!(args.background, BoardBackground::Solid(_)) + && args.frame.shapes.iter().any(|drawn| { + matches!( + &drawn.shape, + crate::draw::Shape::Spotlight { magnification, .. } + if crate::draw::spotlight_magnification_is_active(*magnification) + ) + }) + { + return None; + } let ctx = args.render.cairo; let matrix = ctx.matrix(); let (dx, dy) = ctx.group_target().device_scale(); @@ -277,3 +291,6 @@ fn try_render_cached( #[cfg(test)] mod tests; + +#[cfg(test)] +mod spotlight_tests; diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs new file mode 100644 index 000000000..974d6bc5b --- /dev/null +++ b/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs @@ -0,0 +1,126 @@ +use super::*; +use crate::draw::{Color, Frame, Shape}; + +fn pixels( + frame: &Frame, + background: &BoardBackground, + scales: (f64, f64), + format: cairo::Format, + partial_clip: bool, + cache: Option<&mut ThumbnailCache>, +) -> Vec { + let (device, matrix) = scales; + let mut surface = cairo::ImageSurface::create( + format, + (220.0 * device * matrix).ceil() as i32, + (170.0 * device * matrix).ceil() as i32, + ) + .unwrap(); + surface.set_device_scale(device, device); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(matrix, matrix); + ctx.set_source_rgba(0.2, 0.3, 0.4, 0.75); + ctx.paint().unwrap(); + for i in 0..30 { + ctx.set_source_rgba(0.7, 0.5, 0.3, 0.6); + ctx.rectangle(f64::from(i * 9), 0.0, 3.0, 170.0); + ctx.fill().unwrap(); + } + if partial_clip { + ctx.rectangle(45.0, 35.0, 100.0, 75.0); + ctx.clip(); + } + let engine = UiTextEngine::default(); + let measurer = TextMeasurer::default(); + let mut draw = crate::draw::RenderCaches::default(); + let mut render = RenderCtx::new(&ctx, &mut draw); + let args = PageContentArgs { + render: &mut render, + frame, + background, + x: 35.5, + y: 27.25, + width: 128.0, + height: 96.0, + screen_width: 400, + screen_height: 300, + text_halo_enabled: true, + }; + if let Some(cache) = cache { + render_cached_page_content(&engine, &measurer, cache, args); + } else { + render_page_content(&engine, &measurer, args); + } + } + let mut data = surface.data().unwrap().to_vec(); + if format == cairo::Format::Rgb24 { + for pixel in data.as_chunks_mut::<4>().0 { + let value = u32::from_ne_bytes(*pixel) | 0xff00_0000; + pixel.copy_from_slice(&value.to_ne_bytes()); + } + } + data +} + +#[test] +fn edge_spotlights_preserve_sampling_on_first_repeated_and_clipped_paints() { + for (cx, cy) in [(440, 140), (-40, 140), (200, -40), (200, 340)] { + let mut frame = super::tests::sample_frame(); + frame.add_shape(Shape::Spotlight { + cx, + cy, + rx: 240, + ry: 140, + magnification: 2.5, + }); + for background in [ + BoardBackground::Solid(Color::new(0.8, 0.9, 1.0, 1.0)), + BoardBackground::Transparent, + ] { + let magnifying = matches!(background, BoardBackground::Solid(_)); + for scales in [(1.0, 1.0), (1.25, 1.0), (2.0, 1.0), (1.0, 1.25)] { + for format in [cairo::Format::ARgb32, cairo::Format::Rgb24] { + let mut cache = ThumbnailCache::default(); + for partial_clip in [false, true, false] { + let expected = + pixels(&frame, &background, scales, format, partial_clip, None); + for _ in 0..2 { + let actual = pixels( + &frame, + &background, + scales, + format, + partial_clip, + Some(&mut cache), + ); + let mismatches = + actual.iter().zip(&expected).filter(|(a, b)| a != b).count(); + let max_delta = actual + .iter() + .zip(&expected) + .map(|(a, b)| a.abs_diff(*b)) + .max() + .unwrap(); + let exact = magnifying || partial_clip; + assert!( + if exact { + mismatches == 0 + } else { + max_delta <= 1 && mismatches <= expected.len() / 1000 + }, + "Spotlight ({cx}, {cy}), {background:?}, {scales:?}, {format:?}, partial clip {partial_clip}: {mismatches} differing bytes, maximum delta {max_delta}" + ); + } + } + if magnifying { + assert!(cache.entries.is_empty()); + assert_eq!(cache.hits, 0); + } else { + assert_eq!(cache.hits, 3, "transparent previews still reuse the raster"); + } + } + } + } + } +} diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs index decddab72..e2b75e0f7 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs @@ -14,7 +14,7 @@ fn rect(x: i32, color: Color) -> Shape { } } -fn sample_frame() -> Frame { +pub(super) fn sample_frame() -> Frame { let mut frame = Frame::new(); frame.add_shape(rect(30, Color::new(1.0, 0.1, 0.2, 0.65))); frame.add_shape(Shape::Text { @@ -170,7 +170,7 @@ fn cache_prunes_removed_pages_and_stays_within_entry_and_byte_budgets() { } #[test] -fn spotlight_cache_has_the_same_pixels_as_direct_replay() { +fn transparent_spotlights_and_unmagnified_solid_spotlights_remain_cacheable() { let mut frame = sample_frame(); frame.add_shape(Shape::Spotlight { cx: 140, @@ -181,6 +181,9 @@ fn spotlight_cache_has_the_same_pixels_as_direct_replay() { }); let mut cache = ThumbnailCache::default(); assert_parity(&frame, &BoardBackground::Transparent, &mut cache); + if let Shape::Spotlight { magnification, .. } = &mut frame.shapes[2].shape { + *magnification = 1.0; + } assert_parity( &frame, &BoardBackground::Solid(Color::new(0.8, 0.9, 1.0, 1.0)),