From a5f2121bbb8991dea86038ec4c29efd7f6886781 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 1/7] feat: add adaptive keyboard shortcut recorder Replace free-form keybinding entry rows with chips, press-to-bind recording, per-row reset, raw text override, and explicit conflict replacement so users can edit shortcuts without learning the string syntax. --- configurator/src/app/component/shell.rs | 8 +- configurator/src/app/component/view.rs | 7 +- configurator/src/app/pages/keybindings.rs | 206 -------- configurator/src/app/pages/keybindings/mod.rs | 184 +++++++ .../src/app/pages/keybindings/recorder.rs | 98 ++++ configurator/src/app/pages/keybindings/row.rs | 286 +++++++++++ .../src/app/pages/keybindings/text_editor.rs | 116 +++++ .../src/app/pages/keybindings/widgets.rs | 105 ++++ configurator/src/app/state.rs | 21 +- configurator/src/app/update/AGENTS.md | 2 +- .../src/app/update/config/defaults.rs | 1 + configurator/src/app/update/config/load.rs | 1 + configurator/src/app/update/config/save.rs | 5 + configurator/src/app/update/mod.rs | 31 +- configurator/src/app/update/shortcuts.rs | 240 +++++++++ .../src/app/update/shortcuts/tests.rs | 280 +++++++++++ configurator/src/messages.rs | 23 +- configurator/src/models/keybindings/AGENTS.md | 2 +- .../src/models/keybindings/conflicts.rs | 314 ++++++++++++ configurator/src/models/keybindings/edit.rs | 261 ++++++++++ configurator/src/models/keybindings/mod.rs | 18 +- configurator/src/models/keybindings/parse.rs | 26 +- .../src/models/keybindings/recording.rs | 467 ++++++++++++++++++ configurator/src/models/mod.rs | 5 +- 24 files changed, 2471 insertions(+), 236 deletions(-) delete mode 100644 configurator/src/app/pages/keybindings.rs create mode 100644 configurator/src/app/pages/keybindings/mod.rs create mode 100644 configurator/src/app/pages/keybindings/recorder.rs create mode 100644 configurator/src/app/pages/keybindings/row.rs create mode 100644 configurator/src/app/pages/keybindings/text_editor.rs create mode 100644 configurator/src/app/pages/keybindings/widgets.rs create mode 100644 configurator/src/app/update/shortcuts.rs create mode 100644 configurator/src/app/update/shortcuts/tests.rs create mode 100644 configurator/src/models/keybindings/conflicts.rs create mode 100644 configurator/src/models/keybindings/edit.rs create mode 100644 configurator/src/models/keybindings/recording.rs diff --git a/configurator/src/app/component/shell.rs b/configurator/src/app/component/shell.rs index 6923abdd..0959521b 100644 --- a/configurator/src/app/component/shell.rs +++ b/configurator/src/app/component/shell.rs @@ -261,10 +261,10 @@ pub(super) fn build( return gtk::glib::Propagation::Stop; } if key == gtk::gdk::Key::Escape { - // The model owns which destructive question is current. - // Propagate as well so a widget with its own Escape - // behavior does not lose it when no confirmation exists. - sender.input(Message::ActiveConfirmationCanceled); + // The recorder owns Escape while it is open so a recorded + // Escape becomes a binding. Confirmation cancel is the + // fallback when nothing is recording. + sender.input(Message::WindowEscapePressed); } // Tab is the user moving focus deliberately; a still-pending // startup search focus must not steal it back later. diff --git a/configurator/src/app/component/view.rs b/configurator/src/app/component/view.rs index 4bf3e4fa..0cfbf88e 100644 --- a/configurator/src/app/component/view.rs +++ b/configurator/src/app/component/view.rs @@ -15,8 +15,11 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { // A color field the parser rejects is an edit that never reached the // draft, so Save is not offered while one is on screen: pressing it // would write the last value that parsed and lose the text being typed. - let save_enabled = - app.is_dirty && !app.is_saving && !app.is_loading && app.invalid_color_hex_count() == 0; + let save_enabled = app.is_dirty + && !app.is_saving + && !app.is_loading + && app.invalid_color_hex_count() == 0 + && app.pending_shortcut_conflict.is_none(); if widgets.save_button.is_sensitive() != save_enabled { widgets.save_button.set_sensitive(save_enabled); } diff --git a/configurator/src/app/pages/keybindings.rs b/configurator/src/app/pages/keybindings.rs deleted file mode 100644 index 126548ea..00000000 --- a/configurator/src/app/pages/keybindings.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Keybindings page: nine sections of shortcut lists. -//! -//! The section in view is model state, not widget state: the switcher sends -//! `KeybindingsTabSelected` and a binding drives the stack from -//! `active_keybindings_tab`, so a deep link or the search realignment that -//! moves the active section shows up here without anyone touching the stack. - -use relm4::prelude::*; -use relm4::{adw, gtk}; - -use adw::prelude::*; - -use crate::messages::Message; -use crate::models::keybindings::parse_keybinding_list; -use crate::models::{KeybindingField, KeybindingsTabId, TabId}; - -use super::super::search::AppSearchSummary; -use super::super::state::ConfiguratorApp; -use super::{Binding, BuiltPage, set_text_blocked}; - -/// The format hint the Iced page carried in its heading. -const SECTION_DESCRIPTION: &str = "Shortcut lists, separated by commas."; - -pub(super) fn build(sender: &ComponentSender) -> BuiltPage { - let stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .vexpand(true) - .build(); - let mut bindings: Vec = Vec::new(); - let mut sections: Vec<(KeybindingsTabId, adw::PreferencesPage)> = Vec::new(); - let fields = KeybindingField::all(); - - for tab in KeybindingsTabId::ALL { - let section = adw::PreferencesPage::new(); - let group = adw::PreferencesGroup::builder() - .description(SECTION_DESCRIPTION) - .build(); - section.add(&group); - for field in fields.iter().copied().filter(|field| field.tab() == tab) { - binding_row(&group, field, sender, &mut bindings); - } - stack.add_titled(§ion, Some(tab.title()), tab.title()); - sections.push((tab, section)); - } - - { - let sender = sender.clone(); - stack.connect_visible_child_name_notify(move |stack| { - let Some(name) = stack.visible_child_name() else { - return; - }; - let Some(tab) = tab_from_name(&name) else { - return; - }; - sender.input(Message::KeybindingsTabSelected(tab)); - }); - } - - { - let stack = stack.clone(); - bindings.push(Box::new(move |app, summary| { - // Reveal before switching and hide after: the stack drops a - // visible child that goes invisible, and picking the section the - // model asks for first keeps that fallback out of the way. - for (tab, section) in §ions { - if section_visible(summary, *tab) && !section.is_visible() { - section.set_visible(true); - } - } - let name = app.active_keybindings_tab.title(); - if stack.visible_child_name().as_deref() != Some(name) { - stack.set_visible_child_name(name); - } - for (tab, section) in §ions { - if !section_visible(summary, *tab) && section.is_visible() { - section.set_visible(false); - } - } - })); - } - - let switcher = gtk::StackSwitcher::builder() - .stack(&stack) - .halign(gtk::Align::Center) - .margin_top(12) - .margin_start(12) - .margin_end(12) - .build(); - // Nine section buttons are wider than a narrow window; let them scroll - // sideways rather than set a floor on the window's width. - let switcher_scroll = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Automatic) - .vscrollbar_policy(gtk::PolicyType::Never) - .propagate_natural_height(true) - .child(&switcher) - .build(); - - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); - content.append(&switcher_scroll); - content.append(&stack); - - BuiltPage { - widget: content.upcast(), - bindings, - } -} - -/// One shortcut field: the draft's comma-joined list, with the default -/// binding alongside it the way the Iced row showed it. -fn binding_row( - group: &adw::PreferencesGroup, - field: KeybindingField, - sender: &ComponentSender, - bindings: &mut Vec, -) { - let row = adw::EntryRow::builder().title(field.label()).build(); - let handler = { - let sender = sender.clone(); - row.connect_changed(move |row| { - sender.input(Message::KeybindingChanged(field, row.text().to_string())); - }) - }; - // Bounded so a two-shortcut default cannot crowd out the entry; the - // whole list stays readable in the tooltip. - let default_label = gtk::Label::builder() - .css_classes(["dim-label", "caption"]) - .valign(gtk::Align::Center) - .ellipsize(gtk::pango::EllipsizeMode::End) - .max_width_chars(28) - .build(); - row.add_suffix(&default_label); - group.add(&row); - - bindings.push(Box::new(move |app, summary| { - let value = app.draft.keybindings.value_for(field).unwrap_or_default(); - // Blocked: the draft owns this list, and a load reporting its own - // value back as a user edit clears that load's diagnostics. - set_text_blocked(&row, &handler, value); - - let parse_error = parse_keybinding_list(value).err(); - let has_error_class = row.has_css_class("error"); - match parse_error { - Some(message) => { - if !has_error_class { - row.add_css_class("error"); - } - if row.tooltip_text().as_deref() != Some(message.as_str()) { - row.set_tooltip_text(Some(&message)); - } - } - None => { - if has_error_class { - row.remove_css_class("error"); - } - if row.tooltip_text().is_some() { - row.set_tooltip_text(None); - } - } - } - - let default = app - .defaults - .keybindings - .value_for(field) - .unwrap_or_default(); - let default_text = if default.trim().is_empty() { - String::new() - } else { - format!("Default: {default}") - }; - if default_label.label() != default_text { - default_label.set_label(&default_text); - } - let tooltip = (!default_text.is_empty()).then_some(default_text.as_str()); - if default_label.tooltip_text().as_deref() != tooltip { - default_label.set_tooltip_text(tooltip); - } - - let visible = row_visible(summary, field); - if row.is_visible() != visible { - row.set_visible(visible); - } - })); -} - -fn tab_from_name(name: &str) -> Option { - KeybindingsTabId::ALL - .into_iter() - .find(|tab| tab.title() == name) -} - -/// Whether a section still has anything the search asked for, which is also -/// what decides if the switcher offers it. -fn section_visible(summary: &AppSearchSummary, tab: KeybindingsTabId) -> bool { - summary - .tab(TabId::Keybindings) - .is_none_or(|keybindings| keybindings.keybindings_tab_visible(tab)) -} - -/// A field row shows when it matched, or when its section's title did. -fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { - summary.tab(TabId::Keybindings).is_none_or(|keybindings| { - keybindings.keybinding_field_visible(field) - || keybindings.keybinding_tab_title_visible(field.tab()) - }) -} diff --git a/configurator/src/app/pages/keybindings/mod.rs b/configurator/src/app/pages/keybindings/mod.rs new file mode 100644 index 00000000..f42ff540 --- /dev/null +++ b/configurator/src/app/pages/keybindings/mod.rs @@ -0,0 +1,184 @@ +//! Keybindings page: shortcut chips, recorder, and per-row reset. + +mod recorder; +mod row; +mod text_editor; +mod widgets; + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{KeybindingField, KeybindingsTabId, TabId}; + +use super::super::search::AppSearchSummary; +use super::super::state::ConfiguratorApp; +use super::{Binding, BuiltPage}; +use row::binding_row; +use widgets::{set_accessible_label, set_label, set_visible}; + +const SECTION_DESCRIPTION: &str = + "Record a shortcut, reset one action, or edit the comma-separated list as text."; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .vexpand(true) + .build(); + let mut bindings: Vec = Vec::new(); + let mut sections: Vec<(KeybindingsTabId, adw::PreferencesPage)> = Vec::new(); + let fields = KeybindingField::all(); + + for tab in KeybindingsTabId::ALL { + let section = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .description(SECTION_DESCRIPTION) + .build(); + section.add(&group); + for field in fields.iter().copied().filter(|field| field.tab() == tab) { + binding_row(&group, field, sender, &mut bindings); + } + stack.add_titled(§ion, Some(tab.title()), tab.title()); + sections.push((tab, section)); + } + + { + let sender = sender.clone(); + stack.connect_visible_child_name_notify(move |stack| { + let Some(name) = stack.visible_child_name() else { + return; + }; + let Some(tab) = tab_from_name(&name) else { + return; + }; + sender.input(Message::KeybindingsTabSelected(tab)); + }); + } + + { + let stack = stack.clone(); + bindings.push(Box::new(move |app, summary| { + for (tab, section) in §ions { + if section_visible(summary, *tab) && !section.is_visible() { + section.set_visible(true); + } + } + let name = app.active_keybindings_tab.title(); + if stack.visible_child_name().as_deref() != Some(name) { + stack.set_visible_child_name(name); + } + for (tab, section) in §ions { + if !section_visible(summary, *tab) && section.is_visible() { + section.set_visible(false); + } + } + })); + } + + let switcher = gtk::StackSwitcher::builder() + .stack(&stack) + .halign(gtk::Align::Center) + .margin_top(12) + .margin_start(12) + .margin_end(12) + .build(); + let switcher_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Never) + .propagate_natural_height(true) + .child(&switcher) + .build(); + + let conflict = conflict_banner(sender, &mut bindings); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.append(&conflict); + content.append(&switcher_scroll); + content.append(&stack); + + BuiltPage { + widget: content.upcast(), + bindings, + } +} + +fn conflict_banner( + sender: &ComponentSender, + bindings: &mut Vec, +) -> gtk::Revealer { + let label = gtk::Label::builder() + .wrap(true) + .xalign(0.0) + .hexpand(true) + .build(); + let replace = gtk::Button::builder() + .label("Replace") + .css_classes(["suggested-action"]) + .build(); + set_accessible_label(&replace, "Replace conflicting shortcuts"); + { + let sender = sender.clone(); + replace.connect_clicked(move |_| { + sender.input(Message::ShortcutConflictReplaceConfirmed); + }); + } + let cancel = gtk::Button::builder().label("Cancel").build(); + set_accessible_label(&cancel, "Cancel shortcut conflict"); + { + let sender = sender.clone(); + cancel.connect_clicked(move |_| { + sender.input(Message::ShortcutConflictCanceled); + }); + } + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + buttons.append(&replace); + buttons.append(&cancel); + + let row = gtk::Box::new(gtk::Orientation::Horizontal, 12); + row.set_margin_top(8); + row.set_margin_bottom(8); + row.set_margin_start(12); + row.set_margin_end(12); + row.append(&label); + row.append(&buttons); + + let revealer = gtk::Revealer::builder() + .transition_type(gtk::RevealerTransitionType::SlideDown) + .child(&row) + .build(); + + let revealer_for_bind = revealer.clone(); + bindings.push(Box::new(move |app, _summary| { + match &app.pending_shortcut_conflict { + Some(conflict) => { + set_label(&label, &conflict.prompt()); + replace.set_label(conflict.replace_label()); + set_accessible_label(&replace, conflict.replace_label()); + if !revealer_for_bind.reveals_child() { + revealer_for_bind.set_reveal_child(true); + } + set_visible(&revealer_for_bind, true); + } + None => { + if revealer_for_bind.reveals_child() { + revealer_for_bind.set_reveal_child(false); + } + } + } + })); + revealer +} + +fn tab_from_name(name: &str) -> Option { + KeybindingsTabId::ALL + .into_iter() + .find(|tab| tab.title() == name) +} + +fn section_visible(summary: &AppSearchSummary, tab: KeybindingsTabId) -> bool { + summary + .tab(TabId::Keybindings) + .is_none_or(|keybindings| keybindings.keybindings_tab_visible(tab)) +} diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs new file mode 100644 index 00000000..f6ccd8b2 --- /dev/null +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -0,0 +1,98 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::glib::object::IsA; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::KeybindingField; +use crate::models::keybindings::{KeyboardModifiers, waiting_prompt}; + +use super::super::super::state::ConfiguratorApp; +use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; + +pub(super) struct RecorderPopover { + pub popover: gtk::Popover, + prompt: gtk::Label, +} + +impl RecorderPopover { + pub(super) fn build( + parent: &impl IsA, + field: KeybindingField, + sender: &ComponentSender, + ) -> Self { + let prompt = gtk::Label::builder() + .label(waiting_prompt()) + .wrap(true) + .xalign(0.0) + .max_width_chars(36) + .can_focus(true) + .build(); + set_accessible_label(&prompt, "Shortcut recorder"); + + let cancel = gtk::Button::builder().label("Cancel").build(); + set_accessible_label(&cancel, "Cancel recording"); + { + let sender = sender.clone(); + cancel.connect_clicked(move |_| { + sender.input(Message::ShortcutRecordingCanceled(field)); + }); + } + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_top(12); + content.set_margin_bottom(12); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&prompt); + content.append(&cancel); + + let popover = gtk::Popover::builder() + .autohide(true) + .child(&content) + .build(); + popover.set_parent(parent); + ignore_activating_click(&popover); + field_canceled(&popover, field, sender, Message::ShortcutRecordingCanceled); + attach_key_controller(&popover, sender); + + Self { popover, prompt } + } + + pub(super) fn refresh(&self, recording: bool, prompt: &str) { + set_label(&self.prompt, prompt); + if recording { + if !self.popover.is_visible() { + self.popover.popup(); + self.prompt.grab_focus(); + } + } else if self.popover.is_visible() { + self.popover.popdown(); + } + } +} + +fn attach_key_controller(popover: >k::Popover, sender: &ComponentSender) { + let sender = sender.clone(); + let controller = gtk::EventControllerKey::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.connect_key_pressed(move |_, key, _, modifiers| { + let keyval = gdk_keyval(key); + let recorded = KeyboardModifiers { + ctrl: modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK), + shift: modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK), + alt: modifiers.contains(gtk::gdk::ModifierType::ALT_MASK), + super_held: modifiers.contains(gtk::gdk::ModifierType::SUPER_MASK) + || modifiers.contains(gtk::gdk::ModifierType::META_MASK) + || modifiers.contains(gtk::gdk::ModifierType::HYPER_MASK), + }; + sender.input(Message::ShortcutRecorderKey(keyval, recorded)); + gtk::glib::Propagation::Stop + }); + popover.add_controller(controller); +} + +fn gdk_keyval(key: gtk::gdk::Key) -> u32 { + use gtk::glib::translate::IntoGlib; + key.into_glib() +} diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs new file mode 100644 index 00000000..c5e7ffbc --- /dev/null +++ b/configurator/src/app/pages/keybindings/row.rs @@ -0,0 +1,286 @@ +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use wayscriber::config::KeyBinding; + +use crate::messages::Message; +use crate::models::KeybindingField; +use crate::models::keybindings::{ + field_has_internal_duplicate, field_matches_defaults, parse_keybindings, reset_tooltip, + serialize_bindings, +}; + +use super::super::super::search::AppSearchSummary; +use super::super::super::state::ConfiguratorApp; +use super::super::Binding; +use super::recorder::RecorderPopover; +use super::text_editor::TextEditorPopover; +use super::widgets::{ + connect_clicked, icon_button, set_accessible_label, set_label, set_sensitive, set_tooltip, + set_visible, watch_compact, +}; +use crate::models::TabId; + +pub(super) fn binding_row( + group: &adw::PreferencesGroup, + field: KeybindingField, + sender: &ComponentSender, + bindings: &mut Vec, +) { + let row = adw::PreferencesRow::builder().title(field.label()).build(); + let title = gtk::Label::builder() + .label(field.label()) + .xalign(0.0) + .hexpand(true) + .wrap(true) + .css_classes(["title"]) + .build(); + + let chips = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .min_children_per_line(1) + .max_children_per_line(8) + .column_spacing(6) + .row_spacing(6) + .hexpand(true) + .build(); + let add = icon_button("input-keyboard-symbolic", "Add shortcut"); + connect_clicked(&add, sender, Message::ShortcutRecordingStarted(field)); + let reset = icon_button("view-refresh-symbolic", "Reset to default"); + connect_clicked(&reset, sender, Message::ShortcutResetRequested(field)); + let edit = icon_button("document-edit-symbolic", "Edit as text"); + connect_clicked(&edit, sender, Message::ShortcutTextEditStarted(field)); + + let controls = gtk::Box::new(gtk::Orientation::Horizontal, 4); + controls.append(&add); + controls.append(&reset); + controls.append(&edit); + + let editor = gtk::Box::new(gtk::Orientation::Vertical, 8); + editor.append(&chips); + editor.append(&controls); + + let compact_summary = gtk::Label::builder() + .css_classes(["dim-label"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(24) + .xalign(1.0) + .build(); + let edit_shortcuts = gtk::Button::builder() + .label("Edit Shortcuts") + .valign(gtk::Align::Center) + .build(); + set_accessible_label(&edit_shortcuts, "Edit shortcuts"); + let compact = gtk::Box::new(gtk::Orientation::Horizontal, 8); + compact.set_halign(gtk::Align::End); + compact.append(&compact_summary); + compact.append(&edit_shortcuts); + compact.set_visible(false); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 12); + header.append(&title); + header.append(&compact); + + let caption = gtk::Label::builder() + .css_classes(["dim-label", "caption"]) + .wrap(true) + .xalign(0.0) + .build(); + + let inline_host = gtk::Box::new(gtk::Orientation::Vertical, 8); + inline_host.append(&editor); + + let body = gtk::Box::new(gtk::Orientation::Vertical, 8); + body.set_margin_top(10); + body.set_margin_bottom(10); + body.set_margin_start(12); + body.set_margin_end(12); + body.append(&header); + body.append(&inline_host); + body.append(&caption); + row.set_child(Some(&body)); + group.add(&row); + + let editor_popover = gtk::Popover::builder().autohide(true).build(); + editor_popover.set_parent(&edit_shortcuts); + { + let editor_popover = editor_popover.clone(); + let editor = editor.clone(); + edit_shortcuts.connect_clicked(move |_| { + if editor.parent().as_ref() != Some(editor_popover.upcast_ref()) { + editor.unparent(); + editor_popover.set_child(Some(&editor)); + } + editor_popover.popup(); + }); + } + { + let editor = editor.clone(); + let inline_host = inline_host.clone(); + editor_popover.connect_closed(move |_| { + if editor.parent().as_ref() != Some(inline_host.upcast_ref()) { + editor.unparent(); + inline_host.append(&editor); + } + }); + } + + let recorder = RecorderPopover::build(&row, field, sender); + let text_editor = TextEditorPopover::build(&row, field, sender); + let seen_chips = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let compact_mode = std::rc::Rc::new(std::cell::Cell::new(false)); + { + let compact = compact.clone(); + let inline_host = inline_host.clone(); + let compact_mode = compact_mode.clone(); + let editor_popover = editor_popover.clone(); + let editor = editor.clone(); + watch_compact(&row, move |is_compact| { + compact_mode.set(is_compact); + set_visible(&compact, is_compact); + set_visible(&inline_host, !is_compact); + if !is_compact && editor_popover.is_visible() { + editor_popover.popdown(); + if editor.parent().as_ref() != Some(inline_host.upcast_ref()) { + editor.unparent(); + inline_host.append(&editor); + } + } + }); + } + + let sender = sender.clone(); + bindings.push(Box::new(move |app, search_summary| { + let visible = row_visible(search_summary, field); + set_visible(&row, visible); + + let value = app.draft.keybindings.value_for(field).unwrap_or_default(); + let parsed = parse_keybindings(value); + let parse_error = parsed.as_ref().err().cloned(); + match &parsed { + Ok(bindings) => { + let labels: Vec = bindings.iter().map(ToString::to_string).collect(); + if seen_chips.borrow().as_slice() != labels.as_slice() { + sync_chips(&chips, field, bindings, &sender); + *seen_chips.borrow_mut() = labels; + } + let summary_text = if bindings.is_empty() { + "Unbound".to_string() + } else { + serialize_bindings(bindings) + }; + set_label(&compact_summary, &summary_text); + set_tooltip(&compact_summary, Some(&summary_text)); + } + Err(message) => { + if !seen_chips.borrow().is_empty() { + clear_flow(&chips); + seen_chips.borrow_mut().clear(); + } + set_label(&compact_summary, value); + set_tooltip(&compact_summary, Some(message)); + } + } + + 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); + set_sensitive(&add, 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) => { + "This action lists the same shortcut twice.".to_string() + } + None => { + let default = app + .defaults + .keybindings + .value_for(field) + .unwrap_or_default(); + if default.trim().is_empty() { + "Default: Unbound".to_string() + } else { + format!("Default: {default}") + } + } + }; + set_label(&caption, &caption_text); + if parse_error.is_some() || field_has_internal_duplicate(&app.draft.keybindings, field) { + if !caption.has_css_class("error") { + caption.add_css_class("error"); + } + } else if caption.has_css_class("error") { + caption.remove_css_class("error"); + } + + let recording = app + .active_shortcut_recorder + .as_ref() + .filter(|recorder| recorder.field == field); + recorder.refresh( + recording.is_some(), + recording + .map(|recorder| recorder.prompt.as_str()) + .unwrap_or_default(), + ); + + let editing = app + .shortcut_text_editor + .as_ref() + .filter(|editor| editor.field == field); + let editor_text = editing.map(|editor| editor.text.as_str()).unwrap_or(value); + let editor_error = editing.and_then(|editor| editor.parse_error()); + text_editor.refresh(editing.is_some(), editor_text, editor_error.as_deref()); + })); +} + +fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { + summary.tab(TabId::Keybindings).is_none_or(|keybindings| { + keybindings.keybinding_field_visible(field) + || keybindings.keybinding_tab_title_visible(field.tab()) + }) +} + +fn sync_chips( + flow: >k::FlowBox, + field: KeybindingField, + bindings: &[KeyBinding], + sender: &ComponentSender, +) { + clear_flow(flow); + for binding in bindings { + flow.insert(&shortcut_chip(field, binding, sender), -1); + } +} + +fn clear_flow(flow: >k::FlowBox) { + while let Some(child) = flow.first_child() { + flow.remove(&child); + } +} + +fn shortcut_chip( + field: KeybindingField, + binding: &KeyBinding, + sender: &ComponentSender, +) -> gtk::Box { + let label_text = binding.to_string(); + let chip = gtk::Box::new(gtk::Orientation::Horizontal, 4); + chip.add_css_class("osd"); + chip.set_valign(gtk::Align::Center); + let label = gtk::Label::builder().label(&label_text).build(); + let remove = icon_button("window-close-symbolic", &format!("Remove {label_text}")); + let binding = binding.clone(); + let sender = sender.clone(); + remove.connect_clicked(move |_| { + sender.input(Message::ShortcutRemoved(field, binding.clone())); + }); + chip.append(&label); + chip.append(&remove); + chip +} diff --git a/configurator/src/app/pages/keybindings/text_editor.rs b/configurator/src/app/pages/keybindings/text_editor.rs new file mode 100644 index 00000000..15501b15 --- /dev/null +++ b/configurator/src/app/pages/keybindings/text_editor.rs @@ -0,0 +1,116 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::glib::object::IsA; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::KeybindingField; + +use super::super::super::state::ConfiguratorApp; +use super::super::set_text_blocked; +use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; + +pub(super) struct TextEditorPopover { + pub popover: gtk::Popover, + entry: gtk::Entry, + entry_handler: gtk::glib::SignalHandlerId, + error: gtk::Label, +} + +impl TextEditorPopover { + pub(super) fn build( + parent: &impl IsA, + field: KeybindingField, + sender: &ComponentSender, + ) -> Self { + let entry = gtk::Entry::builder() + .placeholder_text("Ctrl+Shift+X, F5") + .hexpand(true) + .width_chars(28) + .build(); + set_accessible_label(&entry, "Shortcut list"); + let entry_handler = { + let sender = sender.clone(); + entry.connect_changed(move |entry| { + sender.input(Message::ShortcutTextEditChanged(entry.text().to_string())); + }) + }; + + let error = gtk::Label::builder() + .wrap(true) + .xalign(0.0) + .css_classes(["error", "caption"]) + .build(); + + let apply = gtk::Button::builder() + .label("Apply") + .css_classes(["suggested-action"]) + .build(); + set_accessible_label(&apply, "Apply shortcut text"); + { + let sender = sender.clone(); + apply.connect_clicked(move |_| { + sender.input(Message::ShortcutTextEditApplied); + }); + } + let cancel = gtk::Button::builder().label("Cancel").build(); + set_accessible_label(&cancel, "Cancel text editing"); + { + let sender = sender.clone(); + cancel.connect_clicked(move |_| { + sender.input(Message::ShortcutTextEditCanceled(field)); + }); + } + + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + buttons.set_halign(gtk::Align::End); + buttons.append(&cancel); + buttons.append(&apply); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 8); + content.set_margin_top(12); + content.set_margin_bottom(12); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&entry); + content.append(&error); + content.append(&buttons); + + let popover = gtk::Popover::builder() + .autohide(true) + .child(&content) + .build(); + popover.set_parent(parent); + ignore_activating_click(&popover); + field_canceled(&popover, field, sender, Message::ShortcutTextEditCanceled); + + Self { + popover, + entry, + entry_handler, + error, + } + } + + pub(super) fn refresh(&self, open: bool, text: &str, parse_error: Option<&str>) { + set_text_blocked(&self.entry, &self.entry_handler, text); + match parse_error { + Some(message) => { + set_label(&self.error, message); + self.error.set_visible(true); + } + None => { + set_label(&self.error, ""); + self.error.set_visible(false); + } + } + if open { + if !self.popover.is_visible() { + self.popover.popup(); + self.entry.grab_focus(); + } + } else if self.popover.is_visible() { + self.popover.popdown(); + } + } +} diff --git a/configurator/src/app/pages/keybindings/widgets.rs b/configurator/src/app/pages/keybindings/widgets.rs new file mode 100644 index 00000000..743ba0e3 --- /dev/null +++ b/configurator/src/app/pages/keybindings/widgets.rs @@ -0,0 +1,105 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::glib::object::IsA; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::KeybindingField; + +use super::super::super::state::ConfiguratorApp; + +pub(super) fn icon_button(icon: &str, tooltip: &str) -> gtk::Button { + let button = gtk::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .has_frame(false) + .css_classes(["flat"]) + .build(); + set_accessible_label(&button, tooltip); + button +} + +pub(super) fn set_accessible_label(widget: &impl gtk::prelude::AccessibleExt, label: &str) { + widget.update_property(&[gtk::accessible::Property::Label(label)]); +} + +pub(super) fn set_visible(widget: &impl IsA, visible: bool) { + if widget.is_visible() != visible { + widget.set_visible(visible); + } +} + +pub(super) fn set_sensitive(widget: &impl IsA, sensitive: bool) { + if widget.is_sensitive() != sensitive { + widget.set_sensitive(sensitive); + } +} + +pub(super) fn set_label(label: >k::Label, text: &str) { + if label.label() != text { + label.set_label(text); + } +} + +pub(super) fn set_tooltip(widget: &impl IsA, tooltip: Option<&str>) { + if widget.tooltip_text().as_deref() != tooltip { + widget.set_tooltip_text(tooltip); + } +} + +pub(super) const COMPACT_WIDTH: i32 = 560; + +pub(super) fn watch_compact(row: &impl IsA, apply: impl Fn(bool) + 'static) { + let widget = row.as_ref().clone(); + widget.connect_notify_local(Some("width"), move |widget, _| { + let width = widget.width(); + if width <= 0 { + return; + } + apply(width < COMPACT_WIDTH); + }); +} + +pub(super) fn connect_clicked( + button: >k::Button, + sender: &ComponentSender, + message: Message, +) { + let sender = sender.clone(); + button.connect_clicked(move |_| { + sender.input(message.clone()); + }); +} + +pub(super) fn ignore_activating_click(popover: >k::Popover) { + let ignore = std::rc::Rc::new(std::cell::Cell::new(false)); + { + let ignore = ignore.clone(); + popover.connect_show(move |_| { + ignore.set(true); + }); + } + let click = gtk::GestureClick::new(); + click.set_button(0); + click.set_propagation_phase(gtk::PropagationPhase::Capture); + click.connect_pressed(move |gesture, _, _, _| { + if ignore.get() { + ignore.set(false); + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + popover.add_controller(click); +} + +pub(super) fn field_canceled( + popover: >k::Popover, + field: KeybindingField, + sender: &ComponentSender, + to_message: fn(KeybindingField) -> Message, +) { + let sender = sender.clone(); + popover.connect_closed(move |_| { + sender.input(to_message(field)); + }); +} diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 8e7445d3..ca95148d 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -7,8 +7,9 @@ use wayscriber::config::{ use crate::models::{ ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, DragMouseButton, - KeybindingsTabId, SearchQuery, SessionCatalogState, StartupRequest, TabId, - ToolbarLayoutModeOption, UiTabId, + KeybindingsTabId, PendingShortcutConflict, SearchQuery, SessionCatalogState, + ShortcutRecorderState, ShortcutTextEditor, StartupRequest, TabId, ToolbarLayoutModeOption, + UiTabId, }; use super::effects::Effect; @@ -74,6 +75,9 @@ pub(crate) struct ConfiguratorApp { /// What the launching process asked to open, taken by the first config /// load and empty from then on. pub(crate) startup_request: StartupRequest, + pub(crate) active_shortcut_recorder: Option, + pub(crate) shortcut_text_editor: Option, + pub(crate) pending_shortcut_conflict: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -231,6 +235,9 @@ impl ConfiguratorApp { search_focus_serial: 0, startup_search_focus_pending: true, startup_request: startup, + active_shortcut_recorder: None, + shortcut_text_editor: None, + pending_shortcut_conflict: None, }; app.sync_all_color_picker_hex(); @@ -296,6 +303,16 @@ impl ConfiguratorApp { } self.migration_preview.as_ref() } + + pub(crate) fn shortcut_recorder_active(&self) -> bool { + self.active_shortcut_recorder.is_some() + } + + pub(super) fn clear_shortcut_editing(&mut self) { + self.active_shortcut_recorder = None; + self.shortcut_text_editor = None; + self.pending_shortcut_conflict = None; + } } #[cfg(test)] diff --git a/configurator/src/app/update/AGENTS.md b/configurator/src/app/update/AGENTS.md index 263f96cf..33bf3cbd 100644 --- a/configurator/src/app/update/AGENTS.md +++ b/configurator/src/app/update/AGENTS.md @@ -5,7 +5,7 @@ ## Architecture - Update modules handle `Message` variants and return `Vec` for async work; the component runs each effect exactly once. -- Modules are split by config sections and workflows such as boards, daemon, fields, presets, render profiles, session catalog, and tabs. +- Modules are split by config sections and workflows such as boards, daemon, fields, presets, render profiles, session catalog, shortcuts, and tabs. ## Invariants - Keep update routing centralized and explicit. diff --git a/configurator/src/app/update/config/defaults.rs b/configurator/src/app/update/config/defaults.rs index 7f8cea0f..88293faf 100644 --- a/configurator/src/app/update/config/defaults.rs +++ b/configurator/src/app/update/config/defaults.rs @@ -40,6 +40,7 @@ impl ConfiguratorApp { self.color_picker_hex.clear(); self.sync_all_color_picker_hex(); self.clear_defaults_confirmation(); + self.clear_shortcut_editing(); self.status = StatusMessage::info("Loaded default configuration (not saved)."); self.refresh_dirty_flag(); Vec::new() diff --git a/configurator/src/app/update/config/load.rs b/configurator/src/app/update/config/load.rs index 02163728..5b4b0e27 100644 --- a/configurator/src/app/update/config/load.rs +++ b/configurator/src/app/update/config/load.rs @@ -23,6 +23,7 @@ impl ConfiguratorApp { self.sync_all_color_picker_hex(); self.is_dirty = false; self.clear_defaults_confirmation(); + self.clear_shortcut_editing(); self.refresh_migration_preview(&document); self.status = repair_warning.map_or_else( || config_document_status(&document, "Configuration loaded from disk."), diff --git a/configurator/src/app/update/config/save.rs b/configurator/src/app/update/config/save.rs index 57a99c5c..f9cc30ea 100644 --- a/configurator/src/app/update/config/save.rs +++ b/configurator/src/app/update/config/save.rs @@ -29,6 +29,11 @@ impl ConfiguratorApp { return Vec::new(); } + if self.pending_shortcut_conflict.is_some() { + self.status = StatusMessage::error("Resolve the shortcut conflict before saving."); + 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` diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 48c5896d..97b1baa4 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -6,6 +6,7 @@ mod fields; mod presets; mod render_profiles; mod session_catalog; +mod shortcuts; mod tabs; use crate::messages::{CommandMessage, Message}; @@ -46,7 +47,7 @@ impl ConfiguratorApp { Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), Message::ResetToDefaultsConfirmed => self.handle_reset_to_defaults_confirmed(), Message::ResetToDefaultsCanceled => self.handle_reset_to_defaults_canceled(), - Message::ActiveConfirmationCanceled => self.handle_active_confirmation_canceled(), + Message::WindowEscapePressed => self.handle_window_escape_pressed(), Message::SaveRequested => self.handle_save_requested(), Message::MigrationApplyRequested => self.handle_migration_apply_requested(), Message::MigrationDismissed => self.handle_migration_dismissed(), @@ -241,9 +242,33 @@ impl ConfiguratorApp { self.handle_export_pdf_label_content_changed(option) } Message::BufferCountChanged(count) => self.handle_buffer_count_changed(count), - Message::KeybindingChanged(field, value) => { - self.handle_keybinding_changed(field, value) + Message::ShortcutRecordingStarted(field) => { + self.handle_shortcut_recording_started(field) } + Message::ShortcutRecordingCanceled(field) => { + self.handle_shortcut_recording_canceled(field) + } + Message::ShortcutRecorderKey(keyval, modifiers) => { + self.handle_shortcut_recorder_key(keyval, modifiers) + } + Message::ShortcutRemoved(field, binding) => { + self.handle_shortcut_removed(field, binding) + } + Message::ShortcutResetRequested(field) => self.handle_shortcut_reset_requested(field), + Message::ShortcutTextEditStarted(field) => { + self.handle_shortcut_text_edit_started(field) + } + Message::ShortcutTextEditChanged(value) => { + self.handle_shortcut_text_edit_changed(value) + } + Message::ShortcutTextEditApplied => self.handle_shortcut_text_edit_applied(), + Message::ShortcutTextEditCanceled(field) => { + self.handle_shortcut_text_edit_canceled(field) + } + Message::ShortcutConflictReplaceConfirmed => { + self.handle_shortcut_conflict_replace_confirmed() + } + Message::ShortcutConflictCanceled => self.handle_shortcut_conflict_canceled(), Message::FontStyleOptionSelected(option) => { self.handle_font_style_option_selected(option) } diff --git a/configurator/src/app/update/shortcuts.rs b/configurator/src/app/update/shortcuts.rs new file mode 100644 index 00000000..b423165a --- /dev/null +++ b/configurator/src/app/update/shortcuts.rs @@ -0,0 +1,240 @@ +//! Shortcut recorder, chip, reset, text-editor, and conflict messages. + +#[cfg(test)] +mod tests; + +use wayscriber::config::KeyBinding; + +use crate::models::KeybindingField; +use crate::models::keybindings::{ + AppendOutcome, KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, ShortcutTextEditor, + append_binding, apply_recorded_replace, apply_text_replace, normalize_key_event, + other_claimants, parse_keybindings, remove_binding, reset_field, text_conflicts_for, +}; + +use super::super::effects::Effect; +use super::super::state::{ConfiguratorApp, StatusMessage}; + +impl ConfiguratorApp { + pub(super) fn handle_shortcut_recording_started( + &mut self, + field: KeybindingField, + ) -> Vec { + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + self.shortcut_text_editor = None; + self.active_shortcut_recorder = Some(ShortcutRecorderState::new(field)); + Vec::new() + } + + pub(super) fn handle_shortcut_recording_canceled( + &mut self, + field: KeybindingField, + ) -> Vec { + if self + .active_shortcut_recorder + .as_ref() + .is_some_and(|recorder| recorder.field == field) + { + self.active_shortcut_recorder = None; + } + Vec::new() + } + + pub(super) fn handle_shortcut_recorder_key( + &mut self, + keyval: u32, + modifiers: KeyboardModifiers, + ) -> Vec { + let Some(recorder) = self.active_shortcut_recorder.as_mut() else { + return Vec::new(); + }; + let field = recorder.field; + match normalize_key_event(keyval, modifiers) { + RecordedKeyboard::Pending { preview } => { + recorder.prompt = preview; + Vec::new() + } + RecordedKeyboard::Unsupported { message } => { + recorder.prompt = message; + Vec::new() + } + RecordedKeyboard::Chord(binding) => { + self.active_shortcut_recorder = None; + self.commit_recorded_binding(field, binding) + } + } + } + + pub(super) fn handle_shortcut_removed( + &mut self, + field: KeybindingField, + binding: KeyBinding, + ) -> Vec { + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + match remove_binding(&mut self.draft.keybindings, field, &binding) { + Ok(()) => { + self.status = StatusMessage::idle(); + self.refresh_dirty_flag(); + } + Err(error) => { + self.status = StatusMessage::error(error.message().to_string()); + } + } + Vec::new() + } + + pub(super) fn handle_shortcut_reset_requested( + &mut self, + field: KeybindingField, + ) -> Vec { + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + reset_field( + &mut self.draft.keybindings, + &self.defaults.keybindings, + field, + ); + self.status = StatusMessage::idle(); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(super) fn handle_shortcut_text_edit_started( + &mut self, + field: KeybindingField, + ) -> Vec { + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + self.active_shortcut_recorder = None; + let text = self + .draft + .keybindings + .value_for(field) + .unwrap_or_default() + .to_string(); + self.shortcut_text_editor = Some(ShortcutTextEditor::new(field, text)); + Vec::new() + } + + pub(super) fn handle_shortcut_text_edit_changed(&mut self, text: String) -> Vec { + if let Some(editor) = self.shortcut_text_editor.as_mut() { + editor.text = text; + } + Vec::new() + } + + pub(super) fn handle_shortcut_text_edit_applied(&mut self) -> Vec { + let Some(editor) = self.shortcut_text_editor.clone() else { + return Vec::new(); + }; + match parse_keybindings(&editor.text) { + Ok(parsed) => { + let conflicts = text_conflicts_for(&self.draft.keybindings, editor.field, &parsed); + if conflicts.is_empty() { + self.shortcut_text_editor = None; + return self + .handle_keybinding_changed(editor.field, editor.text.trim().to_string()); + } + self.shortcut_text_editor = None; + self.pending_shortcut_conflict = + Some(crate::models::PendingShortcutConflict::Text { + target: editor.field, + new_value: editor.text, + conflicts, + }); + } + Err(error) => { + self.status = StatusMessage::error(error); + } + } + Vec::new() + } + + pub(super) fn handle_shortcut_text_edit_canceled( + &mut self, + field: KeybindingField, + ) -> Vec { + if self + .shortcut_text_editor + .as_ref() + .is_some_and(|editor| editor.field == field) + { + self.shortcut_text_editor = None; + } + Vec::new() + } + + pub(super) fn handle_shortcut_conflict_replace_confirmed(&mut self) -> Vec { + let Some(pending) = self.pending_shortcut_conflict.take() else { + return Vec::new(); + }; + let result = match pending { + crate::models::PendingShortcutConflict::Recorded { + target, + binding, + claimants, + } => apply_recorded_replace(&mut self.draft.keybindings, target, &binding, &claimants), + crate::models::PendingShortcutConflict::Text { + target, + new_value, + conflicts, + } => apply_text_replace(&mut self.draft.keybindings, target, &new_value, &conflicts), + }; + match result { + Ok(()) => { + self.status = StatusMessage::idle(); + self.refresh_dirty_flag(); + } + Err(error) => { + self.status = StatusMessage::error(error.message().to_string()); + } + } + Vec::new() + } + + pub(super) fn handle_shortcut_conflict_canceled(&mut self) -> Vec { + self.pending_shortcut_conflict = None; + Vec::new() + } + + pub(super) fn handle_window_escape_pressed(&mut self) -> Vec { + if self.shortcut_recorder_active() { + return Vec::new(); + } + self.handle_active_confirmation_canceled() + } + + fn commit_recorded_binding( + &mut self, + field: KeybindingField, + binding: KeyBinding, + ) -> Vec { + let claimants = other_claimants(&self.draft.keybindings, field, &binding); + if !claimants.is_empty() { + self.pending_shortcut_conflict = + Some(crate::models::PendingShortcutConflict::Recorded { + target: field, + binding, + claimants, + }); + return Vec::new(); + } + match append_binding(&mut self.draft.keybindings, field, &binding) { + Ok(AppendOutcome::Added) => { + self.status = StatusMessage::idle(); + self.refresh_dirty_flag(); + } + Ok(AppendOutcome::AlreadyPresent) => {} + Err(error) => { + self.status = StatusMessage::error(error.message().to_string()); + } + } + Vec::new() + } +} diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs new file mode 100644 index 00000000..e2478e62 --- /dev/null +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -0,0 +1,280 @@ +use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, KeyBinding}; + +use crate::app::effects::Effect; +use crate::app::state::{ConfiguratorApp, PendingConfirmation, StatusMessage}; +use crate::models::keybindings::keyval; +use crate::models::{KeybindingField, KeyboardModifiers, SearchQuery}; +use crate::test_temp::TempDir; + +fn status_contains(status: &StatusMessage, needle: &str) -> bool { + status.text().is_some_and(|text| text.contains(needle)) +} + +fn load_config(app: &mut ConfiguratorApp, contents: &str) -> (TempDir, std::path::PathBuf) { + let dir = crate::test_temp::tempdir().expect("temporary test directory"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, contents).expect("write test config"); + let document = ConfigDocument::load_from_path(&path).expect("load test config document"); + let _ = app.handle_config_loaded(Ok((Box::new(document), None))); + (dir, path) +} + +fn save_draft(app: &mut ConfiguratorApp) { + let mut effects = app.handle_save_requested(); + assert_eq!(effects.len(), 1, "a Save asks for exactly one write"); + match effects.remove(0) { + Effect::SaveConfig { document, config } => { + let (saved, backup) = document + .save_with_backup(*config) + .expect("the document saves") + .into_parts(); + let _ = app.handle_config_saved(Ok((backup, Box::new(saved)))); + } + other => panic!("a Save must ask for a write, not {other:?}"), + } +} + +fn config_setting(contents: &str, key: &str) -> Option { + let mut lines = contents.lines().map(str::trim).skip_while(|line| { + !(line.starts_with(key) && line[key.len()..].trim_start().starts_with('=')) + }); + let mut setting = lines.next()?.to_string(); + while setting.matches('[').count() > setting.matches(']').count() { + let Some(continuation) = lines.next() else { + break; + }; + setting.push(' '); + setting.push_str(continuation); + } + Some(setting.split_whitespace().collect::>().join(" ")) +} + +#[test] +fn starting_one_recorder_closes_any_older_recorder() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_recording_started(KeybindingField::ClearCanvas); + let _ = app.handle_shortcut_recording_started(KeybindingField::Undo); + assert_eq!( + app.active_shortcut_recorder + .as_ref() + .map(|recorder| recorder.field), + Some(KeybindingField::Undo) + ); +} + +#[test] +fn confirmation_and_shortcut_conflict_do_not_consume_each_other() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.pending_confirmation = Some(PendingConfirmation::DefaultsReset); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let _ = app.handle_shortcut_recorder_key( + u32::from(b'e'), + KeyboardModifiers { + ctrl: false, + shift: false, + alt: false, + super_held: false, + }, + ); + assert!( + app.pending_confirmation.is_some(), + "recording a conflict must not disarm Defaults" + ); + assert!(app.pending_shortcut_conflict.is_some()); + let _ = app.handle_window_escape_pressed(); + assert!( + app.pending_confirmation.is_none(), + "Escape still cancels Defaults when the recorder is closed" + ); + assert!( + app.pending_shortcut_conflict.is_some(), + "Escape must not take the shortcut conflict with it" + ); +} + +#[test] +fn search_changes_do_not_dirty_the_config() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_dirty = false; + let _ = app.handle_search_changed("undo".to_string()); + assert!(!app.is_dirty); + assert_eq!(app.search_query, SearchQuery::new("undo")); +} + +#[test] +fn recording_reset_and_removal_dirty_without_saving() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let effects = app.handle_shortcut_recorder_key(keyval::F5, KeyboardModifiers::default()); + assert!(effects.is_empty()); + assert!(app.is_dirty); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("F5") + ); + + let _ = app.handle_shortcut_reset_requested(KeybindingField::ClearCanvas); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ClearCanvas), + Some("E") + ); + let effects = app.handle_shortcut_removed( + KeybindingField::ToggleFloatingBadge, + KeyBinding::parse("F5").expect("parses"), + ); + assert!(effects.is_empty()); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("") + ); +} + +#[test] +fn recorder_escape_does_not_cancel_defaults_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.pending_confirmation = Some(PendingConfirmation::DefaultsReset); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let _ = app.handle_window_escape_pressed(); + assert!(app.pending_confirmation.is_some()); + assert!(app.active_shortcut_recorder.is_some()); +} + +#[test] +fn conflict_cancel_leaves_the_draft_byte_for_byte() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = app.draft.clone(); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let _ = app.handle_shortcut_recorder_key(u32::from(b'e'), KeyboardModifiers::default()); + assert!(app.pending_shortcut_conflict.is_some()); + let _ = app.handle_shortcut_conflict_canceled(); + assert!(app.pending_shortcut_conflict.is_none()); + assert_eq!(app.draft, before); +} + +#[test] +fn invalid_raw_text_blocks_save_and_stays_visible() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _dir = load_config( + &mut app, + &format!("config_revision = {CURRENT_CONFIG_REVISION}\n"), + ); + app.draft + .keybindings + .set(KeybindingField::Exit, "Ctrl+Shift".to_string()); + let effects = app.handle_save_requested(); + assert!(effects.is_empty()); + assert!(app.base_document.is_some()); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Exit), + Some("Ctrl+Shift") + ); + assert!(status_contains(&app.status, "keybindings.exit")); +} + +#[test] +fn save_after_confirmed_replacement_writes_only_the_intended_fields() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (_dir, path) = load_config( + &mut app, + &format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n# keep me\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n# trailing\nunknown_future = true\n" + ), + ); + let _ = app.handle_shortcut_recording_started(KeybindingField::ClearCanvas); + let _ = app.handle_shortcut_recorder_key( + u32::from(b'u'), + KeyboardModifiers { + ctrl: true, + shift: false, + alt: true, + super_held: false, + }, + ); + assert!(app.pending_shortcut_conflict.is_some()); + let _ = app.handle_shortcut_conflict_replace_confirmed(); + save_draft(&mut app); + + let contents = std::fs::read_to_string(&path).expect("read saved config"); + assert!(contents.contains("# keep me"), "{contents}"); + assert!(contents.contains("unknown_future = true"), "{contents}"); + let revision = format!("config_revision = {CURRENT_CONFIG_REVISION}"); + assert_eq!( + config_setting(&contents, "config_revision").as_deref(), + Some(revision.as_str()) + ); + assert_eq!( + config_setting(&contents, "clear_canvas").as_deref(), + Some("clear_canvas = [ \"E\", \"Ctrl+Alt+U\", ]") + ); + assert_eq!( + config_setting(&contents, "undo").as_deref(), + Some("undo = []") + ); +} + +#[test] +fn pending_conflict_blocks_save_without_taking_the_document() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _dir = load_config( + &mut app, + &format!("config_revision = {CURRENT_CONFIG_REVISION}\n"), + ); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let _ = app.handle_shortcut_recorder_key(u32::from(b'e'), KeyboardModifiers::default()); + let effects = app.handle_save_requested(); + assert!(effects.is_empty()); + assert!(app.base_document.is_some()); + assert!(status_contains(&app.status, "shortcut conflict")); +} + +#[test] +fn text_editor_keeps_invalid_text_until_canceled() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = app.draft.clone(); + let _ = app.handle_shortcut_text_edit_started(KeybindingField::Undo); + let _ = app.handle_shortcut_text_edit_changed("Ctrl+Shift".to_string()); + let _ = app.handle_shortcut_text_edit_applied(); + assert_eq!(app.draft, before); + assert!(app.shortcut_text_editor.is_some()); + let _ = app.handle_shortcut_text_edit_canceled(KeybindingField::Undo); + assert!(app.shortcut_text_editor.is_none()); + assert_eq!(app.draft, before); +} + +#[test] +fn recording_does_not_emit_save_config() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let effects = app.handle_shortcut_recording_started(KeybindingField::Undo); + assert!(effects.is_empty()); + let effects = app.handle_shortcut_reset_requested(KeybindingField::Undo); + assert!(effects.is_empty()); +} + +#[test] +fn keybinding_changed_still_updates_the_authored_string() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let effects = app.handle_keybinding_changed(KeybindingField::Undo, "F8".to_string()); + assert!(effects.is_empty()); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("F8") + ); +} + +#[test] +fn active_confirmation_canceled_clears_defaults_without_touching_conflicts() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.pending_confirmation = Some(PendingConfirmation::DefaultsReset); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let _ = app.handle_shortcut_recorder_key(u32::from(b'e'), KeyboardModifiers::default()); + let effects = app.handle_active_confirmation_canceled(); + assert!(effects.is_empty()); + assert!(app.pending_confirmation.is_none()); + assert!(app.pending_shortcut_conflict.is_some()); +} diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 4d8d8603..05497dab 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -1,14 +1,14 @@ use std::path::PathBuf; -use wayscriber::config::{ConfigDocument, ToolbarItemId, ToolbarItemOrderGroup}; +use wayscriber::config::{ConfigDocument, KeyBinding, ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption, DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, KeybindingField, KeybindingsTabId, - NamedColorOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, - PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, + KeyboardModifiers, NamedColorOption, OverrideOption, PdfFitModeOption, + PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, @@ -62,9 +62,6 @@ pub enum Message { ResetToDefaultsConfirmed, /// Answers an armed confirmation with no. ResetToDefaultsCanceled, - /// Cancels whichever destructive confirmation currently owns the answer - /// controls. Used by the window-level Escape key binding. - ActiveConfirmationCanceled, SaveRequested, MigrationApplyRequested, MigrationDismissed, @@ -155,7 +152,19 @@ pub enum Message { ExportPdfLabelPositionChanged(PdfLabelPositionOption), ExportPdfLabelContentChanged(PdfLabelContentModeOption), BufferCountChanged(u32), - KeybindingChanged(KeybindingField, String), + ShortcutRecordingStarted(KeybindingField), + ShortcutRecordingCanceled(KeybindingField), + ShortcutRecorderKey(u32, KeyboardModifiers), + ShortcutRemoved(KeybindingField, KeyBinding), + ShortcutResetRequested(KeybindingField), + ShortcutTextEditStarted(KeybindingField), + ShortcutTextEditChanged(String), + ShortcutTextEditApplied, + ShortcutTextEditCanceled(KeybindingField), + ShortcutConflictReplaceConfirmed, + ShortcutConflictCanceled, + /// Window-level Escape: cancel a confirmation unless a recorder owns the key. + WindowEscapePressed, FontStyleOptionSelected(FontStyleOption), FontWeightOptionSelected(FontWeightOption), #[cfg(feature = "tablet-input")] diff --git a/configurator/src/models/keybindings/AGENTS.md b/configurator/src/models/keybindings/AGENTS.md index 0c8133a5..9d1fbea3 100644 --- a/configurator/src/models/keybindings/AGENTS.md +++ b/configurator/src/models/keybindings/AGENTS.md @@ -4,7 +4,7 @@ - Applies to configurator keybinding models under `configurator/src/models/keybindings/`. ## Architecture -- Owns draft keybinding state, parsing, field-level labels/config read/write, grouping, and tests. +- Owns draft keybinding state, parsing, field-level labels/config read/write, grouping, recorder normalization, conflict lookup, and tests. ## Invariants - Keep action labels, categories, defaults, parsing, and display aligned with `src/config/keybindings/` and `src/config/action_meta/`. diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs new file mode 100644 index 00000000..a5896162 --- /dev/null +++ b/configurator/src/models/keybindings/conflicts.rs @@ -0,0 +1,314 @@ +//! Draft-wide shortcut conflict lookup and explicit replacement. + +use wayscriber::config::KeyBinding; + +use super::draft::KeybindingsDraft; +use super::edit::{FieldEditError, append_binding, remove_binding}; +use super::field::KeybindingField; +use super::parse::parse_keybindings; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutClaim { + pub field: KeybindingField, + pub label: String, +} + +impl ShortcutClaim { + fn from_field(field: KeybindingField) -> Self { + Self { + field, + label: field.label().to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingShortcutConflict { + Recorded { + target: KeybindingField, + binding: KeyBinding, + claimants: Vec, + }, + Text { + target: KeybindingField, + new_value: String, + conflicts: Vec, + }, +} + +impl PendingShortcutConflict { + pub fn prompt(&self) -> String { + match self { + Self::Recorded { + binding, claimants, .. + } => recorded_conflict_prompt(binding, claimants), + Self::Text { conflicts, .. } => text_conflict_prompt(conflicts), + } + } + + pub fn replace_label(&self) -> &'static str { + match self { + Self::Recorded { .. } => "Replace", + Self::Text { .. } => "Resolve Conflicts", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextShortcutConflict { + pub binding: KeyBinding, + pub claimants: Vec, +} + +/// 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: &KeyBinding) -> Vec { + let mut claims = Vec::new(); + for entry in &draft.entries { + if parse_keybindings(&entry.value) + .is_ok_and(|parsed| parsed.iter().any(|candidate| candidate == binding)) + { + claims.push(ShortcutClaim::from_field(entry.field)); + } + } + claims +} + +pub fn other_claimants( + draft: &KeybindingsDraft, + target: KeybindingField, + binding: &KeyBinding, +) -> Vec { + claimants_for(draft, binding) + .into_iter() + .filter(|claim| claim.field != target) + .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| other == binding)) +} + +pub fn text_conflicts_for( + draft: &KeybindingsDraft, + target: KeybindingField, + parsed: &[KeyBinding], +) -> Vec { + let mut conflicts = Vec::new(); + let mut seen = Vec::new(); + for binding in parsed { + if seen.iter().any(|existing| existing == binding) { + if !conflicts + .iter() + .any(|conflict: &TextShortcutConflict| conflict.binding == *binding) + { + conflicts.push(TextShortcutConflict { + binding: binding.clone(), + claimants: vec![ShortcutClaim::from_field(target)], + }); + } + continue; + } + seen.push(binding.clone()); + let claimants = other_claimants(draft, target, binding); + if !claimants.is_empty() { + conflicts.push(TextShortcutConflict { + binding: binding.clone(), + claimants, + }); + } + } + conflicts +} + +pub fn apply_recorded_replace( + draft: &mut KeybindingsDraft, + target: KeybindingField, + binding: &KeyBinding, + claimants: &[ShortcutClaim], +) -> Result<(), FieldEditError> { + let snapshot = draft.clone(); + for claim in claimants { + if claim.field == target { + continue; + } + if let Err(error) = remove_binding(draft, claim.field, binding) { + *draft = snapshot; + return Err(error); + } + } + match append_binding(draft, target, binding) { + Ok(_) => Ok(()), + Err(error) => { + *draft = snapshot; + Err(error) + } + } +} + +pub fn apply_text_replace( + draft: &mut KeybindingsDraft, + target: KeybindingField, + new_value: &str, + conflicts: &[TextShortcutConflict], +) -> Result<(), FieldEditError> { + let snapshot = draft.clone(); + for conflict in conflicts { + for claim in &conflict.claimants { + if claim.field == target { + continue; + } + if let Err(error) = remove_binding(draft, claim.field, &conflict.binding) { + *draft = snapshot; + return Err(error); + } + } + } + let parsed = parse_keybindings(new_value).map_err(FieldEditError::InvalidText)?; + let _ = parsed; + draft.set(target, new_value.trim().to_string()); + Ok(()) +} + +pub fn recorded_conflict_prompt(binding: &KeyBinding, claimants: &[ShortcutClaim]) -> String { + let mut lines = vec![format!("{binding} is already assigned to:")]; + for claim in claimants { + lines.push(format!("- {}", claim.label)); + } + lines.push(String::new()); + lines.push("Replace those assignments?".to_string()); + lines.join("\n") +} + +fn text_conflict_prompt(conflicts: &[TextShortcutConflict]) -> String { + let mut lines = vec!["These shortcuts conflict:".to_string()]; + for conflict in conflicts { + let names = conflict + .claimants + .iter() + .map(|claim| claim.label.as_str()) + .collect::>() + .join(", "); + lines.push(format!("- {}: {names}", conflict.binding)); + } + lines.push(String::new()); + lines.push("Resolve those assignments?".to_string()); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use wayscriber::config::keybindings::KeybindingsConfig; + + use super::*; + use crate::models::keybindings::KeybindingField; + + fn draft() -> KeybindingsDraft { + KeybindingsDraft::from_config(&KeybindingsConfig::default()) + } + + #[test] + fn conflict_lookup_returns_all_claimants() { + let mut draft = draft(); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::Undo, "ctrl+shift+x".to_string()); + let binding = KeyBinding::parse("Ctrl+Shift+X").expect("parses"); + let claimants = claimants_for(&draft, &binding); + let fields: Vec<_> = claimants.iter().map(|claim| claim.field).collect(); + assert!(fields.contains(&KeybindingField::ClearCanvas)); + assert!(fields.contains(&KeybindingField::ToggleToolbar)); + assert!(fields.contains(&KeybindingField::Undo)); + assert_eq!(claimants.len(), 3); + } + + #[test] + 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 + )); + let binding = KeyBinding::parse("E").expect("parses"); + let claimants = claimants_for(&draft, &binding); + assert_eq!(claimants.len(), 1); + assert_eq!(claimants[0].field, KeybindingField::ClearCanvas); + } + + #[test] + fn replace_removes_only_the_contested_binding() { + let mut draft = draft(); + draft.set( + KeybindingField::ToggleHelp, + "F10, F1, Ctrl+Shift+X".to_string(), + ); + draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+X".to_string()); + let binding = KeyBinding::parse("Ctrl+Shift+X").expect("parses"); + let claimants = other_claimants(&draft, KeybindingField::ClearCanvas, &binding); + apply_recorded_replace( + &mut draft, + KeybindingField::ClearCanvas, + &binding, + &claimants, + ) + .expect("replace"); + assert_eq!( + draft.value_for(KeybindingField::ToggleHelp), + Some("F10, F1") + ); + assert_eq!(draft.value_for(KeybindingField::Undo), Some("Ctrl+Z")); + assert_eq!( + draft.value_for(KeybindingField::ClearCanvas), + Some("E, Ctrl+Shift+X") + ); + } + + #[test] + fn cancel_leaves_the_draft_byte_for_byte() { + let mut draft = draft(); + draft.set(KeybindingField::ClearCanvas, "E, Q".to_string()); + let before = draft.clone(); + let _pending = PendingShortcutConflict::Recorded { + target: KeybindingField::Undo, + binding: KeyBinding::parse("E").expect("parses"), + claimants: other_claimants( + &draft, + KeybindingField::Undo, + &KeyBinding::parse("E").expect("parses"), + ), + }; + assert_eq!(draft, before); + } + + #[test] + fn text_conflicts_collect_every_contested_shortcut() { + let draft = draft(); + let conflicts = text_conflicts_for( + &draft, + KeybindingField::ToggleFloatingBadge, + &[ + KeyBinding::parse("E").expect("parses"), + KeyBinding::parse("Ctrl+Z").expect("parses"), + KeyBinding::parse("E").expect("parses"), + ], + ); + assert_eq!(conflicts.len(), 2); + assert_eq!(conflicts[0].binding.to_string(), "E"); + assert_eq!( + conflicts[0].claimants[0].field, + KeybindingField::ClearCanvas + ); + assert_eq!(conflicts[1].binding.to_string(), "Ctrl+Z"); + assert_eq!(conflicts[1].claimants[0].field, KeybindingField::Undo); + } +} diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs new file mode 100644 index 00000000..cc3c8da7 --- /dev/null +++ b/configurator/src/models/keybindings/edit.rs @@ -0,0 +1,261 @@ +//! Parsed draft operations for one action's shortcut list. +//! +//! Mutations work on authored comma-separated text and only rewrite a field +//! when the user explicitly adds, removes, resets, or applies a parsed list. +//! Invalid text stays in place until one of those replacements happens. + +use wayscriber::config::KeyBinding; + +use super::draft::KeybindingsDraft; +use super::field::KeybindingField; +use super::parse::{authored_shortcut_parts, parse_keybindings}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutTextEditor { + pub field: KeybindingField, + pub text: String, +} + +impl ShortcutTextEditor { + pub fn new(field: KeybindingField, text: impl Into) -> Self { + Self { + field, + text: text.into(), + } + } + + pub fn parse_error(&self) -> Option { + parse_keybindings(&self.text).err() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppendOutcome { + Added, + AlreadyPresent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FieldEditError { + InvalidText(String), +} + +impl FieldEditError { + pub fn message(&self) -> &str { + match self { + Self::InvalidText(message) => message, + } + } +} + +pub fn append_binding( + draft: &mut KeybindingsDraft, + field: KeybindingField, + binding: &KeyBinding, +) -> Result { + let authored = draft.value_for(field).unwrap_or("").to_string(); + let parsed = parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; + if parsed.iter().any(|existing| existing == binding) { + return Ok(AppendOutcome::AlreadyPresent); + } + let added = binding.to_string(); + let next = if authored.trim().is_empty() { + added + } else { + format!("{}, {added}", authored.trim()) + }; + draft.set(field, next); + Ok(AppendOutcome::Added) +} + +pub fn remove_binding( + draft: &mut KeybindingsDraft, + field: KeybindingField, + binding: &KeyBinding, +) -> Result<(), FieldEditError> { + let authored = draft.value_for(field).unwrap_or("").to_string(); + parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; + let mut removed = false; + let mut kept = Vec::new(); + for part in authored_shortcut_parts(&authored) { + if !removed && KeyBinding::parse(part).is_ok_and(|parsed| parsed == *binding) { + removed = true; + continue; + } + kept.push(part.to_string()); + } + draft.set(field, kept.join(", ")); + Ok(()) +} + +pub fn reset_field( + draft: &mut KeybindingsDraft, + defaults: &KeybindingsDraft, + field: KeybindingField, +) { + let value = defaults.value_for(field).unwrap_or_default().to_string(); + draft.set(field, value); +} + +#[cfg(test)] +fn apply_parsed_text( + draft: &mut KeybindingsDraft, + field: KeybindingField, + text: &str, +) -> Result, FieldEditError> { + let parsed = parse_keybindings(text).map_err(FieldEditError::InvalidText)?; + draft.set(field, text.trim().to_string()); + 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() { + "Reset to default: Unbound".to_string() + } else if default.contains(',') { + format!("Reset to defaults: {default}") + } else { + format!("Reset to default: {default}") + } +} + +pub fn serialize_bindings(bindings: &[KeyBinding]) -> String { + bindings + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use wayscriber::config::keybindings::KeybindingsConfig; + + use super::*; + use crate::models::keybindings::KeybindingField; + + fn draft() -> (KeybindingsDraft, KeybindingsDraft) { + let defaults = KeybindingsDraft::from_config(&KeybindingsConfig::default()); + (defaults.clone(), defaults) + } + + #[test] + fn equivalent_spelling_cannot_create_a_duplicate_chip() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Undo, "ctrl+z".to_string()); + let binding = KeyBinding::parse("Ctrl+Z").expect("parses"); + let outcome = append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); + assert_eq!(outcome, AppendOutcome::AlreadyPresent); + assert_eq!(draft.value_for(KeybindingField::Undo), Some("ctrl+z")); + } + + #[test] + fn removing_one_chip_preserves_siblings_and_authored_spelling() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Redo, "ctrl+shift+z, Ctrl+Y".to_string()); + let binding = KeyBinding::parse("Ctrl+Y").expect("parses"); + remove_binding(&mut draft, KeybindingField::Redo, &binding).expect("remove"); + assert_eq!(draft.value_for(KeybindingField::Redo), Some("ctrl+shift+z")); + } + + #[test] + fn removing_the_last_chip_unbinds_instead_of_resetting() { + let (mut draft, defaults) = draft(); + let binding = KeyBinding::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 + )); + } + + #[test] + fn reset_handles_one_default_multiple_defaults_and_unbound() { + let (mut draft, defaults) = draft(); + draft.set(KeybindingField::ClearCanvas, "X".to_string()); + draft.set(KeybindingField::Redo, "".to_string()); + draft.set(KeybindingField::ToggleFloatingBadge, "F8".to_string()); + + reset_field(&mut draft, &defaults, KeybindingField::ClearCanvas); + reset_field(&mut draft, &defaults, KeybindingField::Redo); + reset_field(&mut draft, &defaults, KeybindingField::ToggleFloatingBadge); + + assert_eq!(draft.value_for(KeybindingField::ClearCanvas), Some("E")); + assert_eq!( + draft.value_for(KeybindingField::Redo), + Some("Ctrl+Shift+Z, Ctrl+Y") + ); + assert_eq!( + draft.value_for(KeybindingField::ToggleFloatingBadge), + Some("") + ); + assert_eq!( + reset_tooltip(&defaults, KeybindingField::ClearCanvas), + "Reset to default: E" + ); + assert_eq!( + reset_tooltip(&defaults, KeybindingField::Redo), + "Reset to defaults: Ctrl+Shift+Z, Ctrl+Y" + ); + assert_eq!( + reset_tooltip(&defaults, KeybindingField::ToggleFloatingBadge), + "Reset to default: Unbound" + ); + } + + #[test] + fn invalid_raw_text_blocks_parsed_edits_and_stays_visible() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Exit, "Ctrl+Shift".to_string()); + let binding = KeyBinding::parse("F5").expect("parses"); + let error = append_binding(&mut draft, KeybindingField::Exit, &binding) + .expect_err("invalid text cannot append"); + assert!(error.message().contains("No key specified")); + assert_eq!(draft.value_for(KeybindingField::Exit), Some("Ctrl+Shift")); + let error = remove_binding(&mut draft, KeybindingField::Exit, &binding) + .expect_err("invalid text cannot remove"); + assert!(error.message().contains("No key specified")); + assert_eq!(draft.value_for(KeybindingField::Exit), Some("Ctrl+Shift")); + } + + #[test] + fn apply_parsed_text_keeps_the_authored_string() { + let (mut draft, _defaults) = draft(); + apply_parsed_text(&mut draft, KeybindingField::Undo, " ctrl+z , Ctrl+Shift+Z ") + .expect("parses"); + assert_eq!( + draft.value_for(KeybindingField::Undo), + Some("ctrl+z , Ctrl+Shift+Z") + ); + } + + #[test] + 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 + )); + } +} diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index e7275afb..8351fb19 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -1,10 +1,26 @@ +mod conflicts; mod draft; +mod edit; mod field; mod parse; +mod recording; +pub use conflicts::{ + PendingShortcutConflict, apply_recorded_replace, apply_text_replace, + field_has_internal_duplicate, other_claimants, text_conflicts_for, +}; pub use draft::KeybindingsDraft; +pub use edit::{ + AppendOutcome, ShortcutTextEditor, append_binding, field_matches_defaults, remove_binding, + reset_field, reset_tooltip, serialize_bindings, +}; pub use field::KeybindingField; -pub(crate) use parse::parse_keybinding_list; +pub(crate) use parse::parse_keybindings; +#[cfg(test)] +pub(crate) use recording::keyval; +pub use recording::{ + KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, normalize_key_event, waiting_prompt, +}; #[cfg(test)] mod tests; diff --git a/configurator/src/models/keybindings/parse.rs b/configurator/src/models/keybindings/parse.rs index e4da185d..b19ffe6b 100644 --- a/configurator/src/models/keybindings/parse.rs +++ b/configurator/src/models/keybindings/parse.rs @@ -1,16 +1,26 @@ use wayscriber::config::KeyBinding; +pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect() +} + pub(crate) fn parse_keybinding_list(value: &str) -> Result, String> { let mut entries = Vec::new(); - - for part in value.split(',') { - let trimmed = part.trim(); - if trimmed.is_empty() { - continue; - } - KeyBinding::parse(trimmed)?; - entries.push(trimmed.to_string()); + for part in authored_shortcut_parts(value) { + KeyBinding::parse(part)?; + entries.push(part.to_string()); } + Ok(entries) +} +pub(crate) fn parse_keybindings(value: &str) -> Result, String> { + let mut entries = Vec::new(); + for part in authored_shortcut_parts(value) { + entries.push(KeyBinding::parse(part)?); + } Ok(entries) } diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs new file mode 100644 index 00000000..dc026b3e --- /dev/null +++ b/configurator/src/models/keybindings/recording.rs @@ -0,0 +1,467 @@ +//! GDK key-event normalization for the configurator shortcut recorder. +//! +//! The runtime still matches [`wayscriber::config::KeyBinding`]. This module +//! only translates a keyval plus modifier flags into that type, so the +//! recorder can be tested without a GTK display. + +use wayscriber::config::KeyBinding; + +use super::field::KeybindingField; + +/// Modifier flags taken from a GDK key event. +/// +/// Super is tracked so a Super-modified chord is rejected instead of being +/// recorded as the same shortcut without Super. Super storage lands in a +/// later phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct KeyboardModifiers { + pub ctrl: bool, + pub shift: bool, + pub alt: bool, + pub super_held: bool, +} + +impl KeyboardModifiers { + /// Canonical modifier prefix, including the trailing `+` when any + /// supported modifier is down. + pub fn prefix(self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("Ctrl"); + } + if self.shift { + parts.push("Shift"); + } + if self.alt { + parts.push("Alt"); + } + if parts.is_empty() { + String::new() + } else { + format!("{}+", parts.join("+")) + } + } +} + +/// Result of one recorder key event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedKeyboard { + /// Bare modifiers: keep the recorder open and show `preview`. + Pending { preview: String }, + /// A complete chord ready to add. + Chord(KeyBinding), + /// The key cannot be stored; the recorder stays open. + Unsupported { message: String }, +} + +/// Live recorder owned by [`crate::app::state::ConfiguratorApp`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutRecorderState { + pub field: KeybindingField, + pub prompt: String, +} + +impl ShortcutRecorderState { + pub fn new(field: KeybindingField) -> Self { + Self { + field, + prompt: waiting_prompt(), + } + } +} + +pub fn waiting_prompt() -> String { + "Press a shortcut.".to_string() +} + +/// Map a GDK/X11 keyval and current modifiers onto the shared binding type. +pub fn normalize_key_event(keyval: u32, modifiers: KeyboardModifiers) -> RecordedKeyboard { + if is_super_family(keyval) || (modifiers.super_held && !is_supported_modifier_key(keyval)) { + return RecordedKeyboard::Unsupported { + message: super_unsupported_message().to_string(), + }; + } + + if is_supported_modifier_key(keyval) { + let preview = pending_preview(modifiers); + return RecordedKeyboard::Pending { preview }; + } + + match key_name_from_keyval(keyval, modifiers.shift) { + Some(key) => { + let binding = KeyBinding { + key, + ctrl: modifiers.ctrl, + shift: modifiers.shift, + alt: modifiers.alt, + }; + RecordedKeyboard::Chord(binding) + } + None => RecordedKeyboard::Unsupported { + message: unsupported_key_message().to_string(), + }, + } +} + +pub fn pending_preview(modifiers: KeyboardModifiers) -> String { + let prefix = modifiers.prefix(); + if prefix.is_empty() { + waiting_prompt() + } else { + format!("{prefix}…") + } +} + +pub fn super_unsupported_message() -> &'static str { + "Super, Meta, and Hyper cannot be recorded yet. Use Edit as Text to type them." +} + +pub fn unsupported_key_message() -> &'static str { + "This key cannot be recorded. Use Edit as Text if Wayscriber names it." +} + +fn is_supported_modifier_key(keyval: u32) -> bool { + matches!( + keyval, + keyval::SHIFT_L + | keyval::SHIFT_R + | keyval::CONTROL_L + | keyval::CONTROL_R + | keyval::CAPS_LOCK + | keyval::SHIFT_LOCK + | keyval::ALT_L + | keyval::ALT_R + | keyval::ISO_LEVEL3_SHIFT + | keyval::NUM_LOCK + ) +} + +fn is_super_family(keyval: u32) -> bool { + matches!( + keyval, + keyval::META_L + | keyval::META_R + | keyval::SUPER_L + | keyval::SUPER_R + | keyval::HYPER_L + | keyval::HYPER_R + ) +} + +fn key_name_from_keyval(keyval: u32, shift: bool) -> Option { + if let Some(name) = named_key(keyval) { + return Some(name.to_string()); + } + + let character = keyval_to_unicode(keyval)?; + if character.is_control() { + return None; + } + + if character == ' ' { + return Some("Space".to_string()); + } + + if shift + && let Some(unshifted) = unshifted_punctuation(character) + && character != '+' + { + return Some(unshifted.to_string()); + } + + if character.is_ascii_alphabetic() { + return Some(character.to_ascii_uppercase().to_string()); + } + + Some(character.to_string()) +} + +fn named_key(keyval: u32) -> Option<&'static str> { + Some(match keyval { + keyval::ESCAPE => "Escape", + keyval::RETURN | keyval::KP_ENTER => "Return", + keyval::BACKSPACE => "Backspace", + keyval::MENU => "Menu", + keyval::DELETE | keyval::KP_DELETE => "Delete", + keyval::HOME | keyval::KP_HOME => "Home", + keyval::END | keyval::KP_END => "End", + keyval::PAGE_UP | keyval::KP_PAGE_UP => "PageUp", + keyval::PAGE_DOWN | keyval::KP_PAGE_DOWN => "PageDown", + keyval::LEFT | keyval::KP_LEFT => "ArrowLeft", + keyval::UP | keyval::KP_UP => "ArrowUp", + keyval::RIGHT | keyval::KP_RIGHT => "ArrowRight", + keyval::DOWN | keyval::KP_DOWN => "ArrowDown", + keyval::F1 => "F1", + keyval::F2 => "F2", + keyval::F3 => "F3", + keyval::F4 => "F4", + keyval::F5 => "F5", + keyval::F6 => "F6", + keyval::F7 => "F7", + keyval::F8 => "F8", + keyval::F9 => "F9", + keyval::F10 => "F10", + keyval::F11 => "F11", + keyval::F12 => "F12", + keyval::KP_ADD => "+", + keyval::KP_SUBTRACT => "-", + keyval::KP_MULTIPLY => "*", + keyval::KP_DIVIDE => "/", + keyval::KP_DECIMAL => ".", + keyval::KP_0 => "0", + keyval::KP_1 => "1", + keyval::KP_2 => "2", + keyval::KP_3 => "3", + keyval::KP_4 => "4", + keyval::KP_5 => "5", + keyval::KP_6 => "6", + keyval::KP_7 => "7", + keyval::KP_8 => "8", + keyval::KP_9 => "9", + _ => return None, + }) +} + +fn keyval_to_unicode(keyval: u32) -> Option { + if (0x20..0x7f).contains(&keyval) { + return char::from_u32(keyval); + } + if (0x0100_0000..0x0111_0000).contains(&keyval) { + return char::from_u32(keyval - 0x0100_0000); + } + None +} + +/// Inverse of the runtime shifted-symbol fallback: Shift+1 records as `1` +/// with Shift, so dispatch that sees `!` can still match through that table. +fn unshifted_punctuation(character: char) -> Option { + Some(match character { + '!' => '1', + '@' => '2', + '#' => '3', + '$' => '4', + '%' => '5', + '^' => '6', + '&' => '7', + '*' => '8', + '(' => '9', + ')' => '0', + '_' => '-', + // '+' maps to '=' in the runtime fallback, but Ctrl+Shift++ is the + // stored spelling for the plus key, so '+' is kept as-is by the + // caller. + '{' => '[', + '}' => ']', + '|' => '\\', + ':' => ';', + '"' => '\'', + '<' => ',', + '>' => '.', + '?' => '/', + '~' => '`', + _ => return None, + }) +} + +/// GDK/X11 keyvals used by the recorder. Numbers are the public GDK contract, +/// so tests do not need a display. +#[allow(dead_code)] +pub mod keyval { + pub const ESCAPE: u32 = 0xff1b; + pub const RETURN: u32 = 0xff0d; + pub const KP_ENTER: u32 = 0xff8d; + pub const BACKSPACE: u32 = 0xff08; + pub const TAB: u32 = 0xff09; + pub const MENU: u32 = 0xff67; + pub const DELETE: u32 = 0xffff; + pub const KP_DELETE: u32 = 0xff9f; + pub const HOME: u32 = 0xff50; + pub const END: u32 = 0xff57; + pub const PAGE_UP: u32 = 0xff55; + pub const PAGE_DOWN: u32 = 0xff56; + pub const LEFT: u32 = 0xff51; + pub const UP: u32 = 0xff52; + pub const RIGHT: u32 = 0xff53; + pub const DOWN: u32 = 0xff54; + pub const KP_HOME: u32 = 0xff95; + pub const KP_LEFT: u32 = 0xff96; + pub const KP_UP: u32 = 0xff97; + pub const KP_RIGHT: u32 = 0xff98; + pub const KP_DOWN: u32 = 0xff99; + pub const KP_PAGE_UP: u32 = 0xff9a; + pub const KP_PAGE_DOWN: u32 = 0xff9b; + pub const KP_END: u32 = 0xff9c; + pub const F1: u32 = 0xffbe; + pub const F2: u32 = 0xffbf; + pub const F3: u32 = 0xffc0; + pub const F4: u32 = 0xffc1; + pub const F5: u32 = 0xffc2; + pub const F6: u32 = 0xffc3; + pub const F7: u32 = 0xffc4; + pub const F8: u32 = 0xffc5; + pub const F9: u32 = 0xffc6; + pub const F10: u32 = 0xffc7; + pub const F11: u32 = 0xffc8; + pub const F12: u32 = 0xffc9; + pub const SHIFT_L: u32 = 0xffe1; + pub const SHIFT_R: u32 = 0xffe2; + pub const CONTROL_L: u32 = 0xffe3; + pub const CONTROL_R: u32 = 0xffe4; + pub const CAPS_LOCK: u32 = 0xffe5; + pub const SHIFT_LOCK: u32 = 0xffe6; + pub const META_L: u32 = 0xffe7; + pub const META_R: u32 = 0xffe8; + pub const ALT_L: u32 = 0xffe9; + pub const ALT_R: u32 = 0xffea; + pub const SUPER_L: u32 = 0xffeb; + pub const SUPER_R: u32 = 0xffec; + pub const HYPER_L: u32 = 0xffed; + pub const HYPER_R: u32 = 0xffee; + pub const ISO_LEVEL3_SHIFT: u32 = 0xfe03; + pub const NUM_LOCK: u32 = 0xff7f; + pub const KP_0: u32 = 0xffb0; + pub const KP_1: u32 = 0xffb1; + pub const KP_2: u32 = 0xffb2; + pub const KP_3: u32 = 0xffb3; + pub const KP_4: u32 = 0xffb4; + pub const KP_5: u32 = 0xffb5; + pub const KP_6: u32 = 0xffb6; + pub const KP_7: u32 = 0xffb7; + pub const KP_8: u32 = 0xffb8; + pub const KP_9: u32 = 0xffb9; + pub const KP_DECIMAL: u32 = 0xffae; + pub const KP_DIVIDE: u32 = 0xffaf; + pub const KP_MULTIPLY: u32 = 0xffaa; + pub const KP_SUBTRACT: u32 = 0xffad; + pub const KP_ADD: u32 = 0xffab; + pub const PLUS: u32 = 0x002b; + pub const SPACE: u32 = 0x0020; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mods(ctrl: bool, shift: bool, alt: bool) -> KeyboardModifiers { + KeyboardModifiers { + ctrl, + shift, + alt, + super_held: false, + } + } + + fn chord(keyval: u32, modifiers: KeyboardModifiers) -> KeyBinding { + match normalize_key_event(keyval, modifiers) { + RecordedKeyboard::Chord(binding) => binding, + other => panic!("expected a chord, got {other:?}"), + } + } + + #[test] + fn f5_records_as_f5() { + let binding = chord(keyval::F5, KeyboardModifiers::default()); + assert_eq!(binding.to_string(), "F5"); + } + + #[test] + fn ctrl_shift_x_uses_canonical_modifier_order() { + let binding = chord(u32::from(b'x'), mods(true, true, false)); + assert_eq!(binding.to_string(), "Ctrl+Shift+X"); + let from_uppercase = chord(u32::from(b'X'), mods(true, true, false)); + assert_eq!(from_uppercase.to_string(), "Ctrl+Shift+X"); + assert_eq!(binding, from_uppercase); + } + + #[test] + fn shifted_punctuation_records_the_unshifted_key() { + let binding = chord(u32::from(b'!'), mods(false, true, false)); + assert_eq!(binding.to_string(), "Shift+1"); + assert!(binding.matches("1", false, true, false)); + assert!(!binding.matches("!", false, true, false)); + } + + #[test] + fn ctrl_shift_plus_round_trips_the_plus_key() { + let binding = chord(keyval::PLUS, mods(true, true, false)); + assert_eq!(binding.key, "+"); + assert_eq!(binding.to_string(), "Ctrl+Shift++"); + let parsed = KeyBinding::parse("Ctrl+Shift++").expect("plus key parses"); + assert_eq!(binding, parsed); + } + + #[test] + fn bare_modifiers_stay_pending() { + for keyval in [ + keyval::CONTROL_L, + keyval::CONTROL_R, + keyval::SHIFT_L, + keyval::SHIFT_R, + keyval::ALT_L, + keyval::ALT_R, + ] { + match normalize_key_event(keyval, mods(true, false, false)) { + RecordedKeyboard::Pending { preview } => { + assert_eq!(preview, "Ctrl+…"); + } + other => panic!("modifier {keyval:#x} should stay pending, got {other:?}"), + } + } + } + + #[test] + fn escape_and_backspace_are_recordable() { + assert_eq!( + chord(keyval::ESCAPE, KeyboardModifiers::default()).to_string(), + "Escape" + ); + assert_eq!( + chord(keyval::BACKSPACE, KeyboardModifiers::default()).to_string(), + "Backspace" + ); + } + + #[test] + fn unknown_keyvals_are_unsupported() { + match normalize_key_event(0xffff_fffe, KeyboardModifiers::default()) { + RecordedKeyboard::Unsupported { message } => { + assert!(message.contains("cannot be recorded")); + } + other => panic!("expected unsupported, got {other:?}"), + } + match normalize_key_event(keyval::TAB, KeyboardModifiers::default()) { + RecordedKeyboard::Unsupported { .. } => {} + other => panic!("Tab is not a named binding key, got {other:?}"), + } + } + + #[test] + fn super_chords_are_rejected_instead_of_dropping_the_modifier() { + let mut modifiers = mods(false, false, false); + modifiers.super_held = true; + match normalize_key_event(u32::from(b'x'), modifiers) { + RecordedKeyboard::Unsupported { message } => { + assert!(message.contains("Super")); + } + other => panic!("expected Super to be unsupported, got {other:?}"), + } + match normalize_key_event(keyval::SUPER_L, KeyboardModifiers::default()) { + RecordedKeyboard::Unsupported { message } => { + assert!(message.contains("Super")); + } + other => panic!("expected Super_L to be unsupported, got {other:?}"), + } + } + + #[test] + fn space_and_return_use_named_keys() { + assert_eq!( + chord(keyval::SPACE, KeyboardModifiers::default()).to_string(), + "Space" + ); + assert_eq!( + chord(keyval::RETURN, KeyboardModifiers::default()).to_string(), + "Return" + ); + } +} diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 948dd09e..61d43578 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -35,7 +35,10 @@ pub use fields::{ }; #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; -pub use keybindings::KeybindingField; +pub use keybindings::{ + KeybindingField, KeyboardModifiers, PendingShortcutConflict, ShortcutRecorderState, + ShortcutTextEditor, +}; pub(crate) use search::SearchQuery; pub(crate) use session::SessionCatalogOperation; pub use session::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogState}; From 9a31923f9f4ee01d192aa9b789f2269b9f9f524b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 2/7] feat: support Super/Meta in shortcuts Parse Super, Meta, Logo, Win, and Windows as one modifier, match it at runtime from the Wayland logo flag, and record it in the configurator when the desktop delivers the chord. --- config.example.toml | 5 +- configurator/README.md | 2 +- .../src/app/pages/keybindings/recorder.rs | 12 ++- .../src/models/keybindings/conflicts.rs | 10 +++ configurator/src/models/keybindings/mod.rs | 3 +- .../src/models/keybindings/recording.rs | 83 +++++++++---------- docs/CONFIG.md | 9 +- src/backend/wayland/handlers/keyboard/mod.rs | 12 ++- .../wayland/handlers/keyboard/translate.rs | 15 ++++ .../wayland/input_monitor/translate.rs | 1 + src/config/keybindings/binding.rs | 16 +++- src/config/keybindings/tests.rs | 54 ++++++++++-- src/config/types/input_hud.rs | 2 +- src/input/events.rs | 2 + src/input/modifiers.rs | 14 +++- src/input/state/actions/key_press/mod.rs | 1 + src/input/state/actions/key_release.rs | 6 +- src/input/state/core/base/state/modifiers.rs | 4 +- src/input/state/core/command_palette/input.rs | 7 +- src/input/state/core/command_palette/mod.rs | 27 ++++++ src/input/state/core/utility/actions.rs | 1 + src/input/state/input_hud/label.rs | 53 ++++++++---- .../state/interaction/adapters/keyboard.rs | 4 +- src/input/state/tests/action_bindings.rs | 22 +++++ src/input/state/tests/text_input/actions.rs | 2 + src/input/state/tests/tool_controls.rs | 20 ++++- 26 files changed, 296 insertions(+), 91 deletions(-) diff --git a/config.example.toml b/config.example.toml index 93741d6c..d05d13fb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -33,7 +33,8 @@ config_revision = 3 [keybindings] # Customize keyboard shortcuts for all actions # Format: key names with modifiers separated by + -# Examples: "Escape", "Ctrl+Z", "Ctrl+Shift+T", "F10" +# Examples: "Escape", "Ctrl+Z", "Super+X", "Ctrl+Shift+T", "F10" +# Super also accepts Meta, Logo, Win, and Windows. # Each action can have multiple keybindings (e.g., ["+", "="]) # Exit overlay (or cancel current action) @@ -758,7 +759,7 @@ position = "bottom-center" # Show mouse buttons and scroll wheel show_mouse = true -# Show taps of bare modifiers (Ctrl, Shift, Alt) as their own chips +# Show taps of bare modifiers (Ctrl, Shift, Alt, Super) as their own chips show_bare_modifiers = true # How long a chip stays after its last press, before fading (200 - 30000 ms) diff --git a/configurator/README.md b/configurator/README.md index 01e10a08..bf4f0ff7 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -69,7 +69,7 @@ if its UI task is no longer observed. - **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components). - **Default color** – toggle between named colors and custom RGB triples. -- **Keybindings** – per-action comma-separated shortcut lists that map to `KeybindingsConfig`. +- **Keybindings** – per-action shortcut chips with press-to-bind recording, per-row reset, and a raw comma-separated text editor. Super/Meta chords record when the desktop delivers them. - **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. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs index f6ccd8b2..25884b76 100644 --- a/configurator/src/app/pages/keybindings/recorder.rs +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -5,7 +5,7 @@ use gtk::prelude::*; use crate::messages::Message; use crate::models::KeybindingField; -use crate::models::keybindings::{KeyboardModifiers, waiting_prompt}; +use crate::models::keybindings::{KeyboardModifiers, super_consumed_hint, waiting_prompt}; use super::super::super::state::ConfiguratorApp; use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; @@ -30,6 +30,15 @@ impl RecorderPopover { .build(); set_accessible_label(&prompt, "Shortcut recorder"); + let hint = gtk::Label::builder() + .label(super_consumed_hint()) + .wrap(true) + .xalign(0.0) + .max_width_chars(36) + .css_classes(["dim-label"]) + .build(); + set_accessible_label(&hint, super_consumed_hint()); + let cancel = gtk::Button::builder().label("Cancel").build(); set_accessible_label(&cancel, "Cancel recording"); { @@ -45,6 +54,7 @@ impl RecorderPopover { content.set_margin_start(12); content.set_margin_end(12); content.append(&prompt); + content.append(&hint); content.append(&cancel); let popover = gtk::Popover::builder() diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index a5896162..be776f75 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -231,6 +231,16 @@ mod tests { assert_eq!(claimants.len(), 3); } + #[test] + fn conflict_lookup_treats_meta_and_super_as_the_same_binding() { + let mut draft = draft(); + draft.set(KeybindingField::Undo, "Meta+X".to_string()); + let binding = KeyBinding::parse("Super+X").expect("parses"); + let claimants = claimants_for(&draft, &binding); + assert_eq!(claimants.len(), 1); + assert_eq!(claimants[0].field, KeybindingField::Undo); + } + #[test] fn duplicate_inside_one_action_is_a_conflict() { let mut draft = draft(); diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index 8351fb19..14e48133 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -19,7 +19,8 @@ pub(crate) use parse::parse_keybindings; #[cfg(test)] pub(crate) use recording::keyval; pub use recording::{ - KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, normalize_key_event, waiting_prompt, + KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, normalize_key_event, + super_consumed_hint, waiting_prompt, }; #[cfg(test)] diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs index dc026b3e..503be463 100644 --- a/configurator/src/models/keybindings/recording.rs +++ b/configurator/src/models/keybindings/recording.rs @@ -10,9 +10,7 @@ use super::field::KeybindingField; /// Modifier flags taken from a GDK key event. /// -/// Super is tracked so a Super-modified chord is rejected instead of being -/// recorded as the same shortcut without Super. Super storage lands in a -/// later phase. +/// Super, Meta, and Hyper all map onto the runtime Super/logo modifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct KeyboardModifiers { pub ctrl: bool, @@ -35,6 +33,9 @@ impl KeyboardModifiers { if self.alt { parts.push("Alt"); } + if self.super_held { + parts.push("Super"); + } if parts.is_empty() { String::new() } else { @@ -76,12 +77,6 @@ pub fn waiting_prompt() -> String { /// Map a GDK/X11 keyval and current modifiers onto the shared binding type. pub fn normalize_key_event(keyval: u32, modifiers: KeyboardModifiers) -> RecordedKeyboard { - if is_super_family(keyval) || (modifiers.super_held && !is_supported_modifier_key(keyval)) { - return RecordedKeyboard::Unsupported { - message: super_unsupported_message().to_string(), - }; - } - if is_supported_modifier_key(keyval) { let preview = pending_preview(modifiers); return RecordedKeyboard::Pending { preview }; @@ -94,6 +89,7 @@ pub fn normalize_key_event(keyval: u32, modifiers: KeyboardModifiers) -> Recorde ctrl: modifiers.ctrl, shift: modifiers.shift, alt: modifiers.alt, + logo: modifiers.super_held, }; RecordedKeyboard::Chord(binding) } @@ -112,8 +108,8 @@ pub fn pending_preview(modifiers: KeyboardModifiers) -> String { } } -pub fn super_unsupported_message() -> &'static str { - "Super, Meta, and Hyper cannot be recorded yet. Use Edit as Text to type them." +pub fn super_consumed_hint() -> &'static str { + "If Super never appears, the desktop captured it. Use Edit as Text." } pub fn unsupported_key_message() -> &'static str { @@ -131,20 +127,14 @@ fn is_supported_modifier_key(keyval: u32) -> bool { | keyval::SHIFT_LOCK | keyval::ALT_L | keyval::ALT_R - | keyval::ISO_LEVEL3_SHIFT - | keyval::NUM_LOCK - ) -} - -fn is_super_family(keyval: u32) -> bool { - matches!( - keyval, - keyval::META_L + | keyval::META_L | keyval::META_R | keyval::SUPER_L | keyval::SUPER_R | keyval::HYPER_L | keyval::HYPER_R + | keyval::ISO_LEVEL3_SHIFT + | keyval::NUM_LOCK ) } @@ -342,12 +332,12 @@ pub mod keyval { mod tests { use super::*; - fn mods(ctrl: bool, shift: bool, alt: bool) -> KeyboardModifiers { + fn mods(ctrl: bool, shift: bool, alt: bool, super_held: bool) -> KeyboardModifiers { KeyboardModifiers { ctrl, shift, alt, - super_held: false, + super_held, } } @@ -366,24 +356,24 @@ mod tests { #[test] fn ctrl_shift_x_uses_canonical_modifier_order() { - let binding = chord(u32::from(b'x'), mods(true, true, false)); + let binding = chord(u32::from(b'x'), mods(true, true, false, false)); assert_eq!(binding.to_string(), "Ctrl+Shift+X"); - let from_uppercase = chord(u32::from(b'X'), mods(true, true, false)); + let from_uppercase = chord(u32::from(b'X'), mods(true, true, false, false)); assert_eq!(from_uppercase.to_string(), "Ctrl+Shift+X"); assert_eq!(binding, from_uppercase); } #[test] fn shifted_punctuation_records_the_unshifted_key() { - let binding = chord(u32::from(b'!'), mods(false, true, false)); + let binding = chord(u32::from(b'!'), mods(false, true, false, false)); assert_eq!(binding.to_string(), "Shift+1"); - assert!(binding.matches("1", false, true, false)); - assert!(!binding.matches("!", false, true, false)); + assert!(binding.matches("1", false, true, false, false)); + assert!(!binding.matches("!", false, true, false, false)); } #[test] fn ctrl_shift_plus_round_trips_the_plus_key() { - let binding = chord(keyval::PLUS, mods(true, true, false)); + let binding = chord(keyval::PLUS, mods(true, true, false, false)); assert_eq!(binding.key, "+"); assert_eq!(binding.to_string(), "Ctrl+Shift++"); let parsed = KeyBinding::parse("Ctrl+Shift++").expect("plus key parses"); @@ -399,8 +389,12 @@ mod tests { keyval::SHIFT_R, keyval::ALT_L, keyval::ALT_R, + keyval::SUPER_L, + keyval::SUPER_R, + keyval::META_L, + keyval::HYPER_L, ] { - match normalize_key_event(keyval, mods(true, false, false)) { + match normalize_key_event(keyval, mods(true, false, false, false)) { RecordedKeyboard::Pending { preview } => { assert_eq!(preview, "Ctrl+…"); } @@ -436,20 +430,23 @@ mod tests { } #[test] - fn super_chords_are_rejected_instead_of_dropping_the_modifier() { - let mut modifiers = mods(false, false, false); - modifiers.super_held = true; - match normalize_key_event(u32::from(b'x'), modifiers) { - RecordedKeyboard::Unsupported { message } => { - assert!(message.contains("Super")); - } - other => panic!("expected Super to be unsupported, got {other:?}"), - } - match normalize_key_event(keyval::SUPER_L, KeyboardModifiers::default()) { - RecordedKeyboard::Unsupported { message } => { - assert!(message.contains("Super")); - } - other => panic!("expected Super_L to be unsupported, got {other:?}"), + fn super_chords_record_as_canonical_super() { + let binding = chord(u32::from(b'x'), mods(false, false, false, true)); + assert_eq!(binding.to_string(), "Super+X"); + assert!(binding.logo); + assert_eq!(binding, KeyBinding::parse("Meta+X").expect("Meta alias")); + + assert_eq!( + chord(keyval::F5, mods(false, true, false, true)).to_string(), + "Shift+Super+F5" + ); + } + + #[test] + fn bare_super_stays_pending() { + match normalize_key_event(keyval::SUPER_L, mods(false, false, false, true)) { + RecordedKeyboard::Pending { preview } => assert_eq!(preview, "Super+…"), + other => panic!("expected Super to stay pending, got {other:?}"), } } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 8a725927..6b374683 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -860,7 +860,7 @@ names the keybinding config and help overlay print (`Ctrl+Shift+Z`, `Space`, | `mode` | enum | `"auto"` | `auto`, `overlay`, or `system` | | `position` | enum | `"bottom-center"` | Nine screen anchors (3×3 grid) | | `show_mouse` | bool | `true` | Show buttons and scroll | -| `show_bare_modifiers` | bool | `true` | Show lone Ctrl/Shift/Alt taps | +| `show_bare_modifiers` | bool | `true` | Show lone Ctrl/Shift/Alt/Super taps | | `display_ms` | u64 | `1600` | Hold before fading (200–30000) | | `fade_ms` | u64 | `350` | Fade duration (0–5000) | | `max_entries` | usize | `6` | Simultaneous chips (1–16) | @@ -1598,6 +1598,13 @@ For end-to-end CLI, overlay, and configurator flows, see [`examples/session-mana Customize keyboard shortcuts for all actions. Each action can have multiple keybindings. For multi-monitor, customize `focus_prev_output` and `focus_next_output` in this section. +A shortcut is a key name with optional modifiers joined by `+`. The modifiers are `Ctrl` +(`Control`), `Shift`, `Alt`, and `Super` (`Meta`, `Logo`, `Win`, `Windows`). Matching, +conflicts, and display use the canonical spelling `Super`. Examples: `Escape`, `Ctrl+Z`, +`Super+X`, `Ctrl+Shift+T`, `F10`. Super chords work when the compositor delivers them; +if the desktop consumes Super before Wayscriber or the configurator sees it, type the +shortcut with Edit as Text. + #### How a shortcut you did not write is decided A `[keybindings]` field your file spells out is yours: it is used exactly as authored, including an diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 98ff8c5d..f04486e4 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -245,13 +245,17 @@ impl KeyboardHandler for WaylandState { _group: u32, ) { debug!( - "Modifiers: ctrl={} alt={} shift={}", - modifiers.ctrl, modifiers.alt, modifiers.shift + "Modifiers: ctrl={} alt={} shift={} logo={}", + modifiers.ctrl, modifiers.alt, modifiers.shift, modifiers.logo ); // Trust compositor-reported modifier state to reconcile any missed key release // events and avoid "stuck" modifiers. - self.input_state - .sync_modifiers(modifiers.shift, modifiers.ctrl, modifiers.alt); + self.input_state.sync_modifiers( + modifiers.shift, + modifiers.ctrl, + modifiers.alt, + modifiers.logo, + ); } fn repeat_key( diff --git a/src/backend/wayland/handlers/keyboard/translate.rs b/src/backend/wayland/handlers/keyboard/translate.rs index 13f59a5d..64c241f7 100644 --- a/src/backend/wayland/handlers/keyboard/translate.rs +++ b/src/backend/wayland/handlers/keyboard/translate.rs @@ -21,6 +21,7 @@ pub(in crate::backend::wayland) fn keysym_to_key(keysym: Keysym) -> Key { Keysym::Shift_L | Keysym::Shift_R => Key::Shift, Keysym::Control_L | Keysym::Control_R => Key::Ctrl, Keysym::Alt_L | Keysym::Alt_R => Key::Alt, + Keysym::Super_L | Keysym::Super_R | Keysym::Hyper_L | Keysym::Hyper_R => Key::Super, Keysym::Menu => Key::Menu, Keysym::F1 => Key::F1, Keysym::F2 => Key::F2, @@ -37,3 +38,17 @@ pub(in crate::backend::wayland) fn keysym_to_key(keysym: Keysym) -> Key { _ => keysym.key_char().map_or(Key::Unknown, Key::Char), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn super_and_hyper_keysyms_map_to_the_super_modifier() { + assert_eq!(keysym_to_key(Keysym::Super_L), Key::Super); + assert_eq!(keysym_to_key(Keysym::Super_R), Key::Super); + assert_eq!(keysym_to_key(Keysym::Hyper_L), Key::Super); + assert_eq!(keysym_to_key(Keysym::Hyper_R), Key::Super); + assert_eq!(keysym_to_key(Keysym::Alt_L), Key::Alt); + } +} diff --git a/src/backend/wayland/input_monitor/translate.rs b/src/backend/wayland/input_monitor/translate.rs index 8014cbdb..7ed1c436 100644 --- a/src/backend/wayland/input_monitor/translate.rs +++ b/src/backend/wayland/input_monitor/translate.rs @@ -24,6 +24,7 @@ pub(super) fn modifiers_from_xkb(state: &xkb::State) -> Modifiers { shift: state.mod_name_is_active(xkb::MOD_NAME_SHIFT, xkb::STATE_MODS_EFFECTIVE), ctrl: state.mod_name_is_active(xkb::MOD_NAME_CTRL, xkb::STATE_MODS_EFFECTIVE), alt: state.mod_name_is_active(xkb::MOD_NAME_ALT, xkb::STATE_MODS_EFFECTIVE), + logo: state.mod_name_is_active(xkb::MOD_NAME_LOGO, xkb::STATE_MODS_EFFECTIVE), // Tab is a drag modifier the overlay tracks itself; it is not an xkb // modifier and never contributes to a chord label. tab: false, diff --git a/src/config/keybindings/binding.rs b/src/config/keybindings/binding.rs index 6f3baa84..b6bc898c 100644 --- a/src/config/keybindings/binding.rs +++ b/src/config/keybindings/binding.rs @@ -8,6 +8,8 @@ pub struct KeyBinding { pub ctrl: bool, pub shift: bool, pub alt: bool, + /// Super / Meta / Windows (Wayland logo modifier). Displayed as `Super`. + pub logo: bool, } /// Equality and hashing ignore the key name's case, exactly like [`Self::matches`] @@ -22,6 +24,7 @@ impl PartialEq for KeyBinding { && self.ctrl == other.ctrl && self.shift == other.shift && self.alt == other.alt + && self.logo == other.logo } } @@ -31,6 +34,7 @@ impl Hash for KeyBinding { self.ctrl.hash(state); self.shift.hash(state); self.alt.hash(state); + self.logo.hash(state); } } @@ -90,7 +94,7 @@ pub fn suggest_key_name(key: &str) -> Option { if let Some((head, rest)) = key.split_once('+') { // The parser only leaves a `+` in the key when a segment was not a // modifier it knows, so the head is almost always a typo for one. - let canonical = ["Ctrl", "Shift", "Alt"] + let canonical = ["Ctrl", "Shift", "Alt", "Super"] .into_iter() .find(|modifier| within_one_edit(&head.to_lowercase(), &modifier.to_lowercase()))?; return Some(format!("{canonical}+{rest}")); @@ -165,6 +169,7 @@ impl KeyBinding { let mut ctrl = false; let mut shift = false; let mut alt = false; + let mut logo = false; let mut key_parts = Vec::new(); // Process each part, checking if it's a modifier or the actual key @@ -173,6 +178,7 @@ impl KeyBinding { "ctrl" | "control" => ctrl = true, "shift" => shift = true, "alt" => alt = true, + "super" | "meta" | "logo" | "win" | "windows" => logo = true, _ => { // Not a modifier, so it's part of the key key_parts.push(part); @@ -197,6 +203,7 @@ impl KeyBinding { ctrl, shift, alt, + logo, }) } else { Ok(Self { @@ -204,16 +211,18 @@ impl KeyBinding { ctrl, shift, alt, + logo, }) } } /// Check if this keybinding matches the current input state. - pub fn matches(&self, key: &str, ctrl: bool, shift: bool, alt: bool) -> bool { + pub fn matches(&self, key: &str, ctrl: bool, shift: bool, alt: bool, logo: bool) -> bool { self.key.eq_ignore_ascii_case(key) && self.ctrl == ctrl && self.shift == shift && self.alt == alt + && self.logo == logo } } @@ -229,6 +238,9 @@ impl fmt::Display for KeyBinding { if self.alt { parts.push("Alt"); } + if self.logo { + parts.push("Super"); + } parts.push(self.key.as_str()); write!(f, "{}", parts.join("+")) } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index cc533c7d..641309fd 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -93,6 +93,38 @@ fn bindings_differing_only_in_key_case_are_the_same_binding() { fn test_parse_requires_non_modifier_key() { let err = KeyBinding::parse("Ctrl+Shift").unwrap_err(); assert!(err.contains("No key specified")); + let super_only = KeyBinding::parse("Super").unwrap_err(); + assert!(super_only.contains("No key specified")); +} + +#[test] +fn super_aliases_parse_display_equal_and_match_as_super() { + let canonical = KeyBinding::parse("Super+X").unwrap(); + assert!(canonical.logo); + assert!(!canonical.ctrl); + assert_eq!(canonical.to_string(), "Super+X"); + assert!(canonical.matches("x", false, false, false, true)); + assert!(!canonical.matches("x", false, false, false, false)); + assert!(!canonical.matches("x", true, false, false, false)); + + for alias in ["Meta+X", "Logo+X", "Win+X", "Windows+X", "meta+x", "WIN+X"] { + let parsed = KeyBinding::parse(alias).unwrap(); + assert_eq!(parsed, canonical, "{alias} must equal Super+X"); + assert!( + parsed.to_string().starts_with("Super+"), + "{alias} displays Super, got {parsed}" + ); + } + + let shifted = KeyBinding::parse("Shift+Super+F5").unwrap(); + assert_eq!(shifted.to_string(), "Shift+Super+F5"); + assert_eq!(KeyBinding::parse("Super+Shift+F5").unwrap(), shifted); + + let mut map = std::collections::HashMap::new(); + map.insert(canonical.clone(), ()); + assert!(map.contains_key(&KeyBinding::parse("Meta+X").unwrap())); + assert!(!map.contains_key(&KeyBinding::parse("Ctrl+X").unwrap())); + assert!(!map.contains_key(&KeyBinding::parse("X").unwrap())); } #[test] @@ -104,11 +136,11 @@ fn test_display_normalizes_modifier_order() { #[test] fn test_matches() { let binding = KeyBinding::parse("Ctrl+Shift+W").unwrap(); - assert!(binding.matches("W", true, true, false)); - assert!(binding.matches("w", true, true, false)); // Case insensitive - assert!(!binding.matches("W", false, true, false)); // Missing ctrl - assert!(!binding.matches("W", true, false, false)); // Missing shift - assert!(!binding.matches("A", true, true, false)); // Wrong key + assert!(binding.matches("W", true, true, false, false)); + assert!(binding.matches("w", true, true, false, false)); // Case insensitive + assert!(!binding.matches("W", false, true, false, false)); // Missing ctrl + assert!(!binding.matches("W", true, false, false, false)); // Missing shift + assert!(!binding.matches("A", true, true, false, false)); // Wrong key } #[test] @@ -260,8 +292,8 @@ fn test_parse_trims_surrounding_whitespace() { #[test] fn test_matches_requires_exact_alt_state() { let binding = KeyBinding::parse("Alt+X").unwrap(); - assert!(binding.matches("x", false, false, true)); - assert!(!binding.matches("x", false, false, false)); + assert!(binding.matches("x", false, false, true, false)); + assert!(!binding.matches("x", false, false, false, false)); } #[test] @@ -823,6 +855,12 @@ fn a_misspelled_modifier_is_reported_with_a_suggestion() { parsed.key ); assert_eq!(suggest_key_name(&parsed.key).as_deref(), Some("Ctrl+Z")); + + let super_typo = KeyBinding::parse("Supre+X").expect("the string still parses"); + assert_eq!( + suggest_key_name(&super_typo.key).as_deref(), + Some("Super+X") + ); } #[test] @@ -858,6 +896,8 @@ fn ordinary_bindings_are_not_flagged() { "Escape", "F10", "Ctrl+Shift+P", + "Super+X", + "Meta+X", "a", "/", "PageUp", diff --git a/src/config/types/input_hud.rs b/src/config/types/input_hud.rs index 3be8badf..5c4f8e69 100644 --- a/src/config/types/input_hud.rs +++ b/src/config/types/input_hud.rs @@ -21,7 +21,7 @@ pub struct InputHudConfig { #[serde(default = "default_input_hud_show_mouse")] pub show_mouse: bool, - /// Show taps of bare modifiers (Ctrl, Shift, Alt) as their own chips + /// Show taps of bare modifiers (Ctrl, Shift, Alt, Super) as their own chips #[serde(default = "default_input_hud_show_bare_modifiers")] pub show_bare_modifiers: bool, diff --git a/src/input/events.rs b/src/input/events.rs index 4c858422..ae6befa7 100644 --- a/src/input/events.rs +++ b/src/input/events.rs @@ -43,6 +43,8 @@ pub enum Key { Ctrl, /// Alt modifier Alt, + /// Super / Meta / Windows (Wayland logo) modifier + Super, /// Context menu/application key Menu, /// F1 function key (help) diff --git a/src/input/modifiers.rs b/src/input/modifiers.rs index 6f844452..b1acd2c9 100644 --- a/src/input/modifiers.rs +++ b/src/input/modifiers.rs @@ -189,8 +189,9 @@ impl DragToolBindings { /// Keyboard modifier state. /// -/// Tracks which modifier keys (Shift, Ctrl, Alt, Tab) are currently pressed. -/// Used to determine the active drawing tool and handle keyboard shortcuts. +/// Tracks which modifier keys (Shift, Ctrl, Alt, Super, Tab) are currently pressed. +/// Super participates in shortcut matching; it does not change drag-tool priority. +/// Tab is a drag-tool modifier only. #[derive(Debug, Clone, Copy)] pub struct Modifiers { /// Shift key pressed @@ -199,6 +200,8 @@ pub struct Modifiers { pub ctrl: bool, /// Alt key pressed pub alt: bool, + /// Super / Meta / Windows (Wayland logo) key pressed + pub logo: bool, /// Tab key pressed pub tab: bool, } @@ -216,6 +219,7 @@ impl Modifiers { shift: false, ctrl: false, alt: false, + logo: false, tab: false, } } @@ -242,6 +246,12 @@ impl Modifiers { } } + /// Ctrl, Shift, Alt, and Super — the modifiers a keybinding can carry. + /// Tab is a drag-tool modifier only. + pub fn has_shortcut_modifier(self) -> bool { + self.ctrl || self.shift || self.alt || self.logo + } + /// Determines which drawing tool is active based on current modifier state and drag mapping. pub fn current_tool_with_bindings(&self, bindings: DragToolBindings) -> Tool { bindings.tool_for_modifier(self.active_drag_modifier()) diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index 3975d9fa..08d937e9 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -13,6 +13,7 @@ impl InputState { Key::Shift => self.modifiers.shift = true, Key::Ctrl => self.modifiers.ctrl = true, Key::Alt => self.modifiers.alt = true, + Key::Super => self.modifiers.logo = true, Key::Tab => self.modifiers.tab = true, _ => return false, } diff --git a/src/input/state/actions/key_release.rs b/src/input/state/actions/key_release.rs index f3a5acc8..ebccedf3 100644 --- a/src/input/state/actions/key_release.rs +++ b/src/input/state/actions/key_release.rs @@ -8,11 +8,15 @@ impl InputState { /// Currently only tracks modifier key releases to update the modifier state. pub fn on_key_release(&mut self, key: Key) { self.release_command_palette_repeat_key(key); - let was_modifier = matches!(key, Key::Shift | Key::Ctrl | Key::Alt | Key::Tab); + let was_modifier = matches!( + key, + Key::Shift | Key::Ctrl | Key::Alt | Key::Super | Key::Tab + ); match key { Key::Shift => self.modifiers.shift = false, Key::Ctrl => self.modifiers.ctrl = false, Key::Alt => self.modifiers.alt = false, + Key::Super => self.modifiers.logo = false, Key::Tab => self.modifiers.tab = false, _ => {} } diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs index 9fbd73fb..c3a840c8 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -12,6 +12,7 @@ impl InputState { self.modifiers.shift = false; self.modifiers.ctrl = false; self.modifiers.alt = false; + self.modifiers.logo = false; self.modifiers.tab = false; if matches!(self.state, DrawingState::Idle) { self.sync_current_settings_from_active_tool(); @@ -22,10 +23,11 @@ impl InputState { /// /// This lets us correct cases where a key release event was missed but the compositor's /// authoritative modifier state is still accurate. - pub fn sync_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool) { + pub fn sync_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool, logo: bool) { self.modifiers.shift = shift; self.modifiers.ctrl = ctrl; self.modifiers.alt = alt; + self.modifiers.logo = logo; // Tab has no direct compositor flag; leave it unchanged. if matches!(self.state, DrawingState::Idle) { self.sync_current_settings_from_active_tool(); diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index f4ff8f81..aee4cbf0 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -145,14 +145,14 @@ impl InputState { match key { // Every modifier a `KeyBinding` can carry is tracked here, because - // the shortcut controls and the capture modal read all three: + // the shortcut controls and the capture modal read all four: // without this arm the palette would swallow the press, Ctrl+Shift+E - // could never be told apart from Ctrl+E, and an Alt already held + // could never be told apart from Ctrl+E, and a Super already held // when capture began would be missing from the chord that gets // saved. `on_key_release` clears them again on the way out, // whatever is open at the time. `Key::Tab` stays with the catch-all // below: it is the pointer-drag modifier, not part of a chord. - Key::Ctrl | Key::Shift | Key::Alt => { + Key::Ctrl | Key::Shift | Key::Alt | Key::Super => { self.handle_modifier_key_press(key); true } @@ -287,6 +287,7 @@ impl InputState { ctrl: self.modifiers.ctrl, shift: self.modifiers.shift, alt: self.modifiers.alt, + logo: self.modifiers.logo, } .to_string(); self.keybinding_capture_action = None; diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index f5cfd4d0..6fb59a47 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -263,6 +263,33 @@ mod tests { assert!(!state.modifiers.alt); } + #[test] + fn super_held_through_the_palette_reaches_the_captured_chord() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "pen tool".to_string(); + + assert!(state.handle_command_palette_key(crate::input::Key::Super)); + assert!(state.modifiers.logo, "the palette must track Super itself"); + assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); + assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert_eq!(state.keybinding_capture_action, Some(Action::SelectPenTool)); + + assert!(state.handle_command_palette_key(crate::input::Key::Char('k'))); + assert_eq!( + state.take_pending_keybinding_edits(), + vec![crate::input::state::KeybindingEditRequest { + action: Action::SelectPenTool, + operation: crate::input::state::KeybindingEditOperation::Replace(vec![ + "Ctrl+Super+K".to_string() + ]), + }] + ); + + state.on_key_release(crate::input::Key::Super); + assert!(!state.modifiers.logo); + } + #[test] fn palette_shortcut_controls_request_delete_and_reset() { let mut state = make_state(); diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index 8a641b55..5d685e64 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -13,6 +13,7 @@ impl InputState { self.modifiers.ctrl, self.modifiers.shift, self.modifiers.alt, + self.modifiers.logo, ) { return Some(*action); } diff --git a/src/input/state/input_hud/label.rs b/src/input/state/input_hud/label.rs index fdeef91f..c0f3372f 100644 --- a/src/input/state/input_hud/label.rs +++ b/src/input/state/input_hud/label.rs @@ -8,7 +8,7 @@ use crate::input::events::Key; use crate::input::modifiers::Modifiers; -/// Modifier prefix in the canonical `Ctrl+Shift+Alt+` order the keybinding +/// Modifier prefix in the canonical `Ctrl+Shift+Alt+Super+` order the keybinding /// parser and `KeyBinding`'s `Display` both use. Empty when nothing is held. pub(crate) fn modifier_prefix(modifiers: Modifiers) -> String { let mut prefix = String::new(); @@ -21,13 +21,16 @@ pub(crate) fn modifier_prefix(modifiers: Modifiers) -> String { if modifiers.alt { prefix.push_str("Alt+"); } + if modifiers.logo { + prefix.push_str("Super+"); + } prefix } /// Whether a key is a bare modifier press (its own chip only when /// `show_bare_modifiers` is on). pub fn is_bare_modifier(key: Key) -> bool { - matches!(key, Key::Shift | Key::Ctrl | Key::Alt) + matches!(key, Key::Shift | Key::Ctrl | Key::Alt | Key::Super) } /// Display name of a single key without modifiers, or `None` for keys the HUD @@ -58,6 +61,7 @@ pub(crate) fn key_display_name(key: Key) -> Option { Key::Shift => "Shift", Key::Ctrl => "Ctrl", Key::Alt => "Alt", + Key::Super => "Super", Key::Menu => "Menu", Key::F1 => "F1", Key::F2 => "F2", @@ -102,11 +106,12 @@ pub fn input_hud_scroll_label(up: bool, modifiers: Modifiers) -> String { mod tests { use super::*; - fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers { + fn mods(ctrl: bool, shift: bool, alt: bool, logo: bool) -> Modifiers { Modifiers { shift, ctrl, alt, + logo, tab: false, } } @@ -114,35 +119,43 @@ mod tests { #[test] fn chord_labels_use_the_canonical_modifier_order() { assert_eq!( - input_hud_key_label(Key::Char('z'), mods(true, true, false)).as_deref(), + input_hud_key_label(Key::Char('z'), mods(true, true, false, false)).as_deref(), Some("Ctrl+Shift+Z") ); assert_eq!( - input_hud_key_label(Key::F10, mods(false, false, true)).as_deref(), + input_hud_key_label(Key::F10, mods(false, false, true, false)).as_deref(), Some("Alt+F10") ); assert_eq!( - input_hud_key_label(Key::Char('a'), mods(false, false, false)).as_deref(), + input_hud_key_label(Key::Char('a'), mods(false, false, false, false)).as_deref(), Some("A") ); + assert_eq!( + input_hud_key_label(Key::Char('x'), mods(false, false, false, true)).as_deref(), + Some("Super+X") + ); + assert_eq!( + input_hud_key_label(Key::F5, mods(false, true, false, true)).as_deref(), + Some("Shift+Super+F5") + ); } #[test] fn special_keys_use_the_help_overlay_names_and_arrow_glyphs() { assert_eq!( - input_hud_key_label(Key::Space, mods(false, false, false)).as_deref(), + input_hud_key_label(Key::Space, mods(false, false, false, false)).as_deref(), Some("Space") ); assert_eq!( - input_hud_key_label(Key::Escape, mods(false, false, false)).as_deref(), + input_hud_key_label(Key::Escape, mods(false, false, false, false)).as_deref(), Some("Esc") ); assert_eq!( - input_hud_key_label(Key::Up, mods(false, false, false)).as_deref(), + input_hud_key_label(Key::Up, mods(false, false, false, false)).as_deref(), Some("\u{2191}") ); assert_eq!( - input_hud_key_label(Key::Left, mods(false, false, false)).as_deref(), + input_hud_key_label(Key::Left, mods(false, false, false, false)).as_deref(), Some("\u{2190}") ); } @@ -150,33 +163,39 @@ mod tests { #[test] fn bare_modifier_presses_do_not_prefix_themselves() { assert_eq!( - input_hud_key_label(Key::Ctrl, mods(true, false, false)).as_deref(), + input_hud_key_label(Key::Ctrl, mods(true, false, false, false)).as_deref(), Some("Ctrl") ); assert_eq!( - input_hud_key_label(Key::Shift, mods(true, true, false)).as_deref(), + input_hud_key_label(Key::Shift, mods(true, true, false, false)).as_deref(), Some("Shift") ); + assert_eq!( + input_hud_key_label(Key::Super, mods(false, false, false, true)).as_deref(), + Some("Super") + ); } #[test] fn unmapped_keys_are_skipped() { - assert!(input_hud_key_label(Key::Unknown, mods(false, false, false)).is_none()); - assert!(input_hud_key_label(Key::Char('\u{1}'), mods(false, false, false)).is_none()); + assert!(input_hud_key_label(Key::Unknown, mods(false, false, false, false)).is_none()); + assert!( + input_hud_key_label(Key::Char('\u{1}'), mods(false, false, false, false)).is_none() + ); } #[test] fn mouse_and_scroll_labels_carry_the_modifier_prefix() { assert_eq!( - input_hud_mouse_label("Click", mods(true, false, false)), + input_hud_mouse_label("Click", mods(true, false, false, false)), "Ctrl+Click" ); assert_eq!( - input_hud_scroll_label(true, mods(false, false, false)), + input_hud_scroll_label(true, mods(false, false, false, false)), "Scroll \u{2191}" ); assert_eq!( - input_hud_scroll_label(false, mods(false, true, false)), + input_hud_scroll_label(false, mods(false, true, false, false)), "Shift+Scroll \u{2193}" ); } diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs index 9eb17cbb..e3657cc9 100644 --- a/src/input/state/interaction/adapters/keyboard.rs +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -241,9 +241,7 @@ pub(crate) fn handle_return_edit_selected_text_key( key: Key, ) -> Option { if matches!(key, Key::Return) - && !state.modifiers.ctrl - && !state.modifiers.shift - && !state.modifiers.alt + && !state.modifiers.has_shortcut_modifier() && matches!(state.state, DrawingState::Idle) { if state.edit_selected_text() { diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs index b533b583..d9286e8a 100644 --- a/src/input/state/tests/action_bindings.rs +++ b/src/input/state/tests/action_bindings.rs @@ -78,3 +78,25 @@ fn find_action_respects_exact_modifier_matches() { state.modifiers.ctrl = false; assert_eq!(state.find_action("z"), None); } + +#[test] +fn find_action_treats_super_as_a_distinct_modifier() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.core.exit = vec!["Super+X".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + + state.modifiers.logo = true; + assert_eq!(state.find_action("x"), Some(Action::Exit)); + assert_eq!(state.find_action("X"), Some(Action::Exit)); + + state.modifiers.logo = false; + assert_eq!(state.find_action("x"), None); + + state.modifiers.ctrl = true; + assert_eq!(state.find_action("x"), None); + + state.modifiers.logo = true; + state.reset_modifiers(); + assert!(!state.modifiers.logo); + assert!(!state.modifiers.ctrl); +} diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs index 157cb6f5..a8cd68ac 100644 --- a/src/input/state/tests/text_input/actions.rs +++ b/src/input/state/tests/text_input/actions.rs @@ -40,12 +40,14 @@ fn capture_action_sets_pending_and_clears_modifiers() { state.modifiers.ctrl = true; state.modifiers.shift = true; state.modifiers.alt = true; + state.modifiers.logo = true; state.handle_action(Action::CaptureClipboardFull); assert!(!state.modifiers.ctrl); assert!(!state.modifiers.shift); assert!(!state.modifiers.alt); + assert!(!state.modifiers.logo); assert_eq!( state.take_pending_backend_action(), diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 65f66b85..2e4f1e70 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -475,6 +475,24 @@ fn reset_modifiers_resyncs_current_settings_to_base_tool() { assert_eq!(state.current_thickness, pen_thickness); } +#[test] +fn super_does_not_change_drag_tool_priority() { + let mut state = create_test_input_state(); + state.on_key_press(Key::Super); + assert!(state.modifiers.logo); + assert_eq!(state.active_tool(), Tool::Pen); + + state.on_key_press(Key::Shift); + assert_eq!(state.active_tool(), Tool::Line); + + state.on_key_press(Key::Ctrl); + assert_eq!(state.active_tool(), Tool::Arrow); + + state.on_key_release(Key::Super); + assert!(!state.modifiers.logo); + assert_eq!(state.active_tool(), Tool::Arrow); +} + #[test] fn sync_modifiers_resyncs_current_settings_to_compositor_tool() { let mut state = create_test_input_state(); @@ -497,7 +515,7 @@ fn sync_modifiers_resyncs_current_settings_to_compositor_tool() { assert_eq!(state.current_color, line_color); assert_eq!(state.current_thickness, 14.0); - state.sync_modifiers(false, false, false); + state.sync_modifiers(false, false, false, false); assert_eq!(state.active_tool(), Tool::Pen); assert_eq!(state.current_color, pen_color); assert_eq!(state.current_thickness, pen_thickness); From 24bf7db72e8f1433da8692aa5c4d4b403a8d6505 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 3/7] feat: bind auxiliary mouse and stylus buttons as shortcuts Let Back/Forward/Extra mouse buttons and stylus barrels share the keybinding table so they can be recorded and dispatched without changing primary drawing buttons or silently dropping legacy tablet assignments. --- config.example.toml | 8 +- configurator/README.md | 2 +- .../src/app/pages/keybindings/recorder.rs | 83 +++- configurator/src/app/pages/keybindings/row.rs | 6 +- configurator/src/app/update/mod.rs | 3 + configurator/src/app/update/shortcuts.rs | 37 +- .../src/app/update/shortcuts/tests.rs | 55 ++- configurator/src/messages.rs | 7 +- .../src/models/config/draft/from_config.rs | 6 +- .../src/models/config/to_config/tablet.rs | 2 + .../src/models/keybindings/conflicts.rs | 176 +++++-- configurator/src/models/keybindings/draft.rs | 27 +- configurator/src/models/keybindings/edit.rs | 32 +- configurator/src/models/keybindings/mod.rs | 6 +- configurator/src/models/keybindings/parse.rs | 8 +- .../src/models/keybindings/recording.rs | 159 ++++++- configurator/src/models/mod.rs | 4 +- docs/CONFIG.md | 30 +- src/app/usage.rs | 17 +- .../wayland/backend/state_init/input_state.rs | 6 +- src/backend/wayland/config_edits/tests.rs | 4 +- src/backend/wayland/handlers/pointer/press.rs | 26 +- .../wayland/handlers/pointer/release.rs | 7 + src/backend/wayland/handlers/tablet/frame.rs | 84 +++- src/backend/wayland/session/tests.rs | 4 +- src/backend/wayland/state/keybindings.rs | 4 +- .../state/keybindings/implementation.rs | 6 +- .../implementation/tests/completion.rs | 4 +- .../implementation/tests/preparation.rs | 2 +- src/config/io.rs | 4 +- src/config/keybindings.rs | 5 + src/config/keybindings/AGENTS.md | 3 +- src/config/keybindings/binding.rs | 165 ++++--- src/config/keybindings/config/map/mod.rs | 32 +- src/config/keybindings/shortcut.rs | 446 ++++++++++++++++++ src/config/keybindings/tests.rs | 71 ++- src/config/mod.rs | 3 +- src/config/tests/document.rs | 51 +- src/config/validate/keybindings.rs | 30 +- src/input/state/core/base/state/init.rs | 8 +- src/input/state/core/base/state/modifiers.rs | 1 + src/input/state/core/base/state/structs.rs | 14 +- src/input/state/core/menus/shortcuts.rs | 6 +- src/input/state/core/tour.rs | 8 +- src/input/state/core/utility/actions.rs | 63 ++- src/input/state/mod.rs | 6 +- src/input/state/tests/action_bindings.rs | 55 ++- src/session/tests/helpers.rs | 4 +- src/toolbar_gtk/view/top_bar/tests.rs | 4 +- src/ui/radial_menu/cache.rs | 4 +- src/ui/toolbar/apply/mod.rs | 4 +- src/ui/toolbar/bindings.rs | 10 +- 52 files changed, 1495 insertions(+), 317 deletions(-) create mode 100644 src/config/keybindings/shortcut.rs diff --git a/config.example.toml b/config.example.toml index d05d13fb..15d76d93 100644 --- a/config.example.toml +++ b/config.example.toml @@ -33,8 +33,12 @@ config_revision = 3 [keybindings] # Customize keyboard shortcuts for all actions # Format: key names with modifiers separated by + -# Examples: "Escape", "Ctrl+Z", "Super+X", "Ctrl+Shift+T", "F10" +# Examples: "Escape", "Ctrl+Z", "Super+X", "Ctrl+Shift+T", "F10", +# "MouseBack", "Ctrl+MouseForward", "StylusPrimary" # Super also accepts Meta, Logo, Win, and Windows. +# Auxiliary mouse buttons: MouseBack, MouseForward, MouseExtra1–MouseExtra4. +# Stylus barrel buttons: StylusPrimary, StylusSecondary. +# Left, middle, and right cannot be bound as actions. # Each action can have multiple keybindings (e.g., ["+", "="]) # Exit overlay (or cancel current action) @@ -1360,6 +1364,8 @@ pressure_thickness_scale_step = 0.1 # # Each button can trigger a keybinding action on press (e.g. "toggle_radial_menu"). # Omit action to ignore the button. +# Prefer StylusPrimary / StylusSecondary under [keybindings]; these tablet +# nodes keep working until you move them in the configurator. # Eraser switching is handled automatically via the physical tool type # (controlled by auto_eraser_switch above), not via barrel buttons. diff --git a/configurator/README.md b/configurator/README.md index bf4f0ff7..05e8d1f8 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -69,7 +69,7 @@ if its UI task is no longer observed. - **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components). - **Default color** – toggle between named colors and custom RGB triples. -- **Keybindings** – per-action shortcut chips with press-to-bind recording, per-row reset, and a raw comma-separated text editor. Super/Meta chords record when the desktop delivers them. +- **Keybindings** – per-action shortcut chips with press-to-bind recording for keys, auxiliary mouse buttons, and stylus barrel buttons, plus per-row reset and a raw comma-separated text editor. 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. - **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. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs index 25884b76..0b79a08f 100644 --- a/configurator/src/app/pages/keybindings/recorder.rs +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -5,7 +5,11 @@ use gtk::prelude::*; use crate::messages::Message; use crate::models::KeybindingField; -use crate::models::keybindings::{KeyboardModifiers, super_consumed_hint, waiting_prompt}; +#[cfg(not(feature = "tablet-input"))] +use crate::models::keybindings::tablet_unavailable_hint; +use crate::models::keybindings::{ + KeyboardModifiers, RecorderDeviceKind, super_consumed_hint, waiting_prompt, +}; use super::super::super::state::ConfiguratorApp; use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; @@ -55,6 +59,7 @@ impl RecorderPopover { content.set_margin_end(12); content.append(&prompt); content.append(&hint); + content.append(&device_hint_label()); content.append(&cancel); let popover = gtk::Popover::builder() @@ -65,6 +70,7 @@ impl RecorderPopover { ignore_activating_click(&popover); field_canceled(&popover, field, sender, Message::ShortcutRecordingCanceled); attach_key_controller(&popover, sender); + attach_button_controller(&popover, sender); Self { popover, prompt } } @@ -102,6 +108,81 @@ fn attach_key_controller(popover: >k::Popover, sender: &ComponentSender gtk::Label { + let text = device_hint_text(); + let hint = gtk::Label::builder() + .label(text) + .wrap(true) + .xalign(0.0) + .max_width_chars(36) + .css_classes(["dim-label"]) + .build(); + set_accessible_label(&hint, text); + hint +} + +fn device_hint_text() -> &'static str { + #[cfg(feature = "tablet-input")] + { + "Auxiliary mouse and stylus barrel buttons can be recorded. Left, middle, right, and the stylus tip cannot." + } + #[cfg(not(feature = "tablet-input"))] + { + tablet_unavailable_hint() + } +} + +fn attach_button_controller(popover: >k::Popover, sender: &ComponentSender) { + let ignore = std::rc::Rc::new(std::cell::Cell::new(false)); + { + let ignore = ignore.clone(); + popover.connect_show(move |_| { + ignore.set(true); + }); + } + let sender = sender.clone(); + let controller = gtk::EventControllerLegacy::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.connect_event(move |_, event| { + if event.event_type() != gtk::gdk::EventType::ButtonPress { + return gtk::glib::Propagation::Proceed; + } + if ignore.get() { + ignore.set(false); + return gtk::glib::Propagation::Stop; + } + let Some(button_event) = event.downcast_ref::() else { + return gtk::glib::Propagation::Stop; + }; + let kind = event + .device() + .map(|device| match device.source() { + gtk::gdk::InputSource::Mouse + | gtk::gdk::InputSource::Touchpad + | gtk::gdk::InputSource::Trackpoint => RecorderDeviceKind::Mouse, + gtk::gdk::InputSource::Pen => RecorderDeviceKind::Pen, + _ => RecorderDeviceKind::Other, + }) + .unwrap_or(RecorderDeviceKind::Other); + let modifiers = event.modifier_state(); + let recorded = KeyboardModifiers { + ctrl: modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK), + shift: modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK), + alt: modifiers.contains(gtk::gdk::ModifierType::ALT_MASK), + super_held: modifiers.contains(gtk::gdk::ModifierType::SUPER_MASK) + || modifiers.contains(gtk::gdk::ModifierType::META_MASK) + || modifiers.contains(gtk::gdk::ModifierType::HYPER_MASK), + }; + sender.input(Message::ShortcutRecorderButton( + button_event.button(), + kind, + recorded, + )); + gtk::glib::Propagation::Stop + }); + popover.add_controller(controller); +} + fn gdk_keyval(key: gtk::gdk::Key) -> u32 { use gtk::glib::translate::IntoGlib; key.into_glib() diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index c5e7ffbc..199e20df 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -2,7 +2,7 @@ use relm4::prelude::*; use relm4::{adw, gtk}; use adw::prelude::*; -use wayscriber::config::KeyBinding; +use wayscriber::config::ShortcutTrigger; use crate::messages::Message; use crate::models::KeybindingField; @@ -249,7 +249,7 @@ fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { fn sync_chips( flow: >k::FlowBox, field: KeybindingField, - bindings: &[KeyBinding], + bindings: &[ShortcutTrigger], sender: &ComponentSender, ) { clear_flow(flow); @@ -266,7 +266,7 @@ fn clear_flow(flow: >k::FlowBox) { fn shortcut_chip( field: KeybindingField, - binding: &KeyBinding, + binding: &ShortcutTrigger, sender: &ComponentSender, ) -> gtk::Box { let label_text = binding.to_string(); diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 97b1baa4..c34bedf0 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -251,6 +251,9 @@ impl ConfiguratorApp { Message::ShortcutRecorderKey(keyval, modifiers) => { self.handle_shortcut_recorder_key(keyval, modifiers) } + Message::ShortcutRecorderButton(button, kind, modifiers) => { + self.handle_shortcut_recorder_button(button, kind, modifiers) + } Message::ShortcutRemoved(field, binding) => { self.handle_shortcut_removed(field, binding) } diff --git a/configurator/src/app/update/shortcuts.rs b/configurator/src/app/update/shortcuts.rs index b423165a..50a8f6a4 100644 --- a/configurator/src/app/update/shortcuts.rs +++ b/configurator/src/app/update/shortcuts.rs @@ -3,13 +3,14 @@ #[cfg(test)] mod tests; -use wayscriber::config::KeyBinding; +use wayscriber::config::ShortcutTrigger; use crate::models::KeybindingField; use crate::models::keybindings::{ - AppendOutcome, KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, ShortcutTextEditor, - append_binding, apply_recorded_replace, apply_text_replace, normalize_key_event, - other_claimants, parse_keybindings, remove_binding, reset_field, text_conflicts_for, + AppendOutcome, KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, + ShortcutRecorderState, ShortcutTextEditor, append_binding, apply_recorded_replace, + apply_text_replace, normalize_button_event, normalize_key_event, other_claimants, + parse_keybindings, remove_binding, reset_field, text_conflicts_for, }; use super::super::effects::Effect; @@ -62,7 +63,29 @@ impl ConfiguratorApp { } RecordedKeyboard::Chord(binding) => { self.active_shortcut_recorder = None; - self.commit_recorded_binding(field, binding) + self.commit_recorded_binding(field, binding.into()) + } + } + } + + pub(super) fn handle_shortcut_recorder_button( + &mut self, + button: u32, + kind: RecorderDeviceKind, + modifiers: KeyboardModifiers, + ) -> Vec { + let Some(recorder) = self.active_shortcut_recorder.as_mut() else { + return Vec::new(); + }; + let field = recorder.field; + match normalize_button_event(button, kind, modifiers) { + RecordedDevice::Unsupported { message } => { + recorder.prompt = message; + Vec::new() + } + RecordedDevice::Trigger(trigger) => { + self.active_shortcut_recorder = None; + self.commit_recorded_binding(field, trigger) } } } @@ -70,7 +93,7 @@ impl ConfiguratorApp { pub(super) fn handle_shortcut_removed( &mut self, field: KeybindingField, - binding: KeyBinding, + binding: ShortcutTrigger, ) -> Vec { if self.pending_shortcut_conflict.is_some() { return Vec::new(); @@ -213,7 +236,7 @@ impl ConfiguratorApp { fn commit_recorded_binding( &mut self, field: KeybindingField, - binding: KeyBinding, + binding: ShortcutTrigger, ) -> Vec { let claimants = other_claimants(&self.draft.keybindings, field, &binding); if !claimants.is_empty() { diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index e2478e62..d407375f 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -1,9 +1,9 @@ -use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, KeyBinding}; +use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, ShortcutTrigger}; use crate::app::effects::Effect; use crate::app::state::{ConfiguratorApp, PendingConfirmation, StatusMessage}; use crate::models::keybindings::keyval; -use crate::models::{KeybindingField, KeyboardModifiers, SearchQuery}; +use crate::models::{KeybindingField, KeyboardModifiers, RecorderDeviceKind, SearchQuery}; use crate::test_temp::TempDir; fn status_contains(status: &StatusMessage, needle: &str) -> bool { @@ -124,7 +124,7 @@ fn recording_reset_and_removal_dirty_without_saving() { ); let effects = app.handle_shortcut_removed( KeybindingField::ToggleFloatingBadge, - KeyBinding::parse("F5").expect("parses"), + ShortcutTrigger::parse("F5").expect("parses"), ); assert!(effects.is_empty()); assert_eq!( @@ -278,3 +278,52 @@ fn active_confirmation_canceled_clears_defaults_without_touching_conflicts() { assert!(app.pending_confirmation.is_none()); assert!(app.pending_shortcut_conflict.is_some()); } + +#[test] +fn auxiliary_mouse_button_records_into_the_draft() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_recording_started(KeybindingField::ToggleFloatingBadge); + let effects = app.handle_shortcut_recorder_button( + 8, + RecorderDeviceKind::Mouse, + KeyboardModifiers::default(), + ); + assert!(effects.is_empty()); + assert!(app.active_shortcut_recorder.is_none()); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("MouseBack") + ); +} + +#[cfg(feature = "tablet-input")] +#[test] +fn recording_stylus_primary_prompts_to_move_the_default_legacy_barrel() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + assert_eq!( + app.draft.keybindings.legacy_tablet.stylus_primary, + Some(wayscriber::config::Action::ToggleRadialMenu) + ); + let _ = app.handle_shortcut_recording_started(KeybindingField::Undo); + let _ = app.handle_shortcut_recorder_button( + 2, + RecorderDeviceKind::Pen, + KeyboardModifiers::default(), + ); + let pending = app + .pending_shortcut_conflict + .as_ref() + .expect("legacy barrel is already assigned"); + assert_eq!(pending.replace_label(), "Move Legacy Binding"); + let effects = app.handle_shortcut_conflict_replace_confirmed(); + assert!(effects.is_empty()); + assert_eq!(app.draft.keybindings.legacy_tablet.stylus_primary, None); + assert!( + app.draft + .keybindings + .value_for(KeybindingField::Undo) + .is_some_and(|value| value.contains("StylusPrimary")) + ); +} diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 05497dab..133eecef 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use wayscriber::config::{ConfigDocument, KeyBinding, ToolbarItemId, ToolbarItemOrderGroup}; +use wayscriber::config::{ConfigDocument, ShortcutTrigger, ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, @@ -11,7 +11,7 @@ use crate::models::{ PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, - ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, + RecorderDeviceKind, ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult, SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, @@ -155,7 +155,8 @@ pub enum Message { ShortcutRecordingStarted(KeybindingField), ShortcutRecordingCanceled(KeybindingField), ShortcutRecorderKey(u32, KeyboardModifiers), - ShortcutRemoved(KeybindingField, KeyBinding), + ShortcutRecorderButton(u32, RecorderDeviceKind, KeyboardModifiers), + ShortcutRemoved(KeybindingField, ShortcutTrigger), ShortcutResetRequested(KeybindingField), ShortcutTextEditStarted(KeybindingField), ShortcutTextEditChanged(String), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index c5bc2c93..b41226ce 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -355,7 +355,11 @@ impl ConfigDraft { presets: PresetsDraft::from_config(config), - keybindings: KeybindingsDraft::from_config(&config.keybindings), + keybindings: { + let mut keybindings = KeybindingsDraft::from_config(&config.keybindings); + keybindings.set_legacy_from_config(config); + keybindings + }, } } } diff --git a/configurator/src/models/config/to_config/tablet.rs b/configurator/src/models/config/to_config/tablet.rs index a9c10830..341baaa1 100644 --- a/configurator/src/models/config/to_config/tablet.rs +++ b/configurator/src/models/config/to_config/tablet.rs @@ -38,6 +38,8 @@ impl ConfigDraft { errors, |value| config.tablet.pressure_thickness_scale_step = value, ); + config.tablet.stylus_button.action = self.keybindings.legacy_tablet.stylus_primary; + config.tablet.stylus_button2.action = self.keybindings.legacy_tablet.stylus_secondary; } } diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index be776f75..1cf9d15b 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -1,23 +1,39 @@ //! Draft-wide shortcut conflict lookup and explicit replacement. -use wayscriber::config::KeyBinding; +use wayscriber::config::{Action, ShortcutTrigger, StylusButton, action_label}; use super::draft::KeybindingsDraft; use super::edit::{FieldEditError, append_binding, remove_binding}; use super::field::KeybindingField; use super::parse::parse_keybindings; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + Keybindings, + LegacyTablet, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShortcutClaim { - pub field: KeybindingField, + pub field: Option, pub label: String, + pub source: BindingSource, } impl ShortcutClaim { fn from_field(field: KeybindingField) -> Self { Self { - field, + field: Some(field), label: field.label().to_string(), + source: BindingSource::Keybindings, + } + } + + fn legacy_tablet(action: Action) -> Self { + Self { + field: None, + label: format!("{} (legacy tablet barrel button)", action_label(action)), + source: BindingSource::LegacyTablet, } } } @@ -26,7 +42,7 @@ impl ShortcutClaim { pub enum PendingShortcutConflict { Recorded { target: KeybindingField, - binding: KeyBinding, + binding: ShortcutTrigger, claimants: Vec, }, Text { @@ -48,6 +64,14 @@ impl PendingShortcutConflict { pub fn replace_label(&self) -> &'static str { match self { + Self::Recorded { claimants, .. } + if !claimants.is_empty() + && claimants + .iter() + .all(|claim| claim.source == BindingSource::LegacyTablet) => + { + "Move Legacy Binding" + } Self::Recorded { .. } => "Replace", Self::Text { .. } => "Resolve Conflicts", } @@ -56,13 +80,13 @@ impl PendingShortcutConflict { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextShortcutConflict { - pub binding: KeyBinding, + pub binding: ShortcutTrigger, pub claimants: Vec, } /// 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: &KeyBinding) -> Vec { +pub fn claimants_for(draft: &KeybindingsDraft, binding: &ShortcutTrigger) -> Vec { let mut claims = Vec::new(); for entry in &draft.entries { if parse_keybindings(&entry.value) @@ -71,17 +95,20 @@ pub fn claimants_for(draft: &KeybindingsDraft, binding: &KeyBinding) -> Vec Vec { claimants_for(draft, binding) .into_iter() - .filter(|claim| claim.field != target) + .filter(|claim| claim.field != Some(target)) .collect() } @@ -101,7 +128,7 @@ pub fn field_has_internal_duplicate(draft: &KeybindingsDraft, field: KeybindingF pub fn text_conflicts_for( draft: &KeybindingsDraft, target: KeybindingField, - parsed: &[KeyBinding], + parsed: &[ShortcutTrigger], ) -> Vec { let mut conflicts = Vec::new(); let mut seen = Vec::new(); @@ -133,15 +160,22 @@ pub fn text_conflicts_for( pub fn apply_recorded_replace( draft: &mut KeybindingsDraft, target: KeybindingField, - binding: &KeyBinding, + binding: &ShortcutTrigger, claimants: &[ShortcutClaim], ) -> Result<(), FieldEditError> { let snapshot = draft.clone(); for claim in claimants { - if claim.field == target { + if claim.field == Some(target) { + continue; + } + if claim.source == BindingSource::LegacyTablet { + clear_legacy_for(draft, binding); continue; } - if let Err(error) = remove_binding(draft, claim.field, binding) { + let Some(field) = claim.field else { + continue; + }; + if let Err(error) = remove_binding(draft, field, binding) { *draft = snapshot; return Err(error); } @@ -164,10 +198,17 @@ pub fn apply_text_replace( let snapshot = draft.clone(); for conflict in conflicts { for claim in &conflict.claimants { - if claim.field == target { + if claim.field == Some(target) { + continue; + } + if claim.source == BindingSource::LegacyTablet { + clear_legacy_for(draft, &conflict.binding); continue; } - if let Err(error) = remove_binding(draft, claim.field, &conflict.binding) { + let Some(field) = claim.field else { + continue; + }; + if let Err(error) = remove_binding(draft, field, &conflict.binding) { *draft = snapshot; return Err(error); } @@ -179,16 +220,52 @@ pub fn apply_text_replace( Ok(()) } -pub fn recorded_conflict_prompt(binding: &KeyBinding, claimants: &[ShortcutClaim]) -> String { +pub fn recorded_conflict_prompt(binding: &ShortcutTrigger, claimants: &[ShortcutClaim]) -> String { let mut lines = vec![format!("{binding} is already assigned to:")]; for claim in claimants { lines.push(format!("- {}", claim.label)); } lines.push(String::new()); - lines.push("Replace those assignments?".to_string()); + if claimants + .iter() + .all(|claim| claim.source == BindingSource::LegacyTablet) + && !claimants.is_empty() + { + lines.push( + "Move that legacy barrel-button assignment into this action's shortcuts?".to_string(), + ); + } else { + lines.push("Replace those assignments?".to_string()); + } lines.join("\n") } +fn legacy_action_for(draft: &KeybindingsDraft, binding: &ShortcutTrigger) -> Option { + let ShortcutTrigger::Stylus(trigger) = binding else { + return None; + }; + if trigger.ctrl || trigger.shift || trigger.alt || trigger.logo { + return None; + } + match trigger.button { + StylusButton::Primary => draft.legacy_tablet.stylus_primary, + StylusButton::Secondary => draft.legacy_tablet.stylus_secondary, + } +} + +fn clear_legacy_for(draft: &mut KeybindingsDraft, binding: &ShortcutTrigger) { + let ShortcutTrigger::Stylus(trigger) = binding else { + return; + }; + if trigger.ctrl || trigger.shift || trigger.alt || trigger.logo { + return; + } + match trigger.button { + StylusButton::Primary => draft.legacy_tablet.stylus_primary = None, + StylusButton::Secondary => draft.legacy_tablet.stylus_secondary = None, + } +} + fn text_conflict_prompt(conflicts: &[TextShortcutConflict]) -> String { let mut lines = vec!["These shortcuts conflict:".to_string()]; for conflict in conflicts { @@ -222,12 +299,12 @@ mod tests { draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); draft.set(KeybindingField::Undo, "ctrl+shift+x".to_string()); - let binding = KeyBinding::parse("Ctrl+Shift+X").expect("parses"); + let binding = ShortcutTrigger::parse("Ctrl+Shift+X").expect("parses"); let claimants = claimants_for(&draft, &binding); let fields: Vec<_> = claimants.iter().map(|claim| claim.field).collect(); - assert!(fields.contains(&KeybindingField::ClearCanvas)); - assert!(fields.contains(&KeybindingField::ToggleToolbar)); - assert!(fields.contains(&KeybindingField::Undo)); + assert!(fields.contains(&Some(KeybindingField::ClearCanvas))); + assert!(fields.contains(&Some(KeybindingField::ToggleToolbar))); + assert!(fields.contains(&Some(KeybindingField::Undo))); assert_eq!(claimants.len(), 3); } @@ -235,10 +312,10 @@ mod tests { fn conflict_lookup_treats_meta_and_super_as_the_same_binding() { let mut draft = draft(); draft.set(KeybindingField::Undo, "Meta+X".to_string()); - let binding = KeyBinding::parse("Super+X").expect("parses"); + let binding = ShortcutTrigger::parse("Super+X").expect("parses"); let claimants = claimants_for(&draft, &binding); assert_eq!(claimants.len(), 1); - assert_eq!(claimants[0].field, KeybindingField::Undo); + assert_eq!(claimants[0].field, Some(KeybindingField::Undo)); } #[test] @@ -249,10 +326,10 @@ mod tests { &draft, KeybindingField::ClearCanvas )); - let binding = KeyBinding::parse("E").expect("parses"); + let binding = ShortcutTrigger::parse("E").expect("parses"); let claimants = claimants_for(&draft, &binding); assert_eq!(claimants.len(), 1); - assert_eq!(claimants[0].field, KeybindingField::ClearCanvas); + assert_eq!(claimants[0].field, Some(KeybindingField::ClearCanvas)); } #[test] @@ -263,7 +340,7 @@ mod tests { "F10, F1, Ctrl+Shift+X".to_string(), ); draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+X".to_string()); - let binding = KeyBinding::parse("Ctrl+Shift+X").expect("parses"); + let binding = ShortcutTrigger::parse("Ctrl+Shift+X").expect("parses"); let claimants = other_claimants(&draft, KeybindingField::ClearCanvas, &binding); apply_recorded_replace( &mut draft, @@ -290,11 +367,11 @@ mod tests { let before = draft.clone(); let _pending = PendingShortcutConflict::Recorded { target: KeybindingField::Undo, - binding: KeyBinding::parse("E").expect("parses"), + binding: ShortcutTrigger::parse("E").expect("parses"), claimants: other_claimants( &draft, KeybindingField::Undo, - &KeyBinding::parse("E").expect("parses"), + &ShortcutTrigger::parse("E").expect("parses"), ), }; assert_eq!(draft, before); @@ -307,18 +384,53 @@ mod tests { &draft, KeybindingField::ToggleFloatingBadge, &[ - KeyBinding::parse("E").expect("parses"), - KeyBinding::parse("Ctrl+Z").expect("parses"), - KeyBinding::parse("E").expect("parses"), + ShortcutTrigger::parse("E").expect("parses"), + ShortcutTrigger::parse("Ctrl+Z").expect("parses"), + ShortcutTrigger::parse("E").expect("parses"), ], ); assert_eq!(conflicts.len(), 2); assert_eq!(conflicts[0].binding.to_string(), "E"); assert_eq!( conflicts[0].claimants[0].field, - KeybindingField::ClearCanvas + Some(KeybindingField::ClearCanvas) ); assert_eq!(conflicts[1].binding.to_string(), "Ctrl+Z"); - assert_eq!(conflicts[1].claimants[0].field, KeybindingField::Undo); + assert_eq!(conflicts[1].claimants[0].field, Some(KeybindingField::Undo)); + } + + #[test] + fn legacy_stylus_assignment_conflicts_with_canonical_stylus_trigger() { + let mut draft = draft(); + draft.legacy_tablet.stylus_primary = Some(wayscriber::config::Action::ToggleRadialMenu); + let binding = ShortcutTrigger::parse("StylusPrimary").expect("parses"); + let claimants = other_claimants(&draft, KeybindingField::Undo, &binding); + assert_eq!(claimants.len(), 1); + assert_eq!(claimants[0].source, BindingSource::LegacyTablet); + assert!(claimants[0].label.contains("legacy tablet")); + + let pending = PendingShortcutConflict::Recorded { + target: KeybindingField::Undo, + binding: binding.clone(), + claimants: claimants.clone(), + }; + assert_eq!(pending.replace_label(), "Move Legacy Binding"); + + apply_recorded_replace(&mut draft, KeybindingField::Undo, &binding, &claimants) + .expect("move"); + assert_eq!(draft.legacy_tablet.stylus_primary, None); + assert!( + draft + .value_for(KeybindingField::Undo) + .is_some_and(|value| value.contains("StylusPrimary")) + ); + } + + #[test] + fn modifiered_stylus_trigger_does_not_claim_the_legacy_barrel() { + let mut draft = draft(); + draft.legacy_tablet.stylus_primary = Some(wayscriber::config::Action::ToggleRadialMenu); + let binding = ShortcutTrigger::parse("Ctrl+StylusPrimary").expect("parses"); + assert!(other_claimants(&draft, KeybindingField::Undo, &binding).is_empty()); } } diff --git a/configurator/src/models/keybindings/draft.rs b/configurator/src/models/keybindings/draft.rs index 3e2035da..d9ffae64 100644 --- a/configurator/src/models/keybindings/draft.rs +++ b/configurator/src/models/keybindings/draft.rs @@ -1,12 +1,20 @@ use wayscriber::config::keybindings::KeybindingsConfig; +use wayscriber::config::{Action, Config}; use super::super::error::FormError; use super::field::KeybindingField; use super::parse::parse_keybinding_list; +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LegacyTabletShortcuts { + pub stylus_primary: Option, + pub stylus_secondary: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct KeybindingsDraft { pub entries: Vec, + pub legacy_tablet: LegacyTabletShortcuts, } #[derive(Debug, Clone, PartialEq)] @@ -24,7 +32,24 @@ impl KeybindingsDraft { field, }) .collect(); - Self { entries } + Self { + entries, + legacy_tablet: LegacyTabletShortcuts::default(), + } + } + + pub fn set_legacy_from_config(&mut self, config: &Config) { + #[cfg(feature = "tablet-input")] + { + self.legacy_tablet = LegacyTabletShortcuts { + stylus_primary: config.tablet.stylus_button.action, + stylus_secondary: config.tablet.stylus_button2.action, + }; + } + #[cfg(not(feature = "tablet-input"))] + { + let _ = config; + } } pub fn set(&mut self, field: KeybindingField, value: String) { diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs index cc3c8da7..b53a61e6 100644 --- a/configurator/src/models/keybindings/edit.rs +++ b/configurator/src/models/keybindings/edit.rs @@ -4,7 +4,7 @@ //! when the user explicitly adds, removes, resets, or applies a parsed list. //! Invalid text stays in place until one of those replacements happens. -use wayscriber::config::KeyBinding; +use wayscriber::config::ShortcutTrigger; use super::draft::KeybindingsDraft; use super::field::KeybindingField; @@ -51,7 +51,7 @@ impl FieldEditError { pub fn append_binding( draft: &mut KeybindingsDraft, field: KeybindingField, - binding: &KeyBinding, + binding: &ShortcutTrigger, ) -> Result { let authored = draft.value_for(field).unwrap_or("").to_string(); let parsed = parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; @@ -71,14 +71,14 @@ pub fn append_binding( pub fn remove_binding( draft: &mut KeybindingsDraft, field: KeybindingField, - binding: &KeyBinding, + binding: &ShortcutTrigger, ) -> Result<(), FieldEditError> { let authored = draft.value_for(field).unwrap_or("").to_string(); parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; let mut removed = false; let mut kept = Vec::new(); for part in authored_shortcut_parts(&authored) { - if !removed && KeyBinding::parse(part).is_ok_and(|parsed| parsed == *binding) { + if !removed && ShortcutTrigger::parse(part).is_ok_and(|parsed| parsed == *binding) { removed = true; continue; } @@ -102,7 +102,7 @@ fn apply_parsed_text( draft: &mut KeybindingsDraft, field: KeybindingField, text: &str, -) -> Result, FieldEditError> { +) -> Result, FieldEditError> { let parsed = parse_keybindings(text).map_err(FieldEditError::InvalidText)?; draft.set(field, text.trim().to_string()); Ok(parsed) @@ -136,7 +136,7 @@ pub fn reset_tooltip(defaults: &KeybindingsDraft, field: KeybindingField) -> Str } } -pub fn serialize_bindings(bindings: &[KeyBinding]) -> String { +pub fn serialize_bindings(bindings: &[ShortcutTrigger]) -> String { bindings .iter() .map(ToString::to_string) @@ -160,7 +160,7 @@ mod tests { fn equivalent_spelling_cannot_create_a_duplicate_chip() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Undo, "ctrl+z".to_string()); - let binding = KeyBinding::parse("Ctrl+Z").expect("parses"); + let binding = ShortcutTrigger::parse("Ctrl+Z").expect("parses"); let outcome = append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); assert_eq!(outcome, AppendOutcome::AlreadyPresent); assert_eq!(draft.value_for(KeybindingField::Undo), Some("ctrl+z")); @@ -170,7 +170,7 @@ mod tests { fn removing_one_chip_preserves_siblings_and_authored_spelling() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Redo, "ctrl+shift+z, Ctrl+Y".to_string()); - let binding = KeyBinding::parse("Ctrl+Y").expect("parses"); + let binding = ShortcutTrigger::parse("Ctrl+Y").expect("parses"); remove_binding(&mut draft, KeybindingField::Redo, &binding).expect("remove"); assert_eq!(draft.value_for(KeybindingField::Redo), Some("ctrl+shift+z")); } @@ -178,7 +178,7 @@ mod tests { #[test] fn removing_the_last_chip_unbinds_instead_of_resetting() { let (mut draft, defaults) = draft(); - let binding = KeyBinding::parse("E").expect("parses"); + let binding = ShortcutTrigger::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( @@ -226,7 +226,7 @@ mod tests { fn invalid_raw_text_blocks_parsed_edits_and_stays_visible() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Exit, "Ctrl+Shift".to_string()); - let binding = KeyBinding::parse("F5").expect("parses"); + let binding = ShortcutTrigger::parse("F5").expect("parses"); let error = append_binding(&mut draft, KeybindingField::Exit, &binding) .expect_err("invalid text cannot append"); assert!(error.message().contains("No key specified")); @@ -258,4 +258,16 @@ mod tests { KeybindingField::Redo )); } + + #[test] + fn mouse_and_stylus_strings_append_as_chips() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Undo, "".to_string()); + let binding = ShortcutTrigger::parse("Ctrl+MouseBack").expect("parses"); + append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); + assert_eq!( + draft.value_for(KeybindingField::Undo), + Some("Ctrl+MouseBack") + ); + } } diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index 14e48133..8b49baf7 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -18,9 +18,11 @@ pub use field::KeybindingField; pub(crate) use parse::parse_keybindings; #[cfg(test)] pub(crate) use recording::keyval; +#[cfg(not(feature = "tablet-input"))] +pub use recording::tablet_unavailable_hint; pub use recording::{ - KeyboardModifiers, RecordedKeyboard, ShortcutRecorderState, normalize_key_event, - super_consumed_hint, waiting_prompt, + KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, ShortcutRecorderState, + normalize_button_event, normalize_key_event, super_consumed_hint, waiting_prompt, }; #[cfg(test)] diff --git a/configurator/src/models/keybindings/parse.rs b/configurator/src/models/keybindings/parse.rs index b19ffe6b..455885ea 100644 --- a/configurator/src/models/keybindings/parse.rs +++ b/configurator/src/models/keybindings/parse.rs @@ -1,4 +1,4 @@ -use wayscriber::config::KeyBinding; +use wayscriber::config::ShortcutTrigger; pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { value @@ -11,16 +11,16 @@ pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { pub(crate) fn parse_keybinding_list(value: &str) -> Result, String> { let mut entries = Vec::new(); for part in authored_shortcut_parts(value) { - KeyBinding::parse(part)?; + ShortcutTrigger::parse(part)?; entries.push(part.to_string()); } Ok(entries) } -pub(crate) fn parse_keybindings(value: &str) -> Result, String> { +pub(crate) fn parse_keybindings(value: &str) -> Result, String> { let mut entries = Vec::new(); for part in authored_shortcut_parts(value) { - entries.push(KeyBinding::parse(part)?); + entries.push(ShortcutTrigger::parse(part)?); } Ok(entries) } diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs index 503be463..85e5e9d9 100644 --- a/configurator/src/models/keybindings/recording.rs +++ b/configurator/src/models/keybindings/recording.rs @@ -1,10 +1,13 @@ //! GDK key-event normalization for the configurator shortcut recorder. //! -//! The runtime still matches [`wayscriber::config::KeyBinding`]. This module -//! only translates a keyval plus modifier flags into that type, so the -//! recorder can be tested without a GTK display. +//! The runtime still matches [`wayscriber::config::ShortcutTrigger`]. This +//! module only translates a keyval or button event plus modifier flags into +//! that type, so the recorder can be tested without a GTK display. -use wayscriber::config::KeyBinding; +#[cfg(feature = "tablet-input")] +use wayscriber::config::StylusTrigger; +use wayscriber::config::keybindings::{MAX_POINTER_EXTRA, gdk}; +use wayscriber::config::{KeyBinding, PointerTrigger, ShortcutTrigger}; use super::field::KeybindingField; @@ -55,6 +58,21 @@ pub enum RecordedKeyboard { Unsupported { message: String }, } +/// GTK/GDK input source as the recorder needs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecorderDeviceKind { + Mouse, + Pen, + Other, +} + +/// Result of one recorder pointer/tablet button event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedDevice { + Trigger(ShortcutTrigger), + Unsupported { message: String }, +} + /// Live recorder owned by [`crate::app::state::ConfiguratorApp`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShortcutRecorderState { @@ -112,6 +130,78 @@ pub fn super_consumed_hint() -> &'static str { "If Super never appears, the desktop captured it. Use Edit as Text." } +#[cfg(not(feature = "tablet-input"))] +pub fn tablet_unavailable_hint() -> &'static str { + "Tablet recording is unavailable in this build. Stylus names can still be typed with Edit as Text." +} + +pub fn device_unidentified_message() -> String { + format!( + "This device cannot be identified. Use Edit as Text, or a supported trigger: MouseBack, MouseForward, MouseExtra1–{MAX_POINTER_EXTRA}, StylusPrimary, StylusSecondary." + ) +} + +/// Map a GDK button number, device kind, and modifiers onto a device trigger. +pub fn normalize_button_event( + button: u32, + kind: RecorderDeviceKind, + modifiers: KeyboardModifiers, +) -> RecordedDevice { + match kind { + RecorderDeviceKind::Mouse => normalize_mouse_button(button, modifiers), + RecorderDeviceKind::Pen => normalize_stylus_button(button, modifiers), + RecorderDeviceKind::Other => RecordedDevice::Unsupported { + message: device_unidentified_message(), + }, + } +} + +fn normalize_mouse_button(button: u32, modifiers: KeyboardModifiers) -> RecordedDevice { + match gdk::pointer_button(button) { + Some(pointer) => RecordedDevice::Trigger(ShortcutTrigger::Pointer(PointerTrigger { + button: pointer, + ctrl: modifiers.ctrl, + shift: modifiers.shift, + alt: modifiers.alt, + logo: modifiers.super_held, + })), + None if (1..=3).contains(&button) => RecordedDevice::Unsupported { + message: "Left, middle, and right already own drawing and toolbar input.".to_string(), + }, + None => RecordedDevice::Unsupported { + message: format!( + "Unknown mouse button. Use MouseBack, MouseForward, or MouseExtra1 through MouseExtra{MAX_POINTER_EXTRA}." + ), + }, + } +} + +#[cfg(not(feature = "tablet-input"))] +fn normalize_stylus_button(_button: u32, _modifiers: KeyboardModifiers) -> RecordedDevice { + RecordedDevice::Unsupported { + message: tablet_unavailable_hint().to_string(), + } +} + +#[cfg(feature = "tablet-input")] +fn normalize_stylus_button(button: u32, modifiers: KeyboardModifiers) -> RecordedDevice { + match gdk::stylus_button(button) { + Some(stylus) => RecordedDevice::Trigger(ShortcutTrigger::Stylus(StylusTrigger { + button: stylus, + ctrl: modifiers.ctrl, + shift: modifiers.shift, + alt: modifiers.alt, + logo: modifiers.super_held, + })), + None if button == 1 => RecordedDevice::Unsupported { + message: "The stylus tip cannot be bound as an action.".to_string(), + }, + None => RecordedDevice::Unsupported { + message: "Unknown stylus button. Use StylusPrimary or StylusSecondary.".to_string(), + }, + } +} + pub fn unsupported_key_message() -> &'static str { "This key cannot be recorded. Use Edit as Text if Wayscriber names it." } @@ -461,4 +551,65 @@ mod tests { "Return" ); } + + #[test] + fn mouse_back_and_forward_record_semantic_names() { + match normalize_button_event(8, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { + RecordedDevice::Trigger(trigger) => assert_eq!(trigger.to_string(), "MouseBack"), + other => panic!("expected MouseBack, got {other:?}"), + } + match normalize_button_event( + 9, + RecorderDeviceKind::Mouse, + mods(true, false, false, false), + ) { + RecordedDevice::Trigger(trigger) => { + assert_eq!(trigger.to_string(), "Ctrl+MouseForward"); + } + other => panic!("expected Ctrl+MouseForward, got {other:?}"), + } + match normalize_button_event(1, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { + RecordedDevice::Unsupported { message } => { + assert!(message.contains("Left, middle, and right"), "{message}"); + } + other => panic!("expected primary-button rejection, got {other:?}"), + } + } + + #[test] + fn unidentified_devices_keep_edit_as_text() { + match normalize_button_event(8, RecorderDeviceKind::Other, KeyboardModifiers::default()) { + RecordedDevice::Unsupported { message } => { + assert!(message.contains("Edit as Text"), "{message}"); + assert!(message.contains("MouseBack"), "{message}"); + } + other => panic!("expected unidentified-device message, got {other:?}"), + } + } + + #[cfg(feature = "tablet-input")] + #[test] + fn pen_barrel_buttons_record_stylus_names() { + match normalize_button_event(2, RecorderDeviceKind::Pen, KeyboardModifiers::default()) { + RecordedDevice::Trigger(trigger) => assert_eq!(trigger.to_string(), "StylusPrimary"), + other => panic!("expected StylusPrimary, got {other:?}"), + } + match normalize_button_event(3, RecorderDeviceKind::Pen, mods(false, false, true, false)) { + RecordedDevice::Trigger(trigger) => { + assert_eq!(trigger.to_string(), "Alt+StylusSecondary"); + } + other => panic!("expected Alt+StylusSecondary, got {other:?}"), + } + } + + #[cfg(not(feature = "tablet-input"))] + #[test] + fn pen_buttons_are_unavailable_without_tablet_input() { + match normalize_button_event(2, RecorderDeviceKind::Pen, KeyboardModifiers::default()) { + RecordedDevice::Unsupported { message } => { + assert!(message.contains("unavailable"), "{message}"); + } + other => panic!("expected unavailable tablet message, got {other:?}"), + } + } } diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 61d43578..51f54f4a 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -36,8 +36,8 @@ pub use fields::{ #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; pub use keybindings::{ - KeybindingField, KeyboardModifiers, PendingShortcutConflict, ShortcutRecorderState, - ShortcutTextEditor, + KeybindingField, KeyboardModifiers, PendingShortcutConflict, RecorderDeviceKind, + ShortcutRecorderState, ShortcutTextEditor, }; pub(crate) use search::SearchQuery; pub(crate) use session::SessionCatalogOperation; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 6b374683..ef63d6c6 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1513,6 +1513,7 @@ action = "toggle_radial_menu" - Pressure-to-thickness mapping applies only to pressure-sensitive freehand Pen strokes. Marker/Textmarker, Step Marker, and shape tools keep their selected sizes when used with a stylus. - `stylus_button` is the primary barrel button (`BTN_STYLUS` / 331); `stylus_button2` is the secondary barrel button (`BTN_STYLUS2` / 332). - Barrel button `action` values use normal action names, such as `toggle_radial_menu`, `undo`, and `redo`. Omit `action` to leave a button unbound. +- These nodes are a compatibility source. Prefer `StylusPrimary` / `StylusSecondary` in `[keybindings]`. The configurator can move a legacy assignment into the keybinding list; loading alone never deletes the tablet nodes. ### `[session]` - Session Persistence @@ -1598,12 +1599,20 @@ For end-to-end CLI, overlay, and configurator flows, see [`examples/session-mana Customize keyboard shortcuts for all actions. Each action can have multiple keybindings. For multi-monitor, customize `focus_prev_output` and `focus_next_output` in this section. -A shortcut is a key name with optional modifiers joined by `+`. The modifiers are `Ctrl` -(`Control`), `Shift`, `Alt`, and `Super` (`Meta`, `Logo`, `Win`, `Windows`). Matching, -conflicts, and display use the canonical spelling `Super`. Examples: `Escape`, `Ctrl+Z`, -`Super+X`, `Ctrl+Shift+T`, `F10`. Super chords work when the compositor delivers them; -if the desktop consumes Super before Wayscriber or the configurator sees it, type the -shortcut with Edit as Text. +A shortcut is a key name, auxiliary mouse button, or stylus barrel button with optional +modifiers joined by `+`. The modifiers are `Ctrl` (`Control`), `Shift`, `Alt`, and `Super` +(`Meta`, `Logo`, `Win`, `Windows`). Matching, conflicts, and display use the canonical +spelling `Super`. Examples: `Escape`, `Ctrl+Z`, `Super+X`, `Ctrl+Shift+T`, `F10`, +`MouseBack`, `Ctrl+MouseForward`, `StylusPrimary`. Super chords work when the compositor +delivers them; if the desktop consumes Super before Wayscriber or the configurator sees it, +type the shortcut with Edit as Text. + +Auxiliary mouse buttons `MouseBack`, `MouseForward`, and `MouseExtra1` through `MouseExtra4` +can be bound to actions. Left, middle, and right cannot: they already own drawing and +toolbar input. Stylus barrel buttons use `StylusPrimary` and `StylusSecondary`. Those names +can be recorded in the configurator when the device is identifiable; otherwise type them +with Edit as Text. Existing `[tablet.stylus_button]` / `[tablet.stylus_button2]` assignments +keep working until you move them into the keybinding list with **Move Legacy Binding**. #### How a shortcut you did not write is decided @@ -1936,15 +1945,20 @@ clear_preset_5 = ["Ctrl+5"] **Keybinding Format:** -Keybindings are specified as strings with modifiers and keys separated by `+`: +Keybindings are specified as strings with modifiers and a key or device button separated by `+`: - Simple keys: `"E"`, `"T"`, `"Escape"`, `"F10"` -- With modifiers: `"Ctrl+Z"`, `"Shift+T"`, `"Ctrl+Shift+W"` +- With modifiers: `"Ctrl+Z"`, `"Shift+T"`, `"Ctrl+Shift+W"`, `"Super+X"` +- Auxiliary mouse buttons: `"MouseBack"`, `"MouseForward"`, `"MouseExtra1"` through `"MouseExtra4"` +- Stylus barrel buttons: `"StylusPrimary"`, `"StylusSecondary"` - Special keys: `"Escape"`, `"Return"`, `"Backspace"`, `"Space"`, `"F10"`, `"F11"`, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"`, `"ArrowUp"`, `"ArrowDown"`, `"ArrowLeft"`, `"ArrowRight"`, `"+"`, `"-"`, `"="`, `"_"` +Left, middle, and right mouse buttons cannot be bound as generic actions. + **Supported Modifiers:** - `Ctrl` (or `Control`) - `Shift` - `Alt` +- `Super` (or `Meta`, `Logo`, `Win`, `Windows`) **Modifier Order:** Modifiers can appear in any order - `"Ctrl+Shift+W"`, `"Shift+Ctrl+W"`, and `"Shift+W+Ctrl"` are all equivalent. diff --git a/src/app/usage.rs b/src/app/usage.rs index 43e82c46..df1638b2 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; -use crate::config::{Action, KeyBinding, KeybindingsConfig, action_display_label, action_label}; +use crate::config::{ + Action, KeybindingsConfig, ShortcutTrigger, action_display_label, action_label, +}; use crate::label_format::format_binding_labels; -fn default_action_bindings() -> HashMap> { +fn default_action_bindings() -> HashMap> { match KeybindingsConfig::default().build_action_bindings() { Ok(bindings) => bindings, Err(err) => { @@ -14,7 +16,7 @@ fn default_action_bindings() -> HashMap> { } fn action_binding_labels( - bindings: &HashMap>, + bindings: &HashMap>, action: Action, ) -> Vec { bindings @@ -23,12 +25,15 @@ fn action_binding_labels( .unwrap_or_default() } -fn action_binding_label(bindings: &HashMap>, action: Action) -> String { +fn action_binding_label( + bindings: &HashMap>, + action: Action, +) -> String { format_binding_labels(&action_binding_labels(bindings, action)) } fn action_primary_binding_label( - bindings: &HashMap>, + bindings: &HashMap>, action: Action, ) -> Option { bindings @@ -37,7 +42,7 @@ fn action_primary_binding_label( .map(|binding| binding.to_string()) } -fn color_binding_labels(bindings: &HashMap>) -> String { +fn color_binding_labels(bindings: &HashMap>) -> String { let colors = [ Action::SetColorRed, Action::SetColorGreen, diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 2081e125..3dbf6135 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -1,7 +1,7 @@ use log::warn; use std::collections::HashMap; -use crate::config::{Action, Config, KeyBinding, KeybindingsConfig, QuickColorPalette}; +use crate::config::{Action, Config, KeybindingsConfig, QuickColorPalette, ShortcutTrigger}; use crate::draw::{FontDescriptor, clamp_regular_sides}; use crate::input::{ClickHighlightSettings, DragToolBindings, InputHudSettings, InputState}; @@ -119,7 +119,7 @@ fn build_drag_tool_bindings(config: &Config) -> DragToolBindings { DragToolBindings::from_config(&drag_tools) } -fn build_action_map(config: &Config) -> HashMap { +fn build_action_map(config: &Config) -> HashMap { match config.keybindings.build_action_map() { Ok(map) => map, Err(err) => { @@ -140,7 +140,7 @@ fn build_action_map(config: &Config) -> HashMap { } } -fn build_action_bindings(config: &Config) -> HashMap> { +fn build_action_bindings(config: &Config) -> HashMap> { match config.keybindings.build_action_bindings() { Ok(map) => map, Err(err) => { diff --git a/src/backend/wayland/config_edits/tests.rs b/src/backend/wayland/config_edits/tests.rs index 5c590deb..dd0df5ff 100644 --- a/src/backend/wayland/config_edits/tests.rs +++ b/src/backend/wayland/config_edits/tests.rs @@ -731,14 +731,14 @@ fn an_answered_edit_gives_its_projection_back_even_when_the_write_failed() { /// An input state the way the overlay's own suites build one, so the gestures /// below are recorded by the real code rather than poked into a field. fn test_input_state() -> crate::input::state::InputState { - use crate::config::{Action, BoardsConfig, KeyBinding, PresenterModeConfig}; + use crate::config::{Action, BoardsConfig, PresenterModeConfig, ShortcutTrigger}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode}; use std::collections::HashMap; let mut action_map = HashMap::new(); action_map.insert( - KeyBinding::parse("Escape").expect("a chord this test spelled"), + ShortcutTrigger::parse("Escape").expect("a chord this test spelled"), Action::Exit, ); crate::input::state::InputState::with_defaults( diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index ada2f717..f2971820 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -131,6 +131,10 @@ impl WaylandState { return; } + if self.try_dispatch_pointer_shortcut(button) { + return; + } + if debug_toolbar_drag_logging_enabled() { debug!( "pointer press: button={}, on_toolbar={}, inline_active={}, drag_active={}", @@ -289,6 +293,20 @@ impl WaylandState { self.input_state.needs_redraw = true; } + fn try_dispatch_pointer_shortcut(&mut self, button: u32) -> bool { + let Some(pointer) = crate::config::keybindings::linux::pointer_button(button) else { + return false; + }; + let trigger = self.input_state.pointer_trigger(pointer); + let Some(action) = self.input_state.find_trigger_action(&trigger) else { + return false; + }; + self.input_state.consume_pointer_shortcut_button(button); + debug!("Pointer shortcut {trigger}: dispatching {action:?}"); + self.dispatch_input_action(action); + true + } + /// Click-away dismissal for the top-strip menus/popovers. Defers to the /// canonical [`InputState::close_top_toolbar_menus`] so the click-away set /// stays in lockstep with the keyboard Escape route and the apply-action @@ -314,13 +332,15 @@ impl WaylandState { } /// Input HUD label for a raw pointer button code. The three primary buttons -/// get their spoken names; anything else reports its evdev code so extra mouse -/// buttons stay visible without inventing names for them. +/// get their spoken names; auxiliary buttons use the same semantic names as +/// the shortcut parser; anything else reports its evdev code. fn input_hud_button_label(button: u32) -> String { match button { BTN_LEFT => "Click".to_string(), BTN_RIGHT => "Right Click".to_string(), BTN_MIDDLE => "Middle Click".to_string(), - other => format!("Button {other}"), + other => crate::config::keybindings::linux::pointer_button(other) + .map(|button| button.name()) + .unwrap_or_else(|| format!("Button {other}")), } } diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index bf3df556..f046559d 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -16,6 +16,13 @@ impl WaylandState { inline_active: bool, button: u32, ) { + if self + .input_state + .take_consumed_pointer_shortcut_button(button) + { + return; + } + if self.input_state.ocr_is_active() { if button == BTN_LEFT { let screen_position = if on_toolbar { diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 00f6421b..f98d3526 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -1,14 +1,10 @@ use log::{debug, info}; use crate::backend::wayland::state::{PerfInputSource, WaylandState}; +use crate::config::keybindings::{StylusButton, linux}; use crate::input::MouseButton; use crate::input::state::HelpOverlayPressSource; -/// Linux input event code for the primary stylus barrel button. -const BTN_STYLUS: u32 = 331; -/// Linux input event code for the secondary stylus barrel button. -const BTN_STYLUS2: u32 = 332; - fn modal_blocks_stylus_barrel_actions(input_state: &crate::input::InputState) -> bool { input_state.show_help || input_state.tour_active @@ -19,6 +15,22 @@ fn modal_blocks_stylus_barrel_actions(input_state: &crate::input::InputState) -> || input_state.screen_modal_is_engaged() } +fn stylus_barrel_action( + input_state: &crate::input::InputState, + button: u32, + tablet: &crate::config::TabletInputConfig, +) -> Option { + let stylus = linux::stylus_button(button)?; + let trigger = input_state.stylus_trigger(stylus); + if let Some(action) = input_state.find_trigger_action(&trigger) { + return Some(action); + } + match stylus { + StylusButton::Primary => tablet.stylus_button.action, + StylusButton::Secondary => tablet.stylus_button2.action, + } +} + impl WaylandState { /// Queue a tablet motion axis update until the enclosing tablet frame commits. pub(super) fn queue_stylus_motion(&mut self, x: f64, y: f64) { @@ -280,18 +292,11 @@ impl WaylandState { /// Dispatch the configured action for a stylus barrel button press. fn dispatch_stylus_button_press(&mut self, button: u32) { - let binding = match button { - BTN_STYLUS => self.config.tablet.stylus_button, - BTN_STYLUS2 => self.config.tablet.stylus_button2, - _ => { - debug!("Ignoring unknown stylus button {}", button); - return; - } - }; - - if let Some(action) = binding.action { + if let Some(action) = stylus_barrel_action(&self.input_state, button, &self.config.tablet) { debug!("Stylus button {}: dispatching {:?}", button, action); self.dispatch_input_action(action); + } else if linux::stylus_button(button).is_none() { + debug!("Ignoring unknown stylus button {}", button); } } @@ -310,7 +315,7 @@ impl WaylandState { #[cfg(test)] mod tests { - use super::modal_blocks_stylus_barrel_actions; + use super::{modal_blocks_stylus_barrel_actions, stylus_barrel_action}; use crate::input::state::test_support::make_test_input_state; #[test] @@ -350,4 +355,51 @@ mod tests { state.cancel_eyedropper(); assert!(!modal_blocks_stylus_barrel_actions(&state)); } + + #[test] + fn canonical_stylus_shortcut_wins_over_legacy_tablet_binding() { + use crate::config::keybindings::linux; + use crate::config::{Action, KeybindingsConfig}; + + let mut keybindings = KeybindingsConfig::default(); + keybindings.core.undo = vec!["StylusPrimary".to_string()]; + let action_map = keybindings.build_action_map().expect("map"); + let action_bindings = keybindings.build_action_bindings().expect("bindings"); + let mut state = crate::input::state::test_support::make_test_input_state(); + state.set_keybinding_maps(action_map, action_bindings); + + let mut tablet = crate::config::TabletInputConfig::default(); + tablet.stylus_button.action = Some(Action::ToggleRadialMenu); + + assert_eq!( + stylus_barrel_action(&state, linux::BTN_STYLUS, &tablet), + Some(Action::Undo) + ); + tablet.stylus_button2.action = Some(Action::Redo); + assert_eq!( + stylus_barrel_action(&state, linux::BTN_STYLUS2, &tablet), + Some(Action::Redo) + ); + } + + #[test] + fn unbound_stylus_falls_back_to_legacy_tablet_action() { + use crate::config::Action; + use crate::config::keybindings::linux; + + let state = crate::input::state::test_support::make_test_input_state(); + let mut tablet = crate::config::TabletInputConfig::default(); + tablet.stylus_button.action = Some(Action::ToggleRadialMenu); + tablet.stylus_button2.action = None; + + assert_eq!( + stylus_barrel_action(&state, linux::BTN_STYLUS, &tablet), + Some(Action::ToggleRadialMenu) + ); + assert_eq!( + stylus_barrel_action(&state, linux::BTN_STYLUS2, &tablet), + None + ); + assert_eq!(stylus_barrel_action(&state, 0, &tablet), None); + } } diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index f799bf23..153ff2bb 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::{Action, BoardsConfig, Config, KeyBinding, PresenterModeConfig}; +use crate::config::{Action, BoardsConfig, Config, PresenterModeConfig, ShortcutTrigger}; use crate::draw::{ Color, EraserKind, FontDescriptor, Frame, PageDeleteOutcome, REGULAR_POLYGON_DEFAULT_SIDES, Shape, ShapeId, @@ -105,7 +105,7 @@ fn can_create_probe(parent: &Path) -> bool { fn test_input_state() -> InputState { let mut action_map = HashMap::new(); - action_map.insert(KeyBinding::parse("Escape").unwrap(), Action::Exit); + action_map.insert(ShortcutTrigger::parse("Escape").unwrap(), Action::Exit); InputState::with_defaults( Color { r: 1.0, diff --git a/src/backend/wayland/state/keybindings.rs b/src/backend/wayland/state/keybindings.rs index 23241909..d71fe03f 100644 --- a/src/backend/wayland/state/keybindings.rs +++ b/src/backend/wayland/state/keybindings.rs @@ -56,8 +56,8 @@ use super::super::config_edits::{ }; use super::WaylandState; use crate::config::{ - Action, ConfigEditNotReadBack, ConfigEditOutcome, ConfigEditWrite, KeyBinding, - KeybindingsConfig, ShortcutClaimedOnDisk, action_label, + Action, ConfigEditNotReadBack, ConfigEditOutcome, ConfigEditWrite, KeybindingsConfig, + ShortcutClaimedOnDisk, ShortcutTrigger, action_label, }; use crate::input::state::{ InputState, KeybindingEditOperation, KeybindingEditRequest, Toast, ToastPriority, diff --git a/src/backend/wayland/state/keybindings/implementation.rs b/src/backend/wayland/state/keybindings/implementation.rs index 94de585e..de8764d7 100644 --- a/src/backend/wayland/state/keybindings/implementation.rs +++ b/src/backend/wayland/state/keybindings/implementation.rs @@ -51,7 +51,7 @@ fn apply_keybinding_edit( let claimed = other_bindings.claimed_keys(); let mut requested = HashMap::new(); for binding_text in &bindings { - let binding = KeyBinding::parse(binding_text).map_err(KeybindingEditError::Edit)?; + let binding = ShortcutTrigger::parse(binding_text).map_err(KeybindingEditError::Edit)?; if let Some(existing_action) = claimed.get(&binding) { return Err(KeybindingEditError::Conflict { binding: binding_text.clone(), @@ -265,8 +265,8 @@ fn prepare_keybinding_edit( /// The run's keymap, and the two views built from it, after one edit lands. struct InstalledKeybindings { keybindings: KeybindingsConfig, - action_map: HashMap, - action_bindings: HashMap>, + action_map: HashMap, + action_bindings: HashMap>, } /// Fold one landed edit into the keymap the run holds *now*. diff --git a/src/backend/wayland/state/keybindings/implementation/tests/completion.rs b/src/backend/wayland/state/keybindings/implementation/tests/completion.rs index 1443dc58..9e069603 100644 --- a/src/backend/wayland/state/keybindings/implementation/tests/completion.rs +++ b/src/backend/wayland/state/keybindings/implementation/tests/completion.rs @@ -185,8 +185,8 @@ fn a_second_edits_completion_keeps_the_first_edits_chord() { .bindings_for_action(Action::SelectMarkerTool), Some(&["Ctrl+Alt+Shift+M".to_string()][..]) ); - let pen_chord = KeyBinding::parse("Ctrl+Alt+Shift+P").expect("a parseable chord"); - let marker_chord = KeyBinding::parse("Ctrl+Alt+Shift+M").expect("a parseable chord"); + let pen_chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+P").expect("a parseable chord"); + let marker_chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+M").expect("a parseable chord"); assert_eq!( after_both.action_map.get(&pen_chord), Some(&Action::SelectPenTool) diff --git a/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs b/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs index dfb41cc7..322bfe0e 100644 --- a/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs +++ b/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs @@ -173,7 +173,7 @@ fn an_edited_keymap_still_builds_both_runtime_views() { .build_action_bindings() .expect("action bindings"); - let chord = KeyBinding::parse("Ctrl+Alt+Shift+K").expect("a parseable chord"); + let chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+K").expect("a parseable chord"); assert_eq!(action_map.get(&chord), Some(&Action::ClearCanvas)); assert_eq!( action_bindings.get(&Action::ClearCanvas), diff --git a/src/config/io.rs b/src/config/io.rs index 53edf875..857d1a0f 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,6 +1,6 @@ use super::ColorSpec; use super::action_meta::action_label; -use super::keybindings::{Action, KeyBinding, KeybindingAuthorship}; +use super::keybindings::{Action, KeybindingAuthorship, ShortcutTrigger}; use super::paths::primary_config_dir; use super::types::{PRESET_SLOTS_MAX, ToolPresetConfig}; use super::validate::ConfigValidationReport; @@ -576,7 +576,7 @@ fn refuse_shortcut_claimed_on_disk( .map_err(|error| anyhow!(error))?; let claimed = others.claimed_keys(); for text in bindings { - let binding = KeyBinding::parse(text).map_err(|error| anyhow!(error))?; + let binding = ShortcutTrigger::parse(text).map_err(|error| anyhow!(error))?; if let Some(owner) = claimed.get(&binding) { return Err(anyhow!(ShortcutClaimedOnDisk { binding: text.clone(), diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index e72de5fe..052ffe52 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -7,11 +7,16 @@ mod authorship; mod binding; mod config; mod defaults; +mod shortcut; pub use crate::domain::Action; pub use authorship::KeybindingAuthorship; pub use binding::{KeyBinding, NAMED_KEYS, is_deliverable_key_name, suggest_key_name}; pub use config::{KeybindingConflict, KeybindingsConfig}; +pub use shortcut::{ + MAX_POINTER_EXTRA, PointerButton, PointerTrigger, ShortcutTrigger, StylusButton, StylusTrigger, + gdk, linux, +}; /// The compiled-in default keymap, built once per process. /// diff --git a/src/config/keybindings/AGENTS.md b/src/config/keybindings/AGENTS.md index 2e5761e2..6770b283 100644 --- a/src/config/keybindings/AGENTS.md +++ b/src/config/keybindings/AGENTS.md @@ -6,7 +6,8 @@ ## Architecture - `actions.rs` owns action enums. -- `binding.rs` owns key binding parsing/display. +- `binding.rs` owns keyboard chord parsing/display. +- `shortcut.rs` owns the unified trigger type (keyboard, pointer, stylus). - `defaults/` owns default bindings. - `config/` owns typed keybinding config and per-domain action maps. diff --git a/src/config/keybindings/binding.rs b/src/config/keybindings/binding.rs index b6bc898c..4169ce56 100644 --- a/src/config/keybindings/binding.rs +++ b/src/config/keybindings/binding.rs @@ -146,74 +146,93 @@ fn within_one_edit(a: &str, b: &str) -> bool { true } -impl KeyBinding { - /// Parse a keybinding string like "Ctrl+Shift+W" or "Escape". - /// Modifiers can appear in any order: "Shift+Ctrl+W", "Alt+Shift+Ctrl+W", etc. - /// Supports spaces around '+' (e.g., "Ctrl + Shift + W") - pub fn parse(s: &str) -> Result { - let s = s.trim(); - if s.is_empty() { - return Err("Empty keybinding string".to_string()); - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ChordParts { + pub ctrl: bool, + pub shift: bool, + pub alt: bool, + pub logo: bool, + pub key: String, +} - // Normalize by removing spaces around '+' - let s_normalized = s.replace(" + ", "+").replace("+ ", "+").replace(" +", "+"); +pub(crate) fn parse_chord_parts(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("Empty keybinding string".to_string()); + } - // Split on '+' to get all parts - let parts: Vec<&str> = s_normalized.split('+').collect(); + // Normalize by removing spaces around '+' + let s_normalized = s.replace(" + ", "+").replace("+ ", "+").replace(" +", "+"); + let parts: Vec<&str> = s_normalized.split('+').collect(); + if parts.is_empty() { + return Err("Empty keybinding string".to_string()); + } - if parts.is_empty() { - return Err("Empty keybinding string".to_string()); - } + let mut ctrl = false; + let mut shift = false; + let mut alt = false; + let mut logo = false; + let mut key_parts = Vec::new(); - let mut ctrl = false; - let mut shift = false; - let mut alt = false; - let mut logo = false; - let mut key_parts = Vec::new(); - - // Process each part, checking if it's a modifier or the actual key - for part in parts { - match part.to_lowercase().as_str() { - "ctrl" | "control" => ctrl = true, - "shift" => shift = true, - "alt" => alt = true, - "super" | "meta" | "logo" | "win" | "windows" => logo = true, - _ => { - // Not a modifier, so it's part of the key - key_parts.push(part); - } - } + for part in parts { + match part.to_lowercase().as_str() { + "ctrl" | "control" => ctrl = true, + "shift" => shift = true, + "alt" => alt = true, + "super" | "meta" | "logo" | "win" | "windows" => logo = true, + _ => key_parts.push(part), } + } - // Reconstruct the key from remaining parts (handles cases like "+" being the key) - if key_parts.is_empty() { - return Err(format!("No key specified in: {}", s)); - } + if key_parts.is_empty() { + return Err(format!("No key specified in: {s}")); + } - // Join with '+' to handle the case where the key itself is '+' - // (e.g., "Ctrl+Shift++" becomes ["Ctrl", "Shift", "", ""] with last two being the '+' key) - let key = key_parts.join("+"); - - if key.is_empty() { - // This happens for "Ctrl+Shift++" where we have empty strings after the modifiers - // The key is actually '+' - Ok(Self { - key: "+".to_string(), - ctrl, - shift, - alt, - logo, - }) - } else { - Ok(Self { - key, - ctrl, - shift, - alt, - logo, - }) - } + let key = key_parts.join("+"); + Ok(ChordParts { + ctrl, + shift, + alt, + logo, + key: if key.is_empty() { "+".to_string() } else { key }, + }) +} + +pub(crate) fn format_modifiers( + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, +) -> Vec<&'static str> { + let mut parts = Vec::new(); + if ctrl { + parts.push("Ctrl"); + } + if shift { + parts.push("Shift"); + } + if alt { + parts.push("Alt"); + } + if logo { + parts.push("Super"); + } + parts +} + +impl KeyBinding { + /// Parse a keybinding string like "Ctrl+Shift+W" or "Escape". + /// Modifiers can appear in any order: "Shift+Ctrl+W", "Alt+Shift+Ctrl+W", etc. + /// Supports spaces around '+' (e.g., "Ctrl + Shift + W") + pub fn parse(s: &str) -> Result { + let parts = parse_chord_parts(s)?; + Ok(Self { + key: parts.key, + ctrl: parts.ctrl, + shift: parts.shift, + alt: parts.alt, + logo: parts.logo, + }) } /// Check if this keybinding matches the current input state. @@ -228,20 +247,14 @@ impl KeyBinding { impl fmt::Display for KeyBinding { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut parts: Vec<&str> = Vec::new(); - if self.ctrl { - parts.push("Ctrl"); - } - if self.shift { - parts.push("Shift"); - } - if self.alt { - parts.push("Alt"); - } - if self.logo { - parts.push("Super"); - } - parts.push(self.key.as_str()); - write!(f, "{}", parts.join("+")) + write!( + f, + "{}", + format_modifiers(self.ctrl, self.shift, self.alt, self.logo) + .into_iter() + .chain(std::iter::once(self.key.as_str())) + .collect::>() + .join("+") + ) } } diff --git a/src/config/keybindings/config/map/mod.rs b/src/config/keybindings/config/map/mod.rs index fca2108b..1c0256b5 100644 --- a/src/config/keybindings/config/map/mod.rs +++ b/src/config/keybindings/config/map/mod.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use super::super::{Action, KeyBinding}; +use super::super::{Action, ShortcutTrigger}; use super::types::KeybindingsConfig; mod board; @@ -22,13 +22,13 @@ mod zoom; /// action lists more than once yields a one-element `actions`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeybindingConflict { - binding: KeyBinding, + binding: ShortcutTrigger, actions: Vec, } impl KeybindingConflict { /// The parsed shortcut every listed action claims. - pub fn binding(&self) -> &KeyBinding { + pub fn binding(&self) -> &ShortcutTrigger { &self.binding } @@ -41,11 +41,11 @@ impl KeybindingConflict { #[derive(Default)] struct ConflictLog { entries: Vec, - positions: HashMap, + positions: HashMap, } impl ConflictLog { - fn record(&mut self, binding: &KeyBinding, first: Action, next: Action) { + fn record(&mut self, binding: &ShortcutTrigger, first: Action, next: Action) { match self.positions.get(binding) { Some(&position) => { let actions = &mut self.entries[position].actions; @@ -69,8 +69,8 @@ impl ConflictLog { } struct BindingInserter<'a> { - map: &'a mut HashMap, - ordered: Option<&'a mut HashMap>>, + map: &'a mut HashMap, + ordered: Option<&'a mut HashMap>>, conflicts: Option<&'a mut ConflictLog>, /// Whether a bad binding string or a duplicate key is data rather than a /// failure. Set only for the views that answer "which keys are taken", @@ -79,7 +79,7 @@ struct BindingInserter<'a> { } impl<'a> BindingInserter<'a> { - fn new(map: &'a mut HashMap) -> Self { + fn new(map: &'a mut HashMap) -> Self { Self { map, ordered: None, @@ -89,8 +89,8 @@ impl<'a> BindingInserter<'a> { } fn new_with_order( - map: &'a mut HashMap, - ordered: &'a mut HashMap>, + map: &'a mut HashMap, + ordered: &'a mut HashMap>, ) -> Self { Self { map, @@ -101,7 +101,7 @@ impl<'a> BindingInserter<'a> { } fn new_collecting( - map: &'a mut HashMap, + map: &'a mut HashMap, conflicts: &'a mut ConflictLog, ) -> Self { Self { @@ -112,7 +112,7 @@ impl<'a> BindingInserter<'a> { } } - fn new_tolerant(map: &'a mut HashMap) -> Self { + fn new_tolerant(map: &'a mut HashMap) -> Self { Self { map, ordered: None, @@ -122,7 +122,7 @@ impl<'a> BindingInserter<'a> { } fn insert(&mut self, binding_str: &str, action: Action) -> Result<(), String> { - let binding = match KeyBinding::parse(binding_str) { + let binding = match ShortcutTrigger::parse(binding_str) { Ok(binding) => binding, // A string the parser rejects binds nothing at runtime, so a view // of the keys in effect has nothing to record and nothing to say. @@ -179,7 +179,7 @@ impl KeybindingsConfig { /// Build a lookup map from keybindings to actions for efficient matching. /// Returns an error if any keybinding string is invalid or if duplicates are detected. - pub fn build_action_map(&self) -> Result, String> { + pub fn build_action_map(&self) -> Result, String> { let mut map = HashMap::new(); let mut inserter = BindingInserter::new(&mut map); self.insert_every_binding(&mut inserter)?; @@ -188,7 +188,7 @@ impl KeybindingsConfig { /// Build an ordered list of keybindings per action. /// Returns an error if any keybinding string is invalid or if duplicates are detected. - pub fn build_action_bindings(&self) -> Result>, String> { + pub fn build_action_bindings(&self) -> Result>, String> { let mut map = HashMap::new(); let mut ordered = HashMap::new(); let mut inserter = BindingInserter::new_with_order(&mut map, &mut ordered); @@ -222,7 +222,7 @@ impl KeybindingsConfig { /// claims nothing at runtime either. That keeps an editor able to accept an /// edit to an unrelated action while a tolerated problem sits elsewhere in /// the file (#293). - pub fn claimed_keys(&self) -> HashMap { + pub fn claimed_keys(&self) -> HashMap { let mut map = HashMap::new(); let mut inserter = BindingInserter::new_tolerant(&mut map); // The tolerant inserter reports nothing, so there is no error to diff --git a/src/config/keybindings/shortcut.rs b/src/config/keybindings/shortcut.rs new file mode 100644 index 00000000..ad4a8013 --- /dev/null +++ b/src/config/keybindings/shortcut.rs @@ -0,0 +1,446 @@ +//! Single-trigger shortcuts: keyboard chords, auxiliary pointer buttons, and +//! stylus barrel buttons. +//! +//! Existing keyboard strings keep parsing as [`ShortcutTrigger::Keyboard`]. +//! Pointer and stylus names are reserved and never stored as key names. + +use std::fmt; +use std::hash::{Hash, Hasher}; + +use super::binding::{ + ChordParts, KeyBinding, format_modifiers, parse_chord_parts, suggest_key_name, +}; + +/// Highest `MouseExtraN` index accepted in this release (`MouseExtra1`..=`MouseExtra4`). +pub const MAX_POINTER_EXTRA: u8 = 4; + +/// Auxiliary mouse buttons that can dispatch configured actions. +/// +/// Left, middle, and right stay drawing/UI controls and cannot be bound here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PointerButton { + Back, + Forward, + Extra(u8), +} + +impl PointerButton { + pub fn parse_name(name: &str) -> Result { + if name.eq_ignore_ascii_case("MouseBack") { + return Ok(Self::Back); + } + if name.eq_ignore_ascii_case("MouseForward") { + return Ok(Self::Forward); + } + if let Some(index) = mouse_extra_index(name) { + if (1..=MAX_POINTER_EXTRA).contains(&index) { + return Ok(Self::Extra(index)); + } + return Err(format!( + "Unsupported mouse button `{name}`. Extra buttons are MouseExtra1 through MouseExtra{MAX_POINTER_EXTRA}." + )); + } + if is_primary_mouse_name(name) { + return Err(format!( + "`{name}` cannot be bound as an action. Left, middle, and right already own drawing and toolbar input." + )); + } + if name.to_ascii_lowercase().starts_with("mouse") { + return Err(format!( + "Unknown mouse button `{name}`. Use MouseBack, MouseForward, or MouseExtra1 through MouseExtra{MAX_POINTER_EXTRA}." + )); + } + Err(format!("Unknown mouse button `{name}`.")) + } + + pub fn name(self) -> String { + match self { + Self::Back => "MouseBack".to_string(), + Self::Forward => "MouseForward".to_string(), + Self::Extra(index) => format!("MouseExtra{index}"), + } + } +} + +/// Stylus barrel buttons that can dispatch configured actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StylusButton { + Primary, + Secondary, +} + +impl StylusButton { + pub fn parse_name(name: &str) -> Result { + if name.eq_ignore_ascii_case("StylusPrimary") { + return Ok(Self::Primary); + } + if name.eq_ignore_ascii_case("StylusSecondary") { + return Ok(Self::Secondary); + } + if name.to_ascii_lowercase().starts_with("stylus") { + return Err(format!( + "Unknown stylus button `{name}`. Use StylusPrimary or StylusSecondary." + )); + } + Err(format!("Unknown stylus button `{name}`.")) + } + + pub fn name(self) -> &'static str { + match self { + Self::Primary => "StylusPrimary", + Self::Secondary => "StylusSecondary", + } + } +} + +/// One auxiliary mouse button plus the same modifier set as a keyboard chord. +#[derive(Debug, Clone, Eq)] +pub struct PointerTrigger { + pub button: PointerButton, + pub ctrl: bool, + pub shift: bool, + pub alt: bool, + pub logo: bool, +} + +impl PartialEq for PointerTrigger { + fn eq(&self, other: &Self) -> bool { + self.button == other.button + && self.ctrl == other.ctrl + && self.shift == other.shift + && self.alt == other.alt + && self.logo == other.logo + } +} + +impl Hash for PointerTrigger { + fn hash(&self, state: &mut H) { + self.button.hash(state); + self.ctrl.hash(state); + self.shift.hash(state); + self.alt.hash(state); + self.logo.hash(state); + } +} + +/// One stylus barrel button plus the same modifier set as a keyboard chord. +#[derive(Debug, Clone, Eq)] +pub struct StylusTrigger { + pub button: StylusButton, + pub ctrl: bool, + pub shift: bool, + pub alt: bool, + pub logo: bool, +} + +impl PartialEq for StylusTrigger { + fn eq(&self, other: &Self) -> bool { + self.button == other.button + && self.ctrl == other.ctrl + && self.shift == other.shift + && self.alt == other.alt + && self.logo == other.logo + } +} + +impl Hash for StylusTrigger { + fn hash(&self, state: &mut H) { + self.button.hash(state); + self.ctrl.hash(state); + self.shift.hash(state); + self.alt.hash(state); + self.logo.hash(state); + } +} + +/// One complete shortcut identity used for maps, conflicts, and display. +#[derive(Debug, Clone, Eq)] +pub enum ShortcutTrigger { + Keyboard(KeyBinding), + Pointer(PointerTrigger), + Stylus(StylusTrigger), +} + +impl PartialEq for ShortcutTrigger { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Keyboard(left), Self::Keyboard(right)) => left == right, + (Self::Pointer(left), Self::Pointer(right)) => left == right, + (Self::Stylus(left), Self::Stylus(right)) => left == right, + _ => false, + } + } +} + +impl Hash for ShortcutTrigger { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Keyboard(binding) => binding.hash(state), + Self::Pointer(trigger) => trigger.hash(state), + Self::Stylus(trigger) => trigger.hash(state), + } + } +} + +impl From for ShortcutTrigger { + fn from(binding: KeyBinding) -> Self { + Self::Keyboard(binding) + } +} + +impl ShortcutTrigger { + /// Parse one shortcut string: a keyboard chord or a reserved device name. + pub fn parse(s: &str) -> Result { + let parts = parse_chord_parts(s)?; + if looks_like_pointer_name(&parts.key) { + return Ok(Self::Pointer(PointerTrigger { + button: PointerButton::parse_name(&parts.key)?, + ctrl: parts.ctrl, + shift: parts.shift, + alt: parts.alt, + logo: parts.logo, + })); + } + if looks_like_stylus_name(&parts.key) { + return Ok(Self::Stylus(StylusTrigger { + button: StylusButton::parse_name(&parts.key)?, + ctrl: parts.ctrl, + shift: parts.shift, + alt: parts.alt, + logo: parts.logo, + })); + } + Ok(Self::Keyboard(key_binding_from_parts(parts))) + } + + pub fn as_key_binding(&self) -> Option<&KeyBinding> { + match self { + Self::Keyboard(binding) => Some(binding), + Self::Pointer(_) | Self::Stylus(_) => None, + } + } + + /// Whether an input event can currently deliver this trigger. + pub fn is_deliverable(&self) -> bool { + match self { + Self::Keyboard(binding) => super::binding::is_deliverable_key_name(&binding.key), + Self::Pointer(_) | Self::Stylus(_) => true, + } + } + + pub fn unknown_key_suggestion(&self) -> Option { + match self { + Self::Keyboard(binding) => suggest_key_name(&binding.key), + Self::Pointer(_) | Self::Stylus(_) => None, + } + } + + pub fn unknown_key_name(&self) -> Option { + match self { + Self::Keyboard(binding) => Some(binding.key.clone()), + Self::Pointer(_) | Self::Stylus(_) => None, + } + } +} + +impl fmt::Display for ShortcutTrigger { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keyboard(binding) => write!(f, "{binding}"), + Self::Pointer(trigger) => write_device_binding( + f, + trigger.ctrl, + trigger.shift, + trigger.alt, + trigger.logo, + &trigger.button.name(), + ), + Self::Stylus(trigger) => write_device_binding( + f, + trigger.ctrl, + trigger.shift, + trigger.alt, + trigger.logo, + trigger.button.name(), + ), + } + } +} + +fn write_device_binding( + f: &mut fmt::Formatter<'_>, + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, + name: &str, +) -> fmt::Result { + let mut parts = format_modifiers(ctrl, shift, alt, logo); + parts.push(name); + write!(f, "{}", parts.join("+")) +} + +fn key_binding_from_parts(parts: ChordParts) -> KeyBinding { + KeyBinding { + key: parts.key, + ctrl: parts.ctrl, + shift: parts.shift, + alt: parts.alt, + logo: parts.logo, + } +} + +fn looks_like_pointer_name(name: &str) -> bool { + name.to_ascii_lowercase().starts_with("mouse") +} + +fn looks_like_stylus_name(name: &str) -> bool { + name.to_ascii_lowercase().starts_with("stylus") +} + +fn is_primary_mouse_name(name: &str) -> bool { + name.eq_ignore_ascii_case("MouseLeft") + || name.eq_ignore_ascii_case("MouseMiddle") + || name.eq_ignore_ascii_case("MouseRight") + || name.eq_ignore_ascii_case("MousePrimary") + || name.eq_ignore_ascii_case("MouseSecondary") +} + +fn mouse_extra_index(name: &str) -> Option { + let lowered = name.to_ascii_lowercase(); + let digits = lowered.strip_prefix("mouseextra")?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// Linux evdev / Wayland `wl_pointer.button` codes for auxiliary mice. +pub mod linux { + use super::{PointerButton, StylusButton}; + + pub const BTN_SIDE: u32 = 0x113; + pub const BTN_EXTRA: u32 = 0x114; + pub const BTN_FORWARD: u32 = 0x115; + pub const BTN_BACK: u32 = 0x116; + pub const BTN_TASK: u32 = 0x117; + pub const BTN_EXTRA2: u32 = 0x118; + pub const BTN_EXTRA3: u32 = 0x119; + pub const BTN_EXTRA4: u32 = 0x11a; + pub const BTN_STYLUS: u32 = 0x14b; + pub const BTN_STYLUS2: u32 = 0x14c; + + pub fn pointer_button(code: u32) -> Option { + match code { + BTN_SIDE | BTN_BACK => Some(PointerButton::Back), + BTN_EXTRA | BTN_FORWARD => Some(PointerButton::Forward), + BTN_TASK => Some(PointerButton::Extra(1)), + BTN_EXTRA2 => Some(PointerButton::Extra(2)), + BTN_EXTRA3 => Some(PointerButton::Extra(3)), + BTN_EXTRA4 => Some(PointerButton::Extra(4)), + _ => None, + } + } + + pub fn stylus_button(code: u32) -> Option { + match code { + BTN_STYLUS => Some(StylusButton::Primary), + BTN_STYLUS2 => Some(StylusButton::Secondary), + _ => None, + } + } +} + +/// GTK/GDK 1-based button numbers (X11-style: 8 back, 9 forward, 10+ extras). +pub mod gdk { + use super::{MAX_POINTER_EXTRA, PointerButton, StylusButton}; + + pub fn pointer_button(button: u32) -> Option { + match button { + 8 => Some(PointerButton::Back), + 9 => Some(PointerButton::Forward), + extra if (10..10 + u32::from(MAX_POINTER_EXTRA)).contains(&extra) => { + Some(PointerButton::Extra((extra - 9) as u8)) + } + _ => None, + } + } + + /// Barrel buttons on a GDK tablet/pen source. Tip (1) is not bindable here. + pub fn stylus_button(button: u32) -> Option { + match button { + 2 => Some(StylusButton::Primary), + 3 => Some(StylusButton::Secondary), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keyboard_strings_still_parse_as_keyboard_triggers() { + let trigger = ShortcutTrigger::parse("Ctrl+Shift+X").unwrap(); + assert_eq!(trigger.to_string(), "Ctrl+Shift+X"); + assert!(trigger.as_key_binding().is_some()); + } + + #[test] + fn pointer_and_stylus_names_round_trip() { + for text in [ + "MouseBack", + "Ctrl+MouseForward", + "Shift+Super+MouseExtra1", + "StylusPrimary", + "Alt+StylusSecondary", + ] { + let trigger = ShortcutTrigger::parse(text).unwrap(); + assert_eq!(trigger.to_string(), text); + assert!(trigger.is_deliverable()); + } + } + + #[test] + fn primary_mouse_buttons_are_rejected() { + for name in ["MouseLeft", "MouseMiddle", "MouseRight"] { + let error = ShortcutTrigger::parse(name).unwrap_err(); + assert!(error.contains("cannot be bound"), "{name}: {error}"); + } + } + + #[test] + fn unknown_mouse_and_stylus_names_suggest_the_supported_set() { + let mouse = ShortcutTrigger::parse("MouseThumb").unwrap_err(); + assert!(mouse.contains("MouseBack"), "{mouse}"); + let stylus = ShortcutTrigger::parse("StylusBarrel").unwrap_err(); + assert!(stylus.contains("StylusPrimary"), "{stylus}"); + } + + #[test] + fn linux_and_gdk_codes_share_semantic_names() { + assert_eq!( + linux::pointer_button(linux::BTN_SIDE), + Some(PointerButton::Back) + ); + assert_eq!( + linux::pointer_button(linux::BTN_BACK), + Some(PointerButton::Back) + ); + assert_eq!( + linux::pointer_button(linux::BTN_EXTRA), + Some(PointerButton::Forward) + ); + assert_eq!(gdk::pointer_button(8), Some(PointerButton::Back)); + assert_eq!(gdk::pointer_button(9), Some(PointerButton::Forward)); + assert_eq!(gdk::pointer_button(10), Some(PointerButton::Extra(1))); + assert_eq!(gdk::pointer_button(1), None); + assert_eq!( + linux::stylus_button(linux::BTN_STYLUS), + Some(StylusButton::Primary) + ); + assert_eq!(gdk::stylus_button(2), Some(StylusButton::Primary)); + } +} diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 641309fd..d7b4fd84 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -181,23 +181,23 @@ fn test_build_action_map() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+1").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap()), Some(&Action::Exit) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+2").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+2").unwrap()), Some(&Action::Undo) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+3").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+3").unwrap()), Some(&Action::Redo) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+4").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+4").unwrap()), Some(&Action::ToggleHelp) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+5").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+5").unwrap()), Some(&Action::ToggleWhiteboard) ); } @@ -210,15 +210,15 @@ fn command_palette_and_full_screen_capture_defaults_are_distinct_and_ordered() { let map = config.build_action_map().expect("default keymap is valid"); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+K").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+K").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Shift+P").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Shift+P").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+F").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+F").unwrap()), Some(&Action::CaptureFullScreen) ); } @@ -232,11 +232,11 @@ fn toolbar_display_cycle_owns_f2_and_toggle_toolbar_keeps_f9() { // The split leaves no duplicate binding in the defaults. let map = config.build_action_map().expect("default keymap is valid"); assert_eq!( - map.get(&KeyBinding::parse("F2").unwrap()), + map.get(&ShortcutTrigger::parse("F2").unwrap()), Some(&Action::CycleToolbarDisplay) ); assert_eq!( - map.get(&KeyBinding::parse("F9").unwrap()), + map.get(&ShortcutTrigger::parse("F9").unwrap()), Some(&Action::ToggleToolbar) ); } @@ -271,6 +271,29 @@ fn test_duplicate_with_different_modifier_order() { assert!(err_msg.contains("Shift+Ctrl+W")); } +#[test] +fn device_triggers_round_trip_through_the_action_map() { + let mut config = KeybindingsConfig::default(); + config.core.undo = vec!["MouseBack".to_string(), "Ctrl+StylusSecondary".to_string()]; + let map = config.build_action_map().unwrap(); + assert_eq!( + map.get(&ShortcutTrigger::parse("MouseBack").unwrap()), + Some(&Action::Undo) + ); + assert_eq!( + map.get(&ShortcutTrigger::parse("Ctrl+StylusSecondary").unwrap()), + Some(&Action::Undo) + ); +} + +#[test] +fn primary_mouse_strings_are_rejected_from_the_keymap() { + let mut config = KeybindingsConfig::default(); + config.core.undo = vec!["MouseLeft".to_string()]; + let err = config.build_action_map().unwrap_err(); + assert!(err.contains("cannot be bound"), "{err}"); +} + #[test] fn test_parse_plus_key_without_modifiers() { let binding = KeyBinding::parse("+").unwrap(); @@ -312,15 +335,15 @@ fn test_build_action_bindings_preserves_declared_binding_order() { assert_eq!( bindings.get(&Action::ToggleHelp), Some(&vec![ - KeyBinding::parse("Ctrl+Alt+Shift+1").unwrap(), - KeyBinding::parse("Ctrl+Alt+Shift+2").unwrap(), + ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap(), + ShortcutTrigger::parse("Ctrl+Alt+Shift+2").unwrap(), ]) ); assert_eq!( bindings.get(&Action::Redo), Some(&vec![ - KeyBinding::parse("Ctrl+Alt+Shift+3").unwrap(), - KeyBinding::parse("Ctrl+Alt+Shift+4").unwrap(), + ShortcutTrigger::parse("Ctrl+Alt+Shift+3").unwrap(), + ShortcutTrigger::parse("Ctrl+Alt+Shift+4").unwrap(), ]) ); } @@ -350,23 +373,23 @@ fn build_action_map_includes_canvas_export_bindings() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+F").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+F").unwrap()), Some(&Action::ExportCanvasFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+C").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+C").unwrap()), Some(&Action::ExportCanvasClipboard) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+B").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+B").unwrap()), Some(&Action::ExportCanvasClipboardAndFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+P").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+P").unwrap()), Some(&Action::ExportBoardPdfFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+A").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+A").unwrap()), Some(&Action::ExportAllBoardsPdfFile) ); } @@ -378,7 +401,7 @@ fn screen_eyedropper_defaults_to_i_and_maps_when_reconfigured() { let default_map = config.build_action_map().unwrap(); assert_eq!( - default_map.get(&KeyBinding::parse("I").unwrap()), + default_map.get(&ShortcutTrigger::parse("I").unwrap()), Some(&Action::PickScreenColor) ); @@ -387,7 +410,7 @@ fn screen_eyedropper_defaults_to_i_and_maps_when_reconfigured() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+E").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+E").unwrap()), Some(&Action::PickScreenColor) ); } @@ -399,7 +422,7 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { let default_map = config.build_action_map().unwrap(); assert_eq!( - default_map.get(&KeyBinding::parse("O").unwrap()), + default_map.get(&ShortcutTrigger::parse("O").unwrap()), Some(&Action::SetColorOrange) ); assert!( @@ -411,7 +434,7 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { config.capture.copy_text_from_screen = vec!["Ctrl+Alt+T".to_string()]; let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+T").unwrap()), + map.get(&ShortcutTrigger::parse("Ctrl+Alt+T").unwrap()), Some(&Action::CopyTextFromScreen) ); } @@ -486,7 +509,7 @@ fn collect_binding_conflicts_reports_every_collision_in_traversal_order() { assert_eq!(conflicts.len(), 2, "collection does not stop at the first"); assert_eq!( conflicts[0].binding(), - &KeyBinding::parse("Ctrl+Alt+Shift+1").unwrap() + &ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap() ); assert_eq!(conflicts[0].actions(), [Action::Exit, Action::Undo]); assert_eq!( diff --git a/src/config/mod.rs b/src/config/mod.rs index 18588ee6..3d9cb699 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -52,7 +52,8 @@ pub use io::{ persist_preset_slot, persist_quick_color, }; pub use keybindings::{ - Action, KeyBinding, KeybindingAuthorship, KeybindingConflict, KeybindingsConfig, + Action, KeyBinding, KeybindingAuthorship, KeybindingConflict, KeybindingsConfig, PointerButton, + PointerTrigger, ShortcutTrigger, StylusButton, StylusTrigger, }; pub use migration::{MigrationChange, MigrationPreview}; #[allow(unused_imports)] diff --git a/src/config/tests/document.rs b/src/config/tests/document.rs index 3b065997..03c07ed4 100644 --- a/src/config/tests/document.rs +++ b/src/config/tests/document.rs @@ -1187,11 +1187,11 @@ toggle_toolbar = ["F2", "F9"] .build_action_map() .expect("the resolved keymap has no duplicates left"); assert_eq!( - map.get(&KeyBinding::parse("F2").expect("F2 parses")), + map.get(&ShortcutTrigger::parse("F2").expect("F2 parses")), Some(&Action::ToggleToolbar) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Shift+P").expect("Ctrl+Shift+P parses")), + map.get(&ShortcutTrigger::parse("Ctrl+Shift+P").expect("Ctrl+Shift+P parses")), Some(&Action::CaptureFullScreen) ); @@ -1387,7 +1387,7 @@ capture_clipboard_full = ["Ctrl+Shift+K"] .build_action_map() .expect("the resolved keymap has no duplicates"); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Shift+K").expect("Ctrl+Shift+K parses")), + map.get(&ShortcutTrigger::parse("Ctrl+Shift+K").expect("Ctrl+Shift+K parses")), Some(&Action::CaptureClipboardFull) ); assert_eq!(fs::read_to_string(&temp.path).unwrap(), original); @@ -1860,6 +1860,51 @@ fn enabled_tablet_section_reports_and_preserves_nested_unknown_setting() { assert!(saved.contains("future_tablet_setting = 12")); } +#[cfg(feature = "tablet-input")] +#[test] +fn unrelated_save_preserves_legacy_stylus_button_nodes() { + let temp = TempConfig::new("preserve-stylus-button"); + temp.write( + "[tablet.stylus_button]\naction = \"undo\"\n\n[tablet.stylus_button2]\naction = \"redo\"\n\n[performance]\nmax_fps_no_vsync = 90\n", + ); + let document = ConfigDocument::load_from_path(&temp.path).expect("load stylus buttons"); + let mut config = document.config().clone(); + config.performance.max_fps_no_vsync = 144; + document + .save_with_backup(config) + .expect("save unrelated performance edit"); + let saved = fs::read_to_string(&temp.path).expect("read preserved stylus buttons"); + assert!(saved.contains("action = \"undo\""), "{saved}"); + assert!(saved.contains("action = \"redo\""), "{saved}"); + assert!(saved.contains("max_fps_no_vsync = 144"), "{saved}"); +} + +#[cfg(feature = "tablet-input")] +#[test] +fn explicit_legacy_stylus_unbind_rewrites_only_that_action_node() { + let temp = TempConfig::new("migrate-stylus-button"); + temp.write( + "[tablet.stylus_button]\naction = \"undo\"\n\n[keybindings]\nclear_canvas = [\"E\"]\n\n[performance]\nmax_fps_no_vsync = 90\n", + ); + let document = ConfigDocument::load_from_path(&temp.path).expect("load stylus button"); + let mut config = document.config().clone(); + config.tablet.stylus_button.action = None; + config.keybindings.core.clear_canvas = vec!["E".to_string(), "StylusPrimary".to_string()]; + document + .save_with_backup(config) + .expect("save explicit stylus migration"); + let saved = fs::read_to_string(&temp.path).expect("read migrated stylus button"); + assert!( + !saved.contains("action = \"undo\""), + "legacy action must move off the tablet node:\n{saved}" + ); + assert!( + saved.contains("StylusPrimary"), + "canonical trigger must land on the action:\n{saved}" + ); + assert!(saved.contains("max_fps_no_vsync = 90"), "{saved}"); +} + #[test] fn performance_metadata_is_unique_and_matches_example_and_docs() { let mut ids = std::collections::HashSet::new(); diff --git a/src/config/validate/keybindings.rs b/src/config/validate/keybindings.rs index 470ae60d..a3d711cb 100644 --- a/src/config/validate/keybindings.rs +++ b/src/config/validate/keybindings.rs @@ -2,7 +2,7 @@ use std::fmt; use super::super::CURRENT_CONFIG_REVISION; use super::super::action_meta::action_label; -use super::super::keybindings::{Action, KeyBinding, KeybindingAuthorship, KeybindingsConfig}; +use super::super::keybindings::{Action, KeybindingAuthorship, KeybindingsConfig, ShortcutTrigger}; use super::Config; const LEGACY_COMMAND_PALETTE_DEFAULT: &[&str] = &["Ctrl+K"]; @@ -30,7 +30,7 @@ fn bindings_from(expected: &[&str]) -> Vec { /// Whether an action other than `owner` already claims `binding`. /// -/// Candidates go through the parser and then [`KeyBinding`] equality, so +/// Candidates go through the parser and then [`ShortcutTrigger`] equality, so /// `shift+ctrl+k` counts as a claim on `Ctrl+Shift+K`: modifier order, spacing, /// and key case are all things the keymap ignores when a key is pressed. A /// binding string that does not parse is not a claim, because it binds nothing @@ -38,7 +38,7 @@ fn bindings_from(expected: &[&str]) -> Vec { fn binding_claimed_by_another_action( keybindings: &KeybindingsConfig, owner: Action, - binding: &KeyBinding, + binding: &ShortcutTrigger, ) -> bool { KeybindingsConfig::configurable_actions() .iter() @@ -48,7 +48,7 @@ fn binding_claimed_by_another_action( .bindings_for_action(*action) .is_some_and(|bindings| { bindings.iter().any(|candidate| { - KeyBinding::parse(candidate).is_ok_and(|parsed| parsed == *binding) + ShortcutTrigger::parse(candidate).is_ok_and(|parsed| parsed == *binding) }) }) }) @@ -243,7 +243,7 @@ impl fmt::Display for KeybindingConflictResolution { fn drop_binding( keybindings: &mut KeybindingsConfig, action: Action, - binding: &KeyBinding, + binding: &ShortcutTrigger, keep_first: bool, ) -> bool { let Some(current) = keybindings.bindings_for_action(action) else { @@ -253,7 +253,7 @@ fn drop_binding( let before = bindings.len(); let mut kept_one = false; bindings.retain(|candidate| { - if !KeyBinding::parse(candidate).is_ok_and(|parsed| parsed == *binding) { + if !ShortcutTrigger::parse(candidate).is_ok_and(|parsed| parsed == *binding) { return true; } if keep_first && !kept_one { @@ -405,12 +405,8 @@ impl Config { let mut kept = Vec::with_capacity(current.len()); let mut dropped = false; for binding in current { - match KeyBinding::parse(binding) { - Ok(parsed) - if crate::config::keybindings::is_deliverable_key_name(&parsed.key) => - { - kept.push(binding.clone()) - } + match ShortcutTrigger::parse(binding) { + Ok(parsed) if parsed.is_deliverable() => kept.push(binding.clone()), Ok(parsed) => { // Reported but kept: the string is well formed, and a // future build may learn to deliver this name. @@ -419,10 +415,8 @@ impl Config { action: *action, binding: binding.clone(), problem: KeybindingProblem::UnknownKey { - suggestion: crate::config::keybindings::suggest_key_name( - &parsed.key, - ), - key: parsed.key, + suggestion: parsed.unknown_key_suggestion(), + key: parsed.unknown_key_name().unwrap_or_default(), }, }); } @@ -566,7 +560,7 @@ impl Config { let mut kept = Vec::with_capacity(current.len()); let mut dropped = false; for text in current { - let Ok(binding) = KeyBinding::parse(text) else { + let Ok(binding) = ShortcutTrigger::parse(text) else { // `drop_unparseable_bindings` already removed anything the // parser rejects, so this arm is only reachable if that // pass changes; keep the string rather than losing it here. @@ -741,7 +735,7 @@ impl Config { } let Some(contested) = CURRENT_TOGGLE_INPUT_HUD_DEFAULT .first() - .and_then(|binding| KeyBinding::parse(binding).ok()) + .and_then(|binding| ShortcutTrigger::parse(binding).ok()) else { return; }; diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index ba03ca17..677a419d 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -8,7 +8,8 @@ use super::super::types::{ }; use super::structs::InputState; use crate::config::{ - Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX, QuickColorPalette, RadialMenuMouseBinding, + Action, BoardsConfig, PRESET_SLOTS_MAX, QuickColorPalette, RadialMenuMouseBinding, + ShortcutTrigger, }; use crate::draw::{ BlurStyle, DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES, @@ -20,7 +21,7 @@ use crate::input::{ modifiers::{DragToolBindings, Modifiers}, tool::{EraserMode, PerToolDrawingSettings}, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{SystemTime, UNIX_EPOCH}; impl InputState { @@ -60,7 +61,7 @@ impl InputState { arrow_head_at_end: bool, show_status_bar: bool, boards_config: BoardsConfig, - action_map: HashMap, + action_map: HashMap, max_shapes_per_frame: usize, click_highlight_settings: ClickHighlightSettings, undo_all_delay_ms: u64, @@ -206,6 +207,7 @@ impl InputState { text_input_revision: 0, action_map, action_bindings: HashMap::new(), + consumed_pointer_buttons: HashSet::new(), pending_backend_action: None, pending_toolbar_persistence: Vec::new(), pending_keybinding_edits: Vec::new(), diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs index c3a840c8..ae3f9834 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -14,6 +14,7 @@ impl InputState { self.modifiers.alt = false; self.modifiers.logo = false; self.modifiers.tab = false; + self.consumed_pointer_buttons.clear(); if matches!(self.state, DrawingState::Idle) { self.sync_current_settings_from_active_tool(); } diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index e10ad4e4..e0f78461 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -25,9 +25,8 @@ use super::super::types::{ ZoomAction, }; use crate::config::{ - Action, KeyBinding, PresenterModeConfig, QuickColorPalette, RadialMenuMouseBinding, - ResolvedToolbarItems, ToolPresetConfig, ToolbarItemId, ToolbarItemOrderGroup, - ToolbarItemsConfig, + Action, PresenterModeConfig, QuickColorPalette, RadialMenuMouseBinding, ResolvedToolbarItems, + ShortcutTrigger, ToolPresetConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemsConfig, }; use crate::draw::frame::ShapeSnapshot; use crate::draw::{BlurStyle, Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId}; @@ -43,7 +42,7 @@ use crate::input::{ use crate::render_profiles::RenderProfileSet; use crate::session::SessionOptions; use crate::util::Rect; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::time::Instant; @@ -369,9 +368,12 @@ pub struct InputState { /// the same bytes and selection are later restored. pub(crate) text_input_revision: u64, /// Keybinding action map for efficient lookup - pub(in crate::input::state::core) action_map: HashMap, + pub(in crate::input::state::core) action_map: HashMap, /// Ordered keybindings per action (as configured) - pub(in crate::input::state::core) action_bindings: HashMap>, + pub(in crate::input::state::core) action_bindings: HashMap>, + /// Auxiliary pointer codes whose press dispatched a shortcut, so the + /// matching release is consumed instead of starting a stroke or UI action. + pub(crate) consumed_pointer_buttons: HashSet, /// Bumped whenever the keymap is replaced. Shortcut labels feed command /// scoring, so the palette's result cache keys on this. pub(in crate::input::state::core) keymap_revision: u64, diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 168e34ab..748a1d6e 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -43,11 +43,11 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use crate::config::{KeyBinding, RadialMenuMouseBinding}; + use crate::config::{RadialMenuMouseBinding, ShortcutTrigger}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; use std::collections::HashMap; - fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { entries .iter() .map(|(action, values)| { @@ -55,7 +55,7 @@ mod tests { *action, values .iter() - .map(|value| KeyBinding::parse(value).expect("binding")) + .map(|value| ShortcutTrigger::parse(value).expect("binding")) .collect(), ) }) diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index e74153c5..98de94fb 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -420,14 +420,14 @@ mod tests { #[test] fn tour_copy_tracks_rebound_shortcuts() { - use crate::config::{KeyBinding, KeybindingsConfig}; + use crate::config::{KeybindingsConfig, ShortcutTrigger}; let mut bindings = KeybindingsConfig::default() .build_action_bindings() .expect("default bindings"); bindings.insert( Action::ToggleCommandPalette, - vec![KeyBinding::parse("Ctrl+Shift+P").expect("binding")], + vec![ShortcutTrigger::parse("Ctrl+Shift+P").expect("binding")], ); let mut state = make_test_input_state(); state.set_action_bindings(bindings); @@ -441,7 +441,7 @@ mod tests { #[test] fn status_bar_step_teaches_board_picker_and_tracks_binding() { - use crate::config::{KeyBinding, KeybindingsConfig}; + use crate::config::{KeybindingsConfig, ShortcutTrigger}; // The M9 board-picker beat always names the on-screen entry point (the // status bar) position-neutrally and resolves the board-picker key through @@ -451,7 +451,7 @@ mod tests { .expect("default bindings"); bindings.insert( Action::BoardPicker, - vec![KeyBinding::parse("Ctrl+Shift+B").expect("binding")], + vec![ShortcutTrigger::parse("Ctrl+Shift+B").expect("binding")], ); let mut state = make_test_input_state(); state.set_action_bindings(bindings); diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index 5d685e64..f71ae653 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -1,5 +1,7 @@ use super::super::base::InputState; -use crate::config::KeyBinding; +use crate::config::{KeyBinding, PointerButton, PointerTrigger, ShortcutTrigger}; +#[cfg(feature = "tablet-input")] +use crate::config::{StylusButton, StylusTrigger}; use crate::domain::Action; use crate::label_format::format_binding_labels; use std::collections::{HashMap, HashSet}; @@ -7,21 +9,50 @@ use std::collections::{HashMap, HashSet}; impl InputState { /// Look up an action for the given key and modifiers. pub(crate) fn find_action(&self, key_str: &str) -> Option { - for (binding, action) in &self.action_map { - if binding.matches( - key_str, - self.modifiers.ctrl, - self.modifiers.shift, - self.modifiers.alt, - self.modifiers.logo, - ) { - return Some(*action); - } - } - None + let trigger = ShortcutTrigger::Keyboard(KeyBinding { + key: key_str.to_string(), + ctrl: self.modifiers.ctrl, + shift: self.modifiers.shift, + alt: self.modifiers.alt, + logo: self.modifiers.logo, + }); + self.action_map.get(&trigger).copied() + } + + pub(crate) fn find_trigger_action(&self, trigger: &ShortcutTrigger) -> Option { + self.action_map.get(trigger).copied() + } + + pub(crate) fn pointer_trigger(&self, button: PointerButton) -> ShortcutTrigger { + ShortcutTrigger::Pointer(PointerTrigger { + button, + ctrl: self.modifiers.ctrl, + shift: self.modifiers.shift, + alt: self.modifiers.alt, + logo: self.modifiers.logo, + }) + } + + #[cfg(feature = "tablet-input")] + pub(crate) fn stylus_trigger(&self, button: StylusButton) -> ShortcutTrigger { + ShortcutTrigger::Stylus(StylusTrigger { + button, + ctrl: self.modifiers.ctrl, + shift: self.modifiers.shift, + alt: self.modifiers.alt, + logo: self.modifiers.logo, + }) + } + + pub(crate) fn consume_pointer_shortcut_button(&mut self, code: u32) { + self.consumed_pointer_buttons.insert(code); + } + + pub(crate) fn take_consumed_pointer_shortcut_button(&mut self, code: u32) -> bool { + self.consumed_pointer_buttons.remove(&code) } - pub fn set_action_bindings(&mut self, action_bindings: HashMap>) { + pub fn set_action_bindings(&mut self, action_bindings: HashMap>) { self.action_bindings = action_bindings; self.keymap_revision = self.keymap_revision.wrapping_add(1); } @@ -33,8 +64,8 @@ impl InputState { /// badge and help row reads back. pub(crate) fn set_keybinding_maps( &mut self, - action_map: HashMap, - action_bindings: HashMap>, + action_map: HashMap, + action_bindings: HashMap>, ) { self.action_map = action_map; self.action_bindings = action_bindings; diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 630d51ef..b03693be 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -65,7 +65,9 @@ pub use input_hud::{ #[cfg(test)] pub(crate) mod test_support { - use crate::config::{Action, BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig}; + use crate::config::{ + Action, BoardsConfig, KeybindingsConfig, PresenterModeConfig, ShortcutTrigger, + }; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; use std::collections::HashMap; @@ -82,7 +84,7 @@ pub(crate) mod test_support { // action-binding label overrides. It intentionally keeps the default // dispatch/action map and swaps only the formatted bindings. pub(crate) fn make_test_input_state_with_action_bindings( - action_bindings: HashMap>, + action_bindings: HashMap>, ) -> InputState { let action_map = KeybindingsConfig::default() .build_action_map() diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs index d9286e8a..59848709 100644 --- a/src/input/state/tests/action_bindings.rs +++ b/src/input/state/tests/action_bindings.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::KeyBinding; +use crate::config::{PointerButton, ShortcutTrigger}; use std::collections::HashMap; #[test] @@ -9,9 +9,9 @@ fn explicit_action_binding_labels_dedup_and_preserve_order() { bindings.insert( Action::ToggleHelp, vec![ - KeyBinding::parse("Shift+F1").unwrap(), - KeyBinding::parse("Shift+F1").unwrap(), - KeyBinding::parse("F10").unwrap(), + ShortcutTrigger::parse("Shift+F1").unwrap(), + ShortcutTrigger::parse("Shift+F1").unwrap(), + ShortcutTrigger::parse("F10").unwrap(), ], ); state.set_action_bindings(bindings); @@ -26,7 +26,10 @@ fn explicit_action_binding_labels_dedup_and_preserve_order() { fn custom_action_bindings_override_fallback_action_map_labels() { let mut state = create_test_input_state(); let mut bindings = HashMap::new(); - bindings.insert(Action::ToggleHelp, vec![KeyBinding::parse("Menu").unwrap()]); + bindings.insert( + Action::ToggleHelp, + vec![ShortcutTrigger::parse("Menu").unwrap()], + ); state.set_action_bindings(bindings); assert_eq!( @@ -53,8 +56,8 @@ fn action_binding_primary_label_prefers_first_explicit_binding() { bindings.insert( Action::ToggleStatusBar, vec![ - KeyBinding::parse("F4").unwrap(), - KeyBinding::parse("F12").unwrap(), + ShortcutTrigger::parse("F4").unwrap(), + ShortcutTrigger::parse("F12").unwrap(), ], ); state.set_action_bindings(bindings); @@ -100,3 +103,41 @@ fn find_action_treats_super_as_a_distinct_modifier() { assert!(!state.modifiers.logo); assert!(!state.modifiers.ctrl); } + +#[test] +fn pointer_shortcuts_match_current_modifiers_and_ignore_unbound_buttons() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.core.undo = vec!["Ctrl+MouseBack".to_string()]; + keybindings.core.redo = vec!["MouseForward".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + + state.modifiers.ctrl = true; + assert_eq!( + state.find_trigger_action(&state.pointer_trigger(PointerButton::Back)), + Some(Action::Undo) + ); + state.modifiers.ctrl = false; + assert_eq!( + state.find_trigger_action(&state.pointer_trigger(PointerButton::Back)), + None + ); + assert_eq!( + state.find_trigger_action(&state.pointer_trigger(PointerButton::Forward)), + Some(Action::Redo) + ); + assert_eq!( + state.find_trigger_action(&state.pointer_trigger(PointerButton::Extra(1))), + None + ); +} + +#[test] +fn consumed_pointer_shortcut_buttons_clear_on_focus_loss() { + let mut state = create_test_input_state(); + state.consume_pointer_shortcut_button(0x113); + assert!(state.take_consumed_pointer_shortcut_button(0x113)); + assert!(!state.take_consumed_pointer_shortcut_button(0x113)); + state.consume_pointer_shortcut_button(0x113); + state.reset_modifiers(); + assert!(!state.take_consumed_pointer_shortcut_button(0x113)); +} diff --git a/src/session/tests/helpers.rs b/src/session/tests/helpers.rs index cd013467..a0831069 100644 --- a/src/session/tests/helpers.rs +++ b/src/session/tests/helpers.rs @@ -1,4 +1,4 @@ -use crate::config::{Action, BoardsConfig, KeyBinding, PresenterModeConfig}; +use crate::config::{Action, BoardsConfig, PresenterModeConfig, ShortcutTrigger}; use crate::draw::Color as DrawColor; use crate::draw::FontDescriptor; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; @@ -6,7 +6,7 @@ use std::collections::HashMap; pub(super) fn dummy_input_state() -> InputState { let mut action_map = HashMap::new(); - action_map.insert(KeyBinding::parse("Escape").unwrap(), Action::Exit); + action_map.insert(ShortcutTrigger::parse("Escape").unwrap(), Action::Exit); InputState::with_defaults( DrawColor { r: 1.0, diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 9ce6fcf2..acaa0cdd 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -5,7 +5,7 @@ use std::ffi::{CStr, CString}; use std::time::Duration; use super::*; -use crate::config::{KeyBinding, ToolbarSectionFlag, toolbar_item_ids as ids}; +use crate::config::{ShortcutTrigger, ToolbarSectionFlag, toolbar_item_ids as ids}; use crate::input::state::test_support::make_test_input_state; use crate::toolbar_gtk::widgets::{emit_secondary_press, secondary_click_gesture}; use crate::ui::toolbar::{ @@ -25,7 +25,7 @@ fn top_structure_rebuilds_when_current_shortcuts_change() { state.set_action_bindings(HashMap::from([( Action::SelectPenTool, - vec![KeyBinding::parse("9").expect("binding")], + vec![ShortcutTrigger::parse("9").expect("binding")], )])); let changed = ToolbarSnapshot::from_input_with_bindings( &state, diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index 7d574a5d..eff3e59d 100644 --- a/src/ui/radial_menu/cache.rs +++ b/src/ui/radial_menu/cache.rs @@ -342,7 +342,7 @@ mod tests { /// hints) invalidates the key. #[test] fn base_cache_key_changes_with_binding_hints() { - use crate::config::{Action, KeyBinding}; + use crate::config::{Action, ShortcutTrigger}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; let default_state = make_test_input_state(); @@ -351,7 +351,7 @@ mod tests { .expect("default bindings"); bindings.insert( Action::SelectPenTool, - vec![KeyBinding::parse("F9").expect("parse F9")], + vec![ShortcutTrigger::parse("F9").expect("parse F9")], ); let rebound_state = make_test_input_state_with_action_bindings(bindings); diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index 75d55caf..fb9b3040 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -260,7 +260,7 @@ impl InputState { #[cfg(test)] mod coach_tests { - use crate::config::{Action, KeyBinding}; + use crate::config::{Action, ShortcutTrigger}; use crate::draw::{Color, Shape}; use crate::input::InputState; use crate::input::state::test_support::{ @@ -347,7 +347,7 @@ mod coach_tests { // cannot name. (An empty map would fall back to the default action map, // which still binds Undo, so the override must be an explicit empty // binding list.) - let bindings: HashMap> = + let bindings: HashMap> = HashMap::from([(Action::Undo, Vec::new())]); let mut state = make_test_input_state_with_action_bindings(bindings); add_test_shape(&mut state); diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index 459a3fb2..a135917f 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -117,10 +117,10 @@ pub(crate) fn action_for_clear_preset(slot: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::config::KeyBinding; + use crate::config::ShortcutTrigger; use crate::input::state::test_support::make_test_input_state_with_action_bindings; - fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { entries .iter() .map(|(action, values)| { @@ -128,7 +128,7 @@ mod tests { *action, values .iter() - .map(|value| KeyBinding::parse(value).expect("binding")) + .map(|value| ShortcutTrigger::parse(value).expect("binding")) .collect(), ) }) @@ -201,7 +201,9 @@ mod tests { (Action::Exit, &["Ctrl+Alt+Shift+Q"]), ])); let hints = ToolbarBindingHints::from_input_state(&state); - let expected = KeyBinding::parse("Ctrl+Alt+Shift+O").unwrap().to_string(); + let expected = ShortcutTrigger::parse("Ctrl+Alt+Shift+O") + .unwrap() + .to_string(); assert_eq!( hints.binding_for_action(Action::OpenConfigurator), From 23b5b200884242703aa7272b5cfb48d028bbd442 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 4/7] feat: support multi-step keyboard shortcut sequences Allow Ctrl+K then Ctrl+C style chords so related actions can share a prefix without delaying existing single shortcuts or surviving focus and modal boundaries. --- config.example.toml | 6 +- configurator/README.md | 2 +- configurator/src/app/pages/keybindings/mod.rs | 2 +- .../src/app/pages/keybindings/recorder.rs | 63 +++- configurator/src/app/pages/keybindings/row.rs | 33 ++- configurator/src/app/update/mod.rs | 7 + configurator/src/app/update/shortcuts.rs | 60 +++- .../src/app/update/shortcuts/tests.rs | 120 +++++++- configurator/src/messages.rs | 7 +- .../src/models/keybindings/conflicts.rs | 178 ++++++++--- configurator/src/models/keybindings/edit.rs | 35 ++- configurator/src/models/keybindings/mod.rs | 3 +- configurator/src/models/keybindings/parse.rs | 8 +- .../src/models/keybindings/recording.rs | 136 ++++++++- configurator/src/models/keybindings/tests.rs | 10 +- docs/CONFIG.md | 19 +- src/app/usage.rs | 24 +- src/backend/wayland/backend/event_loop/mod.rs | 4 + .../wayland/backend/state_init/input_state.rs | 6 +- src/backend/wayland/config_edits/tests.rs | 4 +- src/backend/wayland/handlers/keyboard/mod.rs | 2 +- src/backend/wayland/handlers/pointer/press.rs | 1 + src/backend/wayland/handlers/tablet/frame.rs | 1 + src/backend/wayland/session/tests.rs | 4 +- src/backend/wayland/state/input_actions.rs | 4 + src/backend/wayland/state/keybindings.rs | 4 +- .../state/keybindings/implementation.rs | 10 +- .../implementation/tests/completion.rs | 4 +- .../implementation/tests/preparation.rs | 2 +- src/config/io.rs | 8 +- src/config/keybindings.rs | 4 +- src/config/keybindings/AGENTS.md | 3 +- src/config/keybindings/config/map/mod.rs | 52 +++- src/config/keybindings/shortcut.rs | 277 ++++++++++++++++- src/config/keybindings/tests.rs | 95 ++++-- src/config/mod.rs | 5 +- src/config/tests/document.rs | 36 ++- src/config/validate/keybindings.rs | 24 +- src/input/state/actions/key_press/mod.rs | 4 + src/input/state/core/base/state/init.rs | 9 +- src/input/state/core/base/state/modifiers.rs | 1 + src/input/state/core/base/state/structs.rs | 12 +- src/input/state/core/menus/shortcuts.rs | 6 +- src/input/state/core/mod.rs | 1 + src/input/state/core/modal.rs | 1 + src/input/state/core/tour.rs | 8 +- src/input/state/core/utility/actions.rs | 67 ++++- src/input/state/core/utility/mod.rs | 2 + src/input/state/core/utility/sequence.rs | 279 ++++++++++++++++++ src/input/state/interaction/keyboard.rs | 58 +++- src/input/state/interaction/mod.rs | 2 +- src/input/state/interaction/outcome.rs | 1 + src/input/state/mod.rs | 6 +- src/input/state/tests/action_bindings.rs | 140 ++++++++- src/session/tests/helpers.rs | 4 +- src/toolbar_gtk/view/top_bar/tests.rs | 4 +- src/ui/radial_menu/cache.rs | 4 +- src/ui/toolbar/apply/mod.rs | 5 +- src/ui/toolbar/bindings.rs | 10 +- 59 files changed, 1626 insertions(+), 261 deletions(-) create mode 100644 src/input/state/core/utility/sequence.rs diff --git a/config.example.toml b/config.example.toml index 15d76d93..9014d1b4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -34,10 +34,14 @@ config_revision = 3 # Customize keyboard shortcuts for all actions # Format: key names with modifiers separated by + # Examples: "Escape", "Ctrl+Z", "Super+X", "Ctrl+Shift+T", "F10", -# "MouseBack", "Ctrl+MouseForward", "StylusPrimary" +# "MouseBack", "Ctrl+MouseForward", "StylusPrimary", +# "Ctrl+K > Ctrl+C" # Super also accepts Meta, Logo, Win, and Windows. # Auxiliary mouse buttons: MouseBack, MouseForward, MouseExtra1–MouseExtra4. # Stylus barrel buttons: StylusPrimary, StylusSecondary. +# Keyboard sequences use `>` between two or three chords (Ctrl+K > Ctrl+C). +# Commas still separate independent shortcuts: F5, Ctrl+K > Ctrl+C +# A standalone chord cannot also be a sequence prefix. # Left, middle, and right cannot be bound as actions. # Each action can have multiple keybindings (e.g., ["+", "="]) diff --git a/configurator/README.md b/configurator/README.md index 05e8d1f8..23757f9b 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -69,7 +69,7 @@ if its UI task is no longer observed. - **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components). - **Default color** – toggle between named colors and custom RGB triples. -- **Keybindings** – per-action shortcut chips with press-to-bind recording for keys, auxiliary mouse buttons, and stylus barrel buttons, plus per-row reset and a raw comma-separated text editor. 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. +- **Keybindings** – per-action shortcut chips with press-to-bind recording for 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. - **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. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. diff --git a/configurator/src/app/pages/keybindings/mod.rs b/configurator/src/app/pages/keybindings/mod.rs index f42ff540..8b9a87fd 100644 --- a/configurator/src/app/pages/keybindings/mod.rs +++ b/configurator/src/app/pages/keybindings/mod.rs @@ -20,7 +20,7 @@ use row::binding_row; use widgets::{set_accessible_label, set_label, set_visible}; const SECTION_DESCRIPTION: &str = - "Record a shortcut, reset one action, or edit the comma-separated list as text."; + "Record a shortcut or sequence, reset one action, or edit the comma-separated list as text."; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let stack = gtk::Stack::builder() diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs index 0b79a08f..7c1fc6dc 100644 --- a/configurator/src/app/pages/keybindings/recorder.rs +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -8,15 +8,21 @@ use crate::models::KeybindingField; #[cfg(not(feature = "tablet-input"))] use crate::models::keybindings::tablet_unavailable_hint; use crate::models::keybindings::{ - KeyboardModifiers, RecorderDeviceKind, super_consumed_hint, waiting_prompt, + KeyboardModifiers, RecorderDeviceKind, ShortcutRecorderState, super_consumed_hint, + waiting_prompt, }; use super::super::super::state::ConfiguratorApp; -use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; +use super::widgets::{ + field_canceled, ignore_activating_click, set_accessible_label, set_label, set_sensitive, + set_visible, +}; pub(super) struct RecorderPopover { pub popover: gtk::Popover, prompt: gtk::Label, + finish: gtk::Button, + remove_last: gtk::Button, } impl RecorderPopover { @@ -52,6 +58,29 @@ impl RecorderPopover { }); } + let finish = gtk::Button::builder().label("Finish").build(); + set_accessible_label(&finish, "Finish sequence"); + { + let sender = sender.clone(); + finish.connect_clicked(move |_| { + sender.input(Message::ShortcutSequenceFinish); + }); + } + + let remove_last = gtk::Button::builder().label("Remove Last Step").build(); + set_accessible_label(&remove_last, "Remove last sequence step"); + { + let sender = sender.clone(); + remove_last.connect_clicked(move |_| { + sender.input(Message::ShortcutSequenceRemoveLastStep); + }); + } + + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + buttons.append(&finish); + buttons.append(&remove_last); + buttons.append(&cancel); + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); content.set_margin_top(12); content.set_margin_bottom(12); @@ -60,7 +89,7 @@ impl RecorderPopover { content.append(&prompt); content.append(&hint); content.append(&device_hint_label()); - content.append(&cancel); + content.append(&buttons); let popover = gtk::Popover::builder() .autohide(true) @@ -72,11 +101,33 @@ impl RecorderPopover { attach_key_controller(&popover, sender); attach_button_controller(&popover, sender); - Self { popover, prompt } + Self { + popover, + prompt, + finish, + remove_last, + } } - pub(super) fn refresh(&self, recording: bool, prompt: &str) { - set_label(&self.prompt, prompt); + pub(super) fn refresh(&self, recorder: Option<&ShortcutRecorderState>) { + let recording = recorder.is_some(); + set_label( + &self.prompt, + recorder + .map(|recorder| recorder.prompt.as_str()) + .unwrap_or(""), + ); + let sequence = recorder.is_some_and(ShortcutRecorderState::is_sequence); + set_visible(&self.finish, sequence); + set_visible(&self.remove_last, sequence); + set_sensitive( + &self.finish, + recorder.is_some_and(ShortcutRecorderState::can_finish), + ); + set_sensitive( + &self.remove_last, + recorder.is_some_and(ShortcutRecorderState::can_remove_last), + ); if recording { if !self.popover.is_visible() { self.popover.popup(); diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index 199e20df..fbfbde3b 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -2,7 +2,7 @@ use relm4::prelude::*; use relm4::{adw, gtk}; use adw::prelude::*; -use wayscriber::config::ShortcutTrigger; +use wayscriber::config::Shortcut; use crate::messages::Message; use crate::models::KeybindingField; @@ -47,6 +47,20 @@ pub(super) fn binding_row( .build(); let add = icon_button("input-keyboard-symbolic", "Add shortcut"); connect_clicked(&add, sender, Message::ShortcutRecordingStarted(field)); + let record_sequence = gtk::Button::builder() + .label("Record Sequence") + .valign(gtk::Align::Center) + .build(); + set_accessible_label(&record_sequence, "Record sequence"); + set_tooltip( + &record_sequence, + Some("Record a two- or three-chord keyboard sequence"), + ); + connect_clicked( + &record_sequence, + sender, + Message::ShortcutSequenceRecordingStarted(field), + ); let reset = icon_button("view-refresh-symbolic", "Reset to default"); connect_clicked(&reset, sender, Message::ShortcutResetRequested(field)); let edit = icon_button("document-edit-symbolic", "Edit as text"); @@ -54,6 +68,7 @@ pub(super) fn binding_row( let controls = gtk::Box::new(gtk::Orientation::Horizontal, 4); controls.append(&add); + controls.append(&record_sequence); controls.append(&reset); controls.append(&edit); @@ -160,7 +175,7 @@ pub(super) fn binding_row( let parse_error = parsed.as_ref().err().cloned(); match &parsed { Ok(bindings) => { - let labels: Vec = bindings.iter().map(ToString::to_string).collect(); + let labels: Vec = bindings.iter().map(Shortcut::display_label).collect(); if seen_chips.borrow().as_slice() != labels.as_slice() { sync_chips(&chips, field, bindings, &sender); *seen_chips.borrow_mut() = labels; @@ -189,6 +204,7 @@ pub(super) fn binding_row( let at_defaults = field_matches_defaults(&app.draft.keybindings, &app.defaults.keybindings, field); 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 { @@ -222,12 +238,7 @@ pub(super) fn binding_row( .active_shortcut_recorder .as_ref() .filter(|recorder| recorder.field == field); - recorder.refresh( - recording.is_some(), - recording - .map(|recorder| recorder.prompt.as_str()) - .unwrap_or_default(), - ); + recorder.refresh(recording); let editing = app .shortcut_text_editor @@ -249,7 +260,7 @@ fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { fn sync_chips( flow: >k::FlowBox, field: KeybindingField, - bindings: &[ShortcutTrigger], + bindings: &[Shortcut], sender: &ComponentSender, ) { clear_flow(flow); @@ -266,10 +277,10 @@ fn clear_flow(flow: >k::FlowBox) { fn shortcut_chip( field: KeybindingField, - binding: &ShortcutTrigger, + binding: &Shortcut, sender: &ComponentSender, ) -> gtk::Box { - let label_text = binding.to_string(); + let label_text = binding.display_label(); let chip = gtk::Box::new(gtk::Orientation::Horizontal, 4); chip.add_css_class("osd"); chip.set_valign(gtk::Align::Center); diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index c34bedf0..ff8ebd67 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -245,6 +245,9 @@ impl ConfiguratorApp { Message::ShortcutRecordingStarted(field) => { self.handle_shortcut_recording_started(field) } + Message::ShortcutSequenceRecordingStarted(field) => { + self.handle_shortcut_sequence_recording_started(field) + } Message::ShortcutRecordingCanceled(field) => { self.handle_shortcut_recording_canceled(field) } @@ -254,6 +257,10 @@ impl ConfiguratorApp { Message::ShortcutRecorderButton(button, kind, modifiers) => { self.handle_shortcut_recorder_button(button, kind, modifiers) } + Message::ShortcutSequenceFinish => self.handle_shortcut_sequence_finish(), + Message::ShortcutSequenceRemoveLastStep => { + self.handle_shortcut_sequence_remove_last_step() + } Message::ShortcutRemoved(field, binding) => { self.handle_shortcut_removed(field, binding) } diff --git a/configurator/src/app/update/shortcuts.rs b/configurator/src/app/update/shortcuts.rs index 50a8f6a4..7c14d728 100644 --- a/configurator/src/app/update/shortcuts.rs +++ b/configurator/src/app/update/shortcuts.rs @@ -3,14 +3,15 @@ #[cfg(test)] mod tests; -use wayscriber::config::ShortcutTrigger; +use wayscriber::config::Shortcut; use crate::models::KeybindingField; use crate::models::keybindings::{ AppendOutcome, KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, ShortcutRecorderState, ShortcutTextEditor, append_binding, apply_recorded_replace, apply_text_replace, normalize_button_event, normalize_key_event, other_claimants, - parse_keybindings, remove_binding, reset_field, text_conflicts_for, + parse_keybindings, remove_binding, reset_field, sequence_keyboard_only_message, + text_conflicts_for, }; use super::super::effects::Effect; @@ -29,6 +30,18 @@ impl ConfiguratorApp { Vec::new() } + pub(super) fn handle_shortcut_sequence_recording_started( + &mut self, + field: KeybindingField, + ) -> Vec { + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + self.shortcut_text_editor = None; + self.active_shortcut_recorder = Some(ShortcutRecorderState::new_sequence(field)); + Vec::new() + } + pub(super) fn handle_shortcut_recording_canceled( &mut self, field: KeybindingField, @@ -51,10 +64,9 @@ impl ConfiguratorApp { let Some(recorder) = self.active_shortcut_recorder.as_mut() else { return Vec::new(); }; - let field = recorder.field; match normalize_key_event(keyval, modifiers) { RecordedKeyboard::Pending { preview } => { - recorder.prompt = preview; + recorder.apply_pending_preview(preview); Vec::new() } RecordedKeyboard::Unsupported { message } => { @@ -62,8 +74,14 @@ impl ConfiguratorApp { Vec::new() } RecordedKeyboard::Chord(binding) => { - self.active_shortcut_recorder = None; - self.commit_recorded_binding(field, binding.into()) + let field = recorder.field; + let finished = recorder.push_keyboard_step(binding); + if let Some(shortcut) = finished { + self.active_shortcut_recorder = None; + self.commit_recorded_binding(field, shortcut) + } else { + Vec::new() + } } } } @@ -84,16 +102,40 @@ impl ConfiguratorApp { Vec::new() } RecordedDevice::Trigger(trigger) => { + if recorder.is_sequence() { + recorder.prompt = sequence_keyboard_only_message().to_string(); + return Vec::new(); + } self.active_shortcut_recorder = None; - self.commit_recorded_binding(field, trigger) + self.commit_recorded_binding(field, trigger.into()) + } + } + } + + pub(super) fn handle_shortcut_sequence_finish(&mut self) -> Vec { + let Some(recorder) = self.active_shortcut_recorder.take() else { + return Vec::new(); + }; + match recorder.finish_sequence() { + Some(shortcut) => self.commit_recorded_binding(recorder.field, shortcut), + None => { + self.active_shortcut_recorder = Some(recorder); + Vec::new() } } } + pub(super) fn handle_shortcut_sequence_remove_last_step(&mut self) -> Vec { + if let Some(recorder) = self.active_shortcut_recorder.as_mut() { + recorder.remove_last_step(); + } + Vec::new() + } + pub(super) fn handle_shortcut_removed( &mut self, field: KeybindingField, - binding: ShortcutTrigger, + binding: Shortcut, ) -> Vec { if self.pending_shortcut_conflict.is_some() { return Vec::new(); @@ -236,7 +278,7 @@ impl ConfiguratorApp { fn commit_recorded_binding( &mut self, field: KeybindingField, - binding: ShortcutTrigger, + binding: Shortcut, ) -> Vec { let claimants = other_claimants(&self.draft.keybindings, field, &binding); if !claimants.is_empty() { diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index d407375f..0330be72 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -1,4 +1,4 @@ -use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, ShortcutTrigger}; +use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, Shortcut}; use crate::app::effects::Effect; use crate::app::state::{ConfiguratorApp, PendingConfirmation, StatusMessage}; @@ -124,7 +124,7 @@ fn recording_reset_and_removal_dirty_without_saving() { ); let effects = app.handle_shortcut_removed( KeybindingField::ToggleFloatingBadge, - ShortcutTrigger::parse("F5").expect("parses"), + Shortcut::parse("F5").expect("parses"), ); assert!(effects.is_empty()); assert_eq!( @@ -327,3 +327,119 @@ fn recording_stylus_primary_prompts_to_move_the_default_legacy_barrel() { .is_some_and(|value| value.contains("StylusPrimary")) ); } + +#[test] +fn recording_a_two_step_sequence_commits_on_finish() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_sequence_recording_started(KeybindingField::ToggleFloatingBadge); + let chord = KeyboardModifiers { + ctrl: true, + shift: true, + alt: true, + super_held: false, + }; + let effects = app.handle_shortcut_recorder_key(u32::from(b'k'), chord); + assert!(effects.is_empty()); + assert!(app.active_shortcut_recorder.is_some()); + let effects = app.handle_shortcut_recorder_key(u32::from(b'c'), chord); + assert!(effects.is_empty()); + assert!( + app.active_shortcut_recorder + .as_ref() + .is_some_and(|recorder| recorder.can_finish()) + ); + let effects = app.handle_shortcut_sequence_finish(); + assert!(effects.is_empty()); + assert!(app.active_shortcut_recorder.is_none()); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C") + ); +} + +#[test] +fn third_sequence_step_finishes_automatically() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_sequence_recording_started(KeybindingField::ToggleFloatingBadge); + let chord = KeyboardModifiers { + ctrl: true, + shift: true, + alt: true, + super_held: false, + }; + let _ = app.handle_shortcut_recorder_key(u32::from(b'k'), chord); + let _ = app.handle_shortcut_recorder_key(u32::from(b'c'), chord); + let effects = app.handle_shortcut_recorder_key(u32::from(b'v'), chord); + assert!(effects.is_empty()); + assert!(app.active_shortcut_recorder.is_none()); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C > Ctrl+Shift+Alt+V") + ); +} + +#[test] +fn sequence_recording_rejects_device_buttons() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_sequence_recording_started(KeybindingField::ToggleFloatingBadge); + let effects = app.handle_shortcut_recorder_button( + 8, + RecorderDeviceKind::Mouse, + KeyboardModifiers::default(), + ); + assert!(effects.is_empty()); + assert!(app.active_shortcut_recorder.is_some()); + assert!( + app.active_shortcut_recorder + .as_ref() + .is_some_and(|recorder| recorder.prompt.contains("keyboard-only")) + ); +} + +#[test] +fn sequence_prefix_conflict_uses_the_same_replace_flow() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_sequence_recording_started(KeybindingField::ToggleFloatingBadge); + let ctrl = KeyboardModifiers { + ctrl: true, + shift: false, + alt: false, + super_held: false, + }; + let _ = app.handle_shortcut_recorder_key(u32::from(b'k'), ctrl); + let _ = app.handle_shortcut_recorder_key(u32::from(b'c'), ctrl); + let _ = app.handle_shortcut_sequence_finish(); + let pending = app + .pending_shortcut_conflict + .as_ref() + .expect("Ctrl+K is the command palette"); + let prompt = pending.prompt(); + assert!(prompt.contains("Ctrl+K"), "{prompt}"); + assert!( + prompt.contains("cannot coexist") + || prompt.contains("Command Palette") + || prompt.contains("command palette"), + "{prompt}" + ); +} + +#[test] +fn text_editor_accepts_a_sequence_beside_a_single() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_shortcut_text_edit_started(KeybindingField::ToggleFloatingBadge); + let _ = app + .handle_shortcut_text_edit_changed("F5, Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string()); + let effects = app.handle_shortcut_text_edit_applied(); + assert!(effects.is_empty()); + assert!(app.pending_shortcut_conflict.is_none()); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleFloatingBadge), + Some("F5, Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C") + ); +} diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 133eecef..b2f5883f 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use wayscriber::config::{ConfigDocument, ShortcutTrigger, ToolbarItemId, ToolbarItemOrderGroup}; +use wayscriber::config::{ConfigDocument, Shortcut, ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, @@ -153,10 +153,13 @@ pub enum Message { ExportPdfLabelContentChanged(PdfLabelContentModeOption), BufferCountChanged(u32), ShortcutRecordingStarted(KeybindingField), + ShortcutSequenceRecordingStarted(KeybindingField), ShortcutRecordingCanceled(KeybindingField), ShortcutRecorderKey(u32, KeyboardModifiers), ShortcutRecorderButton(u32, RecorderDeviceKind, KeyboardModifiers), - ShortcutRemoved(KeybindingField, ShortcutTrigger), + ShortcutSequenceFinish, + ShortcutSequenceRemoveLastStep, + ShortcutRemoved(KeybindingField, Shortcut), ShortcutResetRequested(KeybindingField), ShortcutTextEditStarted(KeybindingField), ShortcutTextEditChanged(String), diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index 1cf9d15b..7cb60a42 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -1,6 +1,6 @@ //! Draft-wide shortcut conflict lookup and explicit replacement. -use wayscriber::config::{Action, ShortcutTrigger, StylusButton, action_label}; +use wayscriber::config::{Action, Shortcut, ShortcutTrigger, StylusButton, action_label}; use super::draft::KeybindingsDraft; use super::edit::{FieldEditError, append_binding, remove_binding}; @@ -18,22 +18,25 @@ pub struct ShortcutClaim { pub field: Option, pub label: String, pub source: BindingSource, + pub held: Shortcut, } impl ShortcutClaim { - fn from_field(field: KeybindingField) -> Self { + fn from_field(field: KeybindingField, held: Shortcut) -> Self { Self { field: Some(field), label: field.label().to_string(), source: BindingSource::Keybindings, + held, } } - fn legacy_tablet(action: Action) -> Self { + fn legacy_tablet(action: Action, held: Shortcut) -> Self { Self { field: None, label: format!("{} (legacy tablet barrel button)", action_label(action)), source: BindingSource::LegacyTablet, + held, } } } @@ -42,7 +45,7 @@ impl ShortcutClaim { pub enum PendingShortcutConflict { Recorded { target: KeybindingField, - binding: ShortcutTrigger, + binding: Shortcut, claimants: Vec, }, Text { @@ -80,23 +83,33 @@ impl PendingShortcutConflict { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextShortcutConflict { - pub binding: ShortcutTrigger, + pub binding: Shortcut, pub claimants: Vec, } +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: &ShortcutTrigger) -> Vec { +pub fn claimants_for(draft: &KeybindingsDraft, binding: &Shortcut) -> Vec { let mut claims = Vec::new(); for entry in &draft.entries { - if parse_keybindings(&entry.value) - .is_ok_and(|parsed| parsed.iter().any(|candidate| candidate == binding)) - { - claims.push(ShortcutClaim::from_field(entry.field)); + 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)); + claims.push(ShortcutClaim::legacy_tablet(action, binding.clone())); } claims } @@ -104,11 +117,11 @@ pub fn claimants_for(draft: &KeybindingsDraft, binding: &ShortcutTrigger) -> Vec pub fn other_claimants( draft: &KeybindingsDraft, target: KeybindingField, - binding: &ShortcutTrigger, + binding: &Shortcut, ) -> Vec { claimants_for(draft, binding) .into_iter() - .filter(|claim| claim.field != Some(target)) + .filter(|claim| claim.field != Some(target) || claim.held != *binding) .collect() } @@ -119,34 +132,42 @@ pub fn field_has_internal_duplicate(draft: &KeybindingsDraft, field: KeybindingF let Ok(parsed) = parse_keybindings(value) else { return false; }; - parsed - .iter() - .enumerate() - .any(|(index, binding)| parsed.iter().skip(index + 1).any(|other| other == binding)) + 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, - parsed: &[ShortcutTrigger], + parsed: &[Shortcut], ) -> Vec { let mut conflicts = Vec::new(); let mut seen = Vec::new(); for binding in parsed { - if seen.iter().any(|existing| existing == binding) { + if seen + .iter() + .any(|existing| shortcuts_conflict(existing, binding)) + { if !conflicts .iter() .any(|conflict: &TextShortcutConflict| conflict.binding == *binding) { conflicts.push(TextShortcutConflict { binding: binding.clone(), - claimants: vec![ShortcutClaim::from_field(target)], + claimants: vec![ShortcutClaim::from_field(target, binding.clone())], }); } continue; } seen.push(binding.clone()); - let claimants = other_claimants(draft, target, binding); + let claimants: Vec<_> = other_claimants(draft, target, binding) + .into_iter() + .filter(|claim| claim.field != Some(target)) + .collect(); if !claimants.is_empty() { conflicts.push(TextShortcutConflict { binding: binding.clone(), @@ -160,12 +181,18 @@ pub fn text_conflicts_for( pub fn apply_recorded_replace( draft: &mut KeybindingsDraft, target: KeybindingField, - binding: &ShortcutTrigger, + binding: &Shortcut, claimants: &[ShortcutClaim], ) -> Result<(), FieldEditError> { let snapshot = draft.clone(); for claim in claimants { if claim.field == Some(target) { + if claim.held != *binding + && let Err(error) = remove_binding(draft, target, &claim.held) + { + *draft = snapshot; + return Err(error); + } continue; } if claim.source == BindingSource::LegacyTablet { @@ -175,7 +202,7 @@ pub fn apply_recorded_replace( let Some(field) = claim.field else { continue; }; - if let Err(error) = remove_binding(draft, field, binding) { + if let Err(error) = remove_binding(draft, field, &claim.held) { *draft = snapshot; return Err(error); } @@ -208,7 +235,7 @@ pub fn apply_text_replace( let Some(field) = claim.field else { continue; }; - if let Err(error) = remove_binding(draft, field, &conflict.binding) { + if let Err(error) = remove_binding(draft, field, &claim.held) { *draft = snapshot; return Err(error); } @@ -220,10 +247,18 @@ pub fn apply_text_replace( Ok(()) } -pub fn recorded_conflict_prompt(binding: &ShortcutTrigger, claimants: &[ShortcutClaim]) -> String { +pub fn recorded_conflict_prompt(binding: &Shortcut, claimants: &[ShortcutClaim]) -> String { let mut lines = vec![format!("{binding} is already assigned to:")]; for claim in claimants { - lines.push(format!("- {}", claim.label)); + if claim.held == *binding { + lines.push(format!("- {}", claim.label)); + } else { + lines.push(format!( + "- {} ({})", + claim.label, + claim.held.display_label() + )); + } } lines.push(String::new()); if claimants @@ -234,14 +269,19 @@ pub fn recorded_conflict_prompt(binding: &ShortcutTrigger, claimants: &[Shortcut lines.push( "Move that legacy barrel-button assignment into this action's shortcuts?".to_string(), ); + } else if claimants.iter().any(|claim| claim.held != *binding) { + lines.push( + "Replace those assignments? Prefix and extension shortcuts cannot coexist.".to_string(), + ); } else { lines.push("Replace those assignments?".to_string()); } lines.join("\n") } -fn legacy_action_for(draft: &KeybindingsDraft, binding: &ShortcutTrigger) -> Option { - let ShortcutTrigger::Stylus(trigger) = binding else { +fn legacy_action_for(draft: &KeybindingsDraft, binding: &Shortcut) -> Option { + let trigger = binding.as_trigger()?; + let ShortcutTrigger::Stylus(trigger) = trigger else { return None; }; if trigger.ctrl || trigger.shift || trigger.alt || trigger.logo { @@ -253,8 +293,11 @@ fn legacy_action_for(draft: &KeybindingsDraft, binding: &ShortcutTrigger) -> Opt } } -fn clear_legacy_for(draft: &mut KeybindingsDraft, binding: &ShortcutTrigger) { - let ShortcutTrigger::Stylus(trigger) = binding else { +fn clear_legacy_for(draft: &mut KeybindingsDraft, binding: &Shortcut) { + let Some(trigger) = binding.as_trigger() else { + return; + }; + let ShortcutTrigger::Stylus(trigger) = trigger else { return; }; if trigger.ctrl || trigger.shift || trigger.alt || trigger.logo { @@ -272,10 +315,16 @@ fn text_conflict_prompt(conflicts: &[TextShortcutConflict]) -> String { let names = conflict .claimants .iter() - .map(|claim| claim.label.as_str()) + .map(|claim| { + if claim.held == conflict.binding { + claim.label.clone() + } else { + format!("{} ({})", claim.label, claim.held.display_label()) + } + }) .collect::>() .join(", "); - lines.push(format!("- {}: {names}", conflict.binding)); + lines.push(format!("- {}: {names}", conflict.binding.display_label())); } lines.push(String::new()); lines.push("Resolve those assignments?".to_string()); @@ -299,7 +348,7 @@ mod tests { draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); draft.set(KeybindingField::Undo, "ctrl+shift+x".to_string()); - let binding = ShortcutTrigger::parse("Ctrl+Shift+X").expect("parses"); + let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses"); let claimants = claimants_for(&draft, &binding); let fields: Vec<_> = claimants.iter().map(|claim| claim.field).collect(); assert!(fields.contains(&Some(KeybindingField::ClearCanvas))); @@ -312,7 +361,7 @@ mod tests { fn conflict_lookup_treats_meta_and_super_as_the_same_binding() { let mut draft = draft(); draft.set(KeybindingField::Undo, "Meta+X".to_string()); - let binding = ShortcutTrigger::parse("Super+X").expect("parses"); + let binding = Shortcut::parse("Super+X").expect("parses"); let claimants = claimants_for(&draft, &binding); assert_eq!(claimants.len(), 1); assert_eq!(claimants[0].field, Some(KeybindingField::Undo)); @@ -326,7 +375,7 @@ mod tests { &draft, KeybindingField::ClearCanvas )); - let binding = ShortcutTrigger::parse("E").expect("parses"); + let binding = Shortcut::parse("E").expect("parses"); let claimants = claimants_for(&draft, &binding); assert_eq!(claimants.len(), 1); assert_eq!(claimants[0].field, Some(KeybindingField::ClearCanvas)); @@ -340,7 +389,7 @@ mod tests { "F10, F1, Ctrl+Shift+X".to_string(), ); draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+X".to_string()); - let binding = ShortcutTrigger::parse("Ctrl+Shift+X").expect("parses"); + let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses"); let claimants = other_claimants(&draft, KeybindingField::ClearCanvas, &binding); apply_recorded_replace( &mut draft, @@ -367,11 +416,11 @@ mod tests { let before = draft.clone(); let _pending = PendingShortcutConflict::Recorded { target: KeybindingField::Undo, - binding: ShortcutTrigger::parse("E").expect("parses"), + binding: Shortcut::parse("E").expect("parses"), claimants: other_claimants( &draft, KeybindingField::Undo, - &ShortcutTrigger::parse("E").expect("parses"), + &Shortcut::parse("E").expect("parses"), ), }; assert_eq!(draft, before); @@ -384,9 +433,9 @@ mod tests { &draft, KeybindingField::ToggleFloatingBadge, &[ - ShortcutTrigger::parse("E").expect("parses"), - ShortcutTrigger::parse("Ctrl+Z").expect("parses"), - ShortcutTrigger::parse("E").expect("parses"), + Shortcut::parse("E").expect("parses"), + Shortcut::parse("Ctrl+Z").expect("parses"), + Shortcut::parse("E").expect("parses"), ], ); assert_eq!(conflicts.len(), 2); @@ -403,7 +452,7 @@ mod tests { fn legacy_stylus_assignment_conflicts_with_canonical_stylus_trigger() { let mut draft = draft(); draft.legacy_tablet.stylus_primary = Some(wayscriber::config::Action::ToggleRadialMenu); - let binding = ShortcutTrigger::parse("StylusPrimary").expect("parses"); + let binding = Shortcut::parse("StylusPrimary").expect("parses"); let claimants = other_claimants(&draft, KeybindingField::Undo, &binding); assert_eq!(claimants.len(), 1); assert_eq!(claimants[0].source, BindingSource::LegacyTablet); @@ -430,7 +479,50 @@ mod tests { fn modifiered_stylus_trigger_does_not_claim_the_legacy_barrel() { let mut draft = draft(); draft.legacy_tablet.stylus_primary = Some(wayscriber::config::Action::ToggleRadialMenu); - let binding = ShortcutTrigger::parse("Ctrl+StylusPrimary").expect("parses"); + let binding = Shortcut::parse("Ctrl+StylusPrimary").expect("parses"); assert!(other_claimants(&draft, KeybindingField::Undo, &binding).is_empty()); } + + #[test] + fn prefix_conflict_names_both_shortcuts() { + let mut draft = draft(); + let prefix = Shortcut::parse("Ctrl+Alt+Shift+K").expect("parses"); + let sequence = Shortcut::parse("Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C").expect("parses"); + draft.set(KeybindingField::ToggleFloatingBadge, prefix.to_string()); + let claimants = other_claimants(&draft, KeybindingField::Undo, &sequence); + assert_eq!(claimants.len(), 1); + assert_eq!( + claimants[0].field, + Some(KeybindingField::ToggleFloatingBadge) + ); + assert_eq!(claimants[0].held, prefix); + let prompt = recorded_conflict_prompt(&sequence, &claimants); + assert!(prompt.contains(&sequence.to_string()), "{prompt}"); + assert!(prompt.contains(&prefix.to_string()), "{prompt}"); + assert!(prompt.contains("Toggle Board/Page Badge"), "{prompt}"); + assert!(prompt.contains("cannot coexist"), "{prompt}"); + } + + #[test] + fn prefix_replace_removes_the_conflicting_shortcut_only() { + let mut draft = draft(); + let prefix = Shortcut::parse("Ctrl+Alt+Shift+K").expect("parses"); + let sequence = Shortcut::parse("Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C").expect("parses"); + draft.set( + KeybindingField::ToggleFloatingBadge, + format!("F8, {sequence}"), + ); + let claimants = other_claimants(&draft, KeybindingField::Undo, &prefix); + apply_recorded_replace(&mut draft, KeybindingField::Undo, &prefix, &claimants) + .expect("replace"); + assert_eq!( + draft.value_for(KeybindingField::ToggleFloatingBadge), + Some("F8") + ); + assert!( + draft + .value_for(KeybindingField::Undo) + .is_some_and(|value| value.contains(&prefix.to_string())) + ); + } } diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs index b53a61e6..d279994f 100644 --- a/configurator/src/models/keybindings/edit.rs +++ b/configurator/src/models/keybindings/edit.rs @@ -4,7 +4,7 @@ //! when the user explicitly adds, removes, resets, or applies a parsed list. //! Invalid text stays in place until one of those replacements happens. -use wayscriber::config::ShortcutTrigger; +use wayscriber::config::Shortcut; use super::draft::KeybindingsDraft; use super::field::KeybindingField; @@ -51,7 +51,7 @@ impl FieldEditError { pub fn append_binding( draft: &mut KeybindingsDraft, field: KeybindingField, - binding: &ShortcutTrigger, + binding: &Shortcut, ) -> Result { let authored = draft.value_for(field).unwrap_or("").to_string(); let parsed = parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; @@ -71,14 +71,14 @@ pub fn append_binding( pub fn remove_binding( draft: &mut KeybindingsDraft, field: KeybindingField, - binding: &ShortcutTrigger, + binding: &Shortcut, ) -> Result<(), FieldEditError> { let authored = draft.value_for(field).unwrap_or("").to_string(); parse_keybindings(&authored).map_err(FieldEditError::InvalidText)?; let mut removed = false; let mut kept = Vec::new(); for part in authored_shortcut_parts(&authored) { - if !removed && ShortcutTrigger::parse(part).is_ok_and(|parsed| parsed == *binding) { + if !removed && Shortcut::parse(part).is_ok_and(|parsed| parsed == *binding) { removed = true; continue; } @@ -102,7 +102,7 @@ fn apply_parsed_text( draft: &mut KeybindingsDraft, field: KeybindingField, text: &str, -) -> Result, FieldEditError> { +) -> Result, FieldEditError> { let parsed = parse_keybindings(text).map_err(FieldEditError::InvalidText)?; draft.set(field, text.trim().to_string()); Ok(parsed) @@ -136,7 +136,7 @@ pub fn reset_tooltip(defaults: &KeybindingsDraft, field: KeybindingField) -> Str } } -pub fn serialize_bindings(bindings: &[ShortcutTrigger]) -> String { +pub fn serialize_bindings(bindings: &[Shortcut]) -> String { bindings .iter() .map(ToString::to_string) @@ -160,7 +160,7 @@ mod tests { fn equivalent_spelling_cannot_create_a_duplicate_chip() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Undo, "ctrl+z".to_string()); - let binding = ShortcutTrigger::parse("Ctrl+Z").expect("parses"); + let binding = Shortcut::parse("Ctrl+Z").expect("parses"); let outcome = append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); assert_eq!(outcome, AppendOutcome::AlreadyPresent); assert_eq!(draft.value_for(KeybindingField::Undo), Some("ctrl+z")); @@ -170,7 +170,7 @@ mod tests { fn removing_one_chip_preserves_siblings_and_authored_spelling() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Redo, "ctrl+shift+z, Ctrl+Y".to_string()); - let binding = ShortcutTrigger::parse("Ctrl+Y").expect("parses"); + let binding = Shortcut::parse("Ctrl+Y").expect("parses"); remove_binding(&mut draft, KeybindingField::Redo, &binding).expect("remove"); assert_eq!(draft.value_for(KeybindingField::Redo), Some("ctrl+shift+z")); } @@ -178,7 +178,7 @@ mod tests { #[test] fn removing_the_last_chip_unbinds_instead_of_resetting() { let (mut draft, defaults) = draft(); - let binding = ShortcutTrigger::parse("E").expect("parses"); + 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( @@ -226,7 +226,7 @@ mod tests { fn invalid_raw_text_blocks_parsed_edits_and_stays_visible() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Exit, "Ctrl+Shift".to_string()); - let binding = ShortcutTrigger::parse("F5").expect("parses"); + let binding = Shortcut::parse("F5").expect("parses"); let error = append_binding(&mut draft, KeybindingField::Exit, &binding) .expect_err("invalid text cannot append"); assert!(error.message().contains("No key specified")); @@ -263,11 +263,24 @@ mod tests { fn mouse_and_stylus_strings_append_as_chips() { let (mut draft, _defaults) = draft(); draft.set(KeybindingField::Undo, "".to_string()); - let binding = ShortcutTrigger::parse("Ctrl+MouseBack").expect("parses"); + let binding = Shortcut::parse("Ctrl+MouseBack").expect("parses"); append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); assert_eq!( draft.value_for(KeybindingField::Undo), Some("Ctrl+MouseBack") ); } + + #[test] + fn sequences_append_in_storage_form() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Undo, "".to_string()); + let binding = Shortcut::parse("Ctrl+K > Ctrl+C").expect("parses"); + append_binding(&mut draft, KeybindingField::Undo, &binding).expect("append"); + assert_eq!( + draft.value_for(KeybindingField::Undo), + Some("Ctrl+K > Ctrl+C") + ); + assert_eq!(binding.display_label(), "Ctrl+K then Ctrl+C"); + } } diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index 8b49baf7..a0b080b7 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -22,7 +22,8 @@ pub(crate) use recording::keyval; pub use recording::tablet_unavailable_hint; pub use recording::{ KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, ShortcutRecorderState, - normalize_button_event, normalize_key_event, super_consumed_hint, waiting_prompt, + normalize_button_event, normalize_key_event, sequence_keyboard_only_message, + super_consumed_hint, waiting_prompt, }; #[cfg(test)] diff --git a/configurator/src/models/keybindings/parse.rs b/configurator/src/models/keybindings/parse.rs index 455885ea..d01afb73 100644 --- a/configurator/src/models/keybindings/parse.rs +++ b/configurator/src/models/keybindings/parse.rs @@ -1,4 +1,4 @@ -use wayscriber::config::ShortcutTrigger; +use wayscriber::config::Shortcut; pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { value @@ -11,16 +11,16 @@ pub(crate) fn authored_shortcut_parts(value: &str) -> Vec<&str> { pub(crate) fn parse_keybinding_list(value: &str) -> Result, String> { let mut entries = Vec::new(); for part in authored_shortcut_parts(value) { - ShortcutTrigger::parse(part)?; + Shortcut::parse(part)?; entries.push(part.to_string()); } Ok(entries) } -pub(crate) fn parse_keybindings(value: &str) -> Result, String> { +pub(crate) fn parse_keybindings(value: &str) -> Result, String> { let mut entries = Vec::new(); for part in authored_shortcut_parts(value) { - entries.push(ShortcutTrigger::parse(part)?); + entries.push(Shortcut::parse(part)?); } Ok(entries) } diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs index 85e5e9d9..ecbbaa36 100644 --- a/configurator/src/models/keybindings/recording.rs +++ b/configurator/src/models/keybindings/recording.rs @@ -6,8 +6,9 @@ #[cfg(feature = "tablet-input")] use wayscriber::config::StylusTrigger; +use wayscriber::config::keybindings::MAX_SEQUENCE_STEPS; use wayscriber::config::keybindings::{MAX_POINTER_EXTRA, gdk}; -use wayscriber::config::{KeyBinding, PointerTrigger, ShortcutTrigger}; +use wayscriber::config::{KeyBinding, PointerTrigger, Shortcut, ShortcutTrigger}; use super::field::KeybindingField; @@ -78,6 +79,7 @@ pub enum RecordedDevice { pub struct ShortcutRecorderState { pub field: KeybindingField, pub prompt: String, + sequence_steps: Option>, } impl ShortcutRecorderState { @@ -85,6 +87,64 @@ impl ShortcutRecorderState { Self { field, prompt: waiting_prompt(), + sequence_steps: None, + } + } + + pub fn new_sequence(field: KeybindingField) -> Self { + Self { + field, + prompt: sequence_waiting_prompt(&[]), + sequence_steps: Some(Vec::new()), + } + } + + pub fn is_sequence(&self) -> bool { + self.sequence_steps.is_some() + } + + pub fn can_finish(&self) -> bool { + self.sequence_steps + .as_ref() + .is_some_and(|steps| steps.len() >= 2) + } + + pub fn can_remove_last(&self) -> bool { + self.sequence_steps + .as_ref() + .is_some_and(|steps| !steps.is_empty()) + } + + pub fn apply_pending_preview(&mut self, preview: String) { + match &self.sequence_steps { + Some(steps) => self.prompt = sequence_pending_prompt(steps, &preview), + None => self.prompt = preview, + } + } + + /// Record a keyboard chord. Returns the finished shortcut when recording + /// should commit (a single chord, or a sequence that reached three steps). + pub fn push_keyboard_step(&mut self, binding: KeyBinding) -> Option { + let Some(steps) = self.sequence_steps.as_mut() else { + return Some(Shortcut::from(binding)); + }; + steps.push(binding); + if steps.len() >= MAX_SEQUENCE_STEPS { + return Some(Shortcut::Sequence(steps.clone())); + } + self.prompt = sequence_waiting_prompt(steps); + None + } + + pub fn finish_sequence(&self) -> Option { + let steps = self.sequence_steps.as_ref()?; + (steps.len() >= 2).then(|| Shortcut::Sequence(steps.clone())) + } + + pub fn remove_last_step(&mut self) { + if let Some(steps) = self.sequence_steps.as_mut() { + steps.pop(); + self.prompt = sequence_waiting_prompt(steps); } } } @@ -93,6 +153,38 @@ pub fn waiting_prompt() -> String { "Press a shortcut.".to_string() } +fn sequence_waiting_prompt(steps: &[KeyBinding]) -> String { + match steps { + [] => "Press the first chord.".to_string(), + [first] => format!("{first} then …"), + [first, second] => format!("{first} then {second} then …"), + _ => steps + .iter() + .map(ToString::to_string) + .collect::>() + .join(" then "), + } +} + +fn sequence_pending_prompt(steps: &[KeyBinding], preview: &str) -> String { + if steps.is_empty() { + preview.to_string() + } else { + format!( + "{} then {preview}", + steps + .iter() + .map(ToString::to_string) + .collect::>() + .join(" then ") + ) + } +} + +pub fn sequence_keyboard_only_message() -> &'static str { + "Sequences are keyboard-only." +} + /// Map a GDK/X11 keyval and current modifiers onto the shared binding type. pub fn normalize_key_event(keyval: u32, modifiers: KeyboardModifiers) -> RecordedKeyboard { if is_supported_modifier_key(keyval) { @@ -612,4 +704,46 @@ mod tests { other => panic!("expected unavailable tablet message, got {other:?}"), } } + + #[test] + fn sequence_recorder_finishes_at_two_and_auto_completes_at_three() { + let mut recorder = ShortcutRecorderState::new_sequence(KeybindingField::Undo); + assert!(recorder.is_sequence()); + assert!(!recorder.can_finish()); + assert!(!recorder.can_remove_last()); + assert_eq!(recorder.prompt, "Press the first chord."); + + let first = chord(u32::from(b'k'), mods(true, false, false, false)); + assert!(recorder.push_keyboard_step(first).is_none()); + assert!(recorder.can_remove_last()); + assert!(!recorder.can_finish()); + assert_eq!(recorder.prompt, "Ctrl+K then …"); + + let second = chord(u32::from(b'c'), mods(true, false, false, false)); + assert!(recorder.push_keyboard_step(second).is_none()); + assert!(recorder.can_finish()); + assert_eq!( + recorder + .finish_sequence() + .map(|shortcut| shortcut.to_string()), + Some("Ctrl+K > Ctrl+C".to_string()) + ); + + let third = chord(u32::from(b'v'), mods(true, false, false, false)); + let finished = recorder + .push_keyboard_step(third) + .expect("third step auto-finishes"); + assert_eq!(finished.to_string(), "Ctrl+K > Ctrl+C > Ctrl+V"); + assert_eq!(finished.display_label(), "Ctrl+K then Ctrl+C then Ctrl+V"); + } + + #[test] + fn sequence_recorder_can_remove_the_last_step() { + let mut recorder = ShortcutRecorderState::new_sequence(KeybindingField::Undo); + let first = chord(u32::from(b'k'), mods(true, false, false, false)); + recorder.push_keyboard_step(first); + recorder.remove_last_step(); + assert!(!recorder.can_remove_last()); + assert_eq!(recorder.prompt, "Press the first chord."); + } } diff --git a/configurator/src/models/keybindings/tests.rs b/configurator/src/models/keybindings/tests.rs index 93909ecc..60423710 100644 --- a/configurator/src/models/keybindings/tests.rs +++ b/configurator/src/models/keybindings/tests.rs @@ -4,7 +4,7 @@ use wayscriber::config::keybindings::KeybindingsConfig; use super::draft::KeybindingsDraft; use super::field::KeybindingField; -use super::parse::parse_keybinding_list; +use super::parse::{parse_keybinding_list, parse_keybindings}; use crate::models::KeybindingsTabId; #[test] @@ -13,6 +13,14 @@ fn parse_keybinding_list_trims_and_ignores_empty() { assert_eq!(parsed, vec!["Ctrl+Z".to_string(), "Alt+K".to_string()]); } +#[test] +fn parse_keybinding_list_accepts_a_sequence_beside_a_single() { + let parsed = parse_keybindings("F5, Ctrl+K > Ctrl+C").expect("parse succeeds"); + assert_eq!(parsed[0].to_string(), "F5"); + assert_eq!(parsed[1].to_string(), "Ctrl+K > Ctrl+C"); + assert_eq!(parsed[1].display_label(), "Ctrl+K then Ctrl+C"); +} + #[test] fn parse_keybinding_list_rejects_unparseable_shortcuts() { let error = parse_keybinding_list("Ctrl+Shift, Escape").expect_err("modifiers-only is invalid"); diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ef63d6c6..5b06555d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1603,9 +1603,15 @@ A shortcut is a key name, auxiliary mouse button, or stylus barrel button with o modifiers joined by `+`. The modifiers are `Ctrl` (`Control`), `Shift`, `Alt`, and `Super` (`Meta`, `Logo`, `Win`, `Windows`). Matching, conflicts, and display use the canonical spelling `Super`. Examples: `Escape`, `Ctrl+Z`, `Super+X`, `Ctrl+Shift+T`, `F10`, -`MouseBack`, `Ctrl+MouseForward`, `StylusPrimary`. Super chords work when the compositor -delivers them; if the desktop consumes Super before Wayscriber or the configurator sees it, -type the shortcut with Edit as Text. +`MouseBack`, `Ctrl+MouseForward`, `StylusPrimary`, `Ctrl+K > Ctrl+C`. Super chords work when +the compositor delivers them; if the desktop consumes Super before Wayscriber or the +configurator sees it, type the shortcut with Edit as Text. + +Two or three keyboard chords can be chained with `>` (`Ctrl+K > Ctrl+C`). Help and the +configurator show that as `Ctrl+K then Ctrl+C`. A comma still separates independent +shortcuts on one action. A standalone chord cannot also be a prefix of a sequence; two +sequences may share a first chord and diverge later. After a prefix, Wayscriber waits up +to one second for the next step. Auxiliary mouse buttons `MouseBack`, `MouseForward`, and `MouseExtra1` through `MouseExtra4` can be bound to actions. Left, middle, and right cannot: they already own drawing and @@ -1950,9 +1956,14 @@ Keybindings are specified as strings with modifiers and a key or device button s - With modifiers: `"Ctrl+Z"`, `"Shift+T"`, `"Ctrl+Shift+W"`, `"Super+X"` - Auxiliary mouse buttons: `"MouseBack"`, `"MouseForward"`, `"MouseExtra1"` through `"MouseExtra4"` - Stylus barrel buttons: `"StylusPrimary"`, `"StylusSecondary"` +- Keyboard sequences (two or three chords): `"Ctrl+K > Ctrl+C"`. Help and the configurator show this as `Ctrl+K then Ctrl+C`. - Special keys: `"Escape"`, `"Return"`, `"Backspace"`, `"Space"`, `"F10"`, `"F11"`, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"`, `"ArrowUp"`, `"ArrowDown"`, `"ArrowLeft"`, `"ArrowRight"`, `"+"`, `"-"`, `"="`, `"_"` -Left, middle, and right mouse buttons cannot be bound as generic actions. +Left, middle, and right mouse buttons cannot be bound as generic actions. Pointer and stylus buttons cannot appear as sequence steps. + +A comma still separates independent shortcuts on one action (`F5, Ctrl+K > Ctrl+C`). Spaces around `>` are optional in the file and canonicalized to spaced `>` when that binding is rewritten. + +A standalone chord cannot also be a prefix of a configured sequence (`Ctrl+K` and `Ctrl+K > Ctrl+C` cannot coexist). Two sequences may share a first chord and diverge later (`Ctrl+K > Ctrl+C` and `Ctrl+K > Ctrl+X`). After a prefix is pressed, Wayscriber waits up to one second for the next step; focus loss, overlay exit, a modal, a keymap reload, or a mismatch cancels the pending sequence. Key repeat does not advance a sequence. **Supported Modifiers:** - `Ctrl` (or `Control`) diff --git a/src/app/usage.rs b/src/app/usage.rs index df1638b2..25c432f4 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -1,11 +1,9 @@ use std::collections::HashMap; -use crate::config::{ - Action, KeybindingsConfig, ShortcutTrigger, action_display_label, action_label, -}; +use crate::config::{Action, KeybindingsConfig, Shortcut, action_display_label, action_label}; use crate::label_format::format_binding_labels; -fn default_action_bindings() -> HashMap> { +fn default_action_bindings() -> HashMap> { match KeybindingsConfig::default().build_action_bindings() { Ok(bindings) => bindings, Err(err) => { @@ -15,34 +13,28 @@ fn default_action_bindings() -> HashMap> { } } -fn action_binding_labels( - bindings: &HashMap>, - action: Action, -) -> Vec { +fn action_binding_labels(bindings: &HashMap>, action: Action) -> Vec { bindings .get(&action) - .map(|list| list.iter().map(ToString::to_string).collect()) + .map(|list| list.iter().map(Shortcut::display_label).collect()) .unwrap_or_default() } -fn action_binding_label( - bindings: &HashMap>, - action: Action, -) -> String { +fn action_binding_label(bindings: &HashMap>, action: Action) -> String { format_binding_labels(&action_binding_labels(bindings, action)) } fn action_primary_binding_label( - bindings: &HashMap>, + bindings: &HashMap>, action: Action, ) -> Option { bindings .get(&action) .and_then(|list| list.first()) - .map(|binding| binding.to_string()) + .map(Shortcut::display_label) } -fn color_binding_labels(bindings: &HashMap>) -> String { +fn color_binding_labels(bindings: &HashMap>) -> String { let colors = [ Action::SetColorRed, Action::SetColorGreen, diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 7675818e..653f5b5a 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -171,6 +171,7 @@ pub(super) fn run_event_loop( let timeout = min_timeout(timeout, state.input_state.radial_menu_paint_timeout(now)); // A held key must wake the loop to fire its next auto-repeat. let timeout = min_timeout(timeout, state.key_repeat_timeout(now)); + let timeout = min_timeout(timeout, state.input_state.sequence_timeout(now)); if let Err(e) = dispatch::dispatch_events(event_queue, state, runtime_wake, signal_state, timeout) { @@ -262,6 +263,9 @@ pub(super) fn run_event_loop( // wired to this manual loop). `dispatch_key_repeat` sets needs_redraw // as its routed action requires. state.tick_key_repeat(Instant::now(), conn, qh); + if state.input_state.expire_pending_sequence(Instant::now()) { + state.input_state.needs_redraw = true; + } if !capture_active && state.ui_animation_due(std::time::Instant::now()) { state.input_state.needs_redraw = true; diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 3dbf6135..75741a7b 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -1,7 +1,7 @@ use log::warn; use std::collections::HashMap; -use crate::config::{Action, Config, KeybindingsConfig, QuickColorPalette, ShortcutTrigger}; +use crate::config::{Action, Config, KeybindingsConfig, QuickColorPalette, Shortcut}; use crate::draw::{FontDescriptor, clamp_regular_sides}; use crate::input::{ClickHighlightSettings, DragToolBindings, InputHudSettings, InputState}; @@ -119,7 +119,7 @@ fn build_drag_tool_bindings(config: &Config) -> DragToolBindings { DragToolBindings::from_config(&drag_tools) } -fn build_action_map(config: &Config) -> HashMap { +fn build_action_map(config: &Config) -> HashMap { match config.keybindings.build_action_map() { Ok(map) => map, Err(err) => { @@ -140,7 +140,7 @@ fn build_action_map(config: &Config) -> HashMap { } } -fn build_action_bindings(config: &Config) -> HashMap> { +fn build_action_bindings(config: &Config) -> HashMap> { match config.keybindings.build_action_bindings() { Ok(map) => map, Err(err) => { diff --git a/src/backend/wayland/config_edits/tests.rs b/src/backend/wayland/config_edits/tests.rs index dd0df5ff..29c44758 100644 --- a/src/backend/wayland/config_edits/tests.rs +++ b/src/backend/wayland/config_edits/tests.rs @@ -731,14 +731,14 @@ fn an_answered_edit_gives_its_projection_back_even_when_the_write_failed() { /// An input state the way the overlay's own suites build one, so the gestures /// below are recorded by the real code rather than poked into a field. fn test_input_state() -> crate::input::state::InputState { - use crate::config::{Action, BoardsConfig, PresenterModeConfig, ShortcutTrigger}; + use crate::config::{Action, BoardsConfig, PresenterModeConfig, Shortcut}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode}; use std::collections::HashMap; let mut action_map = HashMap::new(); action_map.insert( - ShortcutTrigger::parse("Escape").expect("a chord this test spelled"), + Shortcut::parse("Escape").expect("a chord this test spelled"), Action::Exit, ); crate::input::state::InputState::with_defaults( diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index f04486e4..e69170d8 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -417,7 +417,7 @@ impl WaylandState { { return; } - self.apply_input_key(key); + self.apply_input_key_repeat(key); } } diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index f2971820..09812547 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -301,6 +301,7 @@ impl WaylandState { let Some(action) = self.input_state.find_trigger_action(&trigger) else { return false; }; + self.input_state.clear_pending_sequence(); self.input_state.consume_pointer_shortcut_button(button); debug!("Pointer shortcut {trigger}: dispatching {action:?}"); self.dispatch_input_action(action); diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index f98d3526..4aec110a 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -294,6 +294,7 @@ impl WaylandState { fn dispatch_stylus_button_press(&mut self, button: u32) { if let Some(action) = stylus_barrel_action(&self.input_state, button, &self.config.tablet) { debug!("Stylus button {}: dispatching {:?}", button, action); + self.input_state.clear_pending_sequence(); self.dispatch_input_action(action); } else if linux::stylus_button(button).is_none() { debug!("Ignoring unknown stylus button {}", button); diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 153ff2bb..5f85025b 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::{Action, BoardsConfig, Config, PresenterModeConfig, ShortcutTrigger}; +use crate::config::{Action, BoardsConfig, Config, PresenterModeConfig, Shortcut}; use crate::draw::{ Color, EraserKind, FontDescriptor, Frame, PageDeleteOutcome, REGULAR_POLYGON_DEFAULT_SIDES, Shape, ShapeId, @@ -105,7 +105,7 @@ fn can_create_probe(parent: &Path) -> bool { fn test_input_state() -> InputState { let mut action_map = HashMap::new(); - action_map.insert(ShortcutTrigger::parse("Escape").unwrap(), Action::Exit); + action_map.insert(Shortcut::parse("Escape").unwrap(), Action::Exit); InputState::with_defaults( Color { r: 1.0, diff --git a/src/backend/wayland/state/input_actions.rs b/src/backend/wayland/state/input_actions.rs index 0b1645cc..095aff5f 100644 --- a/src/backend/wayland/state/input_actions.rs +++ b/src/backend/wayland/state/input_actions.rs @@ -33,6 +33,10 @@ impl WaylandState { self.apply_input_update(|input_state| input_state.on_key_press(key)); } + pub(in crate::backend::wayland) fn apply_input_key_repeat(&mut self, key: Key) { + self.apply_input_update(|input_state| input_state.on_key_repeat(key)); + } + pub(in crate::backend::wayland) fn dispatch_input_action(&mut self, action: Action) { self.apply_input_update(|input_state| input_state.handle_action(action)); } diff --git a/src/backend/wayland/state/keybindings.rs b/src/backend/wayland/state/keybindings.rs index d71fe03f..470fab9a 100644 --- a/src/backend/wayland/state/keybindings.rs +++ b/src/backend/wayland/state/keybindings.rs @@ -56,8 +56,8 @@ use super::super::config_edits::{ }; use super::WaylandState; use crate::config::{ - Action, ConfigEditNotReadBack, ConfigEditOutcome, ConfigEditWrite, KeybindingsConfig, - ShortcutClaimedOnDisk, ShortcutTrigger, action_label, + Action, ConfigEditNotReadBack, ConfigEditOutcome, ConfigEditWrite, KeybindingsConfig, Shortcut, + ShortcutClaimedOnDisk, action_label, }; use crate::input::state::{ InputState, KeybindingEditOperation, KeybindingEditRequest, Toast, ToastPriority, diff --git a/src/backend/wayland/state/keybindings/implementation.rs b/src/backend/wayland/state/keybindings/implementation.rs index de8764d7..99749dc1 100644 --- a/src/backend/wayland/state/keybindings/implementation.rs +++ b/src/backend/wayland/state/keybindings/implementation.rs @@ -51,11 +51,11 @@ fn apply_keybinding_edit( let claimed = other_bindings.claimed_keys(); let mut requested = HashMap::new(); for binding_text in &bindings { - let binding = ShortcutTrigger::parse(binding_text).map_err(KeybindingEditError::Edit)?; - if let Some(existing_action) = claimed.get(&binding) { + let binding = Shortcut::parse(binding_text).map_err(KeybindingEditError::Edit)?; + if let Some(existing_action) = binding.claimed_by(&claimed) { return Err(KeybindingEditError::Conflict { binding: binding_text.clone(), - existing_action: *existing_action, + existing_action, }); } if let Some(first) = requested.insert(binding, binding_text.clone()) { @@ -265,8 +265,8 @@ fn prepare_keybinding_edit( /// The run's keymap, and the two views built from it, after one edit lands. struct InstalledKeybindings { keybindings: KeybindingsConfig, - action_map: HashMap, - action_bindings: HashMap>, + action_map: HashMap, + action_bindings: HashMap>, } /// Fold one landed edit into the keymap the run holds *now*. diff --git a/src/backend/wayland/state/keybindings/implementation/tests/completion.rs b/src/backend/wayland/state/keybindings/implementation/tests/completion.rs index 9e069603..a931192d 100644 --- a/src/backend/wayland/state/keybindings/implementation/tests/completion.rs +++ b/src/backend/wayland/state/keybindings/implementation/tests/completion.rs @@ -185,8 +185,8 @@ fn a_second_edits_completion_keeps_the_first_edits_chord() { .bindings_for_action(Action::SelectMarkerTool), Some(&["Ctrl+Alt+Shift+M".to_string()][..]) ); - let pen_chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+P").expect("a parseable chord"); - let marker_chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+M").expect("a parseable chord"); + let pen_chord = Shortcut::parse("Ctrl+Alt+Shift+P").expect("a parseable chord"); + let marker_chord = Shortcut::parse("Ctrl+Alt+Shift+M").expect("a parseable chord"); assert_eq!( after_both.action_map.get(&pen_chord), Some(&Action::SelectPenTool) diff --git a/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs b/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs index 322bfe0e..972095a3 100644 --- a/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs +++ b/src/backend/wayland/state/keybindings/implementation/tests/preparation.rs @@ -173,7 +173,7 @@ fn an_edited_keymap_still_builds_both_runtime_views() { .build_action_bindings() .expect("action bindings"); - let chord = ShortcutTrigger::parse("Ctrl+Alt+Shift+K").expect("a parseable chord"); + let chord = Shortcut::parse("Ctrl+Alt+Shift+K").expect("a parseable chord"); assert_eq!(action_map.get(&chord), Some(&Action::ClearCanvas)); assert_eq!( action_bindings.get(&Action::ClearCanvas), diff --git a/src/config/io.rs b/src/config/io.rs index 857d1a0f..89bb1947 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,6 +1,6 @@ use super::ColorSpec; use super::action_meta::action_label; -use super::keybindings::{Action, KeybindingAuthorship, ShortcutTrigger}; +use super::keybindings::{Action, KeybindingAuthorship, Shortcut}; use super::paths::primary_config_dir; use super::types::{PRESET_SLOTS_MAX, ToolPresetConfig}; use super::validate::ConfigValidationReport; @@ -576,11 +576,11 @@ fn refuse_shortcut_claimed_on_disk( .map_err(|error| anyhow!(error))?; let claimed = others.claimed_keys(); for text in bindings { - let binding = ShortcutTrigger::parse(text).map_err(|error| anyhow!(error))?; - if let Some(owner) = claimed.get(&binding) { + let binding = Shortcut::parse(text).map_err(|error| anyhow!(error))?; + if let Some(owner) = binding.claimed_by(&claimed) { return Err(anyhow!(ShortcutClaimedOnDisk { binding: text.clone(), - claimed_by: *owner, + claimed_by: owner, })); } } diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 052ffe52..81cbae53 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -14,8 +14,8 @@ pub use authorship::KeybindingAuthorship; pub use binding::{KeyBinding, NAMED_KEYS, is_deliverable_key_name, suggest_key_name}; pub use config::{KeybindingConflict, KeybindingsConfig}; pub use shortcut::{ - MAX_POINTER_EXTRA, PointerButton, PointerTrigger, ShortcutTrigger, StylusButton, StylusTrigger, - gdk, linux, + MAX_POINTER_EXTRA, MAX_SEQUENCE_STEPS, PointerButton, PointerTrigger, Shortcut, + ShortcutTrigger, StylusButton, StylusTrigger, gdk, linux, }; /// The compiled-in default keymap, built once per process. diff --git a/src/config/keybindings/AGENTS.md b/src/config/keybindings/AGENTS.md index 6770b283..a44d4b3f 100644 --- a/src/config/keybindings/AGENTS.md +++ b/src/config/keybindings/AGENTS.md @@ -7,7 +7,8 @@ ## Architecture - `actions.rs` owns action enums. - `binding.rs` owns keyboard chord parsing/display. -- `shortcut.rs` owns the unified trigger type (keyboard, pointer, stylus). +- `shortcut.rs` owns the unified shortcut type (single keyboard/pointer/stylus + triggers and keyboard sequences). - `defaults/` owns default bindings. - `config/` owns typed keybinding config and per-domain action maps. diff --git a/src/config/keybindings/config/map/mod.rs b/src/config/keybindings/config/map/mod.rs index 1c0256b5..56fe97e9 100644 --- a/src/config/keybindings/config/map/mod.rs +++ b/src/config/keybindings/config/map/mod.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use super::super::{Action, ShortcutTrigger}; +use super::super::{Action, Shortcut}; use super::types::KeybindingsConfig; mod board; @@ -22,13 +22,13 @@ mod zoom; /// action lists more than once yields a one-element `actions`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeybindingConflict { - binding: ShortcutTrigger, + binding: Shortcut, actions: Vec, } impl KeybindingConflict { /// The parsed shortcut every listed action claims. - pub fn binding(&self) -> &ShortcutTrigger { + pub fn binding(&self) -> &Shortcut { &self.binding } @@ -41,11 +41,11 @@ impl KeybindingConflict { #[derive(Default)] struct ConflictLog { entries: Vec, - positions: HashMap, + positions: HashMap, } impl ConflictLog { - fn record(&mut self, binding: &ShortcutTrigger, first: Action, next: Action) { + fn record(&mut self, binding: &Shortcut, first: Action, next: Action) { match self.positions.get(binding) { Some(&position) => { let actions = &mut self.entries[position].actions; @@ -69,8 +69,8 @@ impl ConflictLog { } struct BindingInserter<'a> { - map: &'a mut HashMap, - ordered: Option<&'a mut HashMap>>, + map: &'a mut HashMap, + ordered: Option<&'a mut HashMap>>, conflicts: Option<&'a mut ConflictLog>, /// Whether a bad binding string or a duplicate key is data rather than a /// failure. Set only for the views that answer "which keys are taken", @@ -79,7 +79,7 @@ struct BindingInserter<'a> { } impl<'a> BindingInserter<'a> { - fn new(map: &'a mut HashMap) -> Self { + fn new(map: &'a mut HashMap) -> Self { Self { map, ordered: None, @@ -89,8 +89,8 @@ impl<'a> BindingInserter<'a> { } fn new_with_order( - map: &'a mut HashMap, - ordered: &'a mut HashMap>, + map: &'a mut HashMap, + ordered: &'a mut HashMap>, ) -> Self { Self { map, @@ -101,7 +101,7 @@ impl<'a> BindingInserter<'a> { } fn new_collecting( - map: &'a mut HashMap, + map: &'a mut HashMap, conflicts: &'a mut ConflictLog, ) -> Self { Self { @@ -112,7 +112,7 @@ impl<'a> BindingInserter<'a> { } } - fn new_tolerant(map: &'a mut HashMap) -> Self { + fn new_tolerant(map: &'a mut HashMap) -> Self { Self { map, ordered: None, @@ -121,14 +121,34 @@ impl<'a> BindingInserter<'a> { } } + fn prefix_owner(&self, binding: &Shortcut) -> Option<(Shortcut, Action)> { + self.map.iter().find_map(|(existing, action)| { + existing + .prefix_conflicts_with(binding) + .then(|| (existing.clone(), *action)) + }) + } + fn insert(&mut self, binding_str: &str, action: Action) -> Result<(), String> { - let binding = match ShortcutTrigger::parse(binding_str) { + let binding = match Shortcut::parse(binding_str) { Ok(binding) => binding, // A string the parser rejects binds nothing at runtime, so a view // of the keys in effect has nothing to record and nothing to say. Err(_) if self.tolerant => return Ok(()), Err(error) => return Err(error), }; + if let Some((other, existing_action)) = self.prefix_owner(&binding) { + if let Some(conflicts) = self.conflicts.as_mut() { + conflicts.record(&binding, existing_action, action); + return Ok(()); + } + if self.tolerant { + return Ok(()); + } + return Err(format!( + "Sequence prefix conflict: '{binding}' for {action:?} cannot coexist with '{other}' for {existing_action:?}" + )); + } let Some(existing_action) = self.map.insert(binding.clone(), action) else { if let Some(ordered) = self.ordered.as_mut() { ordered.entry(action).or_default().push(binding); @@ -179,7 +199,7 @@ impl KeybindingsConfig { /// Build a lookup map from keybindings to actions for efficient matching. /// Returns an error if any keybinding string is invalid or if duplicates are detected. - pub fn build_action_map(&self) -> Result, String> { + pub fn build_action_map(&self) -> Result, String> { let mut map = HashMap::new(); let mut inserter = BindingInserter::new(&mut map); self.insert_every_binding(&mut inserter)?; @@ -188,7 +208,7 @@ impl KeybindingsConfig { /// Build an ordered list of keybindings per action. /// Returns an error if any keybinding string is invalid or if duplicates are detected. - pub fn build_action_bindings(&self) -> Result>, String> { + pub fn build_action_bindings(&self) -> Result>, String> { let mut map = HashMap::new(); let mut ordered = HashMap::new(); let mut inserter = BindingInserter::new_with_order(&mut map, &mut ordered); @@ -222,7 +242,7 @@ impl KeybindingsConfig { /// claims nothing at runtime either. That keeps an editor able to accept an /// edit to an unrelated action while a tolerated problem sits elsewhere in /// the file (#293). - pub fn claimed_keys(&self) -> HashMap { + pub fn claimed_keys(&self) -> HashMap { let mut map = HashMap::new(); let mut inserter = BindingInserter::new_tolerant(&mut map); // The tolerant inserter reports nothing, so there is no error to diff --git a/src/config/keybindings/shortcut.rs b/src/config/keybindings/shortcut.rs index ad4a8013..0c3f1f0b 100644 --- a/src/config/keybindings/shortcut.rs +++ b/src/config/keybindings/shortcut.rs @@ -1,8 +1,9 @@ -//! Single-trigger shortcuts: keyboard chords, auxiliary pointer buttons, and -//! stylus barrel buttons. +//! Shortcut identity: a single trigger or a short keyboard sequence. //! -//! Existing keyboard strings keep parsing as [`ShortcutTrigger::Keyboard`]. -//! Pointer and stylus names are reserved and never stored as key names. +//! Existing keyboard strings keep parsing as +//! [`Shortcut::Single`]`(`[`ShortcutTrigger::Keyboard`]`)`. Pointer and stylus +//! names are reserved and never stored as key names. Sequences use `>` between +//! keyboard chords (`Ctrl+K > Ctrl+C`). use std::fmt; use std::hash::{Hash, Hasher}; @@ -14,6 +15,9 @@ use super::binding::{ /// Highest `MouseExtraN` index accepted in this release (`MouseExtra1`..=`MouseExtra4`). pub const MAX_POINTER_EXTRA: u8 = 4; +/// Maximum keyboard chords in a sequence (`Ctrl+K > Ctrl+C > Ctrl+V`). +pub const MAX_SEQUENCE_STEPS: usize = 3; + /// Auxiliary mouse buttons that can dispatch configured actions. /// /// Left, middle, and right stay drawing/UI controls and cannot be bound here. @@ -268,6 +272,207 @@ impl fmt::Display for ShortcutTrigger { } } +/// One configured shortcut: a single trigger or a two-to-three-step keyboard +/// sequence. +/// +/// Storage uses spaced `>` between steps. User-facing labels use `then`. +#[derive(Debug, Clone, Eq)] +pub enum Shortcut { + Single(ShortcutTrigger), + Sequence(Vec), +} + +impl PartialEq for Shortcut { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Single(left), Self::Single(right)) => left == right, + (Self::Sequence(left), Self::Sequence(right)) => left == right, + _ => false, + } + } +} + +impl Hash for Shortcut { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Single(trigger) => trigger.hash(state), + Self::Sequence(steps) => steps.hash(state), + } + } +} + +impl From for Shortcut { + fn from(trigger: ShortcutTrigger) -> Self { + Self::Single(trigger) + } +} + +impl From for Shortcut { + fn from(binding: KeyBinding) -> Self { + Self::Single(ShortcutTrigger::Keyboard(binding)) + } +} + +impl Shortcut { + /// Parse one shortcut string: a single trigger or a keyboard sequence. + pub fn parse(s: &str) -> Result { + let Some(parts) = split_sequence_steps(s) else { + return ShortcutTrigger::parse(s).map(Self::Single); + }; + if parts.iter().any(|part| part.is_empty()) { + if parses_as_greater_than_key(s) { + return ShortcutTrigger::parse(s).map(Self::Single); + } + return Err( + "Empty sequence step. Use a complete chord on each side of `>`, for example Ctrl+K > Ctrl+C." + .to_string(), + ); + } + if parts.len() == 1 { + return ShortcutTrigger::parse(parts[0]).map(Self::Single); + } + parse_keyboard_sequence(&parts) + } + + pub fn as_trigger(&self) -> Option<&ShortcutTrigger> { + match self { + Self::Single(trigger) => Some(trigger), + Self::Sequence(_) => None, + } + } + + pub fn as_key_binding(&self) -> Option<&KeyBinding> { + self.as_trigger().and_then(ShortcutTrigger::as_key_binding) + } + + /// Keyboard chords that make up this shortcut, if it is keyboard-only. + pub fn keyboard_steps(&self) -> Option<&[KeyBinding]> { + match self { + Self::Single(ShortcutTrigger::Keyboard(binding)) => Some(std::slice::from_ref(binding)), + Self::Sequence(steps) => Some(steps.as_slice()), + Self::Single(_) => None, + } + } + + /// Whether one keyboard shortcut is a strict prefix of the other. + /// + /// A standalone chord that starts a sequence, or a shorter sequence that + /// starts a longer one, cannot be dispatched without delaying the prefix. + pub fn prefix_conflicts_with(&self, other: &Self) -> bool { + let Some(left) = self.keyboard_steps() else { + return false; + }; + let Some(right) = other.keyboard_steps() else { + return false; + }; + left != right && (left.starts_with(right) || right.starts_with(left)) + } + + /// Label for chips, help, and the command palette (`Ctrl+K then Ctrl+C`). + pub fn display_label(&self) -> String { + match self { + Self::Single(trigger) => trigger.to_string(), + Self::Sequence(steps) => steps + .iter() + .map(ToString::to_string) + .collect::>() + .join(" then "), + } + } + + pub fn is_deliverable(&self) -> bool { + match self { + Self::Single(trigger) => trigger.is_deliverable(), + Self::Sequence(steps) => steps + .iter() + .all(|step| super::binding::is_deliverable_key_name(&step.key)), + } + } + + pub fn unknown_key_suggestion(&self) -> Option { + match self { + Self::Single(trigger) => trigger.unknown_key_suggestion(), + Self::Sequence(steps) => steps.iter().find_map(|step| suggest_key_name(&step.key)), + } + } + + pub fn unknown_key_name(&self) -> Option { + match self { + Self::Single(trigger) => trigger.unknown_key_name(), + Self::Sequence(steps) => steps.iter().find_map(|step| { + (!super::binding::is_deliverable_key_name(&step.key)).then(|| step.key.clone()) + }), + } + } + + /// Action that already claims this identity or a keyboard prefix/extension. + pub fn claimed_by( + &self, + claimed: &std::collections::HashMap, + ) -> Option { + if let Some(owner) = claimed.get(self) { + return Some(*owner); + } + claimed + .iter() + .find_map(|(existing, owner)| existing.prefix_conflicts_with(self).then_some(*owner)) + } +} + +impl fmt::Display for Shortcut { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Single(trigger) => write!(f, "{trigger}"), + Self::Sequence(steps) => { + let text = steps + .iter() + .map(ToString::to_string) + .collect::>() + .join(" > "); + write!(f, "{text}") + } + } + } +} + +fn split_sequence_steps(s: &str) -> Option> { + if s.contains(" > ") { + return Some(s.split(" > ").map(str::trim).collect()); + } + if s.contains('>') { + return Some(s.split('>').map(str::trim).collect()); + } + None +} + +fn parses_as_greater_than_key(s: &str) -> bool { + ShortcutTrigger::parse(s) + .ok() + .and_then(|trigger| trigger.as_key_binding().cloned()) + .is_some_and(|binding| binding.key == ">") +} + +fn parse_keyboard_sequence(parts: &[&str]) -> Result { + if parts.len() > MAX_SEQUENCE_STEPS { + return Err(format!( + "Keyboard sequences can have at most {MAX_SEQUENCE_STEPS} steps." + )); + } + let mut steps = Vec::with_capacity(parts.len()); + for part in parts { + match ShortcutTrigger::parse(part)? { + ShortcutTrigger::Keyboard(binding) => steps.push(binding), + ShortcutTrigger::Pointer(_) | ShortcutTrigger::Stylus(_) => { + return Err(format!( + "Sequences are keyboard-only; `{part}` is a device button." + )); + } + } + } + Ok(Shortcut::Sequence(steps)) +} + fn write_device_binding( f: &mut fmt::Formatter<'_>, ctrl: bool, @@ -443,4 +648,68 @@ mod tests { ); assert_eq!(gdk::stylus_button(2), Some(StylusButton::Primary)); } + + #[test] + fn two_and_three_step_sequences_round_trip() { + let two = Shortcut::parse("Ctrl+K > Ctrl+C").unwrap(); + assert_eq!(two.to_string(), "Ctrl+K > Ctrl+C"); + assert_eq!(two.display_label(), "Ctrl+K then Ctrl+C"); + assert_eq!(Shortcut::parse("Ctrl+K>Ctrl+C").unwrap(), two); + + let three = Shortcut::parse("Ctrl+K > Ctrl+C > Ctrl+V").unwrap(); + assert_eq!(three.to_string(), "Ctrl+K > Ctrl+C > Ctrl+V"); + assert_eq!(three.display_label(), "Ctrl+K then Ctrl+C then Ctrl+V"); + } + + #[test] + fn single_chords_remain_byte_compatible() { + let shortcut = Shortcut::parse("Ctrl+Shift+X").unwrap(); + assert_eq!(shortcut.to_string(), "Ctrl+Shift+X"); + assert!(matches!(shortcut, Shortcut::Single(_))); + assert_eq!( + shortcut, + Shortcut::Single(ShortcutTrigger::parse("Ctrl+Shift+X").unwrap()) + ); + } + + #[test] + fn empty_oversized_device_and_modifier_only_sequences_fail() { + let empty = Shortcut::parse("Ctrl+K >").unwrap_err(); + assert!(empty.contains("Empty sequence step"), "{empty}"); + + let oversized = Shortcut::parse("A > B > C > D").unwrap_err(); + assert!(oversized.contains("at most"), "{oversized}"); + + let device = Shortcut::parse("Ctrl+K > MouseBack").unwrap_err(); + assert!(device.contains("keyboard-only"), "{device}"); + + let modifiers = Shortcut::parse("Ctrl+K > Ctrl+Shift").unwrap_err(); + assert!(modifiers.contains("No key specified"), "{modifiers}"); + } + + #[test] + fn greater_than_key_is_not_a_sequence_separator() { + let chord = Shortcut::parse("Ctrl+>").unwrap(); + assert_eq!(chord.to_string(), "Ctrl+>"); + assert!(matches!(chord, Shortcut::Single(_))); + } + + #[test] + fn prefix_conflicts_are_detected_and_branching_sequences_are_not() { + let prefix = Shortcut::parse("Ctrl+K").unwrap(); + let sequence = Shortcut::parse("Ctrl+K > Ctrl+C").unwrap(); + let longer = Shortcut::parse("Ctrl+K > Ctrl+C > Ctrl+V").unwrap(); + let branch = Shortcut::parse("Ctrl+K > Ctrl+X").unwrap(); + + assert!(prefix.prefix_conflicts_with(&sequence)); + assert!(sequence.prefix_conflicts_with(&prefix)); + assert!(sequence.prefix_conflicts_with(&longer)); + assert!(!sequence.prefix_conflicts_with(&branch)); + assert!(!prefix.prefix_conflicts_with(&prefix)); + assert!( + !Shortcut::parse("MouseBack") + .unwrap() + .prefix_conflicts_with(&sequence) + ); + } } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index d7b4fd84..95fbf0a7 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -181,23 +181,23 @@ fn test_build_action_map() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+1").unwrap()), Some(&Action::Exit) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+2").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+2").unwrap()), Some(&Action::Undo) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+3").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+3").unwrap()), Some(&Action::Redo) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+4").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+4").unwrap()), Some(&Action::ToggleHelp) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+5").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+5").unwrap()), Some(&Action::ToggleWhiteboard) ); } @@ -210,15 +210,15 @@ fn command_palette_and_full_screen_capture_defaults_are_distinct_and_ordered() { let map = config.build_action_map().expect("default keymap is valid"); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+K").unwrap()), + map.get(&Shortcut::parse("Ctrl+K").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Shift+P").unwrap()), + map.get(&Shortcut::parse("Ctrl+Shift+P").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+F").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+F").unwrap()), Some(&Action::CaptureFullScreen) ); } @@ -232,11 +232,11 @@ fn toolbar_display_cycle_owns_f2_and_toggle_toolbar_keeps_f9() { // The split leaves no duplicate binding in the defaults. let map = config.build_action_map().expect("default keymap is valid"); assert_eq!( - map.get(&ShortcutTrigger::parse("F2").unwrap()), + map.get(&Shortcut::parse("F2").unwrap()), Some(&Action::CycleToolbarDisplay) ); assert_eq!( - map.get(&ShortcutTrigger::parse("F9").unwrap()), + map.get(&Shortcut::parse("F9").unwrap()), Some(&Action::ToggleToolbar) ); } @@ -277,11 +277,11 @@ fn device_triggers_round_trip_through_the_action_map() { config.core.undo = vec!["MouseBack".to_string(), "Ctrl+StylusSecondary".to_string()]; let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&ShortcutTrigger::parse("MouseBack").unwrap()), + map.get(&Shortcut::parse("MouseBack").unwrap()), Some(&Action::Undo) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+StylusSecondary").unwrap()), + map.get(&Shortcut::parse("Ctrl+StylusSecondary").unwrap()), Some(&Action::Undo) ); } @@ -294,6 +294,49 @@ fn primary_mouse_strings_are_rejected_from_the_keymap() { assert!(err.contains("cannot be bound"), "{err}"); } +#[test] +fn sequence_prefix_conflict_is_rejected_and_branching_is_allowed() { + let prefix = Shortcut::parse("Ctrl+Shift+Alt+K").unwrap(); + let copy = Shortcut::parse("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C").unwrap(); + let cut = Shortcut::parse("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+X").unwrap(); + + let mut config = KeybindingsConfig::default(); + config.core.undo = vec![copy.to_string()]; + config.core.redo = vec![prefix.to_string()]; + let err = config.build_action_map().unwrap_err(); + assert!( + err.contains("Sequence prefix conflict"), + "strict map should reject a prefix: {err}" + ); + + let mut config = KeybindingsConfig::default(); + config.core.undo = vec![copy.to_string()]; + config.core.redo = vec![cut.to_string()]; + let map = config + .build_action_map() + .unwrap_or_else(|err| panic!("branching sequences should be valid: {err}")); + assert_eq!(map.get(©), Some(&Action::Undo)); + assert_eq!(map.get(&cut), Some(&Action::Redo)); +} + +#[test] +fn collect_binding_conflicts_reports_a_sequence_prefix() { + let prefix = Shortcut::parse("Ctrl+Shift+Alt+K").unwrap(); + let sequence = Shortcut::parse("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C").unwrap(); + let mut config = KeybindingsConfig::default(); + config.core.undo = vec![sequence.to_string()]; + config.core.redo = vec![prefix.to_string()]; + let conflicts = config.collect_binding_conflicts().expect("prefix is data"); + assert!( + conflicts.iter().any(|conflict| { + (conflict.binding() == &prefix || conflict.binding() == &sequence) + && conflict.actions().contains(&Action::Undo) + && conflict.actions().contains(&Action::Redo) + }), + "{conflicts:?}" + ); +} + #[test] fn test_parse_plus_key_without_modifiers() { let binding = KeyBinding::parse("+").unwrap(); @@ -335,15 +378,15 @@ fn test_build_action_bindings_preserves_declared_binding_order() { assert_eq!( bindings.get(&Action::ToggleHelp), Some(&vec![ - ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap(), - ShortcutTrigger::parse("Ctrl+Alt+Shift+2").unwrap(), + Shortcut::parse("Ctrl+Alt+Shift+1").unwrap(), + Shortcut::parse("Ctrl+Alt+Shift+2").unwrap(), ]) ); assert_eq!( bindings.get(&Action::Redo), Some(&vec![ - ShortcutTrigger::parse("Ctrl+Alt+Shift+3").unwrap(), - ShortcutTrigger::parse("Ctrl+Alt+Shift+4").unwrap(), + Shortcut::parse("Ctrl+Alt+Shift+3").unwrap(), + Shortcut::parse("Ctrl+Alt+Shift+4").unwrap(), ]) ); } @@ -373,23 +416,23 @@ fn build_action_map_includes_canvas_export_bindings() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+F").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+F").unwrap()), Some(&Action::ExportCanvasFile) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+C").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+C").unwrap()), Some(&Action::ExportCanvasClipboard) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+B").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+B").unwrap()), Some(&Action::ExportCanvasClipboardAndFile) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+P").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+P").unwrap()), Some(&Action::ExportBoardPdfFile) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+A").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+A").unwrap()), Some(&Action::ExportAllBoardsPdfFile) ); } @@ -401,7 +444,7 @@ fn screen_eyedropper_defaults_to_i_and_maps_when_reconfigured() { let default_map = config.build_action_map().unwrap(); assert_eq!( - default_map.get(&ShortcutTrigger::parse("I").unwrap()), + default_map.get(&Shortcut::parse("I").unwrap()), Some(&Action::PickScreenColor) ); @@ -410,7 +453,7 @@ fn screen_eyedropper_defaults_to_i_and_maps_when_reconfigured() { let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+Shift+E").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+E").unwrap()), Some(&Action::PickScreenColor) ); } @@ -422,7 +465,7 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { let default_map = config.build_action_map().unwrap(); assert_eq!( - default_map.get(&ShortcutTrigger::parse("O").unwrap()), + default_map.get(&Shortcut::parse("O").unwrap()), Some(&Action::SetColorOrange) ); assert!( @@ -434,7 +477,7 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { config.capture.copy_text_from_screen = vec!["Ctrl+Alt+T".to_string()]; let map = config.build_action_map().unwrap(); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Alt+T").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+T").unwrap()), Some(&Action::CopyTextFromScreen) ); } @@ -509,7 +552,7 @@ fn collect_binding_conflicts_reports_every_collision_in_traversal_order() { assert_eq!(conflicts.len(), 2, "collection does not stop at the first"); assert_eq!( conflicts[0].binding(), - &ShortcutTrigger::parse("Ctrl+Alt+Shift+1").unwrap() + &Shortcut::parse("Ctrl+Alt+Shift+1").unwrap() ); assert_eq!(conflicts[0].actions(), [Action::Exit, Action::Undo]); assert_eq!( diff --git a/src/config/mod.rs b/src/config/mod.rs index 3d9cb699..a061f032 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -52,8 +52,9 @@ pub use io::{ persist_preset_slot, persist_quick_color, }; pub use keybindings::{ - Action, KeyBinding, KeybindingAuthorship, KeybindingConflict, KeybindingsConfig, PointerButton, - PointerTrigger, ShortcutTrigger, StylusButton, StylusTrigger, + Action, KeyBinding, KeybindingAuthorship, KeybindingConflict, KeybindingsConfig, + MAX_SEQUENCE_STEPS, PointerButton, PointerTrigger, Shortcut, ShortcutTrigger, StylusButton, + StylusTrigger, }; pub use migration::{MigrationChange, MigrationPreview}; #[allow(unused_imports)] diff --git a/src/config/tests/document.rs b/src/config/tests/document.rs index 03c07ed4..7a14f755 100644 --- a/src/config/tests/document.rs +++ b/src/config/tests/document.rs @@ -1187,11 +1187,11 @@ toggle_toolbar = ["F2", "F9"] .build_action_map() .expect("the resolved keymap has no duplicates left"); assert_eq!( - map.get(&ShortcutTrigger::parse("F2").expect("F2 parses")), + map.get(&Shortcut::parse("F2").expect("F2 parses")), Some(&Action::ToggleToolbar) ); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Shift+P").expect("Ctrl+Shift+P parses")), + map.get(&Shortcut::parse("Ctrl+Shift+P").expect("Ctrl+Shift+P parses")), Some(&Action::CaptureFullScreen) ); @@ -1346,6 +1346,36 @@ future_knob = 7 ); } +#[test] +fn saving_a_sequence_preserves_unrelated_keybinding_nodes() { + let temp = TempConfig::new("sequence-preserve"); + let original = format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n# keep me\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n# trailing\nunknown_future = true\n" + ); + temp.write(&original); + let document = ConfigDocument::load_from_path(&temp.path).expect("load"); + let mut config = document.config().clone(); + config.keybindings.core.clear_canvas = vec![ + "E".to_string(), + "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C".to_string(), + ]; + document + .save_with_backup(config) + .expect("save a sequence on one action"); + let saved = fs::read_to_string(&temp.path).expect("read"); + assert!(saved.contains("# keep me"), "{saved}"); + assert!(saved.contains("unknown_future = true"), "{saved}"); + assert!(saved.contains("Ctrl+Alt+U"), "{saved}"); + assert!( + saved.contains("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"), + "{saved}" + ); + assert!( + saved.contains(&format!("config_revision = {CURRENT_CONFIG_REVISION}")), + "{saved}" + ); +} + /// #315 added `toggle_input_hud = ["Ctrl+Shift+K"]`, and a file written before /// the action existed cannot have opted out of it. Source presence settles that /// on every load without the file ever having to record the answer. @@ -1387,7 +1417,7 @@ capture_clipboard_full = ["Ctrl+Shift+K"] .build_action_map() .expect("the resolved keymap has no duplicates"); assert_eq!( - map.get(&ShortcutTrigger::parse("Ctrl+Shift+K").expect("Ctrl+Shift+K parses")), + map.get(&Shortcut::parse("Ctrl+Shift+K").expect("Ctrl+Shift+K parses")), Some(&Action::CaptureClipboardFull) ); assert_eq!(fs::read_to_string(&temp.path).unwrap(), original); diff --git a/src/config/validate/keybindings.rs b/src/config/validate/keybindings.rs index a3d711cb..e7cdcc55 100644 --- a/src/config/validate/keybindings.rs +++ b/src/config/validate/keybindings.rs @@ -2,7 +2,7 @@ use std::fmt; use super::super::CURRENT_CONFIG_REVISION; use super::super::action_meta::action_label; -use super::super::keybindings::{Action, KeybindingAuthorship, KeybindingsConfig, ShortcutTrigger}; +use super::super::keybindings::{Action, KeybindingAuthorship, KeybindingsConfig, Shortcut}; use super::Config; const LEGACY_COMMAND_PALETTE_DEFAULT: &[&str] = &["Ctrl+K"]; @@ -30,7 +30,7 @@ fn bindings_from(expected: &[&str]) -> Vec { /// Whether an action other than `owner` already claims `binding`. /// -/// Candidates go through the parser and then [`ShortcutTrigger`] equality, so +/// Candidates go through the parser and then [`Shortcut`] equality, so /// `shift+ctrl+k` counts as a claim on `Ctrl+Shift+K`: modifier order, spacing, /// and key case are all things the keymap ignores when a key is pressed. A /// binding string that does not parse is not a claim, because it binds nothing @@ -38,7 +38,7 @@ fn bindings_from(expected: &[&str]) -> Vec { fn binding_claimed_by_another_action( keybindings: &KeybindingsConfig, owner: Action, - binding: &ShortcutTrigger, + binding: &Shortcut, ) -> bool { KeybindingsConfig::configurable_actions() .iter() @@ -48,7 +48,7 @@ fn binding_claimed_by_another_action( .bindings_for_action(*action) .is_some_and(|bindings| { bindings.iter().any(|candidate| { - ShortcutTrigger::parse(candidate).is_ok_and(|parsed| parsed == *binding) + Shortcut::parse(candidate).is_ok_and(|parsed| parsed == *binding) }) }) }) @@ -243,7 +243,7 @@ impl fmt::Display for KeybindingConflictResolution { fn drop_binding( keybindings: &mut KeybindingsConfig, action: Action, - binding: &ShortcutTrigger, + binding: &Shortcut, keep_first: bool, ) -> bool { let Some(current) = keybindings.bindings_for_action(action) else { @@ -253,7 +253,7 @@ fn drop_binding( let before = bindings.len(); let mut kept_one = false; bindings.retain(|candidate| { - if !ShortcutTrigger::parse(candidate).is_ok_and(|parsed| parsed == *binding) { + if !Shortcut::parse(candidate).is_ok_and(|parsed| parsed == *binding) { return true; } if keep_first && !kept_one { @@ -405,7 +405,7 @@ impl Config { let mut kept = Vec::with_capacity(current.len()); let mut dropped = false; for binding in current { - match ShortcutTrigger::parse(binding) { + match Shortcut::parse(binding) { Ok(parsed) if parsed.is_deliverable() => kept.push(binding.clone()), Ok(parsed) => { // Reported but kept: the string is well formed, and a @@ -560,15 +560,15 @@ impl Config { let mut kept = Vec::with_capacity(current.len()); let mut dropped = false; for text in current { - let Ok(binding) = ShortcutTrigger::parse(text) else { + let Ok(binding) = Shortcut::parse(text) else { // `drop_unparseable_bindings` already removed anything the // parser rejects, so this arm is only reachable if that // pass changes; keep the string rather than losing it here. kept.push(text.clone()); continue; }; - match claimed.get(&binding) { - Some(owner) if *owner == *action => { + match binding.claimed_by(&claimed) { + Some(owner) if owner == *action => { dropped = true; repeats.push(KeybindingConflictResolution { key: binding.to_string(), @@ -581,7 +581,7 @@ impl Config { skipped.push(DefaultShortcutSkipped { action: *action, binding: binding.to_string(), - claimed_by: *owner, + claimed_by: owner, }); } None => { @@ -735,7 +735,7 @@ impl Config { } let Some(contested) = CURRENT_TOGGLE_INPUT_HUD_DEFAULT .first() - .and_then(|binding| ShortcutTrigger::parse(binding).ok()) + .and_then(|binding| Shortcut::parse(binding).ok()) else { return; }; diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index 08d937e9..a55a43ef 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -36,4 +36,8 @@ impl InputState { pub fn on_key_press(&mut self, key: Key) { let _ = interaction::route_key_press(self, key); } + + pub fn on_key_repeat(&mut self, key: Key) { + let _ = interaction::route_key_repeat(self, key); + } } diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 677a419d..d972ee8d 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -8,8 +8,7 @@ use super::super::types::{ }; use super::structs::InputState; use crate::config::{ - Action, BoardsConfig, PRESET_SLOTS_MAX, QuickColorPalette, RadialMenuMouseBinding, - ShortcutTrigger, + Action, BoardsConfig, PRESET_SLOTS_MAX, QuickColorPalette, RadialMenuMouseBinding, Shortcut, }; use crate::draw::{ BlurStyle, DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES, @@ -61,7 +60,7 @@ impl InputState { arrow_head_at_end: bool, show_status_bar: bool, boards_config: BoardsConfig, - action_map: HashMap, + action_map: HashMap, max_shapes_per_frame: usize, click_highlight_settings: ClickHighlightSettings, undo_all_delay_ms: u64, @@ -77,6 +76,8 @@ impl InputState { let mut tool_settings = PerToolDrawingSettings::new(color, thickness); tool_settings.step_marker.thickness = super::super::super::utility::default_step_marker_size(font_size); + let sequence_trie = + crate::input::state::core::utility::SequenceTrie::from_action_map(&action_map); let mut state = Self { keymap_revision: 0, command_palette_results: std::cell::RefCell::new(None), @@ -207,6 +208,8 @@ impl InputState { text_input_revision: 0, action_map, action_bindings: HashMap::new(), + sequence_trie, + pending_sequence: None, consumed_pointer_buttons: HashSet::new(), pending_backend_action: None, pending_toolbar_persistence: Vec::new(), diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs index ae3f9834..5f1f6061 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -15,6 +15,7 @@ impl InputState { self.modifiers.logo = false; self.modifiers.tab = false; self.consumed_pointer_buttons.clear(); + self.clear_pending_sequence(); if matches!(self.state, DrawingState::Idle) { self.sync_current_settings_from_active_tool(); } diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index e0f78461..2ffe15b0 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -26,7 +26,7 @@ use super::super::types::{ }; use crate::config::{ Action, PresenterModeConfig, QuickColorPalette, RadialMenuMouseBinding, ResolvedToolbarItems, - ShortcutTrigger, ToolPresetConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemsConfig, + Shortcut, ToolPresetConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemsConfig, }; use crate::draw::frame::ShapeSnapshot; use crate::draw::{BlurStyle, Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId}; @@ -368,9 +368,15 @@ pub struct InputState { /// the same bytes and selection are later restored. pub(crate) text_input_revision: u64, /// Keybinding action map for efficient lookup - pub(in crate::input::state::core) action_map: HashMap, + pub(in crate::input::state::core) action_map: HashMap, /// Ordered keybindings per action (as configured) - pub(in crate::input::state::core) action_bindings: HashMap>, + pub(in crate::input::state::core) action_bindings: HashMap>, + /// Keyboard sequence trie derived from `action_map`. + pub(in crate::input::state::core) sequence_trie: + crate::input::state::core::utility::SequenceTrie, + /// In-progress multi-step keyboard sequence, if any. + pub(in crate::input::state::core) pending_sequence: + Option, /// Auxiliary pointer codes whose press dispatched a shortcut, so the /// matching release is consumed instead of starting a stroke or UI action. pub(crate) consumed_pointer_buttons: HashSet, diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 748a1d6e..4a3af194 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -43,11 +43,11 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use crate::config::{RadialMenuMouseBinding, ShortcutTrigger}; + use crate::config::{RadialMenuMouseBinding, Shortcut}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; use std::collections::HashMap; - fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { entries .iter() .map(|(action, values)| { @@ -55,7 +55,7 @@ mod tests { *action, values .iter() - .map(|value| ShortcutTrigger::parse(value).expect("binding")) + .map(|value| Shortcut::parse(value).expect("binding")) .collect(), ) }) diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index e7b23f86..59649d85 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -85,5 +85,6 @@ pub use selection::SelectionState; pub use tool_controls::PrecisionEntryState; pub use tour::TourStep; pub(crate) use utility::HelpOverlayPressSource; +pub(crate) use utility::SequenceMatch; pub(crate) use utility::default_step_marker_size; pub use utility::{HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome}; diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index ecae28a8..4458af6f 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -121,6 +121,7 @@ impl InputState { /// The exclusion step every opener runs first: closes each open surface /// the opening one does not deliberately coexist with. pub(crate) fn close_modals_for_open(&mut self, opening: ModalSurface) { + self.clear_pending_sequence(); for other in ModalSurface::ALL { if other != opening && !opening.keeps_open(other) && self.modal_is_open(other) { self.close_modal(other); diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index 98de94fb..a63efd56 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -420,14 +420,14 @@ mod tests { #[test] fn tour_copy_tracks_rebound_shortcuts() { - use crate::config::{KeybindingsConfig, ShortcutTrigger}; + use crate::config::{KeybindingsConfig, Shortcut}; let mut bindings = KeybindingsConfig::default() .build_action_bindings() .expect("default bindings"); bindings.insert( Action::ToggleCommandPalette, - vec![ShortcutTrigger::parse("Ctrl+Shift+P").expect("binding")], + vec![Shortcut::parse("Ctrl+Shift+P").expect("binding")], ); let mut state = make_test_input_state(); state.set_action_bindings(bindings); @@ -441,7 +441,7 @@ mod tests { #[test] fn status_bar_step_teaches_board_picker_and_tracks_binding() { - use crate::config::{KeybindingsConfig, ShortcutTrigger}; + use crate::config::{KeybindingsConfig, Shortcut}; // The M9 board-picker beat always names the on-screen entry point (the // status bar) position-neutrally and resolves the board-picker key through @@ -451,7 +451,7 @@ mod tests { .expect("default bindings"); bindings.insert( Action::BoardPicker, - vec![ShortcutTrigger::parse("Ctrl+Shift+B").expect("binding")], + vec![Shortcut::parse("Ctrl+Shift+B").expect("binding")], ); let mut state = make_test_input_state(); state.set_action_bindings(bindings); diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index f71ae653..c5f1ceba 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -1,26 +1,30 @@ use super::super::base::InputState; -use crate::config::{KeyBinding, PointerButton, PointerTrigger, ShortcutTrigger}; +use crate::config::{KeyBinding, PointerButton, PointerTrigger, Shortcut, ShortcutTrigger}; #[cfg(feature = "tablet-input")] use crate::config::{StylusButton, StylusTrigger}; use crate::domain::Action; +use crate::input::state::core::utility::{SequenceMatch, SequenceTrie}; use crate::label_format::format_binding_labels; use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; impl InputState { - /// Look up an action for the given key and modifiers. + /// Look up a complete single keyboard chord. Does not advance sequences. pub(crate) fn find_action(&self, key_str: &str) -> Option { - let trigger = ShortcutTrigger::Keyboard(KeyBinding { + let shortcut = Shortcut::Single(ShortcutTrigger::Keyboard(KeyBinding { key: key_str.to_string(), ctrl: self.modifiers.ctrl, shift: self.modifiers.shift, alt: self.modifiers.alt, logo: self.modifiers.logo, - }); - self.action_map.get(&trigger).copied() + })); + self.action_map.get(&shortcut).copied() } pub(crate) fn find_trigger_action(&self, trigger: &ShortcutTrigger) -> Option { - self.action_map.get(trigger).copied() + self.action_map + .get(&Shortcut::Single(trigger.clone())) + .copied() } pub(crate) fn pointer_trigger(&self, button: PointerButton) -> ShortcutTrigger { @@ -52,7 +56,7 @@ impl InputState { self.consumed_pointer_buttons.remove(&code) } - pub fn set_action_bindings(&mut self, action_bindings: HashMap>) { + pub fn set_action_bindings(&mut self, action_bindings: HashMap>) { self.action_bindings = action_bindings; self.keymap_revision = self.keymap_revision.wrapping_add(1); } @@ -64,21 +68,62 @@ impl InputState { /// badge and help row reads back. pub(crate) fn set_keybinding_maps( &mut self, - action_map: HashMap, - action_bindings: HashMap>, + action_map: HashMap, + action_bindings: HashMap>, ) { self.action_map = action_map; self.action_bindings = action_bindings; + self.sequence_trie = SequenceTrie::from_action_map(&self.action_map); + self.clear_pending_sequence(); self.keymap_revision = self.keymap_revision.wrapping_add(1); self.needs_redraw = true; } + pub(crate) fn clear_pending_sequence(&mut self) { + self.pending_sequence = None; + } + + /// Match a keyboard chord, advancing or dispatching a sequence when needed. + pub(crate) fn match_keyboard_chord( + &mut self, + key_str: &str, + is_repeat: bool, + now: Instant, + ) -> SequenceMatch { + let chord = KeyBinding { + key: key_str.to_string(), + ctrl: self.modifiers.ctrl, + shift: self.modifiers.shift, + alt: self.modifiers.alt, + logo: self.modifiers.logo, + }; + self.sequence_trie + .match_chord(&mut self.pending_sequence, &chord, now, is_repeat) + } + + pub(crate) fn sequence_timeout(&self, now: Instant) -> Option { + self.pending_sequence + .as_ref() + .map(|pending| pending.deadline().saturating_duration_since(now)) + } + + pub(crate) fn expire_pending_sequence(&mut self, now: Instant) -> bool { + let expired = self + .pending_sequence + .as_ref() + .is_some_and(|pending| now >= pending.deadline()); + if expired { + self.clear_pending_sequence(); + } + expired + } + pub fn action_binding_labels(&self, action: Action) -> Vec { if let Some(bindings) = self.action_bindings.get(&action) { let mut labels = Vec::new(); let mut seen = HashSet::new(); for binding in bindings { - let label = binding.to_string(); + let label = binding.display_label(); if seen.insert(label.clone()) { labels.push(label); } @@ -89,7 +134,7 @@ impl InputState { .action_map .iter() .filter(|(_, mapped)| **mapped == action) - .map(|(binding, _)| binding.to_string()) + .map(|(binding, _)| binding.display_label()) .collect(); labels.sort(); labels.dedup(); diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index 53a47168..1cd4857d 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -10,9 +10,11 @@ mod light_mode; mod pending; mod presenter_mode; mod render_profiles; +mod sequence; mod step_markers; mod toasts; pub(crate) use help_overlay::HelpOverlayPressSource; pub use help_overlay::{HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome}; +pub(crate) use sequence::{PendingSequence, SequenceMatch, SequenceTrie}; pub(crate) use step_markers::default_step_marker_size; diff --git a/src/input/state/core/utility/sequence.rs b/src/input/state/core/utility/sequence.rs new file mode 100644 index 00000000..c617161b --- /dev/null +++ b/src/input/state/core/utility/sequence.rs @@ -0,0 +1,279 @@ +//! Keyboard sequence matcher for configured multi-step shortcuts. +//! +//! Single chords dispatch immediately. A sequence prefix is consumed and kept +//! pending until the next step, a mismatch (re-evaluated once from the root), +//! a one-second timeout, or an ownership boundary that clears pending state. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crate::config::{KeyBinding, Shortcut}; +use crate::domain::Action; + +/// Inter-step timeout. A pending prefix expires without dispatching. +pub(crate) const SEQUENCE_STEP_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone)] +pub(crate) struct PendingSequence { + steps: Vec, + deadline: Instant, +} + +impl PendingSequence { + fn new(steps: Vec, now: Instant) -> Self { + Self { + steps, + deadline: now + SEQUENCE_STEP_TIMEOUT, + } + } + + pub(crate) fn deadline(&self) -> Instant { + self.deadline + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SequenceMatch { + Dispatched(Action), + Pending, + None, +} + +#[derive(Debug, Default, Clone)] +struct TrieNode { + children: HashMap, + action: Option, +} + +/// Keyboard trie built from the current keymap. +#[derive(Debug, Default, Clone)] +pub(crate) struct SequenceTrie { + root: TrieNode, +} + +impl SequenceTrie { + pub(crate) fn from_action_map(map: &HashMap) -> Self { + let mut trie = Self::default(); + for (shortcut, action) in map { + let Some(steps) = shortcut.keyboard_steps() else { + continue; + }; + trie.insert(steps, *action); + } + trie + } + + fn insert(&mut self, steps: &[KeyBinding], action: Action) { + let mut node = &mut self.root; + for step in steps { + node = node.children.entry(step.clone()).or_default(); + } + node.action = Some(action); + } + + fn node_for(&self, steps: &[KeyBinding]) -> Option<&TrieNode> { + let mut node = &self.root; + for step in steps { + node = node.children.get(step)?; + } + Some(node) + } + + pub(crate) fn match_chord( + &self, + pending: &mut Option, + chord: &KeyBinding, + now: Instant, + is_repeat: bool, + ) -> SequenceMatch { + if pending + .as_ref() + .is_some_and(|pending| now >= pending.deadline) + { + *pending = None; + } + + if is_repeat { + if pending.is_some() { + return SequenceMatch::Pending; + } + return self.dispatch_or_pending(std::slice::from_ref(chord), pending, now); + } + + if let Some(current) = pending.take() { + let mut steps = current.steps; + steps.push(chord.clone()); + match self.lookup_steps(&steps, pending, now) { + SequenceMatch::None => { + // Mismatch: cancel, then re-evaluate the new chord once. + self.dispatch_or_pending(std::slice::from_ref(chord), pending, now) + } + other => other, + } + } else { + self.dispatch_or_pending(std::slice::from_ref(chord), pending, now) + } + } + + fn lookup_steps( + &self, + steps: &[KeyBinding], + pending: &mut Option, + now: Instant, + ) -> SequenceMatch { + let Some(node) = self.node_for(steps) else { + return SequenceMatch::None; + }; + if let Some(action) = node.action { + *pending = None; + return SequenceMatch::Dispatched(action); + } + if node.children.is_empty() { + return SequenceMatch::None; + } + *pending = Some(PendingSequence::new(steps.to_vec(), now)); + SequenceMatch::Pending + } + + fn dispatch_or_pending( + &self, + steps: &[KeyBinding], + pending: &mut Option, + now: Instant, + ) -> SequenceMatch { + self.lookup_steps(steps, pending, now) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ShortcutTrigger; + + fn chord(text: &str) -> KeyBinding { + match ShortcutTrigger::parse(text).unwrap() { + ShortcutTrigger::Keyboard(binding) => binding, + other => panic!("expected a keyboard chord, got {other:?}"), + } + } + + fn trie(entries: &[(&str, Action)]) -> SequenceTrie { + let map = entries + .iter() + .map(|(text, action)| (Shortcut::parse(text).unwrap(), *action)) + .collect(); + SequenceTrie::from_action_map(&map) + } + + fn start() -> Instant { + Instant::now() + } + + #[test] + fn idle_complete_single_dispatches_immediately() { + let trie = trie(&[("F5", Action::SelectPenTool)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("F5"), now, false), + SequenceMatch::Dispatched(Action::SelectPenTool) + ); + assert!(pending.is_none()); + } + + #[test] + fn prefix_enters_pending_and_matching_next_step_dispatches() { + let trie = trie(&[("Ctrl+K > Ctrl+C", Action::CopySelection)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + assert!(pending.is_some()); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+C"), now, false), + SequenceMatch::Dispatched(Action::CopySelection) + ); + assert!(pending.is_none()); + } + + #[test] + fn mismatch_re_evaluates_once_from_the_root() { + let trie = trie(&[ + ("Ctrl+K > Ctrl+C", Action::CopySelection), + ("F5", Action::SelectPenTool), + ]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + assert_eq!( + trie.match_chord(&mut pending, &chord("F5"), now, false), + SequenceMatch::Dispatched(Action::SelectPenTool) + ); + assert!(pending.is_none()); + } + + #[test] + fn timeout_cancels_without_dispatch() { + let trie = trie(&[("Ctrl+K > Ctrl+C", Action::CopySelection)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + let later = now + SEQUENCE_STEP_TIMEOUT; + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+C"), later, false), + SequenceMatch::None + ); + assert!(pending.is_none()); + } + + #[test] + fn key_repeat_does_not_advance_a_pending_sequence() { + let trie = trie(&[("Ctrl+K > Ctrl+C", Action::CopySelection)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, true), + SequenceMatch::Pending + ); + assert!(pending.is_some()); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+C"), now, true), + SequenceMatch::Pending + ); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+C"), now, false), + SequenceMatch::Dispatched(Action::CopySelection) + ); + } + + #[test] + fn three_step_sequence_advances_then_dispatches() { + let trie = trie(&[("Ctrl+K > Ctrl+C > Ctrl+V", Action::PasteSelection)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+C"), now, false), + SequenceMatch::Pending + ); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+V"), now, false), + SequenceMatch::Dispatched(Action::PasteSelection) + ); + } +} diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index 9b0c12f5..65ec9ba3 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -1,10 +1,31 @@ use super::actions::route_action; use super::adapters; -use super::outcome::{NoRouteReason, RoutingOutcome}; +use super::outcome::{ConsumedBy, NoRouteReason, RoutingOutcome}; use crate::input::events::Key; -use crate::input::state::InputState; +use crate::input::state::actions::key_press::bindings::{ + fallback_unshifted_label, key_to_action_label, +}; +use crate::input::state::{DrawingState, InputState}; +use std::time::Instant; + +use super::super::core::SequenceMatch; pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcome { + route_key_event(state, key, false) +} + +pub(crate) fn route_key_repeat(state: &mut InputState, key: Key) -> RoutingOutcome { + route_key_event(state, key, true) +} + +fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> RoutingOutcome { + if state.engaged_modal().is_some() + || matches!(state.state, DrawingState::TextInput { .. }) + || state.screen_modal_is_engaged() + { + state.clear_pending_sequence(); + } + if let Some(outcome) = adapters::handle_tour_key(state, key) { return outcome; } @@ -54,9 +75,12 @@ pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcom return outcome; } - match adapters::action_for_key_binding(state, key) { - Ok(Some(action)) => return route_action(state, action), - Ok(None) => {} + match match_action_for_key_binding(state, key, is_repeat) { + Ok(SequenceMatch::Dispatched(action)) => return route_action(state, action), + Ok(SequenceMatch::Pending) => { + return RoutingOutcome::Consumed(ConsumedBy::SequencePrefix); + } + Ok(SequenceMatch::None) => {} Err(NoRouteReason::UnsupportedKey) => { return RoutingOutcome::NoRoute(NoRouteReason::UnsupportedKey); } @@ -69,3 +93,27 @@ pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcom RoutingOutcome::NoRoute(NoRouteReason::NoKeyBinding) } + +fn match_action_for_key_binding( + state: &mut InputState, + key: Key, + is_repeat: bool, +) -> Result { + let Some(key_str) = key_to_action_label(key) else { + return Err(NoRouteReason::UnsupportedKey); + }; + + let now = Instant::now(); + match state.match_keyboard_chord(&key_str, is_repeat, now) { + SequenceMatch::None => {} + other => return Ok(other), + } + + if state.modifiers.shift + && let Some(fallback) = fallback_unshifted_label(&key_str) + { + return Ok(state.match_keyboard_chord(fallback, is_repeat, now)); + } + + Ok(SequenceMatch::None) +} diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index e3eb09d4..57a7831c 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -11,7 +11,7 @@ pub(crate) use adapters::action_for_key_binding; pub(crate) use event::{ CanvasPoint, PointerMotion, PointerPoints, PointerPress, PointerRelease, ScreenPoint, }; -pub(crate) use keyboard::route_key_press; +pub(crate) use keyboard::{route_key_press, route_key_repeat}; pub(crate) use pointer::{route_pointer_motion, route_pointer_press, route_pointer_release}; #[cfg(test)] diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index 2bfc2ce5..cfb9aa2a 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -27,6 +27,7 @@ pub(crate) enum ConsumedBy { RadialMenuToggle, StatusHud, ZoomChip, + SequencePrefix, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index b03693be..b745f3cc 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -65,9 +65,7 @@ pub use input_hud::{ #[cfg(test)] pub(crate) mod test_support { - use crate::config::{ - Action, BoardsConfig, KeybindingsConfig, PresenterModeConfig, ShortcutTrigger, - }; + use crate::config::{Action, BoardsConfig, KeybindingsConfig, PresenterModeConfig, Shortcut}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; use std::collections::HashMap; @@ -84,7 +82,7 @@ pub(crate) mod test_support { // action-binding label overrides. It intentionally keeps the default // dispatch/action map and swaps only the formatted bindings. pub(crate) fn make_test_input_state_with_action_bindings( - action_bindings: HashMap>, + action_bindings: HashMap>, ) -> InputState { let action_map = KeybindingsConfig::default() .build_action_map() diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs index 59848709..46d7e614 100644 --- a/src/input/state/tests/action_bindings.rs +++ b/src/input/state/tests/action_bindings.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::{PointerButton, ShortcutTrigger}; +use crate::config::{PointerButton, Shortcut}; use std::collections::HashMap; #[test] @@ -9,9 +9,9 @@ fn explicit_action_binding_labels_dedup_and_preserve_order() { bindings.insert( Action::ToggleHelp, vec![ - ShortcutTrigger::parse("Shift+F1").unwrap(), - ShortcutTrigger::parse("Shift+F1").unwrap(), - ShortcutTrigger::parse("F10").unwrap(), + Shortcut::parse("Shift+F1").unwrap(), + Shortcut::parse("Shift+F1").unwrap(), + Shortcut::parse("F10").unwrap(), ], ); state.set_action_bindings(bindings); @@ -26,10 +26,7 @@ fn explicit_action_binding_labels_dedup_and_preserve_order() { fn custom_action_bindings_override_fallback_action_map_labels() { let mut state = create_test_input_state(); let mut bindings = HashMap::new(); - bindings.insert( - Action::ToggleHelp, - vec![ShortcutTrigger::parse("Menu").unwrap()], - ); + bindings.insert(Action::ToggleHelp, vec![Shortcut::parse("Menu").unwrap()]); state.set_action_bindings(bindings); assert_eq!( @@ -56,8 +53,8 @@ fn action_binding_primary_label_prefers_first_explicit_binding() { bindings.insert( Action::ToggleStatusBar, vec![ - ShortcutTrigger::parse("F4").unwrap(), - ShortcutTrigger::parse("F12").unwrap(), + Shortcut::parse("F4").unwrap(), + Shortcut::parse("F12").unwrap(), ], ); state.set_action_bindings(bindings); @@ -68,6 +65,21 @@ fn action_binding_primary_label_prefers_first_explicit_binding() { ); } +#[test] +fn sequence_binding_labels_use_then() { + let mut state = create_test_input_state(); + let mut bindings = HashMap::new(); + bindings.insert( + Action::ToggleHelp, + vec![Shortcut::parse("Ctrl+K > Ctrl+C").unwrap()], + ); + state.set_action_bindings(bindings); + assert_eq!( + state.action_binding_labels(Action::ToggleHelp), + vec!["Ctrl+K then Ctrl+C".to_string()] + ); +} + #[test] fn find_action_respects_exact_modifier_matches() { let mut state = create_test_input_state(); @@ -141,3 +153,111 @@ fn consumed_pointer_shortcut_buttons_clear_on_focus_loss() { state.reset_modifiers(); assert!(!state.take_consumed_pointer_shortcut_button(0x113)); } + +#[test] +fn keyboard_sequences_dispatch_after_the_last_step() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.ui.toggle_floating_badge = vec!["Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + let now = std::time::Instant::now(); + + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + assert_eq!( + state.match_keyboard_chord("k", false, now), + crate::input::state::core::SequenceMatch::Pending + ); + assert_eq!( + state.match_keyboard_chord("c", false, now), + crate::input::state::core::SequenceMatch::Dispatched(Action::ToggleFloatingBadge) + ); +} + +#[test] +fn sequence_timeout_and_focus_loss_clear_pending_without_dispatch() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.ui.toggle_floating_badge = vec!["Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + let now = std::time::Instant::now(); + + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + assert_eq!( + state.match_keyboard_chord("k", false, now), + crate::input::state::core::SequenceMatch::Pending + ); + + assert!(state.expire_pending_sequence(now + std::time::Duration::from_secs(1))); + assert_eq!( + state.match_keyboard_chord("c", false, now + std::time::Duration::from_secs(1)), + crate::input::state::core::SequenceMatch::None + ); + + assert_eq!( + state.match_keyboard_chord("k", false, now), + crate::input::state::core::SequenceMatch::Pending + ); + state.reset_modifiers(); + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + assert_eq!( + state.match_keyboard_chord("c", false, now), + crate::input::state::core::SequenceMatch::None + ); +} + +#[test] +fn keymap_reload_and_modal_open_clear_pending_sequences() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.ui.toggle_floating_badge = vec!["Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings.clone()); + let now = std::time::Instant::now(); + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + assert_eq!( + state.match_keyboard_chord("k", false, now), + crate::input::state::core::SequenceMatch::Pending + ); + + let action_map = keybindings.build_action_map().unwrap(); + let action_bindings = keybindings.build_action_bindings().unwrap(); + state.set_keybinding_maps(action_map, action_bindings); + assert_eq!( + state.match_keyboard_chord("c", false, now), + crate::input::state::core::SequenceMatch::None + ); + + assert_eq!( + state.match_keyboard_chord("k", false, now), + crate::input::state::core::SequenceMatch::Pending + ); + state.close_modals_for_open(crate::input::state::core::modal::ModalSurface::HelpOverlay); + assert_eq!( + state.match_keyboard_chord("c", false, now), + crate::input::state::core::SequenceMatch::None + ); +} + +#[test] +fn on_key_press_completes_a_sequence_and_repeat_does_not() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.ui.toggle_floating_badge = vec!["Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + assert!(state.show_floating_badge); + + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + state.on_key_press(crate::input::Key::Char('k')); + assert!(state.show_floating_badge); + + state.on_key_repeat(crate::input::Key::Char('c')); + assert!(state.show_floating_badge); + + state.on_key_press(crate::input::Key::Char('c')); + assert!(!state.show_floating_badge); +} diff --git a/src/session/tests/helpers.rs b/src/session/tests/helpers.rs index a0831069..ac045adb 100644 --- a/src/session/tests/helpers.rs +++ b/src/session/tests/helpers.rs @@ -1,4 +1,4 @@ -use crate::config::{Action, BoardsConfig, PresenterModeConfig, ShortcutTrigger}; +use crate::config::{Action, BoardsConfig, PresenterModeConfig, Shortcut}; use crate::draw::Color as DrawColor; use crate::draw::FontDescriptor; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; @@ -6,7 +6,7 @@ use std::collections::HashMap; pub(super) fn dummy_input_state() -> InputState { let mut action_map = HashMap::new(); - action_map.insert(ShortcutTrigger::parse("Escape").unwrap(), Action::Exit); + action_map.insert(Shortcut::parse("Escape").unwrap(), Action::Exit); InputState::with_defaults( DrawColor { r: 1.0, diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index acaa0cdd..a9cfb55e 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -5,7 +5,7 @@ use std::ffi::{CStr, CString}; use std::time::Duration; use super::*; -use crate::config::{ShortcutTrigger, ToolbarSectionFlag, toolbar_item_ids as ids}; +use crate::config::{Shortcut, ToolbarSectionFlag, toolbar_item_ids as ids}; use crate::input::state::test_support::make_test_input_state; use crate::toolbar_gtk::widgets::{emit_secondary_press, secondary_click_gesture}; use crate::ui::toolbar::{ @@ -25,7 +25,7 @@ fn top_structure_rebuilds_when_current_shortcuts_change() { state.set_action_bindings(HashMap::from([( Action::SelectPenTool, - vec![ShortcutTrigger::parse("9").expect("binding")], + vec![Shortcut::parse("9").expect("binding")], )])); let changed = ToolbarSnapshot::from_input_with_bindings( &state, diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index eff3e59d..e77f91a7 100644 --- a/src/ui/radial_menu/cache.rs +++ b/src/ui/radial_menu/cache.rs @@ -342,7 +342,7 @@ mod tests { /// hints) invalidates the key. #[test] fn base_cache_key_changes_with_binding_hints() { - use crate::config::{Action, ShortcutTrigger}; + use crate::config::{Action, Shortcut}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; let default_state = make_test_input_state(); @@ -351,7 +351,7 @@ mod tests { .expect("default bindings"); bindings.insert( Action::SelectPenTool, - vec![ShortcutTrigger::parse("F9").expect("parse F9")], + vec![Shortcut::parse("F9").expect("parse F9")], ); let rebound_state = make_test_input_state_with_action_bindings(bindings); diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index fb9b3040..a6e91a73 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -260,7 +260,7 @@ impl InputState { #[cfg(test)] mod coach_tests { - use crate::config::{Action, ShortcutTrigger}; + use crate::config::{Action, Shortcut}; use crate::draw::{Color, Shape}; use crate::input::InputState; use crate::input::state::test_support::{ @@ -347,8 +347,7 @@ mod coach_tests { // cannot name. (An empty map would fall back to the default action map, // which still binds Undo, so the override must be an explicit empty // binding list.) - let bindings: HashMap> = - HashMap::from([(Action::Undo, Vec::new())]); + let bindings: HashMap> = HashMap::from([(Action::Undo, Vec::new())]); let mut state = make_test_input_state_with_action_bindings(bindings); add_test_shape(&mut state); assert!(state.shortcut_for_action(Action::Undo).is_none()); diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index a135917f..b655d07f 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -117,10 +117,10 @@ pub(crate) fn action_for_clear_preset(slot: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::config::ShortcutTrigger; + use crate::config::Shortcut; use crate::input::state::test_support::make_test_input_state_with_action_bindings; - fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { entries .iter() .map(|(action, values)| { @@ -128,7 +128,7 @@ mod tests { *action, values .iter() - .map(|value| ShortcutTrigger::parse(value).expect("binding")) + .map(|value| Shortcut::parse(value).expect("binding")) .collect(), ) }) @@ -201,9 +201,7 @@ mod tests { (Action::Exit, &["Ctrl+Alt+Shift+Q"]), ])); let hints = ToolbarBindingHints::from_input_state(&state); - let expected = ShortcutTrigger::parse("Ctrl+Alt+Shift+O") - .unwrap() - .to_string(); + let expected = Shortcut::parse("Ctrl+Alt+Shift+O").unwrap().to_string(); assert_eq!( hints.binding_for_action(Action::OpenConfigurator), From 6644f16e3c4cfd40fc39c392fc1204b031c5d70a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 5/7] feat: add a bulk shortcut manager to the configurator Let users filter, sort, and reset many keybindings from one screen while reusing the existing row editor, recorder, and conflict flow. --- configurator/README.md | 2 +- configurator/src/app/pages/keybindings/mod.rs | 156 +++-- configurator/src/app/pages/keybindings/row.rs | 88 ++- .../src/app/pages/keybindings/toolbar.rs | 230 +++++++ configurator/src/app/search/mod.rs | 9 +- configurator/src/app/startup.rs | 23 +- configurator/src/app/state.rs | 63 +- configurator/src/app/update/mod.rs | 23 + configurator/src/app/update/shortcuts.rs | 164 ++++- .../src/app/update/shortcuts/tests.rs | 231 +++++++ configurator/src/app/update/tabs.rs | 1 + configurator/src/messages.rs | 18 +- configurator/src/models/keybindings/AGENTS.md | 2 +- .../src/models/keybindings/conflicts.rs | 19 + configurator/src/models/keybindings/edit.rs | 10 + .../src/models/keybindings/field/mod.rs | 2 +- .../src/models/keybindings/manager.rs | 635 ++++++++++++++++++ configurator/src/models/keybindings/mod.rs | 7 +- configurator/src/models/mod.rs | 2 +- 19 files changed, 1584 insertions(+), 101 deletions(-) create mode 100644 configurator/src/app/pages/keybindings/toolbar.rs create mode 100644 configurator/src/models/keybindings/manager.rs diff --git a/configurator/README.md b/configurator/README.md index 23757f9b..ad67e0d9 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -69,7 +69,7 @@ if its UI task is no longer observed. - **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components). - **Default color** – toggle between named colors and custom RGB triples. -- **Keybindings** – per-action shortcut chips with press-to-bind recording for 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. +- **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. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. diff --git a/configurator/src/app/pages/keybindings/mod.rs b/configurator/src/app/pages/keybindings/mod.rs index 8b9a87fd..5d3a6348 100644 --- a/configurator/src/app/pages/keybindings/mod.rs +++ b/configurator/src/app/pages/keybindings/mod.rs @@ -1,102 +1,120 @@ -//! Keybindings page: shortcut chips, recorder, and per-row reset. +//! Keybindings page: bulk shortcut manager over the shared row editor. mod recorder; mod row; mod text_editor; +mod toolbar; mod widgets; +use std::cell::RefCell; +use std::rc::Rc; + use relm4::prelude::*; use relm4::{adw, gtk}; use adw::prelude::*; use crate::messages::Message; -use crate::models::{KeybindingField, KeybindingsTabId, TabId}; +use crate::models::{KeybindingField, KeybindingsTabId}; -use super::super::search::AppSearchSummary; use super::super::state::ConfiguratorApp; use super::{Binding, BuiltPage}; -use row::binding_row; -use widgets::{set_accessible_label, set_label, set_visible}; +use row::{ManagerRefresh, binding_row}; +use toolbar::build_chrome; +use widgets::{set_accessible_label, set_label, set_sensitive, set_visible}; -const SECTION_DESCRIPTION: &str = - "Record a shortcut or sequence, reset one action, or edit the comma-separated list as text."; +const SECTION_DESCRIPTION: &str = "Record a shortcut or sequence, reset one action, or edit the comma-separated list as text. Filter and sort to review many actions at once."; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { - let stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .vexpand(true) - .build(); let mut bindings: Vec = Vec::new(); - let mut sections: Vec<(KeybindingsTabId, adw::PreferencesPage)> = Vec::new(); + 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(), + )); + })); + } + + let page = adw::PreferencesPage::new(); let fields = KeybindingField::all(); + let mut groups: Vec<(KeybindingsTabId, adw::PreferencesGroup)> = Vec::new(); + let mut slots: Vec<(KeybindingField, adw::PreferencesRow, adw::PreferencesGroup)> = Vec::new(); for tab in KeybindingsTabId::ALL { - let section = adw::PreferencesPage::new(); let group = adw::PreferencesGroup::builder() + .title(tab.title()) .description(SECTION_DESCRIPTION) .build(); - section.add(&group); for field in fields.iter().copied().filter(|field| field.tab() == tab) { - binding_row(&group, field, sender, &mut bindings); + let row = binding_row(&group, field, sender, &mut bindings, refresh.clone()); + slots.push((field, row, group.clone())); } - stack.add_titled(§ion, Some(tab.title()), tab.title()); - sections.push((tab, section)); + page.add(&group); + groups.push((tab, group)); } { - let sender = sender.clone(); - stack.connect_visible_child_name_notify(move |stack| { - let Some(name) = stack.visible_child_name() else { + let groups = groups.clone(); + let refresh = refresh.clone(); + bindings.push(Box::new(move |app, _summary| { + let refresh = refresh.borrow(); + let Some((_summary, visible)) = refresh.as_ref() else { return; }; - let Some(tab) = tab_from_name(&name) else { - return; - }; - sender.input(Message::KeybindingsTabSelected(tab)); - }); + for (tab, group) in &groups { + let show = (app.keybindings_show_all || app.active_keybindings_tab == *tab) + && visible.iter().any(|field| field.tab() == *tab); + set_visible(group, show); + } + })); } { - let stack = stack.clone(); - bindings.push(Box::new(move |app, summary| { - for (tab, section) in §ions { - if section_visible(summary, *tab) && !section.is_visible() { - section.set_visible(true); + let refresh = refresh.clone(); + let mut last_order: Vec<(KeybindingsTabId, Vec)> = KeybindingsTabId::ALL + .into_iter() + .map(|tab| (tab, Vec::new())) + .collect(); + bindings.push(Box::new(move |_app, _summary| { + let refresh = refresh.borrow(); + let Some((_summary, visible)) = refresh.as_ref() else { + return; + }; + for (tab, group) in &groups { + let ordered: Vec = visible + .iter() + .copied() + .filter(|field| field.tab() == *tab) + .collect(); + let Some((_, last)) = last_order.iter_mut().find(|(known, _)| *known == *tab) + else { + continue; + }; + if last.as_slice() == ordered.as_slice() { + continue; } - } - let name = app.active_keybindings_tab.title(); - if stack.visible_child_name().as_deref() != Some(name) { - stack.set_visible_child_name(name); - } - for (tab, section) in §ions { - if !section_visible(summary, *tab) && section.is_visible() { - section.set_visible(false); + for field in &ordered { + if let Some((_, row, home)) = slots.iter().find(|(known, _, _)| known == field) + { + home.remove(row); + group.add(row); + } } + *last = ordered; } })); } - let switcher = gtk::StackSwitcher::builder() - .stack(&stack) - .halign(gtk::Align::Center) - .margin_top(12) - .margin_start(12) - .margin_end(12) - .build(); - let switcher_scroll = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Automatic) - .vscrollbar_policy(gtk::PolicyType::Never) - .propagate_natural_height(true) - .child(&switcher) - .build(); - let conflict = conflict_banner(sender, &mut bindings); + let chrome = build_chrome(sender, &mut bindings); let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.append(&chrome); content.append(&conflict); - content.append(&switcher_scroll); - content.append(&stack); + content.append(&page); BuiltPage { widget: content.upcast(), @@ -124,6 +142,8 @@ fn conflict_banner( sender.input(Message::ShortcutConflictReplaceConfirmed); }); } + let jump = gtk::Button::with_label("Jump to Conflict"); + set_accessible_label(&jump, "Jump to conflicting action"); let cancel = gtk::Button::builder().label("Cancel").build(); set_accessible_label(&cancel, "Cancel shortcut conflict"); { @@ -134,6 +154,7 @@ fn conflict_banner( } let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); buttons.append(&replace); + buttons.append(&jump); buttons.append(&cancel); let row = gtk::Box::new(gtk::Orientation::Horizontal, 12); @@ -149,6 +170,17 @@ fn conflict_banner( .child(&row) .build(); + let jump_field = std::rc::Rc::new(std::cell::Cell::new(None::)); + { + let sender = sender.clone(); + let jump_field = jump_field.clone(); + jump.connect_clicked(move |_| { + if let Some(field) = jump_field.get() { + sender.input(Message::ShortcutManagerJumpTo(field)); + } + }); + } + let revealer_for_bind = revealer.clone(); bindings.push(Box::new(move |app, _summary| { match &app.pending_shortcut_conflict { @@ -156,12 +188,16 @@ fn conflict_banner( set_label(&label, &conflict.prompt()); replace.set_label(conflict.replace_label()); set_accessible_label(&replace, conflict.replace_label()); + let target = conflict.jump_field(); + jump_field.set(target); + set_sensitive(&jump, target.is_some()); if !revealer_for_bind.reveals_child() { revealer_for_bind.set_reveal_child(true); } set_visible(&revealer_for_bind, true); } None => { + jump_field.set(None); if revealer_for_bind.reveals_child() { revealer_for_bind.set_reveal_child(false); } @@ -170,15 +206,3 @@ fn conflict_banner( })); revealer } - -fn tab_from_name(name: &str) -> Option { - KeybindingsTabId::ALL - .into_iter() - .find(|tab| tab.title() == name) -} - -fn section_visible(summary: &AppSearchSummary, tab: KeybindingsTabId) -> bool { - summary - .tab(TabId::Keybindings) - .is_none_or(|keybindings| keybindings.keybindings_tab_visible(tab)) -} diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index fbfbde3b..675dba38 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -1,3 +1,6 @@ +use std::cell::RefCell; +use std::rc::Rc; + use relm4::prelude::*; use relm4::{adw, gtk}; @@ -7,11 +10,10 @@ use wayscriber::config::Shortcut; use crate::messages::Message; use crate::models::KeybindingField; use crate::models::keybindings::{ - field_has_internal_duplicate, field_matches_defaults, parse_keybindings, reset_tooltip, - serialize_bindings, + ShortcutManagerSummary, field_has_internal_duplicate, field_matches_defaults, + parse_keybindings, reset_tooltip, serialize_bindings, }; -use super::super::super::search::AppSearchSummary; use super::super::super::state::ConfiguratorApp; use super::super::Binding; use super::recorder::RecorderPopover; @@ -20,14 +22,17 @@ use super::widgets::{ connect_clicked, icon_button, set_accessible_label, set_label, set_sensitive, set_tooltip, set_visible, watch_compact, }; -use crate::models::TabId; + +pub(super) type ManagerRefresh = + Rc)>>>; pub(super) fn binding_row( group: &adw::PreferencesGroup, field: KeybindingField, sender: &ComponentSender, bindings: &mut Vec, -) { + refresh: ManagerRefresh, +) -> adw::PreferencesRow { let row = adw::PreferencesRow::builder().title(field.label()).build(); let title = gtk::Label::builder() .label(field.label()) @@ -93,8 +98,13 @@ pub(super) fn binding_row( compact.append(&edit_shortcuts); compact.set_visible(false); + let badges = gtk::Box::new(gtk::Orientation::Horizontal, 6); + badges.set_halign(gtk::Align::End); + badges.set_valign(gtk::Align::Center); + let header = gtk::Box::new(gtk::Orientation::Horizontal, 12); header.append(&title); + header.append(&badges); header.append(&compact); let caption = gtk::Label::builder() @@ -115,7 +125,16 @@ pub(super) fn binding_row( body.append(&inline_host); body.append(&caption); row.set_child(Some(&body)); + row.set_can_focus(true); group.add(&row); + { + let sender = sender.clone(); + let focus = gtk::EventControllerFocus::new(); + focus.connect_enter(move |_| { + sender.input(Message::ShortcutManagerRowSelected(field)); + }); + row.add_controller(focus); + } let editor_popover = gtk::Popover::builder().autohide(true).build(); editor_popover.set_parent(&edit_shortcuts); @@ -144,7 +163,9 @@ pub(super) fn binding_row( let recorder = RecorderPopover::build(&row, field, sender); let text_editor = TextEditorPopover::build(&row, field, sender); let seen_chips = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let seen_badges = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); let compact_mode = std::rc::Rc::new(std::cell::Cell::new(false)); + let last_focus_serial = std::rc::Rc::new(std::cell::Cell::new(0)); { let compact = compact.clone(); let inline_host = inline_host.clone(); @@ -166,9 +187,14 @@ pub(super) fn binding_row( } let sender = sender.clone(); - bindings.push(Box::new(move |app, search_summary| { - let visible = row_visible(search_summary, field); - set_visible(&row, visible); + let row_for_bind = row.clone(); + bindings.push(Box::new(move |app, _search_summary| { + let refresh = refresh.borrow(); + let Some((summary, visible)) = refresh.as_ref() else { + return; + }; + let row_is_visible = visible.contains(&field); + set_visible(&row_for_bind, row_is_visible); let value = app.draft.keybindings.value_for(field).unwrap_or_default(); let parsed = parse_keybindings(value); @@ -247,14 +273,33 @@ pub(super) fn binding_row( let editor_text = editing.map(|editor| editor.text.as_str()).unwrap_or(value); let editor_error = editing.and_then(|editor| editor.parse_error()); text_editor.refresh(editing.is_some(), editor_text, editor_error.as_deref()); - })); -} -fn row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { - summary.tab(TabId::Keybindings).is_none_or(|keybindings| { - keybindings.keybinding_field_visible(field) - || keybindings.keybinding_tab_title_visible(field.tab()) - }) + if let Some(manager_row) = summary.row(field) { + let titles: Vec = manager_row + .badge_titles() + .into_iter() + .map(str::to_string) + .collect(); + if seen_badges.borrow().as_slice() != titles.as_slice() { + sync_badges(&badges, &titles); + *seen_badges.borrow_mut() = titles; + } + } + + let selected = app.selected_keybinding == Some(field); + if selected { + if !title.has_css_class("accent") { + title.add_css_class("accent"); + } + } else if title.has_css_class("accent") { + title.remove_css_class("accent"); + } + if selected && row_is_visible && app.keybinding_focus_serial > last_focus_serial.get() { + last_focus_serial.set(app.keybinding_focus_serial); + row_for_bind.grab_focus(); + } + })); + row } fn sync_chips( @@ -295,3 +340,16 @@ fn shortcut_chip( chip.append(&remove); chip } + +fn sync_badges(host: >k::Box, titles: &[String]) { + while let Some(child) = host.first_child() { + host.remove(&child); + } + for title in titles { + let badge = gtk::Label::builder() + .label(title) + .css_classes(["caption", "dim-label"]) + .build(); + host.append(&badge); + } +} diff --git a/configurator/src/app/pages/keybindings/toolbar.rs b/configurator/src/app/pages/keybindings/toolbar.rs new file mode 100644 index 00000000..b017cdad --- /dev/null +++ b/configurator/src/app/pages/keybindings/toolbar.rs @@ -0,0 +1,230 @@ +//! Filter, sort, category scope, and bulk reset chrome for the shortcut manager. + +use relm4::{gtk, prelude::*}; + +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::{KeybindingsTabId, ShortcutManagerFilter, ShortcutManagerSort, TabId}; + +use super::super::super::state::ConfiguratorApp; +use super::super::Binding; +use super::widgets::{connect_clicked, set_accessible_label, set_sensitive, set_visible}; + +pub(super) fn build_chrome( + sender: &ComponentSender, + bindings: &mut Vec, +) -> gtk::Box { + let chrome = gtk::Box::new(gtk::Orientation::Vertical, 8); + chrome.set_margin_top(8); + chrome.set_margin_start(12); + chrome.set_margin_end(12); + + chrome.append(&category_bar(sender, bindings)); + chrome.append(&filter_bar(sender, bindings)); + chrome.append(&actions_bar(sender, bindings)); + chrome.append(&reset_banner(sender, bindings)); + chrome +} + +fn category_bar( + sender: &ComponentSender, + bindings: &mut Vec, +) -> gtk::ScrolledWindow { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let all = scope_button("All Categories", sender, Message::ShortcutManagerShowAll); + row.append(&all); + let mut category_buttons = Vec::new(); + for tab in KeybindingsTabId::ALL { + let button = scope_button(tab.title(), sender, Message::KeybindingsTabSelected(tab)); + row.append(&button); + category_buttons.push((tab, button)); + } + + bindings.push(Box::new(move |app, summary| { + let all_active = app.keybindings_show_all; + set_suggested(&all, all_active); + for (tab, button) in &category_buttons { + let visible = summary + .tab(TabId::Keybindings) + .is_none_or(|keybindings| keybindings.keybindings_tab_visible(*tab)); + set_visible(button, visible); + set_suggested(button, !all_active && app.active_keybindings_tab == *tab); + } + })); + + gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Automatic) + .vscrollbar_policy(gtk::PolicyType::Never) + .propagate_natural_height(true) + .child(&row) + .build() +} + +fn filter_bar( + sender: &ComponentSender, + bindings: &mut Vec, +) -> gtk::FlowBox { + let filters = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .min_children_per_line(2) + .max_children_per_line(6) + .column_spacing(6) + .row_spacing(6) + .build(); + let mut buttons = Vec::new(); + for filter in ShortcutManagerFilter::ALL { + let button = scope_button( + filter.title(), + sender, + Message::ShortcutManagerFilterChanged(filter), + ); + filters.insert(&button, -1); + buttons.push((filter, button)); + } + bindings.push(Box::new(move |app, _summary| { + for (filter, button) in &buttons { + set_suggested(button, app.shortcut_filter == *filter); + } + })); + filters +} + +fn actions_bar(sender: &ComponentSender, bindings: &mut Vec) -> gtk::Box { + let labels: Vec<&str> = ShortcutManagerSort::ALL + .iter() + .map(|sort| sort.title()) + .collect(); + let sort = gtk::DropDown::from_strings(&labels); + set_accessible_label(&sort, "Sort shortcuts"); + { + let sender = sender.clone(); + sort.connect_selected_notify(move |dropdown| { + let index = dropdown.selected() as usize; + if let Some(sort) = ShortcutManagerSort::ALL.get(index).copied() { + sender.input(Message::ShortcutManagerSortChanged(sort)); + } + }); + } + + let reset_visible = gtk::Button::with_label("Reset Visible"); + set_accessible_label(&reset_visible, "Reset visible keybindings"); + connect_clicked( + &reset_visible, + sender, + Message::ShortcutResetVisibleRequested, + ); + let reset_all = gtk::Button::with_label("Reset All"); + set_accessible_label(&reset_all, "Reset all keybindings"); + connect_clicked(&reset_all, sender, Message::ShortcutResetAllRequested); + let review = gtk::Button::with_label("Review Conflicts"); + set_accessible_label(&review, "Review shortcut conflicts"); + connect_clicked(&review, sender, Message::ShortcutConflictReviewStarted); + + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + row.append(&sort); + row.append(&reset_visible); + row.append(&reset_all); + row.append(&review); + + let sort_for_bind = sort.clone(); + bindings.push(Box::new(move |app, _summary| { + let selected = ShortcutManagerSort::ALL + .iter() + .position(|sort| *sort == app.shortcut_sort) + .unwrap_or(0) as u32; + if sort_for_bind.selected() != selected { + sort_for_bind.set_selected(selected); + } + let visible_empty = app.visible_keybinding_fields().is_empty(); + let reset_armed = app.shortcut_reset_visible_pending() || app.shortcut_reset_all_pending(); + set_visible(&reset_visible, !reset_armed); + set_visible(&reset_all, !reset_armed); + set_sensitive( + &reset_visible, + !visible_empty && !app.is_loading && !app.is_saving, + ); + set_sensitive(&reset_all, !app.is_loading && !app.is_saving); + set_sensitive( + &review, + app.shortcut_manager_summary().has_conflicts() + && app.pending_shortcut_conflict.is_none(), + ); + })); + row +} + +fn reset_banner( + sender: &ComponentSender, + bindings: &mut Vec, +) -> gtk::Revealer { + let confirm_visible = gtk::Button::builder() + .label("Confirm Reset Visible") + .css_classes(["destructive-action"]) + .build(); + set_accessible_label(&confirm_visible, "Confirm reset visible"); + connect_clicked( + &confirm_visible, + sender, + Message::ShortcutResetVisibleConfirmed, + ); + let confirm_all = gtk::Button::builder() + .label("Confirm Reset All") + .css_classes(["destructive-action"]) + .build(); + set_accessible_label(&confirm_all, "Confirm reset all"); + connect_clicked(&confirm_all, sender, Message::ShortcutResetAllConfirmed); + let cancel = gtk::Button::with_label("Cancel"); + set_accessible_label(&cancel, "Cancel keybinding reset"); + connect_clicked(&cancel, sender, Message::ShortcutResetCanceled); + + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + buttons.append(&confirm_visible); + buttons.append(&confirm_all); + buttons.append(&cancel); + + let revealer = gtk::Revealer::builder() + .transition_type(gtk::RevealerTransitionType::SlideDown) + .child(&buttons) + .build(); + let revealer_for_bind = revealer.clone(); + bindings.push(Box::new(move |app, _summary| { + let visible_armed = app.shortcut_reset_visible_pending(); + let all_armed = app.shortcut_reset_all_pending(); + set_visible(&confirm_visible, visible_armed); + set_visible(&confirm_all, all_armed); + set_visible(&cancel, visible_armed || all_armed); + if let Some(fields) = app.pending_shortcut_reset_visible_fields() { + let label = format!("Confirm Reset Visible ({})", fields.len()); + if confirm_visible.label().as_deref() != Some(label.as_str()) { + confirm_visible.set_label(&label); + } + } + let reveal = visible_armed || all_armed; + if revealer_for_bind.reveals_child() != reveal { + revealer_for_bind.set_reveal_child(reveal); + } + })); + revealer +} + +fn scope_button( + label: &str, + sender: &ComponentSender, + message: Message, +) -> gtk::Button { + let button = gtk::Button::with_label(label); + set_accessible_label(&button, label); + connect_clicked(&button, sender, message); + button +} + +fn set_suggested(button: >k::Button, suggested: bool) { + if suggested { + if !button.has_css_class("suggested-action") { + button.add_css_class("suggested-action"); + } + } else if button.has_css_class("suggested-action") { + button.remove_css_class("suggested-action"); + } +} diff --git a/configurator/src/app/search/mod.rs b/configurator/src/app/search/mod.rs index 621a2956..80200ffe 100644 --- a/configurator/src/app/search/mod.rs +++ b/configurator/src/app/search/mod.rs @@ -4,13 +4,20 @@ mod terms; mod tests; mod types; -use crate::models::{SearchQuery, TabId}; +use crate::models::{KeybindingField, SearchQuery, TabId}; use super::effects::Effect; use super::state::ConfiguratorApp; pub(crate) use types::{AppSearchSummary, SearchArea, TabSearchSummary}; +pub(crate) fn keybinding_row_visible(summary: &AppSearchSummary, field: KeybindingField) -> bool { + summary.tab(TabId::Keybindings).is_none_or(|keybindings| { + keybindings.keybinding_field_visible(field) + || keybindings.keybinding_tab_title_visible(field.tab()) + }) +} + impl ConfiguratorApp { pub(crate) fn search_summary(&self) -> AppSearchSummary { summary::build_search_summary(self) diff --git a/configurator/src/app/startup.rs b/configurator/src/app/startup.rs index c955f09d..bca32972 100644 --- a/configurator/src/app/startup.rs +++ b/configurator/src/app/startup.rs @@ -33,12 +33,18 @@ impl ConfiguratorApp { match term { Some(term) => { - self.search_query = SearchQuery::new(term); + self.search_query = SearchQuery::new(term.clone()); // Aligned after the tab is set, never before: alignment only // moves off a tab with no matches, so a destination whose own // screen matches the term keeps it, and one whose screen has // nothing to show lands where the matches actually are. self.align_active_tabs_for_search(); + if self.active_tab == TabId::Keybindings + && let Some(field) = + crate::models::keybindings::field_matching_search_term(&term) + { + self.select_keybinding_field(field); + } // The box now holds text the user will want to edit or clear. self.handle_search_focus_requested() } @@ -76,10 +82,13 @@ impl ConfiguratorApp { ConfiguratorScreen::Tablet => self.active_tab = TabId::Tablet, ConfiguratorScreen::Keybindings(section) => { self.active_tab = TabId::Keybindings; - // No section means the tab, on whichever subtab it already - // shows; the launcher only knew which tab it wanted. + // No section means the whole manager; a named section keeps + // the destination contract of opening that category. if let Some(section) = section { self.active_keybindings_tab = keybindings_tab(section); + self.keybindings_show_all = false; + } else { + self.keybindings_show_all = true; } } } @@ -263,6 +272,10 @@ mod tests { assert_eq!(app.active_tab, TabId::Keybindings); assert_eq!(app.active_keybindings_tab, KeybindingsTabId::Drawing); assert_eq!(app.search_query.raw(), "Clear Canvas"); + assert_eq!( + app.selected_keybinding, + Some(crate::models::KeybindingField::ClearCanvas) + ); assert!(app.search_summary().tab(TabId::Keybindings).is_some()); } @@ -275,6 +288,10 @@ mod tests { assert_eq!(app.active_tab, TabId::Keybindings); assert_eq!(app.active_keybindings_tab, KeybindingsTabId::Drawing); + assert_eq!( + app.selected_keybinding, + Some(crate::models::KeybindingField::ClearCanvas) + ); } /// A term with no match on the requested screen is the one case where diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index ca95148d..db8a49b8 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -7,9 +7,9 @@ use wayscriber::config::{ use crate::models::{ ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, DragMouseButton, - KeybindingsTabId, PendingShortcutConflict, SearchQuery, SessionCatalogState, - ShortcutRecorderState, ShortcutTextEditor, StartupRequest, TabId, ToolbarLayoutModeOption, - UiTabId, + KeybindingField, KeybindingsTabId, PendingShortcutConflict, SearchQuery, SessionCatalogState, + ShortcutManagerFilter, ShortcutManagerSort, ShortcutRecorderState, ShortcutTextEditor, + StartupRequest, TabId, ToolbarLayoutModeOption, UiTabId, }; use super::effects::Effect; @@ -28,6 +28,12 @@ pub(crate) struct ConfiguratorApp { pub(crate) active_tab: TabId, pub(crate) active_ui_tab: UiTabId, pub(crate) active_keybindings_tab: KeybindingsTabId, + pub(crate) keybindings_show_all: bool, + pub(crate) shortcut_filter: ShortcutManagerFilter, + pub(crate) shortcut_sort: ShortcutManagerSort, + pub(crate) selected_keybinding: Option, + pub(crate) keybinding_focus_serial: u64, + pub(crate) shortcut_conflict_review: bool, pub(crate) active_drawing_drag_button: Option, pub(crate) preset_collapsed: Vec, pub(crate) boards_collapsed: Vec, @@ -84,12 +90,16 @@ pub(crate) struct ConfiguratorApp { pub(crate) enum ConfirmationPrompt { DefaultsReset, SessionClear, + ShortcutResetVisible, + ShortcutResetAll, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PendingConfirmation { DefaultsReset, SessionClear(String), + ShortcutResetVisible(Vec), + ShortcutResetAll, } impl PendingConfirmation { @@ -97,6 +107,10 @@ impl PendingConfirmation { match self { PendingConfirmation::DefaultsReset => ConfirmationPrompt::DefaultsReset, PendingConfirmation::SessionClear(_) => ConfirmationPrompt::SessionClear, + PendingConfirmation::ShortcutResetVisible(_) => { + ConfirmationPrompt::ShortcutResetVisible + } + PendingConfirmation::ShortcutResetAll => ConfirmationPrompt::ShortcutResetAll, } } } @@ -110,6 +124,12 @@ impl ConfirmationPrompt { ConfirmationPrompt::SessionClear => { "Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue." } + ConfirmationPrompt::ShortcutResetVisible => { + "Reset Visible restores the currently listed keybindings to their defaults. Press \"Confirm Reset Visible\" to continue. Nothing is written until you Save." + } + ConfirmationPrompt::ShortcutResetAll => { + "Reset All restores every keybinding to its built-in default. Press \"Confirm Reset All\" to continue. Nothing is written until you Save." + } } } } @@ -210,6 +230,12 @@ impl ConfiguratorApp { active_tab: TabId::Daemon, active_ui_tab: UiTabId::Toolbar, active_keybindings_tab: KeybindingsTabId::General, + keybindings_show_all: true, + shortcut_filter: ShortcutManagerFilter::All, + shortcut_sort: ShortcutManagerSort::Category, + selected_keybinding: None, + keybinding_focus_serial: 0, + shortcut_conflict_review: false, active_drawing_drag_button: None, preset_collapsed: vec![false; PRESET_SLOTS_MAX], boards_collapsed: vec![false; boards_len], @@ -255,6 +281,7 @@ impl ConfiguratorApp { pub(super) fn refresh_dirty_flag(&mut self) { self.clear_defaults_confirmation(); + self.clear_keybinding_reset_confirmation(); self.is_dirty = self.draft != self.baseline; } @@ -268,7 +295,28 @@ impl ConfiguratorApp { pub(crate) fn pending_session_clear_id(&self) -> Option<&str> { match self.pending_confirmation.as_ref() { Some(PendingConfirmation::SessionClear(id)) => Some(id.as_str()), - Some(PendingConfirmation::DefaultsReset) | None => None, + _ => None, + } + } + + pub(crate) fn shortcut_reset_visible_pending(&self) -> bool { + matches!( + self.pending_confirmation, + Some(PendingConfirmation::ShortcutResetVisible(_)) + ) + } + + pub(crate) fn shortcut_reset_all_pending(&self) -> bool { + matches!( + self.pending_confirmation, + Some(PendingConfirmation::ShortcutResetAll) + ) + } + + pub(crate) fn pending_shortcut_reset_visible_fields(&self) -> Option<&[KeybindingField]> { + match self.pending_confirmation.as_ref() { + Some(PendingConfirmation::ShortcutResetVisible(fields)) => Some(fields.as_slice()), + _ => None, } } @@ -284,6 +332,12 @@ impl ConfiguratorApp { } } + pub(super) fn clear_keybinding_reset_confirmation(&mut self) { + if self.shortcut_reset_visible_pending() || self.shortcut_reset_all_pending() { + self.pending_confirmation = None; + } + } + /// The migration offer to show, if there is one to show. /// /// Dismissing hides the offer for the rest of this app run, including @@ -312,6 +366,7 @@ impl ConfiguratorApp { self.active_shortcut_recorder = None; self.shortcut_text_editor = None; self.pending_shortcut_conflict = None; + self.shortcut_conflict_review = false; } } diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index ff8ebd67..aa0aca4b 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -101,6 +101,29 @@ impl ConfiguratorApp { Message::TabSelected(tab) => self.handle_tab_selected(tab), Message::UiTabSelected(tab) => self.handle_ui_tab_selected(tab), Message::KeybindingsTabSelected(tab) => self.handle_keybindings_tab_selected(tab), + Message::ShortcutManagerShowAll => self.handle_shortcut_manager_show_all(), + Message::ShortcutManagerFilterChanged(filter) => { + self.handle_shortcut_manager_filter_changed(filter) + } + Message::ShortcutManagerSortChanged(sort) => { + self.handle_shortcut_manager_sort_changed(sort) + } + Message::ShortcutManagerRowSelected(field) => { + self.handle_shortcut_manager_row_selected(field) + } + Message::ShortcutManagerJumpTo(field) => self.handle_shortcut_manager_jump_to(field), + Message::ShortcutResetVisibleRequested => { + self.handle_shortcut_reset_visible_requested() + } + Message::ShortcutResetVisibleConfirmed => { + self.handle_shortcut_reset_visible_confirmed() + } + Message::ShortcutResetAllRequested => self.handle_shortcut_reset_all_requested(), + Message::ShortcutResetAllConfirmed => self.handle_shortcut_reset_all_confirmed(), + Message::ShortcutResetCanceled => self.handle_shortcut_reset_canceled(), + Message::ShortcutConflictReviewStarted => { + self.handle_shortcut_conflict_review_started() + } Message::ToggleChanged(field, value) => self.handle_toggle_changed(field, value), Message::TextChanged(field, value) => self.handle_text_changed(field, value), Message::ColorPickerHexChanged(id, value) => { diff --git a/configurator/src/app/update/shortcuts.rs b/configurator/src/app/update/shortcuts.rs index 7c14d728..76c7d35b 100644 --- a/configurator/src/app/update/shortcuts.rs +++ b/configurator/src/app/update/shortcuts.rs @@ -5,12 +5,15 @@ mod tests; use wayscriber::config::Shortcut; +use crate::app::search::keybinding_row_visible; +use crate::app::state::{ConfirmationPrompt, PendingConfirmation}; use crate::models::KeybindingField; use crate::models::keybindings::{ AppendOutcome, KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, - ShortcutRecorderState, ShortcutTextEditor, append_binding, apply_recorded_replace, - apply_text_replace, normalize_button_event, normalize_key_event, other_claimants, - parse_keybindings, remove_binding, reset_field, sequence_keyboard_only_message, + ShortcutManagerFilter, ShortcutManagerSort, ShortcutManagerSummary, ShortcutRecorderState, + ShortcutTextEditor, append_binding, apply_recorded_replace, apply_text_replace, + next_review_conflict, normalize_button_event, normalize_key_event, other_claimants, + parse_keybindings, remove_binding, reset_field, reset_fields, sequence_keyboard_only_message, text_conflicts_for, }; @@ -253,8 +256,12 @@ impl ConfiguratorApp { }; match result { Ok(()) => { - self.status = StatusMessage::idle(); self.refresh_dirty_flag(); + if self.shortcut_conflict_review { + self.arm_next_shortcut_conflict(); + } else { + self.status = StatusMessage::idle(); + } } Err(error) => { self.status = StatusMessage::error(error.message().to_string()); @@ -265,6 +272,7 @@ impl ConfiguratorApp { pub(super) fn handle_shortcut_conflict_canceled(&mut self) -> Vec { self.pending_shortcut_conflict = None; + self.shortcut_conflict_review = false; Vec::new() } @@ -275,6 +283,154 @@ impl ConfiguratorApp { self.handle_active_confirmation_canceled() } + pub(crate) fn shortcut_manager_summary(&self) -> ShortcutManagerSummary { + ShortcutManagerSummary::from_drafts(&self.draft.keybindings, &self.defaults.keybindings) + } + + pub(crate) fn visible_keybinding_fields(&self) -> Vec { + let search = self.search_summary(); + 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), + ) + } + + pub(crate) fn select_keybinding_field(&mut self, field: KeybindingField) { + self.selected_keybinding = Some(field); + self.active_keybindings_tab = field.tab(); + self.keybinding_focus_serial = self.keybinding_focus_serial.saturating_add(1); + } + + pub(super) fn handle_shortcut_manager_show_all(&mut self) -> Vec { + self.keybindings_show_all = true; + Vec::new() + } + + pub(super) fn handle_shortcut_manager_filter_changed( + &mut self, + filter: ShortcutManagerFilter, + ) -> Vec { + self.shortcut_filter = filter; + Vec::new() + } + + pub(super) fn handle_shortcut_manager_sort_changed( + &mut self, + sort: ShortcutManagerSort, + ) -> Vec { + self.shortcut_sort = sort; + Vec::new() + } + + pub(super) fn handle_shortcut_manager_row_selected( + &mut self, + field: KeybindingField, + ) -> Vec { + self.selected_keybinding = Some(field); + Vec::new() + } + + pub(super) fn handle_shortcut_manager_jump_to( + &mut self, + field: KeybindingField, + ) -> Vec { + self.select_keybinding_field(field); + Vec::new() + } + + pub(super) fn handle_shortcut_reset_visible_requested(&mut self) -> Vec { + if self.is_loading || self.is_saving || self.shortcut_reset_visible_pending() { + return Vec::new(); + } + let fields = self.visible_keybinding_fields(); + if fields.is_empty() { + return Vec::new(); + } + self.pending_confirmation = Some(PendingConfirmation::ShortcutResetVisible(fields)); + self.status = StatusMessage::confirmation(ConfirmationPrompt::ShortcutResetVisible); + Vec::new() + } + + pub(super) fn handle_shortcut_reset_visible_confirmed(&mut self) -> Vec { + let Some(PendingConfirmation::ShortcutResetVisible(fields)) = + self.pending_confirmation.take() + else { + return Vec::new(); + }; + reset_fields( + &mut self.draft.keybindings, + &self.defaults.keybindings, + &fields, + ); + self.status = StatusMessage::info("Reset visible keybindings (not saved)."); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(super) fn handle_shortcut_reset_all_requested(&mut self) -> Vec { + if self.is_loading || self.is_saving || self.shortcut_reset_all_pending() { + return Vec::new(); + } + self.pending_confirmation = Some(PendingConfirmation::ShortcutResetAll); + self.status = StatusMessage::confirmation(ConfirmationPrompt::ShortcutResetAll); + Vec::new() + } + + pub(super) fn handle_shortcut_reset_all_confirmed(&mut self) -> Vec { + if !self.shortcut_reset_all_pending() { + return Vec::new(); + } + self.pending_confirmation = None; + self.draft.keybindings = self.defaults.keybindings.clone(); + self.clear_shortcut_editing(); + self.status = StatusMessage::info("Reset all keybindings (not saved)."); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(super) fn handle_shortcut_reset_canceled(&mut self) -> Vec { + if !self.shortcut_reset_visible_pending() && !self.shortcut_reset_all_pending() { + return Vec::new(); + } + self.handle_active_confirmation_canceled() + } + + pub(super) fn handle_shortcut_conflict_review_started(&mut self) -> Vec { + if !self.shortcut_manager_summary().has_conflicts() { + self.shortcut_conflict_review = false; + self.status = StatusMessage::info("No shortcut conflicts to review."); + return Vec::new(); + } + self.shortcut_conflict_review = true; + if self.pending_shortcut_conflict.is_some() { + return Vec::new(); + } + self.arm_next_shortcut_conflict(); + Vec::new() + } + + fn arm_next_shortcut_conflict(&mut self) { + match next_review_conflict(&self.draft.keybindings) { + Some((field, binding, claimants)) => { + self.select_keybinding_field(field); + self.pending_shortcut_conflict = + Some(crate::models::PendingShortcutConflict::Recorded { + target: field, + binding, + claimants, + }); + self.status = StatusMessage::info("Review the next conflicting shortcut."); + } + None => { + self.shortcut_conflict_review = false; + self.status = StatusMessage::success("No remaining shortcut conflicts."); + } + } + } + fn commit_recorded_binding( &mut self, field: KeybindingField, diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index 0330be72..a294f0d0 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -443,3 +443,234 @@ fn text_editor_accepts_a_sequence_beside_a_single() { Some("F5, Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C") ); } + +#[test] +fn search_filter_and_selection_do_not_dirty_the_config() { + use crate::models::{ShortcutManagerFilter, ShortcutManagerSort}; + + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_dirty = false; + let _ = app.handle_search_changed("undo".to_string()); + let _ = app.handle_shortcut_manager_filter_changed(ShortcutManagerFilter::Changed); + let _ = app.handle_shortcut_manager_sort_changed(ShortcutManagerSort::Name); + let _ = app.handle_shortcut_manager_show_all(); + let _ = app.handle_shortcut_manager_row_selected(KeybindingField::Undo); + assert!(!app.is_dirty); + assert_eq!(app.selected_keybinding, Some(KeybindingField::Undo)); + assert!(app.keybindings_show_all); +} + +#[test] +fn reset_visible_affects_exactly_the_filtered_identity_set() { + use crate::models::ShortcutManagerFilter; + + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.keybindings_show_all = true; + app.draft + .keybindings + .set(KeybindingField::Undo, "F9".to_string()); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "X".to_string()); + app.draft + .keybindings + .set(KeybindingField::Redo, "F4".to_string()); + app.baseline = app.draft.clone(); + app.is_dirty = false; + let _ = app.handle_shortcut_manager_filter_changed(ShortcutManagerFilter::Changed); + let visible = app.visible_keybinding_fields(); + assert!(visible.contains(&KeybindingField::Undo)); + assert!(visible.contains(&KeybindingField::ClearCanvas)); + assert!(visible.contains(&KeybindingField::Redo)); + + let _ = app.handle_shortcut_reset_visible_requested(); + assert!(app.shortcut_reset_visible_pending()); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("F9") + ); + + let _ = app.handle_shortcut_reset_visible_requested(); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("F9"), + "asking twice must not apply the reset" + ); + + let _ = app.handle_shortcut_reset_visible_confirmed(); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("Ctrl+Z") + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ClearCanvas), + Some("E") + ); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Redo), + Some("Ctrl+Shift+Z, Ctrl+Y") + ); + assert!(app.is_dirty); + assert!(app.pending_confirmation.is_none()); +} + +#[test] +fn reset_visible_does_not_touch_fields_outside_the_filter() { + use crate::models::ShortcutManagerFilter; + + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.keybindings_show_all = true; + app.draft + .keybindings + .set(KeybindingField::Undo, "F9".to_string()); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "X".to_string()); + let _ = app.handle_shortcut_manager_filter_changed(ShortcutManagerFilter::Unbound); + app.draft + .keybindings + .set(KeybindingField::ToggleFloatingBadge, "".to_string()); + let _ = app.handle_shortcut_reset_visible_requested(); + let _ = app.handle_shortcut_reset_visible_confirmed(); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("F9"), + "a changed bound action must survive an Unbound reset" + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ClearCanvas), + Some("X") + ); +} + +#[test] +fn reset_all_requires_confirmation_and_stays_draft_only() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft + .keybindings + .set(KeybindingField::Undo, "F9".to_string()); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "X".to_string()); + app.baseline = app.draft.clone(); + app.is_dirty = false; + let before = app.draft.keybindings.clone(); + + let effects = app.handle_shortcut_reset_all_requested(); + assert!(effects.is_empty()); + assert!(app.shortcut_reset_all_pending()); + assert_eq!(app.draft.keybindings, before); + + let effects = app.handle_shortcut_reset_all_confirmed(); + assert!(effects.is_empty(), "reset all must not write the file"); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("Ctrl+Z") + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ClearCanvas), + Some("E") + ); + assert!(app.is_dirty); +} + +#[test] +fn reset_all_confirm_without_request_changes_nothing() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft + .keybindings + .set(KeybindingField::Undo, "F9".to_string()); + let _ = app.handle_shortcut_reset_all_confirmed(); + assert_eq!( + app.draft.keybindings.value_for(KeybindingField::Undo), + Some("F9") + ); +} + +#[test] +fn conflict_review_queue_arms_the_next_conflict_after_replace() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + app.draft + .keybindings + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + app.draft + .keybindings + .set(KeybindingField::Undo, "Ctrl+Alt+Shift+Y".to_string()); + app.draft + .keybindings + .set(KeybindingField::Redo, "Ctrl+Alt+Shift+Y".to_string()); + + let _ = app.handle_shortcut_conflict_review_started(); + assert!(app.shortcut_conflict_review); + assert!(app.pending_shortcut_conflict.is_some()); + let first_target = match app.pending_shortcut_conflict.as_ref() { + Some(crate::models::PendingShortcutConflict::Recorded { target, .. }) => *target, + other => panic!("expected a recorded conflict, got {other:?}"), + }; + + let _ = app.handle_shortcut_conflict_replace_confirmed(); + assert!( + app.shortcut_conflict_review, + "the queue continues after one replace" + ); + let second = app + .pending_shortcut_conflict + .as_ref() + .expect("the next conflict should be armed"); + match second { + crate::models::PendingShortcutConflict::Recorded { target, .. } => { + assert_ne!(*target, first_target); + } + other => panic!("expected a recorded conflict, got {other:?}"), + } +} + +#[test] +fn conflict_review_cancel_stops_the_queue() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + app.draft + .keybindings + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + let _ = app.handle_shortcut_conflict_review_started(); + let _ = app.handle_shortcut_conflict_canceled(); + assert!(!app.shortcut_conflict_review); + assert!(app.pending_shortcut_conflict.is_none()); +} + +#[test] +fn jump_to_conflict_selects_the_other_claimant() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + app.draft + .keybindings + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + let _ = app.handle_shortcut_conflict_review_started(); + let jump = app + .pending_shortcut_conflict + .as_ref() + .and_then(crate::models::PendingShortcutConflict::jump_field) + .expect("a claimant to jump to"); + let _ = app.handle_shortcut_manager_jump_to(jump); + assert_eq!(app.selected_keybinding, Some(jump)); + assert_eq!(app.active_keybindings_tab, jump.tab()); + assert!(!app.is_dirty); +} diff --git a/configurator/src/app/update/tabs.rs b/configurator/src/app/update/tabs.rs index 86f3249a..a98f6a4e 100644 --- a/configurator/src/app/update/tabs.rs +++ b/configurator/src/app/update/tabs.rs @@ -18,6 +18,7 @@ impl ConfiguratorApp { pub(super) fn handle_keybindings_tab_selected(&mut self, tab: KeybindingsTabId) -> Vec { self.active_keybindings_tab = tab; + self.keybindings_show_all = false; self.align_active_tabs_for_search(); Vec::new() } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index b2f5883f..33afe4f6 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -13,9 +13,10 @@ use crate::models::{ PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, RecorderDeviceKind, ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult, SessionCatalogItem, - SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, - ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, UiTabId, UiThemeOption, ZoomChipDisplayOption, + SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter, ShortcutManagerSort, + StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, + ToolbarOverrideField, ToolbarRebindModifierOption, UiTabId, UiThemeOption, + ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -89,6 +90,17 @@ pub enum Message { TabSelected(TabId), UiTabSelected(UiTabId), KeybindingsTabSelected(KeybindingsTabId), + ShortcutManagerShowAll, + ShortcutManagerFilterChanged(ShortcutManagerFilter), + ShortcutManagerSortChanged(ShortcutManagerSort), + ShortcutManagerRowSelected(KeybindingField), + ShortcutManagerJumpTo(KeybindingField), + ShortcutResetVisibleRequested, + ShortcutResetVisibleConfirmed, + ShortcutResetAllRequested, + ShortcutResetAllConfirmed, + ShortcutResetCanceled, + ShortcutConflictReviewStarted, ToggleChanged(ToggleField, bool), TextChanged(TextField, String), ColorPickerHexChanged(ColorPickerId, String), diff --git a/configurator/src/models/keybindings/AGENTS.md b/configurator/src/models/keybindings/AGENTS.md index 9d1fbea3..aa2ac4aa 100644 --- a/configurator/src/models/keybindings/AGENTS.md +++ b/configurator/src/models/keybindings/AGENTS.md @@ -4,7 +4,7 @@ - Applies to configurator keybinding models under `configurator/src/models/keybindings/`. ## Architecture -- Owns draft keybinding state, parsing, field-level labels/config read/write, grouping, recorder normalization, conflict lookup, and tests. +- Owns draft keybinding state, parsing, field-level labels/config read/write, grouping, recorder normalization, conflict lookup, the bulk `ShortcutManagerSummary`, and tests. ## Invariants - Keep action labels, categories, defaults, parsing, and display aligned with `src/config/keybindings/` and `src/config/action_meta/`. diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index 7cb60a42..0c6b8c85 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -79,6 +79,25 @@ impl PendingShortcutConflict { Self::Text { .. } => "Resolve Conflicts", } } + + pub fn jump_field(&self) -> Option { + match self { + Self::Recorded { + target, claimants, .. + } => claimants + .iter() + .find_map(|claim| claim.field.filter(|field| *field != *target)) + .or_else(|| claimants.iter().find_map(|claim| claim.field)), + Self::Text { + target, conflicts, .. + } => conflicts.iter().find_map(|conflict| { + conflict + .claimants + .iter() + .find_map(|claim| claim.field.filter(|field| *field != *target)) + }), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs index d279994f..45a6681c 100644 --- a/configurator/src/models/keybindings/edit.rs +++ b/configurator/src/models/keybindings/edit.rs @@ -97,6 +97,16 @@ pub fn reset_field( draft.set(field, value); } +pub fn reset_fields( + draft: &mut KeybindingsDraft, + defaults: &KeybindingsDraft, + fields: &[KeybindingField], +) { + for field in fields { + reset_field(draft, defaults, *field); + } +} + #[cfg(test)] fn apply_parsed_text( draft: &mut KeybindingsDraft, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index e3064d8b..950ae096 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -3,7 +3,7 @@ mod labels; mod list; mod tab; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeybindingField { Exit, EnterTextMode, diff --git a/configurator/src/models/keybindings/manager.rs b/configurator/src/models/keybindings/manager.rs new file mode 100644 index 00000000..04651b9e --- /dev/null +++ b/configurator/src/models/keybindings/manager.rs @@ -0,0 +1,635 @@ +//! Pure bulk-manager view of the keybinding draft. +//! +//! Filtering, sorting, source badges, and conflict membership are computed +//! from [`KeybindingsDraft`] identity, not from widgets. Search still lives in +//! the app shell; callers intersect that visibility with this summary. + +use wayscriber::config::{Action, Shortcut, ShortcutTrigger}; + +use super::conflicts::{claimants_for, field_has_internal_duplicate, other_claimants}; +use super::draft::KeybindingsDraft; +use super::edit::field_matches_defaults; +use super::field::KeybindingField; +use super::parse::parse_keybindings; +use crate::models::KeybindingsTabId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShortcutManagerFilter { + #[default] + All, + Changed, + Conflicts, + Unbound, + Device, + Sequences, +} + +impl ShortcutManagerFilter { + pub const ALL: [Self; 6] = [ + Self::All, + Self::Changed, + Self::Conflicts, + Self::Unbound, + Self::Device, + Self::Sequences, + ]; + + pub fn title(self) -> &'static str { + match self { + Self::All => "All", + Self::Changed => "Changed", + Self::Conflicts => "Conflicts", + Self::Unbound => "Unbound", + Self::Device => "Device", + Self::Sequences => "Sequences", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShortcutManagerSort { + #[default] + Category, + Name, + Changed, +} + +impl ShortcutManagerSort { + pub const ALL: [Self; 3] = [Self::Category, Self::Name, Self::Changed]; + + pub fn title(self) -> &'static str { + match self { + Self::Category => "Category", + Self::Name => "Name", + Self::Changed => "Changed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutSourceBadge { + Default, + Authored, + LegacyTablet, + Unavailable, +} + +impl ShortcutSourceBadge { + pub fn title(self) -> &'static str { + match self { + Self::Default => "Default", + Self::Authored => "Authored", + Self::LegacyTablet => "Legacy Tablet", + Self::Unavailable => "Unavailable", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutRowStatus { + Default, + Changed, + Unbound, + Conflict, + Invalid, +} + +impl ShortcutRowStatus { + pub fn title(self) -> &'static str { + match self { + Self::Default => "Default", + Self::Changed => "Changed", + Self::Unbound => "Unbound", + Self::Conflict => "Conflict", + Self::Invalid => "Invalid", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutManagerRow { + pub field: KeybindingField, + pub label: &'static str, + pub default_label: String, + pub status: ShortcutRowStatus, + pub sources: Vec, + pub changed: bool, + pub unbound: bool, + pub has_conflict: bool, + pub has_device: bool, + pub has_sequence: bool, + pub has_unavailable: bool, + pub parse_error: bool, +} + +impl ShortcutManagerRow { + pub fn badge_titles(&self) -> Vec<&'static str> { + let mut titles = Vec::new(); + if matches!( + self.status, + ShortcutRowStatus::Conflict | ShortcutRowStatus::Invalid | ShortcutRowStatus::Unbound + ) { + titles.push(self.status.title()); + } + for source in &self.sources { + let title = source.title(); + if !titles.contains(&title) { + titles.push(title); + } + } + if titles.is_empty() { + titles.push(self.status.title()); + } + titles + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutManagerSummary { + rows: Vec, +} + +impl ShortcutManagerSummary { + pub fn from_drafts(draft: &KeybindingsDraft, defaults: &KeybindingsDraft) -> Self { + let contested = contested_fields(draft); + let rows = KeybindingField::all() + .into_iter() + .map(|field| row_for(draft, defaults, field, contested.contains(&field))) + .collect(); + Self { rows } + } + + #[cfg(test)] + pub fn rows(&self) -> &[ShortcutManagerRow] { + &self.rows + } + + pub fn row(&self, field: KeybindingField) -> Option<&ShortcutManagerRow> { + self.rows.iter().find(|row| row.field == field) + } + + pub fn has_conflicts(&self) -> bool { + self.rows.iter().any(|row| row.has_conflict) + } + + pub fn visible_fields( + &self, + filter: ShortcutManagerFilter, + sort: ShortcutManagerSort, + scope: Option, + search_visible: impl Fn(KeybindingField) -> bool, + ) -> Vec { + let mut fields: Vec = self + .rows + .iter() + .filter(|row| scope.is_none_or(|tab| row.field.tab() == tab)) + .filter(|row| search_visible(row.field)) + .filter(|row| row_matches_filter(row, filter)) + .map(|row| row.field) + .collect(); + sort_fields(&mut fields, self, sort); + fields + } +} + +/// Exact destination/search identity: the opened config key or the action label. +pub fn field_matching_search_term(term: &str) -> Option { + let needle = compact_term(term); + if needle.is_empty() { + return None; + } + let mut key_match = None; + let mut label_match = None; + for field in KeybindingField::all() { + if compact_term(&field.field_key().replace('_', " ")) == needle { + key_match = Some(field); + } + if compact_term(field.label()) == needle { + label_match = Some(field); + } + } + key_match.or(label_match) +} + +pub fn next_review_conflict( + draft: &KeybindingsDraft, +) -> Option<( + KeybindingField, + Shortcut, + Vec, +)> { + for field in KeybindingField::all() { + let Some(value) = draft.value_for(field) else { + continue; + }; + let Ok(parsed) = parse_keybindings(value) else { + continue; + }; + for binding in parsed { + let claimants = other_claimants(draft, field, &binding); + if !claimants.is_empty() { + return Some((field, binding, claimants)); + } + } + } + None +} + +fn row_for( + draft: &KeybindingsDraft, + defaults: &KeybindingsDraft, + 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 parse_error = parsed.is_err(); + let changed = !field_matches_defaults(draft, defaults, field); + let (unbound, has_device, has_sequence, has_unavailable) = match &parsed { + Ok(bindings) => flags_for_bindings(bindings), + Err(_) => (false, false, false, false), + }; + let has_legacy = field_has_legacy_tablet(draft, field); + let status = if parse_error { + ShortcutRowStatus::Invalid + } else if has_conflict { + ShortcutRowStatus::Conflict + } else if unbound { + ShortcutRowStatus::Unbound + } else if changed { + ShortcutRowStatus::Changed + } else { + ShortcutRowStatus::Default + }; + let mut sources = Vec::new(); + if changed { + sources.push(ShortcutSourceBadge::Authored); + } else { + sources.push(ShortcutSourceBadge::Default); + } + if has_legacy { + sources.push(ShortcutSourceBadge::LegacyTablet); + } + if has_unavailable { + sources.push(ShortcutSourceBadge::Unavailable); + } + let default_label = if default_value.trim().is_empty() { + "Unbound".to_string() + } else { + default_value.to_string() + }; + ShortcutManagerRow { + field, + label: field.label(), + default_label, + status, + sources, + changed, + unbound, + has_conflict, + has_device, + has_sequence, + has_unavailable, + parse_error, + } +} + +fn flags_for_bindings(bindings: &[Shortcut]) -> (bool, bool, bool, bool) { + let unbound = bindings.is_empty(); + let has_device = bindings.iter().any(is_device_shortcut); + let has_sequence = bindings + .iter() + .any(|binding| matches!(binding, Shortcut::Sequence(_))); + let has_unavailable = bindings.iter().any(|binding| !binding.is_deliverable()); + (unbound, has_device, has_sequence, has_unavailable) +} + +fn is_device_shortcut(binding: &Shortcut) -> bool { + matches!( + binding.as_trigger(), + Some(ShortcutTrigger::Pointer(_) | ShortcutTrigger::Stylus(_)) + ) +} + +fn field_has_legacy_tablet(draft: &KeybindingsDraft, field: KeybindingField) -> bool { + let Some(action) = field.action() else { + return false; + }; + draft.legacy_tablet.stylus_primary == Some(action) + || draft.legacy_tablet.stylus_secondary == Some(action) +} + +fn contested_fields(draft: &KeybindingsDraft) -> Vec { + let mut contested = Vec::new(); + let mut mark = |field: KeybindingField| { + if !contested.contains(&field) { + contested.push(field); + } + }; + for field in KeybindingField::all() { + if field_has_internal_duplicate(draft, field) { + mark(field); + } + let Some(value) = draft.value_for(field) else { + continue; + }; + let Ok(parsed) = parse_keybindings(value) else { + continue; + }; + for binding in parsed { + if !other_claimants(draft, field, &binding).is_empty() { + mark(field); + } + } + } + mark_legacy_claimants(draft, &mut mark); + contested +} + +fn mark_legacy_claimants(draft: &KeybindingsDraft, mark: &mut impl FnMut(KeybindingField)) { + for (action, name) in [ + (draft.legacy_tablet.stylus_primary, "StylusPrimary"), + (draft.legacy_tablet.stylus_secondary, "StylusSecondary"), + ] { + let Some(action) = action else { + continue; + }; + let Ok(binding) = Shortcut::parse(name) else { + continue; + }; + let claims = claimants_for(draft, &binding); + if claims.len() < 2 { + continue; + } + if let Some(field) = field_for_action(action) { + mark(field); + } + } +} + +fn field_for_action(action: Action) -> Option { + KeybindingField::all() + .into_iter() + .find(|field| field.action() == Some(action)) +} + +fn row_matches_filter(row: &ShortcutManagerRow, filter: ShortcutManagerFilter) -> bool { + match filter { + ShortcutManagerFilter::All => true, + ShortcutManagerFilter::Changed => row.changed, + ShortcutManagerFilter::Conflicts => row.has_conflict, + ShortcutManagerFilter::Unbound => row.unbound, + ShortcutManagerFilter::Device => row.has_device, + ShortcutManagerFilter::Sequences => row.has_sequence, + } +} + +fn sort_fields( + fields: &mut [KeybindingField], + summary: &ShortcutManagerSummary, + sort: ShortcutManagerSort, +) { + match sort { + ShortcutManagerSort::Category => {} + ShortcutManagerSort::Name => fields.sort_by_key(|field| field.label()), + ShortcutManagerSort::Changed => fields.sort_by_key(|field| { + let changed = summary.row(*field).is_some_and(|row| row.changed); + (!changed, field.label()) + }), + } +} + +fn compact_term(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use wayscriber::config::keybindings::KeybindingsConfig; + use wayscriber::config::{Action, Shortcut}; + + use super::*; + use crate::models::keybindings::{apply_recorded_replace, other_claimants}; + + fn drafts() -> (KeybindingsDraft, KeybindingsDraft) { + let defaults = KeybindingsDraft::from_config(&KeybindingsConfig::default()); + (defaults.clone(), defaults) + } + + #[test] + fn every_configurable_action_appears_exactly_once() { + let (draft, defaults) = drafts(); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let fields: Vec<_> = summary.rows().iter().map(|row| row.field).collect(); + assert_eq!(fields, KeybindingField::all()); + let mut seen = Vec::new(); + for field in fields { + assert!( + !seen.contains(&field), + "{field:?} listed twice in the manager" + ); + seen.push(field); + } + } + + #[test] + fn changed_compares_parsed_values_not_whitespace() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::Redo, "Ctrl+Shift+Z,Ctrl+Y".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let row = summary.row(KeybindingField::Redo).expect("redo row"); + assert!(!row.changed); + assert_eq!(row.status, ShortcutRowStatus::Default); + assert!(row.sources.contains(&ShortcutSourceBadge::Default)); + + draft.set(KeybindingField::Redo, "F4".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let row = summary.row(KeybindingField::Redo).expect("redo row"); + assert!(row.changed); + assert!(row.sources.contains(&ShortcutSourceBadge::Authored)); + } + + #[test] + fn conflict_filter_includes_every_claimant() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let visible = summary.visible_fields( + ShortcutManagerFilter::Conflicts, + ShortcutManagerSort::Category, + None, + |_| true, + ); + assert!(visible.contains(&KeybindingField::ClearCanvas)); + assert!(visible.contains(&KeybindingField::ToggleToolbar)); + } + + #[test] + fn device_and_sequence_filters_keep_field_identity() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::Undo, "MouseBack".to_string()); + draft.set( + KeybindingField::CopySelection, + "Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C".to_string(), + ); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + assert_eq!( + summary.visible_fields( + ShortcutManagerFilter::Device, + ShortcutManagerSort::Category, + None, + |_| true, + ), + vec![KeybindingField::Undo] + ); + assert_eq!( + summary.visible_fields( + ShortcutManagerFilter::Sequences, + ShortcutManagerSort::Category, + None, + |_| true, + ), + vec![KeybindingField::CopySelection] + ); + } + + #[test] + fn unbound_filter_is_empty_parsed_lists_only() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::ToggleFloatingBadge, "".to_string()); + draft.set(KeybindingField::Exit, "Ctrl+Shift".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let visible = summary.visible_fields( + ShortcutManagerFilter::Unbound, + ShortcutManagerSort::Category, + None, + |_| true, + ); + assert!(visible.contains(&KeybindingField::ToggleFloatingBadge)); + assert!(!visible.contains(&KeybindingField::Exit)); + assert!( + summary + .row(KeybindingField::Exit) + .is_some_and(|row| row.parse_error && row.status == ShortcutRowStatus::Invalid) + ); + } + + #[test] + fn sort_orders_by_name_and_changed_status() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::Undo, "F9".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let subset = |sort| { + summary.visible_fields(ShortcutManagerFilter::All, sort, None, |field| { + matches!( + field, + KeybindingField::ClearCanvas | KeybindingField::Undo | KeybindingField::Redo + ) + }) + }; + assert_eq!( + subset(ShortcutManagerSort::Name), + vec![ + KeybindingField::ClearCanvas, + KeybindingField::Redo, + KeybindingField::Undo, + ] + ); + let changed_first = subset(ShortcutManagerSort::Changed); + assert_eq!(changed_first[0], KeybindingField::Undo); + } + + #[test] + fn category_scope_keeps_the_filtered_identity_set() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::Undo, "".to_string()); + draft.set(KeybindingField::ClearCanvas, "".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let visible = summary.visible_fields( + ShortcutManagerFilter::Unbound, + ShortcutManagerSort::Category, + Some(KeybindingsTabId::History), + |_| true, + ); + assert!(visible.contains(&KeybindingField::Undo)); + assert!(!visible.contains(&KeybindingField::ClearCanvas)); + assert!( + visible + .iter() + .all(|field| field.tab() == KeybindingsTabId::History) + ); + } + + #[test] + fn destination_terms_select_the_action_row() { + assert_eq!( + field_matching_search_term("clear canvas"), + Some(KeybindingField::ClearCanvas) + ); + assert_eq!( + field_matching_search_term("Clear Canvas"), + Some(KeybindingField::ClearCanvas) + ); + assert_eq!( + field_matching_search_term("undo"), + Some(KeybindingField::Undo) + ); + assert_eq!(field_matching_search_term("not a shortcut"), None); + } + + #[test] + fn source_badges_survive_explicit_legacy_migration() { + let (mut draft, defaults) = drafts(); + draft.legacy_tablet.stylus_primary = Some(Action::ToggleRadialMenu); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let radial = summary + .row(KeybindingField::ToggleRadialMenu) + .expect("radial row"); + assert!(radial.sources.contains(&ShortcutSourceBadge::LegacyTablet)); + + let binding = Shortcut::parse("StylusPrimary").expect("parses"); + let claimants = other_claimants(&draft, KeybindingField::Undo, &binding); + apply_recorded_replace(&mut draft, KeybindingField::Undo, &binding, &claimants) + .expect("move"); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let radial = summary + .row(KeybindingField::ToggleRadialMenu) + .expect("radial row"); + assert!(!radial.sources.contains(&ShortcutSourceBadge::LegacyTablet)); + let undo = summary.row(KeybindingField::Undo).expect("undo row"); + assert!(undo.has_device); + assert!(undo.sources.contains(&ShortcutSourceBadge::Authored)); + } + + #[test] + fn unavailable_badge_marks_undeliverable_keys() { + let (mut draft, defaults) = drafts(); + draft.set(KeybindingField::Exit, "Escpae".to_string()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let row = summary.row(KeybindingField::Exit).expect("exit row"); + assert!(row.has_unavailable); + assert!(row.sources.contains(&ShortcutSourceBadge::Unavailable)); + assert!(row.changed); + } + + #[test] + fn next_review_conflict_names_the_other_claimant() { + let (mut draft, _defaults) = drafts(); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + let (field, binding, claimants) = next_review_conflict(&draft).expect("conflict"); + assert_eq!(binding.to_string(), "Ctrl+Shift+X"); + assert!(claimants.iter().any(|claim| claim.field == Some(field) + || matches!( + claim.field, + Some(KeybindingField::ClearCanvas | KeybindingField::ToggleToolbar) + ))); + assert!(field == KeybindingField::ClearCanvas || field == KeybindingField::ToggleToolbar); + } +} diff --git a/configurator/src/models/keybindings/mod.rs b/configurator/src/models/keybindings/mod.rs index a0b080b7..77a6c545 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -2,6 +2,7 @@ mod conflicts; mod draft; mod edit; mod field; +mod manager; mod parse; mod recording; @@ -12,9 +13,13 @@ pub use conflicts::{ pub use draft::KeybindingsDraft; pub use edit::{ AppendOutcome, ShortcutTextEditor, append_binding, field_matches_defaults, remove_binding, - reset_field, reset_tooltip, serialize_bindings, + reset_field, reset_fields, reset_tooltip, serialize_bindings, }; pub use field::KeybindingField; +pub use manager::{ + ShortcutManagerFilter, ShortcutManagerSort, ShortcutManagerSummary, field_matching_search_term, + next_review_conflict, +}; pub(crate) use parse::parse_keybindings; #[cfg(test)] pub(crate) use recording::keyval; diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 51f54f4a..738541e3 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -37,7 +37,7 @@ pub use fields::{ pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; pub use keybindings::{ KeybindingField, KeyboardModifiers, PendingShortcutConflict, RecorderDeviceKind, - ShortcutRecorderState, ShortcutTextEditor, + ShortcutManagerFilter, ShortcutManagerSort, ShortcutRecorderState, ShortcutTextEditor, }; pub(crate) use search::SearchQuery; pub(crate) use session::SessionCatalogOperation; From 6ead347f33d76b113994d95c08fee89421f9075e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:33:18 +0200 Subject: [PATCH 6/7] fix: keep authored keymaps and recorder controls after shortcut review bugs Same-action prefix conflicts, swallowed recorder clicks, and mismatched device button mappings could discard a user's keymap or make sequences impossible to finish; resolve those so the recorder, conflict UI, and runtime stay usable. --- .../src/app/pages/keybindings/recorder.rs | 42 +++--- configurator/src/app/pages/keybindings/row.rs | 3 +- .../src/app/pages/keybindings/text_editor.rs | 4 +- .../src/app/pages/keybindings/widgets.rs | 24 ++-- .../src/models/keybindings/conflicts.rs | 116 +++++++++++++-- .../src/models/keybindings/manager.rs | 31 +++- .../src/models/keybindings/recording.rs | 12 ++ src/backend/wayland/handlers/pointer/press.rs | 4 +- src/backend/wayland/handlers/tablet/frame.rs | 8 +- src/config/keybindings/config/map/mod.rs | 5 + src/config/keybindings/shortcut.rs | 51 +++++-- src/config/keybindings/tests.rs | 23 +++ src/config/tests/validate.rs | 134 ++++++++++++++++++ src/config/validate/keybindings.rs | 133 ++++++++++++++--- src/input/state/core/modal.rs | 9 ++ src/input/state/core/utility/actions.rs | 19 +++ src/input/state/interaction/keyboard.rs | 9 +- src/input/state/tests/action_bindings.rs | 20 +++ src/input/state/tests/modal.rs | 21 +++ 19 files changed, 571 insertions(+), 97 deletions(-) diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs index 7c1fc6dc..1a6b193b 100644 --- a/configurator/src/app/pages/keybindings/recorder.rs +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -8,14 +8,14 @@ use crate::models::KeybindingField; #[cfg(not(feature = "tablet-input"))] use crate::models::keybindings::tablet_unavailable_hint; use crate::models::keybindings::{ - KeyboardModifiers, RecorderDeviceKind, ShortcutRecorderState, super_consumed_hint, - waiting_prompt, + KeyboardModifiers, RecordedDevice, RecorderDeviceKind, ShortcutRecorderState, + normalize_button_event, super_consumed_hint, waiting_prompt, }; use super::super::super::state::ConfiguratorApp; use super::widgets::{ - field_canceled, ignore_activating_click, set_accessible_label, set_label, set_sensitive, - set_visible, + field_canceled, set_accessible_label, set_label, set_sensitive, set_visible, + unparent_on_destroy, }; pub(super) struct RecorderPopover { @@ -96,7 +96,7 @@ impl RecorderPopover { .child(&content) .build(); popover.set_parent(parent); - ignore_activating_click(&popover); + unparent_on_destroy(parent, &popover); field_canceled(&popover, field, sender, Message::ShortcutRecordingCanceled); attach_key_controller(&popover, sender); attach_button_controller(&popover, sender); @@ -184,13 +184,6 @@ fn device_hint_text() -> &'static str { } fn attach_button_controller(popover: >k::Popover, sender: &ComponentSender) { - let ignore = std::rc::Rc::new(std::cell::Cell::new(false)); - { - let ignore = ignore.clone(); - popover.connect_show(move |_| { - ignore.set(true); - }); - } let sender = sender.clone(); let controller = gtk::EventControllerLegacy::new(); controller.set_propagation_phase(gtk::PropagationPhase::Capture); @@ -198,12 +191,8 @@ fn attach_button_controller(popover: >k::Popover, sender: &ComponentSender() else { - return gtk::glib::Propagation::Stop; + return gtk::glib::Propagation::Proceed; }; let kind = event .device() @@ -224,12 +213,19 @@ fn attach_button_controller(popover: >k::Popover, sender: &ComponentSender { + sender.input(Message::ShortcutRecorderButton( + button_event.button(), + kind, + recorded, + )); + gtk::glib::Propagation::Stop + } + RecordedDevice::Unsupported { .. } => gtk::glib::Propagation::Proceed, + } }); popover.add_controller(controller); } diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index 675dba38..ef500583 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -20,7 +20,7 @@ use super::recorder::RecorderPopover; use super::text_editor::TextEditorPopover; use super::widgets::{ connect_clicked, icon_button, set_accessible_label, set_label, set_sensitive, set_tooltip, - set_visible, watch_compact, + set_visible, unparent_on_destroy, watch_compact, }; pub(super) type ManagerRefresh = @@ -138,6 +138,7 @@ pub(super) fn binding_row( let editor_popover = gtk::Popover::builder().autohide(true).build(); editor_popover.set_parent(&edit_shortcuts); + unparent_on_destroy(&edit_shortcuts, &editor_popover); { let editor_popover = editor_popover.clone(); let editor = editor.clone(); diff --git a/configurator/src/app/pages/keybindings/text_editor.rs b/configurator/src/app/pages/keybindings/text_editor.rs index 15501b15..f17a4d56 100644 --- a/configurator/src/app/pages/keybindings/text_editor.rs +++ b/configurator/src/app/pages/keybindings/text_editor.rs @@ -8,7 +8,7 @@ use crate::models::KeybindingField; use super::super::super::state::ConfiguratorApp; use super::super::set_text_blocked; -use super::widgets::{field_canceled, ignore_activating_click, set_accessible_label, set_label}; +use super::widgets::{field_canceled, set_accessible_label, set_label, unparent_on_destroy}; pub(super) struct TextEditorPopover { pub popover: gtk::Popover, @@ -81,7 +81,7 @@ impl TextEditorPopover { .child(&content) .build(); popover.set_parent(parent); - ignore_activating_click(&popover); + unparent_on_destroy(parent, &popover); field_canceled(&popover, field, sender, Message::ShortcutTextEditCanceled); Self { diff --git a/configurator/src/app/pages/keybindings/widgets.rs b/configurator/src/app/pages/keybindings/widgets.rs index 743ba0e3..bfb4b2ab 100644 --- a/configurator/src/app/pages/keybindings/widgets.rs +++ b/configurator/src/app/pages/keybindings/widgets.rs @@ -72,24 +72,16 @@ pub(super) fn connect_clicked( }); } -pub(super) fn ignore_activating_click(popover: >k::Popover) { - let ignore = std::rc::Rc::new(std::cell::Cell::new(false)); - { - let ignore = ignore.clone(); - popover.connect_show(move |_| { - ignore.set(true); - }); - } - let click = gtk::GestureClick::new(); - click.set_button(0); - click.set_propagation_phase(gtk::PropagationPhase::Capture); - click.connect_pressed(move |gesture, _, _, _| { - if ignore.get() { - ignore.set(false); - gesture.set_state(gtk::EventSequenceState::Claimed); +/// GtkPopover widgets created with `set_parent` are not ordinary children. +/// If they are still attached when the parent is destroyed, GTK warns on +/// finalize. Unparent in the destroy handler so shutdown stays quiet. +pub(super) fn unparent_on_destroy(parent: &impl IsA, popover: >k::Popover) { + let popover = popover.clone(); + parent.connect_destroy(move |_| { + if popover.parent().is_some() { + popover.unparent(); } }); - popover.add_controller(click); } pub(super) fn field_canceled( diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index 0c6b8c85..b9eb7209 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -5,7 +5,7 @@ use wayscriber::config::{Action, Shortcut, ShortcutTrigger, StylusButton, action use super::draft::KeybindingsDraft; use super::edit::{FieldEditError, append_binding, remove_binding}; use super::field::KeybindingField; -use super::parse::parse_keybindings; +use super::parse::{authored_shortcut_parts, parse_keybindings}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BindingSource { @@ -22,7 +22,7 @@ pub struct ShortcutClaim { } impl ShortcutClaim { - fn from_field(field: KeybindingField, held: Shortcut) -> Self { + pub(crate) fn from_field(field: KeybindingField, held: Shortcut) -> Self { Self { field: Some(field), label: field.label().to_string(), @@ -206,9 +206,7 @@ pub fn apply_recorded_replace( let snapshot = draft.clone(); for claim in claimants { if claim.field == Some(target) { - if claim.held != *binding - && let Err(error) = remove_binding(draft, target, &claim.held) - { + if let Err(error) = remove_binding(draft, target, &claim.held) { *draft = snapshot; return Err(error); } @@ -241,7 +239,12 @@ pub fn apply_text_replace( new_value: &str, conflicts: &[TextShortcutConflict], ) -> Result<(), FieldEditError> { + parse_keybindings(new_value).map_err(FieldEditError::InvalidText)?; let snapshot = draft.clone(); + let mut parts: Vec = authored_shortcut_parts(new_value) + .into_iter() + .map(str::to_string) + .collect(); for conflict in conflicts { for claim in &conflict.claimants { if claim.field == Some(target) { @@ -260,12 +263,35 @@ pub fn apply_text_replace( } } } - let parsed = parse_keybindings(new_value).map_err(FieldEditError::InvalidText)?; - let _ = parsed; - draft.set(target, new_value.trim().to_string()); + remove_later_internal_conflicts(&mut parts); + draft.set(target, parts.join(", ")); Ok(()) } +/// Drop the later side of each same-field prefix or exact pair, then look +/// again. Prefix conflicts are not transitive: two branches that share a +/// first step do not conflict with each other, so they must not be grouped +/// with the prefix as one duplicate set. +fn remove_later_internal_conflicts(parts: &mut Vec) { + while let Some(drop_at) = later_internal_conflict_index(parts) { + parts.remove(drop_at); + } +} + +fn later_internal_conflict_index(parts: &[String]) -> Option { + for (index, left) in parts.iter().enumerate() { + let Ok(left) = Shortcut::parse(left) else { + continue; + }; + for (other, part) in parts.iter().enumerate().skip(index + 1) { + if Shortcut::parse(part).is_ok_and(|right| shortcuts_conflict(&left, &right)) { + return Some(other); + } + } + } + None +} + pub fn recorded_conflict_prompt(binding: &Shortcut, claimants: &[ShortcutClaim]) -> String { let mut lines = vec![format!("{binding} is already assigned to:")]; for claim in claimants { @@ -544,4 +570,78 @@ mod tests { .is_some_and(|value| value.contains(&prefix.to_string())) ); } + + #[test] + fn text_replace_removes_a_same_field_prefix() { + let mut draft = draft(); + let prefix = "Ctrl+Alt+Shift+K"; + let sequence = "Ctrl+Alt+Shift+K > Ctrl+Alt+Shift+C"; + let new_value = format!("{prefix}, {sequence}"); + draft.set(KeybindingField::ClearCanvas, new_value.clone()); + let parsed = parse_keybindings(&new_value).expect("parses"); + let conflicts = text_conflicts_for(&draft, KeybindingField::ClearCanvas, &parsed); + assert!( + !conflicts.is_empty(), + "same-field prefix must be a text conflict" + ); + apply_text_replace( + &mut draft, + KeybindingField::ClearCanvas, + &new_value, + &conflicts, + ) + .expect("replace"); + assert_eq!(draft.value_for(KeybindingField::ClearCanvas), Some(prefix)); + } + + #[test] + fn text_replace_keeps_branching_sequences_when_dropping_a_prefix() { + let mut draft = draft(); + let copy = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"; + let prefix = "Ctrl+Shift+Alt+K"; + let cut = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+X"; + let new_value = format!("{copy}, {prefix}, {cut}"); + draft.set(KeybindingField::ClearCanvas, new_value.clone()); + let parsed = parse_keybindings(&new_value).expect("parses"); + let conflicts = text_conflicts_for(&draft, KeybindingField::ClearCanvas, &parsed); + assert!( + !conflicts.is_empty(), + "a prefix beside branching sequences must be a text conflict" + ); + apply_text_replace( + &mut draft, + KeybindingField::ClearCanvas, + &new_value, + &conflicts, + ) + .expect("replace"); + let expected = format!("{copy}, {cut}"); + assert_eq!( + draft.value_for(KeybindingField::ClearCanvas), + Some(expected.as_str()) + ); + } + + #[test] + fn recorded_replace_keeps_one_same_field_duplicate() { + let mut draft = draft(); + draft.set(KeybindingField::ClearCanvas, "E, e".to_string()); + let binding = Shortcut::parse("E").expect("parses"); + let claimants = vec![ShortcutClaim::from_field( + KeybindingField::ClearCanvas, + binding.clone(), + )]; + apply_recorded_replace( + &mut draft, + KeybindingField::ClearCanvas, + &binding, + &claimants, + ) + .expect("replace"); + let value = draft + .value_for(KeybindingField::ClearCanvas) + .expect("value"); + let parsed = parse_keybindings(value).expect("parses"); + assert_eq!(parsed, vec![binding]); + } } diff --git a/configurator/src/models/keybindings/manager.rs b/configurator/src/models/keybindings/manager.rs index 04651b9e..087723e0 100644 --- a/configurator/src/models/keybindings/manager.rs +++ b/configurator/src/models/keybindings/manager.rs @@ -6,7 +6,9 @@ use wayscriber::config::{Action, Shortcut, ShortcutTrigger}; -use super::conflicts::{claimants_for, field_has_internal_duplicate, other_claimants}; +use super::conflicts::{ + ShortcutClaim, claimants_for, field_has_internal_duplicate, other_claimants, +}; use super::draft::KeybindingsDraft; use super::edit::field_matches_defaults; use super::field::KeybindingField; @@ -225,11 +227,16 @@ pub fn next_review_conflict( let Ok(parsed) = parse_keybindings(value) else { continue; }; - for binding in parsed { - let claimants = other_claimants(draft, field, &binding); - if !claimants.is_empty() { - return Some((field, binding, claimants)); + for binding in &parsed { + let mut claimants = other_claimants(draft, field, binding); + if claimants.is_empty() { + let extras = parsed.iter().filter(|other| *other == binding).count(); + if extras <= 1 { + continue; + } + claimants.push(ShortcutClaim::from_field(field, binding.clone())); } + return Some((field, binding.clone(), claimants)); } } None @@ -632,4 +639,18 @@ mod tests { ))); assert!(field == KeybindingField::ClearCanvas || field == KeybindingField::ToggleToolbar); } + + #[test] + fn next_review_conflict_includes_same_field_duplicates() { + let (mut draft, _defaults) = drafts(); + draft.set(KeybindingField::ClearCanvas, "E, e".to_string()); + let (field, binding, claimants) = next_review_conflict(&draft).expect("duplicate"); + assert_eq!(field, KeybindingField::ClearCanvas); + assert_eq!(binding.to_string(), "E"); + assert!( + claimants + .iter() + .any(|claim| claim.field == Some(KeybindingField::ClearCanvas)) + ); + } } diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs index ecbbaa36..3808782e 100644 --- a/configurator/src/models/keybindings/recording.rs +++ b/configurator/src/models/keybindings/recording.rs @@ -666,6 +666,18 @@ mod tests { } other => panic!("expected primary-button rejection, got {other:?}"), } + match normalize_button_event(10, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { + RecordedDevice::Trigger(trigger) => assert_eq!(trigger.to_string(), "MouseForward"), + other => panic!("expected Wayland BTN_FORWARD as MouseForward, got {other:?}"), + } + match normalize_button_event(11, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { + RecordedDevice::Trigger(trigger) => assert_eq!(trigger.to_string(), "MouseBack"), + other => panic!("expected Wayland BTN_BACK as MouseBack, got {other:?}"), + } + match normalize_button_event(12, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { + RecordedDevice::Trigger(trigger) => assert_eq!(trigger.to_string(), "MouseExtra1"), + other => panic!("expected Wayland BTN_TASK as MouseExtra1, got {other:?}"), + } } #[test] diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 09812547..79639620 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -131,7 +131,9 @@ impl WaylandState { return; } - if self.try_dispatch_pointer_shortcut(button) { + if !self.input_state.modal_owns_pointer_shortcuts() + && self.try_dispatch_pointer_shortcut(button) + { return; } diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 4aec110a..e4ee3ac1 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -6,13 +6,7 @@ use crate::input::MouseButton; use crate::input::state::HelpOverlayPressSource; fn modal_blocks_stylus_barrel_actions(input_state: &crate::input::InputState) -> bool { - input_state.show_help - || input_state.tour_active - // A screen-region modal owns pointer and keyboard input while it is up. - // A barrel button is bound to an ordinary action — undo, clear canvas, - // the radial menu — so letting one through would run it on the canvas - // behind the selector. - || input_state.screen_modal_is_engaged() + input_state.modal_owns_pointer_shortcuts() } fn stylus_barrel_action( diff --git a/src/config/keybindings/config/map/mod.rs b/src/config/keybindings/config/map/mod.rs index 56fe97e9..f294c283 100644 --- a/src/config/keybindings/config/map/mod.rs +++ b/src/config/keybindings/config/map/mod.rs @@ -139,7 +139,12 @@ impl<'a> BindingInserter<'a> { }; if let Some((other, existing_action)) = self.prefix_owner(&binding) { if let Some(conflicts) = self.conflicts.as_mut() { + // Record both identities so a same-action prefix pair is not a + // one-sided conflict that `keep_first` would leave untouched. conflicts.record(&binding, existing_action, action); + if other != binding { + conflicts.record(&other, existing_action, action); + } return Ok(()); } if self.tolerant { diff --git a/src/config/keybindings/shortcut.rs b/src/config/keybindings/shortcut.rs index 0c3f1f0b..954cf87f 100644 --- a/src/config/keybindings/shortcut.rs +++ b/src/config/keybindings/shortcut.rs @@ -229,7 +229,11 @@ impl ShortcutTrigger { pub fn is_deliverable(&self) -> bool { match self { Self::Keyboard(binding) => super::binding::is_deliverable_key_name(&binding.key), - Self::Pointer(_) | Self::Stylus(_) => true, + Self::Pointer(_) => true, + #[cfg(feature = "tablet-input")] + Self::Stylus(_) => true, + #[cfg(not(feature = "tablet-input"))] + Self::Stylus(_) => false, } } @@ -243,6 +247,7 @@ impl ShortcutTrigger { pub fn unknown_key_name(&self) -> Option { match self { Self::Keyboard(binding) => Some(binding.key.clone()), + Self::Pointer(_) | Self::Stylus(_) if !self.is_deliverable() => Some(self.to_string()), Self::Pointer(_) | Self::Stylus(_) => None, } } @@ -557,16 +562,20 @@ pub mod linux { } } -/// GTK/GDK 1-based button numbers (X11-style: 8 back, 9 forward, 10+ extras). +/// GTK/GDK button numbers. +/// +/// X11-style buttons 8/9 are Back/Forward. GTK's Wayland backend then maps +/// evdev `BTN_FORWARD`/`BTN_BACK` to 10/11 and `BTN_TASK` through `BTN_EXTRA4` +/// to 12 through 15, matching [`linux::pointer_button`]. pub mod gdk { use super::{MAX_POINTER_EXTRA, PointerButton, StylusButton}; pub fn pointer_button(button: u32) -> Option { match button { - 8 => Some(PointerButton::Back), - 9 => Some(PointerButton::Forward), - extra if (10..10 + u32::from(MAX_POINTER_EXTRA)).contains(&extra) => { - Some(PointerButton::Extra((extra - 9) as u8)) + 8 | 11 => Some(PointerButton::Back), + 9 | 10 => Some(PointerButton::Forward), + extra if (12..12 + u32::from(MAX_POINTER_EXTRA)).contains(&extra) => { + Some(PointerButton::Extra((extra - 11) as u8)) } _ => None, } @@ -604,10 +613,32 @@ mod tests { ] { let trigger = ShortcutTrigger::parse(text).unwrap(); assert_eq!(trigger.to_string(), text); - assert!(trigger.is_deliverable()); + match &trigger { + ShortcutTrigger::Stylus(_) => { + #[cfg(feature = "tablet-input")] + assert!(trigger.is_deliverable()); + #[cfg(not(feature = "tablet-input"))] + assert!(!trigger.is_deliverable()); + } + _ => assert!(trigger.is_deliverable()), + } } } + #[cfg(not(feature = "tablet-input"))] + #[test] + fn stylus_triggers_are_undeliverable_without_tablet_input() { + let trigger = ShortcutTrigger::parse("StylusPrimary").unwrap(); + assert!(!trigger.is_deliverable()); + assert_eq!(trigger.unknown_key_name().as_deref(), Some("StylusPrimary")); + assert!( + ShortcutTrigger::parse("MouseBack") + .unwrap() + .is_deliverable() + ); + assert!(!Shortcut::parse("StylusSecondary").unwrap().is_deliverable()); + } + #[test] fn primary_mouse_buttons_are_rejected() { for name in ["MouseLeft", "MouseMiddle", "MouseRight"] { @@ -640,7 +671,11 @@ mod tests { ); assert_eq!(gdk::pointer_button(8), Some(PointerButton::Back)); assert_eq!(gdk::pointer_button(9), Some(PointerButton::Forward)); - assert_eq!(gdk::pointer_button(10), Some(PointerButton::Extra(1))); + assert_eq!(gdk::pointer_button(10), Some(PointerButton::Forward)); + assert_eq!(gdk::pointer_button(11), Some(PointerButton::Back)); + assert_eq!(gdk::pointer_button(12), Some(PointerButton::Extra(1))); + assert_eq!(gdk::pointer_button(15), Some(PointerButton::Extra(4))); + assert_eq!(gdk::pointer_button(16), None); assert_eq!(gdk::pointer_button(1), None); assert_eq!( linux::stylus_button(linux::BTN_STYLUS), diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 95fbf0a7..4a3b1185 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -337,6 +337,29 @@ fn collect_binding_conflicts_reports_a_sequence_prefix() { ); } +#[test] +fn collect_binding_conflicts_reports_both_sides_of_a_same_action_prefix() { + let prefix = Shortcut::parse("Ctrl+Shift+Alt+K").unwrap(); + let sequence = Shortcut::parse("Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C").unwrap(); + let mut config = KeybindingsConfig::default(); + config.core.undo = vec![prefix.to_string(), sequence.to_string()]; + let conflicts = config + .collect_binding_conflicts() + .expect("self-prefix is data"); + assert!( + conflicts + .iter() + .any(|conflict| conflict.binding() == &prefix && conflict.actions() == [Action::Undo]), + "{conflicts:?}" + ); + assert!( + conflicts + .iter() + .any(|conflict| conflict.binding() == &sequence && conflict.actions() == [Action::Undo]), + "{conflicts:?}" + ); +} + #[test] fn test_parse_plus_key_without_modifiers() { let binding = KeyBinding::parse("+").unwrap(); diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 483eb8e6..80565e83 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -1126,6 +1126,140 @@ fn keybinding_listed_twice_for_one_action_is_deduplicated() { assert!(report.keybinding_conflicts[0].is_self_duplicate()); } +#[test] +fn same_action_sequence_prefix_is_resolved_so_the_keymap_still_builds() { + let prefix = "Ctrl+Shift+Alt+K"; + let sequence = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"; + let mut config = Config::default(); + config.keybindings.core.undo = vec![prefix.to_string(), sequence.to_string()]; + + let report = config.validate_and_clamp(); + + assert_eq!(config.keybindings.core.undo, [prefix]); + config + .keybindings + .build_action_map() + .expect("self-prefix must not discard the rest of the keymap"); + assert_eq!(report.keybinding_conflicts.len(), 1); + let resolution = &report.keybinding_conflicts[0]; + assert!(!resolution.is_self_duplicate()); + assert_eq!(resolution.kept_key(), prefix); + assert_eq!(resolution.dropped_key(), sequence); + + let mut config = Config::default(); + config.keybindings.core.undo = vec![sequence.to_string(), prefix.to_string()]; + config.validate_and_clamp(); + assert_eq!(config.keybindings.core.undo, [sequence]); + config + .keybindings + .build_action_map() + .expect("keeping the earlier sequence must still build"); +} + +#[test] +fn same_action_prefix_is_resolved_after_deduplicating_the_prefix() { + let prefix = "Ctrl+Shift+Alt+K"; + let sequence = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"; + let mut config = Config::default(); + config.keybindings.core.undo = + vec![sequence.to_string(), prefix.to_string(), prefix.to_string()]; + + let report = config.validate_and_clamp(); + + assert_eq!(config.keybindings.core.undo, [sequence]); + config + .keybindings + .build_action_map() + .expect("a leftover prefix after dedup must not discard the keymap"); + assert!(!report.keybinding_conflicts.is_empty()); +} + +#[test] +fn same_action_prefix_is_resolved_when_another_action_also_claims_the_prefix() { + let prefix = "Ctrl+Shift+Alt+K"; + let sequence = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"; + + let mut config = Config::default(); + config.keybindings.core.undo = vec![sequence.to_string(), prefix.to_string()]; + config.keybindings.core.redo = vec![prefix.to_string()]; + let report = config.validate_and_clamp(); + assert_eq!(config.keybindings.core.undo, [sequence]); + assert!(config.keybindings.core.redo.is_empty()); + config + .keybindings + .build_action_map() + .expect("cross-action prefix must not leave a same-action pair in the winner"); + assert!( + report + .keybinding_conflicts + .iter() + .any(|resolution| resolution.dropped() == Action::Redo), + "{:?}", + report.keybinding_conflicts + ); + + let mut config = Config::default(); + config.keybindings.core.undo = vec![prefix.to_string(), sequence.to_string()]; + config.keybindings.core.redo = vec![prefix.to_string()]; + config.validate_and_clamp(); + assert_eq!(config.keybindings.core.undo, [prefix]); + assert!(config.keybindings.core.redo.is_empty()); + config + .keybindings + .build_action_map() + .expect("keeping the winner's earlier prefix must still build"); + + let mut config = Config::default(); + config.keybindings.core.undo = vec![sequence.to_string(), prefix.to_string()]; + config.keybindings.core.redo = vec![sequence.to_string()]; + config.validate_and_clamp(); + assert_eq!(config.keybindings.core.undo, [sequence]); + assert!(config.keybindings.core.redo.is_empty()); + config + .keybindings + .build_action_map() + .expect("keeping the winner's earlier sequence must still build"); +} + +#[test] +fn prefix_conflict_report_names_the_shortcuts_each_action_actually_held() { + let prefix = "Ctrl+Shift+Alt+K"; + let sequence = "Ctrl+Shift+Alt+K > Ctrl+Shift+Alt+C"; + let mut config = Config::default(); + config.keybindings.core.undo = vec![prefix.to_string()]; + config.keybindings.core.redo = vec![sequence.to_string()]; + + let report = config.validate_and_clamp(); + + let resolution = report + .keybinding_conflicts + .first() + .expect("prefix conflict must be reported"); + assert_eq!(resolution.key(), sequence); + assert_eq!(resolution.kept_key(), prefix); + assert_eq!(resolution.dropped_key(), sequence); + assert_eq!( + resolution.summary(), + format!("{prefix} kept for Undo; conflicting {sequence} dropped from Redo.") + ); + assert!(!resolution.is_self_duplicate()); + + let mut config = Config::default(); + config.keybindings.core.undo = vec![prefix.to_string(), sequence.to_string()]; + let report = config.validate_and_clamp(); + let resolution = report + .keybinding_conflicts + .first() + .expect("same-action prefix conflict must be reported"); + assert_eq!(resolution.kept_key(), prefix); + assert_eq!(resolution.dropped_key(), sequence); + assert_eq!( + resolution.summary(), + format!("{prefix} kept for Undo; conflicting {sequence} dropped from that action.") + ); + assert!(!resolution.is_self_duplicate()); +} + #[test] fn validating_the_defaults_reports_nothing_to_surface() { assert!(Config::default().validate_and_clamp().is_empty()); diff --git a/src/config/validate/keybindings.rs b/src/config/validate/keybindings.rs index e7cdcc55..51e15709 100644 --- a/src/config/validate/keybindings.rs +++ b/src/config/validate/keybindings.rs @@ -154,7 +154,7 @@ impl fmt::Display for InvalidKeybinding { } } -/// One duplicate shortcut resolved while loading a configuration. +/// One shortcut conflict resolved while loading a configuration. /// /// The resolution applies to the running session only: a save writes just the /// delta its caller asked for, so nothing here is ever written to @@ -162,15 +162,29 @@ impl fmt::Display for InvalidKeybinding { /// this is returned rather than only logged. #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeybindingConflictResolution { - key: String, + kept_key: String, + dropped_key: String, kept: Action, dropped: Action, } impl KeybindingConflictResolution { - /// The conflicting shortcut in its normalized form (`Ctrl+Shift+P`). + /// The shortcut removed by this resolution, in normalized form. + /// + /// For an exact conflict this is also the shortcut the winner kept. Use + /// [`Self::kept_key`] when a sequence conflicts with one of its prefixes. pub fn key(&self) -> &str { - &self.key + &self.dropped_key + } + + /// The shortcut retained by the winning action, in normalized form. + pub fn kept_key(&self) -> &str { + &self.kept_key + } + + /// The shortcut removed from the losing action, in normalized form. + pub fn dropped_key(&self) -> &str { + &self.dropped_key } /// The action that keeps the shortcut for this session. @@ -186,7 +200,7 @@ impl KeybindingConflictResolution { /// Whether one action listed the same shortcut more than once, rather than /// two actions claiming it. pub fn is_self_duplicate(&self) -> bool { - self.kept == self.dropped + self.kept == self.dropped && self.kept_key == self.dropped_key } /// The `[keybindings]` key the shortcut was removed from. @@ -199,13 +213,28 @@ impl KeybindingConflictResolution { if self.is_self_duplicate() { format!( "{} is listed more than once for {}.", - self.key, + self.dropped_key, action_label(self.kept) ) + } else if self.kept_key != self.dropped_key && self.kept == self.dropped { + format!( + "{} kept for {}; conflicting {} dropped from that action.", + self.kept_key, + action_label(self.kept), + self.dropped_key + ) + } else if self.kept_key != self.dropped_key { + format!( + "{} kept for {}; conflicting {} dropped from {}.", + self.kept_key, + action_label(self.kept), + self.dropped_key, + action_label(self.dropped) + ) } else { format!( "{} kept for {}, dropped from {}.", - self.key, + self.dropped_key, action_label(self.kept), action_label(self.dropped) ) @@ -219,14 +248,38 @@ impl fmt::Display for KeybindingConflictResolution { return write!( formatter, "`{}` is listed more than once for {}; the repeats are ignored for this session", - self.key, + self.dropped_key, action_label(self.kept) ); } + if self.kept_key != self.dropped_key && self.kept == self.dropped { + return write!( + formatter, + "`{}` conflicts with earlier `{}` for {}; `{}` keeps the shortcut for this session", + self.dropped_key, + self.kept_key, + action_label(self.kept), + self.kept_key, + ); + } + if self.kept_key != self.dropped_key { + return write!( + formatter, + "`{}` for {} conflicts with `{}` for {}; {} keeps `{}` for this session and {} loses `{}`", + self.dropped_key, + action_label(self.dropped), + self.kept_key, + action_label(self.kept), + action_label(self.kept), + self.kept_key, + action_label(self.dropped), + self.dropped_key, + ); + } write!( formatter, "`{}` is bound to both {} and {}; {} keeps it for this session and {} loses it", - self.key, + self.dropped_key, action_label(self.kept), action_label(self.dropped), action_label(self.kept), @@ -271,6 +324,22 @@ fn drop_binding( true } +/// The earliest binding on `action` that is this shortcut or a prefix partner. +fn first_conflicting_binding( + keybindings: &KeybindingsConfig, + action: Action, + binding: &Shortcut, +) -> Option { + keybindings + .bindings_for_action(action) + .and_then(|bindings| { + bindings.iter().find_map(|candidate| { + let parsed = Shortcut::parse(candidate).ok()?; + (parsed == *binding || parsed.prefix_conflicts_with(binding)).then_some(parsed) + }) + }) +} + /// A compiled-in default shortcut that never took effect. /// /// The action was omitted from `[keybindings]`, so serde handed it this build's @@ -483,16 +552,45 @@ impl Config { continue; }; let key = conflict.binding().to_string(); - let mut repeated = false; + let mut self_resolution = None; for &action in conflict.actions() { if action == kept { - repeated = + let duplicate_removed = drop_binding(&mut self.keybindings, action, conflict.binding(), true); + // Drop a later same-action prefix partner even when another + // action also claims this identity. `len() == 1` skipped + // that cleanup, so Undo could keep both Ctrl+K and + // Ctrl+K > Ctrl+C after Redo lost the prefix, and the + // session keymap still failed to build. + if let Some(first) = + first_conflicting_binding(&self.keybindings, action, conflict.binding()) + && first != *conflict.binding() + && drop_binding(&mut self.keybindings, action, conflict.binding(), false) + { + self_resolution = Some(KeybindingConflictResolution { + kept_key: first.to_string(), + dropped_key: key.clone(), + kept, + dropped: kept, + }); + } else if duplicate_removed { + self_resolution = Some(KeybindingConflictResolution { + kept_key: key.clone(), + dropped_key: key.clone(), + kept, + dropped: kept, + }); + } continue; } if drop_binding(&mut self.keybindings, action, conflict.binding(), false) { + let kept_key = + first_conflicting_binding(&self.keybindings, kept, conflict.binding()) + .unwrap_or_else(|| conflict.binding().clone()) + .to_string(); resolutions.push(KeybindingConflictResolution { - key: key.clone(), + kept_key, + dropped_key: key.clone(), kept, dropped: action, }); @@ -502,12 +600,8 @@ impl Config { // repeat in the winner's own list was still removed, and hearing // only about the cross-action side would leave that edit // unexplained. - if repeated { - resolutions.push(KeybindingConflictResolution { - key, - kept, - dropped: kept, - }); + if let Some(resolution) = self_resolution { + resolutions.push(resolution); } } @@ -571,7 +665,8 @@ impl Config { Some(owner) if owner == *action => { dropped = true; repeats.push(KeybindingConflictResolution { - key: binding.to_string(), + kept_key: binding.to_string(), + dropped_key: binding.to_string(), kept: *action, dropped: *action, }); diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 4458af6f..dbba478b 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -8,6 +8,7 @@ //! the rule instead: opening a surface closes every other open surface unless //! this module says the pair deliberately coexists. +use super::DrawingState; use crate::input::state::InputState; /// Every popup surface that participates in modal mutual exclusion, in the @@ -90,6 +91,14 @@ impl InputState { .find(|surface| self.modal_is_open(*surface)) } + /// True when a popup, in-progress text editor, or pending screen-region + /// modal should receive pointer input before auxiliary-button shortcuts. + pub(crate) fn modal_owns_pointer_shortcuts(&self) -> bool { + self.engaged_modal().is_some() + || matches!(self.state, DrawingState::TextInput { .. }) + || self.screen_modal_is_engaged() + } + /// Closes the surface through its canonical closer, so caches, layouts, /// and pointer hit maps are dropped the same way a direct dismissal /// drops them. diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index c5f1ceba..c3838699 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -101,6 +101,25 @@ impl InputState { .match_chord(&mut self.pending_sequence, &chord, now, is_repeat) } + /// Retry a shifted-punctuation fallback against the pending sequence as it + /// stood before the primary label mutated it. + pub(crate) fn match_keyboard_chord_with_fallback( + &mut self, + key_str: &str, + fallback: &str, + is_repeat: bool, + now: Instant, + ) -> SequenceMatch { + let snapshot = self.pending_sequence.clone(); + match self.match_keyboard_chord(key_str, is_repeat, now) { + SequenceMatch::None => { + self.pending_sequence = snapshot; + self.match_keyboard_chord(fallback, is_repeat, now) + } + other => other, + } + } + pub(crate) fn sequence_timeout(&self, now: Instant) -> Option { self.pending_sequence .as_ref() diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index 65ec9ba3..f561c1f3 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -104,16 +104,11 @@ fn match_action_for_key_binding( }; let now = Instant::now(); - match state.match_keyboard_chord(&key_str, is_repeat, now) { - SequenceMatch::None => {} - other => return Ok(other), - } - if state.modifiers.shift && let Some(fallback) = fallback_unshifted_label(&key_str) { - return Ok(state.match_keyboard_chord(fallback, is_repeat, now)); + return Ok(state.match_keyboard_chord_with_fallback(&key_str, fallback, is_repeat, now)); } - Ok(SequenceMatch::None) + Ok(state.match_keyboard_chord(&key_str, is_repeat, now)) } diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs index 46d7e614..6b50ed61 100644 --- a/src/input/state/tests/action_bindings.rs +++ b/src/input/state/tests/action_bindings.rs @@ -261,3 +261,23 @@ fn on_key_press_completes_a_sequence_and_repeat_does_not() { state.on_key_press(crate::input::Key::Char('c')); assert!(!state.show_floating_badge); } + +#[test] +fn shifted_punctuation_fallback_completes_a_pending_sequence() { + let mut keybindings = crate::config::KeybindingsConfig::default(); + keybindings.ui.toggle_floating_badge = vec!["Ctrl+Alt+Shift+K > Shift+/".to_string()]; + let mut state = create_test_input_state_with_keybindings(keybindings); + assert!(state.show_floating_badge); + + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.modifiers.shift = true; + state.on_key_press(crate::input::Key::Char('k')); + assert!(state.show_floating_badge); + + state.modifiers.ctrl = false; + state.modifiers.alt = false; + state.modifiers.shift = true; + state.on_key_press(crate::input::Key::Char('?')); + assert!(!state.show_floating_badge); +} diff --git a/src/input/state/tests/modal.rs b/src/input/state/tests/modal.rs index 45b9d6b7..602ce6d2 100644 --- a/src/input/state/tests/modal.rs +++ b/src/input/state/tests/modal.rs @@ -162,3 +162,24 @@ fn openers_leave_no_excluded_surface_behind() { } } } + +#[test] +fn popups_text_editor_and_pending_screen_modals_own_pointer_shortcuts() { + let mut state = create_test_input_state(); + assert!(!state.modal_owns_pointer_shortcuts()); + + state.open_radial_menu(100.0, 100.0); + assert!(state.modal_owns_pointer_shortcuts()); + state.close_radial_menu(); + + state.open_color_picker_popup(); + assert!(state.modal_owns_pointer_shortcuts()); + state.close_color_picker_popup(true); + + state.state = crate::input::state::DrawingState::text_input(0, 0, String::new()); + assert!(state.modal_owns_pointer_shortcuts()); + state.state = crate::input::state::DrawingState::Idle; + + state.set_eyedropper_pending_capture(crate::input::state::EyedropperCaptureSource::Frozen); + assert!(state.modal_owns_pointer_shortcuts()); +} From 8e6311b501e3c09d21163568a24bbc2696012206 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:16 +0200 Subject: [PATCH 7/7] fix: harden shortcut recording and source tracking --- configurator/src/app/component.rs | 17 +++++ .../src/app/pages/keybindings/recorder.rs | 47 +++++++++---- configurator/src/app/pages/keybindings/row.rs | 22 ++++-- .../src/app/pages/keybindings/widgets.rs | 6 +- configurator/src/app/style.css | 11 +++ .../src/app/update/shortcuts/tests.rs | 69 +++++++++++++++++++ .../src/models/config/draft/from_config.rs | 9 +++ configurator/src/models/config/tests.rs | 11 +++ configurator/src/models/keybindings/draft.rs | 50 +++++++++++++- configurator/src/models/keybindings/edit.rs | 43 +++++++++++- .../src/models/keybindings/manager.rs | 27 +++++++- .../src/models/keybindings/recording.rs | 39 +++++++++-- .../wayland/handlers/keyboard/translate.rs | 32 ++++++--- src/backend/wayland/handlers/pointer/press.rs | 45 ++++++++++-- src/backend/wayland/state/gtk_toolbar.rs | 38 +++++++++- src/config/core.rs | 5 ++ src/config/keybindings/authorship.rs | 28 +++++++- src/input/state/core/utility/sequence.rs | 47 ++++++++++++- src/toolbar_gtk/bridge.rs | 1 + src/toolbar_gtk/mod.rs | 14 ++++ src/toolbar_gtk/widgets.rs | 40 +++++++++++ src/toolbar_gtk/widgets/tests.rs | 55 +++++++++++++++ 22 files changed, 610 insertions(+), 46 deletions(-) create mode 100644 configurator/src/app/style.css diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index 5fe886f9..fd5f5d60 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -21,6 +21,22 @@ use crate::models::{StartupRequest, TabId}; use super::pages::Binding; use super::state::ConfiguratorApp; +fn load_app_stylesheet() { + let provider = gtk::CssProvider::new(); + // `load_from_string` is 4.12+. The configurator floor is Ubuntu 24.04 + // (gtk4 `v4_10`). Workspace `--all-features` unifies gtk4 with the + // overlay crate's `v4_12` and then deprecates this method. + #[allow(deprecated)] + provider.load_from_data(include_str!("style.css")); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} + /// GApplication id. A valid dotted id is required by GLib; the window still /// advertises this as its Wayland app-id, so compositor rules and the /// `.desktop` `StartupWMClass` must match it. @@ -78,6 +94,7 @@ impl Component for ConfiguratorApp { type Widgets = AppWidgets; fn init_root() -> Self::Root { + load_app_stylesheet(); adw::ApplicationWindow::builder() .default_width(1000) .default_height(680) diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs index 1a6b193b..aae5ad2d 100644 --- a/configurator/src/app/pages/keybindings/recorder.rs +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -1,3 +1,7 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + use relm4::{ComponentSender, gtk}; use gtk::glib::object::IsA; @@ -141,20 +145,39 @@ impl RecorderPopover { fn attach_key_controller(popover: >k::Popover, sender: &ComponentSender) { let sender = sender.clone(); + let pressed = Rc::new(RefCell::new(HashSet::new())); let controller = gtk::EventControllerKey::new(); controller.set_propagation_phase(gtk::PropagationPhase::Capture); - controller.connect_key_pressed(move |_, key, _, modifiers| { - let keyval = gdk_keyval(key); - let recorded = KeyboardModifiers { - ctrl: modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK), - shift: modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK), - alt: modifiers.contains(gtk::gdk::ModifierType::ALT_MASK), - super_held: modifiers.contains(gtk::gdk::ModifierType::SUPER_MASK) - || modifiers.contains(gtk::gdk::ModifierType::META_MASK) - || modifiers.contains(gtk::gdk::ModifierType::HYPER_MASK), - }; - sender.input(Message::ShortcutRecorderKey(keyval, recorded)); - gtk::glib::Propagation::Stop + { + let sender = sender.clone(); + let pressed = pressed.clone(); + controller.connect_key_pressed(move |_, key, keycode, modifiers| { + // GTK repeats key-pressed for auto-repeat. Sequence recording + // would otherwise append Ctrl+K > Ctrl+K > Ctrl+K from a hold. + if !pressed.borrow_mut().insert(keycode) { + return gtk::glib::Propagation::Stop; + } + let keyval = gdk_keyval(key); + let recorded = KeyboardModifiers { + ctrl: modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK), + shift: modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK), + alt: modifiers.contains(gtk::gdk::ModifierType::ALT_MASK), + super_held: modifiers.contains(gtk::gdk::ModifierType::SUPER_MASK) + || modifiers.contains(gtk::gdk::ModifierType::META_MASK) + || modifiers.contains(gtk::gdk::ModifierType::HYPER_MASK), + }; + sender.input(Message::ShortcutRecorderKey(keyval, recorded)); + gtk::glib::Propagation::Stop + }); + } + { + let pressed = pressed.clone(); + controller.connect_key_released(move |_, _, keycode, _| { + pressed.borrow_mut().remove(&keycode); + }); + } + popover.connect_hide(move |_| { + pressed.borrow_mut().clear(); }); popover.add_controller(controller); } diff --git a/configurator/src/app/pages/keybindings/row.rs b/configurator/src/app/pages/keybindings/row.rs index ef500583..c257b5cb 100644 --- a/configurator/src/app/pages/keybindings/row.rs +++ b/configurator/src/app/pages/keybindings/row.rs @@ -327,11 +327,25 @@ fn shortcut_chip( sender: &ComponentSender, ) -> gtk::Box { let label_text = binding.display_label(); - let chip = gtk::Box::new(gtk::Orientation::Horizontal, 4); - chip.add_css_class("osd"); + // Application CSS (`.shortcut-chip-key`) draws the raised key. GTK's + // `.keycap` class only applies under ShortcutLabel, so `+ x` would + // otherwise stay plain text beside the clear button. + let chip = gtk::Box::new(gtk::Orientation::Horizontal, 6); chip.set_valign(gtk::Align::Center); - let label = gtk::Label::builder().label(&label_text).build(); - let remove = icon_button("window-close-symbolic", &format!("Remove {label_text}")); + let label = gtk::Label::builder() + .label(&label_text) + .css_classes(["shortcut-chip-key"]) + .valign(gtk::Align::Center) + .build(); + let remove_label = format!("Remove {label_text}"); + let remove = gtk::Button::builder() + .icon_name("edit-clear-symbolic") + .tooltip_text(&remove_label) + .valign(gtk::Align::Center) + .has_frame(false) + .css_classes(["flat", "circular", "dim-label"]) + .build(); + set_accessible_label(&remove, &remove_label); let binding = binding.clone(); let sender = sender.clone(); remove.connect_clicked(move |_| { diff --git a/configurator/src/app/pages/keybindings/widgets.rs b/configurator/src/app/pages/keybindings/widgets.rs index bfb4b2ab..4d44bcf9 100644 --- a/configurator/src/app/pages/keybindings/widgets.rs +++ b/configurator/src/app/pages/keybindings/widgets.rs @@ -24,8 +24,12 @@ pub(super) fn set_accessible_label(widget: &impl gtk::prelude::AccessibleExt, la widget.update_property(&[gtk::accessible::Property::Label(label)]); } +/// Writes the widget's own visibility flag, never `is_visible`: a widget +/// inside a hidden parent reports invisible while its own flag still says +/// otherwise, and skipping the write there would leak the stale state the +/// moment the parent comes back. pub(super) fn set_visible(widget: &impl IsA, visible: bool) { - if widget.is_visible() != visible { + if widget.get_visible() != visible { widget.set_visible(visible); } } diff --git a/configurator/src/app/style.css b/configurator/src/app/style.css new file mode 100644 index 00000000..d9986a15 --- /dev/null +++ b/configurator/src/app/style.css @@ -0,0 +1,11 @@ +/* GTK only styles `.keycap` under ShortcutLabel. Shortcut chips are ordinary + labels, so they need application CSS to read as keys rather than `+ x`. */ +.shortcut-chip-key { + min-width: 1.25em; + padding: 2px 8px 4px 8px; + border: 1px solid alpha(currentColor, 0.28); + border-radius: 6px; + background-color: alpha(currentColor, 0.08); + box-shadow: inset 0 -2px alpha(currentColor, 0.2); + font-weight: 600; +} diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index a294f0d0..cab17a62 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -267,6 +267,75 @@ fn keybinding_changed_still_updates_the_authored_string() { ); } +#[test] +fn resetting_an_edited_omitted_binding_restores_clean_default_source() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _dir = load_config( + &mut app, + &format!("config_revision = {CURRENT_CONFIG_REVISION}\n"), + ); + assert!(!app.draft.keybindings.is_authored(KeybindingField::Undo)); + + let _ = app.handle_keybinding_changed(KeybindingField::Undo, "F8".to_string()); + assert!(app.is_dirty); + assert!(app.draft.keybindings.is_authored(KeybindingField::Undo)); + + let _ = app.handle_shortcut_reset_requested(KeybindingField::Undo); + assert!(!app.is_dirty, "returning to the sparse baseline is clean"); + assert!(!app.draft.keybindings.is_authored(KeybindingField::Undo)); + assert_eq!( + app.shortcut_manager_summary() + .row(KeybindingField::Undo) + .expect("undo summary") + .badge_titles(), + vec!["Default"] + ); +} + +#[test] +fn resetting_an_explicit_binding_stays_authored_through_save_reload() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (_dir, path) = load_config( + &mut app, + &format!("config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"F8\"]\n"), + ); + assert!(app.draft.keybindings.is_authored(KeybindingField::Undo)); + + let _ = app.handle_shortcut_reset_requested(KeybindingField::Undo); + assert!(app.is_dirty); + assert!(app.draft.keybindings.is_authored(KeybindingField::Undo)); + save_draft(&mut app); + + let contents = std::fs::read_to_string(path).expect("read saved config"); + assert_eq!( + config_setting(&contents, "undo").as_deref(), + Some("undo = [\"Ctrl+Z\"]") + ); + assert!(app.draft.keybindings.is_authored(KeybindingField::Undo)); + assert_eq!( + app.shortcut_manager_summary() + .row(KeybindingField::Undo) + .expect("undo summary") + .badge_titles(), + vec!["Authored"] + ); +} + +#[test] +fn typing_an_edited_omitted_binding_back_to_baseline_restores_clean_source() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _dir = load_config( + &mut app, + &format!("config_revision = {CURRENT_CONFIG_REVISION}\n"), + ); + + let _ = app.handle_keybinding_changed(KeybindingField::Undo, "F8".to_string()); + let _ = app.handle_keybinding_changed(KeybindingField::Undo, "Ctrl+Z".to_string()); + + assert!(!app.is_dirty, "an exact revert matches the sparse baseline"); + assert!(!app.draft.keybindings.is_authored(KeybindingField::Undo)); +} + #[test] fn active_confirmation_canceled_clears_defaults_without_touching_conflicts() { let (mut app, _effects) = ConfiguratorApp::new_app(); diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index b41226ce..c7e7b4a3 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -357,6 +357,15 @@ impl ConfigDraft { keybindings: { let mut keybindings = KeybindingsDraft::from_config(&config.keybindings); + keybindings.set_authorship(match config.keybinding_authorship() { + // Built-in and in-memory configs are AllExplicit for + // validation. The UI treats them as omitted defaults so + // Reset All / Defaults does not badge every row Authored. + wayscriber::config::KeybindingAuthorship::AllExplicit => { + wayscriber::config::KeybindingAuthorship::FromFile(Default::default()) + } + from_file => from_file.clone(), + }); keybindings.set_legacy_from_config(config); keybindings }, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index aed3b089..c6d1930b 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1375,3 +1375,14 @@ fn config_draft_preserves_tray_icon_style() { wayscriber::config::TrayIconStyle::Colored ); } + +#[test] +fn compiled_defaults_are_not_shown_as_authored_keybindings() { + let draft = ConfigDraft::from_config(&Config::default()); + assert!( + !draft + .keybindings + .is_authored(crate::models::KeybindingField::Undo), + "Config::default() is AllExplicit for validation, not for UI source badges" + ); +} diff --git a/configurator/src/models/keybindings/draft.rs b/configurator/src/models/keybindings/draft.rs index d9ffae64..df940868 100644 --- a/configurator/src/models/keybindings/draft.rs +++ b/configurator/src/models/keybindings/draft.rs @@ -1,5 +1,7 @@ +use std::collections::HashSet; + use wayscriber::config::keybindings::KeybindingsConfig; -use wayscriber::config::{Action, Config}; +use wayscriber::config::{Action, Config, KeybindingAuthorship}; use super::super::error::FormError; use super::field::KeybindingField; @@ -15,6 +17,9 @@ pub struct LegacyTabletShortcuts { pub struct KeybindingsDraft { pub entries: Vec, pub legacy_tablet: LegacyTabletShortcuts, + authorship: KeybindingAuthorship, + baseline_entries: Vec, + baseline_authorship: KeybindingAuthorship, } #[derive(Debug, Clone, PartialEq)] @@ -25,7 +30,7 @@ pub struct KeybindingEntry { impl KeybindingsDraft { pub fn from_config(config: &KeybindingsConfig) -> Self { - let entries = KeybindingField::all() + let entries: Vec = KeybindingField::all() .into_iter() .map(|field| KeybindingEntry { value: field.get(config).join(", "), @@ -33,11 +38,19 @@ impl KeybindingsDraft { }) .collect(); Self { + baseline_entries: entries.clone(), entries, legacy_tablet: LegacyTabletShortcuts::default(), + authorship: KeybindingAuthorship::FromFile(HashSet::new()), + baseline_authorship: KeybindingAuthorship::FromFile(HashSet::new()), } } + pub fn set_authorship(&mut self, authorship: KeybindingAuthorship) { + self.baseline_authorship = authorship.clone(); + self.authorship = authorship; + } + pub fn set_legacy_from_config(&mut self, config: &Config) { #[cfg(feature = "tablet-input")] { @@ -55,9 +68,42 @@ impl KeybindingsDraft { pub fn set(&mut self, field: KeybindingField, value: String) { if let Some(entry) = self.entries.iter_mut().find(|entry| entry.field == field) { entry.value = value; + self.sync_authorship_with_baseline(field); + } + } + + pub fn restore_default(&mut self, field: KeybindingField, value: String) { + if let Some(entry) = self.entries.iter_mut().find(|entry| entry.field == field) { + entry.value = value; + self.sync_authorship_with_baseline(field); } } + fn sync_authorship_with_baseline(&mut self, field: KeybindingField) { + let key = field.field_key(); + let value_matches_baseline = self + .baseline_entries + .iter() + .find(|entry| entry.field == field) + .zip(self.entries.iter().find(|entry| entry.field == field)) + .is_some_and(|(baseline, current)| baseline.value == current.value); + if !value_matches_baseline || self.baseline_authorship.is_explicit(key) { + // A changed list is authored by the draft. Returning an existing + // TOML array to its loaded value/default also stays authored. + self.authorship.mark_explicit(key); + } else { + // Returning an omitted field to its loaded value produces no + // document delta. Restore source state as well so the draft + // becomes clean and does not claim authorship the merge cannot + // persist. + self.authorship.clear_explicit(key); + } + } + + pub fn is_authored(&self, field: KeybindingField) -> bool { + self.authorship.is_explicit(field.field_key()) + } + pub fn to_config(&self) -> Result> { let mut config = KeybindingsConfig::default(); let mut errors = Vec::new(); diff --git a/configurator/src/models/keybindings/edit.rs b/configurator/src/models/keybindings/edit.rs index 45a6681c..8c8b3110 100644 --- a/configurator/src/models/keybindings/edit.rs +++ b/configurator/src/models/keybindings/edit.rs @@ -94,7 +94,7 @@ pub fn reset_field( field: KeybindingField, ) { let value = defaults.value_for(field).unwrap_or_default().to_string(); - draft.set(field, value); + draft.restore_default(field, value); } pub fn reset_fields( @@ -156,6 +156,7 @@ pub fn serialize_bindings(bindings: &[Shortcut]) -> String { #[cfg(test)] mod tests { + use wayscriber::config::KeybindingAuthorship; use wayscriber::config::keybindings::KeybindingsConfig; use super::*; @@ -201,6 +202,9 @@ mod tests { #[test] fn reset_handles_one_default_multiple_defaults_and_unbound() { let (mut draft, defaults) = draft(); + draft.set_authorship(KeybindingAuthorship::from_toml_source( + "[keybindings]\nclear_canvas = []\nredo = []\ntoggle_floating_badge = []\n", + )); draft.set(KeybindingField::ClearCanvas, "X".to_string()); draft.set(KeybindingField::Redo, "".to_string()); draft.set(KeybindingField::ToggleFloatingBadge, "F8".to_string()); @@ -209,6 +213,10 @@ mod tests { reset_field(&mut draft, &defaults, KeybindingField::Redo); reset_field(&mut draft, &defaults, KeybindingField::ToggleFloatingBadge); + assert!( + draft.is_authored(KeybindingField::ClearCanvas), + "reset keeps authorship because save rewrites the key instead of removing it" + ); assert_eq!(draft.value_for(KeybindingField::ClearCanvas), Some("E")); assert_eq!( draft.value_for(KeybindingField::Redo), @@ -232,6 +240,39 @@ mod tests { ); } + #[test] + fn reset_does_not_author_an_omitted_field() { + let (mut draft, defaults) = draft(); + assert!(!draft.is_authored(KeybindingField::Undo)); + reset_field(&mut draft, &defaults, KeybindingField::Undo); + assert!(!draft.is_authored(KeybindingField::Undo)); + } + + #[test] + fn reset_restores_omitted_authorship_after_an_edit() { + let (mut draft, defaults) = draft(); + let baseline = draft.clone(); + + draft.set(KeybindingField::Undo, "F9".to_string()); + assert!(draft.is_authored(KeybindingField::Undo)); + reset_field(&mut draft, &defaults, KeybindingField::Undo); + + assert!(!draft.is_authored(KeybindingField::Undo)); + assert_eq!(draft, baseline, "the reverted draft must become clean"); + } + + #[test] + fn typing_the_loaded_value_restores_omitted_authorship_after_an_edit() { + let (mut draft, _defaults) = draft(); + let baseline = draft.clone(); + + draft.set(KeybindingField::Undo, "F9".to_string()); + draft.set(KeybindingField::Undo, "Ctrl+Z".to_string()); + + assert!(!draft.is_authored(KeybindingField::Undo)); + assert_eq!(draft, baseline, "an exact text revert must become clean"); + } + #[test] fn invalid_raw_text_blocks_parsed_edits_and_stays_visible() { let (mut draft, _defaults) = draft(); diff --git a/configurator/src/models/keybindings/manager.rs b/configurator/src/models/keybindings/manager.rs index 087723e0..d8667361 100644 --- a/configurator/src/models/keybindings/manager.rs +++ b/configurator/src/models/keybindings/manager.rs @@ -270,7 +270,7 @@ fn row_for( ShortcutRowStatus::Default }; let mut sources = Vec::new(); - if changed { + if draft.is_authored(field) { sources.push(ShortcutSourceBadge::Authored); } else { sources.push(ShortcutSourceBadge::Default); @@ -452,7 +452,8 @@ mod tests { let row = summary.row(KeybindingField::Redo).expect("redo row"); assert!(!row.changed); assert_eq!(row.status, ShortcutRowStatus::Default); - assert!(row.sources.contains(&ShortcutSourceBadge::Default)); + assert!(row.sources.contains(&ShortcutSourceBadge::Authored)); + assert!(!row.sources.contains(&ShortcutSourceBadge::Default)); draft.set(KeybindingField::Redo, "F4".to_string()); let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); @@ -461,6 +462,28 @@ mod tests { assert!(row.sources.contains(&ShortcutSourceBadge::Authored)); } + #[test] + fn explicit_default_list_is_authored_not_default() { + use wayscriber::config::KeybindingAuthorship; + + let mut draft = KeybindingsDraft::from_config(&KeybindingsConfig::default()); + draft.set_authorship(KeybindingAuthorship::from_toml_source( + "[keybindings]\nundo = [\"Ctrl+Z\"]\n", + )); + let defaults = KeybindingsDraft::from_config(&KeybindingsConfig::default()); + let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); + let undo = summary.row(KeybindingField::Undo).expect("undo row"); + assert!(!undo.changed); + assert_eq!(undo.status, ShortcutRowStatus::Default); + assert!(undo.sources.contains(&ShortcutSourceBadge::Authored)); + assert!(!undo.sources.contains(&ShortcutSourceBadge::Default)); + + let redo = summary.row(KeybindingField::Redo).expect("redo row"); + assert!(!redo.changed); + assert!(redo.sources.contains(&ShortcutSourceBadge::Default)); + assert!(!redo.sources.contains(&ShortcutSourceBadge::Authored)); + } + #[test] fn conflict_filter_includes_every_claimant() { let (mut draft, defaults) = drafts(); diff --git a/configurator/src/models/keybindings/recording.rs b/configurator/src/models/keybindings/recording.rs index 3808782e..a58eed75 100644 --- a/configurator/src/models/keybindings/recording.rs +++ b/configurator/src/models/keybindings/recording.rs @@ -395,13 +395,12 @@ fn named_key(keyval: u32) -> Option<&'static str> { } fn keyval_to_unicode(keyval: u32) -> Option { - if (0x20..0x7f).contains(&keyval) { - return char::from_u32(keyval); - } - if (0x0100_0000..0x0111_0000).contains(&keyval) { - return char::from_u32(keyval - 0x0100_0000); - } - None + use gtk4::glib::translate::FromGlib; + // SAFETY: `Key` is a GDK keyval newtype. `from_glib` does not validate, + // and `to_unicode` is defined for any u32 keyval (it returns None when + // GDK has no mapping). + let key = unsafe { gtk4::gdk::Key::from_glib(keyval) }; + key.to_unicode() } /// Inverse of the runtime shifted-symbol fallback: Shift+1 records as `1` @@ -644,6 +643,32 @@ mod tests { ); } + #[test] + fn keypad_home_records_the_same_name_as_home() { + assert_eq!( + chord(keyval::KP_HOME, KeyboardModifiers::default()).to_string(), + "Home" + ); + assert_eq!( + chord(keyval::KP_LEFT, KeyboardModifiers::default()).to_string(), + "ArrowLeft" + ); + assert_eq!( + chord(keyval::KP_DELETE, KeyboardModifiers::default()).to_string(), + "Delete" + ); + } + + #[test] + fn latin1_and_legacy_letter_keysyms_record_as_characters() { + let eacute = chord(0x00e9, KeyboardModifiers::default()); + assert_eq!(eacute.key, "é"); + let greek_alpha = chord(0x07e1, KeyboardModifiers::default()); + assert_eq!(greek_alpha.key, "α"); + let cyrillic_a = chord(0x06c1, KeyboardModifiers::default()); + assert_eq!(cyrillic_a.key, "а"); + } + #[test] fn mouse_back_and_forward_record_semantic_names() { match normalize_button_event(8, RecorderDeviceKind::Mouse, KeyboardModifiers::default()) { diff --git a/src/backend/wayland/handlers/keyboard/translate.rs b/src/backend/wayland/handlers/keyboard/translate.rs index 64c241f7..91765427 100644 --- a/src/backend/wayland/handlers/keyboard/translate.rs +++ b/src/backend/wayland/handlers/keyboard/translate.rs @@ -9,15 +9,15 @@ pub(in crate::backend::wayland) fn keysym_to_key(keysym: Keysym) -> Key { Keysym::BackSpace => Key::Backspace, Keysym::Tab | Keysym::KP_Tab => Key::Tab, Keysym::space => Key::Space, - Keysym::Up => Key::Up, - Keysym::Down => Key::Down, - Keysym::Left => Key::Left, - Keysym::Right => Key::Right, - Keysym::Delete => Key::Delete, - Keysym::Home => Key::Home, - Keysym::End => Key::End, - Keysym::Page_Up => Key::PageUp, - Keysym::Page_Down => Key::PageDown, + Keysym::Up | Keysym::KP_Up => Key::Up, + Keysym::Down | Keysym::KP_Down => Key::Down, + Keysym::Left | Keysym::KP_Left => Key::Left, + Keysym::Right | Keysym::KP_Right => Key::Right, + Keysym::Delete | Keysym::KP_Delete => Key::Delete, + Keysym::Home | Keysym::KP_Home => Key::Home, + Keysym::End | Keysym::KP_End => Key::End, + Keysym::Page_Up | Keysym::KP_Page_Up => Key::PageUp, + Keysym::Page_Down | Keysym::KP_Page_Down => Key::PageDown, Keysym::Shift_L | Keysym::Shift_R => Key::Shift, Keysym::Control_L | Keysym::Control_R => Key::Ctrl, Keysym::Alt_L | Keysym::Alt_R => Key::Alt, @@ -51,4 +51,18 @@ mod tests { assert_eq!(keysym_to_key(Keysym::Hyper_R), Key::Super); assert_eq!(keysym_to_key(Keysym::Alt_L), Key::Alt); } + + #[test] + fn keypad_navigation_keysyms_match_the_non_keypad_names() { + assert_eq!(keysym_to_key(Keysym::KP_Home), Key::Home); + assert_eq!(keysym_to_key(Keysym::Home), Key::Home); + assert_eq!(keysym_to_key(Keysym::KP_Left), Key::Left); + assert_eq!(keysym_to_key(Keysym::KP_Delete), Key::Delete); + assert_eq!(keysym_to_key(Keysym::KP_End), Key::End); + assert_eq!(keysym_to_key(Keysym::KP_Up), Key::Up); + assert_eq!(keysym_to_key(Keysym::KP_Down), Key::Down); + assert_eq!(keysym_to_key(Keysym::KP_Right), Key::Right); + assert_eq!(keysym_to_key(Keysym::KP_Page_Up), Key::PageUp); + assert_eq!(keysym_to_key(Keysym::KP_Page_Down), Key::PageDown); + } } diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 79639620..43d2e7e7 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -299,13 +299,50 @@ impl WaylandState { let Some(pointer) = crate::config::keybindings::linux::pointer_button(button) else { return false; }; - let trigger = self.input_state.pointer_trigger(pointer); - let Some(action) = self.input_state.find_trigger_action(&trigger) else { + if self.dispatch_pointer_shortcut(pointer) { + self.input_state.consume_pointer_shortcut_button(button); + true + } else { + false + } + } + + pub(in crate::backend::wayland) fn try_dispatch_gdk_pointer_shortcut( + &mut self, + button: u32, + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, + ) -> bool { + let Some(pointer) = crate::config::keybindings::gdk::pointer_button(button) else { + return false; + }; + self.dispatch_pointer_trigger(crate::config::PointerTrigger { + button: pointer, + ctrl, + shift, + alt, + logo, + }) + } + + fn dispatch_pointer_shortcut(&mut self, pointer: crate::config::PointerButton) -> bool { + match self.input_state.pointer_trigger(pointer) { + crate::config::ShortcutTrigger::Pointer(trigger) => { + self.dispatch_pointer_trigger(trigger) + } + _ => false, + } + } + + fn dispatch_pointer_trigger(&mut self, trigger: crate::config::PointerTrigger) -> bool { + let shortcut = crate::config::ShortcutTrigger::Pointer(trigger); + let Some(action) = self.input_state.find_trigger_action(&shortcut) else { return false; }; self.input_state.clear_pending_sequence(); - self.input_state.consume_pointer_shortcut_button(button); - debug!("Pointer shortcut {trigger}: dispatching {action:?}"); + debug!("Pointer shortcut {shortcut}: dispatching {action:?}"); self.dispatch_input_action(action); true } diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index 87a3256c..6f14cb38 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -20,6 +20,7 @@ fn acknowledge_blocked_gtk_drag_feedback(top_seq: &mut u64, feedback: &GtkToolba *top_seq = (*top_seq).max(*seq); } GtkToolbarFeedback::Event { .. } + | GtkToolbarFeedback::PointerShortcut { .. } | GtkToolbarFeedback::TopHover { .. } | GtkToolbarFeedback::CaptureSuppressionReady { .. } | GtkToolbarFeedback::CaptureSuppressionFailed { .. } => {} @@ -36,7 +37,9 @@ fn gtk_toolbar_feedback_is_blocked( | GtkToolbarFeedback::CaptureSuppressionFailed { .. } => false, // Hover is passive state, not a user action; never gate it. GtkToolbarFeedback::TopHover { .. } => false, - GtkToolbarFeedback::Event { .. } => modal_engaged, + GtkToolbarFeedback::Event { .. } | GtkToolbarFeedback::PointerShortcut { .. } => { + modal_engaged + } GtkToolbarFeedback::SetTopOffset { phase, .. } => { let blocked = modal_engaged || *top_drag_blocked; if blocked { @@ -155,6 +158,17 @@ impl WaylandState { Some(qh), ); } + GtkToolbarFeedback::PointerShortcut { + button, + ctrl, + shift, + alt, + logo, + } => { + if !self.input_state.modal_owns_pointer_shortcuts() { + self.try_dispatch_gdk_pointer_shortcut(button, ctrl, shift, alt, logo); + } + } GtkToolbarFeedback::TopHover { hovered } => { self.data.gtk_top_hover = hovered; } @@ -343,4 +357,26 @@ mod modal_tests { )); assert!(top_blocked); } + + #[test] + fn pointer_shortcuts_are_blocked_like_toolbar_events() { + let mut top_blocked = false; + let aux = GtkToolbarFeedback::PointerShortcut { + button: 8, + ctrl: false, + shift: false, + alt: false, + logo: false, + }; + assert!(gtk_toolbar_feedback_is_blocked( + true, + &mut top_blocked, + &aux, + )); + assert!(!gtk_toolbar_feedback_is_blocked( + false, + &mut top_blocked, + &aux, + )); + } } diff --git a/src/config/core.rs b/src/config/core.rs index d0cb2f47..dbd85b1b 100644 --- a/src/config/core.rs +++ b/src/config/core.rs @@ -197,6 +197,11 @@ impl Config { self.keybinding_authorship = KeybindingAuthorship::AllExplicit; } + /// Which `[keybindings]` keys the source this configuration came from spelled out. + pub fn keybinding_authorship(&self) -> &KeybindingAuthorship { + &self.keybinding_authorship + } + /// Declares one action's `[keybindings]` list authored. /// /// The narrow shortcut editor's form of the above: it rewrites exactly one diff --git a/src/config/keybindings/authorship.rs b/src/config/keybindings/authorship.rs index 20d446d0..f9a8152c 100644 --- a/src/config/keybindings/authorship.rs +++ b/src/config/keybindings/authorship.rs @@ -31,7 +31,7 @@ impl KeybindingAuthorship { /// keys is exactly the set of authored actions, and a key that no action /// owns is harmless here (the document loader reports it as an unknown /// setting separately). - pub(crate) fn from_toml_source(input: &str) -> Self { + pub fn from_toml_source(input: &str) -> Self { let Ok(root) = input.parse::() else { // Unreachable from the loaders, which both parse this same text // into a `Config` first. Claiming "nothing was authored" from a @@ -53,7 +53,7 @@ impl KeybindingAuthorship { /// described by the source's presence set, while every other list still is. /// [`Config::mark_keybindings_explicit`](crate::config::Config::mark_keybindings_explicit) /// is the whole-section form, for an editor that rebuilds all of them. - pub(crate) fn mark_explicit(&mut self, config_key: &str) { + pub fn mark_explicit(&mut self, config_key: &str) { match self { // Nothing was omitted, so there is nothing to record. Self::AllExplicit => {} @@ -63,6 +63,18 @@ impl KeybindingAuthorship { } } + /// Drops one `[keybindings]` key from the file-presence set. + /// + /// An editor uses this when a field omitted by the loaded document returns + /// to its baseline value, so a transient edit does not invent source + /// authorship the value-based document merge cannot persist. + /// [`Self::AllExplicit`] has no omitted keys, so this is a no-op there. + pub fn clear_explicit(&mut self, config_key: &str) { + if let Self::FromFile(keys) = self { + keys.remove(config_key); + } + } + /// Whether the source spelled out one `[keybindings]` key. pub fn is_explicit(&self, config_key: &str) -> bool { match self { @@ -116,4 +128,16 @@ mod tests { assert!(authorship.is_explicit("undo")); assert!(authorship.is_explicit("anything")); } + + #[test] + fn clear_explicit_drops_file_presence_and_leaves_all_explicit_alone() { + let mut from_file = + KeybindingAuthorship::from_toml_source("[keybindings]\nundo = [\"Ctrl+Z\"]\n"); + from_file.clear_explicit("undo"); + assert!(!from_file.is_explicit("undo")); + + let mut all = KeybindingAuthorship::default(); + all.clear_explicit("undo"); + assert!(all.is_explicit("undo")); + } } diff --git a/src/input/state/core/utility/sequence.rs b/src/input/state/core/utility/sequence.rs index c617161b..56d82702 100644 --- a/src/input/state/core/utility/sequence.rs +++ b/src/input/state/core/utility/sequence.rs @@ -97,7 +97,10 @@ impl SequenceTrie { if pending.is_some() { return SequenceMatch::Pending; } - return self.dispatch_or_pending(std::slice::from_ref(chord), pending, now); + // Repeats must not create or rearm a prefix. Holding Ctrl+K past + // the timeout would otherwise start the same sequence again and + // let a later chord complete it. A complete single may still fire. + return self.complete_single_match(chord); } if let Some(current) = pending.take() { @@ -143,6 +146,15 @@ impl SequenceTrie { ) -> SequenceMatch { self.lookup_steps(steps, pending, now) } + + fn complete_single_match(&self, chord: &KeyBinding) -> SequenceMatch { + let Some(node) = self.node_for(std::slice::from_ref(chord)) else { + return SequenceMatch::None; + }; + node.action + .map(SequenceMatch::Dispatched) + .unwrap_or(SequenceMatch::None) + } } #[cfg(test)] @@ -234,6 +246,39 @@ mod tests { assert!(pending.is_none()); } + #[test] + fn key_repeat_does_not_rearm_an_expired_prefix() { + let trie = trie(&[("Ctrl+K > Ctrl+C", Action::CopySelection)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), now, false), + SequenceMatch::Pending + ); + let later = now + SEQUENCE_STEP_TIMEOUT; + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), later, true), + SequenceMatch::None + ); + assert!(pending.is_none()); + assert_eq!( + trie.match_chord(&mut pending, &chord("Ctrl+K"), later, false), + SequenceMatch::Pending + ); + } + + #[test] + fn key_repeat_still_dispatches_a_complete_single() { + let trie = trie(&[("F5", Action::SelectPenTool)]); + let mut pending = None; + let now = start(); + assert_eq!( + trie.match_chord(&mut pending, &chord("F5"), now, true), + SequenceMatch::Dispatched(Action::SelectPenTool) + ); + assert!(pending.is_none()); + } + #[test] fn key_repeat_does_not_advance_a_pending_sequence() { let trie = trie(&[("Ctrl+K > Ctrl+C", Action::CopySelection)]); diff --git a/src/toolbar_gtk/bridge.rs b/src/toolbar_gtk/bridge.rs index fecfd305..129fbc66 100644 --- a/src/toolbar_gtk/bridge.rs +++ b/src/toolbar_gtk/bridge.rs @@ -326,6 +326,7 @@ impl GtkToolbarFeedback { match self { Self::SetTopOffset { .. } => Some(GtkToolbarKind::Top), Self::Event { .. } + | Self::PointerShortcut { .. } | Self::TopHover { .. } | Self::CaptureSuppressionReady { .. } | Self::CaptureSuppressionFailed { .. } => None, diff --git a/src/toolbar_gtk/mod.rs b/src/toolbar_gtk/mod.rs index cc299dd2..9fd81595 100644 --- a/src/toolbar_gtk/mod.rs +++ b/src/toolbar_gtk/mod.rs @@ -157,6 +157,20 @@ pub enum GtkToolbarFeedback { event: ToolbarEvent, rebind_requested: bool, }, + /// Auxiliary mouse button on a GTK toolbar surface. + /// + /// GTK bars are separate windows, so canvas pointer handlers never see + /// these clicks. The button number is a GDK button (8/11 Back, 9/10 + /// Forward, 12–15 Extra1–4). Modifiers are captured from the GTK click: + /// focusing the toolbar can reset overlay modifier state, so Ctrl+MouseBack + /// and similar chords must use this snapshot rather than the overlay seat. + PointerShortcut { + button: u32, + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, + }, /// Pointer entered/left the GTK top strip. GTK runs on its own Wayland /// connection, so the backend cannot observe this hover itself; it drives /// the top-strip idle-fade restore/hold. diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index 36477c66..8f99b495 100644 --- a/src/toolbar_gtk/widgets.rs +++ b/src/toolbar_gtk/widgets.rs @@ -99,6 +99,42 @@ impl FeedbackSender { } } +fn gdk_pointer_modifiers(state: gtk4::gdk::ModifierType) -> (bool, bool, bool, bool) { + ( + state.contains(gtk4::gdk::ModifierType::CONTROL_MASK), + state.contains(gtk4::gdk::ModifierType::SHIFT_MASK), + state.contains(gtk4::gdk::ModifierType::ALT_MASK), + state.contains(gtk4::gdk::ModifierType::SUPER_MASK) + || state.contains(gtk4::gdk::ModifierType::META_MASK) + || state.contains(gtk4::gdk::ModifierType::HYPER_MASK), + ) +} + +fn pointer_shortcut_feedback( + button: u32, + state: gtk4::gdk::ModifierType, +) -> Option { + crate::config::keybindings::gdk::pointer_button(button)?; + let (ctrl, shift, alt, logo) = gdk_pointer_modifiers(state); + Some(GtkToolbarFeedback::PointerShortcut { + button, + ctrl, + shift, + alt, + logo, + }) +} + +fn maybe_forward_auxiliary_pointer(gesture: >k4::GestureClick, feedback: &FeedbackSender) { + let Some(event) = + pointer_shortcut_feedback(gesture.current_button(), gesture.current_event_state()) + else { + return; + }; + gesture.set_state(gtk4::EventSequenceState::Claimed); + let _ = feedback.send(event); +} + pub(super) fn send_event(sender: &FeedbackSender, event: ToolbarEvent) { let rebind_requested = sender.click_rebind_requested.replace(false); let shift_click = sender.click_shift.replace(false); @@ -165,10 +201,12 @@ pub(super) fn install_click_modifier_capture( feedback: &FeedbackSender, ) { let click = gtk4::GestureClick::new(); + click.set_button(0); click.set_propagation_phase(gtk4::PropagationPhase::Capture); let press_feedback = feedback.clone(); click.connect_pressed(move |gesture, _, _, _| { press_feedback.capture_click_modifiers(gesture.current_event_state()); + maybe_forward_auxiliary_pointer(gesture, &press_feedback); }); // Mirror the toolbar window's controller: a click that never activates a // button (cancelled, or dragged off it) still has to clear the pending @@ -244,10 +282,12 @@ pub(super) fn install_shortcut_focus_policy(window: >k4::Window, feedback: &Fe // Inspect the pointer target after every click and keep focus only for // the editable hex field. let click = gtk4::GestureClick::new(); + click.set_button(0); click.set_propagation_phase(gtk4::PropagationPhase::Capture); let click_feedback = feedback.clone(); click.connect_pressed(move |gesture, _, _, _| { click_feedback.capture_click_modifiers(gesture.current_event_state()); + maybe_forward_auxiliary_pointer(gesture, &click_feedback); }); let release_feedback = feedback.clone(); click.connect_released(move |_, _, _, _| { diff --git a/src/toolbar_gtk/widgets/tests.rs b/src/toolbar_gtk/widgets/tests.rs index 56cccbdc..fc39fea2 100644 --- a/src/toolbar_gtk/widgets/tests.rs +++ b/src/toolbar_gtk/widgets/tests.rs @@ -111,3 +111,58 @@ fn backend_modifier_latch_survives_focus_reset_during_click() { } ); } + +#[test] +fn auxiliary_mouse_buttons_forward_as_pointer_shortcuts() { + let (tx, rx) = std::sync::mpsc::channel(); + let sender = FeedbackSender::new(tx); + let _ = sender.send(GtkToolbarFeedback::PointerShortcut { + button: 8, + ctrl: true, + shift: false, + alt: false, + logo: false, + }); + assert_eq!( + rx.recv().unwrap(), + GtkToolbarFeedback::PointerShortcut { + button: 8, + ctrl: true, + shift: false, + alt: false, + logo: false, + } + ); + assert_eq!( + crate::config::keybindings::gdk::pointer_button(8), + Some(crate::config::PointerButton::Back) + ); + assert_eq!(crate::config::keybindings::gdk::pointer_button(1), None); +} + +#[test] +fn gtk_pointer_shortcuts_carry_click_modifiers() { + assert_eq!( + super::pointer_shortcut_feedback(8, gtk4::gdk::ModifierType::CONTROL_MASK), + Some(GtkToolbarFeedback::PointerShortcut { + button: 8, + ctrl: true, + shift: false, + alt: false, + logo: false, + }) + ); + assert_eq!( + super::pointer_shortcut_feedback( + 9, + gtk4::gdk::ModifierType::SHIFT_MASK | gtk4::gdk::ModifierType::SUPER_MASK, + ), + Some(GtkToolbarFeedback::PointerShortcut { + button: 9, + ctrl: false, + shift: true, + alt: false, + logo: true, + }) + ); +}