diff --git a/config.example.toml b/config.example.toml index 93741d6c..9014d1b4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -33,7 +33,16 @@ 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", +# "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., ["+", "="]) # Exit overlay (or cancel current action) @@ -758,7 +767,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) @@ -1359,6 +1368,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 01e10a08..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 comma-separated shortcut lists that map to `KeybindingsConfig`. +- **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/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/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..5d3a6348 --- /dev/null +++ b/configurator/src/app/pages/keybindings/mod.rs @@ -0,0 +1,208 @@ +//! 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}; + +use super::super::state::ConfiguratorApp; +use super::{Binding, BuiltPage}; +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. Filter and sort to review many actions at once."; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut bindings: Vec = 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 group = adw::PreferencesGroup::builder() + .title(tab.title()) + .description(SECTION_DESCRIPTION) + .build(); + for field in fields.iter().copied().filter(|field| field.tab() == tab) { + let row = binding_row(&group, field, sender, &mut bindings, refresh.clone()); + slots.push((field, row, group.clone())); + } + page.add(&group); + groups.push((tab, group)); + } + + { + 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; + }; + 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 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; + } + for field in &ordered { + if let Some((_, row, home)) = slots.iter().find(|(known, _, _)| known == field) + { + home.remove(row); + group.add(row); + } + } + *last = ordered; + } + })); + } + + 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(&page); + + 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 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"); + { + 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(&jump); + 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 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 { + Some(conflict) => { + 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); + } + } + } + })); + revealer +} diff --git a/configurator/src/app/pages/keybindings/recorder.rs b/configurator/src/app/pages/keybindings/recorder.rs new file mode 100644 index 00000000..aae5ad2d --- /dev/null +++ b/configurator/src/app/pages/keybindings/recorder.rs @@ -0,0 +1,259 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + +use relm4::{ComponentSender, gtk}; + +use gtk::glib::object::IsA; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::KeybindingField; +#[cfg(not(feature = "tablet-input"))] +use crate::models::keybindings::tablet_unavailable_hint; +use crate::models::keybindings::{ + KeyboardModifiers, RecordedDevice, RecorderDeviceKind, ShortcutRecorderState, + normalize_button_event, super_consumed_hint, waiting_prompt, +}; + +use super::super::super::state::ConfiguratorApp; +use super::widgets::{ + field_canceled, set_accessible_label, set_label, set_sensitive, set_visible, + unparent_on_destroy, +}; + +pub(super) struct RecorderPopover { + pub popover: gtk::Popover, + prompt: gtk::Label, + finish: gtk::Button, + remove_last: gtk::Button, +} + +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 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"); + { + let sender = sender.clone(); + cancel.connect_clicked(move |_| { + sender.input(Message::ShortcutRecordingCanceled(field)); + }); + } + + 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); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&prompt); + content.append(&hint); + content.append(&device_hint_label()); + content.append(&buttons); + + let popover = gtk::Popover::builder() + .autohide(true) + .child(&content) + .build(); + popover.set_parent(parent); + unparent_on_destroy(parent, &popover); + field_canceled(&popover, field, sender, Message::ShortcutRecordingCanceled); + attach_key_controller(&popover, sender); + attach_button_controller(&popover, sender); + + Self { + popover, + prompt, + finish, + remove_last, + } + } + + 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(); + 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 pressed = Rc::new(RefCell::new(HashSet::new())); + let controller = gtk::EventControllerKey::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + { + 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); +} + +fn device_hint_label() -> 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 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; + } + let Some(button_event) = event.downcast_ref::() else { + return gtk::glib::Propagation::Proceed; + }; + 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), + }; + // Primary clicks must reach Finish / Remove Last Step / Cancel. Stop + // only bindable auxiliary mouse or stylus barrel events. + match normalize_button_event(button_event.button(), kind, recorded) { + RecordedDevice::Trigger(_) => { + sender.input(Message::ShortcutRecorderButton( + button_event.button(), + kind, + recorded, + )); + gtk::glib::Propagation::Stop + } + RecordedDevice::Unsupported { .. } => gtk::glib::Propagation::Proceed, + } + }); + 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..c257b5cb --- /dev/null +++ b/configurator/src/app/pages/keybindings/row.rs @@ -0,0 +1,370 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use adw::prelude::*; +use wayscriber::config::Shortcut; + +use crate::messages::Message; +use crate::models::KeybindingField; +use crate::models::keybindings::{ + ShortcutManagerSummary, field_has_internal_duplicate, field_matches_defaults, + parse_keybindings, reset_tooltip, serialize_bindings, +}; + +use 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, unparent_on_destroy, watch_compact, +}; + +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()) + .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 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"); + connect_clicked(&edit, sender, Message::ShortcutTextEditStarted(field)); + + let controls = gtk::Box::new(gtk::Orientation::Horizontal, 4); + controls.append(&add); + controls.append(&record_sequence); + 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 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() + .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)); + 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); + unparent_on_destroy(&edit_shortcuts, &editor_popover); + { + 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 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(); + 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(); + 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); + let parse_error = parsed.as_ref().err().cloned(); + match &parsed { + Ok(bindings) => { + 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; + } + 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(&record_sequence, parse_error.is_none()); + set_sensitive(&reset, !at_defaults); + + let caption_text = match &parse_error { + Some(message) => message.clone(), + None if field_has_internal_duplicate(&app.draft.keybindings, field) => { + "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); + + 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()); + + 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( + flow: >k::FlowBox, + field: KeybindingField, + bindings: &[Shortcut], + 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: &Shortcut, + sender: &ComponentSender, +) -> gtk::Box { + let label_text = binding.display_label(); + // 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) + .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 |_| { + sender.input(Message::ShortcutRemoved(field, binding.clone())); + }); + chip.append(&label); + 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/text_editor.rs b/configurator/src/app/pages/keybindings/text_editor.rs new file mode 100644 index 00000000..f17a4d56 --- /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, set_accessible_label, set_label, unparent_on_destroy}; + +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); + unparent_on_destroy(parent, &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/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/pages/keybindings/widgets.rs b/configurator/src/app/pages/keybindings/widgets.rs new file mode 100644 index 00000000..4d44bcf9 --- /dev/null +++ b/configurator/src/app/pages/keybindings/widgets.rs @@ -0,0 +1,101 @@ +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)]); +} + +/// 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.get_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()); + }); +} + +/// 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(); + } + }); +} + +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/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 8e7445d3..db8a49b8 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, + KeybindingField, KeybindingsTabId, PendingShortcutConflict, SearchQuery, SessionCatalogState, + ShortcutManagerFilter, ShortcutManagerSort, ShortcutRecorderState, ShortcutTextEditor, + StartupRequest, TabId, ToolbarLayoutModeOption, UiTabId, }; use super::effects::Effect; @@ -27,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, @@ -74,18 +81,25 @@ 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)] 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 { @@ -93,6 +107,10 @@ impl PendingConfirmation { match self { PendingConfirmation::DefaultsReset => ConfirmationPrompt::DefaultsReset, PendingConfirmation::SessionClear(_) => ConfirmationPrompt::SessionClear, + PendingConfirmation::ShortcutResetVisible(_) => { + ConfirmationPrompt::ShortcutResetVisible + } + PendingConfirmation::ShortcutResetAll => ConfirmationPrompt::ShortcutResetAll, } } } @@ -106,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." + } } } } @@ -206,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], @@ -231,6 +261,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(); @@ -248,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; } @@ -261,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, } } @@ -277,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 @@ -296,6 +357,17 @@ 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; + self.shortcut_conflict_review = false; + } } #[cfg(test)] 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/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..aa0aca4b 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(), @@ -100,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) => { @@ -241,9 +265,43 @@ 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::ShortcutSequenceRecordingStarted(field) => { + self.handle_shortcut_sequence_recording_started(field) + } + Message::ShortcutRecordingCanceled(field) => { + self.handle_shortcut_recording_canceled(field) + } + 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::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) + } + 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..76c7d35b --- /dev/null +++ b/configurator/src/app/update/shortcuts.rs @@ -0,0 +1,461 @@ +//! Shortcut recorder, chip, reset, text-editor, and conflict messages. + +#[cfg(test)] +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, + 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, +}; + +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_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, + ) -> 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(); + }; + match normalize_key_event(keyval, modifiers) { + RecordedKeyboard::Pending { preview } => { + recorder.apply_pending_preview(preview); + Vec::new() + } + RecordedKeyboard::Unsupported { message } => { + recorder.prompt = message; + Vec::new() + } + RecordedKeyboard::Chord(binding) => { + 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() + } + } + } + } + + 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) => { + 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.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: Shortcut, + ) -> 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.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()); + } + } + Vec::new() + } + + pub(super) fn handle_shortcut_conflict_canceled(&mut self) -> Vec { + self.pending_shortcut_conflict = None; + self.shortcut_conflict_review = false; + 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() + } + + 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, + binding: Shortcut, + ) -> 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..cab17a62 --- /dev/null +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -0,0 +1,745 @@ +use wayscriber::config::{CURRENT_CONFIG_REVISION, ConfigDocument, Shortcut}; + +use crate::app::effects::Effect; +use crate::app::state::{ConfiguratorApp, PendingConfirmation, StatusMessage}; +use crate::models::keybindings::keyval; +use crate::models::{KeybindingField, KeyboardModifiers, RecorderDeviceKind, 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, + Shortcut::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 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(); + 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()); +} + +#[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")) + ); +} + +#[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") + ); +} + +#[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 4d8d8603..33afe4f6 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -1,21 +1,22 @@ use std::path::PathBuf; -use wayscriber::config::{ConfigDocument, ToolbarItemId, ToolbarItemOrderGroup}; +use wayscriber::config::{ConfigDocument, Shortcut, 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, + 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}; @@ -62,9 +63,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, @@ -92,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), @@ -155,7 +164,23 @@ pub enum Message { ExportPdfLabelPositionChanged(PdfLabelPositionOption), ExportPdfLabelContentChanged(PdfLabelContentModeOption), BufferCountChanged(u32), - KeybindingChanged(KeybindingField, String), + ShortcutRecordingStarted(KeybindingField), + ShortcutSequenceRecordingStarted(KeybindingField), + ShortcutRecordingCanceled(KeybindingField), + ShortcutRecorderKey(u32, KeyboardModifiers), + ShortcutRecorderButton(u32, RecorderDeviceKind, KeyboardModifiers), + ShortcutSequenceFinish, + ShortcutSequenceRemoveLastStep, + ShortcutRemoved(KeybindingField, Shortcut), + 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/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index c5bc2c93..c7e7b4a3 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -355,7 +355,20 @@ impl ConfigDraft { presets: PresetsDraft::from_config(config), - keybindings: KeybindingsDraft::from_config(&config.keybindings), + 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/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/AGENTS.md b/configurator/src/models/keybindings/AGENTS.md index 0c8133a5..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, 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 new file mode 100644 index 00000000..b9eb7209 --- /dev/null +++ b/configurator/src/models/keybindings/conflicts.rs @@ -0,0 +1,647 @@ +//! Draft-wide shortcut conflict lookup and explicit replacement. + +use wayscriber::config::{Action, Shortcut, ShortcutTrigger, StylusButton, action_label}; + +use super::draft::KeybindingsDraft; +use super::edit::{FieldEditError, append_binding, remove_binding}; +use super::field::KeybindingField; +use super::parse::{authored_shortcut_parts, parse_keybindings}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + Keybindings, + LegacyTablet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShortcutClaim { + pub field: Option, + pub label: String, + pub source: BindingSource, + pub held: Shortcut, +} + +impl ShortcutClaim { + pub(crate) 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, held: Shortcut) -> Self { + Self { + field: None, + label: format!("{} (legacy tablet barrel button)", action_label(action)), + source: BindingSource::LegacyTablet, + held, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingShortcutConflict { + Recorded { + target: KeybindingField, + binding: Shortcut, + 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 { claimants, .. } + if !claimants.is_empty() + && claimants + .iter() + .all(|claim| claim.source == BindingSource::LegacyTablet) => + { + "Move Legacy Binding" + } + Self::Recorded { .. } => "Replace", + 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)] +pub struct TextShortcutConflict { + 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: &Shortcut) -> Vec { + let mut claims = Vec::new(); + for entry in &draft.entries { + if let Ok(parsed) = parse_keybindings(&entry.value) { + for candidate in parsed { + if shortcuts_conflict(&candidate, binding) + && !claims.iter().any(|claim: &ShortcutClaim| { + claim.field == Some(entry.field) && claim.held == candidate + }) + { + claims.push(ShortcutClaim::from_field(entry.field, candidate)); + } + } + } + } + if let Some(action) = legacy_action_for(draft, binding) { + claims.push(ShortcutClaim::legacy_tablet(action, binding.clone())); + } + claims +} + +pub fn other_claimants( + draft: &KeybindingsDraft, + target: KeybindingField, + binding: &Shortcut, +) -> Vec { + claimants_for(draft, binding) + .into_iter() + .filter(|claim| claim.field != Some(target) || claim.held != *binding) + .collect() +} + +pub fn field_has_internal_duplicate(draft: &KeybindingsDraft, field: KeybindingField) -> bool { + let Some(value) = draft.value_for(field) else { + return false; + }; + let Ok(parsed) = parse_keybindings(value) else { + return false; + }; + parsed.iter().enumerate().any(|(index, binding)| { + parsed + .iter() + .skip(index + 1) + .any(|other| shortcuts_conflict(other, binding)) + }) +} + +pub fn text_conflicts_for( + draft: &KeybindingsDraft, + target: KeybindingField, + parsed: &[Shortcut], +) -> Vec { + let mut conflicts = Vec::new(); + let mut seen = Vec::new(); + for binding in parsed { + 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, binding.clone())], + }); + } + continue; + } + seen.push(binding.clone()); + 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(), + claimants, + }); + } + } + conflicts +} + +pub fn apply_recorded_replace( + draft: &mut KeybindingsDraft, + target: KeybindingField, + binding: &Shortcut, + claimants: &[ShortcutClaim], +) -> Result<(), FieldEditError> { + let snapshot = draft.clone(); + for claim in claimants { + if claim.field == Some(target) { + if let Err(error) = remove_binding(draft, target, &claim.held) { + *draft = snapshot; + return Err(error); + } + continue; + } + if claim.source == BindingSource::LegacyTablet { + clear_legacy_for(draft, binding); + continue; + } + let Some(field) = claim.field else { + continue; + }; + if let Err(error) = remove_binding(draft, field, &claim.held) { + *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> { + 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) { + continue; + } + if claim.source == BindingSource::LegacyTablet { + clear_legacy_for(draft, &conflict.binding); + continue; + } + let Some(field) = claim.field else { + continue; + }; + if let Err(error) = remove_binding(draft, field, &claim.held) { + *draft = snapshot; + return Err(error); + } + } + } + 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 { + 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 + .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 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: &Shortcut) -> Option { + let trigger = binding.as_trigger()?; + let ShortcutTrigger::Stylus(trigger) = trigger 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: &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 { + 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 { + let names = conflict + .claimants + .iter() + .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.display_label())); + } + 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 = 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))); + assert!(fields.contains(&Some(KeybindingField::ToggleToolbar))); + assert!(fields.contains(&Some(KeybindingField::Undo))); + 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 = 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)); + } + + #[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 = Shortcut::parse("E").expect("parses"); + let claimants = claimants_for(&draft, &binding); + assert_eq!(claimants.len(), 1); + assert_eq!(claimants[0].field, Some(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 = Shortcut::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: Shortcut::parse("E").expect("parses"), + claimants: other_claimants( + &draft, + KeybindingField::Undo, + &Shortcut::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, + &[ + Shortcut::parse("E").expect("parses"), + Shortcut::parse("Ctrl+Z").expect("parses"), + Shortcut::parse("E").expect("parses"), + ], + ); + assert_eq!(conflicts.len(), 2); + assert_eq!(conflicts[0].binding.to_string(), "E"); + assert_eq!( + conflicts[0].claimants[0].field, + Some(KeybindingField::ClearCanvas) + ); + assert_eq!(conflicts[1].binding.to_string(), "Ctrl+Z"); + 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 = 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); + 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 = 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())) + ); + } + + #[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/draft.rs b/configurator/src/models/keybindings/draft.rs index 3e2035da..df940868 100644 --- a/configurator/src/models/keybindings/draft.rs +++ b/configurator/src/models/keybindings/draft.rs @@ -1,12 +1,25 @@ +use std::collections::HashSet; + use wayscriber::config::keybindings::KeybindingsConfig; +use wayscriber::config::{Action, Config, KeybindingAuthorship}; 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, + authorship: KeybindingAuthorship, + baseline_entries: Vec, + baseline_authorship: KeybindingAuthorship, } #[derive(Debug, Clone, PartialEq)] @@ -17,22 +30,80 @@ 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(", "), field, }) .collect(); - Self { entries } + 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")] + { + 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) { 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 new file mode 100644 index 00000000..8c8b3110 --- /dev/null +++ b/configurator/src/models/keybindings/edit.rs @@ -0,0 +1,337 @@ +//! 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::Shortcut; + +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: &Shortcut, +) -> 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: &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 && Shortcut::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.restore_default(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, + 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: &[Shortcut]) -> String { + bindings + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use wayscriber::config::KeybindingAuthorship; + 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 = 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")); + } + + #[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 = 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")); + } + + #[test] + fn removing_the_last_chip_unbinds_instead_of_resetting() { + let (mut draft, defaults) = draft(); + let binding = Shortcut::parse("E").expect("parses"); + remove_binding(&mut draft, KeybindingField::ClearCanvas, &binding).expect("remove"); + assert_eq!(draft.value_for(KeybindingField::ClearCanvas), Some("")); + assert!(!field_matches_defaults( + &draft, + &defaults, + KeybindingField::ClearCanvas + )); + } + + #[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()); + + reset_field(&mut draft, &defaults, KeybindingField::ClearCanvas); + 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), + 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 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(); + draft.set(KeybindingField::Exit, "Ctrl+Shift".to_string()); + 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")); + 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 + )); + } + + #[test] + fn mouse_and_stylus_strings_append_as_chips() { + let (mut draft, _defaults) = draft(); + draft.set(KeybindingField::Undo, "".to_string()); + 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/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..d8667361 --- /dev/null +++ b/configurator/src/models/keybindings/manager.rs @@ -0,0 +1,679 @@ +//! 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::{ + ShortcutClaim, 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 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 +} + +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 draft.is_authored(field) { + 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::Authored)); + 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 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(); + 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); + } + + #[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/mod.rs b/configurator/src/models/keybindings/mod.rs index e7275afb..77a6c545 100644 --- a/configurator/src/models/keybindings/mod.rs +++ b/configurator/src/models/keybindings/mod.rs @@ -1,10 +1,35 @@ +mod conflicts; mod draft; +mod edit; mod field; +mod manager; 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_fields, reset_tooltip, serialize_bindings, +}; pub use field::KeybindingField; -pub(crate) use parse::parse_keybinding_list; +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; +#[cfg(not(feature = "tablet-input"))] +pub use recording::tablet_unavailable_hint; +pub use recording::{ + KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind, ShortcutRecorderState, + normalize_button_event, normalize_key_event, sequence_keyboard_only_message, + super_consumed_hint, waiting_prompt, +}; #[cfg(test)] mod tests; diff --git a/configurator/src/models/keybindings/parse.rs b/configurator/src/models/keybindings/parse.rs index e4da185d..d01afb73 100644 --- a/configurator/src/models/keybindings/parse.rs +++ b/configurator/src/models/keybindings/parse.rs @@ -1,16 +1,26 @@ -use wayscriber::config::KeyBinding; +use wayscriber::config::Shortcut; + +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) { + Shortcut::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(Shortcut::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..a58eed75 --- /dev/null +++ b/configurator/src/models/keybindings/recording.rs @@ -0,0 +1,786 @@ +//! GDK key-event normalization for the configurator shortcut recorder. +//! +//! 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. + +#[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, Shortcut, ShortcutTrigger}; + +use super::field::KeybindingField; + +/// Modifier flags taken from a GDK key event. +/// +/// 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, + 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 self.super_held { + parts.push("Super"); + } + 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 }, +} + +/// 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 { + pub field: KeybindingField, + pub prompt: String, + sequence_steps: Option>, +} + +impl ShortcutRecorderState { + pub fn new(field: KeybindingField) -> Self { + 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); + } + } +} + +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) { + 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, + logo: modifiers.super_held, + }; + 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_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." +} + +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::META_L + | keyval::META_R + | keyval::SUPER_L + | keyval::SUPER_R + | keyval::HYPER_L + | keyval::HYPER_R + | keyval::ISO_LEVEL3_SHIFT + | keyval::NUM_LOCK + ) +} + +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 { + 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` +/// 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, super_held: bool) -> KeyboardModifiers { + KeyboardModifiers { + ctrl, + shift, + alt, + super_held, + } + } + + 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, false)); + assert_eq!(binding.to_string(), "Ctrl+Shift+X"); + 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, false)); + assert_eq!(binding.to_string(), "Shift+1"); + 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, 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, + keyval::SUPER_L, + keyval::SUPER_R, + keyval::META_L, + keyval::HYPER_L, + ] { + match normalize_key_event(keyval, mods(true, false, 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_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:?}"), + } + } + + #[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" + ); + } + + #[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()) { + 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:?}"), + } + 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] + 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:?}"), + } + } + + #[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/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 948dd09e..738541e3 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, RecorderDeviceKind, + ShortcutManagerFilter, ShortcutManagerSort, ShortcutRecorderState, ShortcutTextEditor, +}; pub(crate) use search::SearchQuery; pub(crate) use session::SessionCatalogOperation; pub use session::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogState}; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 8a725927..5b06555d 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) | @@ -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,6 +1599,27 @@ 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, 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`, `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 +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 A `[keybindings]` field your file spells out is yours: it is used exactly as authored, including an @@ -1929,15 +1951,25 @@ 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"` +- 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. 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`) - `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..25c432f4 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; -use crate::config::{Action, KeyBinding, KeybindingsConfig, 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) => { @@ -13,31 +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 2081e125..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, KeyBinding, KeybindingsConfig, QuickColorPalette}; +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 5c590deb..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, KeyBinding, PresenterModeConfig}; + 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( - KeyBinding::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 98ff8c5d..e69170d8 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( @@ -413,7 +417,7 @@ impl WaylandState { { return; } - self.apply_input_key(key); + self.apply_input_key_repeat(key); } } diff --git a/src/backend/wayland/handlers/keyboard/translate.rs b/src/backend/wayland/handlers/keyboard/translate.rs index 13f59a5d..91765427 100644 --- a/src/backend/wayland/handlers/keyboard/translate.rs +++ b/src/backend/wayland/handlers/keyboard/translate.rs @@ -9,18 +9,19 @@ 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, + 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,31 @@ 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); + } + + #[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 ada2f717..43d2e7e7 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -131,6 +131,12 @@ impl WaylandState { return; } + if !self.input_state.modal_owns_pointer_shortcuts() + && 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 +295,58 @@ 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; + }; + 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(); + debug!("Pointer shortcut {shortcut}: 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 +372,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..e4ee3ac1 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -1,22 +1,28 @@ 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 - // 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( + 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 { @@ -280,18 +286,12 @@ 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.input_state.clear_pending_sequence(); self.dispatch_input_action(action); + } else if linux::stylus_button(button).is_none() { + debug!("Ignoring unknown stylus button {}", button); } } @@ -310,7 +310,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 +350,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/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/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index f799bf23..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, KeyBinding, PresenterModeConfig}; +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(KeyBinding::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/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/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 23241909..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, KeyBinding, - KeybindingsConfig, ShortcutClaimedOnDisk, 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 94de585e..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 = KeyBinding::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 1443dc58..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 = 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 = 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 dfb41cc7..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 = KeyBinding::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/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/io.rs b/src/config/io.rs index 53edf875..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, KeyBinding, KeybindingAuthorship}; +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 = KeyBinding::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 e72de5fe..81cbae53 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, 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 2e5761e2..a44d4b3f 100644 --- a/src/config/keybindings/AGENTS.md +++ b/src/config/keybindings/AGENTS.md @@ -6,7 +6,9 @@ ## 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 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/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/config/keybindings/binding.rs b/src/config/keybindings/binding.rs index 6f3baa84..4169ce56 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}")); @@ -142,94 +146,115 @@ 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 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, - _ => { - // 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, - }) - } else { - Ok(Self { - key, - ctrl, - shift, - alt, - }) - } + 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. - 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 } } 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"); - } - 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..f294c283 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, 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: KeyBinding, + binding: Shortcut, actions: Vec, } impl KeybindingConflict { /// The parsed shortcut every listed action claims. - pub fn binding(&self) -> &KeyBinding { + 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: &KeyBinding, 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,39 @@ 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 KeyBinding::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() { + // 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 { + 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 +204,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 +213,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 +247,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..954cf87f --- /dev/null +++ b/src/config/keybindings/shortcut.rs @@ -0,0 +1,750 @@ +//! Shortcut identity: a single trigger or a short keyboard sequence. +//! +//! 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}; + +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; + +/// 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. +#[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(_) => true, + #[cfg(feature = "tablet-input")] + Self::Stylus(_) => true, + #[cfg(not(feature = "tablet-input"))] + Self::Stylus(_) => false, + } + } + + 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(_) if !self.is_deliverable() => Some(self.to_string()), + 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(), + ), + } + } +} + +/// 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, + 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 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 | 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, + } + } + + /// 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); + 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"] { + 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::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), + Some(StylusButton::Primary) + ); + 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 cc533c7d..4a3b1185 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] @@ -149,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(&Shortcut::parse("Ctrl+Alt+Shift+1").unwrap()), Some(&Action::Exit) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+2").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+2").unwrap()), Some(&Action::Undo) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+3").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+3").unwrap()), Some(&Action::Redo) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+4").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+4").unwrap()), Some(&Action::ToggleHelp) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+5").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+5").unwrap()), Some(&Action::ToggleWhiteboard) ); } @@ -178,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(&Shortcut::parse("Ctrl+K").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Shift+P").unwrap()), + map.get(&Shortcut::parse("Ctrl+Shift+P").unwrap()), Some(&Action::ToggleCommandPalette) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+F").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+F").unwrap()), Some(&Action::CaptureFullScreen) ); } @@ -200,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(&Shortcut::parse("F2").unwrap()), Some(&Action::CycleToolbarDisplay) ); assert_eq!( - map.get(&KeyBinding::parse("F9").unwrap()), + map.get(&Shortcut::parse("F9").unwrap()), Some(&Action::ToggleToolbar) ); } @@ -239,6 +271,95 @@ 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(&Shortcut::parse("MouseBack").unwrap()), + Some(&Action::Undo) + ); + assert_eq!( + map.get(&Shortcut::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 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 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(); @@ -260,8 +381,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] @@ -280,15 +401,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(), + Shortcut::parse("Ctrl+Alt+Shift+1").unwrap(), + Shortcut::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(), + Shortcut::parse("Ctrl+Alt+Shift+3").unwrap(), + Shortcut::parse("Ctrl+Alt+Shift+4").unwrap(), ]) ); } @@ -318,23 +439,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(&Shortcut::parse("Ctrl+Alt+Shift+F").unwrap()), Some(&Action::ExportCanvasFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+C").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+C").unwrap()), Some(&Action::ExportCanvasClipboard) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+B").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+B").unwrap()), Some(&Action::ExportCanvasClipboardAndFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+P").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+P").unwrap()), Some(&Action::ExportBoardPdfFile) ); assert_eq!( - map.get(&KeyBinding::parse("Ctrl+Alt+Shift+A").unwrap()), + map.get(&Shortcut::parse("Ctrl+Alt+Shift+A").unwrap()), Some(&Action::ExportAllBoardsPdfFile) ); } @@ -346,7 +467,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(&Shortcut::parse("I").unwrap()), Some(&Action::PickScreenColor) ); @@ -355,7 +476,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(&Shortcut::parse("Ctrl+Alt+Shift+E").unwrap()), Some(&Action::PickScreenColor) ); } @@ -367,7 +488,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(&Shortcut::parse("O").unwrap()), Some(&Action::SetColorOrange) ); assert!( @@ -379,7 +500,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(&Shortcut::parse("Ctrl+Alt+T").unwrap()), Some(&Action::CopyTextFromScreen) ); } @@ -454,7 +575,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() + &Shortcut::parse("Ctrl+Alt+Shift+1").unwrap() ); assert_eq!(conflicts[0].actions(), [Action::Exit, Action::Undo]); assert_eq!( @@ -823,6 +944,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 +985,8 @@ fn ordinary_bindings_are_not_flagged() { "Escape", "F10", "Ctrl+Shift+P", + "Super+X", + "Meta+X", "a", "/", "PageUp", diff --git a/src/config/mod.rs b/src/config/mod.rs index 18588ee6..a061f032 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -53,6 +53,8 @@ pub use io::{ }; pub use keybindings::{ 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 3b065997..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(&KeyBinding::parse("F2").expect("F2 parses")), + map.get(&Shortcut::parse("F2").expect("F2 parses")), Some(&Action::ToggleToolbar) ); assert_eq!( - map.get(&KeyBinding::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(&KeyBinding::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); @@ -1860,6 +1890,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/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/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/config/validate/keybindings.rs b/src/config/validate/keybindings.rs index 470ae60d..51e15709 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, 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 [`KeyBinding`] 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: &KeyBinding, + 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| { - KeyBinding::parse(candidate).is_ok_and(|parsed| parsed == *binding) + Shortcut::parse(candidate).is_ok_and(|parsed| parsed == *binding) }) }) }) @@ -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), @@ -243,7 +296,7 @@ impl fmt::Display for KeybindingConflictResolution { fn drop_binding( keybindings: &mut KeybindingsConfig, action: Action, - binding: &KeyBinding, + binding: &Shortcut, keep_first: bool, ) -> bool { let Some(current) = keybindings.bindings_for_action(action) else { @@ -253,7 +306,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 !Shortcut::parse(candidate).is_ok_and(|parsed| parsed == *binding) { return true; } if keep_first && !kept_one { @@ -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 @@ -405,12 +474,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 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 // future build may learn to deliver this name. @@ -419,10 +484,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(), }, }); } @@ -489,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, }); @@ -508,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); } } @@ -566,18 +654,19 @@ 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) = 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(), + kept_key: binding.to_string(), + dropped_key: binding.to_string(), kept: *action, dropped: *action, }); @@ -587,7 +676,7 @@ impl Config { skipped.push(DefaultShortcutSkipped { action: *action, binding: binding.to_string(), - claimed_by: *owner, + claimed_by: owner, }); } None => { @@ -741,7 +830,7 @@ impl Config { } let Some(contested) = CURRENT_TOGGLE_INPUT_HUD_DEFAULT .first() - .and_then(|binding| KeyBinding::parse(binding).ok()) + .and_then(|binding| Shortcut::parse(binding).ok()) else { return; }; 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..a55a43ef 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, } @@ -35,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/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/init.rs b/src/input/state/core/base/state/init.rs index ba03ca17..d972ee8d 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -8,7 +8,7 @@ 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, Shortcut, }; use crate::draw::{ BlurStyle, DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES, @@ -20,7 +20,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 +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, @@ -76,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), @@ -206,6 +208,9 @@ 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(), 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 9fbd73fb..5f1f6061 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -12,7 +12,10 @@ impl InputState { self.modifiers.shift = false; self.modifiers.ctrl = false; self.modifiers.alt = false; + 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(); } @@ -22,10 +25,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/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index e10ad4e4..2ffe15b0 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, + Shortcut, 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,18 @@ 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, /// 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/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/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 168e34ab..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::{KeyBinding, RadialMenuMouseBinding}; + 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| KeyBinding::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..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. @@ -121,6 +130,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 e74153c5..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::{KeyBinding, KeybindingsConfig}; + use crate::config::{KeybindingsConfig, Shortcut}; let mut bindings = KeybindingsConfig::default() .build_action_bindings() .expect("default bindings"); bindings.insert( Action::ToggleCommandPalette, - vec![KeyBinding::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::{KeyBinding, KeybindingsConfig}; + 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![KeyBinding::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 8a641b55..c3838699 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -1,26 +1,62 @@ use super::super::base::InputState; -use crate::config::KeyBinding; +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 { - for (binding, action) in &self.action_map { - if binding.matches( - key_str, - self.modifiers.ctrl, - self.modifiers.shift, - self.modifiers.alt, - ) { - return Some(*action); - } - } - None + 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(&shortcut).copied() + } + + pub(crate) fn find_trigger_action(&self, trigger: &ShortcutTrigger) -> Option { + self.action_map + .get(&Shortcut::Single(trigger.clone())) + .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 fn set_action_bindings(&mut self, action_bindings: HashMap>) { + 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>) { self.action_bindings = action_bindings; self.keymap_revision = self.keymap_revision.wrapping_add(1); } @@ -32,21 +68,81 @@ 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) + } + + /// 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() + .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); } @@ -57,7 +153,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..56d82702 --- /dev/null +++ b/src/input/state/core/utility/sequence.rs @@ -0,0 +1,324 @@ +//! 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; + } + // 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() { + 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) + } + + 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)] +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_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)]); + 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/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/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index 9b0c12f5..f561c1f3 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,22 @@ 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(); + if state.modifiers.shift + && let Some(fallback) = fallback_unshifted_label(&key_str) + { + return Ok(state.match_keyboard_chord_with_fallback(&key_str, fallback, is_repeat, now)); + } + + Ok(state.match_keyboard_chord(&key_str, is_repeat, now)) +} 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 630d51ef..b745f3cc 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -65,7 +65,7 @@ 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, Shortcut}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; use std::collections::HashMap; @@ -82,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 b533b583..6b50ed61 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, Shortcut}; 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(), + Shortcut::parse("Shift+F1").unwrap(), + Shortcut::parse("Shift+F1").unwrap(), + Shortcut::parse("F10").unwrap(), ], ); state.set_action_bindings(bindings); @@ -26,7 +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![KeyBinding::parse("Menu").unwrap()]); + bindings.insert(Action::ToggleHelp, vec![Shortcut::parse("Menu").unwrap()]); state.set_action_bindings(bindings); assert_eq!( @@ -53,8 +53,8 @@ fn action_binding_primary_label_prefers_first_explicit_binding() { bindings.insert( Action::ToggleStatusBar, vec![ - KeyBinding::parse("F4").unwrap(), - KeyBinding::parse("F12").unwrap(), + Shortcut::parse("F4").unwrap(), + Shortcut::parse("F12").unwrap(), ], ); state.set_action_bindings(bindings); @@ -65,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(); @@ -78,3 +93,191 @@ 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); +} + +#[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)); +} + +#[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); +} + +#[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()); +} 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); diff --git a/src/session/tests/helpers.rs b/src/session/tests/helpers.rs index cd013467..ac045adb 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, 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(KeyBinding::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/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/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 9ce6fcf2..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::{KeyBinding, 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![KeyBinding::parse("9").expect("binding")], + vec![Shortcut::parse("9").expect("binding")], )])); let changed = ToolbarSnapshot::from_input_with_bindings( &state, 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, + }) + ); +} diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index 7d574a5d..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, KeyBinding}; + 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![KeyBinding::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 75d55caf..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, KeyBinding}; + 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 459a3fb2..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::KeyBinding; + 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| KeyBinding::parse(value).expect("binding")) + .map(|value| Shortcut::parse(value).expect("binding")) .collect(), ) }) @@ -201,7 +201,7 @@ 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 = Shortcut::parse("Ctrl+Alt+Shift+O").unwrap().to_string(); assert_eq!( hints.binding_for_action(Action::OpenConfigurator),