From 70c84ec76c094a78e9b03cfca91e810519ca048d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:24:18 +0000 Subject: [PATCH 01/33] keymap: named shortcut sets with JSON export and import, and the shortcut editor model Profiles keeps the user's sets beside the app's built-in ones: switch, duplicate, rename, delete, export one set as JSON and import it back, and import another application's set through an import plan as a new set. The editor model holds an edit session with undo and cancel, conflict queries for steal or keep, a chord recorder with double press, and search. --- crates/keymap/README.md | 83 +++++- crates/keymap/src/editor.rs | 254 +++++++++++++++++++ crates/keymap/src/egui.rs | 3 +- crates/keymap/src/lib.rs | 16 +- crates/keymap/src/profiles.rs | 437 ++++++++++++++++++++++++++++++++ crates/keymap/tests/editor.rs | 200 +++++++++++++++ crates/keymap/tests/profiles.rs | 172 +++++++++++++ crates/keymap/tests/settings.rs | 31 ++- 8 files changed, 1181 insertions(+), 15 deletions(-) create mode 100644 crates/keymap/src/editor.rs create mode 100644 crates/keymap/src/profiles.rs create mode 100644 crates/keymap/tests/editor.rs create mode 100644 crates/keymap/tests/profiles.rs diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 81b55ec9..0d4bd1a0 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -1,9 +1,9 @@ # Craft Keymap `craft-keymap` holds the data and logic for keyboard bindings in the Craft apps: chords and their -text forms, bindings from chords to commands, scopes, conflicts, user overrides, and an import -pipeline for other applications' shortcut sets. Each application supplies its command catalogue, -its file-format readers and its UI. +text forms, bindings from chords to commands, scopes, conflicts, user overrides, named sets, an +import pipeline for other applications' shortcut sets, and an editor model. Each application +supplies its command catalogue, its file-format readers and its UI. ## Boundaries @@ -90,7 +90,7 @@ stay in the entry when the command is edited, unless the edit leaves it without `Keymap::problems` lists them. ```rust -use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; use craft_settings::{Location, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; @@ -99,6 +99,7 @@ use serde::{Deserialize, Serialize}; struct Prefs { #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + shortcut_sets: Profiles, } impl Settings for Prefs { @@ -130,6 +131,42 @@ store.update(|p| p.shortcuts = keymap.overrides().clone()); - `conflicts()` lists every pair of commands on one chord; `conflicts_with(&command)` lists the commands that share one command's chords, with that command as `a`. +## Shortcut sets + +`Profiles` holds the user's named sets of overrides and which one is active. The app's built-in +sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change. +Put `Profiles` next to the `Overrides` in the app's settings type. The sets, and each set's +overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file. + +- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). +- `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, + `rename` and `delete` work on user sets only. +- `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a + built-in set makes a user copy named after `copy_name`, which becomes active. +- New names are made unique (`Custom 2`, `Custom 3`) and compare without case. A user set named + like a built-in added later is renamed by `reconcile`. +- `export(name)` writes one set as JSON + (`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into + a new set. A file from a newer version is refused (`ProfileError::NewerVersion`). +- `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a + copy of the keymap and keeps the result as a new set, so another application's set becomes one + the user can switch to and away from. + +```rust +use craft_keymap::{Overrides, Profiles}; + +let mut classic = Overrides::new(); +classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +let mut sets = Profiles::default(); +assert_eq!(sets.active_name(&builtins), "Craft"); + +sets.duplicate("Classic", "Mine", &builtins).unwrap(); +let text = sets.export("Mine", &builtins).unwrap(); +assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +``` + ## Importing a shortcut set The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an @@ -199,6 +236,44 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. +## Editor + +`editor` is the model behind a shortcut dialog; the app draws it. + +- `EditSession::begin(&keymap)` when the dialog opens. Each change is one undo step: + `assign`, `replace` (record over one key of a command), `remove`, `clear`, `reset`, `reset_all`, + or any keymap edit through `session.edit(&mut keymap, |km| …)`. `undo`, `redo`, `cancel` (back + to the state at open) and `is_modified`. +- Conflicts: `editor::holders(&keymap, &command, &binding)` lists the commands that hold the key + while the user records; `Keymap::conflicts_with(&command)` marks the rows whose keys are + shared after a `Keep`. Assigning with `Policy::Refuse` returns `AssignError::Taken` and changes + nothing; the user's answer is a second call with `Policy::Steal` or `Policy::Keep`. +- `Recorder::press(chord, time)` gives `Single`, `Double` (the same chord again within + `DOUBLE_PRESS`, 0.6 s) or `Cancel` (Escape). +- `editor::search(&keymap, query, platform)` filters the catalogue by label, alias, menu path, + command id and key text, canonical or as shown on `platform`. + +```rust +use craft_keymap::editor::holders; +use craft_keymap::{AssignError, Binding, Chord, EditSession, Policy, Recorded, Recorder}; + +// `keymap` is the app's Keymap, `now` the input time in seconds. +let mut session = EditSession::begin(&keymap); +let mut recorder = Recorder::new(); +let save = "file.save".into(); +// The user presses Cmd+C in the shortcut field of Save. +if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { + let binding = Binding::new(trigger); + let _shown = holders(&keymap, &save, &binding); + if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { + // The user chose to take the key from Copy. + session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); + } +} +// OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +session.cancel(&mut keymap); +``` + ## Features - `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the diff --git a/crates/keymap/src/editor.rs b/crates/keymap/src/editor.rs new file mode 100644 index 00000000..2598552b --- /dev/null +++ b/crates/keymap/src/editor.rs @@ -0,0 +1,254 @@ +//! The model behind a shortcut editor: an edit session with undo, a chord recorder, conflict +//! queries and search. +//! +//! A shortcut dialog drives it like this: +//! +//! ``` +//! use craft_keymap::editor::holders; +//! use craft_keymap::{AssignError, Bindable, Binding, Chord, EditSession, Keymap, Platform, Policy, Recorded, Recorder, Scope, Scopes}; +//! +//! let catalogue = vec![ +//! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), +//! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), +//! ]; +//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::Mac); +//! let now = 12.5; +//! +//! let mut session = EditSession::begin(&keymap); +//! let mut recorder = Recorder::new(); +//! let save = "file.save".into(); +//! // The user presses Cmd+C in the shortcut field of Save. +//! if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { +//! let binding = Binding::new(trigger); +//! assert_eq!(holders(&keymap, &save, &binding)[0].command, "edit.copy".into()); +//! if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { +//! // The user chose to take the key from Copy. +//! session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); +//! } +//! } +//! assert!(keymap.bindings(&"edit.copy".into()).is_empty()); +//! // OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +//! session.cancel(&mut keymap); +//! assert_eq!(keymap.bindings(&"edit.copy".into()), [Binding::parse("Cmd+C").unwrap()]); +//! ``` + +use std::collections::VecDeque; + +use crate::chord::{Chord, Key, Platform, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{AssignError, Assigned, Bindable, Binding, Holder, Keymap, Overrides, Policy}; + +/// Undo steps an [`EditSession`] keeps; older ones are dropped. +pub const MAX_UNDO: usize = 500; +/// Most bytes of undo snapshots an [`EditSession`] keeps (keys and texts, plus a fixed estimate +/// per entry and per text for their allocations); older steps are dropped, though the latest one +/// always stays. +pub const MAX_UNDO_BYTES: usize = 64 << 20; + +/// Seconds within which a second press of the same chord counts as a double press. +pub const DOUBLE_PRESS: f64 = 0.6; + +/// One opening of a shortcut editor: the overrides it started from, and undo and redo as +/// snapshots of [`Overrides`]. +#[derive(Clone, Debug)] +pub struct EditSession { + start: Overrides, + /// Snapshots with their [`size`]. + undo: VecDeque<(Overrides, usize)>, + undo_bytes: usize, + redo: Vec, +} + +/// Heap bytes a snapshot entry costs beyond its strings' bytes: the key's `String`, the list and +/// the map node, estimated. +const ENTRY_OVERHEAD: usize = size_of::() + 64; +/// Bytes a text costs beyond its own: its `String` and allocator rounding, estimated. +const TEXT_OVERHEAD: usize = size_of::() + 16; + +/// The estimated bytes of a snapshot, so many short texts count as more than their length. +fn size(o: &Overrides) -> usize { + o.iter().map(|(k, texts)| ENTRY_OVERHEAD + k.len() + texts.iter().map(|t| TEXT_OVERHEAD + t.len()).sum::()).sum() +} + +impl EditSession { + /// Starts a session on `keymap`'s current overrides; [`cancel`](Self::cancel) returns to them. + pub fn begin(keymap: &Keymap) -> EditSession { + EditSession { start: keymap.overrides().clone(), undo: VecDeque::new(), undo_bytes: 0, redo: Vec::new() } + } + + /// Runs one edit. When it changes the overrides it becomes an undo step and clears redo. + pub fn edit(&mut self, keymap: &mut Keymap, f: impl FnOnce(&mut Keymap) -> R) -> R { + let before = keymap.overrides().clone(); + let out = f(keymap); + if *keymap.overrides() != before { + self.push_undo(before); + self.redo.clear(); + } + out + } + + /// Adds a binding as one undo step. With [`Policy::Refuse`] a chord other commands hold gives + /// [`AssignError::Taken`] and changes nothing, so the dialog can ask whether to steal or keep; + /// the answer is a second call with [`Policy::Steal`] or [`Policy::Keep`]. + pub fn assign(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: Binding, policy: Policy) -> Result { + self.edit(keymap, |km| km.assign(command, binding, policy)) + } + + /// Replaces one binding of a command with another as one undo step, as when the user records + /// over a key in the list. Conflicts follow `policy` as in [`EditSession::assign`]. + pub fn replace( + &mut self, + keymap: &mut Keymap, + command: &CommandRef, + old: &Binding, + new: Binding, + policy: Policy, + ) -> Result { + let list: Vec = keymap.bindings(command).into_iter().map(|b| if b == *old { new.clone() } else { b }).collect(); + self.edit(keymap, |km| km.set_bindings(command, list, policy)) + } + + /// Removes one binding of a command as one undo step. + pub fn remove(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: &Binding) -> bool { + self.edit(keymap, |km| km.remove(command, binding)) + } + + /// Leaves a command without bindings as one undo step. + pub fn clear(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.clear(command)) + } + + /// Gives a command its default bindings back as one undo step. + pub fn reset(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.reset(command)) + } + + /// Gives every command its defaults as one undo step. + pub fn reset_all(&mut self, keymap: &mut Keymap) { + self.edit(keymap, Keymap::reset_all); + } + + fn push_undo(&mut self, snapshot: Overrides) { + let bytes = size(&snapshot); + self.undo.push_back((snapshot, bytes)); + self.undo_bytes += bytes; + while self.undo.len() > 1 && (self.undo.len() > MAX_UNDO || self.undo_bytes > MAX_UNDO_BYTES) { + if let Some((_, b)) = self.undo.pop_front() { + self.undo_bytes -= b; + } + } + } + + /// Reverts the last edit; false when there is none. + pub fn undo(&mut self, keymap: &mut Keymap) -> bool { + let Some((previous, bytes)) = self.undo.pop_back() else { return false }; + self.undo_bytes -= bytes; + self.redo.push(keymap.overrides().clone()); + keymap.set_overrides(previous); + true + } + + /// Applies the last undone edit again; false when there is none. + pub fn redo(&mut self, keymap: &mut Keymap) -> bool { + let Some(next) = self.redo.pop() else { return false }; + self.push_undo(keymap.overrides().clone()); + keymap.set_overrides(next); + true + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// Restores the overrides the session started with and forgets its undo history. + pub fn cancel(&mut self, keymap: &mut Keymap) { + keymap.set_overrides(self.start.clone()); + self.undo.clear(); + self.undo_bytes = 0; + self.redo.clear(); + } + + /// Whether `keymap`'s overrides differ from those the session started with. + pub fn is_modified(&self, keymap: &Keymap) -> bool { + *keymap.overrides() != self.start + } +} + +/// The other commands whose bindings clash with `binding` given to `command`: what +/// [`Policy::Steal`] would take and [`Policy::Keep`] would leave clashing. Tools of the command's +/// group and twins sharing a default key are not listed. A dialog shows these while the user +/// records, before anything is assigned. +pub fn holders(keymap: &Keymap, command: &CommandRef, binding: &Binding) -> Vec { + keymap.clashing(command, std::slice::from_ref(binding)) +} + +/// What a press means to the recorder. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recorded { + Single(Trigger), + /// The chord just recorded, pressed again within [`DOUBLE_PRESS`]: it replaces that single. + Double(Trigger), + /// Escape without modifiers: stop recording. + Cancel, +} + +/// Turns the presses of a shortcut field into triggers. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + last: Option<(Chord, f64)>, +} + +impl Recorder { + pub fn new() -> Recorder { + Recorder::default() + } + + /// Records a press at `now_secs` (any monotonic clock, such as egui's `input.time`). + pub fn press(&mut self, chord: Chord, now_secs: f64) -> Recorded { + if chord.key == Key::Escape && chord.mods.is_none() { + self.last = None; + return Recorded::Cancel; + } + // After a double the next press starts over, so a third press is a single again. + if let Some((last, at)) = self.last.take() + && last == chord + && now_secs >= at + && now_secs - at < DOUBLE_PRESS + { + return Recorded::Double(Trigger::double(chord)); + } + self.last = Some((chord, now_secs)); + Recorded::Single(Trigger::single(chord)) + } + + /// Forgets the last press, so the next one cannot become a double. + pub fn reset(&mut self) { + self.last = None; + } +} + +/// The bindables matching `query`, in catalogue order: a case-insensitive substring of the label, +/// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display +/// text. An empty query matches everything. +pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> { + let q = query.trim().to_lowercase(); + keymap + .catalogue() + .iter() + .filter(|b| { + if q.is_empty() { + return true; + } + let hit = |s: &str| s.to_lowercase().contains(&q); + hit(&b.label) + || b.aliases.iter().any(|a| hit(a)) + || b.paths.iter().flatten().any(|s| hit(s)) + || hit(&b.command.id) + || keymap.bindings(&b.command).iter().any(|binding| hit(&binding.trigger.to_string()) || hit(&binding.trigger.display(platform))) + }) + .collect() +} diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fd4039d5..46511e29 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -135,8 +135,7 @@ fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { out } -/// Seconds within which a second press of the same chord counts as a double press. -pub const DOUBLE_PRESS: f64 = 0.6; +pub use crate::editor::DOUBLE_PRESS; /// A command a key press runs. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index bebf33f7..fd93f7bb 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,10 +6,11 @@ //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's //! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] -//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module -//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's -//! key events. +//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the +//! chords each operating system takes. [`import`] brings in other applications' shortcut sets. +//! [`editor`] is the model of a shortcut editor: undo, a chord recorder, conflict queries and +//! search. With the `egui` feature, the `egui` module converts to and from egui keys, matches key +//! presses against bindings and dispatches a frame's key events. //! //! ``` //! use craft_keymap::{Chord, Platform}; @@ -68,7 +69,7 @@ //! removed) stays removed after saving. //! //! ```no_run -//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; //! use craft_settings::{Location, Settings, SettingsStore}; //! use serde::{Deserialize, Serialize}; //! @@ -76,6 +77,7 @@ //! #[serde(default)] //! struct Prefs { //! shortcuts: Overrides, +//! shortcut_sets: Profiles, //! } //! //! impl Settings for Prefs { @@ -128,19 +130,23 @@ pub mod chord; pub mod command; +pub mod editor; #[cfg(feature = "egui")] pub mod egui; pub mod import; pub mod keymap; +pub mod profiles; pub mod reserved; pub mod scope; pub use chord::foreign::Flavor; pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; pub use command::{CommandRef, MAX_KEY}; +pub use editor::{DOUBLE_PRESS, EditSession, MAX_UNDO, MAX_UNDO_BYTES, Recorded, Recorder}; pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan}; pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, }; +pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs new file mode 100644 index 00000000..3f7e92f6 --- /dev/null +++ b/crates/keymap/src/profiles.rs @@ -0,0 +1,437 @@ +//! Named sets of overrides: the app's built-in sets (read only) and the user's own. +//! +//! ``` +//! use craft_keymap::{Overrides, Profiles}; +//! +//! let mut classic = Overrides::new(); +//! classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +//! let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +//! let mut sets = Profiles::default(); +//! assert_eq!(sets.active_name(&builtins), "Craft"); +//! +//! sets.duplicate("Classic", "Mine", &builtins).unwrap(); +//! let text = sets.export("Mine", &builtins).unwrap(); +//! assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +//! assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +//! ``` + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Value, json}; + +use crate::import::{ImportPlan, ImportReport}; +use crate::keymap::{Keymap, Overrides}; + +/// Most user sets. +pub const MAX_SETS: usize = 64; +/// Longest set name, in chars. +pub const MAX_SET_NAME: usize = 64; +/// Largest export document [`Profiles::import`] reads, in bytes. +pub const MAX_EXPORT_BYTES: usize = 4 << 20; + +const FORMAT: &str = "craft-keymap"; +const VERSION: u64 = 1; +const IMPORTED: &str = "Imported"; + +/// The app's built-in sets: name and overrides against the catalogue defaults. +pub type Builtins<'a> = [(&'a str, Overrides)]; + +/// Why a set operation was refused. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProfileError { + /// Empty, over [`MAX_SET_NAME`] chars, or with control characters. + BadName, + NameTaken, + NotFound, + /// Built-in sets cannot be changed, renamed or deleted. + BuiltIn, + /// [`MAX_SETS`] user sets exist. + TooMany, + /// Not a JSON export document. + BadFile, + /// A `format` other than `craft-keymap`. + WrongFormat, + /// A `version` newer than this crate reads. + NewerVersion(u64), + /// Over [`MAX_EXPORT_BYTES`]. + TooLarge, +} + +impl fmt::Display for ProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ProfileError::BadName => "bad set name", + ProfileError::NameTaken => "a set with this name exists", + ProfileError::NotFound => "no such set", + ProfileError::BuiltIn => "built-in sets are read only", + ProfileError::TooMany => "too many sets", + ProfileError::BadFile => "not a shortcut set file", + ProfileError::WrongFormat => "not a craft-keymap file", + ProfileError::NewerVersion(v) => return write!(f, "shortcut set file version {v} is newer than this app reads"), + ProfileError::TooLarge => "shortcut set file too large", + }; + f.write_str(s) + } +} + +impl std::error::Error for ProfileError {} + +/// The user's sets and which set is active. The built-in sets are passed to each call. +/// +/// An `active` name that matches no set means the first built-in set (or no overrides when the app +/// has none). +/// +/// A user set may carry a built-in's name, for instance after an update adds that built-in. Until +/// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call +/// `reconcile` after loading; every method that changes the profiles calls it first. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Profiles { + #[serde(deserialize_with = "lenient_name")] + pub active: String, + #[serde(serialize_with = "sets_as_list", deserialize_with = "capped_sets")] + pub sets: BTreeMap, +} + +static NO_OVERRIDES: Overrides = Overrides::new(); + +/// A name as given, trimmed; `None` when empty, too long or with control characters. +pub fn set_name(name: &str) -> Option { + let n = name.trim(); + let ok = !n.is_empty() && n.chars().count() <= MAX_SET_NAME && !n.chars().any(char::is_control); + ok.then(|| n.to_string()) +} + +impl Profiles { + /// Built-in names first, then the user's sets. + pub fn names(&self, builtins: &Builtins) -> Vec { + builtins.iter().map(|(n, _)| n.to_string()).chain(self.sets.keys().cloned()).collect() + } + + pub fn is_builtin(name: &str, builtins: &Builtins) -> bool { + builtins.iter().any(|(n, _)| *n == name) + } + + /// The name of the set in use. + pub fn active_name<'a>(&'a self, builtins: &'a Builtins) -> &'a str { + if self.sets.contains_key(&self.active) || Self::is_builtin(&self.active, builtins) { + &self.active + } else { + builtins.first().map_or("", |(n, _)| n) + } + } + + /// The overrides of the set in use, to hand to [`Keymap::set_overrides`](crate::Keymap::set_overrides). + pub fn active_overrides<'a>(&'a self, builtins: &'a Builtins) -> &'a Overrides { + self.get(self.active_name(builtins), builtins).unwrap_or(&NO_OVERRIDES) + } + + pub fn get<'a>(&'a self, name: &str, builtins: &'a Builtins) -> Option<&'a Overrides> { + self.sets.get(name).or_else(|| builtins.iter().find(|(n, _)| *n == name).map(|(_, o)| o)) + } + + /// Renames user sets whose name matches a built-in's (ignoring case) to a free name; `active` + /// follows. Returns whether anything was renamed. + pub fn reconcile(&mut self, builtins: &Builtins) -> bool { + let clashing: Vec = self.sets.keys().filter(|k| self.builtin_like(k, builtins)).cloned().collect(); + let mut renamed = false; + for old in clashing { + let Some(set) = self.sets.remove(&old) else { continue }; + // `unique` cannot fail here: a set was just removed, so there is room and the + // candidate names outnumber the sets. + let name = self.unique(&old, builtins).unwrap_or_else(|_| old.clone()); + if self.active == old { + self.active = name.clone(); + } + renamed |= name != old; + self.sets.insert(name, set); + } + renamed + } + + pub fn select(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if self.get(name, builtins).is_none() { + return Err(ProfileError::NotFound); + } + self.active = name.to_string(); + Ok(()) + } + + /// Saves edited overrides to the set in use. A built-in set is not changed: the edit goes to a + /// new set named after `copy_name` (made unique), which becomes active and is returned. The + /// new name is returned too when [`Profiles::reconcile`] renamed the set in use. + pub fn store(&mut self, overrides: Overrides, builtins: &Builtins, copy_name: &str) -> Result, ProfileError> { + let was = self.active_name(builtins).to_string(); + self.reconcile(builtins); + let active = self.active_name(builtins).to_string(); + if let Some(set) = self.sets.get_mut(&active) { + *set = overrides; + return Ok((active != was).then_some(active)); + } + if self.get(&active, builtins) == Some(&overrides) || (active.is_empty() && overrides.is_empty()) { + return Ok(None); + } + let base = set_name(copy_name).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins).map(Some) + } + + /// Saves `overrides` as a user set (replacing one of that name) and makes it active. + pub fn save_as(&mut self, name: &str, overrides: Overrides, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let name = set_name(name).ok_or(ProfileError::BadName)?; + if self.builtin_like(&name, builtins) { + return Err(ProfileError::BuiltIn); + } + if !self.sets.contains_key(&name) { + if self.sets.keys().any(|k| k.to_lowercase() == name.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + } + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + pub fn rename(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + if Self::is_builtin(from, builtins) { + return Err(ProfileError::BuiltIn); + } + let to = set_name(to).ok_or(ProfileError::BadName)?; + if !self.sets.contains_key(from) { + return Err(ProfileError::NotFound); + } + if to != from && (self.taken(&to, builtins) && to.to_lowercase() != from.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if let Some(set) = self.sets.remove(from) { + self.sets.insert(to.clone(), set); + } + if self.active == from { + self.active = to.clone(); + } + Ok(to) + } + + pub fn delete(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if Self::is_builtin(name, builtins) { + return Err(ProfileError::BuiltIn); + } + self.sets.remove(name).ok_or(ProfileError::NotFound)?; + if self.active == name { + self.active = builtins.first().map_or(String::new(), |(n, _)| n.to_string()); + } + Ok(()) + } + + /// Copies a set (built-in or user) to a new user set named `to` (made unique), which becomes + /// active. Returns its name. + pub fn duplicate(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let overrides = self.get(from, builtins).ok_or(ProfileError::NotFound)?.clone(); + let base = set_name(to).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins) + } + + /// A set as a JSON document: `{"format":"craft-keymap","version":1,"name":…,"overrides":…}`, + /// overrides as [`Overrides`] serializes them. + pub fn export(&self, name: &str, builtins: &Builtins) -> Result { + let overrides = self.get(name, builtins).ok_or(ProfileError::NotFound)?; + let doc = json!({"format": FORMAT, "version": VERSION, "name": name, "overrides": overrides}); + serde_json::to_string_pretty(&doc).map_err(|_| ProfileError::BadFile) + } + + /// Reads an exported set into a new user set (renamed when the name is taken) and makes it + /// active. Returns its name. + pub fn import(&mut self, text: &str, builtins: &Builtins) -> Result { + if text.len() > MAX_EXPORT_BYTES { + return Err(ProfileError::TooLarge); + } + let doc: Value = serde_json::from_str(text).map_err(|_| ProfileError::BadFile)?; + if doc.get("format").and_then(Value::as_str) != Some(FORMAT) { + return Err(ProfileError::WrongFormat); + } + match doc.get("version").and_then(Value::as_u64) { + Some(VERSION) => {} + Some(v) if v > VERSION => return Err(ProfileError::NewerVersion(v)), + _ => return Err(ProfileError::BadFile), + } + let overrides = match doc.get("overrides") { + Some(v @ (Value::Object(_) | Value::Array(_))) => Overrides::deserialize(v).map_err(|_| ProfileError::BadFile)?, + None => Overrides::new(), + Some(_) => return Err(ProfileError::BadFile), + }; + self.reconcile(builtins); + let base = doc.get("name").and_then(Value::as_str).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + self.add(&base, overrides, builtins) + } + + /// Applies an import plan to a copy of `keymap` (the keymap the plan was made from) and keeps + /// the resulting overrides as a new user set, which becomes active; `keymap` is not changed. + /// The set is named `name`, else the foreign set's name, else `Imported`, made unique. + /// Returns its name and the report of [`ImportPlan::apply`]. + pub fn import_plan( + &mut self, + plan: &ImportPlan, + keymap: &Keymap, + name: Option<&str>, + builtins: &Builtins, + ) -> Result<(String, ImportReport), ProfileError> { + self.reconcile(builtins); + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + let mut copy = keymap.clone(); + let report = plan.apply(&mut copy); + let base = name.or(plan.report.set.as_deref()).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + let name = self.add(&base, copy.overrides().clone(), builtins)?; + Ok((name, report)) + } + + /// Adds a user set under a unique name from `base` and makes it active. + fn add(&mut self, base: &str, overrides: Overrides, builtins: &Builtins) -> Result { + let name = self.unique(base, builtins)?; + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + fn builtin_like(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + builtins.iter().any(|(n, _)| n.to_lowercase() == lower) + } + + fn taken(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower) + } + + /// `base`, or `base 2`, `base 3`… cut to fit [`MAX_SET_NAME`]. + fn unique(&self, base: &str, builtins: &Builtins) -> Result { + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + if !self.taken(base, builtins) { + return Ok(base.to_string()); + } + // `Custom 2` taken: try `Custom 3`, not `Custom 2 2`. + let base = match base.rsplit_once(' ') { + Some((stem, n)) if !stem.is_empty() && (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()) => stem, + _ => base, + }; + for n in 2..(MAX_SETS + builtins.len() + 3) { + let suffix = format!(" {n}"); + let keep = MAX_SET_NAME.saturating_sub(suffix.chars().count()); + let name: String = base.chars().take(keep).collect::().trim_end().to_string() + &suffix; + if !self.taken(&name, builtins) { + return Ok(name); + } + } + Err(ProfileError::NameTaken) + } +} + +fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let v = Value::deserialize(deserializer)?; + Ok(v.as_str().and_then(set_name).unwrap_or_default()) +} + +// An array of `{ name, overrides }` rather than a table, and each set's overrides in their array +// form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the +// file. +fn sets_as_list(sets: &BTreeMap, serializer: S) -> Result { + #[derive(Serialize)] + struct Set<'a> { + name: &'a str, + #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] + overrides: &'a Overrides, + } + serializer.collect_seq(sets.iter().map(|(name, overrides)| Set { name, overrides })) +} + +fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + deserializer.deserialize_any(SetsVisitor) +} + +struct SetsVisitor; + +// Anything other than a list or a map loads as no sets, so the rest of `Profiles` still loads. +macro_rules! no_sets { + ($($name:ident: $t:ty),*) => { + $(fn $name(self, _: $t) -> Result { + Ok(BTreeMap::new()) + })* + }; +} + +impl<'de> Visitor<'de> for SetsVisitor { + type Value = BTreeMap; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides") + } + + no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); + + fn visit_unit(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_none(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(SetsVisitor) + } + + fn visit_seq>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(item) = access.next_element::()? { + let Some(name) = item.get("name").and_then(Value::as_str) else { continue }; + if !admits(&out, name) { + continue; + } + if let Some(o) = item.get("overrides").and_then(overrides_of) { + out.insert(name.to_string(), o); + } + } + Ok(out) + } + + fn visit_map>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(key) = access.next_key::()? { + if admits(&out, &key) { + if let Some(o) = overrides_of(&access.next_value::()?) { + out.insert(key, o); + } + } else { + access.next_value::()?; + } + } + Ok(out) + } +} + +/// A valid name, stored as written, with room under [`MAX_SETS`]. +fn admits(sets: &BTreeMap, name: &str) -> bool { + set_name(name).is_some_and(|n| n == name) && (sets.len() < MAX_SETS || sets.contains_key(name)) +} + +/// A set's overrides: a list or a map, anything else is no set. +fn overrides_of(v: &Value) -> Option { + match v { + Value::Object(_) | Value::Array(_) => Overrides::deserialize(v).ok(), + _ => None, + } +} diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..b33c9045 --- /dev/null +++ b/crates/keymap/tests/editor.rs @@ -0,0 +1,200 @@ +use craft_keymap::editor::{MAX_UNDO_BYTES, holders, search}; +use craft_keymap::{ + AssignError, Bindable, Binding, Chord, CommandRef, DOUBLE_PRESS, EditSession, Holder, Keymap, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, + MAX_UNDO, Overrides, Platform, Policy, Recorded, Recorder, Scope, Scopes, Trigger, +}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap(platform: Platform) -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), + Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), + Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), + ]; + Keymap::new(catalogue, Scopes::new(), platform) +} + +fn keys(km: &Keymap, id: &str) -> Vec { + km.bindings(&CommandRef::new(id)).iter().map(|b| b.to_string()).collect() +} + +#[test] +fn edits_undo_redo_and_cancel() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + + let assigned = session.edit(&mut km, |km| km.assign(&save, b("Cmd+Shift+S"), Policy::Steal)); + assert!(assigned.is_ok()); + session.edit(&mut km, |km| km.clear(&CommandRef::new("edit.copy"))); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + assert!(keys(&km, "edit.copy").is_empty()); + assert!(session.is_modified(&km)); + + // An edit that changes nothing is no undo step. + session.edit(&mut km, |km| km.reset(&CommandRef::new("tool.brush"))); + + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert!(!session.undo(&mut km)); + assert!(!session.is_modified(&km)); + + assert!(session.redo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + // A new edit drops what could be redone. + session.edit(&mut km, |km| km.clear(&CommandRef::new("tool.brush"))); + assert!(!session.redo(&mut km)); + + session.cancel(&mut km); + assert!(!session.is_modified(&km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert_eq!(keys(&km, "tool.brush"), ["B"]); + assert!(!session.can_undo()); +} + +#[test] +fn undo_keeps_the_newest_steps() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let brush = CommandRef::new("tool.brush"); + // Each edit changes the key: N, B, N, … (B is the default). + let edits = MAX_UNDO + 11; + for i in 0..edits { + let key = if i % 2 == 0 { "N" } else { "B" }; + assert!(session.edit(&mut km, |km| km.set_bindings(&brush, vec![b(key)], Policy::Keep)).is_ok()); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert_eq!(steps, MAX_UNDO); + // The 11 oldest steps are gone: undo stops after the 11th edit, which set N. + assert_eq!(keys(&km, "tool.brush"), ["N"]); + assert!(session.is_modified(&km)); +} + +#[test] +fn undo_history_is_capped_by_size() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + // Full-size snapshots of entries for commands the catalogue does not have. + let big = |tag: char| { + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS])); + } + o + }; + let (a, b) = (big('a'), big('b')); + let string = size_of::(); + let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::()).sum::(); + let edits = 2 * MAX_UNDO_BYTES / per + 4; + for n in 0..edits { + let next = if n % 2 == 0 { a.clone() } else { b.clone() }; + session.edit(&mut km, |km| km.set_overrides(next)); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert!(steps >= 2 && steps <= MAX_UNDO_BYTES / per, "{steps} steps of {per} bytes"); +} + +#[test] +fn recorder_turns_presses_into_triggers() { + let mut r = Recorder::new(); + let a = chord("A"); + assert_eq!(r.press(a, 1.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 1.0 + DOUBLE_PRESS / 2.0), Recorded::Double(Trigger::double(a))); + // The press after a double starts over. + assert_eq!(r.press(a, 1.4), Recorded::Single(Trigger::single(a))); + // Too late for a double. + assert_eq!(r.press(a, 1.4 + DOUBLE_PRESS + 0.1), Recorded::Single(Trigger::single(a))); + // Another chord in between. + assert_eq!(r.press(chord("B"), 3.0), Recorded::Single(Trigger::single(chord("B")))); + assert_eq!(r.press(a, 3.1), Recorded::Single(Trigger::single(a))); + + assert_eq!(r.press(chord("Escape"), 3.2), Recorded::Cancel); + let shift_escape = chord("Shift+Escape"); + assert_eq!(r.press(shift_escape, 3.3), Recorded::Single(Trigger::single(shift_escape))); + // Two presses in one egui frame share its time. + assert_eq!(r.press(a, 4.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 4.0), Recorded::Double(Trigger::double(a))); +} + +#[test] +fn search_reads_labels_aliases_paths_ids_and_keys() { + let ids = |km: &Keymap, q: &str, p: Platform| search(km, q, p).iter().map(|b| b.command.id.clone()).collect::>(); + let win = keymap(Platform::Windows); + let mac = keymap(Platform::Mac); + assert_eq!(ids(&win, "zoom", Platform::Windows), ["view.zoomIn"]); + assert_eq!(ids(&win, "KOPIEREN", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&win, "paint", Platform::Windows), ["tool.brush"]); + assert_eq!(ids(&win, "file.sa", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "ctrl+s", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "cmd+c", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&mac, "⌘s", Platform::Mac), ["file.save"]); + assert_eq!(ids(&mac, "⌘s", Platform::Windows), Vec::::new()); + assert_eq!(ids(&mac, " ", Platform::Mac).len(), 4); +} + +#[test] +fn a_dialog_asks_before_stealing_and_undoes_each_answer() { + let mut km = keymap(Platform::Mac); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + let copy = CommandRef::new("edit.copy"); + let held = vec![Holder { command: copy.clone(), binding: b("Cmd+C") }]; + + // While recording: who else has the key. + assert_eq!(holders(&km, &save, &b("Cmd+C")), held); + assert!(holders(&km, ©, &b("Cmd+C")).is_empty(), "its own key"); + assert_eq!(session.assign(&mut km, &save, b("Cmd+C"), Policy::Refuse), Err(AssignError::Taken(held.clone()))); + assert!(!session.can_undo(), "a refused assignment changes nothing"); + + // Keep: both hold it. + let kept = session.assign(&mut km, &save, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(kept.clashes, held); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + + // Steal: the other command loses it. + let stolen = session.assign(&mut km, &save, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(stolen.taken_from, held); + assert!(keys(&km, "edit.copy").is_empty()); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + + // Recording over one key of the list replaces it in place. + session.replace(&mut km, &save, &b("Cmd+S"), b("Cmd+Shift+S"), Policy::Refuse).unwrap(); + assert_eq!(keys(&km, "file.save"), ["Cmd+Shift+S", "Cmd+C"]); + assert!(session.remove(&mut km, &save, &b("Cmd+C"))); + assert!(session.clear(&mut km, &CommandRef::new("tool.brush"))); + + assert!(session.reset(&mut km, &save)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + session.reset_all(&mut km); + assert!(km.overrides().is_empty()); + assert!(!session.is_modified(&km)); + + // One undo per action, back to the steal. + for _ in 0..5 { + assert!(session.undo(&mut km)); + } + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + assert!(session.undo(&mut km)); + assert!(!session.can_undo()); + + session.redo(&mut km); + session.cancel(&mut km); + assert!(km.overrides().is_empty(), "cancel restores the state at open"); +} diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..84be8f09 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,172 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, Start, plan, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap() -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), + Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Mac) +} + +#[test] +fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let mut edited = Overrides::new(); + edited.insert("edit.copy", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into()))); + assert_eq!(p.active_name(&builtins), "Custom"); + assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place"); + assert!(p.active_overrides(&builtins).is_empty()); + + p.select("Classic", &builtins).unwrap(); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy"); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into()))); + assert_eq!(builtins[1].1, classic); + + let exported = p.export("Custom 2", &builtins).unwrap(); + assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into())); + assert_eq!(p.active_overrides(&builtins), &edited); + + assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); + assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); + assert_eq!(p.active, "Mine"); + assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); + p.delete("Mine", &builtins).unwrap(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let wrong = exported.replace("craft-keymap", "other"); + assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat)); + let newer = exported.replace("\"version\": 1", "\"version\": 2"); + assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2))); + + let text = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); + assert_eq!(junk.sets.keys().collect::>(), ["ok"]); + let junk: Profiles = + serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); + assert_eq!(junk.sets["ok"].get("a"), Some(&["A".to_string()][..])); + assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); +} + +#[test] +fn sets_that_are_not_a_list_or_map_load_as_none() { + for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { + let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(p.sets.is_empty(), "{text}"); + assert_eq!(p.active, "Mine", "{text}"); + } +} + +#[test] +fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { + let mut mine = Overrides::new(); + mine.insert("edit.copy", vec!["Cmd+K".into()]); + let mut p = Profiles::default(); + p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap(); + + // An update adds a built-in of that name. + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads"); + + let mut edited = mine.clone(); + edited.insert("edit.paste", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into()))); + assert_eq!(p.active_name(&builtins), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &edited); + assert_eq!(p.get("Classic", &builtins), Some(&classic)); + assert_eq!(builtins[1].1, classic); + + let mut q = Profiles::default(); + q.sets.insert("CRAFT".into(), mine.clone()); + q.active = "CRAFT".into(); + assert!(q.reconcile(&builtins)); + assert_eq!(q.active_name(&builtins), "CRAFT 2"); + assert_eq!(q.active_overrides(&builtins), &mine); + assert!(!q.reconcile(&builtins)); +} + +#[test] +fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { + let mut km = keymap(); + km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap(); + km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + p.save_as("Mine", km.overrides().clone(), &builtins).unwrap(); + + let text = p.export("Mine", &builtins).unwrap(); + assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); + assert_eq!(p.active, "Mine 2"); + + let mut again = keymap(); + again.set_overrides(p.active_overrides(&builtins).clone()); + for bindable in km.catalogue() { + assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); + } + assert_eq!(again.overrides(), km.overrides()); +} + +#[test] +fn duplicate_copies_built_in_and_user_sets() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + + assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); + assert_eq!(p.active, "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); + assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); + assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); + assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); + + while p.sets.len() < MAX_SETS { + p.duplicate("Craft", "Spare", &builtins).unwrap(); + } + assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); +} + +#[test] +fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { + let km = keymap(); + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); + let set = ForeignSet { name: Some("Other App".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; + let planned = plan(&set, &km, &options); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + + let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap(); + assert_eq!(name, "Other App"); + assert_eq!(report.changed.len(), 1); + assert!(km.overrides().is_empty()); + assert_eq!(p.active, "Other App"); + let mut imported = keymap(); + imported.set_overrides(p.active_overrides(&builtins).clone()); + assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); + + let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); + assert_eq!(name, "Craft 2", "a built-in's name is not reused"); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index 89c744bd..6d2351b6 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Scope, Scopes}; +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Profiles, Scope, Scopes}; use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -11,6 +11,7 @@ struct Prefs { general: General, #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + profiles: Profiles, } #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] @@ -52,7 +53,11 @@ fn overrides_round_trip_through_craft_settings() { km.clear(&"edit.copy".into()); let mut store = open(tmp.path()); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.save_as("Mine", km.overrides().clone(), &[]).unwrap(); + p.profiles.save_as("Spare", Overrides::new(), &[]).unwrap(); + }); store.flush().unwrap(); // A hand edit: one text that does not parse, one value that is not a list. @@ -72,6 +77,7 @@ fn overrides_round_trip_through_craft_settings() { let problems = km.problems(); assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + assert_eq!(store.get().profiles.sets.len(), 2); store.update(|p| p.general.language = "de".into()); store.flush().unwrap(); @@ -85,13 +91,18 @@ fn overrides_round_trip_through_craft_settings() { // Removals reach the file. km.reset(&layers); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.delete("Spare", &[]).unwrap(); + }); store.flush().unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); - assert!(!line.contains("window.panel"), "{text}"); + assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); let reloaded = open(tmp.path()); assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); + assert_eq!(reloaded.get().profiles.sets.keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); } #[test] @@ -121,3 +132,15 @@ fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); } } + +#[test] +fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert_eq!(store.get().profiles.active, "Mine", "{value}"); + assert!(store.get().profiles.sets.is_empty(), "{value}"); + } +} From b9bd4119cce2fb050d3475791587648a015e2f5b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:42:38 +0000 Subject: [PATCH 02/33] keymap: shortcuts dialog model behind the ui feature DialogState with every dialog action as a call on EditSession, Recorder, editor::holders and Profiles; the table rows (areas from paths, kind and scope, filters, search by label, translation or chord, grouping, sorting, a row cache); key capture while recording, including clipboard events, which removes the events it takes; the Text trait, I18nText and the list of strings the dialog looks up. Drawing comes separately. --- Cargo.lock | 3 + crates/keymap/Cargo.toml | 6 + crates/keymap/src/chord.rs | 4 +- crates/keymap/src/egui.rs | 6 +- crates/keymap/src/lib.rs | 2 + crates/keymap/src/ui/capture.rs | 95 +++ crates/keymap/src/ui/mod.rs | 281 +++++++ crates/keymap/src/ui/rows.rs | 628 +++++++++++++++ crates/keymap/src/ui/state.rs | 1305 +++++++++++++++++++++++++++++++ crates/keymap/src/ui/text.rs | 283 +++++++ 10 files changed, 2608 insertions(+), 5 deletions(-) create mode 100644 crates/keymap/src/ui/capture.rs create mode 100644 crates/keymap/src/ui/mod.rs create mode 100644 crates/keymap/src/ui/rows.rs create mode 100644 crates/keymap/src/ui/state.rs create mode 100644 crates/keymap/src/ui/text.rs diff --git a/Cargo.lock b/Cargo.lock index 7ae3ad37..76a76163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -622,8 +622,11 @@ dependencies = [ name = "craft-keymap" version = "0.1.0" dependencies = [ + "craft-i18n", "craft-settings", + "craft-ui", "egui", + "egui_kittest", "serde", "serde_json", "tempfile", diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 0e5e80f8..0363ae7b 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -14,10 +14,16 @@ workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" egui = { workspace = true, optional = true } +craft-ui = { workspace = true, optional = true } +craft-i18n = { workspace = true, optional = true } [dev-dependencies] craft-settings = { workspace = true } tempfile = "3" +egui = { workspace = true, features = ["default_fonts"] } +egui_kittest = "0.36" [features] egui = ["dep:egui"] +# The Keyboard Shortcuts dialog. +ui = ["egui", "dep:craft-ui", "dep:craft-i18n"] diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 740ccf21..b94357eb 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -178,7 +178,7 @@ impl Key { } /// The key as macOS menus show it: arrows and editing keys as glyphs. - fn mac_symbol(self) -> &'static str { + pub(crate) fn mac_symbol(self) -> &'static str { match self { Key::Up => "↑", Key::Down => "↓", @@ -194,7 +194,7 @@ impl Key { } /// The key as Windows and Linux show it. - fn text_name(self) -> &'static str { + pub(crate) fn text_name(self) -> &'static str { match self { Key::PageUp => "Page Up", Key::PageDown => "Page Down", diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 46511e29..a1eb5fc4 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -321,7 +321,7 @@ impl Dispatcher { /// A clipboard event: Copy, Cut or Paste. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Clip { +pub(crate) enum Clip { Copy, Cut, Paste, @@ -400,7 +400,7 @@ fn event_clip(event: &Event) -> Option { } /// The clipboard event a backend sends for a press, if any. -fn clipboard_kind(key: Key, held: Mods) -> Option { +pub(crate) fn clipboard_kind(key: Key, held: Mods) -> Option { match key { Key::C if held.cmd => Some(Clip::Copy), Key::X if held.cmd => Some(Clip::Cut), @@ -414,7 +414,7 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { /// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off /// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { +pub(crate) fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { let with_cmd = Mods { cmd: true, ..held }; let shift_only = held.shift && !held.cmd; match event { diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index fd93f7bb..a54f0756 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -138,6 +138,8 @@ pub mod keymap; pub mod profiles; pub mod reserved; pub mod scope; +#[cfg(feature = "ui")] +pub mod ui; pub use chord::foreign::Flavor; pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; diff --git a/crates/keymap/src/ui/capture.rs b/crates/keymap/src/ui/capture.rs new file mode 100644 index 00000000..a5fef16f --- /dev/null +++ b/crates/keymap/src/ui/capture.rs @@ -0,0 +1,95 @@ +//! Taking a frame's key input while the dialog records, before the app's dispatcher or egui's +//! widgets see it. + +use ::egui::{Event, EventFilter, Id, InputState, Ui}; + +use crate::chord::{Chord, Key, Mods, Platform}; +use crate::egui::{Clip, clipboard_chord, clipboard_kind}; + +/// The keys a recording widget keeps from egui's focus navigation, so Tab, arrows and Escape are +/// recorded instead of moving focus. +pub(crate) const FOCUS_LOCK: EventFilter = EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }; + +/// Holds egui's focus on `id` with [`FOCUS_LOCK`]; call each frame the widget records. +pub(crate) fn lock_focus(ui: &Ui, id: Id) { + ui.memory_mut(|m| m.set_focus_lock_filter(id, FOCUS_LOCK)); +} + +/// Removes every key, text, IME and clipboard event from `input` and returns the chords pressed, +/// in order. Repeats, releases and modifier-only presses give no chord. Copy, Cut and Paste +/// count as the presses that cause them (Cmd+C, Cmd+X, Cmd+V, or Shift+Delete and Shift+Insert), +/// once when a backend sends both the press and the event. +pub(crate) fn take(input: &mut InputState, platform: Platform) -> Vec { + let frame_mods = Mods::from_egui(&input.modifiers, platform); + let mut chords = Vec::new(); + let mut kept = Vec::with_capacity(input.events.len()); + let mut clipboard_press: Option = None; + for event in std::mem::take(&mut input.events) { + match &event { + Event::Key { key, pressed, repeat, modifiers, .. } => { + let Some(key) = Key::from_egui(*key) else { continue }; + if *pressed && !*repeat { + let held = Mods::from_egui(modifiers, platform); + if let Some(clip) = clipboard_kind(key, held) { + clipboard_press = Some(clip); + } + chords.push(Chord::new(held, key)); + } + } + Event::Copy | Event::Cut | Event::Paste(_) => { + let (key, held, clip) = clipboard_chord(&event, frame_mods); + if clipboard_press == Some(clip) { + clipboard_press = None; + } else { + chords.push(Chord::new(held, key)); + } + } + Event::Text(_) | Event::Ime(_) => {} + _ => kept.push(event), + } + } + input.events = kept; + chords +} + +#[cfg(test)] +mod tests { + use ::egui::{Context, Key as EguiKey, Modifiers, RawInput}; + + use super::*; + + fn run(events: Vec) -> (Vec, usize) { + let ctx = Context::default(); + let mut out = (Vec::new(), 0); + let mut full = ctx.run_ui(RawInput { events, ..RawInput::default() }, |ui| { + let chords = ui.ctx().input_mut(|i| take(i, Platform::Mac)); + out = (chords.iter().map(Chord::to_string).collect(), ui.ctx().input(|i| i.events.len())); + }); + full.textures_delta.clear(); + out + } + + fn press(key: EguiKey, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + #[test] + fn takes_presses_and_clipboard_events_and_leaves_nothing() { + let cmd = Modifiers::MAC_CMD; + let events = vec![ + press(EguiKey::E, cmd), + Event::Text("e".into()), + Event::Key { key: EguiKey::E, physical_key: None, pressed: false, repeat: false, modifiers: cmd }, + press(EguiKey::Tab, Modifiers::NONE), + // The web build: the press, then the event it causes, counted once. + press(EguiKey::C, cmd), + Event::Copy, + // egui-winit: only the event. + Event::Paste("x".into()), + Event::PointerGone, + ]; + let (chords, left) = run(events); + assert_eq!(chords, ["Cmd+E", "Tab", "Cmd+C", "Cmd+V"]); + assert_eq!(left, 1, "only the pointer event stays"); + } +} diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs new file mode 100644 index 00000000..038d36ea --- /dev/null +++ b/crates/keymap/src/ui/mod.rs @@ -0,0 +1,281 @@ +//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalogue with recording, +//! conflicts, undo, named sets and import review, over [`Keymap`] and optionally [`Profiles`]. +//! +//! The app keeps a [`DialogState`] while the dialog is open and calls [`ShortcutsDialog::show`] +//! (or [`ShortcutsDialog::ui`] inside its own container) every frame. While +//! [`DialogState::is_open`] is true the app skips its own key dispatch; the dialog takes the +//! frame's key events itself while it records. The app saves on [`Outcome::Ok`] (or on +//! [`Outcome::Changed`] for live saving) and drops the state once it is closed. + +use std::collections::BTreeMap; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::import::{ForeignSet, ImportOptions}; +use crate::keymap::{Bindable, Binding, Keymap}; +use crate::profiles::{Builtins, ProfileError, Profiles}; + +// The view draws with these; until it calls every action some stay unused. +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod capture; +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod rows; +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod state; +mod text; + +pub use state::DialogState; +pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text}; + +use state::Env; + +/// What the dialog reports for a frame. +#[derive(Clone, Debug)] +pub enum Outcome { + /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. + Ok, + /// Closed with Cancel: the keymap and profiles are back as they were when it opened. + Cancel, + /// An edit this frame (for live menus and live saving). + Changed, + /// The user asked to import: the app opens a file of one of these formats and calls + /// [`ShortcutsDialog::import_json`] or [`ShortcutsDialog::review_import`]. + Import { formats: Vec<&'static str> }, + /// A set as craft-keymap JSON, for the app to write. + Export { name: String, json: String }, + /// A set in one of [`Options::export_formats`], for the app to write. + ExportAs { format: &'static str, name: String, keymap: Box }, + /// The shortcut summary, for the app to show, save or print. + Summary { name: String, html: String, text: String }, + /// Import answers the user gave (item → command), for the app to keep for later imports. + Remembered(BTreeMap), +} + +/// What happens when a recorded chord is held by other commands. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ConflictMode { + /// Ask with the conflict bar: Take It, Take It and Go to Conflict, Keep Both, Cancel. + #[default] + Ask, + /// Take the chord from the other commands, then say so. + Steal, + /// Keep both bindings, then say so. + Keep, +} + +/// An app's own Show entry. +#[derive(Clone, Copy, Debug)] +pub struct Area { + pub id: &'static str, + pub label: &'static str, + pub rows: fn(&Bindable) -> bool, +} + +/// A read-only row for a key outside the catalogue. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixedRow { + pub group: String, + pub label: String, + /// The keys as shown. + pub keys: String, + /// The trigger, when the row has one, so a catalogue binding on it can be pointed out. + pub chord: Option, +} + +/// A file format an app reads or writes itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Format { + pub id: &'static str, + pub label: &'static str, + pub extensions: &'static [&'static str], +} + +/// The dialog's colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DialogStyle { + pub accent: egui::Color32, + pub warning: egui::Color32, + pub dim: egui::Color32, + pub changed: egui::Color32, + pub app_key: egui::Color32, + pub scoped_key: egui::Color32, + pub both_key: egui::Color32, + pub modifier_key: egui::Color32, + pub free_key: egui::Color32, +} + +impl DialogStyle { + pub fn from_visuals(v: &egui::Visuals) -> DialogStyle { + let accent = v.selection.bg_fill; + DialogStyle { + accent, + warning: v.warn_fg_color, + dim: v.weak_text_color(), + changed: accent, + app_key: accent, + scoped_key: v.warn_fg_color, + both_key: v.error_fg_color, + modifier_key: v.widgets.active.bg_fill, + free_key: v.widgets.inactive.bg_fill, + } + } +} + +/// A rule an app adds for which bindings a command may get; `Err` carries the message shown. +pub type Accept = Box Result<(), String>>; +/// Called for every control once per frame with (automation id, rect, label). +pub type OnWidget = Box; + +/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field. +pub struct Options { + /// "Keyboard Shortcuts". + pub title: &'static str, + /// true; false shows the table read-only with Close and Summarize. + pub editable: bool, + /// "Custom": the name edits of a built-in set are saved under. + pub custom_set_name: &'static str, + /// Ask. + pub conflicts: ConflictMode, + /// true: Keep Both is offered. + pub allow_keep: bool, + /// false: a recorded chord waits [`DOUBLE_PRESS`](crate::DOUBLE_PRESS) for a second press. + pub double_press: bool, + /// false: Backspace or Delete without modifiers removes the binding being recorded over. + pub backspace_clears: bool, + /// false: Enter when idle is OK. + pub enter_is_ok: bool, + /// false: rows sort by translated label instead of catalogue order. + pub sort_by_label: bool, + /// false: Show as tabs instead of a dropdown. + pub area_tabs: bool, + /// The app's own Show entries. + pub areas: Vec, + /// false: the on-screen keyboard pane. + pub keyboard: bool, + pub fixed: Vec, + pub import_formats: Vec, + pub export_formats: Vec, + /// true: Summarize is offered. + pub summary: bool, + pub accept: Option, + pub on_widget: Option, + /// `None`: [`DialogStyle::from_visuals`]. + pub style: Option, +} + +impl Default for Options { + fn default() -> Options { + Options { + title: "Keyboard Shortcuts", + editable: true, + custom_set_name: "Custom", + conflicts: ConflictMode::Ask, + allow_keep: true, + double_press: false, + backspace_clears: false, + enter_is_ok: false, + sort_by_label: false, + area_tabs: false, + areas: Vec::new(), + keyboard: false, + fixed: Vec::new(), + import_formats: Vec::new(), + export_formats: Vec::new(), + summary: true, + accept: None, + on_widget: None, + style: None, + } + } +} + +/// The format id of craft-keymap's own JSON in [`Outcome::Import`]. +pub const JSON_FORMAT: &str = "craft-keymap"; + +/// The dialog for one frame: what it edits, borrowed, and how it looks. +pub struct ShortcutsDialog<'a> { + keymap: &'a mut Keymap, + text: &'a dyn Text, + sets: Option<(&'a mut Profiles, &'a Builtins<'a>)>, + options: Option<&'a Options>, +} + +impl<'a> ShortcutsDialog<'a> { + pub fn new(keymap: &'a mut Keymap, text: &'a dyn Text) -> Self { + ShortcutsDialog { keymap, text, sets: None, options: None } + } + + /// Shows and edits named sets. + pub fn sets(mut self, profiles: &'a mut Profiles, builtins: &'a Builtins<'a>) -> Self { + self.sets = Some((profiles, builtins)); + self + } + + pub fn options(mut self, options: &'a Options) -> Self { + self.options = Some(options); + self + } + + fn with_env(self, f: impl FnOnce(&mut Env) -> R) -> R { + let fallback; + let options = match self.options { + Some(o) => o, + None => { + fallback = Options::default(); + &fallback + } + }; + let mut env = Env { keymap: self.keymap, sets: self.sets, options, text: self.text }; + f(&mut env) + } + + /// A window with title, body and Cancel/OK. + pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { + let title = self.text.ui(self.options.map_or("Keyboard Shortcuts", |o| o.title)).into_owned(); + egui::Window::new(title).collapsible(false).show(ctx, |ui| self.ui(ui, state)).and_then(|r| r.inner).flatten() + } + + /// The body only, for a host's own modal, preferences page or backstage. + pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { + self.with_env(|env| { + state.begin(env); + state.input(env, ui.ctx()); + state.finish() + }) + } + + /// OK, for hosts that draw their own button. + pub fn ok(self, state: &mut DialogState) { + self.with_env(|env| { + state.begin(env); + state.ok(env); + }); + } + + /// Cancel, for hosts that draw their own button. + pub fn cancel(self, state: &mut DialogState) { + self.with_env(|env| { + state.begin(env); + state.cancel(env); + }); + } + + /// Reads a craft-keymap JSON set the app loaded after [`Outcome::Import`]. With sets it becomes + /// a new set, which is loaded; without, its overrides replace the keymap's as one undo step. + /// Returns the set's name. + pub fn import_json(self, state: &mut DialogState, text: &str) -> Result { + self.with_env(|env| { + state.begin(env); + state.import_json(env, text) + }) + } + + /// Opens the review of a foreign set the app read: what will change, what did not match and + /// why, with suggestions to pick from. + pub fn review_import(self, state: &mut DialogState, set: ForeignSet, options: ImportOptions) { + self.with_env(|env| { + state.begin(env); + state.review_import(env, set, options); + }); + } +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs new file mode 100644 index 00000000..1bc13261 --- /dev/null +++ b/crates/keymap/src/ui/rows.rs @@ -0,0 +1,628 @@ +//! The table's model: areas (Show), filters, search, group headers, sorting and the cached list of +//! lines the table draws. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use crate::chord::{Platform, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides}; +use crate::ui::Options; +use crate::ui::text::{Text, trigger_text}; + +/// One entry of the Show menu. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum AreaId { + All, + /// A top menu, `paths[0][0]`. + Menu(String), + Tools, + /// A panel scope, by name. + Panel(String), + /// Commands with no path that are not tools. + Other, + /// An app area from [`Options::areas`], by id. + App(&'static str), + /// [`Options::fixed`]. + Fixed, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AreaEntry { + pub id: AreaId, + pub label: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) enum Filter { + #[default] + All, + WithShortcut, + /// An overrides entry exists for the command. + Changed, + /// The command is in a [`ConflictKind::Clash`]. + Conflicts, +} + +impl Filter { + pub(crate) const ALL: [Filter; 4] = [Filter::All, Filter::WithShortcut, Filter::Changed, Filter::Conflicts]; + + pub(crate) fn label(self, text: &dyn Text) -> String { + match self { + Filter::All => text.ui("All"), + Filter::WithShortcut => text.ui("With Shortcut"), + Filter::Changed => text.ui("Changed"), + Filter::Conflicts => text.ui("Conflicts"), + } + .into_owned() + } +} + +/// A binding as a row shows it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Chip { + pub binding: Binding, + /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope. + pub text: String, + /// Not one of the command's defaults. + pub changed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Row { + /// Position in the catalogue. + pub index: usize, + pub command: CommandRef, + /// [`CommandRef::key`], for automation ids and the overrides. + pub key: String, + /// The translated label. + pub label: String, + /// Translated path parts between the group header and the label, shown dim before it. + pub prefix: Vec, + pub chips: Vec, + /// An overrides entry exists. + pub changed: bool, + /// A tool sharing a key with another tool of its group. + pub cycles: bool, + /// In a clash. + pub conflict: bool, +} + +/// A read-only row from [`Options::fixed`]. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FixedLine { + pub label: String, + pub keys: String, + /// The label of a catalogue command now bound to the row's chord: drawn struck through. + pub now_used_by: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Line { + Header { name: String, collapsed: bool }, + Row(Row), + Fixed(FixedLine), +} + +/// The lines for one query, area, filter and set of overrides. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Built { + pub lines: Vec, + /// Command and fixed rows shown (headers not counted); 0 shows "No matching commands". + pub matches: usize, + /// Clash pairs in the whole keymap. + pub clashes: usize, + /// Shadow pairs in the whole keymap, shown as an information line. + pub shadows: usize, +} + +impl Built { + /// The line index of a command's row. + pub(crate) fn position(&self, command: &CommandRef) -> Option { + self.lines.iter().position(|l| matches!(l, Line::Row(r) if r.command == *command)) + } +} + +/// What the lines were built from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct View<'a> { + pub query: &'a str, + pub area: &'a AreaId, + pub filter: Filter, + pub collapsed: &'a BTreeSet, +} + +#[derive(Clone, Debug, PartialEq)] +struct Key { + query: String, + area: AreaId, + filter: Filter, + collapsed: BTreeSet, + overrides: Overrides, + platform: Platform, +} + +/// The built lines, rebuilt only when the view or the keymap's overrides change. +#[derive(Clone, Debug, Default)] +pub(crate) struct RowCache { + key: Option, + built: Built, +} + +impl RowCache { + pub(crate) fn get(&mut self, keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> &Built { + let fresh = self.key.as_ref().is_some_and(|k| { + k.query == view.query + && k.area == *view.area + && k.filter == view.filter + && k.collapsed == *view.collapsed + && k.platform == keymap.platform() + && k.overrides == *keymap.overrides() + }); + if !fresh { + self.built = build(keymap, options, text, view); + self.key = Some(Key { + query: view.query.to_string(), + area: view.area.clone(), + filter: view.filter, + collapsed: view.collapsed.clone(), + overrides: keymap.overrides().clone(), + platform: keymap.platform(), + }); + } + &self.built + } + + /// Forgets the lines, for a change the key does not cover (the interface language). + pub(crate) fn invalidate(&mut self) { + self.key = None; + } +} + +fn top_menu(b: &Bindable) -> Option<&str> { + b.paths.first().and_then(|p| p.first()).map(String::as_str) +} + +fn is_tool(b: &Bindable) -> bool { + matches!(b.kind, Kind::Tool { .. }) +} + +/// The Show entries, in catalogue order, leaving out areas with no rows. +pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { + let catalogue = keymap.catalogue(); + let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; + let mut menus: Vec<&str> = Vec::new(); + for m in catalogue.iter().filter_map(top_menu) { + if !menus.contains(&m) { + menus.push(m); + } + } + for m in menus { + let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); + out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); + } + if catalogue.iter().any(is_tool) { + out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); + } + let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); + for b in catalogue.iter().filter(|b| !b.scope.is_app()) { + if !panels.contains(&b.scope.as_str()) { + panels.push(b.scope.as_str()); + } + } + for p in panels.into_iter().filter(|p| catalogue.iter().any(|b| b.scope.as_str() == *p)) { + out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); + } + if catalogue.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); + } + for a in &options.areas { + out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() }); + } + if !options.fixed.is_empty() { + out.push(AreaEntry { id: AreaId::Fixed, label: text.ui("Fixed Keys").into_owned() }); + } + out +} + +fn panel_label(text: &dyn Text, panel: &str) -> String { + craft_i18n::fmt(&text.ui("{panel} Panel"), &[("panel", &text.segment(panel))]) +} + +fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool { + match area { + AreaId::All => true, + AreaId::Menu(m) => top_menu(b) == Some(m.as_str()), + AreaId::Tools => is_tool(b), + AreaId::Panel(p) => b.scope.as_str() == p, + AreaId::Other => top_menu(b).is_none() && !is_tool(b), + AreaId::App(id) => options.areas.iter().any(|a| a.id == *id && (a.rows)(b)), + AreaId::Fixed => false, + } +} + +/// The first id segment, capitalised: `layer.lock` → `Layer`. +fn id_group(id: &str) -> String { + let first = id.split('.').next().unwrap_or(id); + let mut chars = first.chars(); + chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default() +} + +/// A row's group header (`None`: no header) with its rank among groups, and how many path parts +/// the header stands for. +fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { + let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); + let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth)); + match area { + AreaId::All => menu(1).unwrap_or_else(|| { + if let Kind::Tool { .. } = b.kind { + (1, Some(text.ui("Tools").into_owned()), 0) + } else if !b.scope.is_app() { + (2, Some(panel_label(text, b.scope.as_str())), 0) + } else { + (3, Some(text.ui("Other").into_owned()), 0) + } + }), + AreaId::Menu(_) if submenus && path.len() >= 3 => (0, path.get(1).map(|s| text.segment(s).into_owned()), 2), + AreaId::Menu(_) => (0, None, 1), + AreaId::Tools => match &b.kind { + Kind::Tool { group: Some(g) } => (0, Some(text.segment(g).into_owned()), usize::MAX), + _ => (0, None, usize::MAX), + }, + AreaId::Other => (0, Some(id_group(&b.command.id)), usize::MAX), + AreaId::Panel(_) | AreaId::App(_) | AreaId::Fixed => menu(1).unwrap_or((0, None, usize::MAX)), + } +} + +/// What a query asks for: lowercase text, and the trigger it spells, if any. +struct Query { + text: String, + trigger: Option, +} + +impl Query { + fn new(q: &str, platform: Platform) -> Query { + let text = q.trim().to_lowercase(); + let trigger = Trigger::parse(q.trim()).ok().map(|t| t.effective(platform)); + Query { text, trigger } + } + + fn hit(&self, s: &str) -> bool { + s.to_lowercase().contains(&self.text) + } + + /// Label, aliases, path parts and id, as written and translated; then the bindings: the same + /// effective trigger when the query spells one, else their canonical or display text. A query + /// that spells a chord (`cmd+s`, `⌘S`) does not also match longer chords containing its text. + fn matches(&self, b: &Bindable, bindings: &[Binding], text: &dyn Text, platform: Platform) -> bool { + if self.text.is_empty() { + return true; + } + let named = self.hit(&b.label) + || self.hit(&text.command(b)) + || b.aliases.iter().any(|a| self.hit(a)) + || b.paths.iter().flatten().any(|s| self.hit(s) || self.hit(&text.segment(s))) + || self.hit(&b.command.id); + named + || match self.trigger { + Some(t) => bindings.iter().any(|x| x.trigger.effective(platform) == t), + None => bindings.iter().any(|x| { + self.hit(&x.trigger.to_string()) || self.hit(&x.trigger.display(platform)) || self.hit(&trigger_text(text, &x.trigger, platform)) + }), + } + } +} + +fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built { + let platform = keymap.platform(); + let catalogue = keymap.catalogue(); + let mut built = Built::default(); + let mut clashing: HashSet = HashSet::new(); + for c in keymap.conflicts() { + match c.kind { + ConflictKind::Clash => { + built.clashes += 1; + clashing.insert(c.a.0.key()); + clashing.insert(c.b.0.key()); + } + ConflictKind::Shadow => built.shadows += 1, + } + } + let query = Query::new(view.query, platform); + if *view.area == AreaId::Fixed { + build_fixed(keymap, options, text, view, &query, &mut built); + return built; + } + + let bindings: Vec> = catalogue.iter().map(|b| keymap.bindings(&b.command)).collect(); + // How many tools of a group hold each effective trigger. + let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new(); + for (b, list) in catalogue.iter().zip(&bindings) { + if let Kind::Tool { group } = &b.kind { + for x in list { + *tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1; + } + } + } + let submenus = match view.area { + AreaId::Menu(m) => catalogue.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + _ => false, + }; + + // Groups in first-seen order within their rank. + let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); + for (index, (b, list)) in catalogue.iter().zip(&bindings).enumerate() { + if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { + continue; + } + let key = b.command.key(); + let changed = keymap.overrides().get(&key).is_some(); + let conflict = clashing.contains(&key); + let keep = match view.filter { + Filter::All => true, + Filter::WithShortcut => !list.is_empty(), + Filter::Changed => changed, + Filter::Conflicts => conflict, + }; + if !keep { + continue; + } + let (rank, header, depth) = group(view.area, b, submenus, text); + let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); + let prefix = path.get(depth..path.len().saturating_sub(1)).unwrap_or_default().iter().map(|s| text.segment(s).into_owned()).collect(); + let defaults = &b.defaults; + let chips = list + .iter() + .map(|x| { + let mut t = trigger_text(text, &x.trigger, platform); + if x.scope_or(&b.scope) != &b.scope { + t = format!("{t} ({})", text.segment(x.scope_or(&b.scope).as_str())); + } + Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } + }) + .collect(); + let cycles = match &b.kind { + Kind::Tool { group } => list.iter().any(|x| tool_keys.get(&(group.as_deref(), x.trigger.effective(platform))).is_some_and(|n| *n > 1)), + Kind::Command => false, + }; + let row = Row { index, command: b.command.clone(), key, label: text.command(b).into_owned(), prefix, chips, changed, cycles, conflict }; + match groups.iter_mut().find(|(r, h, _)| *r == rank && *h == header) { + Some((_, _, rows)) => rows.push(row), + None => groups.push((rank, header, vec![row])), + } + } + groups.sort_by_key(|(rank, _, _)| *rank); + for (_, header, mut rows) in groups { + if options.sort_by_label { + rows.sort_by_cached_key(|r| r.label.to_lowercase()); + } + built.matches += rows.len(); + let collapsed = header.as_ref().is_some_and(|h| view.collapsed.contains(h)); + if let Some(name) = header { + built.lines.push(Line::Header { name, collapsed }); + } + if !collapsed { + built.lines.extend(rows.into_iter().map(Line::Row)); + } + } + built +} + +fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, query: &Query, built: &mut Built) { + if matches!(view.filter, Filter::Changed | Filter::Conflicts) { + return; + } + let platform = keymap.platform(); + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for f in &options.fixed { + if !query.text.is_empty() + && !(query.hit(&f.label) + || query.hit(&f.keys) + || query.hit(&f.group) + || query.trigger.is_some_and(|t| f.chord.is_some_and(|c| c.effective(platform) == t))) + { + continue; + } + let now_used_by = f.chord.and_then(|c| { + let c = c.effective(platform); + keymap + .catalogue() + .iter() + .find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c)) + .map(|b| text.command(b).into_owned()) + }); + let line = FixedLine { label: f.label.clone(), keys: f.keys.clone(), now_used_by }; + match groups.iter_mut().find(|(g, _)| *g == f.group) { + Some((_, lines)) => lines.push(line), + None => groups.push((&f.group, vec![line])), + } + } + for (name, lines) in groups { + built.matches += lines.len(); + let collapsed = view.collapsed.contains(name); + built.lines.push(Line::Header { name: name.to_string(), collapsed }); + if !collapsed { + built.lines.extend(lines.into_iter().map(Line::Fixed)); + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::borrow::Cow; + + use super::*; + use crate::keymap::Bindable; + use crate::scope::{Scope, Scopes}; + use crate::ui::text::English; + use crate::ui::{Area, FixedRow}; + + fn key(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|_| Binding::new(Trigger::single(crate::Chord::from(crate::Key::F35)))) + } + + /// The fixture of the dialog tests: Save, Save As, Export, Arrange › Bring to Front, two + /// tools sharing M, Timeline Loop, Bold in the Text context. + pub(crate) fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(key("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(key("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + } + + fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet) -> View<'a> { + View { query, area, filter, collapsed } + } + + fn labels(built: &Built) -> Vec { + built + .lines + .iter() + .map(|l| match l { + Line::Header { name, .. } => format!("# {name}"), + Line::Row(r) => { + let chips: Vec<&str> = r.chips.iter().map(|c| c.text.as_str()).collect(); + let cycles = if r.cycles { " (cycles)" } else { "" }; + format!("{}{}: {}{cycles}", r.prefix.iter().map(|p| format!("{p} › ")).collect::(), r.label, chips.join(", ")) + } + Line::Fixed(f) => format!("{} = {}{}", f.label, f.keys, f.now_used_by.as_ref().map(|l| format!(" (now {l})")).unwrap_or_default()), + }) + .collect() + } + + fn lines(km: &Keymap, options: &Options, text: &dyn Text, query: &str, area: AreaId, filter: Filter) -> Vec { + let none = BTreeSet::new(); + labels(&build(km, options, text, &view(query, &area, filter, &none))) + } + + #[test] + fn areas_follow_paths_kind_and_scope() { + let km = fixture(Platform::Mac); + let options = Options { areas: vec![Area { id: "type", label: "Type", rows: |b| b.command.id.starts_with("type.") }], ..Options::default() }; + let names: Vec = areas(&km, &options, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other", "Type"]); + + let all = lines(&km, &options, &English, "", AreaId::All, Filter::All); + assert_eq!( + all, + [ + "# File", + "Save: ⌘S", + "Save As…: ⇧⌘S", + "Export…: ⌘E", + "# Object", + "Arrange › Bring to Front: ", + "# Tools", + "Marquee Tool: M (cycles)", + "Elliptical Marquee Tool: M (cycles)", + "# Timeline Panel", + "Loop: L", + "# Other", + "Bold: ⌘B (Text)", + ] + ); + assert_eq!(lines(&km, &options, &English, "", AreaId::Menu("Object".into()), Filter::All), ["# Arrange", "Bring to Front: "]); + assert_eq!(lines(&km, &options, &English, "", AreaId::Panel("Timeline".into()), Filter::All), ["Loop: L"]); + assert_eq!(lines(&km, &options, &English, "", AreaId::Other, Filter::All), ["# Timeline", "Loop: L", "# Type", "Bold: ⌘B (Text)"]); + } + + #[test] + fn sort_by_label_and_collapsed_groups() { + let km = fixture(Platform::Mac); + let options = Options { sort_by_label: true, ..Options::default() }; + let area = AreaId::Menu("File".into()); + let none = BTreeSet::new(); + assert_eq!(labels(&build(&km, &options, &English, &view("", &area, Filter::All, &none))), ["Export…: ⌘E", "Save: ⌘S", "Save As…: ⇧⌘S"]); + let collapsed: BTreeSet = ["File".to_string()].into(); + let built = build(&km, &options, &English, &view("", &AreaId::All, Filter::All, &collapsed)); + assert_eq!(built.lines.first(), Some(&Line::Header { name: "File".into(), collapsed: true })); + assert!(matches!(built.lines.get(1), Some(Line::Header { .. }))); + } + + struct Spanish; + impl Text for Spanish { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + English.plural(n, one, other) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.export" { "Exportar…".into() } else { b.label.as_str().into() } + } + } + + #[test] + fn search_by_name_translation_or_chord() { + let km = fixture(Platform::Mac); + let o = Options::default(); + let find = |q: &str| -> Vec { + let none = BTreeSet::new(); + build(&km, &o, &Spanish, &view(q, &AreaId::All, Filter::All, &none)) + .lines + .into_iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r.key) } else { None }) + .collect() + }; + assert_eq!(find("save"), ["file.save", "file.saveAs"]); + assert_eq!(find("exportar"), ["file.export"]); + assert_eq!(find("cmd+s"), ["file.save"]); + assert_eq!(find("⌘S"), ["file.save"]); + assert_eq!(find("⇧⌘S"), ["file.saveAs"]); + assert_eq!(find("Text: Cmd+B"), Vec::::new(), "a binding text with a scope is not a trigger"); + assert_eq!(find("cmd+b"), ["type.bold"], "a trigger matches in any scope"); + assert_eq!(find("arrange"), ["object.front"]); + assert!(find("zzz").is_empty()); + } + + #[test] + fn filters_and_conflict_counts() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Windows); + let o = Options::default(); + km.assign(&"file.export".into(), key("Cmd+S"), crate::Policy::Keep)?; + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Changed), ["# File", "Export…: Ctrl+E, Ctrl+S"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: Ctrl+S", "Export…: Ctrl+E, Ctrl+S"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("Object".into()), Filter::WithShortcut), Vec::::new()); + let none = BTreeSet::new(); + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!((built.clashes, built.matches), (1, 8)); + Ok(()) + } + + #[test] + fn fixed_rows_strike_through_when_a_command_takes_their_chord() { + let km = fixture(Platform::Mac); + let fixed = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.and_then(|c| Trigger::parse(c).ok()), + }; + let o = Options { fixed: vec![fixed("Rate 1", "1", Some("1")), fixed("Loop", "L", Some("L"))], ..Options::default() }; + assert_eq!(lines(&km, &o, &English, "", AreaId::Fixed, Filter::All), ["# Rating", "Rate 1 = 1", "Loop = L (now Loop)"]); + } + + #[test] + fn cache_rebuilds_on_overrides_and_view_changes() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Mac); + let o = Options::default(); + let mut cache = RowCache::default(); + let none = BTreeSet::new(); + let area = AreaId::Menu("File".into()); + let before = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone(); + km.assign(&"file.save".into(), key("F2"), crate::Policy::Steal)?; + let after = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone(); + assert_ne!(before, after); + assert_eq!(cache.get(&km, &o, &English, &view("save as", &area, Filter::All, &none)).matches, 1); + Ok(()) + } +} diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs new file mode 100644 index 00000000..ed3803b7 --- /dev/null +++ b/crates/keymap/src/ui/state.rs @@ -0,0 +1,1305 @@ +//! The dialog's persistent state and every action it takes, as calls on [`EditSession`], +//! [`Recorder`], [`editor::holders`] and [`Profiles`]. + +use std::collections::BTreeSet; + +use crate::chord::{Chord, Key, Trigger}; +use crate::command::CommandRef; +use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; +use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; +use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; +use crate::profiles::{Builtins, ProfileError, Profiles}; +use crate::scope::Scope; +use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; +use crate::ui::text::{Text, trigger_text}; +use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture}; + +/// What the dialog edits this frame. +pub(crate) struct Env<'e> { + pub keymap: &'e mut Keymap, + pub sets: Option<(&'e mut Profiles, &'e Builtins<'e>)>, + pub options: &'e Options, + pub text: &'e dyn Text, +} + +impl Env<'_> { + fn chord_text(&self, t: &Trigger) -> String { + trigger_text(self.text, t, self.keymap.platform()) + } + + fn label(&self, command: &CommandRef) -> String { + self.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.text.command(b).into_owned()) + } + + /// `label (Scope)` for each holder, joined. + fn holder_labels(&self, holders: &[Holder]) -> String { + let mut out: Vec = Vec::new(); + for h in holders { + let label = self.label(&h.command); + let s = match &h.binding.scope { + Some(scope) => format!("{label} ({})", self.text.segment(scope.as_str())), + None => label, + }; + if !out.contains(&s) { + out.push(s); + } + } + out.join(", ") + } + + fn ui(&self, source: &str, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&self.text.ui(source), args) + } +} + +/// Where a recording goes. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Target { + /// A row's shortcut cell: replaces `old`, or adds when `None`. Commits at once. + Cell { command: CommandRef, old: Option }, + /// The detail pane's New Shortcut field: the chord waits for Assign. + Field { command: CommandRef }, + /// Search by key: the chord's text becomes the query. + Search, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Recording { + pub target: Target, + /// With [`Options::double_press`]: a single press waiting [`DOUBLE_PRESS`] for its second press. + pub waiting: Option<(Trigger, f64)>, +} + +/// The New Shortcut field's chord, waiting for Assign. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FieldChord { + pub command: CommandRef, + pub binding: Binding, + /// Commands whose bindings would clash ("Currently assigned to"). + pub holders: Vec, + /// Commands with the same trigger in another scope that do not clash ("both work"). + pub shadows: Vec, +} + +/// The conflict bar, for one pending chord. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ConflictBar { + pub command: CommandRef, + pub old: Option, + pub binding: Binding, + pub holders: Vec, + /// "⚠ {chord} is used by {label}". + pub text: String, +} + +/// A line under the table after an action. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Message { + pub text: String, + /// Nothing changed: shown with ⚠. + pub refused: bool, + /// Offers Go to Conflict. + pub goto: Option, + /// Offers Undo. + pub undo: bool, +} + +/// What to do after the Save changes prompt. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Then { + Switch(String), + NewSet { name: String, based_on: Option }, + Import, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Prompt { + /// "Restore every default shortcut in this set?" + ResetAll, + /// "Delete the set “{set}”?" + DeleteSet(String), + /// "Save changes to “{set}”?" with Save, Don't Save and Cancel. + SaveChanges { set: String, then: Then }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Answer { + /// Save, or confirm. + Yes, + /// Don't Save. + No, + Cancel, +} + +/// A foreign set under review. +#[derive(Clone, Debug)] +pub(crate) struct Review { + pub set: ForeignSet, + pub options: ImportOptions, + pub plan: ImportPlan, + /// The new set's name. + pub name: String, + /// After Import as New Set: the set's name and the report. + pub done: Option<(String, ImportReport)>, +} + +#[derive(Clone, Debug)] +struct Opened { + profiles: Option, + overrides: Overrides, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + New, + Open, + Closed, +} + +/// The dialog's state between frames. Keep it while [`is_open`](Self::is_open) is true. +#[derive(Clone, Debug)] +pub struct DialogState { + phase: Phase, + opened: Option, + session: Option, + recorder: Recorder, + pub(crate) selected: Option, + /// Scroll the selected row into view on the next frame. + pub(crate) reveal: bool, + pub(crate) areas: Vec, + pub(crate) area: AreaId, + pub(crate) filter: Filter, + pub(crate) query: String, + pub(crate) collapsed: BTreeSet, + /// The detail pane's Context: `None` is the command's home scope. + pub(crate) context: Option, + pub(crate) recording: Option, + pub(crate) field: Option, + pub(crate) conflict: Option, + pub(crate) prompt: Option, + pub(crate) message: Option, + pub(crate) review: Option, + rows: RowCache, + changed: bool, + outcome: Option, +} + +impl Default for DialogState { + fn default() -> DialogState { + DialogState::new() + } +} + +impl DialogState { + /// A dialog about to open; it takes its snapshot on the first frame. + pub fn new() -> DialogState { + DialogState { + phase: Phase::New, + opened: None, + session: None, + recorder: Recorder::new(), + selected: None, + reveal: false, + areas: Vec::new(), + area: AreaId::All, + filter: Filter::All, + query: String::new(), + collapsed: BTreeSet::new(), + context: None, + recording: None, + field: None, + conflict: None, + prompt: None, + message: None, + review: None, + rows: RowCache::default(), + changed: false, + outcome: None, + } + } + + /// False after OK or Cancel: drop the state. + pub fn is_open(&self) -> bool { + self.phase != Phase::Closed + } + + /// While true the app also pauses native menu key equivalents. + pub fn is_recording(&self) -> bool { + self.recording.is_some() + } + + /// Selects a command and scrolls to it, as when the dialog opens from "Edit Shortcut…". + pub fn select(&mut self, command: &CommandRef) { + self.selected = Some(command.clone()); + self.reveal = true; + self.context = None; + self.field = None; + } + + // ---- Frame ---- + + /// The first frame: snapshot for Cancel, an edit session, the Show entries. + pub(crate) fn begin(&mut self, env: &mut Env) { + if self.phase != Phase::New { + return; + } + self.phase = Phase::Open; + self.opened = Some(Opened { profiles: env.sets.as_ref().map(|(p, _)| (**p).clone()), overrides: env.keymap.overrides().clone() }); + self.session = Some(EditSession::begin(env.keymap)); + self.areas = crate::ui::rows::areas(env.keymap, env.options, env.text); + } + + /// Takes this frame's key input while recording and commits a double-press wait that ended. + pub(crate) fn input(&mut self, env: &mut Env, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + self.tick(env, now); + if self.is_recording() { + let platform = env.keymap.platform(); + let chords = ctx.input_mut(|i| capture::take(i, platform)); + self.feed(env, &chords, now); + } + if let Some((_, at)) = self.recording.as_ref().and_then(|r| r.waiting) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((at + DOUBLE_PRESS - now).max(0.0))); + } + } + + /// The frame's outcome: a closing or file outcome first, else Changed after an edit. + pub(crate) fn finish(&mut self) -> Option { + let changed = std::mem::take(&mut self.changed); + self.outcome.take().or(changed.then_some(Outcome::Changed)) + } + + /// The table's lines for the current view. + pub(crate) fn lines(&mut self, env: &Env) -> &Built { + let view = View { query: &self.query, area: &self.area, filter: self.filter, collapsed: &self.collapsed }; + self.rows.get(env.keymap, env.options, env.text, &view) + } + + // ---- Selection and view ---- + + pub(crate) fn select_row(&mut self, command: &CommandRef) { + if self.selected.as_ref() != Some(command) { + self.selected = Some(command.clone()); + self.context = None; + self.field = None; + } + } + + pub(crate) fn set_area(&mut self, area: AreaId) { + self.area = area; + } + + pub(crate) fn set_filter(&mut self, filter: Filter) { + self.filter = filter; + } + + pub(crate) fn set_query(&mut self, query: String) { + self.query = query; + } + + pub(crate) fn toggle_group(&mut self, name: &str) { + if !self.collapsed.remove(name) { + self.collapsed.insert(name.to_string()); + } + } + + /// Selects a command and shows its row: area All, no search, no filter. + pub(crate) fn go_to(&mut self, command: &CommandRef) { + self.select_row(command); + self.area = AreaId::All; + self.query.clear(); + self.filter = Filter::All; + self.collapsed.clear(); + self.reveal = true; + } + + /// The Context entries for a command: its home scope (`None`, shown as Default), Application, + /// every panel and every context. + pub(crate) fn contexts(env: &Env, command: &CommandRef) -> Vec> { + let home = env.keymap.bindable(command).map(|b| b.scope.clone()); + let scopes = env.keymap.scopes(); + let mut out = vec![None]; + let names = std::iter::once(Scope::app()).chain(scopes.panels().iter().chain(scopes.contexts()).filter_map(|s| Scope::new(s.as_str()))); + for s in names { + if Some(&s) != home.as_ref() && !out.contains(&Some(s.clone())) { + out.push(Some(s)); + } + } + out + } + + pub(crate) fn set_context(&mut self, env: &Env, scope: Option) { + self.context = scope; + if let Some(f) = self.field.take() { + self.set_field(env, f.command, f.binding.trigger); + } + } + + // ---- Recording ---- + + /// Starts recording; drops the conflict bar and any message. + pub(crate) fn record(&mut self, env: &Env, target: Target) { + if !env.options.editable && target != Target::Search { + return; + } + self.conflict = None; + self.message = None; + self.recorder.reset(); + if let Target::Cell { command, .. } | Target::Field { command } = &target { + self.select_row(command); + } + self.recording = Some(Recording { target, waiting: None }); + } + + pub(crate) fn stop_recording(&mut self) { + self.recording = None; + self.recorder.reset(); + } + + /// Commits a single press whose double-press window has ended. + pub(crate) fn tick(&mut self, env: &mut Env, now: f64) { + let expired = self.recording.as_ref().and_then(|r| r.waiting).filter(|(_, at)| now < *at || now - *at >= DOUBLE_PRESS); + if let Some((trigger, _)) = expired { + self.commit_recorded(env, trigger); + } + } + + /// Feeds captured presses to the recorder. + pub(crate) fn feed(&mut self, env: &mut Env, chords: &[Chord], now: f64) { + for &chord in chords { + self.tick(env, now); + let Some(rec) = &mut self.recording else { return }; + if env.options.backspace_clears && chord.mods.is_none() && matches!(chord.key, Key::Backspace | Key::Delete) { + let target = rec.target.clone(); + self.stop_recording(); + match target { + Target::Cell { command, old: Some(old) } => self.remove(env, &command, &old), + Target::Field { .. } => self.field = None, + _ => {} + } + continue; + } + match self.recorder.press(chord, now) { + Recorded::Cancel => self.stop_recording(), + Recorded::Single(t) if env.options.double_press => rec.waiting = Some((t, now)), + Recorded::Single(t) | Recorded::Double(t) => self.commit_recorded(env, t), + } + } + } + + /// The recorded trigger is final: ends the recording and sends it where it belongs. + fn commit_recorded(&mut self, env: &mut Env, trigger: Trigger) { + let Some(rec) = self.recording.take() else { return }; + self.recorder.reset(); + match rec.target { + Target::Search => self.query = env.chord_text(&trigger), + Target::Cell { command, old } => { + let binding = match old.as_ref().and_then(|o| o.scope.clone()) { + Some(scope) => Binding::in_scope(trigger, scope), + None => Binding::new(trigger), + }; + self.commit(env, &command, old, binding); + } + Target::Field { command } => self.set_field(env, command, trigger), + } + } + + /// Puts a chord into the New Shortcut field with the chosen Context, and finds who holds it. + pub(crate) fn set_field(&mut self, env: &Env, command: CommandRef, trigger: Trigger) { + let binding = match &self.context { + Some(scope) => Binding::in_scope(trigger, scope.clone()), + None => Binding::new(trigger), + }; + let holders = editor::holders(env.keymap, &command, &binding); + let platform = env.keymap.platform(); + let want = trigger.effective(platform); + let mut shadows = Vec::new(); + for b in env.keymap.catalogue() { + if b.command == command { + continue; + } + for x in env.keymap.bindings(&b.command) { + if x.trigger.effective(platform) == want && !holders.iter().any(|h| h.command == b.command && h.binding == x) { + shadows.push(Holder { command: b.command.clone(), binding: x }); + } + } + } + self.field = Some(FieldChord { command, binding, holders, shadows }); + } + + /// Assign in the detail pane: commits the field's chord. + pub(crate) fn assign_field(&mut self, env: &mut Env) { + if let Some(f) = self.field.take() { + self.commit(env, &f.command, None, f.binding); + } + } + + /// Typed entry: the text as [`Binding::parse`] reads it, committed like a recorded chord. + /// `Err` is the parse error for under the field. + pub(crate) fn type_binding(&mut self, env: &mut Env, command: &CommandRef, text: &str) -> Result<(), String> { + let binding = Binding::parse(text).map_err(|e| e.to_string())?; + self.select_row(command); + if let Some(scope) = binding.scope.clone() { + self.context = Some(scope); + } + self.set_field(env, command.clone(), binding.trigger); + Ok(()) + } + + // ---- Commit and conflicts ---- + + /// Reserved chord, then the app's rule, then holders: assign at once, or by + /// [`Options::conflicts`]. + pub(crate) fn commit(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding) { + self.conflict = None; + self.message = None; + let chord = env.chord_text(&binding.trigger); + if env.keymap.is_reserved(&binding.trigger.chord) { + self.refuse(env.ui("{chord} is reserved by the system", &[("chord", &chord)])); + return; + } + if let (Some(accept), Some(b)) = (&env.options.accept, env.keymap.bindable(command)) + && let Err(text) = accept(b, &binding) + { + self.refuse(text); + return; + } + let holders = editor::holders(env.keymap, command, &binding); + if holders.is_empty() { + self.apply(env, command, old, binding, Policy::Steal); + return; + } + match env.options.conflicts { + ConflictMode::Ask => { + let text = env.ui("⚠ {chord} is used by {label}", &[("chord", &chord), ("label", &env.holder_labels(&holders))]); + self.conflict = Some(ConflictBar { command: command.clone(), old, binding, holders, text }); + } + ConflictMode::Steal => self.apply_and_say(env, command, old, binding, Policy::Steal), + ConflictMode::Keep => self.apply_and_say(env, command, old, binding, Policy::Keep), + } + } + + fn refuse(&mut self, text: String) { + self.message = Some(Message { text, refused: true, goto: None, undo: false }); + } + + fn apply(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) -> Option { + let km = &mut *env.keymap; + let session = self.session.get_or_insert_with(|| EditSession::begin(km)); + let result = match &old { + Some(o) => session.replace(km, command, o, binding, policy), + None => session.assign(km, command, binding, policy), + }; + match result { + Ok(a) => { + self.edited(env); + Some(a) + } + Err(e) => { + let text = assign_error(env, &e); + self.refuse(text); + None + } + } + } + + /// Applies with Steal or Keep and says what it did, with Go to Conflict (and Undo for Steal). + fn apply_and_say(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) { + let chord = env.chord_text(&binding.trigger); + let Some(a) = self.apply(env, command, old, binding, policy) else { return }; + let (holders, text) = + if policy == Policy::Steal { (&a.taken_from, "{chord} was removed from {label}") } else { (&a.clashes, "Also used by {label}") }; + if holders.is_empty() { + return; + } + let text = env.ui(text, &[("chord", &chord), ("label", &env.holder_labels(holders))]); + let goto = holders.first().map(|h| h.command.clone()); + self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal }); + } + + /// Take It. + pub(crate) fn take_it(&mut self, env: &mut Env) { + if let Some(c) = self.conflict.take() { + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Steal); + } + } + + /// Take It and Go to Conflict. + pub(crate) fn take_it_and_go(&mut self, env: &mut Env) { + if let Some(c) = self.conflict.take() { + let first = c.holders.first().map(|h| h.command.clone()); + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Steal); + if let Some(holder) = first { + self.go_to(&holder); + } + } + } + + /// Keep Both. + pub(crate) fn keep_both(&mut self, env: &mut Env) { + if !env.options.allow_keep { + return; + } + if let Some(c) = self.conflict.take() { + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Keep); + } + } + + /// ✕ on the conflict bar: nothing was applied, so nothing changes. + pub(crate) fn dismiss_conflict(&mut self) { + self.conflict = None; + } + + // ---- Edits ---- + + /// After any edit: drop the conflict bar, refresh the field's holders, report Changed. + fn edited(&mut self, env: &Env) { + self.changed = true; + self.conflict = None; + if let Some(f) = self.field.take() { + self.set_field(env, f.command, f.binding.trigger); + } + } + + /// Runs one session edit; true when it changed something. + fn edit(&mut self, env: &mut Env, f: impl FnOnce(&mut EditSession, &mut Keymap) -> bool) -> bool { + self.message = None; + let km = &mut *env.keymap; + let done = f(self.session.get_or_insert_with(|| EditSession::begin(km)), km); + if done { + self.edited(env); + } + done + } + + /// × on a chip, or Remove. + pub(crate) fn remove(&mut self, env: &mut Env, command: &CommandRef, binding: &Binding) { + self.edit(env, |s, km| s.remove(km, command, binding)); + } + + /// Clear: no shortcut. + pub(crate) fn clear(&mut self, env: &mut Env, command: &CommandRef) { + self.edit(env, |s, km| s.clear(km, command)); + } + + /// Use Default, ↺. + pub(crate) fn use_default(&mut self, env: &mut Env, command: &CommandRef) { + self.edit(env, |s, km| s.reset(km, command)); + } + + /// Whether Use Default does anything. + pub(crate) fn can_use_default(env: &Env, command: &CommandRef) -> bool { + env.keymap.overrides().get(&command.key()).is_some() + } + + /// Reset All…: asks first. + pub(crate) fn ask_reset_all(&mut self) { + self.prompt = Some(Prompt::ResetAll); + } + + fn reset_all(&mut self, env: &mut Env) { + self.edit(env, |s, km| { + let before = km.overrides().clone(); + s.reset_all(km); + *km.overrides() != before + }); + } + + pub(crate) fn undo(&mut self, env: &mut Env) { + self.edit(env, |s, km| s.undo(km)); + } + + pub(crate) fn redo(&mut self, env: &mut Env) { + self.edit(env, |s, km| s.redo(km)); + } + + pub(crate) fn can_undo(&self) -> bool { + self.session.as_ref().is_some_and(EditSession::can_undo) + } + + pub(crate) fn can_redo(&self) -> bool { + self.session.as_ref().is_some_and(EditSession::can_redo) + } + + // ---- OK, Cancel, Escape, Enter ---- + + /// OK: with sets, stores the overrides (a built-in's edits become a copy), then closes. + pub(crate) fn ok(&mut self, env: &mut Env) { + if let Err(e) = self.store(env) { + let text = profile_error(env, &e); + self.refuse(text); + return; + } + self.close(Outcome::Ok); + } + + /// Cancel, window ×, or Escape when idle: everything back as it was when the dialog opened. + pub(crate) fn cancel(&mut self, env: &mut Env) { + if let Some(opened) = self.opened.take() { + env.keymap.set_overrides(opened.overrides); + if let (Some((p, _)), Some(profiles)) = (env.sets.as_mut(), opened.profiles) { + **p = profiles; + } + } + self.close(Outcome::Cancel); + } + + fn close(&mut self, outcome: Outcome) { + self.phase = Phase::Closed; + self.recording = None; + self.conflict = None; + self.prompt = None; + self.outcome = Some(outcome); + } + + /// Escape when nothing records: closes the innermost open thing (conflict bar, prompt, import + /// review, the field's chord), else cancels. Rename fields and dropdowns are the view's. + pub(crate) fn escape(&mut self, env: &mut Env) { + if self.recording.is_some() { + self.stop_recording(); + } else if self.conflict.is_some() { + self.conflict = None; + } else if self.prompt.is_some() { + self.prompt = None; + } else if self.review.is_some() { + self.review = None; + } else if self.field.is_some() { + self.field = None; + } else { + self.cancel(env); + } + } + + /// Enter when idle and no text field has focus: OK with [`Options::enter_is_ok`]. + pub(crate) fn enter(&mut self, env: &mut Env) { + if env.options.enter_is_ok && self.recording.is_none() && self.prompt.is_none() { + self.ok(env); + } + } + + // ---- Sets ---- + + /// Whether the keymap's overrides differ from the stored active set. + pub(crate) fn is_modified(env: &Env) -> bool { + env.sets.as_ref().is_some_and(|(p, b)| p.active_overrides(b) != env.keymap.overrides()) + } + + fn custom_name(env: &Env) -> String { + env.text.ui(env.options.custom_set_name).into_owned() + } + + /// Stores the overrides in the active set; a built-in's edits become a new set. + fn store(&mut self, env: &mut Env) -> Result<(), ProfileError> { + let custom = Self::custom_name(env); + if let Some((p, b)) = env.sets.as_mut() + && p.active_overrides(b) != env.keymap.overrides() + { + p.store(env.keymap.overrides().clone(), b, &custom)?; + self.changed = true; + } + Ok(()) + } + + /// Makes `name` active and loads it, with a fresh undo history. + fn load_set(&mut self, env: &mut Env, name: &str) -> Result<(), ProfileError> { + let Some((p, b)) = env.sets.as_mut() else { return Err(ProfileError::NotFound) }; + p.select(name, b)?; + env.keymap.set_overrides(p.active_overrides(b).clone()); + self.session = Some(EditSession::begin(env.keymap)); + self.recording = None; + self.conflict = None; + self.field = None; + self.changed = true; + Ok(()) + } + + fn report(&mut self, env: &Env, result: Result<(), ProfileError>) { + match result { + Ok(()) => self.message = None, + Err(e) => self.refuse(profile_error(env, &e)), + } + } + + /// The set picker. With unsaved edits it asks to save them first. + pub(crate) fn switch_set(&mut self, env: &mut Env, name: &str) { + self.after_save_prompt(env, Then::Switch(name.to_string())); + } + + /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. + pub(crate) fn new_set(&mut self, env: &mut Env, name: &str, based_on: Option<&str>) { + let then = Then::NewSet { name: name.to_string(), based_on: based_on.map(str::to_string) }; + if based_on.is_none() { + self.run(env, then); + } else { + self.after_save_prompt(env, then); + } + } + + /// Import…: asks to save unsaved edits first, then asks the app for a file. + pub(crate) fn request_import(&mut self, env: &mut Env) { + self.after_save_prompt(env, Then::Import); + } + + fn after_save_prompt(&mut self, env: &mut Env, then: Then) { + if Self::is_modified(env) + && let Some((p, b)) = env.sets.as_ref() + { + self.prompt = Some(Prompt::SaveChanges { set: p.active_name(b).to_string(), then }); + return; + } + self.run(env, then); + } + + fn run(&mut self, env: &mut Env, then: Then) { + let result = match then { + Then::Switch(name) => self.load_set(env, &name), + Then::NewSet { name, based_on } => { + let made = match (env.sets.as_mut(), based_on) { + (None, _) => Err(ProfileError::NotFound), + (Some((p, b)), None) => p.save_as(&name, env.keymap.overrides().clone(), b), + (Some((p, b)), Some(from)) => p.duplicate(&from, &name, b), + }; + made.and_then(|n| self.load_set(env, &n)) + } + Then::Import => { + let formats = std::iter::once(JSON_FORMAT).chain(env.options.import_formats.iter().map(|f| f.id)).collect(); + self.outcome = Some(Outcome::Import { formats }); + Ok(()) + } + }; + self.report(env, result); + } + + /// Rename… (not for built-ins). + pub(crate) fn rename_set(&mut self, env: &mut Env, to: &str) { + let result = match env.sets.as_mut() { + Some((p, b)) => { + let from = p.active_name(b).to_string(); + p.rename(&from, to, b).map(|_| ()) + } + None => Err(ProfileError::NotFound), + }; + if result.is_ok() { + self.changed = true; + } + self.report(env, result); + } + + /// Delete: asks first. + pub(crate) fn ask_delete_set(&mut self, env: &Env) { + if let Some((p, b)) = env.sets.as_ref() { + let name = p.active_name(b); + if !Profiles::is_builtin(name, b) { + self.prompt = Some(Prompt::DeleteSet(name.to_string())); + } + } + } + + fn delete_set(&mut self, env: &mut Env, name: &str) { + let result = match env.sets.as_mut() { + Some((p, b)) => p.delete(name, b).map(|()| p.active_name(b).to_string()), + None => Err(ProfileError::NotFound), + }; + let result = result.and_then(|active| self.load_set(env, &active)); + self.report(env, result); + } + + /// Save: stores the edits; a built-in's become a copy. The undo history stays. + pub(crate) fn save_set(&mut self, env: &mut Env) { + let result = self.store(env); + self.report(env, result); + } + + /// Revert Set: back to the stored set, as one undo step. + pub(crate) fn revert_set(&mut self, env: &mut Env) { + let Some(stored) = env.sets.as_ref().map(|(p, b)| p.active_overrides(b).clone()) else { return }; + self.edit(env, |s, km| { + let before = km.overrides().clone(); + s.edit(km, |k| k.set_overrides(stored)); + *km.overrides() != before + }); + } + + /// Export ▾ → Keyboard Shortcuts (JSON): the set as shown, edits included. + pub(crate) fn export_set(&mut self, env: &mut Env) { + let custom = Self::custom_name(env); + let result = match env.sets.as_ref() { + Some((p, b)) => { + let mut tmp = (**p).clone(); + tmp.store(env.keymap.overrides().clone(), b, &custom).and_then(|_| { + let name = tmp.active_name(b).to_string(); + tmp.export(&name, b).map(|json| (name, json)) + }) + } + None => { + let mut tmp = Profiles::default(); + tmp.save_as(&custom, env.keymap.overrides().clone(), &[]).and_then(|name| tmp.export(&name, &[]).map(|json| (name, json))) + } + }; + match result { + Ok((name, json)) => self.outcome = Some(Outcome::Export { name, json }), + Err(e) => self.refuse(profile_error(env, &e)), + } + } + + /// Export ▾ → an app format. + pub(crate) fn export_as(&mut self, env: &Env, format: &'static str) { + let name = match env.sets.as_ref() { + Some((p, b)) => p.active_name(b).to_string(), + None => Self::custom_name(env), + }; + self.outcome = Some(Outcome::ExportAs { format, name, keymap: Box::new(env.keymap.clone()) }); + } + + /// A craft-keymap JSON set the app read: a new set, loaded; without sets its overrides replace + /// the keymap's as one undo step. + pub(crate) fn import_json(&mut self, env: &mut Env, text: &str) -> Result { + let result = match env.sets.as_mut() { + Some((p, b)) => p.import(text, b).and_then(|name| self.load_set(env, &name).map(|()| name)), + None => { + let mut tmp = Profiles::default(); + let name = tmp.import(text, &[]); + if name.is_ok() { + let overrides = tmp.active_overrides(&[]).clone(); + self.edit(env, |s, km| { + s.edit(km, |k| k.set_overrides(overrides)); + true + }); + } + name + } + }; + if let Err(e) = &result { + self.refuse(profile_error(env, e)); + } + result + } + + /// Answers the open prompt. + pub(crate) fn answer(&mut self, env: &mut Env, answer: Answer) { + let Some(prompt) = self.prompt.take() else { return }; + match (prompt, answer) { + (_, Answer::Cancel) => {} + (Prompt::ResetAll, Answer::Yes) => self.reset_all(env), + (Prompt::DeleteSet(name), Answer::Yes) => self.delete_set(env, &name), + (Prompt::SaveChanges { then, .. }, Answer::Yes) => match self.store(env) { + Ok(()) => self.run(env, then), + Err(e) => self.refuse(profile_error(env, &e)), + }, + (Prompt::SaveChanges { then, .. }, Answer::No) => self.run(env, then), + (Prompt::ResetAll | Prompt::DeleteSet(_), Answer::No) => {} + } + } + + // ---- Import review ---- + + pub(crate) fn review_import(&mut self, env: &mut Env, set: ForeignSet, options: ImportOptions) { + let plan = import::plan(&set, env.keymap, &options); + let name = plan.report.set.clone().unwrap_or_else(|| env.text.ui("Imported").into_owned()); + self.stop_recording(); + self.conflict = None; + self.review = Some(Review { set, options, plan, name, done: None }); + } + + /// Picks candidate `candidate` for suggestion `suggestion`, re-plans, and reports the answers + /// for the app to keep. + pub(crate) fn pick_suggestion(&mut self, env: &Env, suggestion: usize, candidate: usize) { + let Some(r) = &mut self.review else { return }; + let Some(s) = r.plan.report.suggestions.get(suggestion) else { return }; + let Some(command) = s.candidates.get(candidate).cloned() else { return }; + let Some(entry) = r.set.entries.iter().find(|e| e.item() == s.item && e.line == s.line) else { return }; + r.options.remembered.insert(entry.remember_key(), command); + r.plan = import::plan(&r.set, env.keymap, &r.options); + self.outcome = Some(Outcome::Remembered(r.options.remembered.clone())); + } + + /// Import as New Set (with sets), else applies the plan to the keymap as one undo step. + pub(crate) fn import_reviewed(&mut self, env: &mut Env) { + let Some(mut r) = self.review.take() else { return }; + let result = match env.sets.as_mut() { + Some((p, b)) => p.import_plan(&r.plan, env.keymap, Some(&r.name), b), + None => { + let plan = &r.plan; + let mut report = None; + self.edit(env, |s, km| { + report = Some(s.edit(km, |k| plan.apply(k))); + true + }); + Ok((r.name.clone(), report.unwrap_or_default())) + } + }; + match result { + Ok((name, report)) => { + if env.sets.is_some() + && let Err(e) = self.load_set(env, &name) + { + self.refuse(profile_error(env, &e)); + } + r.done = Some((name, report)); + } + Err(e) => self.refuse(profile_error(env, &e)), + } + self.review = Some(r); + } + + pub(crate) fn close_review(&mut self) { + self.review = None; + } +} + +fn assign_error(env: &Env, e: &AssignError) -> String { + match e { + AssignError::Reserved(t) => env.ui("{chord} is reserved by the system", &[("chord", &env.chord_text(t))]), + AssignError::TooMany => env.ui("A command can have at most {n} shortcuts", &[("n", &MAX_BINDINGS.to_string())]), + AssignError::UnknownScope(s) => env.ui("The scope {scope} is unknown", &[("scope", s.as_str())]), + AssignError::Full => env.ui("Too many shortcuts are assigned", &[]), + AssignError::UnknownCommand => env.ui("This command is unknown", &[]), + AssignError::Taken(holders) => env.ui("Also used by {label}", &[("label", &env.holder_labels(holders))]), + } +} + +fn profile_error(env: &Env, e: &ProfileError) -> String { + match e { + ProfileError::BadName => env.ui("The name is empty, too long or has control characters", &[]), + ProfileError::NameTaken => env.ui("A set with this name exists", &[]), + ProfileError::NotFound => env.ui("The set was not found", &[]), + ProfileError::BuiltIn => env.ui("Built-in sets cannot be changed", &[]), + ProfileError::TooMany => env.ui("There are too many sets", &[]), + ProfileError::BadFile => env.ui("This is not a shortcut set file", &[]), + ProfileError::WrongFormat => env.ui("This is not a craft-keymap file", &[]), + ProfileError::NewerVersion(v) => env.ui("This file is from a newer version (format {n})", &[("n", &v.to_string())]), + ProfileError::TooLarge => env.ui("The file is too large", &[]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Platform; + use crate::keymap::ConflictKind; + use crate::ui::rows::Line; + use crate::ui::rows::tests::fixture; + use crate::ui::text::English; + + struct Fx { + km: Keymap, + p: Profiles, + b: Vec<(&'static str, Overrides)>, + o: Options, + sets: bool, + } + + impl Fx { + fn new(o: Options) -> Fx { + Fx { km: fixture(Platform::Mac), p: Profiles::default(), b: vec![("Fixture Defaults", Overrides::new())], o, sets: true } + } + + fn env(&mut self) -> Env<'_> { + let sets = if self.sets { Some((&mut self.p, &self.b[..])) } else { None }; + Env { keymap: &mut self.km, sets, options: &self.o, text: &English } + } + + fn keys(&self, id: &str) -> Vec { + self.km.bindings(&id.into()).iter().map(ToString::to_string).collect() + } + } + + fn c(s: &str) -> Chord { + Chord::parse(s).unwrap_or(Chord::from(Key::F35)) + } + + fn b(s: &str) -> Binding { + Binding::parse(s).unwrap_or(Binding::new(Chord::from(Key::F35))) + } + + fn open(fx: &mut Fx) -> DialogState { + let mut st = DialogState::new(); + st.begin(&mut fx.env()); + st + } + + /// Records `chords` into `target` at time 0. + fn record(st: &mut DialogState, fx: &mut Fx, target: Target, chords: &[&str]) { + let mut env = fx.env(); + st.record(&env, target); + let chords: Vec = chords.iter().map(|s| c(s)).collect(); + st.feed(&mut env, &chords, 0.0); + } + + fn add(command: &str) -> Target { + Target::Cell { command: command.into(), old: None } + } + + #[test] + fn recording_takes_the_frames_keys_before_the_app() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.record(&fx.env(), Target::Cell { command: "file.save".into(), old: Some(b("Cmd+S")) }); + let ctx = egui::Context::default(); + let press = egui::Event::Key { key: egui::Key::E, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::MAC_CMD }; + let input = egui::RawInput { events: vec![press, egui::Event::Text("e".into())], ..egui::RawInput::default() }; + let mut left = usize::MAX; + let mut full = ctx.run_ui(input, |ui| { + st.input(&mut fx.env(), ui.ctx()); + left = ui.ctx().input(|i| i.events.len()); + }); + full.textures_delta.clear(); + assert_eq!(left, 0); + assert!(!st.is_recording()); + assert_eq!(st.conflict.as_ref().map(|c| c.text.as_str()), Some("⚠ ⌘E is used by Export…")); + assert_eq!(fx.keys("file.save"), ["Cmd+S"], "nothing changes before the answer"); + } + + #[test] + fn recording_takes_tab_and_shift_escape_and_escape_cancels_only_the_recording() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["Tab"]); + record(&mut st, &mut fx, add("object.front"), &["Shift+Escape"]); + assert_eq!(fx.keys("object.front"), ["Tab", "Shift+Escape"]); + + record(&mut st, &mut fx, add("object.front"), &["Escape"]); + assert!(!st.is_recording() && st.is_open()); + assert_eq!(fx.keys("object.front").len(), 2); + assert!(matches!(st.finish(), Some(Outcome::Changed))); + st.escape(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Cancel))); + assert!(!st.is_open()); + assert!(fx.keys("object.front").is_empty(), "Cancel restores the opening keymap"); + } + + #[test] + fn double_press_records_k_k_within_the_window() { + let mut fx = Fx::new(Options { double_press: true, ..Options::default() }); + let mut st = open(&mut fx); + let mut env = fx.env(); + st.record(&env, add("object.front")); + st.feed(&mut env, &[c("K")], 0.0); + assert!(st.is_recording(), "a single waits for a second press"); + st.feed(&mut env, &[c("K")], 0.3); + assert!(!st.is_recording()); + assert_eq!(fx.keys("object.front"), ["K K"]); + + let mut env = fx.env(); + st.record(&env, add("type.bold")); + st.feed(&mut env, &[c("K")], 1.0); + st.feed(&mut env, &[c("K")], 1.9); + assert_eq!(fx.keys("type.bold"), ["Text: Cmd+B", "K"]); + } + + #[test] + fn conflict_take_it_steals_and_undo_restores() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.conflict.as_ref().map(|c| c.text.as_str()), Some("⚠ ⌘S is used by Save")); + st.take_it(&mut fx.env()); + assert_eq!((fx.keys("file.save"), fx.keys("file.export")), (vec![], vec!["Cmd+E".to_string(), "Cmd+S".to_string()])); + assert!(st.message.as_ref().is_some_and(|m| m.undo && m.goto == Some("file.save".into()))); + st.undo(&mut fx.env()); + assert_eq!((fx.keys("file.save"), fx.keys("file.export")), (vec!["Cmd+S".to_string()], vec!["Cmd+E".to_string()])); + st.redo(&mut fx.env()); + assert!(fx.keys("file.save").is_empty()); + } + + #[test] + fn conflict_keep_both_lists_the_clash_and_cancel_changes_nothing() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + st.dismiss_conflict(); + assert_eq!(fx.keys("file.export"), ["Cmd+E"]); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + st.keep_both(&mut fx.env()); + let clashes = fx.km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + st.set_filter(Filter::Conflicts); + let env = fx.env(); + let rows: Vec = st.lines(&env).lines.iter().filter_map(|l| if let Line::Row(r) = l { Some(r.key.clone()) } else { None }).collect(); + assert_eq!(rows, ["file.save", "file.export"]); + } + + #[test] + fn go_to_conflict_selects_and_reveals_the_holder() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.set_area(AreaId::Tools); + st.set_query("marquee".into()); + record(&mut st, &mut fx, add("tool.marquee"), &["Cmd+S"]); + st.take_it_and_go(&mut fx.env()); + assert_eq!((&st.area, st.query.as_str(), st.selected.clone(), st.reveal), (&AreaId::All, "", Some("file.save".into()), true)); + } + + #[test] + fn steal_and_keep_modes_apply_and_say_so() { + let mut fx = Fx::new(Options { conflicts: ConflictMode::Steal, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.message.as_ref().map(|m| m.text.as_str()), Some("⌘S was removed from Save")); + let mut fx = Fx::new(Options { conflicts: ConflictMode::Keep, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.message.as_ref().map(|m| (m.text.as_str(), m.undo)), Some(("Also used by Save", false))); + assert_eq!(fx.keys("file.save"), ["Cmd+S"]); + } + + #[test] + fn reserved_chord_and_app_rule_refuse() { + let mut fx = Fx::new(Options { + accept: Some(Box::new(|bindable, binding| match bindable.kind { + crate::Kind::Tool { .. } if !binding.trigger.chord.mods.is_none() => Err("Tools take single keys".into()), + _ => Ok(()), + })), + ..Options::default() + }); + fx.km = fx.km.clone().with_reserved(vec![c("Cmd+Tab")]); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["Cmd+Tab"]); + assert_eq!(st.message.as_ref().map(|m| (m.text.as_str(), m.refused)), Some(("⌘⇥ is reserved by the system", true))); + record(&mut st, &mut fx, add("tool.marquee"), &["Cmd+J"]); + assert_eq!(st.message.as_ref().map(|m| m.text.as_str()), Some("Tools take single keys")); + assert!(fx.km.overrides().is_empty()); + assert!(st.finish().is_none()); + } + + #[test] + fn backspace_clears_the_binding_being_replaced() { + let mut fx = Fx::new(Options { backspace_clears: true, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, Target::Cell { command: "file.save".into(), old: Some(b("Cmd+S")) }, &["Backspace"]); + assert!(fx.keys("file.save").is_empty()); + } + + #[test] + fn field_waits_for_assign_and_lists_holders() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, Target::Field { command: "file.save".into() }, &["Cmd+B"]); + let f = st.field.clone(); + assert_eq!(f.as_ref().map(|f| (f.holders.len(), f.shadows.len())), Some((0, 1)), "Bold's Text: Cmd+B only shadows"); + st.set_context(&fx.env(), Scope::new("Text")); + assert_eq!(st.field.as_ref().map(|f| f.holders.len()), Some(1), "in Text it clashes"); + st.assign_field(&mut fx.env()); + assert!(st.conflict.is_some()); + assert!(fx.km.overrides().is_empty()); + + st.set_context(&fx.env(), None); + assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "F7 F7"), Ok(())); + st.assign_field(&mut fx.env()); + assert_eq!(fx.keys("file.save"), ["Cmd+S", "F7 F7"]); + assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); + } + + #[test] + fn cancel_restores_keymap_and_sets() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let (km0, p0) = (fx.km.overrides().clone(), fx.p.clone()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.new_set(&mut fx.env(), "Other", None); + assert_eq!(fx.p.active, "Other"); + st.ask_delete_set(&fx.env()); + assert_eq!(st.prompt, Some(Prompt::DeleteSet("Other".into()))); + st.answer(&mut fx.env(), Answer::Yes); + assert!(!fx.p.sets.contains_key("Other")); + st.cancel(&mut fx.env()); + assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); + } + + #[test] + fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + assert!(DialogState::is_modified(&fx.env())); + st.ok(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Ok))); + assert_eq!(fx.p.active, "Custom"); + assert_eq!(fx.p.get("Custom", &fx.b).and_then(|o| o.get("object.front")), Some(&["F2".to_string()][..])); + } + + #[test] + fn switching_a_modified_set_asks_to_save() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.switch_set(&mut fx.env(), "Fixture Defaults"); + assert!(matches!(&st.prompt, Some(Prompt::SaveChanges { set, .. }) if set == "Mine")); + st.answer(&mut fx.env(), Answer::No); + assert_eq!(fx.p.active, "Fixture Defaults"); + assert!(fx.km.overrides().is_empty() && !st.can_undo(), "the set loads with a fresh undo history"); + assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); + } + + #[test] + fn set_export_import_round_trip() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.new_set(&mut fx.env(), "Name", None); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.export_set(&mut fx.env()); + let (name, json) = match st.finish() { + Some(Outcome::Export { name, json }) => (name, json), + _ => (String::new(), String::new()), + }; + assert_eq!(name, "Name"); + let edited = fx.km.overrides().clone(); + + let mut fresh = Fx::new(Options::default()); + fresh.p = fx.p.clone(); + let mut st = open(&mut fresh); + assert_eq!(st.import_json(&mut fresh.env(), &json), Ok("Name 2".into())); + assert_eq!(fresh.km.overrides(), &edited); + assert!(st.import_json(&mut fresh.env(), "{}").is_err()); + assert!(st.message.as_ref().is_some_and(|m| m.refused)); + + let mut no_sets = Fx::new(Options::default()); + no_sets.sets = false; + let mut st = open(&mut no_sets); + assert!(st.import_json(&mut no_sets.env(), &json).is_ok()); + assert_eq!(no_sets.km.overrides(), &edited); + assert!(st.can_undo()); + } + + #[test] + fn import_request_and_reset_all() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.request_import(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Import { formats }) if formats == [JSON_FORMAT])); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.ask_reset_all(); + st.answer(&mut fx.env(), Answer::Yes); + assert!(fx.km.overrides().is_empty()); + st.undo(&mut fx.env()); + assert_eq!(fx.keys("object.front"), ["F2"]); + } + + #[test] + fn foreign_import_review_makes_a_new_set() { + use crate::import::{ContextRule, ForeignEntry, ForeignKey}; + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ], + }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + st.review_import(&mut fx.env(), set, options); + let r = st.review.as_ref(); + assert_eq!(r.map(|r| (r.name.as_str(), r.plan.commands.len(), r.plan.report.unmatched.len())), Some(("Theirs", 1, 1))); + assert!(fx.km.overrides().is_empty(), "review changes nothing"); + st.import_reviewed(&mut fx.env()); + assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.keys("file.save"), ["Cmd+Alt+S"]); + assert!(st.review.as_ref().is_some_and(|r| r.done.is_some())); + st.escape(&mut fx.env()); + assert!(st.review.is_none() && st.is_open()); + } +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs new file mode 100644 index 00000000..5eb12009 --- /dev/null +++ b/crates/keymap/src/ui/text.rs @@ -0,0 +1,283 @@ +//! The dialog's words: its own strings, command labels, menu path parts, key names and text +//! direction, all supplied by the app through [`Text`]. + +use std::borrow::Cow; + +use crate::chord::{Key, Platform, Trigger}; +use crate::keymap::Bindable; + +/// Where the dialog gets its words. Apps on craft-i18n pass [`I18nText`]; others implement the two +/// required methods with their own lookup. +pub trait Text { + /// The dialog's own strings, by English source text (listed in [`STRINGS`]). Placeholders + /// (`{chord}`, `{label}`, `{set}`, `{n}`) are filled after the lookup. + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str>; + /// A count's wording with `{n}` filled in. `one` and `other` are the English forms. + fn plural(&self, n: u64, one: &str, other: &str) -> String; + /// A command's label. + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + b.label.as_str().into() + } + /// A menu path part or a scope name. + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + s.into() + } + /// Extra lines for a command's tooltip. + fn hint(&self, _b: &Bindable) -> Option { + None + } + /// A key cap's name on `platform` (`Strg`, `Umschalt`); `None` keeps the crate's name. + fn key(&self, _k: Key, _p: Platform) -> Option { + None + } + /// Whether the interface language is written right to left. + fn rtl(&self) -> bool { + false + } + /// A label or message put into display order (the app's bidi reordering). Chord text is never + /// passed here. + fn visual(&self, s: &str) -> String { + s.to_string() + } +} + +/// [`Text`] over a craft-i18n catalog. +pub struct I18nText<'a> { + pub i18n: &'a craft_i18n::I18n, + pub lang: craft_i18n::Lang, +} + +impl Text for I18nText<'_> { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + self.i18n.trn(self.lang, n, one, other) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + self.i18n.tid(self.lang, &b.command.id, &b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, s).into() + } + + fn key(&self, k: Key, p: Platform) -> Option { + // Glyphs (⌫, ↑) need no translation. + if p.is_mac() && k.mac_symbol() != k.text_name() { + return None; + } + self.i18n.get(self.lang, "key", k.text_name()).map(str::to_string) + } + + fn rtl(&self) -> bool { + self.i18n.rtl(self.lang) + } +} + +/// English [`Text`]: every string as written. +pub struct English; + +impl Text for English { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + craft_i18n::fmt(if n == 1 { one } else { other }, &[("n", &n.to_string())]) + } +} + +/// A trigger as `platform` shows it, with key names from [`Text::key`]. Modifiers come in the +/// order of [`Chord::display`](crate::Chord::display). +pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> String { + let key = trigger.chord.key; + let Some(name) = text.key(key, platform) else { return trigger.display(platform) }; + let base = if platform.is_mac() { key.mac_symbol() } else { key.text_name() }; + let one = trigger.chord.display(platform); + let one = match one.strip_suffix(base) { + Some(mods) => format!("{mods}{name}"), + None => one, + }; + if trigger.double { format!("{one} {one}") } else { one } +} + +/// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. +pub const PLURALS: &[(&str, &str)] = &[ + ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), + ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), + ("{n} command will change", "{n} commands will change"), + ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), +]; + +/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. +pub const STRINGS: &[&str] = &[ + // Window and footer. + "Keyboard Shortcuts", + "Custom", + "OK", + "Cancel", + "Close", + "Undo", + "Redo", + "Reset All…", + "Restore every default shortcut in this set?", + "Reset All", + // Header. + "Show:", + "Filter:", + "Search:", + "Search by key", + "All Commands", + "{menu} Menu", + "Tools", + "{panel} Panel", + "Other", + "Fixed Keys", + "All", + "With Shortcut", + "Changed", + "Conflicts", + "No matching commands", + "(cycles)", + // Table and detail pane. + "Command", + "Shortcut", + "Default", + "Application", + "Current shortcuts:", + "New shortcut:", + "press keys", + "{chord} (press again for a double press)", + "Type…", + "Context:", + "Assign", + "Use Default", + "Clear", + "Remove", + "Default: {chord}", + "None", + "Currently assigned to:", + "Not assigned", + "{label} ({scope}, both work)", + "Add a shortcut to {label}", + "Remove {chord} from {label}", + "Restore the default for {label}", + "{label}: {chord}", + "{label}: press keys", + "Now used by {label}", + // Messages and conflicts. + "⚠ {chord} is used by {label}", + "{chord} was removed from {label}", + "Also used by {label}", + "{chord} is reserved by the system", + "A command can have at most {n} shortcuts", + "The scope {scope} is unknown", + "Too many shortcuts are assigned", + "This command is unknown", + "Take It", + "Take It and Go to Conflict", + "Keep Both", + "Go to Conflict", + // Sets. + "Set:", + "(modified)", + "New Set…", + "Rename…", + "Delete", + "Save", + "Revert Set", + "Import…", + "Export", + "Summarize…", + "More", + "Name:", + "Based on:", + "Current shortcuts", + "Save changes to “{set}”?", + "Don't Save", + "Delete the set “{set}”?", + "Keyboard Shortcuts (JSON)", + "The name is empty, too long or has control characters", + "A set with this name exists", + "The set was not found", + "Built-in sets cannot be changed", + "There are too many sets", + "This is not a shortcut set file", + "This is not a craft-keymap file", + "This file is from a newer version (format {n})", + "The file is too large", + // Import review. + "Will change", + "Not matched", + "Unreadable", + "Approximated", + "Suggestions", + "numeric keypad key read as main key", + "Import as New Set", + "Imported", + "Copy Report", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Chord; + + struct German; + impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, _one: &str, other: &str) -> String { + other.replace("{n}", &n.to_string()) + } + fn key(&self, k: Key, _p: Platform) -> Option { + (k == Key::Delete).then(|| "Entf".to_string()) + } + } + + #[test] + fn trigger_text_uses_localized_key_names() -> Result<(), crate::ParseError> { + let t = Trigger::parse("Cmd+Shift+Delete")?; + assert_eq!(trigger_text(&German, &t, Platform::Windows), "Ctrl+Shift+Entf"); + assert_eq!(trigger_text(&German, &Trigger::parse("F7 F7")?, Platform::Windows), "F7 F7"); + assert_eq!(trigger_text(&English, &Trigger::single(Chord::parse("⇧⌘Z")?), Platform::Mac), "⇧⌘Z"); + Ok(()) + } + + /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. + #[test] + fn every_looked_up_string_is_listed() { + let sources = [include_str!("mod.rs"), include_str!("state.rs"), include_str!("rows.rs"), include_str!("capture.rs")]; + let mut missing = Vec::new(); + for src in sources { + let src = src.split("#[cfg(test)]").next().unwrap_or(src); + for (i, open) in src.match_indices(".ui(\"") { + let rest = &src[i + open.len()..]; + if let Some(end) = rest.find('"') { + let s = &rest[..end]; + if !STRINGS.contains(&s) { + missing.push(s.to_string()); + } + } + } + for (i, _) in src.match_indices(".plural(") { + let quoted: Vec<&str> = src[i..].split('"').skip(1).step_by(2).take(2).collect(); + if let [one, other] = quoted[..] + && !PLURALS.contains(&(one, other)) + { + missing.push(format!("{one}|{other}")); + } + } + } + assert!(missing.is_empty(), "not in STRINGS or PLURALS: {missing:?}"); + let mut sorted = STRINGS.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), STRINGS.len(), "STRINGS has duplicates"); + } +} From 39f343c47ba967411ab39ce346bd5bea7d4ea86b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:03:57 +0000 Subject: [PATCH 03/33] keymap: draw the shortcuts dialog: table, detail pane, conflict bar and footer - view.rs: header with Show (dropdown or tabs), Filter and Search with search by key; table and detail pane in a resizable split, the detail pane below the table under 720 pt; virtual rows with chips, + and the restore button; detail pane with current shortcuts, New Shortcut, typed entry, Context, Assign, Use Default and Clear; prompts, conflict bar, messages, clash, shadow and problem lines; Undo, Redo, Reset All, Cancel and OK. - Escape, Enter, Undo and Redo keys when idle; table keys (arrows, Home, End, Enter records, Delete removes); focus locks while recording. - Accessibility labels for every control, row selection, and on_widget automation ids. - style.rs: DialogStyle and the icon-button and divider styles. - show() sizes the window to the screen; closing it is Cancel. - tests/ui_dialog.rs: recording, conflicts, search, areas, display, labels, table keys, small windows, RTL. tests/ui_snapshot.rs (feature snapshot) writes PNGs when CRAFT_KEYMAP_SNAPSHOT_DIR is set. --- Cargo.lock | 1 + crates/keymap/Cargo.toml | 3 + crates/keymap/src/ui/mod.rs | 84 +- crates/keymap/src/ui/rows.rs | 6 + crates/keymap/src/ui/state.rs | 36 + crates/keymap/src/ui/style.rs | 54 ++ crates/keymap/src/ui/text.rs | 21 +- crates/keymap/src/ui/view.rs | 1257 +++++++++++++++++++++++++ crates/keymap/tests/ui_dialog.rs | 517 ++++++++++ crates/keymap/tests/ui_fixture/mod.rs | 24 + crates/keymap/tests/ui_snapshot.rs | 94 ++ 11 files changed, 2052 insertions(+), 45 deletions(-) create mode 100644 crates/keymap/src/ui/style.rs create mode 100644 crates/keymap/src/ui/view.rs create mode 100644 crates/keymap/tests/ui_dialog.rs create mode 100644 crates/keymap/tests/ui_fixture/mod.rs create mode 100644 crates/keymap/tests/ui_snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index 76a76163..69b01442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,6 +627,7 @@ dependencies = [ "craft-ui", "egui", "egui_kittest", + "image", "serde", "serde_json", "tempfile", diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 0363ae7b..d7133d5a 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -22,8 +22,11 @@ craft-settings = { workspace = true } tempfile = "3" egui = { workspace = true, features = ["default_fonts"] } egui_kittest = "0.36" +image = { version = "0.25", default-features = false, features = ["png"] } [features] egui = ["dep:egui"] # The Keyboard Shortcuts dialog. ui = ["egui", "dep:craft-ui", "dep:craft-i18n"] +# Screenshots of the dialog for review (tests/ui_snapshot.rs; needs a GPU or software adapter). +snapshot = ["ui", "egui_kittest/wgpu"] diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 038d36ea..f09dc8e4 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -15,16 +15,15 @@ use crate::import::{ForeignSet, ImportOptions}; use crate::keymap::{Bindable, Binding, Keymap}; use crate::profiles::{Builtins, ProfileError, Profiles}; -// The view draws with these; until it calls every action some stay unused. -#[expect(dead_code, reason = "actions and row fields the view calls")] mod capture; -#[expect(dead_code, reason = "actions and row fields the view calls")] mod rows; -#[expect(dead_code, reason = "actions and row fields the view calls")] mod state; +mod style; mod text; +mod view; pub use state::DialogState; +pub use style::DialogStyle; pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text}; use state::Env; @@ -90,37 +89,6 @@ pub struct Format { pub extensions: &'static [&'static str], } -/// The dialog's colours. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct DialogStyle { - pub accent: egui::Color32, - pub warning: egui::Color32, - pub dim: egui::Color32, - pub changed: egui::Color32, - pub app_key: egui::Color32, - pub scoped_key: egui::Color32, - pub both_key: egui::Color32, - pub modifier_key: egui::Color32, - pub free_key: egui::Color32, -} - -impl DialogStyle { - pub fn from_visuals(v: &egui::Visuals) -> DialogStyle { - let accent = v.selection.bg_fill; - DialogStyle { - accent, - warning: v.warn_fg_color, - dim: v.weak_text_color(), - changed: accent, - app_key: accent, - scoped_key: v.warn_fg_color, - both_key: v.error_fg_color, - modifier_key: v.widgets.active.bg_fill, - free_key: v.widgets.inactive.bg_fill, - } - } -} - /// A rule an app adds for which bindings a command may get; `Err` carries the message shown. pub type Accept = Box Result<(), String>>; /// Called for every control once per frame with (automation id, rect, label). @@ -229,19 +197,37 @@ impl<'a> ShortcutsDialog<'a> { f(&mut env) } - /// A window with title, body and Cancel/OK. + /// A window with title, body and Cancel/OK, sized to the screen (at least 640×480 where the + /// screen allows, at most 1320×900). Closing it with × is Cancel. pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { - let title = self.text.ui(self.options.map_or("Keyboard Shortcuts", |o| o.title)).into_owned(); - egui::Window::new(title).collapsible(false).show(ctx, |ui| self.ui(ui, state)).and_then(|r| r.inner).flatten() + self.with_env(|env| { + let title = env.text.visual(&env.text.ui(env.options.title)); + let screen = ctx.content_rect().size(); + let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0)); + let min = egui::vec2(640.0, 480.0).min(max); + let mut open = true; + let mut out = None; + egui::Window::new(title) + .id(egui::Id::new("craft_keymap.shortcuts")) + .collapsible(false) + .resizable(true) + .open(&mut open) + .default_size((screen * 0.8).clamp(min, max)) + .min_size(min) + .max_size(max) + .show(ctx, |ui| out = frame(ui, env, state)); + if !open && state.is_open() { + state.cancel(env); + out = state.finish(); + } + out + }) } - /// The body only, for a host's own modal, preferences page or backstage. + /// The body only, for a host's own modal, preferences page or backstage. It fills the + /// available rectangle. pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { - self.with_env(|env| { - state.begin(env); - state.input(env, ui.ctx()); - state.finish() - }) + self.with_env(|env| frame(ui, env, state)) } /// OK, for hosts that draw their own button. @@ -279,3 +265,13 @@ impl<'a> ShortcutsDialog<'a> { }); } } + +/// One frame: take recorded keys before anything else sees them, draw, report. +fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option { + state.begin(env); + state.input(env, ui.ctx()); + if state.is_open() { + view::body(ui, env, state); + } + state.finish() +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 1bc13261..69b0afaa 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -172,7 +172,13 @@ impl RowCache { &self.built } + /// The lines last built. + pub(crate) fn built(&self) -> &Built { + &self.built + } + /// Forgets the lines, for a change the key does not cover (the interface language). + #[allow(dead_code, reason = "for hosts that switch the interface language")] pub(crate) fn invalidate(&mut self) { self.key = None; } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index ed3803b7..edfc7d33 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -105,6 +105,7 @@ pub(crate) struct Message { } /// What to do after the Save changes prompt. +#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Then { Switch(String), @@ -112,6 +113,7 @@ pub(crate) enum Then { Import, } +#[allow(dead_code, reason = "set row prompts")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Prompt { /// "Restore every default shortcut in this set?" @@ -132,6 +134,7 @@ pub(crate) enum Answer { } /// A foreign set under review. +#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug)] pub(crate) struct Review { pub set: ForeignSet, @@ -182,6 +185,20 @@ pub struct DialogState { rows: RowCache, changed: bool, outcome: Option, + // ---- The view's own state ---- + /// The table's share of the table/detail split. + pub(crate) split: f32, + /// The chip of the selected row that Enter and Delete act on. + pub(crate) chip: usize, + /// Typed entry in the detail pane, and the parse error shown under it. + pub(crate) typing: Option, + pub(crate) type_error: Option, + /// Height of the bars and footer under the table, measured last frame. + pub(crate) bottom_h: f32, + /// One of the dialog's text fields had focus at the end of last frame. + pub(crate) text_focus: bool, + /// The table lines drawn last frame. + pub(crate) visible: std::ops::Range, } impl Default for DialogState { @@ -215,6 +232,13 @@ impl DialogState { rows: RowCache::default(), changed: false, outcome: None, + split: 0.62, + chip: 0, + typing: None, + type_error: None, + bottom_h: 0.0, + text_focus: false, + visible: 0..0, } } @@ -275,6 +299,11 @@ impl DialogState { self.rows.get(env.keymap, env.options, env.text, &view) } + /// The table's lines as last built by [`lines`](Self::lines). + pub(crate) fn lines_cached(&self) -> &[crate::ui::rows::Line] { + &self.rows.built().lines + } + // ---- Selection and view ---- pub(crate) fn select_row(&mut self, command: &CommandRef) { @@ -282,6 +311,9 @@ impl DialogState { self.selected = Some(command.clone()); self.context = None; self.field = None; + self.chip = 0; + self.typing = None; + self.type_error = None; } } @@ -676,7 +708,11 @@ impl DialogState { self.ok(env); } } +} +// The set row and the import review draw with these (T3); until then some are unused. +#[allow(dead_code, reason = "set row and import review actions")] +impl DialogState { // ---- Sets ---- /// Whether the keymap's overrides differ from the stored active set. diff --git a/crates/keymap/src/ui/style.rs b/crates/keymap/src/ui/style.rs new file mode 100644 index 00000000..81d4a62d --- /dev/null +++ b/crates/keymap/src/ui/style.rs @@ -0,0 +1,54 @@ +//! The dialog's colours and the icon-button style built from them. + +use craft_ui::buttons::{ButtonVisuals, IconButtonStyle}; +use craft_ui::layout::ResizeHandleStyle; +use egui::{Color32, Stroke, Visuals}; + +/// The dialog's colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DialogStyle { + pub accent: Color32, + pub warning: Color32, + pub dim: Color32, + pub changed: Color32, + pub app_key: Color32, + pub scoped_key: Color32, + pub both_key: Color32, + pub modifier_key: Color32, + pub free_key: Color32, +} + +impl DialogStyle { + pub fn from_visuals(v: &Visuals) -> DialogStyle { + let accent = v.selection.bg_fill; + DialogStyle { + accent, + warning: v.warn_fg_color, + dim: v.weak_text_color(), + changed: accent, + app_key: accent, + scoped_key: v.warn_fg_color, + both_key: v.error_fg_color, + modifier_key: v.widgets.active.bg_fill, + free_key: v.widgets.inactive.bg_fill, + } + } +} + +/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colours of the app's visuals. +pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle { + let w = &v.widgets; + IconButtonStyle { + normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: w.inactive.fg_stroke.color }, + hovered: ButtonVisuals { fill: w.hovered.weak_bg_fill, stroke: w.hovered.bg_stroke, icon: w.hovered.fg_stroke.color }, + pressed: Some(ButtonVisuals { fill: w.active.weak_bg_fill, stroke: w.active.bg_stroke, icon: w.active.fg_stroke.color }), + selected: ButtonVisuals { fill: v.selection.bg_fill, stroke: Stroke::NONE, icon: v.selection.stroke.color }, + corner_radius: w.inactive.corner_radius, + focus_stroke: v.selection.stroke, + } +} + +/// The table/detail divider. +pub(crate) fn divider(v: &Visuals) -> ResizeHandleStyle { + ResizeHandleStyle { hovered: v.widgets.hovered.bg_fill, dragged: v.widgets.active.bg_fill, focused: v.selection.bg_fill } +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 5eb12009..80455b3d 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -111,6 +111,7 @@ pub const PLURALS: &[(&str, &str)] = &[ ("{n} command will change", "{n} commands will change"), ("{n} item without a shortcut", "{n} items without a shortcut"), ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), + ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), ]; /// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. @@ -145,6 +146,9 @@ pub const STRINGS: &[&str] = &[ "(cycles)", // Table and detail pane. "Command", + "Commands", + "Detail pane size", + "Select a command to see and change its shortcuts", "Shortcut", "Default", "Application", @@ -182,6 +186,14 @@ pub const STRINGS: &[&str] = &[ "Take It and Go to Conflict", "Keep Both", "Go to Conflict", + "Cancel the new shortcut", + // Saved shortcuts that could not be used. + "{key}: no command has this name", + "{key}: “{text}” is not a shortcut ({error})", + "{key}: {text} is reserved by the system", + "{key}: the scope in “{text}” is unknown", + "{key}: listed more than once", + "{key}: part of the entry was dropped", // Sets. "Set:", "(modified)", @@ -252,7 +264,14 @@ mod tests { /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. #[test] fn every_looked_up_string_is_listed() { - let sources = [include_str!("mod.rs"), include_str!("state.rs"), include_str!("rows.rs"), include_str!("capture.rs")]; + let sources = [ + include_str!("mod.rs"), + include_str!("state.rs"), + include_str!("rows.rs"), + include_str!("capture.rs"), + include_str!("view.rs"), + include_str!("style.rs"), + ]; let mut missing = Vec::new(); for src in sources { let src = src.split("#[cfg(test)]").next().unwrap_or(src); diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs new file mode 100644 index 00000000..1618a4dd --- /dev/null +++ b/crates/keymap/src/ui/view.rs @@ -0,0 +1,1257 @@ +//! Drawing the dialog: header, table, detail pane, conflict bar, messages and footer. +//! +//! Widgets are created in focus order (§2.10 of the dialog plan): set row, Show, Filter, Search, +//! ⌨, table, detail pane, conflict bar, footer. egui's Tab order is creation order, so the bars +//! under the table are drawn last even though their height is needed first: the view uses last +//! frame's height and asks egui to discard a frame whose height changed. + +use std::ops::Range; + +use craft_ui::buttons::{IconButton, IconButtonStyle}; +use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; +use craft_ui::tabs::{self, Tab}; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, ComboBox, EventFilter, FontId, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, + ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, +}; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::keymap::{Binding, MAX_BINDINGS, Problem}; +use crate::scope::Scope; +use crate::ui::capture; +use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; +use crate::ui::style::{self, DialogStyle}; +use crate::ui::text::trigger_text; + +/// Below this width the detail pane goes under the table. +const NARROW: f32 = 720.0; +/// Smallest table or detail pane when dragging the divider. +const SPLIT_MIN: f32 = 120.0; +/// Icon buttons in rows and bars. +const ICON: f32 = 20.0; +/// Padding inside a chip. +const CHIP_PAD: f32 = 5.0; +const CHIP_GAP: f32 = 4.0; +/// The detail pane's New Shortcut field. +const FIELD_W: f32 = 160.0; + +/// One frame of the dialog body inside `ui`'s available rectangle. +pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { + let style = env.options.style.unwrap_or_else(|| DialogStyle::from_visuals(ui.visuals())); + let icons = style::icon_buttons(ui.visuals()); + let row_h = ui.text_style_height(&TextStyle::Body) + 6.0; + let base = ui.id().with("craft_keymap_shortcuts"); + let mut d = Draw { env, state, style, icons, base, row_h }; + d.keys(ui); + d.layout(ui); +} + +struct Draw<'s, 'e> { + env: &'s mut Env<'e>, + state: &'s mut DialogState, + style: DialogStyle, + icons: IconButtonStyle, + base: Id, + row_h: f32, +} + +fn fill(source: String, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&source, args) +} + +/// What a click in the table asked for, applied after the rows are drawn. +enum RowAction { + Select(CommandRef), + Record(CommandRef, Option, usize), + Reset(CommandRef), + Toggle(String), +} + +impl Draw<'_, '_> { + // ---- Text ---- + + /// The dialog's string, translated (logical order: for accessibility labels and `fill`). + fn ui(&self, source: &str) -> String { + self.env.text.ui(source).into_owned() + } + + /// Display order for drawn text. + fn vis(&self, s: &str) -> String { + self.env.text.visual(s) + } + + fn chord(&self, t: &Trigger) -> String { + trigger_text(self.env.text, t, self.env.keymap.platform()) + } + + fn scope_name(&self, s: &Scope) -> String { + if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } + } + + /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. + fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => format!("{chord} ({})", self.scope_name(s)), + _ => chord, + } + } + + fn label_of(&self, command: &CommandRef) -> String { + self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) + } + + /// "press keys", or the chord waiting for a second press. + fn recording_text(&self) -> String { + match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { + Some((t, _)) => fill(self.ui("{chord} (press again for a double press)"), &[("chord", &self.chord(t))]), + None => self.ui("press keys"), + } + } + + /// Reports a control to [`Options::on_widget`](crate::ui::Options::on_widget). + fn tag(&self, id: impl FnOnce() -> String, rect: Rect, label: &str) { + if let Some(f) = &self.env.options.on_widget { + f(&id(), rect, label); + } + } + + fn button(&self, ui: &mut Ui, enabled: bool, text: &str, id: &str) -> bool { + let r = ui.add_enabled(enabled, Button::new(self.vis(text))); + self.tag(|| id.to_string(), r.rect, text); + r.clicked() + } + + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) + } + + /// A row control: clickable and labelled but not a Tab stop (the table is one). + fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + let v = if !enabled { + self.icons.normal + } else if resp.is_pointer_button_down_on() { + self.icons.pressed.unwrap_or(self.icons.hovered) + } else if resp.hovered() { + self.icons.hovered + } else { + self.icons.normal + }; + if v.fill != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, self.icons.corner_radius, v.fill); + } + let color = if enabled { v.icon } else { self.style.dim.gamma_multiply(0.6) }; + paint(ui.painter(), rect, color); + resp + } + + /// Marks a text field of the dialog, so Escape, Enter, Undo and Delete leave it alone. + fn text_field(&mut self, r: &Response) { + if r.has_focus() { + self.state.text_focus = true; + } + } + + fn recording_target(&self) -> Option<&Target> { + self.state.recording.as_ref().map(|r: &Recording| &r.target) + } + + // ---- Keys when nothing records ---- + + /// Escape, Undo/Redo and Enter (§2.5). Keys while recording were taken by `DialogState::input`. + fn keys(&mut self, ui: &Ui) { + if self.state.is_recording() { + return; + } + let text_focus = self.state.text_focus; + let popup = egui::Popup::is_any_open(ui.ctx()); + if !text_focus && !popup && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Escape)) { + if self.state.typing.take().is_some() { + self.state.type_error = None; + } else { + self.state.escape(self.env); + } + return; + } + if text_focus || !self.env.options.editable { + return; + } + let mac = self.env.keymap.platform().is_mac(); + let (undo, redo) = ui.input_mut(|i| { + let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::Z) || (!mac && i.consume_key(Modifiers::COMMAND, EguiKey::Y)); + let undo = !redo && i.consume_key(Modifiers::COMMAND, EguiKey::Z); + (undo, redo) + }); + if redo { + self.state.redo(self.env); + } else if undo { + self.state.undo(self.env); + } + if self.env.options.enter_is_ok + && self.state.prompt.is_none() + && ui.memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Enter)) + { + self.state.enter(self.env); + } + } + + // ---- Layout ---- + + fn layout(&mut self, ui: &mut Ui) { + let avail = ui.available_rect_before_wrap(); + let size = |v: f32, or: f32| if v.is_finite() { v.clamp(0.0, 4096.0) } else { or }; + let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); + self.state.text_focus = false; + let gap = ui.spacing().item_spacing.y; + ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { + self.sets_row(ui); + self.header(ui); + let rest = ui.available_rect_before_wrap(); + let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); + let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); + let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); + if !self.review(ui, mid) { + self.middle(ui, mid); + } + let top = (mid.max.y + gap).min(rest.max.y); + let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); + let used = + ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(Layout::top_down(Align::Min)), |ui| self.bottom(ui)).response.rect.height(); + if (used - self.state.bottom_h).abs() > 0.5 { + self.state.bottom_h = used; + ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); + } + }); + ui.advance_cursor_after_rect(full); + } + + /// The set row: drawn here, above the header, when the app passes sets (§2.6). + fn sets_row(&mut self, _ui: &mut Ui) {} + + /// The import review takes the place of the table and detail pane while it is open (§2.7). + /// Returns whether it drew. + fn review(&mut self, _ui: &mut Ui, _rect: Rect) -> bool { + false + } + + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + f(self, ui); + }); + } + + // ---- Header: Show, Filter, Search ---- + + fn area_key(id: &AreaId) -> String { + match id { + AreaId::All => "all".into(), + AreaId::Menu(m) => format!("menu.{m}"), + AreaId::Tools => "tools".into(), + AreaId::Panel(p) => format!("panel.{p}"), + AreaId::Other => "other".into(), + AreaId::App(a) => (*a).to_string(), + AreaId::Fixed => "fixed".into(), + } + } + + fn header(&mut self, ui: &mut Ui) { + if self.env.options.area_tabs { + self.area_tabs(ui); + } + ui.horizontal(|ui| { + if !self.env.options.area_tabs { + self.area_combo(ui); + } + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + let combo = ComboBox::from_id_salt(self.base.with("filter")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.filter".into(), combo.rect, &filter_label); + if let Some(f) = pick { + self.state.set_filter(f); + } + + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let width = (ui.available_width() - ICON - ui.spacing().item_spacing.x).clamp(60.0, 240.0); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } + }); + } + + fn area_combo(&mut self, ui: &mut Ui) { + let show = self.ui("Show:"); + let l = ui.label(self.vis(&show)); + let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); + let mut pick = None; + let areas = self.state.areas.clone(); + let combo = ComboBox::from_id_salt(self.base.with("area")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for a in &areas { + let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); + if r.clicked() { + pick = Some(a.id.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.area".into(), combo.rect, &show); + if let Some(a) = pick { + self.state.set_area(a); + } + } + + /// Show as tabs, with the ones that do not fit in a menu. + fn area_tabs(&mut self, ui: &mut Ui) { + let areas = self.state.areas.clone(); + let pad = ui.spacing().button_padding.x * 2.0; + let font = TextStyle::Button.resolve(ui.style()); + let natural: Vec = + areas.iter().map(|a| ui.painter().layout_no_wrap(self.vis(&a.label), font.clone(), Color32::WHITE).size().x + pad).collect(); + let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); + let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let mut x = rect.min.x; + let mut pick = None; + for &(i, w) in &fit.shown { + let Some(a) = areas.get(i) else { continue }; + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height())); + x += w; + let on = i == selected; + let id = self.base.with(("area", i)); + let text = self.vis(&a.label); + let accent = self.style.accent; + let resp = Tab::new(id, &a.label, on).show_at(ui, r, |ui, resp| { + let v = ui.visuals(); + let color = if on { v.strong_text_color() } else { v.text_color() }; + if resp.hovered() && !on { + ui.painter().rect_filled(r, 2.0, v.widgets.hovered.weak_bg_fill); + } + if on { + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); + } + let g = ui.painter().layout(text, font.clone(), color, r.width() - 4.0); + ui.painter().galley(r.center() - g.size() / 2.0, g, color); + }); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); + if resp.clicked() { + pick = Some(a.id.clone()); + } + } + if !fit.overflow.is_empty() { + let r = Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height())); + let more = self.ui("More"); + ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { + ui.menu_button(self.vis(&more), |ui| { + for &i in &fit.overflow { + if let Some(a) = areas.get(i) + && ui.button(self.vis(&a.label)).clicked() + { + pick = Some(a.id.clone()); + } + } + }); + }); + } + if let Some(a) = pick { + self.state.set_area(a); + } + } + + // ---- Table and detail pane ---- + + fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let wide = mid.width() >= NARROW; + let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; + // Right to left: the detail pane goes on the left. + let swap = wide && self.env.text.rtl(); + let ratio = if swap { 1.0 - self.state.split } else { self.state.split }; + let extent = if wide { mid.width() } else { mid.height() }; + let Some(split) = split_rect(mid, axis, SplitSize::Ratio(ratio), 8.0, SPLIT_MIN.min(extent / 3.0)) else { return }; + let (table, detail) = if swap { (split.second, split.first) } else { (split.first, split.second) }; + self.child(ui, table, |d, ui| d.table(ui)); + let label = self.ui("Detail pane size"); + let resp = ResizeHandle::new(self.base.with("split"), axis, split.divider, &label, style::divider(ui.visuals())) + .value(split.first_size(), 0.0..=split.available()) + .show(ui); + let line = ui.visuals().widgets.noninteractive.bg_stroke; + let c = split.divider.center(); + let ends = if wide { + [pos2(c.x, split.divider.min.y), pos2(c.x, split.divider.max.y)] + } else { + [pos2(split.divider.min.x, c.y), pos2(split.divider.max.x, c.y)] + }; + ui.painter().line_segment(ends, line); + if resp.delta != 0.0 + && let Some(SplitSize::Ratio(r)) = split.resized(SplitSize::Ratio(ratio), resp.delta, SPLIT_MIN) + { + self.state.split = if swap { 1.0 - r } else { r }; + } + self.child(ui, detail, |d, ui| d.detail(ui)); + } + + /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. + fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { + let widest = self + .state + .lines_cached() + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r) } else { None }) + .max_by_key(|r| r.chips.iter().map(|c| c.text.chars().count() + 2).sum::()); + let font = TextStyle::Body.resolve(ui.style()); + let chips: f32 = widest.map_or(0.0, |r| { + r.chips + .iter() + .map(|c| ui.painter().layout_no_wrap(c.text.clone(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP) + .sum() + }); + (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) + } + + fn table(&mut self, ui: &mut Ui) { + let rect = ui.max_rect(); + let table_id = self.base.with("table"); + let focus = ui.interact(rect, table_id, Sense::focusable_noninteractive()); + let commands = self.ui("Commands"); + focus.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, &commands)); + let has_focus = focus.has_focus(); + if has_focus && !self.state.is_recording() { + self.table_keys(ui); + } + let recording_cell = matches!(self.recording_target(), Some(Target::Cell { .. })); + if recording_cell && focus.lost_focus() { + self.state.stop_recording(); + } + if has_focus { + let filter = if recording_cell { + capture::FOCUS_LOCK + } else { + EventFilter { vertical_arrows: true, horizontal_arrows: true, tab: false, escape: false } + }; + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, filter)); + } + + let selected = self.state.selected.clone(); + let built = self.state.lines(self.env); + let (count, matches) = (built.lines.len(), built.matches); + let position = selected.as_ref().and_then(|c| built.position(c)); + let row_h = self.row_h; + let shortcut_w = self.shortcut_width(ui, rect.width()); + + // Column titles. + let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); + let font = TextStyle::Body.resolve(ui.style()); + let dim = self.style.dim; + let cmd_title = self.vis(&self.ui("Command")); + let key_title = self.vis(&self.ui("Shortcut")); + ui.painter().text(pos2(head.min.x + 6.0, head.center().y), egui::Align2::LEFT_CENTER, cmd_title, font.clone(), dim); + ui.painter().text(pos2(head.max.x - shortcut_w - ICON - 4.0, head.center().y), egui::Align2::LEFT_CENTER, key_title, font, dim); + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); + + if matches == 0 { + ui.add_space(8.0); + ui.label(RichText::new(self.vis(&self.ui("No matching commands"))).color(dim)); + } + + let mut scroll = ScrollArea::vertical().id_salt(self.base.with("rows")).auto_shrink([false, false]); + if std::mem::take(&mut self.state.reveal) + && let Some(pos) = position + { + let inside = self.state.visible.start < pos && pos + 1 < self.state.visible.end; + if !inside { + let view = ui.available_height(); + scroll = scroll.vertical_scroll_offset((pos as f32 * row_h - (view - row_h) / 2.0).max(0.0)); + } + } + let mut action = None; + let mut shown = 0..0; + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + scroll.show_rows(ui, row_h, count, |ui, range| { + shown = range.clone(); + action = self.rows(ui, range, shortcut_w, has_focus); + }); + }); + self.state.visible = shown; + if has_focus { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + + if let Some(a) = action { + focus.request_focus(); + match a { + RowAction::Select(c) => self.state.select_row(&c), + RowAction::Record(c, old, chip) => { + self.state.record(self.env, Target::Cell { command: c, old }); + self.state.chip = chip; + } + RowAction::Reset(c) => { + self.state.select_row(&c); + self.state.use_default(self.env, &c); + } + RowAction::Toggle(name) => self.state.toggle_group(&name), + } + if self.state.is_recording() { + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, capture::FOCUS_LOCK)); + } + } + } + + /// Up/Down, Home/End, Left/Right between chips, Enter records, Delete removes (§2.10). + fn table_keys(&mut self, ui: &Ui) { + let keys = [ + EguiKey::ArrowUp, + EguiKey::ArrowDown, + EguiKey::Home, + EguiKey::End, + EguiKey::ArrowLeft, + EguiKey::ArrowRight, + EguiKey::Enter, + EguiKey::Delete, + ]; + let pressed = ui.input_mut(|i| keys.map(|k| i.consume_key(Modifiers::NONE, k))); + let [up, down, home, end, left, right, enter, delete] = pressed; + if !pressed.contains(&true) { + return; + } + let rows: Vec<(CommandRef, Vec)> = self + .state + .lines(self.env) + .lines + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some((r.command.clone(), r.chips.iter().map(|c| c.binding.clone()).collect())) } else { None }) + .collect(); + let at = self.state.selected.as_ref().and_then(|s| rows.iter().position(|(c, _)| c == s)); + let to = if up { + Some(at.map_or(0, |i| i.saturating_sub(1))) + } else if down { + Some(at.map_or(0, |i| (i + 1).min(rows.len().saturating_sub(1)))) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some((command, _)) = to.and_then(|i| rows.get(i)) { + self.state.select_row(command); + self.state.reveal = true; + return; + } + let Some((command, bindings)) = at.and_then(|i| rows.get(i)) else { return }; + let chip = self.state.chip.min(bindings.len().saturating_sub(1)); + if left { + self.state.chip = chip.saturating_sub(1); + } else if right { + self.state.chip = (chip + 1).min(bindings.len().saturating_sub(1)); + } else if enter && self.env.options.editable { + self.state.record(self.env, Target::Cell { command: command.clone(), old: bindings.get(chip).cloned() }); + self.state.chip = chip; + } else if delete + && self.env.options.editable + && let Some(b) = bindings.get(chip) + { + self.state.remove(self.env, command, b); + self.state.chip = chip.saturating_sub(1); + } + } + + fn rows(&mut self, ui: &mut Ui, range: Range, shortcut_w: f32, focused: bool) -> Option { + let lines: Vec = self.state.lines(self.env).lines.get(range).map(<[Line]>::to_vec).unwrap_or_default(); + let mut action = None; + for line in lines { + let (_, rect) = ui.allocate_space(vec2(ui.available_width(), self.row_h)); + let a = match &line { + Line::Header { name, collapsed } => self.group_header(ui, rect, name, *collapsed), + Line::Row(r) => self.row(ui, rect, r, shortcut_w, focused), + Line::Fixed(f) => { + self.fixed_row(ui, rect, f, shortcut_w); + None + } + }; + action = action.or(a); + } + action + } + + fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::CLICK); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); + let v = ui.visuals(); + let color = v.strong_text_color(); + let tri = Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0)); + let points = if collapsed { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + } else { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + }; + ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); + let font = TextStyle::Body.resolve(ui.style()); + let g = ui.painter().layout_job(truncated(&self.vis(name), font, color, rect.width() - 24.0)); + ui.painter().galley(pos2(rect.min.x + 20.0, rect.center().y - g.size().y / 2.0), g, color); + resp.clicked().then(|| RowAction::Toggle(name.to_string())) + } + + fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { + let editable = self.env.options.editable; + let selected = self.state.selected.as_ref() == Some(&r.command); + let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); + let mut a11y = r.label.clone(); + if r.cycles { + a11y = format!("{a11y} {}", self.ui("(cycles)")); + } + if r.changed { + a11y = format!("{a11y} ({})", self.ui("Changed")); + } + resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, selected, &a11y)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); + self.tag(|| format!("shortcuts.row.{}", r.key), rect, &r.label); + let v = ui.visuals(); + if selected { + let fill = if focused { v.selection.bg_fill } else { v.selection.bg_fill.gamma_multiply(0.5) }; + ui.painter().rect_filled(rect, 0.0, fill); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); + } + if r.changed { + ui.painter().rect_filled(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0)), 1.0, self.style.changed); + } + let text_color = if selected { v.strong_text_color() } else { v.text_color() }; + let strong = v.strong_text_color(); + let dim = self.style.dim; + let font = TextStyle::Body.resolve(ui.style()); + + // Label column. + let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); + let mut job = LayoutJob::default(); + if r.conflict { + job.append("⚠ ", 0.0, TextFormat::simple(font.clone(), self.style.warning)); + } + for p in &r.prefix { + job.append(&format!("{} › ", self.vis(p)), 0.0, TextFormat::simple(font.clone(), dim)); + } + job.append(&self.vis(&r.label), 0.0, TextFormat::simple(font.clone(), if r.changed { strong } else { text_color })); + if r.cycles { + job.append(&format!(" {}", self.vis(&self.ui("(cycles)"))), 0.0, TextFormat::simple(font.clone(), dim)); + } + job.wrap = TextWrapping::truncate_at_width(label_w); + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, text_color); + + // Shortcut column: chips, then "+". + let mut action = None; + let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); + let painter = ui.painter_at(col); + let mut x = col.min.x; + let rec = match self.recording_target() { + Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), + _ => None, + }; + let chip_h = rect.height() - 4.0; + let mut cells: Vec<(Option, String, bool)> = + r.chips.iter().map(|c| (Some(c.binding.clone()), c.text.clone(), rec.as_ref() == Some(&Some(c.binding.clone())))).collect(); + if rec == Some(None) { + cells.push((None, String::new(), true)); + } + for (n, (binding, text, recording)) in cells.iter().enumerate() { + let shown = if *recording { self.recording_text() } else { text.clone() }; + let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); + let w = g.size().x + 2.0 * CHIP_PAD; + let chip = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h)); + x += w + CHIP_GAP; + let changed = r.chips.get(n).is_some_and(|c| c.changed); + let stroke = if *recording { + Stroke::new(1.5, self.style.accent) + } else if changed { + Stroke::new(1.0, self.style.changed) + } else { + v.widgets.inactive.bg_stroke + }; + let current_chip = selected && focused && n == self.state.chip; + painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); + painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); + let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); + let label = if *recording && waiting { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &shown)]) + } else if *recording { + fill(self.ui("{label}: press keys"), &[("label", &r.label)]) + } else { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) + }; + let resp = ui.interact(chip.intersect(col), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), binding.clone(), n)); + } + } + if cells.is_empty() { + // An empty cell to click for the first shortcut. + let cell = Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y)); + let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); + let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.0", r.key), cell, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, 0)); + } + x = col.min.x; + } + if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { + let plus = Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); + self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); + } + } + if editable { + let reset = Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); + self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); + if resp.clicked() { + action = Some(RowAction::Reset(r.command.clone())); + } + } + + let clicked = resp.clicked(); + let mut tip = vec![self.vis(&r.label)]; + if let Some(b) = self.env.keymap.bindable(&r.command) { + if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { + tip.push(path.iter().map(|s| self.vis(&self.env.text.segment(s))).collect::>().join(" › ")); + } + tip.push(r.command.id.clone()); + if let Some(h) = self.env.text.hint(b) { + tip.push(self.vis(&h)); + } + } + resp.on_hover_text(tip.join("\n")); + action.or_else(|| clicked.then(|| RowAction::Select(r.command.clone()))) + } + + /// A read-only row from `Options::fixed`, struck through when a command took its chord. + fn fixed_row(&self, ui: &mut Ui, rect: Rect, f: &FixedLine, shortcut_w: f32) { + let v = ui.visuals(); + let font = TextStyle::Body.resolve(ui.style()); + let color = if f.now_used_by.is_some() { self.style.dim } else { v.text_color() }; + let mut fmt = TextFormat::simple(font.clone(), color); + if f.now_used_by.is_some() { + fmt.strikethrough = Stroke::new(1.0, color); + } + let mut job = LayoutJob::single_section(self.vis(&f.label), fmt.clone()); + job.wrap = TextWrapping::truncate_at_width((rect.width() - shortcut_w - ICON - 12.0).max(0.0)); + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); + let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); + ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); + if let Some(by) = &f.now_used_by { + let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{}: {} ({tip})", f.label, f.keys))); + resp.on_hover_text(self.vis(&tip)); + } + } + + fn detail(&mut self, ui: &mut Ui) { + ScrollArea::vertical().id_salt(self.base.with("detail")).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(4.0); + let Some(command) = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()) else { + ui.label(RichText::new(self.vis(&self.ui("Select a command to see and change its shortcuts"))).color(self.style.dim)); + return; + }; + self.detail_of(ui, &command); + }); + } + + fn detail_of(&mut self, ui: &mut Ui, command: &CommandRef) { + let Some(b) = self.env.keymap.bindable(command).cloned() else { return }; + let editable = self.env.options.editable; + let label = self.env.text.command(&b).into_owned(); + let home = Some(&b.scope); + let dim = self.style.dim; + ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); + let mut place: Vec = b.paths.first().map(|p| p.iter().map(|s| self.vis(&self.env.text.segment(s))).collect()).unwrap_or_default(); + place.retain(|s| !s.is_empty()); + let place = if place.is_empty() { b.command.id.clone() } else { format!("{} · {}", place.join(" › "), b.command.id) }; + ui.add(Label::new(RichText::new(place).color(dim)).wrap()); + if let Some(h) = self.env.text.hint(&b) { + ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); + } + ui.add_space(6.0); + + // Current shortcuts, each with Remove. + ui.label(self.vis(&self.ui("Current shortcuts:"))); + let bindings = self.env.keymap.bindings(command); + let defaults = self.env.keymap.default_bindings(command); + if bindings.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("None"))).color(dim)); + } + let mut remove = None; + for (n, binding) in bindings.iter().enumerate() { + let text = self.binding_text(binding, home); + ui.horizontal(|ui| { + ui.add_space(8.0); + let shown = if defaults.contains(binding) { format!("{text} ({})", self.vis(&self.ui("Default"))) } else { text.clone() }; + ui.label(shown); + if editable { + let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); + let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); + self.tag(|| format!("shortcuts.remove.{n}"), r.rect, &a11y); + if r.clicked() { + remove = Some(binding.clone()); + } + } + }); + } + if let Some(binding) = remove { + self.state.remove(self.env, command, &binding); + } + if !editable { + self.defaults_line(ui, &defaults, home); + return; + } + ui.add_space(6.0); + self.new_shortcut(ui, command, home); + self.context(ui, command); + self.holders(ui); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if self.button(ui, self.state.field.is_some(), &self.ui("Assign"), "shortcuts.assign") { + self.state.assign_field(self.env); + } + if self.button(ui, DialogState::can_use_default(self.env, command), &self.ui("Use Default"), "shortcuts.useDefault") { + self.state.use_default(self.env, command); + } + if self.button(ui, !bindings.is_empty(), &self.ui("Clear"), "shortcuts.clear") { + self.state.clear(self.env, command); + } + }); + self.defaults_line(ui, &defaults, home); + } + + fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { + let chords = + if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; + ui.add(Label::new(RichText::new(self.vis(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]))).color(self.style.dim)).wrap()); + } + + /// New Shortcut: a field that records when focused or clicked, or typed entry. + fn new_shortcut(&mut self, ui: &mut Ui, command: &CommandRef, home: Option<&Scope>) { + let title = self.ui("New shortcut:"); + let title_id = ui.label(self.vis(&title)).id; + let field_id = self.base.with("new"); + let recording = matches!(self.recording_target(), Some(Target::Field { command: c }) if c == command); + if let Some(mut typed) = self.state.typing.clone() { + let type_id = self.base.with("type"); + let edit = ui.add(TextEdit::singleline(&mut typed).id(type_id).desired_width(FIELD_W).hint_text("Cmd+Shift+K")); + let edit = edit.labelled_by(title_id); + // Focus once, on the frame after Type… was clicked. + if ui.data_mut(|d| d.remove_temp::(type_id)).is_some() { + edit.request_focus(); + } + self.text_field(&edit); + self.tag(|| "shortcuts.type".into(), edit.rect, &title); + let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(EguiKey::Enter)); + self.state.typing = Some(typed.clone()); + if enter { + match self.state.type_binding(self.env, command, &typed) { + Ok(()) => { + self.state.typing = None; + self.state.type_error = None; + } + Err(e) => self.state.type_error = Some(e), + } + } + if let Some(e) = &self.state.type_error { + ui.add(Label::new(RichText::new(format!("⚠ {e}")).color(self.style.warning)).wrap()); + } + return; + } + ui.horizontal(|ui| { + let shown = if recording { + self.recording_text() + } else { + self.state.field.as_ref().filter(|f| f.command == *command).map_or_else(String::new, |f| self.binding_text(&f.binding, home)) + }; + let size = vec2(FIELD_W, ui.spacing().interact_size.y); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, field_id, Sense::click()); + let a11y = format!("{title} {}", if shown.is_empty() { self.ui("None") } else { shown.clone() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &a11y)); + let v = ui.visuals(); + let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; + ui.painter().rect(rect, 3.0, v.extreme_bg_color, stroke, StrokeKind::Inside); + let color = if recording { self.style.dim } else { v.text_color() }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, shown, TextStyle::Body.resolve(ui.style()), color); + self.tag(|| "shortcuts.new".into(), rect, &title); + if !recording && (resp.clicked() || resp.gained_focus()) { + self.state.record(self.env, Target::Field { command: command.clone() }); + resp.request_focus(); + capture::lock_focus(ui, field_id); + } else if recording { + if resp.lost_focus() { + self.state.stop_recording(); + } else { + capture::lock_focus(ui, field_id); + } + } + if ui.link(self.vis(&self.ui("Type…"))).clicked() { + self.state.stop_recording(); + self.state.typing = Some(String::new()); + self.state.type_error = None; + let type_id = self.base.with("type"); + ui.data_mut(|d| d.insert_temp(type_id, true)); + } + }); + } + + fn context(&mut self, ui: &mut Ui, command: &CommandRef) { + let contexts = DialogState::contexts(self.env, command); + let name = |s: &Option| match s { + None => self.ui("Default"), + Some(s) => self.scope_name(s), + }; + let current = name(&self.state.context); + let names: Vec = contexts.iter().map(name).collect(); + let title = self.ui("Context:"); + let mut pick = None; + ui.horizontal(|ui| { + let l = ui.label(self.vis(&title)); + let combo = ComboBox::from_id_salt(self.base.with("context")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for (s, n) in contexts.iter().zip(&names) { + if ui.selectable_label(*s == self.state.context, self.vis(n)).clicked() { + pick = Some(s.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.context".into(), combo.rect, &title); + }); + if let Some(s) = pick { + self.state.set_context(self.env, s); + } + } + + /// "Currently assigned to:" for the field's chord, with links to the holders. + fn holders(&mut self, ui: &mut Ui) { + let Some(f) = self.state.field.clone() else { return }; + if f.holders.is_empty() && f.shadows.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Not assigned"))).color(self.style.dim)); + return; + } + ui.label(self.vis(&self.ui("Currently assigned to:"))); + let mut go = None; + for h in &f.holders { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); + let text = match &h.binding.scope { + Some(s) if Some(s) != home.as_ref() => format!("{label} ({})", self.scope_name(s)), + _ => label, + }; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(format!("⚠ {}", self.vis(&text))).clicked() { + go = Some(h.command.clone()); + } + }); + } + for h in &f.shadows { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()).unwrap_or_else(Scope::app); + let scope = self.scope_name(h.binding.scope_or(&home)); + let text = fill(self.ui("{label} ({scope}, both work)"), &[("label", &label), ("scope", &scope)]); + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.vis(&text)).clicked() { + go = Some(h.command.clone()); + } + }); + } + if let Some(c) = go { + self.state.go_to(&c); + } + } + + // ---- Under the table ---- + + fn bottom(&mut self, ui: &mut Ui) { + self.prompt(ui); + self.conflict_bar(ui); + self.message(ui); + self.info(ui); + self.footer(ui); + } + + fn prompt(&mut self, ui: &mut Ui) { + let Some(p) = self.state.prompt.clone() else { return }; + let (text, yes, no) = match &p { + Prompt::ResetAll => (self.ui("Restore every default shortcut in this set?"), self.ui("Reset All"), None), + Prompt::DeleteSet(set) => (fill(self.ui("Delete the set “{set}”?"), &[("set", set)]), self.ui("Delete"), None), + Prompt::SaveChanges { set, .. } => { + (fill(self.ui("Save changes to “{set}”?"), &[("set", set)]), self.ui("Save"), Some(self.ui("Don't Save"))) + } + }; + ui.add(Label::new(RichText::new(self.vis(&text)).strong()).wrap()); + let mut answer = None; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &yes, "shortcuts.prompt.yes") { + answer = Some(Answer::Yes); + } + if let Some(no) = &no + && self.button(ui, true, no, "shortcuts.prompt.no") + { + answer = Some(Answer::No); + } + if self.button(ui, true, &self.ui("Cancel"), "shortcuts.prompt.cancel") { + answer = Some(Answer::Cancel); + } + }); + if let Some(a) = answer { + self.state.answer(self.env, a); + } + } + + fn conflict_bar(&mut self, ui: &mut Ui) { + let Some(c) = self.state.conflict.clone() else { return }; + ui.add(Label::new(RichText::new(self.vis(&c.text)).color(self.style.warning)).wrap()); + let mut act = 0; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { + act = 1; + } + if self.button(ui, true, &self.ui("Take It and Go to Conflict"), "shortcuts.conflict.goto") { + act = 2; + } + if self.env.options.allow_keep && self.button(ui, true, &self.ui("Keep Both"), "shortcuts.conflict.keep") { + act = 3; + } + let label = self.ui("Cancel the new shortcut"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.conflict.cancel".into(), r.rect, &label); + if r.clicked() { + act = 4; + } + }); + match act { + 1 => self.state.take_it(self.env), + 2 => self.state.take_it_and_go(self.env), + 3 => self.state.keep_both(self.env), + 4 => self.state.dismiss_conflict(), + _ => {} + } + } + + fn message(&mut self, ui: &mut Ui) { + let Some(m) = self.state.message.clone() else { return }; + let mut act = 0; + ui.horizontal_wrapped(|ui| { + let text = if m.refused { format!("⚠ {}", self.vis(&m.text)) } else { self.vis(&m.text) }; + let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; + ui.add(Label::new(RichText::new(text).color(color)).wrap()); + if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { + act = 1; + } + if m.undo && self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.message.undo") { + act = 2; + } + }); + match (act, &m.goto) { + (1, Some(c)) => self.state.go_to(c), + (2, _) => self.state.undo(self.env), + _ => {} + } + } + + /// Clashes, shadows and problems in the keymap as a whole. + fn info(&mut self, ui: &mut Ui) { + let built = self.state.lines(self.env); + let (clashes, shadows) = (built.clashes, built.shadows); + let problems = self.env.keymap.problems(); + if clashes == 0 && shadows == 0 && problems.is_empty() { + return; + } + let mut conflicts = false; + ui.horizontal_wrapped(|ui| { + if clashes > 0 { + let n = clashes as u64; + let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); + conflicts = ui.link(format!("⚠ {}", self.vis(&text))).clicked(); + } + if shadows > 0 { + let n = shadows as u64; + let text = self.env.text.plural(n, "{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + }); + if conflicts { + self.state.set_filter(Filter::Conflicts); + } + if !problems.is_empty() { + let n = problems.len() as u64; + let title = self.env.text.plural(n, "{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"); + egui::CollapsingHeader::new(self.vis(&title)).id_salt(self.base.with("problems")).show(ui, |ui| { + ScrollArea::vertical().max_height(self.row_h * 4.0).show(ui, |ui| { + for p in &problems { + ui.add(Label::new(self.vis(&self.problem(p))).wrap()); + } + }); + }); + } + } + + fn problem(&self, p: &Problem) -> String { + match p { + Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), + Problem::BadBinding { key, text, error } => { + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error.to_string())]) + } + Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), + Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), + Problem::DuplicateKey { key } => fill(self.ui("{key}: listed more than once"), &[("key", key)]), + Problem::Dropped { key, .. } => fill(self.ui("{key}: part of the entry was dropped"), &[("key", key)]), + Problem::TooManyCommands { ignored } => { + self.env.text.plural(*ignored as u64, "{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands") + } + } + } + + fn footer(&mut self, ui: &mut Ui) { + let editable = self.env.options.editable; + let right: Vec<(String, &str)> = if editable { + vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] + } else { + vec![(self.ui("Close"), "shortcuts.ok")] + }; + ui.horizontal(|ui| { + if editable { + if self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + self.state.undo(self.env); + } + if self.button(ui, self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + self.state.redo(self.env); + } + if self.button(ui, !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + self.state.ask_reset_all(); + } + } + let gap = ui.spacing().item_spacing.x; + let pad = ui.spacing().button_padding.x * 2.0; + let min = ui.spacing().interact_size.x; + let width: f32 = right + .iter() + .map(|(t, _)| { + let g = WidgetText::from(self.vis(t)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(min) + gap + }) + .sum(); + ui.add_space((ui.available_width() - width).max(0.0)); + for (text, id) in &right { + if self.button(ui, true, text, id) { + if editable && *id == "shortcuts.ok" { + self.state.ok(self.env); + } else { + self.state.cancel(self.env); + } + } + } + }); + } +} + +fn truncated(text: &str, font: FontId, color: Color32, width: f32) -> LayoutJob { + let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color)); + job.wrap = TextWrapping::truncate_at_width(width.max(0.0)); + job +} + +// ---- Icons, drawn so they do not depend on the app's fonts ---- + +fn square(r: Rect, f: f32) -> Rect { + Rect::from_center_size(r.center(), Vec2::splat(r.width().min(r.height()) * f)) +} + +fn paint_plus(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.5); + let s = Stroke::new(1.5, c); + p.line_segment([r.center_top(), r.center_bottom()], s); + p.line_segment([r.left_center(), r.right_center()], s); +} + +fn paint_cross(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.45); + let s = Stroke::new(1.5, c); + p.line_segment([r.left_top(), r.right_bottom()], s); + p.line_segment([r.right_top(), r.left_bottom()], s); +} + +fn paint_reset(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.55); + let (center, radius) = (r.center(), r.width() / 2.0); + let s = Stroke::new(1.5, c); + let at = |a: f32| center + radius * Vec2::angled(a); + let start = -std::f32::consts::FRAC_PI_2; + let points: Vec<_> = (0..=12).map(|i| at(start + i as f32 / 12.0 * 1.6 * std::f32::consts::PI)).collect(); + p.add(egui::Shape::line(points, s)); + let tip = at(start); + p.line_segment([tip, tip + vec2(radius * 0.6, -radius * 0.35)], s); + p.line_segment([tip, tip + vec2(radius * 0.5, radius * 0.45)], s); +} + +fn paint_keyboard(p: &Painter, r: Rect, c: Color32) { + let r = Rect::from_center_size(r.center(), vec2(r.width() * 0.7, r.height() * 0.5)); + p.rect_stroke(r, 2.0, Stroke::new(1.2, c), StrokeKind::Inside); + for row in 0..2 { + for col in 0..4 { + let x = r.min.x + r.width() * (col as f32 + 0.75) / 4.5; + let y = r.min.y + r.height() * (row as f32 + 1.0) / 4.0; + p.circle_filled(pos2(x, y), 0.9, c); + } + } + p.line_segment( + [pos2(r.min.x + r.width() * 0.3, r.max.y - r.height() * 0.22), pos2(r.max.x - r.width() * 0.3, r.max.y - r.height() * 0.22)], + Stroke::new(1.2, c), + ); +} diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs new file mode 100644 index 00000000..dc88b091 --- /dev/null +++ b/crates/keymap/tests/ui_dialog.rs @@ -0,0 +1,517 @@ +#![cfg(feature = "ui")] +//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, +//! platform display, accessibility labels and small windows. + +use std::borrow::Cow; + +use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger}; +use egui::accesskit::Role; +use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use ui_fixture::fixture; + +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, + text: Box, + window: bool, + outcomes: Vec, + /// A key press was still in the frame's input after the dialog ran. + keys_left: bool, +} + +impl Fx { + fn new(km: Keymap) -> Fx { + Fx { + km, + options: Options::default(), + state: DialogState::new(), + text: Box::new(English), + window: false, + outcomes: Vec::new(), + keys_left: false, + } + } + + fn bindings(&self, id: &str) -> Vec { + self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() + } + + fn cancelled(&self) -> bool { + self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) + } +} + +fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { + let mut h = Harness::builder().with_size(size).build_ui_state( + |ui, fx: &mut Fx| { + let Fx { km, options, state, text, window, outcomes, keys_left } = fx; + let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; + outcomes.extend(out); + *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); + }, + fx, + ); + h.run(); + h +} + +fn open(fx: Fx) -> Harness<'static, Fx> { + harness(fx, vec2(1000.0, 700.0)) +} + +fn mac() -> Harness<'static, Fx> { + open(Fx::new(fixture(Platform::Mac))) +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn press(h: &mut Harness<'_, Fx>, modifiers: Modifiers, key: EguiKey) { + h.key_press_modifiers(modifiers, key); + h.run(); +} + +fn raw_key(h: &mut Harness<'_, Fx>, key: EguiKey, time: f64) { + h.input_mut().time = Some(time); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().time = Some(time + 0.01); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn has(h: &Harness<'_, Fx>, label: &str) -> bool { + h.query_by_label(label).is_some() +} + +fn table_focused(h: &Harness<'_, Fx>) -> bool { + h.get_by_label("Commands").is_focused() +} + +fn chord(text: &str, platform: Platform) -> String { + Trigger::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")).display(platform) +} + +// ---- Recording ---- + +#[test] +fn recording_captures_a_chord_another_command_would_run() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + assert!(has(&h, "Save: press keys")); + h.state_mut().keys_left = false; + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(!h.state().keys_left, "the dialog took the key press"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "nothing applied before an answer"); +} + +#[test] +fn recording_takes_tab_arrows_and_shift_escape() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert_eq!(h.state().bindings("file.save"), ["Tab"]); + assert!(table_focused(&h), "Tab did not move focus"); + + click(&mut h, &format!("Save: {}", chord("Tab", Platform::Mac))); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert_eq!(h.state().bindings("file.save"), ["Down"]); + + click(&mut h, &format!("Save: {}", chord("Down", Platform::Mac))); + press(&mut h, Modifiers::SHIFT, EguiKey::Escape); + assert_eq!(h.state().bindings("file.save"), ["Shift+Escape"]); + assert!(table_focused(&h)); + assert!(h.state().state.is_open()); +} + +#[test] +fn escape_cancels_recording_only() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().state.is_open()); + assert!(!h.state().state.is_recording()); + assert!(!h.state().cancelled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().cancelled()); + assert!(!h.state().state.is_open()); +} + +#[test] +fn copy_event_records_cmd_c() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + h.event(Event::Copy); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+C"]); +} + +#[test] +fn double_press_records_k_k() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.double_press = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + raw_key(&mut h, EguiKey::K, 10.0); + assert!(has(&h, "Save: K (press again for a double press)")); + raw_key(&mut h, EguiKey::K, 10.3); + assert_eq!(h.state().bindings("file.save"), ["K K"]); + + click(&mut h, "Save: K K"); + raw_key(&mut h, EguiKey::K, 20.0); + raw_key(&mut h, EguiKey::K, 20.9); + assert_eq!(h.state().bindings("file.save"), ["K"]); +} + +#[test] +fn the_new_shortcut_field_waits_for_assign() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(has(&h, "Currently assigned to:")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Assign"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + click(&mut h, "Keep Both"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); +} + +// ---- Conflicts ---- + +#[test] +fn conflict_take_it_steals_and_undo_restores() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It"); + assert_eq!(h.state().bindings("file.save"), Vec::::new()); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + assert!(has(&h, "⌘S was removed from Save")); + + // The footer's Undo is after the message's. + let undo = h.get_all_by_label("Undo").last().map(|n| n.rect()); + let Some(undo) = undo else { panic!("no Undo") }; + h.get_all_by_label("Undo").filter(|n| n.rect() == undo).for_each(|n| n.click()); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]); + click(&mut h, "Redo"); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"], "Cmd+Z undoes when no text field has focus"); +} + +#[test] +fn conflict_keep_both_lists_the_clash() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Keep Both"); + let clashes = h.state().km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + assert!(has(&h, "Also used by Save")); + click(&mut h, "⚠ 1 shortcut is assigned more than once"); + assert!(has(&h, "Save: ⌘S")); + assert!(has(&h, "Export…: ⌘S")); + assert!(!has(&h, "Save As…: ⇧⌘S"), "the Conflicts filter shows only the clash"); +} + +#[test] +fn steal_mode_says_what_it_took() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.conflicts = ConflictMode::Steal; + let mut h = open(fx); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "⌘S was removed from Save")); + click(&mut h, "Go to Conflict"); + assert!(selected(&h, "Save (Changed)")); +} + +/// Whether a row with this label reports itself selected. +fn selected(h: &Harness<'_, Fx>, label: &str) -> bool { + h.get_all_by_label(label).any(|n| n.accesskit_node().is_selected() == Some(true)) +} + +#[test] +fn go_to_conflict_selects_and_reveals_the_holder() { + let mut h = mac(); + choose_area(&mut h, "Tools"); + search(&mut h, "marquee"); + assert!(!has(&h, "Save: ⌘S")); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It and Go to Conflict"); + assert_eq!(h.state().bindings("tool.marquee"), ["Cmd+S"]); + assert!(selected(&h, "Save (Changed)"), "the holder is selected"); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), ""); + assert!(has(&h, "Export…: ⌘E"), "area All, no filter"); +} + +#[test] +fn reserved_chord_is_refused() { + let km = fixture(Platform::Mac).with_reserved(vec![Chord::parse("Cmd+Tab").unwrap_or_else(|e| panic!("{e}"))]); + let mut h = open(Fx::new(km)); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::COMMAND, EguiKey::Tab); + assert!(has(&h, &format!("⚠ {} is reserved by the system", chord("Cmd+Tab", Platform::Mac)))); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn app_rule_refuses() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.accept = Some(Box::new(|b, binding| { + if matches!(b.kind, Kind::Tool { .. }) && !binding.trigger.chord.mods.is_none() { + Err("Tools take keys without modifiers".into()) + } else { + Ok(()) + } + })); + let mut h = open(fx); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::K); + assert!(has(&h, "⚠ Tools take keys without modifiers")); + assert_eq!(h.state().bindings("tool.marquee"), ["M"]); +} + +// ---- Search, areas, display ---- + +fn search(h: &mut Harness<'_, Fx>, text: &str) { + let field = h.get_by_label("Search:"); + field.focus(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::A); + h.run(); + h.get_by_label("Search:").type_text(text); + h.run(); +} + +fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + h.get_by_role_and_label(Role::Button, area).click(); + h.run(); +} + +struct German; +impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if n == 1 { one } else { other }.replace("{n}", &n.to_string()) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.save" { "Sichern".into() } else { b.label.as_str().into() } + } +} + +#[test] +fn search_by_name_translation_or_chord() { + let mut h = mac(); + search(&mut h, "save"); + assert!(has(&h, "Save: ⌘S") && has(&h, "Save As…: ⇧⌘S")); + assert!(!has(&h, "Export…: ⌘E")); + search(&mut h, "cmd+s"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "⌘S"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "zzz"); + assert!(has(&h, "No matching commands")); + + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::S); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "⇧⌘S"); + assert!(has(&h, "Save As…: ⇧⌘S") && !has(&h, "Save: ⌘S")); + + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(German); + let mut h = open(fx); + search(&mut h, "sichern"); + assert!(has(&h, "Sichern: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); +} + +#[test] +fn areas_follow_paths_kind_and_scope() { + let mut h = mac(); + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { + assert!(h.get_all_by_label(area).count() >= 1, "{area}"); + } + h.get_by_role_and_label(Role::Button, "Tools").click(); + h.run(); + assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); + assert!(!has(&h, "Save: ⌘S")); + choose_area(&mut h, "Timeline Panel"); + assert!(has(&h, "Loop: L") && !has(&h, "Marquee Tool: M")); +} + +#[test] +fn display_follows_the_keymap_platform() { + let h = mac(); + assert!(has(&h, "Save As…: ⇧⌘S")); + assert!(has(&h, "Bold: ⌘B (Text)"), "a scope other than home is named"); + let h = open(Fx::new(fixture(Platform::Windows))); + assert!(has(&h, "Save As…: Ctrl+Shift+S")); +} + +// ---- Labels, focus, small windows, RTL ---- + +#[test] +fn every_control_has_a_label() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "Take It")); + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + if matches!(n.role(), Role::Button | Role::Tab | Role::TextInput | Role::ComboBox) { + seen += 1; + assert!(n.label().is_some_and(|l| !l.trim().is_empty()), "{:?} without a label at {:?}", n.role(), node.rect()); + } + } + assert!(seen > 20, "{seen} controls"); +} + +#[test] +fn table_keys_move_record_and_delete() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(table_focused(&h)); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert!(selected(&h, "Save As…")); + press(&mut h, Modifiers::NONE, EguiKey::Delete); + assert_eq!(h.state().bindings("file.saveAs"), Vec::::new()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(h.state().state.is_recording()); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); +} + +#[test] +fn small_window_keeps_ok_and_long_labels_truncate() { + let long = "A very long command label ".repeat(12); + let mut km = fixture(Platform::Mac); + let mut catalogue = km.catalogue().to_vec(); + catalogue.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut fx = Fx::new(km); + fx.window = true; + let size = vec2(480.0, 360.0); + let mut h = harness(fx, size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let ok = h.get_by_label("OK").rect(); + assert!(screen.contains_rect(ok), "OK at {ok:?}"); + let row = h.get_by_label(long.trim()); + assert!(row.rect().max.x <= size.x + 0.5, "the row stays inside: {:?}", row.rect()); + row.hover(); + h.run(); + assert!(h.query_all_by_label_contains(long.trim()).count() >= 2, "the tooltip has the full label"); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); +} + +#[test] +fn window_close_is_cancel() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.window = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert_eq!(h.state().bindings("file.save"), ["F2"]); + click(&mut h, "Cancel"); + assert!(h.state().cancelled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn automation_ids_are_reported() { + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut fx = Fx::new(fixture(Platform::Mac)); + let sink = seen.clone(); + fx.options.on_widget = Some(Box::new(move |id, rect, label| { + assert!(rect.is_finite() && !label.is_empty(), "{id}"); + sink.borrow_mut().push(id.to_string()); + })); + let mut h = open(fx); + h.get_by_label("Save").click(); + h.run(); + let ids = seen.borrow(); + for id in [ + "shortcuts.area", + "shortcuts.filter", + "shortcuts.search", + "shortcuts.searchKey", + "shortcuts.row.file.save", + "shortcuts.cell.file.save.0", + "shortcuts.add.file.save", + "shortcuts.reset.file.save", + "shortcuts.remove.0", + "shortcuts.new", + "shortcuts.context", + "shortcuts.assign", + "shortcuts.clear", + "shortcuts.undo", + "shortcuts.redo", + "shortcuts.resetAll", + "shortcuts.cancel", + "shortcuts.ok", + ] { + assert!(ids.iter().any(|s| s == id), "{id} not reported"); + } +} + +#[test] +fn typed_entry_parses_and_shows_errors() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + click(&mut h, "Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); +} + +struct Rtl; +impl Text for Rtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, _n: u64, _one: &str, other: &str) -> String { + other.to_string() + } + fn rtl(&self) -> bool { + true + } +} + +#[test] +fn rtl_puts_the_detail_pane_left_and_keeps_chords() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(Rtl); + let h = open(fx); + let table = h.get_by_label("Commands").rect(); + assert!(table.min.x > 300.0, "table on the right: {table:?}"); + assert!(has(&h, "Save As…: ⇧⌘S")); +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs new file mode 100644 index 00000000..3b4b7fd4 --- /dev/null +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -0,0 +1,24 @@ +//! The catalogue the dialog tests and screenshots use. + +use craft_keymap::{Bindable, Binding, Keymap, Kind, Platform, Scope, Scopes}; + +fn binding(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +pub fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs new file mode 100644 index 00000000..a0dd1f54 --- /dev/null +++ b/crates/keymap/tests/ui_snapshot.rs @@ -0,0 +1,94 @@ +#![cfg(feature = "snapshot")] +//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With +//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×; +//! without it the test does nothing. +//! +//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` + +use std::path::PathBuf; + +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; +use craft_keymap::{Keymap, Platform}; +use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ui_fixture::fixture; + +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, +} + +/// A named state of the dialog: window size and what to do before the picture. +struct Case { + name: &'static str, + size: Vec2, + platform: Platform, + act: fn(&mut Harness<'_, Fx>), +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +const CASES: &[Case] = &[ + Case { + name: "selected", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + act: |h| { + h.get_by_label("Save As…").click(); + h.run(); + }, + }, + Case { + name: "conflict", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + act: |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }, + }, + Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") }, + Case { + name: "narrow", + size: Vec2::new(560.0, 640.0), + platform: Platform::Mac, + act: |h| { + h.get_by_label("Save").click(); + h.run(); + }, + }, +]; + +#[test] +fn dialog_screenshots() { + let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + for case in CASES { + for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { + for scale in [1.0, 2.0] { + let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() }; + let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( + |ui, fx: &mut Fx| { + egui::Frame::central_panel(ui.style()).show(ui, |ui| { + ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state); + }); + }, + fx, + ); + h.run(); + (case.act)(&mut h); + let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); + let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name)); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } + } +} From d2b9f9183856bda1652eac855cabbcb332b3dbd1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:15:32 +0000 Subject: [PATCH 04/33] keymap: shortcuts dialog sets, import review and summary Set row with picker, New Set (based on current shortcuts or a set), Rename, Delete, Save, Revert Set, Export, Import and Summarize; actions that do not fit go into a More menu. The foreign import review lists what will change, suggestions to pick, unmatched, unreadable, approximated and matched-without-change entries, then the report of skipped, taken and failed changes with Copy Report. ui::summary writes the shortcut summary as HTML and plain text. --- crates/keymap/src/ui/mod.rs | 1 + crates/keymap/src/ui/review.rs | 323 +++++++++++++++++++++++++++++ crates/keymap/src/ui/rows.rs | 8 +- crates/keymap/src/ui/sets.rs | 217 +++++++++++++++++++ crates/keymap/src/ui/state.rs | 45 +++- crates/keymap/src/ui/summary.rs | 113 ++++++++++ crates/keymap/src/ui/text.rs | 41 ++++ crates/keymap/src/ui/view.rs | 40 ++-- crates/keymap/tests/ui_dialog.rs | 210 ++++++++++++++++++- crates/keymap/tests/ui_snapshot.rs | 60 +++++- 10 files changed, 1019 insertions(+), 39 deletions(-) create mode 100644 crates/keymap/src/ui/review.rs create mode 100644 crates/keymap/src/ui/sets.rs create mode 100644 crates/keymap/src/ui/summary.rs diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index f09dc8e4..5075b37e 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -19,6 +19,7 @@ mod capture; mod rows; mod state; mod style; +pub mod summary; mod text; mod view; diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs new file mode 100644 index 00000000..fe642cd6 --- /dev/null +++ b/crates/keymap/src/ui/review.rs @@ -0,0 +1,323 @@ +//! The foreign import review (§2.7 of the dialog plan). It takes the place of the table and +//! detail pane: what the set will change, what did not match and why, suggestions to pick from, +//! and after the import the report of what was skipped, taken or failed. + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; + +use super::{Draw, FIELD_W, fill}; +use crate::chord::foreign::{ForeignError, ReadNote}; +use crate::command::CommandRef; +use crate::import::{FailedChange, ImportPlan, ImportReport, NoChangeReason, SkipReason, UnmatchedReason}; + +/// One list row: the item, its line, its keys and a note. +struct Entry { + item: String, + line: Option, + keys: String, + note: String, + /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. + to: bool, +} + +enum Act { + Pick(usize, usize), + Import, + Close, + Copy(String), +} + +impl Draw<'_, '_> { + /// Draws the review in `rect` while one is open; returns whether it drew. + pub(super) fn review(&mut self, ui: &mut Ui, rect: Rect) -> bool { + let Some(r) = self.state.review.as_ref() else { return false }; + let (plan, name, done) = (r.plan.clone(), r.name.clone(), r.done.clone()); + let mut act = None; + self.child(ui, rect, |d, ui| { + act = match &done { + Some((set, report)) => d.after_report(ui, set, report), + None => d.plan_view(ui, &plan, name), + }; + }); + match act { + Some(Act::Pick(s, c)) => self.state.pick_suggestion(self.env, s, c), + Some(Act::Import) => self.state.import_reviewed(self.env), + Some(Act::Close) => self.state.close_review(), + Some(Act::Copy(text)) => ui.ctx().copy_text(text), + None => {} + } + true + } + + fn line_text(&self, line: Option) -> Option { + line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())])) + } + + /// "item · line n · keys — note" on one wrapping line, the item in the strong colour. + fn entry(&self, ui: &mut Ui, e: &Entry) { + let font = TextStyle::Body.resolve(ui.style()); + let strong = ui.visuals().strong_text_color(); + let dim = self.style.dim; + let mut job = LayoutJob::default(); + let mut add = |s: &str, color: Color32| job.append(s, 0.0, TextFormat::simple(font.clone(), color)); + add(&self.vis(&e.item), strong); + if let Some(line) = self.line_text(e.line) { + add(&format!(" · {}", self.vis(&line)), dim); + } + if !e.keys.is_empty() { + add(&format!(" · {}", e.keys), ui.visuals().text_color()); + } + if e.to { + add(&format!(" — {}", self.vis(&e.note)), ui.visuals().text_color()); + } else if !e.note.is_empty() { + add(&format!(" — {}", self.vis(&e.note)), dim); + } + ui.add(Label::new(job).wrap()); + } + + fn section(&self, ui: &mut Ui, title: &str, entries: &[Entry]) { + if entries.is_empty() { + return; + } + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{title} ({})", entries.len()))).strong()); + for e in entries { + self.entry(ui, e); + } + } + + fn unmatched_reason(&self, r: UnmatchedReason) -> String { + match r { + UnmatchedReason::NoMatch => self.ui("no command has this name"), + UnmatchedReason::Ambiguous => self.ui("more than one command fits"), + UnmatchedReason::Context => self.ui("in a context that is not imported"), + UnmatchedReason::Tool => self.ui("only a command of the other kind fits (tool or menu command)"), + } + } + + fn foreign_error(&self, e: &ForeignError) -> String { + match e { + ForeignError::Empty => self.ui("empty shortcut"), + ForeignError::TooLong => self.ui("the shortcut is too long"), + ForeignError::UnknownKey(k) => fill(self.ui("unknown key {key}"), &[("key", k)]), + ForeignError::UnsupportedModifier(m) => fill(self.ui("unsupported modifier {key}"), &[("key", m)]), + } + } + + fn read_note(&self, n: ReadNote) -> String { + match n { + ReadNote::Numpad => self.ui("numeric keypad key read as main key"), + } + } + + fn plan_view(&mut self, ui: &mut Ui, plan: &ImportPlan, mut name: String) -> Option { + let mut act = None; + let report = &plan.report; + let set = report.set.clone().unwrap_or_else(|| self.ui("Imported")); + ui.label(RichText::new(self.vis(&fill(self.ui("Import “{set}”"), &[("set", &set)]))).strong()); + + let t = self.env.text; + let mut counts = vec![t.plural(plan.commands.len() as u64, "{n} command will change", "{n} commands will change")]; + let more = [ + (report.unmatched.len(), t.plural(report.unmatched.len() as u64, "{n} item not matched", "{n} items not matched")), + ( + report.unmatched_without_shortcut, + t.plural(report.unmatched_without_shortcut as u64, "{n} item without a shortcut", "{n} items without a shortcut"), + ), + (report.unreadable.len(), t.plural(report.unreadable.len() as u64, "{n} item could not be read", "{n} items could not be read")), + (report.approximated.len(), t.plural(report.approximated.len() as u64, "{n} key read approximately", "{n} keys read approximately")), + (report.truncated, t.plural(report.truncated as u64, "{n} entry cut at a limit", "{n} entries cut at a limit")), + ]; + counts.extend(more.into_iter().filter(|(n, _)| *n > 0).map(|(_, s)| s)); + ui.label(RichText::new(self.vis(&counts.join(" · "))).color(self.style.dim)); + + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Set name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut name).id(self.base.with("import_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.import.name".into(), edit.rect, &name_label); + let import = if self.env.sets.is_some() { self.ui("Import as New Set") } else { self.ui("Import") }; + let valid = self.env.sets.is_none() || crate::profiles::set_name(&name).is_some(); + if self.button(ui, valid, &import, "shortcuts.import.ok") { + act = Some(Act::Import); + } + if self.button(ui, true, &self.ui("Cancel Import"), "shortcuts.import.cancel") { + act = Some(Act::Close); + } + }); + if let Some(r) = self.state.review.as_mut() { + r.name = name; + } + + ScrollArea::vertical().id_salt(self.base.with("review")).auto_shrink([false, false]).show(ui, |ui| { + let chord_list = |d: &Self, c: &crate::import::Planned| -> String { + let home = d.env.keymap.bindable(&c.command).map(|b| b.scope.clone()); + if c.bindings.is_empty() { + return d.ui("None"); + } + c.bindings.iter().map(|b| d.binding_text(&b.binding, home.as_ref())).collect::>().join(", ") + }; + let changes: Vec = plan + .commands + .iter() + .map(|c| Entry { + item: c.item.clone(), + line: c.line, + keys: String::new(), + note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chord_list(self, c))]), + to: true, + }) + .collect(); + self.section(ui, &self.ui("Will change"), &changes); + + if !report.suggestions.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); + for (si, s) in report.suggestions.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + self.entry(ui, &Entry { item: s.item.clone(), line: s.line, keys: String::new(), note: String::new(), to: false }); + let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]); + let choose = self.ui("Choose a command…"); + let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| { + for (ci, c) in s.candidates.iter().enumerate() { + if ui.selectable_label(false, self.vis(&self.label_of(c))).clicked() { + act = Some(Act::Pick(si, ci)); + } + } + }); + let resp = combo.response.on_hover_text(self.vis(&label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label)); + self.tag(|| format!("shortcuts.import.suggestion.{si}"), resp.rect, &label); + }); + } + } + + let unmatched: Vec = report + .unmatched + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.unmatched_reason(u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Not matched"), &unmatched); + let unreadable: Vec = report + .unreadable + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.foreign_error(&u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Unreadable"), &unreadable); + let approximated: Vec = report + .approximated + .iter() + .map(|a| Entry { item: a.item.clone(), line: a.line, keys: a.keys.clone(), note: self.read_note(a.note), to: false }) + .collect(); + self.section(ui, &self.ui("Approximated"), &approximated); + let kept: Vec = report + .matched_without_change + .iter() + .map(|m| Entry { + item: m.item.clone(), + line: m.line, + keys: String::new(), + note: match m.reason { + NoChangeReason::PanelMenu => { + fill(self.ui("{label} keeps its shortcuts (matched by a panel menu name)"), &[("label", &self.label_of(&m.command))]) + } + }, + to: false, + }) + .collect(); + self.section(ui, &self.ui("Matched without change"), &kept); + }); + act + } + + fn skip_note(&self, reason: SkipReason, kept_by: Option<&CommandRef>) -> String { + match (reason, kept_by) { + (SkipReason::Taken, Some(c)) => fill(self.ui("taken by {label}"), &[("label", &self.label_of(c))]), + (SkipReason::Taken, None) => self.ui("taken by a later entry"), + (SkipReason::Reserved, _) => self.ui("reserved by the system"), + (SkipReason::UnknownScope, _) => self.ui("the scope is unknown"), + } + } + + fn failed_note(&self, change: FailedChange) -> String { + match change { + FailedChange::Bind => self.ui("the set's shortcuts could not be added"), + FailedChange::Clear => self.ui("the shortcuts could not be removed"), + FailedChange::Unbind => self.ui("the shortcut could not be taken from it"), + } + } + + /// The report's lists: skipped keys, shortcuts taken from other commands, failed changes. + fn report_lists(&self, report: &ImportReport) -> [(String, Vec); 3] { + let skipped = report + .skipped + .iter() + .map(|s| Entry { + item: s.item.clone(), + line: s.line, + keys: s.shortcut.clone(), + note: self.skip_note(s.reason, s.kept_by.as_ref()), + to: false, + }) + .collect(); + let cleared = report + .conflicts_cleared + .iter() + .map(|c| Entry { item: self.label_of(&c.command), line: None, keys: c.shortcut.clone(), note: String::new(), to: false }) + .collect(); + let failed = report + .failed + .iter() + .map(|f| Entry { item: self.label_of(&f.command), line: None, keys: f.shortcuts.join(", "), note: self.failed_note(f.change), to: false }) + .collect(); + [(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)] + } + + fn after_report(&mut self, ui: &mut Ui, set: &str, report: &ImportReport) -> Option { + let mut act = None; + let heading = fill(self.ui("Imported as “{set}”"), &[("set", set)]); + ui.label(RichText::new(self.vis(&heading)).strong()); + let changed = self.env.text.plural(report.imported as u64, "{n} command changed", "{n} commands changed"); + ui.label(RichText::new(self.vis(&changed)).color(self.style.dim)); + let lists = self.report_lists(report); + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Copy Report"), "shortcuts.import.copy") { + act = Some(Act::Copy(self.report_text(&heading, &changed, &lists))); + } + if self.button(ui, true, &self.ui("Done"), "shortcuts.import.done") { + act = Some(Act::Close); + } + }); + ScrollArea::vertical().id_salt(self.base.with("report")).auto_shrink([false, false]).show(ui, |ui| { + for (title, entries) in &lists { + self.section(ui, title, entries); + } + }); + act + } + + /// The report as plain text for Copy Report (logical order). + fn report_text(&self, heading: &str, changed: &str, lists: &[(String, Vec)]) -> String { + let mut out = format!("{heading}\n{changed}\n"); + for (title, entries) in lists.iter().filter(|(_, e)| !e.is_empty()) { + out.push_str(&format!("\n{title}\n")); + for e in entries { + let mut line = format!(" {}", e.item); + for part in [self.line_text(e.line).unwrap_or_default(), e.keys.clone()] { + if !part.is_empty() { + line.push_str(" · "); + line.push_str(&part); + } + } + if !e.note.is_empty() { + line.push_str(" — "); + line.push_str(&e.note); + } + out.push_str(&line); + out.push('\n'); + } + } + out + } +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 69b0afaa..1e07a516 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -176,12 +176,6 @@ impl RowCache { pub(crate) fn built(&self) -> &Built { &self.built } - - /// Forgets the lines, for a change the key does not cover (the interface language). - #[allow(dead_code, reason = "for hosts that switch the interface language")] - pub(crate) fn invalidate(&mut self) { - self.key = None; - } } fn top_menu(b: &Bindable) -> Option<&str> { @@ -255,7 +249,7 @@ fn id_group(id: &str) -> String { /// A row's group header (`None`: no header) with its rank among groups, and how many path parts /// the header stands for. -fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { +pub(crate) fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth)); match area { diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs new file mode 100644 index 00000000..64e39f01 --- /dev/null +++ b/crates/keymap/src/ui/sets.rs @@ -0,0 +1,217 @@ +//! The set row (§2.6 of the dialog plan): the set picker, then New Set…, Rename…, Delete, Save, +//! Revert Set, Export, Import… and Summarize…; actions that do not fit go into a More menu. New +//! Set… and Rename… open an inline form under the row. + +use craft_ui::layout::fit_toolbar; +use egui::{ComboBox, Key as EguiKey, RichText, TextEdit, Ui}; + +use super::{Draw, FIELD_W}; +use crate::profiles::{Profiles, set_name}; +use crate::ui::state::{DialogState, SetForm, SetFormKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Act { + New, + Rename, + Delete, + Save, + Revert, + /// Export as craft-keymap JSON; with app formats the item is a menu. + Export, + ExportAs(&'static str), + Import, + Summarize, +} + +struct Item { + act: Act, + text: String, + id: &'static str, + enabled: bool, +} + +impl Draw<'_, '_> { + /// Drawn above the header when the app passes sets. + pub(super) fn sets_row(&mut self, ui: &mut Ui) { + let Some((p, b)) = self.env.sets.as_ref() else { return }; + let names = p.names(b); + let active = p.active_name(b).to_string(); + let builtin = Profiles::is_builtin(&active, b); + let modified = DialogState::is_modified(self.env); + let editable = self.env.options.editable; + let items = self.set_items(builtin, modified); + let mut act = None; + ui.add_enabled_ui(self.state.review.is_none(), |ui| { + ui.horizontal(|ui| { + let set_label = self.ui("Set:"); + let l = ui.label(self.vis(&set_label)); + let mut pick = None; + let combo = ui.add_enabled_ui(editable, |ui| { + ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == active, self.vis(n)).clicked() { + pick = Some(n.clone()); + } + } + }) + }); + let combo = combo.inner.response.labelled_by(l.id); + self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); + if modified { + ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); + } + if let Some(n) = pick.filter(|n| *n != active) { + self.state.set_form = None; + self.state.switch_set(self.env, &n); + } + act = self.set_toolbar(ui, &items); + }); + self.set_form(ui); + }); + if let Some(a) = act { + self.set_act(a, &active); + } + } + + fn set_items(&self, builtin: bool, modified: bool) -> Vec { + let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled }; + let mut items = Vec::new(); + if self.env.options.editable { + items.push(item(Act::New, "New Set…", "shortcuts.set.new", true)); + items.push(item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin)); + items.push(item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin)); + items.push(item(Act::Save, "Save", "shortcuts.set.save", modified)); + items.push(item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified)); + } + let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; + items.push(item(Act::Export, export, "shortcuts.set.export", true)); + if self.env.options.editable { + items.push(item(Act::Import, "Import…", "shortcuts.set.import", true)); + } + if self.env.options.summary { + items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true)); + } + items + } + + /// The actions that fit in the row's remaining width, then More with the rest. + fn set_toolbar(&self, ui: &mut Ui, items: &[Item]) -> Option { + let more = self.ui("More"); + let natural: Vec = items.iter().map(|i| self.button_width(ui, &i.text)).collect(); + let spacing = ui.spacing().item_spacing.x; + let (shown, overflow): (Vec, Vec) = match fit_toolbar(&natural, ui.available_width(), spacing, self.button_width(ui, &more)) { + Some(fit) => (fit.shown.iter().map(|&(i, _)| i).collect(), fit.overflow), + None => (Vec::new(), (0..items.len()).collect()), + }; + let mut act = None; + for i in shown { + act = act.or(self.set_item(ui, &items[i])); + } + if !overflow.is_empty() { + let r = ui.menu_button(self.vis(&more), |ui| { + for &i in &overflow { + act = act.or(self.set_item(ui, &items[i])); + } + }); + self.tag(|| "shortcuts.set.more".into(), r.response.rect, &more); + } + act + } + + fn set_item(&self, ui: &mut Ui, item: &Item) -> Option { + if item.act == Act::Export && !self.env.options.export_formats.is_empty() { + let mut act = None; + let r = ui.menu_button(self.vis(&item.text), |ui| { + if self.button(ui, true, &self.ui("Keyboard Shortcuts (JSON)"), "shortcuts.set.export.json") { + act = Some(Act::Export); + } + for f in &self.env.options.export_formats { + let label = self.env.text.ui(f.label).into_owned(); + if self.button(ui, true, &label, &format!("shortcuts.set.export.{}", f.id)) { + act = Some(Act::ExportAs(f.id)); + } + } + }); + self.tag(|| item.id.into(), r.response.rect, &item.text); + return act; + } + self.button(ui, item.enabled, &item.text, item.id).then_some(item.act) + } + + fn set_act(&mut self, act: Act, active: &str) { + let form = |kind, name: &str| Some(SetForm { kind, name: name.to_string(), focus: true }); + match act { + Act::New => self.state.set_form = form(SetFormKind::New { based_on: None }, ""), + Act::Rename => self.state.set_form = form(SetFormKind::Rename, active), + Act::Delete => self.state.ask_delete_set(self.env), + Act::Save => self.state.save_set(self.env), + Act::Revert => self.state.revert_set(self.env), + Act::Export => self.state.export_set(self.env), + Act::ExportAs(f) => self.state.export_as(self.env, f), + Act::Import => self.state.request_import(self.env), + Act::Summarize => self.state.summarize(self.env), + } + } + + /// New Set… (Name, Based on) or Rename… (Name), under the set row. + fn set_form(&mut self, ui: &mut Ui) { + let Some(mut form) = self.state.set_form.clone() else { return }; + let names = self.env.sets.as_ref().map(|(p, b)| p.names(b)).unwrap_or_default(); + let (mut submit, mut cancel) = (false, false); + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut form.name).id(self.base.with("set_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.setForm.name".into(), edit.rect, &name_label); + if std::mem::take(&mut form.focus) { + edit.request_focus(); + } + if edit.lost_focus() { + let (enter, escape) = ui.input(|i| (i.key_pressed(EguiKey::Enter), i.key_pressed(EguiKey::Escape))); + submit |= enter; + cancel |= escape; + } + if let SetFormKind::New { based_on } = &mut form.kind { + let based_label = self.ui("Based on:"); + let l = ui.label(self.vis(&based_label)); + let current = self.ui("Current shortcuts"); + let shown = based_on.clone().unwrap_or_else(|| current.clone()); + let combo = ComboBox::from_id_salt(self.base.with("based_on")).selected_text(self.vis(&shown)).show_ui(ui, |ui| { + if ui.selectable_label(based_on.is_none(), self.vis(¤t)).clicked() { + *based_on = None; + } + for n in &names { + if ui.selectable_label(based_on.as_ref() == Some(n), self.vis(n)).clicked() { + *based_on = Some(n.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.setForm.basedOn".into(), combo.rect, &based_label); + } + let ok = match form.kind { + SetFormKind::New { .. } => self.ui("Create"), + SetFormKind::Rename => self.ui("Rename"), + }; + submit |= self.button(ui, set_name(&form.name).is_some(), &ok, "shortcuts.setForm.ok"); + cancel |= self.button(ui, true, &self.ui("Cancel"), "shortcuts.setForm.cancel"); + }); + if cancel { + self.state.set_form = None; + return; + } + if !submit || set_name(&form.name).is_none() { + self.state.set_form = Some(form); + return; + } + self.state.message = None; + match &form.kind { + SetFormKind::New { based_on } => self.state.new_set(self.env, &form.name, based_on.as_deref()), + SetFormKind::Rename => self.state.rename_set(self.env, &form.name), + } + // A refused name keeps the form open for another try. + let refused = self.state.message.as_ref().is_some_and(|m| m.refused); + self.state.set_form = refused.then_some(form); + } +} diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index edfc7d33..71bf9ba6 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -12,7 +12,7 @@ use crate::profiles::{Builtins, ProfileError, Profiles}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; -use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture}; +use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture, summary}; /// What the dialog edits this frame. pub(crate) struct Env<'e> { @@ -105,7 +105,6 @@ pub(crate) struct Message { } /// What to do after the Save changes prompt. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Then { Switch(String), @@ -113,7 +112,6 @@ pub(crate) enum Then { Import, } -#[allow(dead_code, reason = "set row prompts")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Prompt { /// "Restore every default shortcut in this set?" @@ -134,7 +132,6 @@ pub(crate) enum Answer { } /// A foreign set under review. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug)] pub(crate) struct Review { pub set: ForeignSet, @@ -146,6 +143,24 @@ pub(crate) struct Review { pub done: Option<(String, ImportReport)>, } +/// The set row's inline form: New Set… or Rename…. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SetForm { + pub kind: SetFormKind, + pub name: String, + /// Focus the name field on the next frame. + pub focus: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SetFormKind { + /// `None`: based on the current shortcuts. + New { + based_on: Option, + }, + Rename, +} + #[derive(Clone, Debug)] struct Opened { profiles: Option, @@ -199,6 +214,7 @@ pub struct DialogState { pub(crate) text_focus: bool, /// The table lines drawn last frame. pub(crate) visible: std::ops::Range, + pub(crate) set_form: Option, } impl Default for DialogState { @@ -239,6 +255,7 @@ impl DialogState { bottom_h: 0.0, text_focus: false, visible: 0..0, + set_form: None, } } @@ -693,6 +710,8 @@ impl DialogState { self.conflict = None; } else if self.prompt.is_some() { self.prompt = None; + } else if self.set_form.is_some() { + self.set_form = None; } else if self.review.is_some() { self.review = None; } else if self.field.is_some() { @@ -710,8 +729,6 @@ impl DialogState { } } -// The set row and the import review draw with these (T3); until then some are unused. -#[allow(dead_code, reason = "set row and import review actions")] impl DialogState { // ---- Sets ---- @@ -911,6 +928,22 @@ impl DialogState { result } + /// Summarize…: every command with a shortcut, as HTML and plain text. + pub(crate) fn summarize(&mut self, env: &Env) { + let title = env.text.ui(env.options.title).into_owned(); + let (name, heading) = match env.sets.as_ref() { + Some((p, b)) => { + let name = p.active_name(b).to_string(); + let heading = env.ui("{title}: {set}", &[("title", &title), ("set", &name)]); + (name, heading) + } + None => (title.clone(), title), + }; + let html = summary::html(env.keymap, env.text, &heading); + let text = summary::text(env.keymap, env.text, &heading); + self.outcome = Some(Outcome::Summary { name, html, text }); + } + /// Answers the open prompt. pub(crate) fn answer(&mut self, env: &mut Env, answer: Answer) { let Some(prompt) = self.prompt.take() else { return }; diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs new file mode 100644 index 00000000..ef2cb56b --- /dev/null +++ b/crates/keymap/src/ui/summary.rs @@ -0,0 +1,113 @@ +//! The shortcut summary behind Summarize…: every command that has a shortcut, grouped as in the +//! dialog's All Commands view, as an HTML page and as plain text. Apps can also call these for a +//! cheat sheet outside the dialog. + +use std::fmt::Write as _; + +use crate::keymap::{Binding, Keymap}; +use crate::scope::Scope; +use crate::ui::rows::{AreaId, group}; +use crate::ui::text::{Text, trigger_text}; + +struct Group { + rank: u8, + name: String, + /// (label, shortcuts as shown) + rows: Vec<(String, Vec)>, +} + +fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> String { + let chord = trigger_text(text, &b.trigger, km.platform()); + match &b.scope { + Some(s) if s != home => { + let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() }; + format!("{chord} ({scope})") + } + _ => chord, + } +} + +/// Groups in the table's order; commands in catalogue order within a group. +fn groups(km: &Keymap, text: &dyn Text) -> Vec { + let mut out: Vec = Vec::new(); + for b in km.catalogue() { + let bindings = km.bindings(&b.command); + if bindings.is_empty() { + continue; + } + let keys = bindings.iter().map(|x| binding_text(km, text, x, &b.scope)).collect(); + let (rank, name, _) = group(&AreaId::All, b, false, text); + let name = name.unwrap_or_default(); + let row = (text.command(b).into_owned(), keys); + match out.iter_mut().find(|g| g.rank == rank && g.name == name) { + Some(g) => g.rows.push(row), + None => out.push(Group { rank, name, rows: vec![row] }), + } + } + out.sort_by_key(|g| g.rank); + out +} + +/// Text for HTML element content and attribute values. +pub fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// One line of plain text: control characters (line breaks, tabs) become spaces, so a label +/// cannot break the layout. +fn one_line(s: &str) -> String { + s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +const CSS: &str = "body{font-family:system-ui,sans-serif;margin:2em;color:#111;background:#fff}\ +h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em;border-bottom:1px solid #ccc}\ +table{border-collapse:collapse;width:100%}th,td{padding:.2em .6em;vertical-align:top}\ +th{font-weight:normal;text-align:start;width:60%}tr:nth-child(even){background:#f4f4f4}\ +kbd{font-family:inherit;border:1px solid #bbb;border-radius:3px;padding:0 .3em;margin-inline-end:.3em;white-space:nowrap}\ +@media print{body{margin:0}tr{break-inside:avoid}}"; + +/// A standalone HTML page titled `title`. Chords are marked left to right so a right-to-left page +/// keeps them as written. +pub fn html(km: &Keymap, text: &dyn Text, title: &str) -> String { + let dir = if text.rtl() { "rtl" } else { "ltr" }; + let title = escape_html(title); + let mut s = String::new(); + let _ = write!( + s, + "\n\n\n\n{title}\n\n\n\n

{title}

\n" + ); + for g in groups(km, text) { + let _ = writeln!(s, "

{}

\n", escape_html(&g.name)); + for (label, keys) in &g.rows { + let keys: String = keys.iter().map(|k| format!("{}", escape_html(k))).collect(); + let _ = writeln!(s, "", escape_html(label)); + } + s.push_str("
{}{keys}
\n"); + } + s.push_str("\n\n"); + s +} + +/// Plain text titled `title`: a line per group, then ` labelshortcuts` per command. +pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String { + let mut s = one_line(title); + s.push('\n'); + for g in groups(km, text) { + let _ = write!(s, "\n{}\n", one_line(&g.name)); + for (label, keys) in &g.rows { + let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", "))); + } + } + s +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 80455b3d..ecca273d 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -110,6 +110,11 @@ pub const PLURALS: &[(&str, &str)] = &[ ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), ("{n} command will change", "{n} commands will change"), ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} item not matched", "{n} items not matched"), + ("{n} item could not be read", "{n} items could not be read"), + ("{n} key read approximately", "{n} keys read approximately"), + ("{n} entry cut at a limit", "{n} entries cut at a limit"), + ("{n} command changed", "{n} commands changed"), ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), ]; @@ -204,7 +209,11 @@ pub const STRINGS: &[&str] = &[ "Revert Set", "Import…", "Export", + "Export…", "Summarize…", + "{title}: {set}", + "Create", + "Rename", "More", "Name:", "Based on:", @@ -223,15 +232,44 @@ pub const STRINGS: &[&str] = &[ "This file is from a newer version (format {n})", "The file is too large", // Import review. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", "Will change", "Not matched", "Unreadable", "Approximated", "Suggestions", "numeric keypad key read as main key", + "Matched without change", + "Command for {item}", + "Choose a command…", + "no command has this name", + "more than one command fits", + "in a context that is not imported", + "only a command of the other kind fits (tool or menu command)", + "empty shortcut", + "the shortcut is too long", + "unknown key {key}", + "unsupported modifier {key}", + "{label} keeps its shortcuts (matched by a panel menu name)", "Import as New Set", "Imported", + "Imported as “{set}”", + "Skipped", + "Taken from other commands", + "Not changed", + "taken by {label}", + "taken by a later entry", + "reserved by the system", + "the scope is unknown", + "the set's shortcuts could not be added", + "the shortcuts could not be removed", + "the shortcut could not be taken from it", "Copy Report", + "Done", ]; #[cfg(test)] @@ -271,6 +309,9 @@ mod tests { include_str!("capture.rs"), include_str!("view.rs"), include_str!("style.rs"), + include_str!("sets.rs"), + include_str!("review.rs"), + include_str!("summary.rs"), ]; let mut missing = Vec::new(); for src in sources { diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 1618a4dd..dbb42d62 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -26,6 +26,11 @@ use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; use crate::ui::style::{self, DialogStyle}; use crate::ui::text::trigger_text; +#[path = "review.rs"] +mod review; +#[path = "sets.rs"] +mod sets; + /// Below this width the detail pane goes under the table. const NARROW: f32 = 720.0; /// Smallest table or detail pane when dragging the divider. @@ -125,6 +130,13 @@ impl Draw<'_, '_> { r.clicked() } + /// The width a [`Button`] with `text` takes. + fn button_width(&self, ui: &Ui, text: &str) -> f32 { + let pad = ui.spacing().button_padding.x * 2.0; + let g = WidgetText::from(self.vis(text)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(ui.spacing().interact_size.x) + } + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) } @@ -211,7 +223,8 @@ impl Draw<'_, '_> { let gap = ui.spacing().item_spacing.y; ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { self.sets_row(ui); - self.header(ui); + // Show, Filter and Search act on the table, which the import review replaces. + ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); let rest = ui.available_rect_before_wrap(); let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); @@ -231,15 +244,6 @@ impl Draw<'_, '_> { ui.advance_cursor_after_rect(full); } - /// The set row: drawn here, above the header, when the app passes sets (§2.6). - fn sets_row(&mut self, _ui: &mut Ui) {} - - /// The import review takes the place of the table and detail pane while it is open (§2.7). - /// Returns whether it drew. - fn review(&mut self, _ui: &mut Ui, _rect: Rect) -> bool { - false - } - fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { ui.set_clip_rect(rect.intersect(ui.clip_rect())); @@ -1177,16 +1181,14 @@ impl Draw<'_, '_> { self.state.ask_reset_all(); } } + if self.env.sets.is_none() && self.env.options.summary { + let label = self.ui("Summarize…"); + if self.button(ui, true, &label, "shortcuts.summarize") { + self.state.summarize(self.env); + } + } let gap = ui.spacing().item_spacing.x; - let pad = ui.spacing().button_padding.x * 2.0; - let min = ui.spacing().interact_size.x; - let width: f32 = right - .iter() - .map(|(t, _)| { - let g = WidgetText::from(self.vis(t)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); - (g.size().x + pad).max(min) + gap - }) - .sum(); + let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); ui.add_space((ui.available_width() - width).max(0.0)); for (text, id) in &right { if self.button(ui, true, text, id) { diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index dc88b091..05c11036 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -1,11 +1,14 @@ #![cfg(feature = "ui")] //! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, -//! platform display, accessibility labels and small windows. +//! platform display, accessibility labels, small windows, sets, import review and the summary. use std::borrow::Cow; use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; -use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{ + Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, + Platform, Profiles, Scope, Scopes, Trigger, +}; use egui::accesskit::Role; use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; use egui_kittest::Harness; @@ -20,11 +23,24 @@ struct Fx { state: DialogState, text: Box, window: bool, + /// Profiles and built-in sets, when the dialog shows sets. + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, outcomes: Vec, /// A key press was still in the frame's input after the dialog ran. keys_left: bool, } +/// The dialog of `fx` for a call outside the frame (import_json, review_import). +fn dialog(fx: &mut Fx) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, text, sets, .. } = fx; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) +} + impl Fx { fn new(km: Keymap) -> Fx { Fx { @@ -33,6 +49,7 @@ impl Fx { state: DialogState::new(), text: Box::new(English), window: false, + sets: None, outcomes: Vec::new(), keys_left: false, } @@ -50,8 +67,12 @@ impl Fx { fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { let mut h = Harness::builder().with_size(size).build_ui_state( |ui, fx: &mut Fx| { - let Fx { km, options, state, text, window, outcomes, keys_left } = fx; + let Fx { km, options, state, text, window, sets, outcomes, keys_left } = fx; let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let dialog = match sets { + Some((p, b)) => dialog.sets(p, b), + None => dialog, + }; let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; outcomes.extend(out); *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); @@ -515,3 +536,186 @@ fn rtl_puts_the_detail_pane_left_and_keeps_chords() { assert!(table.min.x > 300.0, "table on the right: {table:?}"); assert!(has(&h, "Save As…: ⇧⌘S")); } + +// ---- Sets ---- + +fn with_sets(profiles: Profiles) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((profiles, vec![("Fixture Defaults", Overrides::new())])); + open(fx) +} + +fn profiles<'h>(h: &'h Harness<'_, Fx>) -> &'h Profiles { + &h.state().sets.as_ref().expect("sets").0 +} + +/// The last control with this label: a prompt's button rather than the set row's. +fn click_last(h: &mut Harness<'_, Fx>, label: &str) { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("{label}")).click(); + h.run(); +} + +fn new_set(h: &mut Harness<'_, Fx>, name: &str) { + click(h, "New Set…"); + h.get_by_role_and_label(Role::TextInput, "Name:").type_text(name); + h.run(); + click(h, "Create"); +} + +#[test] +fn cancel_restores_keymap_and_sets() { + let mut p = Profiles::default(); + p.save_as("Mine", Overrides::new(), &[("Fixture Defaults", Overrides::new())]).expect("save_as"); + let before = p.clone(); + let mut h = with_sets(p); + click(&mut h, "Set:"); + click_last(&mut h, "Mine"); + assert_eq!(profiles(&h).active, "Mine"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert!(has(&h, "(modified)")); + new_set(&mut h, "Other"); + assert_eq!(profiles(&h).active, "Other"); + assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + click_last(&mut h, "Delete"); + assert!(!profiles(&h).sets.contains_key("Other")); + click(&mut h, "Cancel"); + assert!(h.state().cancelled()); + assert_eq!(profiles(&h), &before); + assert!(h.state().km.overrides().is_empty()); +} + +#[test] +fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut h = with_sets(Profiles::default()); + assert!(h.get_by_label("Rename…").accesskit_node().is_disabled(), "built-ins cannot be renamed"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + let p = profiles(&h); + assert_eq!(p.active, "Custom"); + assert_eq!(p.sets.get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); +} + +#[test] +fn set_export_import_round_trip() { + let mut h = with_sets(Profiles::default()); + new_set(&mut h, "Name"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + h.get_all_by_label("Export…").next().expect("the set row's Export…").click(); + h.run(); + let (name, json) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Export { name, json } = o { Some((name.clone(), json.clone())) } else { None }) + .expect("Outcome::Export"); + assert_eq!(name, "Name"); + let edited = h.state().km.overrides().clone(); + + let mut fresh = with_sets(profiles(&h).clone()); + let (d, state) = dialog(fresh.state_mut()); + assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); + fresh.run(); + assert_eq!(profiles(&fresh).active, "Name 2"); + assert_eq!(fresh.state().km.overrides(), &edited); + assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); +} + +fn foreign_set() -> ForeignSet { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + } +} + +fn review(h: &mut Harness<'_, Fx>) { + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = dialog(h.state_mut()); + d.review_import(state, foreign_set(), options); + h.run(); +} + +#[test] +fn foreign_import_review_lists_skipped_and_unreadable() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(has(&h, "Import “Theirs”")); + for (item, line) in [("File > Save", 3), ("File > Frobnicate", 4), ("File > Export…", 5), ("Object > Arrange > Bring to Front", 6)] { + let found = h.query_all_by_label_contains(item).any(|n| n.accesskit_node().value().is_some_and(|l| l.contains(&format!("line {line}")))); + assert!(found, "{item} with line {line}"); + } + assert!(h.query_by_label_contains("unknown key").is_some(), "the unreadable key says why"); + assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); + assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert_eq!(profiles(&h).active, "Theirs"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); + assert!(has(&h, "Imported as “Theirs”")); + click(&mut h, "Done"); + assert!(h.state().state.is_open() && has(&h, "Commands")); +} + +#[test] +fn picking_a_suggestion_replans_and_is_remembered() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(h.query_by_label_contains("3 commands will change").is_some()); + click(&mut h, "Command for File > Sve Az"); + click_last(&mut h, "Save As…"); + let remembered = h.state().outcomes.iter().find_map(|o| if let Outcome::Remembered(m) = o { Some(m.clone()) } else { None }); + assert!(remembered.is_some_and(|m| m.values().any(|c| c.id == "file.saveAs"))); + assert!(h.query_by_label_contains("4 commands will change").is_some()); + assert!(h.query_by_label_contains("File > Sve Az · line 7 — Save As…: ").is_some(), "the pick moved to Will change"); + assert!(h.query_by_label("Command for File > Sve Az").is_none()); +} + +// ---- Summary ---- + +#[test] +fn summary_escapes_html_and_keeps_text_lines() { + let mut km = fixture(Platform::Mac); + let mut catalogue = km.catalogue().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + catalogue.push( + Bindable::new("edit.tricky", label, Scope::app()) + .with_path(vec!["Edit".into(), "Tricky".into()]) + .with_default(craft_keymap::Binding::parse("F6").expect("F6")), + ); + km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut h = open(Fx::new(km)); + click(&mut h, "Summarize…"); + let (html, text) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Summary { html, text, .. } = o { Some((html.clone(), text.clone())) } else { None }) + .expect("Outcome::Summary"); + assert!(html.contains("<b>"Tom" & 'Jerry'</b>"), "{html}"); + assert!(!html.contains("")); + assert!(html.contains("⌘S")); + assert!(text.lines().any(|l| l == " \"Tom\" & 'Jerry' Next\tF6"), "{text}"); + assert!(text.lines().any(|l| l == " Save\t⌘S"), "{text}"); +} + +#[test] +fn narrow_set_row_moves_actions_into_more() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(560.0, 600.0)); + assert!(h.query_by_label("Summarize…").is_none(), "Summarize… is in More"); + click(&mut h, "More"); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index a0dd1f54..b232f5dc 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; -use craft_keymap::{Keymap, Platform}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -20,6 +20,19 @@ struct Fx { km: Keymap, options: Options, state: DialogState, + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &English).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) + } } /// A named state of the dialog: window size and what to do before the picture. @@ -27,9 +40,29 @@ struct Case { name: &'static str, size: Vec2, platform: Platform, + /// Show named sets. + sets: bool, act: fn(&mut Harness<'_, Fx>), } +fn foreign_review(h: &mut Harness<'_, Fx>) { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = h.state_mut().dialog(); + d.review_import(state, set, options); + h.run(); +} + fn click(h: &mut Harness<'_, Fx>, label: &str) { h.get_by_label(label).click(); h.run(); @@ -40,6 +73,7 @@ const CASES: &[Case] = &[ name: "selected", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save As…").click(); h.run(); @@ -49,22 +83,38 @@ const CASES: &[Case] = &[ name: "conflict", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { click(h, "Export…: ⌘E"); h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); h.run(); }, }, - Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") }, + Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, sets: false, act: |h| click(h, "Save: Ctrl+S") }, Case { name: "narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save").click(); h.run(); }, }, + Case { + name: "sets", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + sets: true, + act: |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }, + }, + Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, act: |h| click(h, "More") }, + Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, act: foreign_review }, ]; #[test] @@ -74,11 +124,13 @@ fn dialog_screenshots() { for case in CASES { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { - let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() }; + let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { - ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state); + let (d, state) = fx.dialog(); + d.ui(ui, state); }); }, fx, From 94eacba872c0160572596ab4a8494a93ca372b28 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:29:41 +0000 Subject: [PATCH 05/33] keymap: on-screen keyboard pane, fixed-row labels and read-only mode The keyboard pane (Options::keyboard) draws a US ANSI layout under the table and detail pane, coloured by who holds each key with the toggled and held modifiers, lists the clicked key's shortcuts and assigns key and modifiers to the selected command. Fixed rows always report a label, with "Now used by" when a command took their key. A read-only dialog has no set row, keeps Summarize in the footer and closes with shortcuts.close. --- crates/keymap/src/ui/keyboard.rs | 636 +++++++++++++++++++++++++++++ crates/keymap/src/ui/sets.rs | 41 +- crates/keymap/src/ui/state.rs | 3 + crates/keymap/src/ui/text.rs | 23 ++ crates/keymap/src/ui/view.rs | 35 +- crates/keymap/tests/ui_dialog.rs | 115 +++++- crates/keymap/tests/ui_snapshot.rs | 46 ++- 7 files changed, 864 insertions(+), 35 deletions(-) create mode 100644 crates/keymap/src/ui/keyboard.rs diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs new file mode 100644 index 00000000..0b53b2b8 --- /dev/null +++ b/crates/keymap/src/ui/keyboard.rs @@ -0,0 +1,636 @@ +//! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout +//! coloured by who holds each key with the chosen modifiers, modifier toggles that also follow the +//! held modifiers, the shortcuts of the clicked key, and Assign to Selected. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, CornerRadius, FontId, Layout, Rect, RichText, ScrollArea, Sense, Stroke, StrokeKind, Ui, WidgetInfo, WidgetType, pos2, + vec2, +}; + +use super::{Draw, fill}; +use crate::chord::{Chord, Key, Mods, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{Binding, Holder, Keymap, Overrides}; +use crate::scope::Scope; + +/// The keyboard's width and height in key units: 15 for the main block, a gap, 3 for the +/// navigation cluster; six rows and the gap under the function row. +const UNITS_W: f32 = 18.5; +const UNITS_H: f32 = 6.25; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mod { + Cmd, + Ctrl, + Alt, + Shift, +} + +impl Mod { + fn id(self) -> &'static str { + match self { + Mod::Cmd => "cmd", + Mod::Ctrl => "ctrl", + Mod::Alt => "alt", + Mod::Shift => "shift", + } + } + + fn get(self, m: Mods) -> bool { + match self { + Mod::Cmd => m.cmd, + Mod::Ctrl => m.ctrl, + Mod::Alt => m.alt, + Mod::Shift => m.shift, + } + } + + fn flip(self, m: &mut Mods) { + match self { + Mod::Cmd => m.cmd = !m.cmd, + Mod::Ctrl => m.ctrl = !m.ctrl, + Mod::Alt => m.alt = !m.alt, + Mod::Shift => m.shift = !m.shift, + } + } +} + +/// A keycap: a key, a modifier, a key the dialog does not bind (Caps Lock, Win), or a gap. +#[derive(Clone, Copy)] +enum Cap { + Key(Key), + Mod(Mod), + CapsLock, + /// Win on Windows, Super on Linux; a Mac has none. + System, + Gap, +} + +type Row = &'static [(Cap, f32)]; + +const fn k(key: Key) -> (Cap, f32) { + (Cap::Key(key), 1.0) +} + +const MAIN_ROWS: [Row; 5] = [ + &[ + (Cap::Key(Key::Backtick), 1.0), + k(Key::Num1), + k(Key::Num2), + k(Key::Num3), + k(Key::Num4), + k(Key::Num5), + k(Key::Num6), + k(Key::Num7), + k(Key::Num8), + k(Key::Num9), + k(Key::Num0), + k(Key::Minus), + k(Key::Equals), + (Cap::Key(Key::Backspace), 2.0), + ], + &[ + (Cap::Key(Key::Tab), 1.5), + k(Key::Q), + k(Key::W), + k(Key::E), + k(Key::R), + k(Key::T), + k(Key::Y), + k(Key::U), + k(Key::I), + k(Key::O), + k(Key::P), + k(Key::OpenBracket), + k(Key::CloseBracket), + (Cap::Key(Key::Backslash), 1.5), + ], + &[ + (Cap::CapsLock, 1.75), + k(Key::A), + k(Key::S), + k(Key::D), + k(Key::F), + k(Key::G), + k(Key::H), + k(Key::J), + k(Key::K), + k(Key::L), + k(Key::Semicolon), + k(Key::Quote), + (Cap::Key(Key::Enter), 2.25), + ], + &[ + (Cap::Mod(Mod::Shift), 2.25), + k(Key::Z), + k(Key::X), + k(Key::C), + k(Key::V), + k(Key::B), + k(Key::N), + k(Key::M), + k(Key::Comma), + k(Key::Period), + k(Key::Slash), + (Cap::Mod(Mod::Shift), 2.75), + ], + &[], +]; + +const FUNCTION_ROW: Row = &[ + k(Key::Escape), + (Cap::Gap, 1.0), + k(Key::F1), + k(Key::F2), + k(Key::F3), + k(Key::F4), + (Cap::Gap, 0.5), + k(Key::F5), + k(Key::F6), + k(Key::F7), + k(Key::F8), + (Cap::Gap, 0.5), + k(Key::F9), + k(Key::F10), + k(Key::F11), + k(Key::F12), +]; + +const MAC_BOTTOM: Row = &[ + (Cap::Mod(Mod::Ctrl), 1.5), + (Cap::Mod(Mod::Alt), 1.25), + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::Key(Key::Space), 6.5), + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::Mod(Mod::Alt), 1.25), + (Cap::Mod(Mod::Ctrl), 1.5), +]; + +/// Off macOS the Ctrl keys are the command modifier. +const PC_BOTTOM: Row = &[ + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::System, 1.25), + (Cap::Mod(Mod::Alt), 1.5), + (Cap::Key(Key::Space), 6.5), + (Cap::Mod(Mod::Alt), 1.5), + (Cap::System, 1.25), + (Cap::Mod(Mod::Cmd), 1.5), +]; + +/// The navigation cluster: (row, column in units, key). Row 0 is the function row. +const NAV: &[(usize, f32, Key)] = &[ + (1, 0.0, Key::Insert), + (1, 1.0, Key::Home), + (1, 2.0, Key::PageUp), + (2, 0.0, Key::Delete), + (2, 1.0, Key::End), + (2, 2.0, Key::PageDown), + (4, 1.0, Key::Up), + (5, 0.0, Key::Left), + (5, 1.0, Key::Down), + (5, 2.0, Key::Right), +]; + +/// A binding on a key and the scope it works in. +#[derive(Clone, Debug)] +pub(crate) struct KeyHolder { + pub holder: Holder, + pub scope: Scope, +} + +/// Who holds a key with given modifiers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Free, + App, + Scoped, + Both, +} + +fn class(holders: &[KeyHolder]) -> Class { + match (holders.iter().any(|h| h.scope.is_app()), holders.iter().any(|h| !h.scope.is_app())) { + (false, false) => Class::Free, + (true, false) => Class::App, + (false, true) => Class::Scoped, + (true, true) => Class::Both, + } +} + +/// The pane's state between frames. +#[derive(Clone, Debug, Default)] +pub(crate) struct KeyboardState { + /// Hidden with the header's Keyboard toggle. + pub hidden: bool, + /// The modifiers toggled on; the held ones are added each frame. + pub mods: Mods, + /// The key whose shortcuts are listed. + pub key: Option, + /// Every binding by effective chord, rebuilt when the overrides change. + index: Option>, +} + +type Index = HashMap>; + +impl KeyboardState { + fn index(&mut self, keymap: &Keymap) -> Arc<(Overrides, Index)> { + if let Some(i) = self.index.as_ref().filter(|i| i.0 == *keymap.overrides()) { + return i.clone(); + } + let platform = keymap.platform(); + let mut map: HashMap> = HashMap::new(); + for b in keymap.catalogue() { + for binding in keymap.bindings(&b.command) { + let scope = binding.scope_or(&b.scope).clone(); + let holder = Holder { command: b.command.clone(), binding }; + map.entry(holder.binding.trigger.chord.effective(platform)).or_default().push(KeyHolder { holder, scope }); + } + } + let index = Arc::new((keymap.overrides().clone(), map)); + self.index = Some(index.clone()); + index + } +} + +fn mod_glyph(m: Mod) -> &'static str { + match m { + Mod::Cmd => "⌘", + Mod::Ctrl => "⌃", + Mod::Alt => "⌥", + Mod::Shift => "⇧", + } +} + +/// The English legend printed on a key: Apple's words on macOS, PC words elsewhere. +fn cap_name(key: Key, mac: bool) -> &'static str { + match (key, mac) { + (Key::Escape, true) => "esc", + (Key::Escape, false) => "Esc", + (Key::Backspace, true) => "delete", + (Key::Delete, true) => "fwd del", + (Key::Insert, true) => "ins", + (Key::Enter, true) => "return", + (Key::Tab, true) => "tab", + (Key::PageUp, true) => "page up", + (Key::PageDown, true) => "page down", + (Key::Home, true) => "home", + (Key::End, true) => "end", + (Key::Insert, false) => "Ins", + (Key::PageUp, false) => "PgUp", + (Key::PageDown, false) => "PgDn", + (Key::Delete, false) => "Del", + (Key::Space, true) => "", + (Key::Up | Key::Down | Key::Left | Key::Right, _) => key.mac_symbol(), + (k, _) => k.text_name(), + } +} + +fn union(a: Mods, b: Mods) -> Mods { + Mods { cmd: a.cmd || b.cmd, ctrl: a.ctrl || b.ctrl, alt: a.alt || b.alt, shift: a.shift || b.shift } +} + +/// Black or white, whichever reads better on `fill`. +fn ink(fill: Color32) -> Color32 { + let l = 0.299 * f32::from(fill.r()) + 0.587 * f32::from(fill.g()) + 0.114 * f32::from(fill.b()); + if l > 150.0 { Color32::from_gray(24) } else { Color32::from_gray(240) } +} + +/// What a frame of the pane asked for. +#[derive(Default)] +struct Clicks { + key: Option, + toggle: Option, + go: Option, + assign: bool, +} + +impl Draw<'_, '_> { + /// The pane's height for a middle area of `height`, or `None` when it is off or does not fit. + pub(super) fn keyboard_height(&self, height: f32) -> Option { + if !self.env.options.keyboard || self.state.keyboard.hidden { + return None; + } + let h = (height * 0.45).min(320.0).min(height - 160.0); + (h >= 120.0).then_some(h) + } + + /// The header's toggle that shows and hides the pane. + pub(super) fn keyboard_toggle(&mut self, ui: &mut Ui) { + if !self.env.options.keyboard { + return; + } + let label = self.ui("Keyboard"); + let r = ui.add(Button::selectable(!self.state.keyboard.hidden, self.vis(&label))); + self.tag(|| "shortcuts.keyboard".into(), r.rect, &label); + if r.clicked() { + self.state.keyboard.hidden = !self.state.keyboard.hidden; + } + } + + fn mods_shown(&self) -> &'static [Mod] { + if self.env.keymap.platform().is_mac() { &[Mod::Cmd, Mod::Ctrl, Mod::Alt, Mod::Shift] } else { &[Mod::Cmd, Mod::Alt, Mod::Shift] } + } + + /// A modifier's name, with its glyph on macOS. + fn mod_name(&self, m: Mod) -> String { + let word = self.mod_word(m); + if self.env.keymap.platform().is_mac() { format!("{} {word}", mod_glyph(m)) } else { word } + } + + /// The key's name on its cap: the app's localized name, else the glyph on macOS. + fn cap_legend(&self, key: Key) -> String { + let p = self.env.keymap.platform(); + self.env.text.key(key, p).unwrap_or_else(|| cap_name(key, p.is_mac()).to_string()) + } + + /// A modifier cap's word, without the glyph (which goes above it on macOS). + fn mod_word(&self, m: Mod) -> String { + match (m, self.env.keymap.platform().is_mac()) { + (Mod::Cmd, true) => self.ui("Command"), + (Mod::Ctrl, true) => self.ui("Control"), + (Mod::Alt, true) => self.ui("Option"), + (Mod::Cmd | Mod::Ctrl, false) => self.ui("Ctrl"), + (Mod::Alt, false) => self.ui("Alt"), + (Mod::Shift, _) => self.ui("Shift"), + } + } + + /// The key's spoken name: the app's localized name, else the name Windows and Linux show. + fn key_name(&self, key: Key) -> String { + self.env.text.key(key, self.env.keymap.platform()).unwrap_or_else(|| key.text_name().to_string()) + } + + /// The holder's label, with its scope when that is not Application. + fn holder_text(&self, h: &KeyHolder) -> String { + let label = self.label_of(&h.holder.command); + if h.scope.is_app() { label } else { format!("{label} ({})", self.scope_name(&h.scope)) } + } + + pub(super) fn keyboard_pane(&mut self, ui: &mut Ui) { + let platform = self.env.keymap.platform(); + let held = if self.state.recording.is_none() { ui.input(|i| Mods::from_egui(&i.modifiers, platform)) } else { Mods::NONE }; + let mut clicks = Clicks::default(); + let rect = ui.max_rect(); + ui.painter().hline(rect.x_range(), rect.min.y, ui.visuals().widgets.noninteractive.bg_stroke); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + let layout = if platform.is_mac() { self.ui("Keyboard: US (macOS)") } else { self.ui("Keyboard: US") }; + ui.label(RichText::new(self.vis(&layout)).color(self.style.dim)); + ui.add_space(8.0); + for &m in self.mods_shown() { + let name = self.mod_name(m); + let on = m.get(union(self.state.keyboard.mods, held)); + let r = ui.add(Button::selectable(on, self.vis(&name))); + self.tag(|| format!("shortcuts.mod.{}", m.id()), r.rect, &name); + if r.clicked() { + clicks.toggle = Some(m); + } + } + ui.add_space(12.0); + self.keyboard_legend(ui); + }); + let mods = union(self.state.keyboard.mods, held); + let body = ui.available_rect_before_wrap(); + let gap = 12.0; + let list_w = (body.width() * 0.3).clamp(160.0, 320.0).min(body.width() / 2.0); + let rtl = self.env.text.rtl(); + let (kb, list) = if rtl { + ( + Rect::from_min_max(pos2(body.min.x + list_w + gap, body.min.y), body.max), + Rect::from_min_max(body.min, pos2(body.min.x + list_w, body.max.y)), + ) + } else { + ( + Rect::from_min_max(body.min, pos2(body.max.x - list_w - gap, body.max.y)), + Rect::from_min_max(pos2(body.max.x - list_w, body.min.y), body.max), + ) + }; + self.keys_drawn(ui, kb, mods, &mut clicks); + self.child(ui, list, |d, ui| d.key_list(ui, mods, &mut clicks)); + ui.advance_cursor_after_rect(body); + + let kb_state = &mut self.state.keyboard; + if let Some(m) = clicks.toggle { + // A held modifier is on anyway; clicking it turns the toggle off. + if m.get(held) { + let mut off = kb_state.mods; + if m.get(off) { + m.flip(&mut off); + } + kb_state.mods = off; + } else { + m.flip(&mut kb_state.mods); + } + } + if let Some(key) = clicks.key { + kb_state.key = Some(key); + } + if let Some(c) = clicks.go { + self.state.go_to(&c); + } + if clicks.assign + && let (Some(command), Some(key)) = (self.state.selected.clone(), self.state.keyboard.key) + { + let trigger = Trigger::single(Chord { mods, key }); + let binding = match &self.state.context { + Some(scope) => Binding::in_scope(trigger, scope.clone()), + None => Binding::new(trigger), + }; + self.state.stop_recording(); + self.state.commit(self.env, &command, None, binding); + } + } + + fn keyboard_legend(&self, ui: &mut Ui) { + let s = &self.style; + for (color, text) in [ + (s.app_key, self.ui("Application")), + (s.scoped_key, self.ui("Panel or context")), + (s.both_key, self.ui("Both")), + (s.modifier_key, self.ui("Modifier on")), + ] { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, color); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + } + + /// The keycaps in `area`, scaled to fit. + fn keys_drawn(&mut self, ui: &mut Ui, area: Rect, mods: Mods, clicks: &mut Clicks) { + let mac = self.env.keymap.platform().is_mac(); + let u = (area.width() / UNITS_W).min(area.height() / UNITS_H).max(1.0); + let origin = area.min; + let row_y = |row: usize| origin.y + row as f32 * u + if row > 0 { 0.25 * u } else { 0.0 }; + let index = self.state.keyboard.index(self.env.keymap); + let index = &index.1; + let bottom = if mac { MAC_BOTTOM } else { PC_BOTTOM }; + let rows = std::iter::once(FUNCTION_ROW).chain(MAIN_ROWS[..4].iter().copied()).chain(std::iter::once(bottom)); + for (r, row) in rows.enumerate() { + let mut x = origin.x; + for &(cap, w) in row { + let rect = Rect::from_min_size(pos2(x, row_y(r)), vec2(w * u, u)); + x += w * u; + self.cap(ui, rect, cap, u, mods, index, clicks); + } + } + let nav_x = origin.x + 15.5 * u; + for &(r, col, key) in NAV { + let rect = Rect::from_min_size(pos2(nav_x + col * u, row_y(r)), vec2(u, u)); + self.cap(ui, rect, Cap::Key(key), u, mods, index, clicks); + } + } + + #[allow(clippy::too_many_arguments)] + fn cap(&mut self, ui: &mut Ui, rect: Rect, cap: Cap, u: f32, mods: Mods, index: &Index, clicks: &mut Clicks) { + if matches!(cap, Cap::Gap) { + return; + } + let r = rect.shrink((u * 0.05).clamp(1.0, 2.0)); + let font = FontId::proportional((u * 0.26).clamp(7.0, 12.0)); + let s = self.style; + let (bg, legend, title) = match cap { + Cap::Key(key) => { + let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice); + let bg = match class(holders) { + Class::Free => s.free_key, + Class::App => s.app_key, + Class::Scoped => s.scoped_key, + Class::Both => s.both_key, + }; + // The application-wide holder names the key; a scoped one when there is none. + let first = holders.iter().find(|h| h.scope.is_app()).or(holders.first()); + (bg, self.cap_legend(key), first.map(|h| self.label_of(&h.holder.command))) + } + Cap::Mod(m) => { + let glyph = self.env.keymap.platform().is_mac().then(|| mod_glyph(m).to_string()); + (if m.get(mods) { s.modifier_key } else { s.free_key }, self.mod_word(m), glyph) + } + Cap::CapsLock => (s.free_key.gamma_multiply(0.7), self.ui("Caps Lock"), None), + Cap::System => { + let name = if self.env.keymap.platform() == crate::chord::Platform::Windows { self.ui("Win") } else { self.ui("Super") }; + (s.free_key.gamma_multiply(0.7), name, None) + } + Cap::Gap => return, + }; + let ink = ink(bg); + ui.painter().rect_filled(r, CornerRadius::same(3), bg); + let pad = (u * 0.1).clamp(2.0, 4.0); + let text_w = (r.width() - 2.0 * pad).max(1.0); + let lines = |text: &str, rows: usize, color: Color32| { + let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font.clone(), color)); + job.wrap = TextWrapping { max_width: text_w, max_rows: rows, break_anywhere: false, overflow_character: Some('…') }; + ui.painter().layout_job(job) + }; + if let Some(t) = &title { + let rows = if r.height() > 4.0 * font.size { 2 } else { 1 }; + let g = lines(&self.vis(t), rows, ink); + ui.painter().galley(r.min + vec2(pad, pad * 0.5), g, ink); + } + // A cap without a command name has room for a two-line legend ("page up"). + let legend_rows = if title.is_none() && r.height() > 3.0 * font.size { 2 } else { 1 }; + let g = lines(&self.vis(&legend), legend_rows, ink.gamma_multiply(0.85)); + ui.painter().galley(pos2(r.min.x + pad, r.max.y - pad * 0.5 - g.size().y), g, ink); + + let Cap::Key(key) = cap else { + if let Cap::Mod(m) = cap { + // Modifier caps toggle like the buttons above; the buttons are their accessible form. + let resp = ui.interact(r, self.base.with(("kbmod", m.id(), r.min.x as i32)), Sense::CLICK); + if resp.clicked() { + clicks.toggle = Some(m); + } + if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); + } + } + return; + }; + let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice); + let chord = self.chord(&Trigger::single(Chord { mods, key })); + let spoken = if mods.is_none() { self.key_name(key) } else { chord }; + let mut names: Vec = Vec::new(); + for h in holders { + let t = self.holder_text(h); + if !names.contains(&t) { + names.push(t); + } + } + let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) }; + let selected = self.state.keyboard.key == Some(key); + let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::CLICK); + resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y)); + let description = match class(holders) { + Class::Free => self.ui("Not assigned"), + Class::App => self.ui("Application"), + Class::Scoped => self.ui("Panel or context"), + Class::Both => self.ui("Both"), + }; + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_description(description)); + self.tag(|| format!("shortcuts.key.{}", key.name()), r, &a11y); + let v = ui.visuals(); + if selected { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside); + } else if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); + } + if resp.clicked() { + clicks.key = Some(key); + } + if !names.is_empty() { + resp.on_hover_text(self.vis(&names.join("\n"))); + } + } + + /// The clicked key's shortcuts with any modifiers, and Assign to Selected. + fn key_list(&mut self, ui: &mut Ui, mods: Mods, clicks: &mut Clicks) { + let Some(key) = self.state.keyboard.key else { + ui.label(RichText::new(self.vis(&self.ui("Click a key to see its shortcuts"))).color(self.style.dim)); + return; + }; + let platform = self.env.keymap.platform(); + let title = fill(self.ui("Key: {key}"), &[("key", &self.key_name(key))]); + ui.label(RichText::new(self.vis(&title)).strong()); + if self.env.options.editable { + let chord = self.chord(&Trigger::single(Chord { mods, key })); + let selected = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()); + let label = self.ui("Assign to Selected"); + let r = ui.add_enabled(selected.is_some(), Button::new(self.vis(&label))); + self.tag(|| "shortcuts.assignKey".into(), r.rect, &label); + let r = match &selected { + Some(c) => r.on_hover_text(self.vis(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]))), + None => r.on_disabled_hover_text(self.vis(&self.ui("Select a command first"))), + }; + if r.clicked() { + clicks.assign = true; + } + } + let index = self.state.keyboard.index(self.env.keymap); + let mut entries: Vec<(Chord, KeyHolder)> = + index.1.iter().filter(|(c, _)| c.key == key).flat_map(|(c, hs)| hs.iter().map(|h| (*c, h.clone()))).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.holder.command.id.cmp(&b.1.holder.command.id))); + if entries.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Nothing uses this key"))).color(self.style.dim)); + return; + } + let current = Chord { mods, key }.effective(platform); + let chord_w = 72.0; + ScrollArea::vertical().id_salt(self.base.with("keylist")).auto_shrink([false, false]).show(ui, |ui| { + for (c, h) in &entries { + ui.horizontal(|ui| { + let mut text = RichText::new(self.chord(&h.holder.binding.trigger)); + if *c == current { + text = text.strong(); + } + ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), Layout::left_to_right(Align::Center), |ui| { + ui.label(text) + }); + if ui.link(self.vis(&self.holder_text(h))).clicked() { + clicks.go = Some(h.holder.command.clone()); + } + }); + } + }); + } +} diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index 64e39f01..1df11548 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -31,14 +31,16 @@ struct Item { } impl Draw<'_, '_> { - /// Drawn above the header when the app passes sets. + /// Drawn above the header when the app passes sets and the dialog is editable. pub(super) fn sets_row(&mut self, ui: &mut Ui) { + if !self.env.options.editable { + return; + } let Some((p, b)) = self.env.sets.as_ref() else { return }; let names = p.names(b); let active = p.active_name(b).to_string(); let builtin = Profiles::is_builtin(&active, b); let modified = DialogState::is_modified(self.env); - let editable = self.env.options.editable; let items = self.set_items(builtin, modified); let mut act = None; ui.add_enabled_ui(self.state.review.is_none(), |ui| { @@ -46,16 +48,14 @@ impl Draw<'_, '_> { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); let mut pick = None; - let combo = ui.add_enabled_ui(editable, |ui| { - ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { - for n in &names { - if ui.selectable_label(*n == active, self.vis(n)).clicked() { - pick = Some(n.clone()); - } + let combo = ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == active, self.vis(n)).clicked() { + pick = Some(n.clone()); } - }) + } }); - let combo = combo.inner.response.labelled_by(l.id); + let combo = combo.response.labelled_by(l.id); self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); if modified { ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); @@ -75,19 +75,16 @@ impl Draw<'_, '_> { fn set_items(&self, builtin: bool, modified: bool) -> Vec { let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled }; - let mut items = Vec::new(); - if self.env.options.editable { - items.push(item(Act::New, "New Set…", "shortcuts.set.new", true)); - items.push(item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin)); - items.push(item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin)); - items.push(item(Act::Save, "Save", "shortcuts.set.save", modified)); - items.push(item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified)); - } let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; - items.push(item(Act::Export, export, "shortcuts.set.export", true)); - if self.env.options.editable { - items.push(item(Act::Import, "Import…", "shortcuts.set.import", true)); - } + let mut items = vec![ + item(Act::New, "New Set…", "shortcuts.set.new", true), + item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin), + item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin), + item(Act::Save, "Save", "shortcuts.set.save", modified), + item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified), + item(Act::Export, export, "shortcuts.set.export", true), + item(Act::Import, "Import…", "shortcuts.set.import", true), + ]; if self.env.options.summary { items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true)); } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 71bf9ba6..34edd13e 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -12,6 +12,7 @@ use crate::profiles::{Builtins, ProfileError, Profiles}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; +use crate::ui::view::keyboard::KeyboardState; use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture, summary}; /// What the dialog edits this frame. @@ -215,6 +216,7 @@ pub struct DialogState { /// The table lines drawn last frame. pub(crate) visible: std::ops::Range, pub(crate) set_form: Option, + pub(crate) keyboard: KeyboardState, } impl Default for DialogState { @@ -256,6 +258,7 @@ impl DialogState { text_focus: false, visible: 0..0, set_form: None, + keyboard: KeyboardState::default(), } } diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index ecca273d..6787c9fa 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -178,6 +178,28 @@ pub const STRINGS: &[&str] = &[ "{label}: {chord}", "{label}: press keys", "Now used by {label}", + // On-screen keyboard. + "Keyboard", + "Keyboard: US", + "Keyboard: US (macOS)", + "Control", + "Option", + "Shift", + "Ctrl", + "Alt", + "Caps Lock", + "Win", + "Super", + "Panel or context", + "Both", + "Modifier on", + "{chord}: {label}", + "Click a key to see its shortcuts", + "Key: {key}", + "Assign to Selected", + "Assign {chord} to {label}", + "Select a command first", + "Nothing uses this key", // Messages and conflicts. "⚠ {chord} is used by {label}", "{chord} was removed from {label}", @@ -308,6 +330,7 @@ mod tests { include_str!("rows.rs"), include_str!("capture.rs"), include_str!("view.rs"), + include_str!("keyboard.rs"), include_str!("style.rs"), include_str!("sets.rs"), include_str!("review.rs"), diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index dbb42d62..844c75aa 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -26,6 +26,8 @@ use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; use crate::ui::style::{self, DialogStyle}; use crate::ui::text::trigger_text; +#[path = "keyboard.rs"] +pub(crate) mod keyboard; #[path = "review.rs"] mod review; #[path = "sets.rs"] @@ -321,6 +323,7 @@ impl Draw<'_, '_> { capture::lock_focus(ui, r.id); } } + self.keyboard_toggle(ui); }); } @@ -405,7 +408,20 @@ impl Draw<'_, '_> { // ---- Table and detail pane ---- + /// The table and detail pane, with the keyboard pane under both when it is on. fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let Some(kb_h) = self.keyboard_height(mid.height()) else { + self.table_and_detail(ui, mid); + return; + }; + let gap = ui.spacing().item_spacing.y; + let top = Rect::from_min_max(mid.min, pos2(mid.max.x, mid.max.y - kb_h - gap)); + let pane = Rect::from_min_max(pos2(mid.min.x, mid.max.y - kb_h), mid.max); + self.table_and_detail(ui, top); + self.child(ui, pane, |d, ui| d.keyboard_pane(ui)); + } + + fn table_and_detail(&mut self, ui: &mut Ui, mid: Rect) { let wide = mid.width() >= NARROW; let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; // Right to left: the detail pane goes on the left. @@ -791,11 +807,15 @@ impl Draw<'_, '_> { ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); - if let Some(by) = &f.now_used_by { - let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); - let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{}: {} ({tip})", f.label, f.keys))); - resp.on_hover_text(self.vis(&tip)); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + match &f.now_used_by { + Some(by) => { + let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({tip})"))); + resp.on_hover_text(self.vis(&tip)); + } + None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } } @@ -1167,7 +1187,7 @@ impl Draw<'_, '_> { let right: Vec<(String, &str)> = if editable { vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] } else { - vec![(self.ui("Close"), "shortcuts.ok")] + vec![(self.ui("Close"), "shortcuts.close")] }; ui.horizontal(|ui| { if editable { @@ -1181,7 +1201,8 @@ impl Draw<'_, '_> { self.state.ask_reset_all(); } } - if self.env.sets.is_none() && self.env.options.summary { + // With sets, Summarize… is in the set row, which a read-only dialog does not show. + if (self.env.sets.is_none() || !editable) && self.env.options.summary { let label = self.ui("Summarize…"); if self.button(ui, true, &label, "shortcuts.summarize") { self.state.summarize(self.env); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 05c11036..f48f3802 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; -use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::ui::{ConflictMode, DialogState, English, FixedRow, Options, Outcome, ShortcutsDialog, Text}; use craft_keymap::{ Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, Platform, Profiles, Scope, Scopes, Trigger, @@ -468,6 +468,7 @@ fn window_close_is_cancel() { fn automation_ids_are_reported() { let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; let sink = seen.clone(); fx.options.on_widget = Some(Box::new(move |id, rect, label| { assert!(rect.is_finite() && !label.is_empty(), "{id}"); @@ -496,6 +497,9 @@ fn automation_ids_are_reported() { "shortcuts.resetAll", "shortcuts.cancel", "shortcuts.ok", + "shortcuts.keyboard", + "shortcuts.mod.cmd", + "shortcuts.key.F7", ] { assert!(ids.iter().any(|s| s == id), "{id} not reported"); } @@ -719,3 +723,112 @@ fn narrow_set_row_moves_actions_into_more() { click(&mut h, "Summarize…"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); } + +// ---- On-screen keyboard, fixed rows, read-only ---- + +fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + options(&mut fx); + open(fx) +} + +/// What a key's colour means, as its accessibility description says it. +fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { + h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() +} + +#[test] +fn keyboard_colours_keys_and_assigns_to_the_selected_command() { + let mut h = with_keyboard(|_| {}); + assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); + assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); + assert_eq!(holders_class(&h, "F7"), "Not assigned"); + + click(&mut h, "⌘ Command"); + assert_eq!(holders_class(&h, "⌘S: Save"), "Application"); + assert_eq!(holders_class(&h, "⌘B: Bold (Text)"), "Panel or context"); + // Held modifiers add to the toggles. + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.run(); + assert!(has(&h, "⇧⌘S: Save As…")); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run(); + click(&mut h, "⌘ Command"); + + click(&mut h, "L: Loop (Timeline)"); + assert!(has(&h, "Key: L")); + assert!(h.get_by_label("Assign to Selected").accesskit_node().is_disabled(), "no command selected"); + click(&mut h, "Save"); + click(&mut h, "Assign to Selected"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "L"]); + assert_eq!(holders_class(&h, "L: Save, Loop (Timeline)"), "Both"); + + // A holder in the key list selects its command. + click(&mut h, "Loop (Timeline)"); + assert!(selected(&h, "Loop")); +} + +#[test] +fn fixed_rows_are_struck_through_when_a_command_takes_their_key() { + let mut fx = Fx::new(fixture(Platform::Mac)); + let row = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.map(|c| Trigger::parse(c).unwrap_or_else(|e| panic!("{c}: {e}"))), + }; + fx.options.fixed = vec![row("Rate 1", "1", Some("1")), row("Rate 2", "2", Some("2")), row("Pan", "Space (hold)", None)]; + let mut h = open(fx); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1")); + assert!(has(&h, "Pan: Space (hold)")); + + choose_area(&mut h, "All Commands"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Num1); + assert_eq!(h.state().bindings("file.save"), ["1"]); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1 (Now used by Save)")); + assert!(has(&h, "Rate 2: 2")); +} + +#[test] +fn read_only_has_no_edit_controls() { + let mut h = with_keyboard(|fx| { + fx.options.editable = false; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + }); + click(&mut h, "Save"); + click(&mut h, "Save: ⌘S"); + assert!(!has(&h, "Save: press keys"), "no recording"); + click(&mut h, "S"); + assert!(has(&h, "Key: S")); + for label in [ + "Undo", + "Redo", + "Reset All…", + "OK", + "Cancel", + "Set:", + "New Set…", + "Import…", + "Add a shortcut to Save", + "Restore the default for Save", + "Remove ⌘S from Save", + "New shortcut:", + "Context:", + "Assign", + "Use Default", + "Clear", + "Assign to Selected", + ] { + assert!(!has(&h, label), "{label} is shown"); + } + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); + click(&mut h, "Close"); + assert!(h.state().cancelled()); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index b232f5dc..33491a41 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -42,6 +42,8 @@ struct Case { platform: Platform, /// Show named sets. sets: bool, + /// Show the on-screen keyboard. + keyboard: bool, act: fn(&mut Harness<'_, Fx>), } @@ -74,6 +76,7 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { h.get_by_label("Save As…").click(); h.run(); @@ -84,18 +87,27 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { click(h, "Export…: ⌘E"); h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); h.run(); }, }, - Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, sets: false, act: |h| click(h, "Save: Ctrl+S") }, + Case { + name: "recording", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Windows, + sets: false, + keyboard: false, + act: |h| click(h, "Save: Ctrl+S"), + }, Case { name: "narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { h.get_by_label("Save").click(); h.run(); @@ -106,6 +118,7 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, + keyboard: false, act: |h| { click(h, "Save: ⌘S"); h.key_press(EguiKey::F2); @@ -113,19 +126,42 @@ const CASES: &[Case] = &[ click(h, "New Set…"); }, }, - Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, act: |h| click(h, "More") }, - Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, act: foreign_review }, + Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: |h| click(h, "More") }, + Case { + name: "keyboard", + size: Vec2::new(1100.0, 720.0), + platform: Platform::Mac, + sets: false, + keyboard: true, + act: |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }, + }, + Case { + name: "keyboard-windows", + size: Vec2::new(860.0, 640.0), + platform: Platform::Windows, + sets: false, + keyboard: true, + act: |h| click(h, "L: Loop (Timeline)"), + }, + Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: foreign_review }, ]; #[test] fn dialog_screenshots() { let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); - for case in CASES { + // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. + let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); - let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new(), sets }; + let options = Options { keyboard: case.keyboard, ..Options::default() }; + let fx = Fx { km: fixture(case.platform), options, state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { From d67d96e9bba123a0598aa41899be33ee3868f941 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:03:20 +0000 Subject: [PATCH 06/33] keymap: shortcuts dialog right-to-left layout, long-label truncation, pseudo-locale test and README --- crates/keymap/README.md | 85 ++++- crates/keymap/src/ui/keyboard.rs | 9 +- crates/keymap/src/ui/review.rs | 99 ++++-- crates/keymap/src/ui/rows.rs | 6 +- crates/keymap/src/ui/sets.rs | 4 +- crates/keymap/src/ui/state.rs | 15 +- crates/keymap/src/ui/view.rs | 439 +++++++++++++++++++------- crates/keymap/tests/ui_dialog.rs | 70 +++- crates/keymap/tests/ui_fixture/mod.rs | 134 +++++++- crates/keymap/tests/ui_snapshot.rs | 243 +++++++++----- 10 files changed, 859 insertions(+), 245 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0d4bd1a0..732a2eaf 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -7,13 +7,15 @@ supplies its command catalogue, its file-format readers and its UI. ## Boundaries -- Data and logic only. The apps keep the command catalogue (ids, labels, menu paths, defaults), - the readers for other applications' files, the shortcut editor UI and running the commands. +- Data and logic, plus a shared Keyboard Shortcuts dialog behind the `ui` feature. The apps keep + the command catalogue (ids, labels, menu paths, defaults), the readers for other applications' + files, file dialogs and running the commands. - Never crashes: no panics, no `unsafe`. Loading settings never fails; what cannot be used is kept or reported (`Keymap::problems`, the import report). - Hostile input is capped: commands, bindings per command, text lengths, import entries, keys, path segments and fuzzy suggestions (the `MAX_*` constants). What is cut is counted. -- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature. +- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature; `craft-ui` and + `craft-i18n` with the `ui` feature. ## A catalogue and dispatch @@ -274,6 +276,77 @@ if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap() session.cancel(&mut keymap); ``` +## Shortcuts dialog + +With the `ui` feature, `ui::ShortcutsDialog` draws the Keyboard Shortcuts dialog over a `Keymap` +and, optionally, `Profiles`: a table of the catalogue grouped by menu, recording, conflicts, undo, +named sets, import review and a summary. It edits the keymap in place, so menus show new keys at +once, and Cancel puts everything back as it was when the dialog opened. + +Adopt it in three steps: + +1. Build the `Keymap` from the catalogue and keep the built-in sets (`Builtins`), as above. +2. While the dialog is open, keep a `DialogState` and call `show` (a window) or `ui` (the body, + for the app's own modal or preferences page) every frame. +3. Act on the `Outcome` it returns, and drop the state when `is_open()` turns false. + +```rust,ignore +use craft_keymap::ui::{DialogState, I18nText, Options, Outcome, ShortcutsDialog}; + +// "Keyboard Shortcuts…" opens it; "Edit Shortcut…" also calls `state.select(&command)`. +self.shortcuts = Some(DialogState::new()); + +// Every frame, before the app's own dispatch: +if let Some(state) = &mut self.shortcuts { + let text = I18nText { i18n: &self.i18n, lang: self.lang }; + let dialog = ShortcutsDialog::new(&mut self.keymap, &text).options(&self.shortcut_options); + let dialog = dialog.sets(&mut self.settings.shortcut_sets, &BUILTINS); + match dialog.show(ctx, state) { + Some(Outcome::Ok) => self.save_settings(), + Some(Outcome::Changed) => self.rebuild_menus(), + Some(Outcome::Import { formats }) => self.pick_import_file(formats), + Some(Outcome::Export { name, json }) => self.save_file(&name, json), + Some(Outcome::Summary { name, html, .. }) => self.show_summary(&name, html), + Some(Outcome::Remembered(answers)) => self.settings.import_answers.extend(answers), + _ => {} + } + if !state.is_open() { + self.shortcuts = None; + } +} +``` + +Outcomes: + +| Outcome | The app | +|---|---| +| `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | +| `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | +| `Changed` | Updates its menus, or saves at once if it saves live. | +| `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | +| `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | +| `Summary { name, html, text }` | Shows, saves or prints the summary. | +| `Remembered(answers)` | Keeps the user's import answers for the next import (`ImportOptions::remembered`). | + +The crate never opens a file dialog or touches the file system. + +Key dispatch while the dialog is open: + +- Skip `Dispatcher::dispatch` while `DialogState::is_open()` is true. The dialog takes the + frame's key, text and clipboard events itself while it records, so the chord the user presses + reaches the recorder and runs nothing. +- A native macOS menu handles its key equivalents before egui sees them, so Cmd+Q, Cmd+, and + the other menu chords never reach the recorder. While `is_recording()` is true, pause them: + feed them to egui as key presses, or rebuild the menu without accelerators. + +Words come from `ui::Text`. Apps on craft-i18n pass `I18nText`; others implement `ui` and +`plural` over their own lookup, and `rtl` and `visual` (the app's bidi reordering) for +right-to-left languages, which mirror the layout. Chord text is never reordered. `ui::STRINGS` +and `ui::PLURALS` list every string the dialog looks up, for the translation catalogue. +`Options` sets the conflict mode, double-press recording, area tabs, the on-screen keyboard, +read-only rows for keys outside the catalogue, the app's import and export formats, and an +`accept` rule for which keys a command may get. + ## Features - `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the @@ -303,3 +376,9 @@ session.cancel(&mut keymap); chords outside text fields, and a Paste event's text moves to `Hit::pasted`. An app whose paste command asks egui for a paste gets a Paste event back, which runs that command again; such an app keeps the default. + +- `ui`: the Shortcuts dialog (see above). Implies `egui`. +- `snapshot` (development): `tests/ui_snapshot.rs` writes PNGs of the dialog to + `CRAFT_KEYMAP_SNAPSHOT_DIR` for review. egui's default fonts lack ⇧ ⌥ ⌃ and the arrows; the + test adds the font in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans when it is installed, and BIZ + UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR` (⇧ ⌘ and arrows only). diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 0b53b2b8..9483ad26 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -599,7 +599,9 @@ impl Draw<'_, '_> { let r = ui.add_enabled(selected.is_some(), Button::new(self.vis(&label))); self.tag(|| "shortcuts.assignKey".into(), r.rect, &label); let r = match &selected { - Some(c) => r.on_hover_text(self.vis(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]))), + Some(c) => r.on_hover_text( + self.vis_chord(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]), &chord), + ), None => r.on_disabled_hover_text(self.vis(&self.ui("Select a command first"))), }; if r.clicked() { @@ -616,6 +618,7 @@ impl Draw<'_, '_> { } let current = Chord { mods, key }.effective(platform); let chord_w = 72.0; + let chord_layout = if self.env.text.rtl() { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) }; ScrollArea::vertical().id_salt(self.base.with("keylist")).auto_shrink([false, false]).show(ui, |ui| { for (c, h) in &entries { ui.horizontal(|ui| { @@ -623,9 +626,7 @@ impl Draw<'_, '_> { if *c == current { text = text.strong(); } - ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), Layout::left_to_right(Align::Center), |ui| { - ui.label(text) - }); + ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), chord_layout, |ui| ui.label(text)); if ui.link(self.vis(&self.holder_text(h))).clicked() { clicks.go = Some(h.holder.command.clone()); } diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index fe642cd6..c7823b5d 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -3,7 +3,7 @@ //! and after the import the report of what was skipped, taken or failed. use egui::text::{LayoutJob, TextFormat}; -use egui::{Color32, ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; +use egui::{ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; use super::{Draw, FIELD_W, fill}; use crate::chord::foreign::{ForeignError, ReadNote}; @@ -18,6 +18,8 @@ struct Entry { note: String, /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. to: bool, + /// Chord text inside the note, kept out of bidi reordering. + note_chord: String, } enum Act { @@ -58,19 +60,21 @@ impl Draw<'_, '_> { let font = TextStyle::Body.resolve(ui.style()); let strong = ui.visuals().strong_text_color(); let dim = self.style.dim; - let mut job = LayoutJob::default(); - let mut add = |s: &str, color: Color32| job.append(s, 0.0, TextFormat::simple(font.clone(), color)); - add(&self.vis(&e.item), strong); + let text = ui.visuals().text_color(); + let mut parts = vec![(self.vis(&e.item), strong)]; if let Some(line) = self.line_text(e.line) { - add(&format!(" · {}", self.vis(&line)), dim); + parts.extend([(" · ".to_string(), dim), (self.vis(&line), dim)]); } if !e.keys.is_empty() { - add(&format!(" · {}", e.keys), ui.visuals().text_color()); + parts.extend([(" · ".to_string(), dim), (e.keys.clone(), text)]); } - if e.to { - add(&format!(" — {}", self.vis(&e.note)), ui.visuals().text_color()); - } else if !e.note.is_empty() { - add(&format!(" — {}", self.vis(&e.note)), dim); + if !e.note.is_empty() { + let color = if e.to { text } else { dim }; + parts.extend([(" — ".to_string(), color), (self.vis_chord(&e.note, &e.note_chord), color)]); + } + let mut job = LayoutJob::default(); + for (s, color) in self.ordered(parts) { + job.append(&s, 0.0, TextFormat::simple(font.clone(), color)); } ui.add(Label::new(job).wrap()); } @@ -161,12 +165,16 @@ impl Draw<'_, '_> { let changes: Vec = plan .commands .iter() - .map(|c| Entry { - item: c.item.clone(), - line: c.line, - keys: String::new(), - note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chord_list(self, c))]), - to: true, + .map(|c| { + let chords = chord_list(self, c); + Entry { + item: c.item.clone(), + line: c.line, + keys: String::new(), + note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chords)]), + to: true, + note_chord: chords, + } }) .collect(); self.section(ui, &self.ui("Will change"), &changes); @@ -176,7 +184,17 @@ impl Draw<'_, '_> { ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); for (si, s) in report.suggestions.iter().enumerate() { ui.horizontal_wrapped(|ui| { - self.entry(ui, &Entry { item: s.item.clone(), line: s.line, keys: String::new(), note: String::new(), to: false }); + self.entry( + ui, + &Entry { + item: s.item.clone(), + line: s.line, + keys: String::new(), + note: String::new(), + to: false, + note_chord: String::new(), + }, + ); let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]); let choose = self.ui("Choose a command…"); let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| { @@ -196,19 +214,40 @@ impl Draw<'_, '_> { let unmatched: Vec = report .unmatched .iter() - .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.unmatched_reason(u.reason), to: false }) + .map(|u| Entry { + item: u.item.clone(), + line: u.line, + keys: u.keys.clone(), + note: self.unmatched_reason(u.reason), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Not matched"), &unmatched); let unreadable: Vec = report .unreadable .iter() - .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.foreign_error(&u.reason), to: false }) + .map(|u| Entry { + item: u.item.clone(), + line: u.line, + keys: u.keys.clone(), + note: self.foreign_error(&u.reason), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Unreadable"), &unreadable); let approximated: Vec = report .approximated .iter() - .map(|a| Entry { item: a.item.clone(), line: a.line, keys: a.keys.clone(), note: self.read_note(a.note), to: false }) + .map(|a| Entry { + item: a.item.clone(), + line: a.line, + keys: a.keys.clone(), + note: self.read_note(a.note), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Approximated"), &approximated); let kept: Vec = report @@ -224,6 +263,7 @@ impl Draw<'_, '_> { } }, to: false, + note_chord: String::new(), }) .collect(); self.section(ui, &self.ui("Matched without change"), &kept); @@ -259,17 +299,32 @@ impl Draw<'_, '_> { keys: s.shortcut.clone(), note: self.skip_note(s.reason, s.kept_by.as_ref()), to: false, + note_chord: String::new(), }) .collect(); let cleared = report .conflicts_cleared .iter() - .map(|c| Entry { item: self.label_of(&c.command), line: None, keys: c.shortcut.clone(), note: String::new(), to: false }) + .map(|c| Entry { + item: self.label_of(&c.command), + line: None, + keys: c.shortcut.clone(), + note: String::new(), + to: false, + note_chord: String::new(), + }) .collect(); let failed = report .failed .iter() - .map(|f| Entry { item: self.label_of(&f.command), line: None, keys: f.shortcuts.join(", "), note: self.failed_note(f.change), to: false }) + .map(|f| Entry { + item: self.label_of(&f.command), + line: None, + keys: f.shortcuts.join(", "), + note: self.failed_note(f.change), + to: false, + note_chord: String::new(), + }) .collect(); [(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)] } diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 1e07a516..93804092 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -61,7 +61,7 @@ impl Filter { #[derive(Clone, Debug, PartialEq)] pub(crate) struct Chip { pub binding: Binding, - /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope. + /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope (display order). pub text: String, /// Not one of the command's defaults. pub changed: bool, @@ -375,7 +375,9 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu .map(|x| { let mut t = trigger_text(text, &x.trigger, platform); if x.scope_or(&b.scope) != &b.scope { - t = format!("{t} ({})", text.segment(x.scope_or(&b.scope).as_str())); + let scope = format!("({})", text.segment(x.scope_or(&b.scope).as_str())); + // Display order; the chord itself is never reordered. + t = if text.rtl() { format!("{} {t}", text.visual(&scope)) } else { format!("{t} {scope}") }; } Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } }) diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index 1df11548..a18343a5 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -48,15 +48,13 @@ impl Draw<'_, '_> { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); let mut pick = None; - let combo = ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + self.combo(ui, "set", &l, &set_label, &active, |ui| { for n in &names { if ui.selectable_label(*n == active, self.vis(n)).clicked() { pick = Some(n.clone()); } } }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); if modified { ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 34edd13e..3c5f87b8 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -103,6 +103,8 @@ pub(crate) struct Message { pub goto: Option, /// Offers Undo. pub undo: bool, + /// The chord text inside `text`, which is never put through `Text::visual`. + pub chord: Option, } /// What to do after the Save changes prompt. @@ -507,7 +509,7 @@ impl DialogState { self.message = None; let chord = env.chord_text(&binding.trigger); if env.keymap.is_reserved(&binding.trigger.chord) { - self.refuse(env.ui("{chord} is reserved by the system", &[("chord", &chord)])); + self.refuse_chord(env.ui("{chord} is reserved by the system", &[("chord", &chord)]), chord); return; } if let (Some(accept), Some(b)) = (&env.options.accept, env.keymap.bindable(command)) @@ -532,10 +534,15 @@ impl DialogState { } fn refuse(&mut self, text: String) { - self.message = Some(Message { text, refused: true, goto: None, undo: false }); + self.message = Some(Message { text, refused: true, goto: None, undo: false, chord: None }); + } + + fn refuse_chord(&mut self, text: String, chord: String) { + self.message = Some(Message { text, refused: true, goto: None, undo: false, chord: Some(chord) }); } fn apply(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) -> Option { + let chord = env.chord_text(&binding.trigger); let km = &mut *env.keymap; let session = self.session.get_or_insert_with(|| EditSession::begin(km)); let result = match &old { @@ -549,7 +556,7 @@ impl DialogState { } Err(e) => { let text = assign_error(env, &e); - self.refuse(text); + self.refuse_chord(text, chord); None } } @@ -566,7 +573,7 @@ impl DialogState { } let text = env.ui(text, &[("chord", &chord), ("label", &env.holder_labels(holders))]); let goto = holders.first().map(|h| h.command.clone()); - self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal }); + self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal, chord: Some(chord) }); } /// Take It. diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 844c75aa..05c430ef 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -6,14 +6,16 @@ //! frame's height and asks egui to discard a frame whose height changed. use std::ops::Range; +use std::sync::Arc; use craft_ui::buttons::{IconButton, IconButtonStyle}; use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; use craft_ui::tabs::{self, Tab}; +use egui::Galley; use egui::text::{LayoutJob, TextFormat, TextWrapping}; use egui::{ - Align, Button, Color32, ComboBox, EventFilter, FontId, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, - ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, + Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, ScrollArea, + Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, }; use crate::chord::Trigger; @@ -44,6 +46,11 @@ const CHIP_PAD: f32 = 5.0; const CHIP_GAP: f32 = 4.0; /// The detail pane's New Shortcut field. const FIELD_W: f32 = 160.0; +/// The widest Show, Filter or Set combo box; longer names are cut with "…". +const COMBO_MAX: f32 = 200.0; +/// Search field width. +const SEARCH_MIN: f32 = 120.0; +const SEARCH_MAX: f32 = 240.0; /// One frame of the dialog body inside `ui`'s available rectangle. pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { @@ -90,6 +97,93 @@ impl Draw<'_, '_> { self.env.text.visual(s) } + fn rtl(&self) -> bool { + self.env.text.rtl() + } + + /// Parts in logical order, each already in display order, put into display order: reversed in + /// a right-to-left interface, where the path separator ` › ` becomes ` ‹ `. + fn ordered(&self, mut parts: Vec<(String, T)>) -> Vec<(String, T)> { + if self.rtl() { + parts.reverse(); + for (s, _) in &mut parts { + if s == PATH_SEP { + *s = " ‹ ".into(); + } + } + } + parts + } + + /// [`ordered`](Self::ordered) parts as one string. + fn joined(&self, parts: Vec) -> String { + self.ordered(parts.into_iter().map(|s| (s, ())).collect()).into_iter().map(|(s, ())| s).collect() + } + + /// Display-order `parts` on one line within `width`, cut with "…" when they do not fit: at + /// the end, or at the start in a right-to-left interface, where the logical end of the text is + /// on the left. Returns the galley and whether it was cut. + fn one_line(&self, painter: &Painter, parts: &[(String, TextFormat)], width: f32) -> (Arc, bool) { + let width = width.max(0.0); + let job = |skip: usize| { + let mut job = LayoutJob::default(); + if let (true, Some((_, f))) = (skip > 0, parts.first()) { + job.append("…", 0.0, f.clone()); + } + let mut left = skip; + for (s, f) in parts { + let n = s.chars().count(); + if left >= n { + left -= n; + continue; + } + let start = s.char_indices().nth(left).map_or(s.len(), |(i, _)| i); + left = 0; + job.append(&s[start..], 0.0, f.clone()); + } + job + }; + let full = painter.layout_job(job(0)); + if full.size().x <= width + 0.5 { + return (full, false); + } + if !self.rtl() { + let mut cut = job(0); + cut.wrap = TextWrapping::truncate_at_width(width); + return (painter.layout_job(cut), true); + } + // The fewest characters to drop from the start so that "…" and the rest fit. + let total: usize = parts.iter().map(|(s, _)| s.chars().count()).sum(); + let (mut lo, mut hi) = (1, total); + while lo < hi { + let mid = (lo + hi) / 2; + if painter.layout_job(job(mid)).size().x <= width + 0.5 { hi = mid } else { lo = mid + 1 } + } + (painter.layout_job(job(lo)), true) + } + + /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes + /// through [`Text::visual`](crate::ui::Text::visual). + fn vis_chord(&self, s: &str, chord: &str) -> String { + match s.find(chord).filter(|_| !chord.is_empty()) { + None => self.vis(s), + Some(i) => { + let (before, rest) = s.split_at(i); + self.joined(vec![self.vis(before), chord.to_string(), self.vis(&rest[chord.len()..])]) + } + } + } + + /// `text` after a ⚠, in display order. + fn warned(&self, text: String) -> String { + self.joined(vec!["⚠".into(), " ".into(), text]) + } + + /// Horizontal placement inside `rect`, mirrored in a right-to-left interface. + fn mirror(&self, rect: Rect) -> Mirror { + Mirror { on: self.rtl(), sum: rect.min.x + rect.max.x } + } + fn chord(&self, t: &Trigger) -> String { trigger_text(self.env.text, t, self.env.keymap.platform()) } @@ -98,6 +192,15 @@ impl Draw<'_, '_> { if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } } + /// [`binding_text`](Self::binding_text) in display order. + fn binding_shown(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => self.joined(vec![chord, " ".into(), self.vis(&format!("({})", self.scope_name(s)))]), + _ => chord, + } + } + /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { let chord = self.chord(&b.trigger); @@ -107,6 +210,18 @@ impl Draw<'_, '_> { } } + /// A menu path in display order: "File › Export", "Export ‹ File" right to left. + fn path_text(&self, path: &[String]) -> String { + let mut parts = Vec::new(); + for (i, s) in path.iter().enumerate() { + if i > 0 { + parts.push(PATH_SEP.to_string()); + } + parts.push(self.vis(&self.env.text.segment(s))); + } + self.joined(parts) + } + fn label_of(&self, command: &CommandRef) -> String { self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) } @@ -114,7 +229,10 @@ impl Draw<'_, '_> { /// "press keys", or the chord waiting for a second press. fn recording_text(&self) -> String { match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { - Some((t, _)) => fill(self.ui("{chord} (press again for a double press)"), &[("chord", &self.chord(t))]), + Some((t, _)) => { + let chord = self.chord(t); + self.vis_chord(&fill(self.ui("{chord} (press again for a double press)"), &[("chord", &chord)]), &chord) + } None => self.ui("press keys"), } } @@ -223,7 +341,8 @@ impl Draw<'_, '_> { let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); self.state.text_focus = false; let gap = ui.spacing().item_spacing.y; - ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(full).layout(column), |ui| { self.sets_row(ui); // Show, Filter and Search act on the table, which the import review replaces. ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); @@ -236,8 +355,7 @@ impl Draw<'_, '_> { } let top = (mid.max.y + gap).min(rest.max.y); let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); - let used = - ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(Layout::top_down(Align::Min)), |ui| self.bottom(ui)).response.rect.height(); + let used = ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(column), |ui| self.bottom(ui)).response.rect.height(); if (used - self.state.bottom_h).abs() > 0.5 { self.state.bottom_h = used; ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); @@ -246,8 +364,15 @@ impl Draw<'_, '_> { ui.advance_cursor_after_rect(full); } + /// Top to bottom, aligned to the reading side. In a right-to-left interface egui's + /// `horizontal` rows inside such a column also run right to left. + fn column(&self) -> Layout { + Layout::top_down(if self.env.text.rtl() { Align::Max } else { Align::Min }) + } + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { - ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(column), |ui| { ui.set_clip_rect(rect.intersect(ui.clip_rect())); f(self, ui); }); @@ -271,60 +396,98 @@ impl Draw<'_, '_> { if self.env.options.area_tabs { self.area_tabs(ui); } + // Show and Filter, then Search, ⌨ and Keyboard; Search goes to a row of its own when the + // two do not fit side by side. + let gap = ui.spacing().item_spacing.x; + let text_w = |d: &Self, ui: &Ui, s: &str| d.button_width(ui, s) - ui.spacing().button_padding.x * 2.0; + let mut first = text_w(self, ui, &self.ui("Filter:")) + COMBO_MAX + 2.0 * gap; + if !self.env.options.area_tabs { + first += text_w(self, ui, &self.ui("Show:")) + COMBO_MAX + 2.0 * gap; + } + let toggle = if self.env.options.keyboard { self.button_width(ui, &self.ui("Keyboard")) + gap } else { 0.0 }; + let second = text_w(self, ui, &self.ui("Search:")) + SEARCH_MIN + ICON + toggle + 3.0 * gap; + let one_row = first + second <= ui.available_width(); ui.horizontal(|ui| { if !self.env.options.area_tabs { self.area_combo(ui); } - let filter_label = self.ui("Filter:"); - let l = ui.label(self.vis(&filter_label)); - let current = self.state.filter.label(self.env.text); - let mut pick = None; - let combo = ComboBox::from_id_salt(self.base.with("filter")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { - for f in Filter::ALL { - let label = f.label(self.env.text); - if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { - pick = Some(f); - } - } - }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.filter".into(), combo.rect, &filter_label); - if let Some(f) = pick { - self.state.set_filter(f); + self.filter_combo(ui); + if one_row { + self.search(ui, toggle); } - - let search_label = self.ui("Search:"); - let l = ui.label(self.vis(&search_label)); - let mut q = self.state.query.clone(); - let width = (ui.available_width() - ICON - ui.spacing().item_spacing.x).clamp(60.0, 240.0); - let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); - self.text_field(&edit); - self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); - if edit.changed() { - self.state.set_query(q); - } - - let key_label = self.ui("Search by key"); - let searching = self.recording_target() == Some(&Target::Search); - let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); - self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); - if r.clicked() { - if searching { - self.state.stop_recording(); - } else { - self.state.record(self.env, Target::Search); - r.request_focus(); - } - } - if self.recording_target() == Some(&Target::Search) { - if r.lost_focus() { - self.state.stop_recording(); - } else if r.has_focus() || r.clicked() { - capture::lock_focus(ui, r.id); - } - } - self.keyboard_toggle(ui); }); + if !one_row { + ui.horizontal(|ui| self.search(ui, toggle)); + } + } + + /// A combo box at most [`COMBO_MAX`] wide, its text cut with "…" and shown in full on hover. + fn combo(&self, ui: &mut Ui, salt: &str, label: &Response, title: &str, current: &str, items: impl FnOnce(&mut Ui)) -> Response { + let full = self.vis(current); + let natural = self.button_width(ui, &full) + ui.spacing().icon_width + ui.spacing().icon_spacing; + let width = natural.clamp(ui.spacing().combo_width, COMBO_MAX); + let r = ui + .allocate_ui(vec2(width, ui.spacing().interact_size.y), |ui| { + ComboBox::from_id_salt(self.base.with(salt)).width(width).truncate().selected_text(full.clone()).show_ui(ui, items).response + }) + .inner + .labelled_by(label.id); + self.tag(|| format!("shortcuts.{salt}"), r.rect, title); + if natural > width { r.on_hover_text(full) } else { r } + } + + fn filter_combo(&mut self, ui: &mut Ui) { + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + self.combo(ui, "filter", &l, &filter_label, ¤t, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + if let Some(f) = pick { + self.state.set_filter(f); + } + } + + /// Search, search by key and the keyboard pane's toggle (`toggle` wide, 0 without it). + fn search(&mut self, ui: &mut Ui, toggle: f32) { + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let gap = ui.spacing().item_spacing.x; + let width = (ui.available_width() - ICON - toggle - 2.0 * gap).clamp(SEARCH_MIN.min(ui.available_width()), SEARCH_MAX); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } + self.keyboard_toggle(ui); } fn area_combo(&mut self, ui: &mut Ui) { @@ -333,7 +496,7 @@ impl Draw<'_, '_> { let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); let mut pick = None; let areas = self.state.areas.clone(); - let combo = ComboBox::from_id_salt(self.base.with("area")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + self.combo(ui, "area", &l, &show, ¤t, |ui| { for a in &areas { let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); @@ -342,8 +505,6 @@ impl Draw<'_, '_> { } } }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.area".into(), combo.rect, &show); if let Some(a) = pick { self.state.set_area(a); } @@ -359,11 +520,12 @@ impl Draw<'_, '_> { let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let m = self.mirror(rect); let mut x = rect.min.x; let mut pick = None; for &(i, w) in &fit.shown { let Some(a) = areas.get(i) else { continue }; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height())); + let r = m.rect(Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height()))); x += w; let on = i == selected; let id = self.base.with(("area", i)); @@ -378,7 +540,7 @@ impl Draw<'_, '_> { if on { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); } - let g = ui.painter().layout(text, font.clone(), color, r.width() - 4.0); + let (g, _) = self.one_line(ui.painter(), &[(text, TextFormat::simple(font.clone(), color))], r.width() - 4.0); ui.painter().galley(r.center() - g.size() / 2.0, g, color); }); self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); @@ -387,7 +549,7 @@ impl Draw<'_, '_> { } } if !fit.overflow.is_empty() { - let r = Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height())); + let r = m.rect(Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height()))); let more = self.ui("More"); ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { ui.menu_button(self.vis(&more), |ui| { @@ -453,19 +615,14 @@ impl Draw<'_, '_> { /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { - let widest = self + let font = TextStyle::Body.resolve(ui.style()); + let chip_w = |text: &str| ui.painter().layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP; + let chips = self .state .lines_cached() .iter() - .filter_map(|l| if let Line::Row(r) = l { Some(r) } else { None }) - .max_by_key(|r| r.chips.iter().map(|c| c.text.chars().count() + 2).sum::()); - let font = TextStyle::Body.resolve(ui.style()); - let chips: f32 = widest.map_or(0.0, |r| { - r.chips - .iter() - .map(|c| ui.painter().layout_no_wrap(c.text.clone(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP) - .sum() - }); + .filter_map(|l| if let Line::Row(r) = l { Some(r.chips.iter().map(|c| chip_w(&c.text)).sum::()) } else { None }) + .fold(0.0, f32::max); (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) } @@ -503,10 +660,13 @@ impl Draw<'_, '_> { let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); let font = TextStyle::Body.resolve(ui.style()); let dim = self.style.dim; - let cmd_title = self.vis(&self.ui("Command")); - let key_title = self.vis(&self.ui("Shortcut")); - ui.painter().text(pos2(head.min.x + 6.0, head.center().y), egui::Align2::LEFT_CENTER, cmd_title, font.clone(), dim); - ui.painter().text(pos2(head.max.x - shortcut_w - ICON - 4.0, head.center().y), egui::Align2::LEFT_CENTER, key_title, font, dim); + let m = self.mirror(head); + let key_x = head.max.x - shortcut_w - ICON - 4.0; + let titles = [(self.ui("Command"), head.min.x + 6.0, key_x - head.min.x - 12.0), (self.ui("Shortcut"), key_x, shortcut_w)]; + for (title, x, w) in titles { + let (g, _) = self.one_line(ui.painter(), &[(self.vis(&title), TextFormat::simple(font.clone(), dim))], w); + ui.painter().galley(pos2(m.x(x, g.size().x), head.center().y - g.size().y / 2.0), g, dim); + } ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); if matches == 0 { @@ -640,17 +800,22 @@ impl Draw<'_, '_> { resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); let v = ui.visuals(); let color = v.strong_text_color(); - let tri = Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0)); - let points = if collapsed { - vec![tri.left_top(), tri.right_center(), tri.left_bottom()] - } else { - vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + let m = self.mirror(rect); + let tri = m.rect(Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0))); + let points = match (collapsed, m.on) { + (true, false) => vec![tri.left_top(), tri.right_center(), tri.left_bottom()], + (true, true) => vec![tri.right_top(), tri.right_bottom(), tri.left_center()], + (false, _) => vec![tri.left_top(), tri.right_top(), tri.center_bottom()], }; ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); let font = TextStyle::Body.resolve(ui.style()); - let g = ui.painter().layout_job(truncated(&self.vis(name), font, color, rect.width() - 24.0)); - ui.painter().galley(pos2(rect.min.x + 20.0, rect.center().y - g.size().y / 2.0), g, color); - resp.clicked().then(|| RowAction::Toggle(name.to_string())) + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(name), TextFormat::simple(font, color))], rect.width() - 24.0); + ui.painter().galley(pos2(m.x(rect.min.x + 20.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let clicked = resp.clicked(); + if cut { + resp.on_hover_text(self.vis(name)); + } + clicked.then(|| RowAction::Toggle(name.to_string())) } fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { @@ -674,8 +839,9 @@ impl Draw<'_, '_> { } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); } + let m = self.mirror(rect); if r.changed { - ui.painter().rect_filled(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0)), 1.0, self.style.changed); + ui.painter().rect_filled(m.rect(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0))), 1.0, self.style.changed); } let text_color = if selected { v.strong_text_color() } else { v.text_color() }; let strong = v.strong_text_color(); @@ -684,25 +850,25 @@ impl Draw<'_, '_> { // Label column. let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); - let mut job = LayoutJob::default(); + let fmt = |c: Color32| TextFormat::simple(font.clone(), c); + let mut parts = Vec::new(); if r.conflict { - job.append("⚠ ", 0.0, TextFormat::simple(font.clone(), self.style.warning)); + parts.extend([("⚠".to_string(), fmt(self.style.warning)), (" ".into(), fmt(dim))]); } for p in &r.prefix { - job.append(&format!("{} › ", self.vis(p)), 0.0, TextFormat::simple(font.clone(), dim)); + parts.extend([(self.vis(p), fmt(dim)), (PATH_SEP.into(), fmt(dim))]); } - job.append(&self.vis(&r.label), 0.0, TextFormat::simple(font.clone(), if r.changed { strong } else { text_color })); + parts.push((self.vis(&r.label), fmt(if r.changed { strong } else { text_color }))); if r.cycles { - job.append(&format!(" {}", self.vis(&self.ui("(cycles)"))), 0.0, TextFormat::simple(font.clone(), dim)); + parts.extend([(" ".into(), fmt(dim)), (self.vis(&self.ui("(cycles)")), fmt(dim))]); } - job.wrap = TextWrapping::truncate_at_width(label_w); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, text_color); + let (g, _) = self.one_line(ui.painter(), &self.ordered(parts), label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, text_color); // Shortcut column: chips, then "+". let mut action = None; let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); - let painter = ui.painter_at(col); + let painter = ui.painter_at(m.rect(col)); let mut x = col.min.x; let rec = match self.recording_target() { Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), @@ -718,7 +884,7 @@ impl Draw<'_, '_> { let shown = if *recording { self.recording_text() } else { text.clone() }; let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); let w = g.size().x + 2.0 * CHIP_PAD; - let chip = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h)); + let chip = m.rect(Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h))); x += w + CHIP_GAP; let changed = r.chips.get(n).is_some_and(|c| c.changed); let stroke = if *recording { @@ -739,7 +905,8 @@ impl Draw<'_, '_> { } else { fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) }; - let resp = ui.interact(chip.intersect(col), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + let resp = + ui.interact(chip.intersect(m.rect(col)), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); if resp.clicked() { @@ -748,7 +915,7 @@ impl Draw<'_, '_> { } if cells.is_empty() { // An empty cell to click for the first shortcut. - let cell = Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y)); + let cell = m.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); @@ -759,7 +926,7 @@ impl Draw<'_, '_> { x = col.min.x; } if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { - let plus = Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let plus = m.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); @@ -768,7 +935,7 @@ impl Draw<'_, '_> { } } if editable { - let reset = Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let reset = m.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); @@ -781,7 +948,7 @@ impl Draw<'_, '_> { let mut tip = vec![self.vis(&r.label)]; if let Some(b) = self.env.keymap.bindable(&r.command) { if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { - tip.push(path.iter().map(|s| self.vis(&self.env.text.segment(s))).collect::>().join(" › ")); + tip.push(self.path_text(path)); } tip.push(r.command.id.clone()); if let Some(h) = self.env.text.hint(b) { @@ -801,22 +968,29 @@ impl Draw<'_, '_> { if f.now_used_by.is_some() { fmt.strikethrough = Stroke::new(1.0, color); } - let mut job = LayoutJob::single_section(self.vis(&f.label), fmt.clone()); - job.wrap = TextWrapping::truncate_at_width((rect.width() - shortcut_w - ICON - 12.0).max(0.0)); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); - let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); - ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); + let m = self.mirror(rect); + let label_w = rect.width() - shortcut_w - ICON - 12.0; + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(&f.label), fmt.clone())], label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); + ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + let mut tip = Vec::new(); + if cut { + tip.push(self.vis(&f.label)); + } match &f.now_used_by { Some(by) => { - let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({tip})"))); - resp.on_hover_text(self.vis(&tip)); + let used = fill(self.ui("Now used by {label}"), &[("label", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({used})"))); + tip.push(self.vis(&used)); } None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } + if !tip.is_empty() { + resp.on_hover_text(tip.join("\n")); + } } fn detail(&mut self, ui: &mut Ui) { @@ -837,9 +1011,9 @@ impl Draw<'_, '_> { let home = Some(&b.scope); let dim = self.style.dim; ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); - let mut place: Vec = b.paths.first().map(|p| p.iter().map(|s| self.vis(&self.env.text.segment(s))).collect()).unwrap_or_default(); - place.retain(|s| !s.is_empty()); - let place = if place.is_empty() { b.command.id.clone() } else { format!("{} · {}", place.join(" › "), b.command.id) }; + let path: Vec = b.paths.first().map(|p| p.iter().filter(|s| !s.is_empty()).cloned().collect()).unwrap_or_default(); + let place = + if path.is_empty() { b.command.id.clone() } else { self.joined(vec![self.path_text(&path), " · ".into(), b.command.id.clone()]) }; ui.add(Label::new(RichText::new(place).color(dim)).wrap()); if let Some(h) = self.env.text.hint(&b) { ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); @@ -858,7 +1032,11 @@ impl Draw<'_, '_> { let text = self.binding_text(binding, home); ui.horizontal(|ui| { ui.add_space(8.0); - let shown = if defaults.contains(binding) { format!("{text} ({})", self.vis(&self.ui("Default"))) } else { text.clone() }; + let shown = if defaults.contains(binding) { + self.joined(vec![self.binding_shown(binding, home), " ".into(), self.vis(&format!("({})", self.ui("Default")))]) + } else { + self.binding_shown(binding, home) + }; ui.label(shown); if editable { let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); @@ -899,7 +1077,8 @@ impl Draw<'_, '_> { fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { let chords = if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; - ui.add(Label::new(RichText::new(self.vis(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]))).color(self.style.dim)).wrap()); + let text = self.vis_chord(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]), &chords); + ui.add(Label::new(RichText::new(text).color(self.style.dim)).wrap()); } /// New Shortcut: a field that records when focused or clicked, or typed entry. @@ -1017,7 +1196,7 @@ impl Draw<'_, '_> { }; ui.horizontal(|ui| { ui.add_space(8.0); - if ui.link(format!("⚠ {}", self.vis(&text))).clicked() { + if ui.link(self.warned(self.vis(&text))).clicked() { go = Some(h.command.clone()); } }); @@ -1080,7 +1259,8 @@ impl Draw<'_, '_> { fn conflict_bar(&mut self, ui: &mut Ui) { let Some(c) = self.state.conflict.clone() else { return }; - ui.add(Label::new(RichText::new(self.vis(&c.text)).color(self.style.warning)).wrap()); + let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); + ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); let mut act = 0; ui.horizontal_wrapped(|ui| { if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { @@ -1112,7 +1292,8 @@ impl Draw<'_, '_> { let Some(m) = self.state.message.clone() else { return }; let mut act = 0; ui.horizontal_wrapped(|ui| { - let text = if m.refused { format!("⚠ {}", self.vis(&m.text)) } else { self.vis(&m.text) }; + let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); + let text = if m.refused { self.warned(text) } else { text }; let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; ui.add(Label::new(RichText::new(text).color(color)).wrap()); if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { @@ -1142,7 +1323,7 @@ impl Draw<'_, '_> { if clashes > 0 { let n = clashes as u64; let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); - conflicts = ui.link(format!("⚠ {}", self.vis(&text))).clicked(); + conflicts = ui.link(self.warned(self.vis(&text))).clicked(); } if shadows > 0 { let n = shadows as u64; @@ -1224,10 +1405,26 @@ impl Draw<'_, '_> { } } -fn truncated(text: &str, font: FontId, color: Color32, width: f32) -> LayoutJob { - let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color)); - job.wrap = TextWrapping::truncate_at_width(width.max(0.0)); - job +/// Separator between menu path parts, mirrored by [`Draw::ordered`]. +const PATH_SEP: &str = " › "; + +/// Places rectangles laid out left to right inside a span, mirrored in a right-to-left interface. +#[derive(Clone, Copy)] +struct Mirror { + on: bool, + /// The span's left plus right edge. + sum: f32, +} + +impl Mirror { + fn rect(self, r: Rect) -> Rect { + if self.on { Rect::from_x_y_ranges((self.sum - r.max.x)..=(self.sum - r.min.x), r.y_range()) } else { r } + } + + /// The left edge of something `width` wide whose left-to-right left edge is `x`. + fn x(self, x: f32, width: f32) -> f32 { + if self.on { self.sum - x - width } else { x } + } } // ---- Icons, drawn so they do not depend on the app's fonts ---- diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index f48f3802..5ddf5800 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -13,7 +13,7 @@ use egui::accesskit::Role; use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; -use ui_fixture::fixture; +use ui_fixture::{Pseudo, fixture, with_long_label}; mod ui_fixture; @@ -538,7 +538,8 @@ fn rtl_puts_the_detail_pane_left_and_keeps_chords() { let h = open(fx); let table = h.get_by_label("Commands").rect(); assert!(table.min.x > 300.0, "table on the right: {table:?}"); - assert!(has(&h, "Save As…: ⇧⌘S")); + let chip = h.get_by_label("Save As…: ⇧⌘S").rect(); + assert!(chip.center().x < table.center().x, "shortcut column on the table's left: {chip:?} in {table:?}"); } // ---- Sets ---- @@ -832,3 +833,68 @@ fn read_only_has_no_edit_controls() { click(&mut h, "Close"); assert!(h.state().cancelled()); } + +// ---- Pseudo locale ---- + +/// Text a test put there or read from a foreign file, which no lookup translates. +const RAW: &[&str] = &["Theirs", "Frobnicate", "Sve Az", "Blorp", "Num", "Name 2"]; + +/// Every control inside the `width` px dialog, and every label with words marked by `Pseudo`. +fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let Some(label) = n.label() else { continue }; + seen += 1; + let r = node.rect(); + assert!(r.min.x >= -1.0 && r.max.x <= width + 1.0, "{step}: {:?} {label:?} at {r:?} leaves the {width} px dialog", n.role()); + let mut rest = String::new(); + let mut depth = 0usize; + for c in label.chars() { + match c { + '⟦' => depth += 1, + '⟧' => depth = depth.saturating_sub(1), + _ if depth == 0 => rest.push(c), + _ => {} + } + } + for raw in RAW { + rest = rest.replace(raw, ""); + } + let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); + assert!(!words, "{step}: {label:?} has text that did not go through Text"); + } + assert!(seen > 15, "{step}: {seen} labelled nodes"); +} + +#[test] +fn pseudo_locale_marks_every_string_and_fits() { + let p = Pseudo::mark; + for rtl in [false, true] { + for width in [560.0, 1100.0] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); + } + } +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs index 3b4b7fd4..efcc82b1 100644 --- a/crates/keymap/tests/ui_fixture/mod.rs +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -1,6 +1,12 @@ //! The catalogue the dialog tests and screenshots use. -use craft_keymap::{Bindable, Binding, Keymap, Kind, Platform, Scope, Scopes}; +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use craft_keymap::ui::{PLURALS, STRINGS, Text}; +use craft_keymap::{Bindable, Binding, Key, Keymap, Kind, Platform, Scope, Scopes}; fn binding(text: &str) -> Binding { Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) @@ -22,3 +28,129 @@ pub fn fixture(platform: Platform) -> Keymap { ]; Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } + +/// `km` with a command whose label and menu path are far too long for a row. +#[allow(dead_code)] +pub fn with_long_label(km: Keymap) -> Keymap { + let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); + let mut catalogue = km.catalogue().to_vec(); + catalogue.insert( + 3, + Bindable::new("object.distribute", long.as_str(), Scope::app()) + .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) + .with_default(binding("Cmd+Alt+Shift+D")) + .with_default(binding("Ctrl+Alt+Shift+F12")), + ); + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) +} + +/// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and +/// in `⟦…⟧`, so text drawn without the marks never went through [`Text`]. Placeholders stay as +/// they are. Labels and path parts are marked too. Looked-up dialog strings missing from +/// `STRINGS` or `PLURALS` are collected in `unlisted`. +#[allow(dead_code)] +#[derive(Default)] +pub struct Pseudo { + pub unlisted: Rc>>, + /// Lay the dialog out right to left. + pub rtl: bool, +} + +#[allow(dead_code)] +impl Pseudo { + pub fn mark(source: &str) -> String { + let mut out = String::from("⟦\u{200F}"); + let mut in_placeholder = false; + let mut letters = 0; + for c in source.chars() { + match c { + '{' => in_placeholder = true, + '}' => in_placeholder = false, + _ => {} + } + let accented = match c { + _ if in_placeholder => c, + 'a' => 'á', + 'e' => 'ë', + 'i' => 'ï', + 'o' => 'ö', + 'u' => 'ü', + 'A' => 'Å', + 'E' => 'É', + 'O' => 'Ø', + _ => c, + }; + if !in_placeholder && c.is_alphabetic() { + letters += 1; + } + out.push(accented); + } + out.extend(std::iter::repeat_n('~', letters * 2 / 5)); + out.push_str("\u{200F}⟧"); + out + } +} + +impl Text for Pseudo { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + if !STRINGS.contains(&source) { + self.unlisted.borrow_mut().insert(source.to_string()); + } + Pseudo::mark(source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if !PLURALS.contains(&(one, other)) { + self.unlisted.borrow_mut().insert(format!("{one}|{other}")); + } + Pseudo::mark(if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + Pseudo::mark(&b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + Pseudo::mark(s).into() + } + + /// Key names spelled out in words (Escape, Space), on every platform. + fn key(&self, k: Key, _p: Platform) -> Option { + let name = k.to_string(); + name.chars().any(|c| c.is_ascii_lowercase()).then(|| Pseudo::mark(&name)) + } + + fn rtl(&self) -> bool { + self.rtl + } +} + +/// English laid out right to left, to look at the mirrored layout. +#[allow(dead_code)] +pub struct EnglishRtl; + +impl Text for EnglishRtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + (if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn rtl(&self) -> bool { + true + } + + /// The words stay in English order; spaces at either end change sides, as in a right-to-left + /// paragraph. + fn visual(&self, s: &str) -> String { + let core = s.trim(); + if core.is_empty() { + return s.to_string(); + } + let start = s.len() - s.trim_start().len(); + let end = s.trim_end().len(); + format!("{}{core}{}", &s[end..], &s[..start]) + } +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index 33491a41..a6176af7 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -4,20 +4,26 @@ //! without it the test does nothing. //! //! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` +//! +//! egui's default fonts have no ⇧ ⌥ ⌃ or arrows. Apps load their own fonts; here the pictures add +//! fallback fonts when they are found: the file in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans +//! from the usual Linux path, and BIZ UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR` +//! (⇧ ⌘ and arrows, not ⌥ ⌃). Without any of them those glyphs show as boxes. use std::path::PathBuf; -use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog, Text}; use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use ui_fixture::fixture; +use ui_fixture::{EnglishRtl, Pseudo, fixture, with_long_label}; mod ui_fixture; struct Fx { km: Keymap, + text: Box, options: Options, state: DialogState, sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, @@ -25,8 +31,8 @@ struct Fx { impl Fx { fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { - let Fx { km, options, state, sets } = self; - let d = ShortcutsDialog::new(km, &English).options(options); + let Fx { km, text, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &**text).options(options); let d = match sets { Some((p, b)) => d.sets(p, b), None => d, @@ -44,9 +50,71 @@ struct Case { sets: bool, /// Show the on-screen keyboard. keyboard: bool, + words: Words, + /// Add a command with a very long label and name the user set at length. + long: bool, act: fn(&mut Harness<'_, Fx>), } +#[derive(Clone, Copy)] +enum Words { + English, + /// English laid out right to left. + Rtl, + /// The pseudo translation of the tests, left to right. + Pseudo, +} + +impl Case { + const fn new(name: &'static str, width: f32, height: f32, act: fn(&mut Harness<'_, Fx>)) -> Case { + Case { name, size: Vec2::new(width, height), platform: Platform::Mac, sets: false, keyboard: false, words: Words::English, long: false, act } + } + + const fn windows(mut self) -> Case { + self.platform = Platform::Windows; + self + } + + const fn sets(mut self) -> Case { + self.sets = true; + self + } + + const fn keyboard(mut self) -> Case { + self.keyboard = true; + self + } + + const fn words(mut self, words: Words) -> Case { + self.words = words; + self + } + + const fn long(mut self) -> Case { + self.long = true; + self + } +} + +/// Fallback fonts with the key glyphs, when this machine has them. +fn fonts() -> egui::FontDefinitions { + let mut defs = egui::FontDefinitions::default(); + let mut files: Vec = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_FONT").map(PathBuf::from).into_iter().collect(); + files.push("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf".into()); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") { + files.push(PathBuf::from(dir).join("fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf")); + } + for (i, file) in files.iter().enumerate() { + let Ok(bytes) = std::fs::read(file) else { continue }; + let name = format!("fallback-{i}"); + defs.font_data.insert(name.clone(), std::sync::Arc::new(egui::FontData::from_owned(bytes))); + for family in defs.families.values_mut() { + family.push(name.clone()); + } + } + defs +} + fn foreign_review(h: &mut Harness<'_, Fx>) { let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); let set = ForeignSet { @@ -70,84 +138,75 @@ fn click(h: &mut Harness<'_, Fx>, label: &str) { h.run(); } +fn select(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + const CASES: &[Case] = &[ - Case { - name: "selected", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - h.get_by_label("Save As…").click(); - h.run(); - }, - }, - Case { - name: "conflict", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - click(h, "Export…: ⌘E"); - h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); - h.run(); - }, - }, - Case { - name: "recording", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Windows, - sets: false, - keyboard: false, - act: |h| click(h, "Save: Ctrl+S"), - }, - Case { - name: "narrow", - size: Vec2::new(560.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - h.get_by_label("Save").click(); - h.run(); - }, - }, - Case { - name: "sets", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: true, - keyboard: false, - act: |h| { - click(h, "Save: ⌘S"); - h.key_press(EguiKey::F2); - h.run(); - click(h, "New Set…"); - }, - }, - Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: |h| click(h, "More") }, - Case { - name: "keyboard", - size: Vec2::new(1100.0, 720.0), - platform: Platform::Mac, - sets: false, - keyboard: true, - act: |h| { - click(h, "Save As…"); - click(h, "⌘ Command"); - click(h, "⌘S: Save"); - }, - }, - Case { - name: "keyboard-windows", - size: Vec2::new(860.0, 640.0), - platform: Platform::Windows, - sets: false, - keyboard: true, - act: |h| click(h, "L: Loop (Timeline)"), - }, - Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: foreign_review }, + Case::new("selected", 1000.0, 640.0, |h| select(h, "Save As…")), + Case::new("conflict", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }), + Case::new("recording", 1000.0, 640.0, |h| click(h, "Save: Ctrl+S")).windows(), + Case::new("narrow", 560.0, 640.0, |h| select(h, "Save")), + Case::new("sets", 1000.0, 640.0, |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }) + .sets(), + Case::new("sets-narrow", 560.0, 640.0, |h| click(h, "More")).sets(), + Case::new("keyboard", 1100.0, 720.0, |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }) + .keyboard(), + Case::new("keyboard-windows", 860.0, 640.0, |h| click(h, "L: Loop (Timeline)")).windows().keyboard(), + Case::new("review", 1000.0, 640.0, foreign_review).sets(), + Case::new("long", 1000.0, 640.0, |h| { + let state = &mut h.state_mut().state; + state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("long-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("rtl", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }) + .sets() + .long() + .words(Words::Rtl), + Case::new("rtl-narrow", 560.0, 640.0, |h| select(h, "Bold")).words(Words::Rtl).long(), + Case::new("rtl-keyboard", 1100.0, 720.0, |h| click(h, "L: Loop (Timeline)")).keyboard().words(Words::Rtl), + Case::new("rtl-review", 1000.0, 640.0, foreign_review).sets().words(Words::Rtl), + Case::new("pseudo", 1100.0, 720.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("file.save")); + h.run(); + }) + .sets() + .keyboard() + .long() + .words(Words::Pseudo), + Case::new("pseudo-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long() + .words(Words::Pseudo), ]; #[test] @@ -156,12 +215,29 @@ fn dialog_screenshots() { std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + let fonts = fonts(); for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { - let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let sets = case.sets.then(|| { + let mut p = Profiles::default(); + let builtins = vec![("Fixture Defaults", Overrides::new())]; + if case.long { + let name = "My Shortcuts for Long Evenings of Retouching Photographs"; + if let Err(e) = p.save_as(name, Overrides::new(), &builtins).and_then(|n| p.select(&n, &builtins)) { + panic!("{name}: {e}"); + } + } + (p, builtins) + }); let options = Options { keyboard: case.keyboard, ..Options::default() }; - let fx = Fx { km: fixture(case.platform), options, state: DialogState::new(), sets }; + let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; + let text: Box = match case.words { + Words::English => Box::new(English), + Words::Rtl => Box::new(EnglishRtl), + Words::Pseudo => Box::new(Pseudo::default()), + }; + let fx = Fx { km, text, options, state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { @@ -171,6 +247,7 @@ fn dialog_screenshots() { }, fx, ); + h.ctx.set_fonts(fonts.clone()); h.run(); (case.act)(&mut h); let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); From 3e344373ef6209b78d8d9753092e4eecb0d8a772 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 07/33] keymap: put overrides_list on the craft-settings example in the crate docs --- crates/keymap/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index a54f0756..0fe2107b 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -63,10 +63,11 @@ //! //! # Storing overrides //! -//! [`Overrides`] is a field of the app's craft-settings type. It is stored as an array of -//! `{ command, keys }` tables, not as a table keyed by command: craft-settings merges tables key -//! by key with what the file holds, but replaces an array whole, so a reset command (an entry -//! removed) stays removed after saving. +//! [`Overrides`] is a field of the app's craft-settings type, marked +//! `#[serde(with = "craft_keymap::keymap::overrides_list")]` so that it is stored as an array of +//! `{ command, keys }` tables. craft-settings merges tables key by key with what the file holds, +//! but replaces an array whole, so a reset command (an entry removed) stays removed after saving. +//! Without the attribute, overrides with at most one text per command are written as a table. //! //! ```no_run //! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; @@ -76,6 +77,7 @@ //! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] //! #[serde(default)] //! struct Prefs { +//! #[serde(with = "craft_keymap::keymap::overrides_list")] //! shortcuts: Overrides, //! shortcut_sets: Profiles, //! } From 093238b065af1924e9052ef3456a45fd1c6df6d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 08/33] keymap: rename refuses a name another set holds in a different case --- crates/keymap/src/profiles.rs | 6 +++++- crates/keymap/tests/profiles.rs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index 3f7e92f6..e19687da 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -209,7 +209,11 @@ impl Profiles { if !self.sets.contains_key(from) { return Err(ProfileError::NotFound); } - if to != from && (self.taken(&to, builtins) && to.to_lowercase() != from.to_lowercase()) { + // A settings file can hold names that differ only in case, so a case-only rename still + // checks the other sets. + let lower = to.to_lowercase(); + let clash = self.sets.keys().any(|k| k != from && k.to_lowercase() == lower); + if to != from && (clash || (self.builtin_like(&to, builtins) && lower != from.to_lowercase())) { return Err(ProfileError::NameTaken); } if let Some(set) = self.sets.remove(from) { diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 84be8f09..c23770a6 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -66,6 +66,17 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); } +#[test] +fn rename_keeps_a_set_whose_name_differs_only_in_case() { + let builtins = [("Craft", Overrides::new())]; + let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); + assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.sets["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); + p.delete("mine", &builtins).unwrap(); + assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); +} + #[test] fn sets_that_are_not_a_list_or_map_load_as_none() { for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { From 2baaf87ecf7a185096626c2adbdb2cf6e883332f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 09/33] keymap: Don't Save before Import reloads the stored set --- crates/keymap/src/ui/state.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 3c5f87b8..34802241 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -965,7 +965,11 @@ impl DialogState { Ok(()) => self.run(env, then), Err(e) => self.refuse(profile_error(env, &e)), }, - (Prompt::SaveChanges { then, .. }, Answer::No) => self.run(env, then), + // Don't Save reloads the stored set first, so an import does not start from the edits. + (Prompt::SaveChanges { set, then }, Answer::No) => match self.load_set(env, &set) { + Ok(()) => self.run(env, then), + Err(e) => self.refuse(profile_error(env, &e)), + }, (Prompt::ResetAll | Prompt::DeleteSet(_), Answer::No) => {} } } @@ -1312,6 +1316,27 @@ mod tests { assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); } + #[test] + fn dont_save_before_import_drops_the_edits() { + use crate::import::{ContextRule, ForeignEntry, ForeignKey}; + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.request_import(&mut fx.env()); + st.answer(&mut fx.env(), Answer::No); + assert!(matches!(st.finish(), Some(Outcome::Import { .. }))); + assert!(fx.km.overrides().is_empty() && !st.can_undo()); + let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); + let set = ForeignSet { name: Some("Theirs".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + st.review_import(&mut fx.env(), set, options); + st.import_reviewed(&mut fx.env()); + assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.get("Theirs", &fx.b).and_then(|o| o.get("object.front")), None); + assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); + } + #[test] fn set_export_import_round_trip() { let mut fx = Fx::new(Options::default()); From 86fb06a0816e22cf9b8056d05b2a562c1f5a9a54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:58:45 +0000 Subject: [PATCH 10/33] keymap: report Changed after Import when the save prompt changed the set --- crates/keymap/README.md | 2 +- crates/keymap/src/ui/mod.rs | 9 +++++++-- crates/keymap/src/ui/state.rs | 29 ++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 732a2eaf..a564b245 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -322,7 +322,7 @@ Outcomes: |---|---| | `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | | `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | -| `Changed` | Updates its menus, or saves at once if it saves live. | +| `Changed` | Updates its menus, or saves at once if it saves live. When a frame also has another outcome, `Changed` comes on the next frame. | | `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | | `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | | `Summary { name, html, text }` | Shows, saves or prints the summary. | diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 5075b37e..dbe9e0a3 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -36,7 +36,8 @@ pub enum Outcome { Ok, /// Closed with Cancel: the keymap and profiles are back as they were when it opened. Cancel, - /// An edit this frame (for live menus and live saving). + /// An edit (for live menus and live saving). When a frame also has another outcome, such as + /// `Import` after the Save changes prompt, `Changed` follows on the next frame. Changed, /// The user asked to import: the app opens a file of one of these formats and calls /// [`ShortcutsDialog::import_json`] or [`ShortcutsDialog::review_import`]. @@ -274,5 +275,9 @@ fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option Option { - let changed = std::mem::take(&mut self.changed); - self.outcome.take().or(changed.then_some(Outcome::Changed)) + self.outcome.take().or_else(|| std::mem::take(&mut self.changed).then_some(Outcome::Changed)) + } + + /// Whether an edit waits for the next [`finish`](Self::finish). + pub(crate) fn change_pending(&self) -> bool { + self.changed } /// The table's lines for the current view. @@ -708,6 +713,8 @@ impl DialogState { self.recording = None; self.conflict = None; self.prompt = None; + // Ok and Cancel cover every edit. + self.changed = false; self.outcome = Some(outcome); } @@ -1337,6 +1344,22 @@ mod tests { assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); } + #[test] + fn save_prompt_before_import_reports_changed_after_import() { + for answer in [Answer::No, Answer::Yes] { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + assert!(matches!(st.finish(), Some(Outcome::Changed))); + st.request_import(&mut fx.env()); + st.answer(&mut fx.env(), answer); + assert!(matches!(st.finish(), Some(Outcome::Import { .. })), "{answer:?}"); + assert!(matches!(st.finish(), Some(Outcome::Changed)), "{answer:?}"); + assert!(st.finish().is_none()); + } + } + #[test] fn set_export_import_round_trip() { let mut fx = Fx::new(Options::default()); From c030b2ff8818f74eb74c72250f4aac8dd94db1ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:40:29 +0000 Subject: [PATCH 11/33] ci: test the keymap workflow with the ui feature (the dialog) instead of all features --- .github/workflows/keymap.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 97b1124e..133cbe35 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -30,12 +30,13 @@ jobs: key: craft-keymap-linux - run: cargo fmt -p craft-keymap --check - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings - - run: cargo clippy -p craft-keymap --all-targets --all-features --locked -- -D warnings - # Doctests run once, in the all-features step. + - run: cargo clippy -p craft-keymap --all-targets --features ui --locked -- -D warnings + # Doctests run once, in the `ui` step. `snapshot` (dialog screenshots) needs a GPU and is + # left out; `ui` covers `egui`. - name: Test core run: cargo test -p craft-keymap --no-default-features --lib --tests --locked - - name: Test egui dispatch - run: cargo test -p craft-keymap --all-features --locked + - name: Test egui dispatch and the shortcuts dialog + run: cargo test -p craft-keymap --features ui --locked native: timeout-minutes: 15 name: Keymap (${{ matrix.os }}) @@ -50,7 +51,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: craft-keymap-${{ matrix.os }} - - run: cargo test -p craft-keymap --all-features --locked + - run: cargo test -p craft-keymap --features ui --locked wasm: timeout-minutes: 10 name: Keymap WASM @@ -63,4 +64,4 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: craft-keymap-wasm - - run: cargo check -p craft-keymap --all-features --locked --target wasm32-unknown-unknown + - run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown From baaf3061b057aeaf83e7a225bd4e440240c9a006 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:40:29 +0000 Subject: [PATCH 12/33] keymap: run the pseudo-locale dialog test once per direction, each at one width --- crates/keymap/tests/ui_dialog.rs | 51 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 5ddf5800..5f0ee994 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -870,31 +870,30 @@ fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { #[test] fn pseudo_locale_marks_every_string_and_fits() { let p = Pseudo::mark; - for rtl in [false, true] { - for width in [560.0, 1100.0] { - let unlisted = std::rc::Rc::default(); - let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); - fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); - fx.options.keyboard = true; - fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); - let mut h = harness(fx, vec2(width, 720.0)); - let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); - check_pseudo(&h, width, &step("opened")); - h.state_mut().state.select(&CommandRef::from("object.distribute")); - h.run(); - check_pseudo(&h, width, &step("long label selected")); - let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); - click(&mut h, &export); - press(&mut h, Modifiers::COMMAND, EguiKey::S); - assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); - check_pseudo(&h, width, &step("conflict")); - click(&mut h, &p("Keep Both")); - check_pseudo(&h, width, &step("kept both")); - click(&mut h, &p("Keyboard")); - check_pseudo(&h, width, &step("keyboard hidden")); - review(&mut h); - check_pseudo(&h, width, &step("review")); - assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); - } + // Each direction at one width: the narrow layout left to right, the wide one right to left. + for (rtl, width) in [(false, 560.0), (true, 1100.0)] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); } } From 9b7102e2972f20a1280d7d2a4e49c827b64059a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:39:59 +0000 Subject: [PATCH 13/33] keymap: New Set refuses a name that is taken The set form's New Set replaced a user set of the same name (save_as) or quietly made the name unique (duplicate). It now refuses a name that matches a built-in or user set, ignoring case and surrounding spaces, the way Rename does, and keeps the form open. The check runs before the prompt to save edits and again after it. --- crates/keymap/src/profiles.rs | 3 ++- crates/keymap/src/ui/state.rs | 41 +++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index e19687da..8642f0f8 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -314,7 +314,8 @@ impl Profiles { builtins.iter().any(|(n, _)| n.to_lowercase() == lower) } - fn taken(&self, name: &str, builtins: &Builtins) -> bool { + /// Whether `name` matches a built-in or user set, ignoring case. + pub(crate) fn taken(&self, name: &str, builtins: &Builtins) -> bool { let lower = name.to_lowercase(); self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower) } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 960419dd..b188ad0e 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -8,7 +8,7 @@ use crate::command::CommandRef; use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; -use crate::profiles::{Builtins, ProfileError, Profiles}; +use crate::profiles::{Builtins, ProfileError, Profiles, set_name}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; @@ -795,8 +795,13 @@ impl DialogState { self.after_save_prompt(env, Then::Switch(name.to_string())); } - /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. + /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. A name that is + /// taken (ignoring case) is refused, before any prompt to save. pub(crate) fn new_set(&mut self, env: &mut Env, name: &str, based_on: Option<&str>) { + if Self::name_taken(env, name) { + self.refuse(profile_error(env, &ProfileError::NameTaken)); + return; + } let then = Then::NewSet { name: name.to_string(), based_on: based_on.map(str::to_string) }; if based_on.is_none() { self.run(env, then); @@ -824,7 +829,10 @@ impl DialogState { let result = match then { Then::Switch(name) => self.load_set(env, &name), Then::NewSet { name, based_on } => { + // Saving edits to a built-in first can have made a set of this name. + let taken = Self::name_taken(env, &name); let made = match (env.sets.as_mut(), based_on) { + _ if taken => Err(ProfileError::NameTaken), (None, _) => Err(ProfileError::NotFound), (Some((p, b)), None) => p.save_as(&name, env.keymap.overrides().clone(), b), (Some((p, b)), Some(from)) => p.duplicate(&from, &name, b), @@ -840,6 +848,11 @@ impl DialogState { self.report(env, result); } + fn name_taken(env: &Env, name: &str) -> bool { + let (Some((p, b)), Some(name)) = (env.sets.as_ref(), set_name(name)) else { return false }; + p.taken(&name, b) + } + /// Rename… (not for built-ins). pub(crate) fn rename_set(&mut self, env: &mut Env, to: &str) { let result = match env.sets.as_mut() { @@ -1280,6 +1293,30 @@ mod tests { assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); } + #[test] + fn new_set_refuses_a_taken_name_and_keeps_the_set() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.new_set(&mut fx.env(), "Mine", None); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.save_set(&mut fx.env()); + let kept = fx.p.sets["Mine"].clone(); + assert!(!kept.is_empty()); + st.switch_set(&mut fx.env(), "Fixture Defaults"); + for name in ["Mine", " mine ", "fixture defaults"] { + for based_on in [None, Some("Fixture Defaults")] { + st.new_set(&mut fx.env(), name, based_on); + assert!(st.message.as_ref().is_some_and(|m| m.refused), "{name:?} from {based_on:?}"); + assert_eq!((fx.p.sets.len(), fx.p.active.as_str()), (1, "Fixture Defaults")); + assert_eq!(fx.p.sets["Mine"], kept); + } + } + st.new_set(&mut fx.env(), "Other", None); + st.rename_set(&mut fx.env(), "MINE"); + assert!(st.message.as_ref().is_some_and(|m| m.refused)); + assert_eq!(fx.p.sets["Mine"], kept); + } + #[test] fn cancel_restores_keymap_and_sets() { let mut fx = Fx::new(Options::default()); From 4674ab7c85d0f63c09ce8cd462831835d2708fc8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:28:24 +0000 Subject: [PATCH 14/33] keymap: use US spelling in the shortcuts dialog --- crates/keymap/src/editor.rs | 8 +++--- crates/keymap/src/profiles.rs | 2 +- crates/keymap/src/ui/keyboard.rs | 4 +-- crates/keymap/src/ui/mod.rs | 8 +++--- crates/keymap/src/ui/review.rs | 4 +-- crates/keymap/src/ui/rows.rs | 36 +++++++++++++-------------- crates/keymap/src/ui/state.rs | 2 +- crates/keymap/src/ui/style.rs | 6 ++--- crates/keymap/src/ui/summary.rs | 4 +-- crates/keymap/src/ui/view.rs | 2 +- crates/keymap/tests/editor.rs | 6 ++--- crates/keymap/tests/profiles.rs | 6 ++--- crates/keymap/tests/ui_dialog.rs | 30 +++++++++++----------- crates/keymap/tests/ui_fixture/mod.rs | 12 ++++----- 14 files changed, 65 insertions(+), 65 deletions(-) diff --git a/crates/keymap/src/editor.rs b/crates/keymap/src/editor.rs index 2598552b..e71f687c 100644 --- a/crates/keymap/src/editor.rs +++ b/crates/keymap/src/editor.rs @@ -7,11 +7,11 @@ //! use craft_keymap::editor::holders; //! use craft_keymap::{AssignError, Bindable, Binding, Chord, EditSession, Keymap, Platform, Policy, Recorded, Recorder, Scope, Scopes}; //! -//! let catalogue = vec![ +//! let catalog = vec![ //! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), //! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), //! ]; -//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::Mac); +//! let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::Mac); //! let now = 12.5; //! //! let mut session = EditSession::begin(&keymap); @@ -231,13 +231,13 @@ impl Recorder { } } -/// The bindables matching `query`, in catalogue order: a case-insensitive substring of the label, +/// The bindables matching `query`, in catalog order: a case-insensitive substring of the label, /// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display /// text. An empty query matches everything. pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> { let q = query.trim().to_lowercase(); keymap - .catalogue() + .catalog() .iter() .filter(|b| { if q.is_empty() { diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index 8642f0f8..baa4b4b9 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -36,7 +36,7 @@ const FORMAT: &str = "craft-keymap"; const VERSION: u64 = 1; const IMPORTED: &str = "Imported"; -/// The app's built-in sets: name and overrides against the catalogue defaults. +/// The app's built-in sets: name and overrides against the catalog defaults. pub type Builtins<'a> = [(&'a str, Overrides)]; /// Why a set operation was refused. diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 9483ad26..32e92ad6 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -1,5 +1,5 @@ //! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout -//! coloured by who holds each key with the chosen modifiers, modifier toggles that also follow the +//! colored by who holds each key with the chosen modifiers, modifier toggles that also follow the //! held modifiers, the shortcuts of the clicked key, and Assign to Selected. use std::collections::HashMap; @@ -242,7 +242,7 @@ impl KeyboardState { } let platform = keymap.platform(); let mut map: HashMap> = HashMap::new(); - for b in keymap.catalogue() { + for b in keymap.catalog() { for binding in keymap.bindings(&b.command) { let scope = binding.scope_or(&b.scope).clone(); let holder = Holder { command: b.command.clone(), binding }; diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index dbe9e0a3..cd476f37 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -1,4 +1,4 @@ -//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalogue with recording, +//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalog with recording, //! conflicts, undo, named sets and import review, over [`Keymap`] and optionally [`Profiles`]. //! //! The app keeps a [`DialogState`] while the dialog is open and calls [`ShortcutsDialog::show`] @@ -72,14 +72,14 @@ pub struct Area { pub rows: fn(&Bindable) -> bool, } -/// A read-only row for a key outside the catalogue. +/// A read-only row for a key outside the catalog. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FixedRow { pub group: String, pub label: String, /// The keys as shown. pub keys: String, - /// The trigger, when the row has one, so a catalogue binding on it can be pointed out. + /// The trigger, when the row has one, so a catalog binding on it can be pointed out. pub chord: Option, } @@ -114,7 +114,7 @@ pub struct Options { pub backspace_clears: bool, /// false: Enter when idle is OK. pub enter_is_ok: bool, - /// false: rows sort by translated label instead of catalogue order. + /// false: rows sort by translated label instead of catalog order. pub sort_by_label: bool, /// false: Show as tabs instead of a dropdown. pub area_tabs: bool, diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index f0523404..006d9405 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -16,7 +16,7 @@ struct Entry { line: Option, keys: String, note: String, - /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. + /// The note is what the item becomes (Will change), drawn in the text color, not dimmed. to: bool, /// Chord text inside the note, kept out of bidi reordering. note_chord: String, @@ -55,7 +55,7 @@ impl Draw<'_, '_> { line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())])) } - /// "item · line n · keys — note" on one wrapping line, the item in the strong colour. + /// "item · line n · keys — note" on one wrapping line, the item in the strong color. fn entry(&self, ui: &mut Ui, e: &Entry) { let font = TextStyle::Body.resolve(ui.style()); let strong = ui.visuals().strong_text_color(); diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 93804092..5bc3e0c2 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -69,7 +69,7 @@ pub(crate) struct Chip { #[derive(Clone, Debug, PartialEq)] pub(crate) struct Row { - /// Position in the catalogue. + /// Position in the catalog. pub index: usize, pub command: CommandRef, /// [`CommandRef::key`], for automation ids and the overrides. @@ -92,7 +92,7 @@ pub(crate) struct Row { pub(crate) struct FixedLine { pub label: String, pub keys: String, - /// The label of a catalogue command now bound to the row's chord: drawn struck through. + /// The label of a catalog command now bound to the row's chord: drawn struck through. pub now_used_by: Option, } @@ -186,12 +186,12 @@ fn is_tool(b: &Bindable) -> bool { matches!(b.kind, Kind::Tool { .. }) } -/// The Show entries, in catalogue order, leaving out areas with no rows. +/// The Show entries, in catalog order, leaving out areas with no rows. pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { - let catalogue = keymap.catalogue(); + let catalog = keymap.catalog(); let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; let mut menus: Vec<&str> = Vec::new(); - for m in catalogue.iter().filter_map(top_menu) { + for m in catalog.iter().filter_map(top_menu) { if !menus.contains(&m) { menus.push(m); } @@ -200,19 +200,19 @@ pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec< let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); } - if catalogue.iter().any(is_tool) { + if catalog.iter().any(is_tool) { out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); } let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); - for b in catalogue.iter().filter(|b| !b.scope.is_app()) { + for b in catalog.iter().filter(|b| !b.scope.is_app()) { if !panels.contains(&b.scope.as_str()) { panels.push(b.scope.as_str()); } } - for p in panels.into_iter().filter(|p| catalogue.iter().any(|b| b.scope.as_str() == *p)) { + for p in panels.into_iter().filter(|p| catalog.iter().any(|b| b.scope.as_str() == *p)) { out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); } - if catalogue.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + if catalog.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); } for a in &options.areas { @@ -240,7 +240,7 @@ fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool { } } -/// The first id segment, capitalised: `layer.lock` → `Layer`. +/// The first id segment, capitalized: `layer.lock` → `Layer`. fn id_group(id: &str) -> String { let first = id.split('.').next().unwrap_or(id); let mut chars = first.chars(); @@ -314,7 +314,7 @@ impl Query { fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built { let platform = keymap.platform(); - let catalogue = keymap.catalogue(); + let catalog = keymap.catalog(); let mut built = Built::default(); let mut clashing: HashSet = HashSet::new(); for c in keymap.conflicts() { @@ -333,10 +333,10 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu return built; } - let bindings: Vec> = catalogue.iter().map(|b| keymap.bindings(&b.command)).collect(); + let bindings: Vec> = catalog.iter().map(|b| keymap.bindings(&b.command)).collect(); // How many tools of a group hold each effective trigger. let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new(); - for (b, list) in catalogue.iter().zip(&bindings) { + for (b, list) in catalog.iter().zip(&bindings) { if let Kind::Tool { group } = &b.kind { for x in list { *tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1; @@ -344,13 +344,13 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu } } let submenus = match view.area { - AreaId::Menu(m) => catalogue.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + AreaId::Menu(m) => catalog.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), _ => false, }; // Groups in first-seen order within their rank. let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); - for (index, (b, list)) in catalogue.iter().zip(&bindings).enumerate() { + for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() { if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { continue; } @@ -427,7 +427,7 @@ fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, let now_used_by = f.chord.and_then(|c| { let c = c.effective(platform); keymap - .catalogue() + .catalog() .iter() .find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c)) .map(|b| text.command(b).into_owned()) @@ -468,7 +468,7 @@ pub(crate) mod tests { let tool = Kind::Tool { group: Some("Marquee".into()) }; let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")), Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")), Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")), @@ -478,7 +478,7 @@ pub(crate) mod tests { Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")), Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet) -> View<'a> { diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index b188ad0e..fbb544a6 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -473,7 +473,7 @@ impl DialogState { let platform = env.keymap.platform(); let want = trigger.effective(platform); let mut shadows = Vec::new(); - for b in env.keymap.catalogue() { + for b in env.keymap.catalog() { if b.command == command { continue; } diff --git a/crates/keymap/src/ui/style.rs b/crates/keymap/src/ui/style.rs index 81d4a62d..5abfc97c 100644 --- a/crates/keymap/src/ui/style.rs +++ b/crates/keymap/src/ui/style.rs @@ -1,10 +1,10 @@ -//! The dialog's colours and the icon-button style built from them. +//! The dialog's colors and the icon-button style built from them. use craft_ui::buttons::{ButtonVisuals, IconButtonStyle}; use craft_ui::layout::ResizeHandleStyle; use egui::{Color32, Stroke, Visuals}; -/// The dialog's colours. +/// The dialog's colors. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DialogStyle { pub accent: Color32, @@ -35,7 +35,7 @@ impl DialogStyle { } } -/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colours of the app's visuals. +/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colors of the app's visuals. pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle { let w = &v.widgets; IconButtonStyle { diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs index ef2cb56b..8b4248ca 100644 --- a/crates/keymap/src/ui/summary.rs +++ b/crates/keymap/src/ui/summary.rs @@ -27,10 +27,10 @@ fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> Stri } } -/// Groups in the table's order; commands in catalogue order within a group. +/// Groups in the table's order; commands in catalog order within a group. fn groups(km: &Keymap, text: &dyn Text) -> Vec { let mut out: Vec = Vec::new(); - for b in km.catalogue() { + for b in km.catalog() { let bindings = km.bindings(&b.command); if bindings.is_empty() { continue; diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 05c430ef..8e5799cb 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -261,7 +261,7 @@ impl Draw<'_, '_> { IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) } - /// A row control: clickable and labelled but not a Tab stop (the table is one). + /// A row control: clickable and labeled but not a Tab stop (the table is one). fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs index b33c9045..02a44052 100644 --- a/crates/keymap/tests/editor.rs +++ b/crates/keymap/tests/editor.rs @@ -13,13 +13,13 @@ fn b(text: &str) -> Binding { } fn keymap(platform: Platform) -> Keymap { - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), ]; - Keymap::new(catalogue, Scopes::new(), platform) + Keymap::new(catalog, Scopes::new(), platform) } fn keys(km: &Keymap, id: &str) -> Vec { @@ -87,7 +87,7 @@ fn undo_keeps_the_newest_steps() { fn undo_history_is_capped_by_size() { let mut km = keymap(Platform::Windows); let mut session = EditSession::begin(&km); - // Full-size snapshots of entries for commands the catalogue does not have. + // Full-size snapshots of entries for commands the catalog does not have. let big = |tag: char| { let mut o = Overrides::new(); for n in 0..MAX_COMMANDS { diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index c23770a6..348420df 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -9,13 +9,13 @@ fn b(text: &str) -> Binding { } fn keymap() -> Keymap { - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Mac) + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Mac) } #[test] @@ -132,7 +132,7 @@ fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { let mut again = keymap(); again.set_overrides(p.active_overrides(&builtins).clone()); - for bindable in km.catalogue() { + for bindable in km.catalog() { assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); } assert_eq!(again.overrides(), km.overrides()); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 5f0ee994..4ff8bf91 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -59,7 +59,7 @@ impl Fx { self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() } - fn cancelled(&self) -> bool { + fn canceled(&self) -> bool { self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) } } @@ -162,10 +162,10 @@ fn escape_cancels_recording_only() { press(&mut h, Modifiers::NONE, EguiKey::Escape); assert!(h.state().state.is_open()); assert!(!h.state().state.is_recording()); - assert!(!h.state().cancelled()); + assert!(!h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); press(&mut h, Modifiers::NONE, EguiKey::Escape); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert!(!h.state().state.is_open()); } @@ -432,9 +432,9 @@ fn table_keys_move_record_and_delete() { fn small_window_keeps_ok_and_long_labels_truncate() { let long = "A very long command label ".repeat(12); let mut km = fixture(Platform::Mac); - let mut catalogue = km.catalogue().to_vec(); - catalogue.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); - km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut catalog = km.catalog().to_vec(); + catalog.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); let mut fx = Fx::new(km); fx.window = true; let size = vec2(480.0, 360.0); @@ -460,7 +460,7 @@ fn window_close_is_cancel() { press(&mut h, Modifiers::NONE, EguiKey::F2); assert_eq!(h.state().bindings("file.save"), ["F2"]); click(&mut h, "Cancel"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); } @@ -587,7 +587,7 @@ fn cancel_restores_keymap_and_sets() { click_last(&mut h, "Delete"); assert!(!profiles(&h).sets.contains_key("Other")); click(&mut h, "Cancel"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert_eq!(profiles(&h), &before); assert!(h.state().km.overrides().is_empty()); } @@ -691,14 +691,14 @@ fn picking_a_suggestion_replans_and_is_remembered() { #[test] fn summary_escapes_html_and_keeps_text_lines() { let mut km = fixture(Platform::Mac); - let mut catalogue = km.catalogue().to_vec(); + let mut catalog = km.catalog().to_vec(); let label = "\"Tom\" & 'Jerry'\nNext"; - catalogue.push( + catalog.push( Bindable::new("edit.tricky", label, Scope::app()) .with_path(vec!["Edit".into(), "Tricky".into()]) .with_default(craft_keymap::Binding::parse("F6").expect("F6")), ); - km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); let mut h = open(Fx::new(km)); click(&mut h, "Summarize…"); let (html, text) = h @@ -734,13 +734,13 @@ fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { open(fx) } -/// What a key's colour means, as its accessibility description says it. +/// What a key's color means, as its accessibility description says it. fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() } #[test] -fn keyboard_colours_keys_and_assigns_to_the_selected_command() { +fn keyboard_colors_keys_and_assigns_to_the_selected_command() { let mut h = with_keyboard(|_| {}); assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); @@ -831,7 +831,7 @@ fn read_only_has_no_edit_controls() { click(&mut h, "Summarize…"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); click(&mut h, "Close"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); } // ---- Pseudo locale ---- @@ -864,7 +864,7 @@ fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); assert!(!words, "{step}: {label:?} has text that did not go through Text"); } - assert!(seen > 15, "{step}: {seen} labelled nodes"); + assert!(seen > 15, "{step}: {seen} labeled nodes"); } #[test] diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs index efcc82b1..f2f930cc 100644 --- a/crates/keymap/tests/ui_fixture/mod.rs +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -1,4 +1,4 @@ -//! The catalogue the dialog tests and screenshots use. +//! The catalog the dialog tests and screenshots use. use std::borrow::Cow; use std::cell::RefCell; @@ -16,7 +16,7 @@ pub fn fixture(platform: Platform) -> Keymap { let tool = Kind::Tool { group: Some("Marquee".into()) }; let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), @@ -26,22 +26,22 @@ pub fn fixture(platform: Platform) -> Keymap { Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } /// `km` with a command whose label and menu path are far too long for a row. #[allow(dead_code)] pub fn with_long_label(km: Keymap) -> Keymap { let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); - let mut catalogue = km.catalogue().to_vec(); - catalogue.insert( + let mut catalog = km.catalog().to_vec(); + catalog.insert( 3, Bindable::new("object.distribute", long.as_str(), Scope::app()) .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) .with_default(binding("Cmd+Alt+Shift+D")) .with_default(binding("Ctrl+Alt+Shift+F12")), ); - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) } /// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and From 2abdcd53ab34c095f96b8280021b533634ba3ff3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:07:36 +0000 Subject: [PATCH 15/33] keymap: give Tab back to focus navigation after the New Shortcut field or Search by key records --- crates/keymap/src/ui/capture.rs | 7 +++++++ crates/keymap/src/ui/view.rs | 4 ++++ crates/keymap/tests/ui_dialog.rs | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/crates/keymap/src/ui/capture.rs b/crates/keymap/src/ui/capture.rs index a5fef16f..612458fc 100644 --- a/crates/keymap/src/ui/capture.rs +++ b/crates/keymap/src/ui/capture.rs @@ -15,6 +15,13 @@ pub(crate) fn lock_focus(ui: &Ui, id: Id) { ui.memory_mut(|m| m.set_focus_lock_filter(id, FOCUS_LOCK)); } +/// Gives Tab, arrows and Escape back to egui's focus navigation. egui keeps a focused widget's +/// filter until focus moves, so a widget that recorded calls this each frame it has focus and +/// does not record. +pub(crate) fn unlock_focus(ui: &Ui, id: Id) { + ui.memory_mut(|m| m.set_focus_lock_filter(id, EventFilter::default())); +} + /// Removes every key, text, IME and clipboard event from `input` and returns the chords pressed, /// in order. Repeats, releases and modifier-only presses give no chord. Copy, Cut and Paste /// count as the presses that cause them (Cmd+C, Cmd+X, Cmd+V, or Shift+Delete and Shift+Insert), diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 8e5799cb..7bebed9f 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -486,6 +486,8 @@ impl Draw<'_, '_> { } else if r.has_focus() || r.clicked() { capture::lock_focus(ui, r.id); } + } else if r.has_focus() { + capture::unlock_focus(ui, r.id); } self.keyboard_toggle(ui); } @@ -1140,6 +1142,8 @@ impl Draw<'_, '_> { } else { capture::lock_focus(ui, field_id); } + } else if resp.has_focus() { + capture::unlock_focus(ui, field_id); } if ui.link(self.vis(&self.ui("Type…"))).clicked() { self.state.stop_recording(); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 4ff8bf91..8df42773 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -210,6 +210,41 @@ fn the_new_shortcut_field_waits_for_assign() { assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); } +/// Presses Tab until the control labeled `label` has focus; false after `max` presses. +fn tab_to(h: &mut Harness<'_, Fx>, label: &str, max: usize) -> bool { + for _ in 0..max { + press(h, Modifiers::NONE, EguiKey::Tab); + if h.get_by_label(label).is_focused() { + return true; + } + } + false +} + +#[test] +fn tab_leaves_the_new_shortcut_field_after_recording() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("New shortcut: F7").is_focused()); + assert!(tab_to(&mut h, "Assign", 6), "Tab moves on to Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "Tab is not recorded"); +} + +#[test] +fn tab_leaves_search_by_key_after_recording() { + let mut h = mac(); + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("Search by key").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert!(table_focused(&h), "Tab moves on to the table"); +} + // ---- Conflicts ---- #[test] From 0039a5c8662e4a5eb52ff950b5cf93bcbd0b6c96 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:08:46 +0000 Subject: [PATCH 16/33] keymap: report Changed after a Cancel that undid edits, so apps rebuild their menus --- crates/keymap/README.md | 5 +++-- crates/keymap/src/ui/mod.rs | 13 ++++++++++--- crates/keymap/src/ui/state.rs | 18 ++++++++++++++++-- crates/keymap/tests/ui_dialog.rs | 3 +++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 97da4352..2b156f6f 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -306,6 +306,7 @@ if let Some(state) = &mut self.shortcuts { let dialog = dialog.sets(&mut self.settings.shortcut_sets, &BUILTINS); match dialog.show(ctx, state) { Some(Outcome::Ok) => self.save_settings(), + // After each edit, and after a Cancel that undid edits. Some(Outcome::Changed) => self.rebuild_menus(), Some(Outcome::Import { formats }) => self.pick_import_file(formats), Some(Outcome::Export { name, json }) => self.save_file(&name, json), @@ -324,8 +325,8 @@ Outcomes: | Outcome | The app | |---|---| | `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | -| `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | -| `Changed` | Updates its menus, or saves at once if it saves live. When a frame also has another outcome, `Changed` comes on the next frame. | +| `Cancel` | Nothing to save: the keymap and the sets are as they were at open. When Cancel undid edits, `Changed` follows. | +| `Changed` | Updates its menus, or saves at once if it saves live. It comes after each edit and after a Cancel that undid edits. When a frame also has another outcome, `Changed` comes on the next frame; `is_open()` stays true until it has. | | `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | | `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | | `Summary { name, html, text }` | Shows, saves or prints the summary. | diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index cd476f37..cf73737f 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -34,7 +34,8 @@ use state::Env; pub enum Outcome { /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. Ok, - /// Closed with Cancel: the keymap and profiles are back as they were when it opened. + /// Closed with Cancel: the keymap and profiles are back as they were when it opened. When that + /// undid edits, `Changed` follows on the next frame. Cancel, /// An edit (for live menus and live saving). When a frame also has another outcome, such as /// `Import` after the Save changes prompt, `Changed` follows on the next frame. @@ -203,6 +204,9 @@ impl<'a> ShortcutsDialog<'a> { /// screen allows, at most 1320×900). Closing it with × is Cancel. pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { self.with_env(|env| { + if !state.is_showing() { + return state.finish(); + } let title = env.text.visual(&env.text.ui(env.options.title)); let screen = ctx.content_rect().size(); let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0)); @@ -218,10 +222,13 @@ impl<'a> ShortcutsDialog<'a> { .min_size(min) .max_size(max) .show(ctx, |ui| out = frame(ui, env, state)); - if !open && state.is_open() { + if !open && state.is_showing() { state.cancel(env); out = state.finish(); } + if state.change_pending() { + ctx.request_repaint(); + } out }) } @@ -272,7 +279,7 @@ impl<'a> ShortcutsDialog<'a> { fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option { state.begin(env); state.input(env, ui.ctx()); - if state.is_open() { + if state.is_showing() { view::body(ui, env, state); } let out = state.finish(); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index fbb544a6..d9461647 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -264,8 +264,15 @@ impl DialogState { } } - /// False after OK or Cancel: drop the state. + /// False once the dialog has closed with OK or Cancel and reported its last outcome: drop the + /// state then. After a Cancel that undid edits it stays true for the frame that reports + /// [`Outcome::Changed`]. pub fn is_open(&self) -> bool { + self.phase != Phase::Closed || self.changed + } + + /// Whether the dialog is drawn: not yet closed with OK or Cancel. + pub(crate) fn is_showing(&self) -> bool { self.phase != Phase::Closed } @@ -698,14 +705,20 @@ impl DialogState { } /// Cancel, window ×, or Escape when idle: everything back as it was when the dialog opened. + /// When that undoes edits, Changed follows, so the app rebuilds its menus (and saves, if it + /// saved the edits live). pub(crate) fn cancel(&mut self, env: &mut Env) { + let mut undone = false; if let Some(opened) = self.opened.take() { + undone = *env.keymap.overrides() != opened.overrides; env.keymap.set_overrides(opened.overrides); if let (Some((p, _)), Some(profiles)) = (env.sets.as_mut(), opened.profiles) { + undone |= **p != profiles; **p = profiles; } } self.close(Outcome::Cancel); + self.changed = undone; } fn close(&mut self, outcome: Outcome) { @@ -1167,8 +1180,9 @@ mod tests { assert!(matches!(st.finish(), Some(Outcome::Changed))); st.escape(&mut fx.env()); assert!(matches!(st.finish(), Some(Outcome::Cancel))); - assert!(!st.is_open()); assert!(fx.keys("object.front").is_empty(), "Cancel restores the opening keymap"); + assert!(st.is_open() && matches!(st.finish(), Some(Outcome::Changed)), "Changed follows, for the app's menus"); + assert!(!st.is_open()); } #[test] diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 8df42773..38a77e28 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -497,6 +497,9 @@ fn window_close_is_cancel() { click(&mut h, "Cancel"); assert!(h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + let after: Vec = h.state().outcomes.iter().skip_while(|o| !matches!(o, Outcome::Cancel)).map(|o| matches!(o, Outcome::Changed)).collect(); + assert_eq!(after, [false, true], "Changed follows Cancel, which undid an edit"); + assert!(!h.state().state.is_open()); } #[test] From 339aaa41dfc29756cdbffe021973b1d27ca8a3d5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:09:14 +0000 Subject: [PATCH 17/33] keymap: show that an app with sets stores only Profiles and loads the active set --- crates/keymap/README.md | 26 +++++++++++++++++++++++--- crates/keymap/src/lib.rs | 7 +++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 2b156f6f..f5ff51eb 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -95,7 +95,7 @@ stay in the entry when the command is edited, unless the edit leaves it without `Keymap::problems` lists them. ```rust -use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; use craft_settings::{Location, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; @@ -104,7 +104,6 @@ use serde::{Deserialize, Serialize}; struct Prefs { #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, - shortcut_sets: Profiles, } impl Settings for Prefs { @@ -140,9 +139,30 @@ store.update(|p| p.shortcuts = keymap.overrides().clone()); `Profiles` holds the user's named sets of overrides and which one is active. The app's built-in sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change. -Put `Profiles` next to the `Overrides` in the app's settings type. The sets, and each set's +An app with sets stores only `Profiles`, in place of the `Overrides` field above: the keymap's +overrides are the active set's, and the dialog's OK stores them there. The sets, and each set's overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file. +```rust +use craft_keymap::{Keymap, Platform, Profiles, Scopes}; +use craft_settings::{Location, SettingsStore}; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq)] +#[serde(default)] +struct Prefs { + shortcut_sets: Profiles, +} + +// `builtins` is the app's &[(&str, Overrides)], the first set the default. +let (mut store, _report) = SettingsStore::::open(Location::Auto); +let mut sets = store.get().shortcut_sets.clone(); +sets.reconcile(&builtins); +let keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(sets.active_overrides(&builtins).clone()); + +// The dialog edits `sets` and `keymap`. On Outcome::Ok (or Changed, to save live): +store.update(|p| p.shortcut_sets = sets.clone()); +``` + - `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). - `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, `rename` and `delete` work on user sets only. diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 7a943fd5..513df955 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -72,7 +72,7 @@ //! Without the attribute, overrides with at most one text per command are written as a table. //! //! ```no_run -//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; //! use craft_settings::{Location, Settings, SettingsStore}; //! use serde::{Deserialize, Serialize}; //! @@ -81,7 +81,6 @@ //! struct Prefs { //! #[serde(with = "craft_keymap::keymap::overrides_list")] //! shortcuts: Overrides, -//! shortcut_sets: Profiles, //! } //! //! impl Settings for Prefs { @@ -100,6 +99,10 @@ //! store.update(|p| p.shortcuts = keymap.overrides().clone()); //! ``` //! +//! An app with named sets stores only [`Profiles`] in place of the `Overrides` field and builds +//! the keymap from the active set: +//! `with_overrides(prefs.shortcut_sets.active_overrides(&builtins).clone())`. +//! //! # Importing a shortcut set //! //! The app's reader builds a [`ForeignSet`]; [`plan`] matches it without changing anything, so a From fd2386b2ba9381a26e3bce3f72bea88e17ae32e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:10:58 +0000 Subject: [PATCH 18/33] keymap: hold the set row, table and edit actions while a prompt or the import review is open Switching sets closes an open prompt, deleting a set reloads only when it was the active one, and deleting the last set with no built-ins loads no overrides. --- crates/keymap/src/ui/sets.rs | 2 +- crates/keymap/src/ui/state.rs | 69 +++++++++++++++++++++++++++++--- crates/keymap/src/ui/view.rs | 22 ++++++---- crates/keymap/tests/ui_dialog.rs | 18 +++++++++ 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index a18343a5..7b4cd479 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -43,7 +43,7 @@ impl Draw<'_, '_> { let modified = DialogState::is_modified(self.env); let items = self.set_items(builtin, modified); let mut act = None; - ui.add_enabled_ui(self.state.review.is_none(), |ui| { + ui.add_enabled_ui(!self.state.modal(), |ui| { ui.horizontal(|ui| { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index d9461647..c53a12d2 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -787,13 +787,26 @@ impl DialogState { fn load_set(&mut self, env: &mut Env, name: &str) -> Result<(), ProfileError> { let Some((p, b)) = env.sets.as_mut() else { return Err(ProfileError::NotFound) }; p.select(name, b)?; - env.keymap.set_overrides(p.active_overrides(b).clone()); + self.load_active(env); + Ok(()) + } + + /// Loads the active set (no overrides when none is left), with a fresh undo history. + fn load_active(&mut self, env: &mut Env) { + if let Some((p, b)) = env.sets.as_ref() { + env.keymap.set_overrides(p.active_overrides(b).clone()); + } self.session = Some(EditSession::begin(env.keymap)); self.recording = None; self.conflict = None; self.field = None; + self.prompt = None; self.changed = true; - Ok(()) + } + + /// A prompt or the import review is open: the set row, the table and the edit actions wait. + pub(crate) fn modal(&self) -> bool { + self.prompt.is_some() || self.review.is_some() } fn report(&mut self, env: &Env, result: Result<(), ProfileError>) { @@ -805,6 +818,7 @@ impl DialogState { /// The set picker. With unsaved edits it asks to save them first. pub(crate) fn switch_set(&mut self, env: &mut Env, name: &str) { + self.prompt = None; self.after_save_prompt(env, Then::Switch(name.to_string())); } @@ -891,13 +905,22 @@ impl DialogState { } } + /// Deletes a user set. Deleting the active set loads the one that becomes active, or no + /// overrides when no set is left. fn delete_set(&mut self, env: &mut Env, name: &str) { let result = match env.sets.as_mut() { - Some((p, b)) => p.delete(name, b).map(|()| p.active_name(b).to_string()), + Some((p, b)) => { + let was_active = p.active_name(b) == name; + p.delete(name, b).map(|()| was_active) + } None => Err(ProfileError::NotFound), }; - let result = result.and_then(|active| self.load_set(env, &active)); - self.report(env, result); + match result { + Ok(true) => self.load_active(env), + Ok(false) => self.changed = true, + Err(_) => {} + } + self.report(env, result.map(|_| ())); } /// Save: stores the edits; a built-in's become a copy. The undo history stays. @@ -1348,6 +1371,42 @@ mod tests { assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); } + #[test] + fn a_stale_delete_prompt_neither_reloads_nor_drops_edits() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + fx.p.save_as("Other", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + st.ask_delete_set(&fx.env()); + assert!(st.modal()); + st.switch_set(&mut fx.env(), "Mine"); + assert_eq!((st.prompt.clone(), fx.p.active.as_str()), (None, "Mine"), "switching closes the prompt"); + + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.prompt = Some(Prompt::DeleteSet("Other".into())); + st.answer(&mut fx.env(), Answer::Yes); + assert!(!fx.p.sets.contains_key("Other")); + assert_eq!((fx.p.active.as_str(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); + assert!(st.can_undo()); + } + + #[test] + fn deleting_the_last_set_without_builtins_loads_no_overrides() { + let mut fx = Fx::new(Options::default()); + fx.b.clear(); + let mut mine = Overrides::new(); + mine.insert("object.front", vec!["F2".into()]); + fx.p.save_as("Mine", mine.clone(), &fx.b).ok(); + fx.km.set_overrides(mine); + let mut st = open(&mut fx); + st.ask_delete_set(&fx.env()); + st.answer(&mut fx.env(), Answer::Yes); + assert!(st.message.is_none(), "{:?}", st.message); + assert!(fx.p.sets.is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); + st.ok(&mut fx.env()); + assert!(fx.p.sets.is_empty(), "OK stores no copy of the deleted set"); + } + #[test] fn ok_stores_edits_of_a_builtin_as_a_copy() { let mut fx = Fx::new(Options::default()); diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 7bebed9f..373bbb82 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -310,7 +310,7 @@ impl Draw<'_, '_> { } return; } - if text_focus || !self.env.options.editable { + if text_focus || !self.env.options.editable || self.state.modal() { return; } let mac = self.env.keymap.platform().is_mac(); @@ -351,7 +351,13 @@ impl Draw<'_, '_> { let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); if !self.review(ui, mid) { - self.middle(ui, mid); + let prompt = self.state.prompt.is_some(); + ui.scope(|ui| { + if prompt { + ui.disable(); + } + self.middle(ui, mid); + }); } let top = (mid.max.y + gap).min(rest.max.y); let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); @@ -1303,7 +1309,7 @@ impl Draw<'_, '_> { if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { act = 1; } - if m.undo && self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.message.undo") { + if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { act = 2; } }); @@ -1374,15 +1380,16 @@ impl Draw<'_, '_> { } else { vec![(self.ui("Close"), "shortcuts.close")] }; + let idle = !self.state.modal(); ui.horizontal(|ui| { if editable { - if self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + if self.button(ui, idle && self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { self.state.undo(self.env); } - if self.button(ui, self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + if self.button(ui, idle && self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { self.state.redo(self.env); } - if self.button(ui, !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + if self.button(ui, idle && !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { self.state.ask_reset_all(); } } @@ -1397,7 +1404,8 @@ impl Draw<'_, '_> { let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); ui.add_space((ui.available_width() - width).max(0.0)); for (text, id) in &right { - if self.button(ui, true, text, id) { + // OK waits for the open prompt or review; Cancel and Close never do. + if self.button(ui, idle || *id != "shortcuts.ok", text, id) { if editable && *id == "shortcuts.ok" { self.state.ok(self.env); } else { diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 38a77e28..aed56d40 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -630,6 +630,24 @@ fn cancel_restores_keymap_and_sets() { assert!(h.state().km.overrides().is_empty()); } +#[test] +fn an_open_prompt_holds_the_set_row_table_and_edit_actions() { + let mut h = with_sets(Profiles::default()); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + new_set(&mut h, "Other"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + for label in ["Set:", "New Set…", "Save: F2", "Undo", "Reset All…", "OK"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); + } + assert!(h.get_all_by_label("Cancel").all(|n| !n.accesskit_node().is_disabled()), "the prompt's and the dialog's"); + h.get_all_by_label("Cancel").next().expect("the prompt's Cancel").click(); + h.run(); + assert!(h.state().state.is_open()); + assert!(!h.get_by_label("OK").accesskit_node().is_disabled()); +} + #[test] fn ok_stores_edits_of_a_builtin_as_a_copy() { let mut h = with_sets(Profiles::default()); From ad79cbcf3152ace45f1defdecc2bcdebf24694ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:29:42 +0000 Subject: [PATCH 19/33] keymap: translate shortcut parse errors and give translators whole sentences with placeholders --- crates/keymap/src/ui/keyboard.rs | 2 +- crates/keymap/src/ui/review.rs | 5 +-- crates/keymap/src/ui/rows.rs | 8 ++--- crates/keymap/src/ui/state.rs | 29 ++++++++++++---- crates/keymap/src/ui/summary.rs | 2 +- crates/keymap/src/ui/text.rs | 26 ++++++++++++-- crates/keymap/src/ui/view.rs | 59 ++++++++++++-------------------- crates/keymap/tests/ui_dialog.rs | 8 +++++ 8 files changed, 86 insertions(+), 53 deletions(-) diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 32e92ad6..0d0bc2a5 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -366,7 +366,7 @@ impl Draw<'_, '_> { /// The holder's label, with its scope when that is not Application. fn holder_text(&self, h: &KeyHolder) -> String { let label = self.label_of(&h.holder.command); - if h.scope.is_app() { label } else { format!("{label} ({})", self.scope_name(&h.scope)) } + if h.scope.is_app() { label } else { fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(&h.scope))]) } } pub(super) fn keyboard_pane(&mut self, ui: &mut Ui) { diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index 006d9405..61ef3da7 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -84,7 +84,7 @@ impl Draw<'_, '_> { return; } ui.add_space(4.0); - ui.label(RichText::new(self.vis(&format!("{title} ({})", entries.len()))).strong()); + ui.label(RichText::new(self.vis(&fill(self.ui("{title} ({n})"), &[("title", title), ("n", &entries.len().to_string())]))).strong()); for e in entries { self.entry(ui, e); } @@ -181,7 +181,8 @@ impl Draw<'_, '_> { if !report.suggestions.is_empty() { ui.add_space(4.0); - ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); + let title = fill(self.ui("Suggestions ({n})"), &[("n", &report.suggestions.len().to_string())]); + ui.label(RichText::new(self.vis(&title)).strong()); for (si, s) in report.suggestions.iter().enumerate() { ui.horizontal_wrapped(|ui| { self.entry( diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 5bc3e0c2..2f85cf31 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -7,7 +7,7 @@ use crate::chord::{Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides}; use crate::ui::Options; -use crate::ui::text::{Text, trigger_text}; +use crate::ui::text::{Text, trigger_text, visual_around}; /// One entry of the Show menu. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -375,9 +375,9 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu .map(|x| { let mut t = trigger_text(text, &x.trigger, platform); if x.scope_or(&b.scope) != &b.scope { - let scope = format!("({})", text.segment(x.scope_or(&b.scope).as_str())); - // Display order; the chord itself is never reordered. - t = if text.rtl() { format!("{} {t}", text.visual(&scope)) } else { format!("{t} {scope}") }; + let scope = text.segment(x.scope_or(&b.scope).as_str()); + let s = craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &t), ("scope", &scope)]); + t = visual_around(text, &s, &t); } Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } }) diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index c53a12d2..701ba546 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -3,11 +3,11 @@ use std::collections::BTreeSet; -use crate::chord::{Chord, Key, Trigger}; +use crate::chord::{Chord, Key, ParseError, Trigger}; use crate::command::CommandRef; use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; -use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; +use crate::keymap::{AssignError, Assigned, Binding, BindingError, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; use crate::profiles::{Builtins, ProfileError, Profiles, set_name}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; @@ -38,7 +38,7 @@ impl Env<'_> { for h in holders { let label = self.label(&h.command); let s = match &h.binding.scope { - Some(scope) => format!("{label} ({})", self.text.segment(scope.as_str())), + Some(scope) => self.ui("{label} ({scope})", &[("label", &label), ("scope", &self.text.segment(scope.as_str()))]), None => label, }; if !out.contains(&s) { @@ -501,9 +501,9 @@ impl DialogState { } /// Typed entry: the text as [`Binding::parse`] reads it, committed like a recorded chord. - /// `Err` is the parse error for under the field. + /// `Err` is the parse error for under the field, translated. pub(crate) fn type_binding(&mut self, env: &mut Env, command: &CommandRef, text: &str) -> Result<(), String> { - let binding = Binding::parse(text).map_err(|e| e.to_string())?; + let binding = Binding::parse(text).map_err(|e| binding_error(env, &e))?; self.select_row(command); if let Some(scope) = binding.scope.clone() { self.context = Some(scope); @@ -1097,6 +1097,23 @@ fn assign_error(env: &Env, e: &AssignError) -> String { } } +/// Why a binding text does not parse, translated. +pub(crate) fn binding_error(env: &Env, e: &BindingError) -> String { + match e { + BindingError::TooLong => env.ui("the shortcut is too long", &[]), + BindingError::Scope => env.ui("the scope name is not valid", &[]), + BindingError::Trigger(e) => match e { + ParseError::Empty => env.ui("empty shortcut", &[]), + ParseError::TooLong => env.ui("the shortcut is too long", &[]), + ParseError::MissingKey => env.ui("the shortcut has no key", &[]), + ParseError::UnknownKey(k) => env.ui("unknown key {key}", &[("key", k)]), + ParseError::UnknownModifier(m) => env.ui("unknown modifier {key}", &[("key", m)]), + ParseError::DuplicateModifier(m) => env.ui("modifier {key} given twice", &[("key", m)]), + ParseError::Sequence => env.ui("key sequences are not supported", &[]), + }, + } +} + fn profile_error(env: &Env, e: &ProfileError) -> String { match e { ProfileError::BadName => env.ui("The name is empty, too long or has control characters", &[]), @@ -1327,7 +1344,7 @@ mod tests { assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "F7 F7"), Ok(())); st.assign_field(&mut fx.env()); assert_eq!(fx.keys("file.save"), ["Cmd+S", "F7 F7"]); - assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); + assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope"), Err("unknown key Nope".into())); } #[test] diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs index 8b4248ca..01603962 100644 --- a/crates/keymap/src/ui/summary.rs +++ b/crates/keymap/src/ui/summary.rs @@ -21,7 +21,7 @@ fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> Stri match &b.scope { Some(s) if s != home => { let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() }; - format!("{chord} ({scope})") + craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &scope)]) } _ => chord, } diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 2550e21e..d248c5c5 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -104,6 +104,15 @@ pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> S if trigger.double { format!("{one} {one}") } else { one } } +/// `s` in display order with `chord`, text inside it, left as it is: chord text never goes +/// through [`Text::visual`]. Right to left, the parts before and after the chord change sides. +pub(crate) fn visual_around(text: &dyn Text, s: &str, chord: &str) -> String { + let Some(i) = s.find(chord).filter(|_| !chord.is_empty()) else { return text.visual(s) }; + let (before, rest) = s.split_at(i); + let (before, after) = (text.visual(before), text.visual(&rest[chord.len()..])); + if text.rtl() { format!("{after}{chord}{before}") } else { format!("{before}{chord}{after}") } +} + /// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. pub const PLURALS: &[(&str, &str)] = &[ ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), @@ -149,6 +158,8 @@ pub const STRINGS: &[&str] = &[ "Conflicts", "No matching commands", "(cycles)", + "{label} (cycles)", + "{label} (Changed)", // Table and detail pane. "Command", "Commands", @@ -156,9 +167,13 @@ pub const STRINGS: &[&str] = &[ "Select a command to see and change its shortcuts", "Shortcut", "Default", + "{chord} (Default)", + "{chord} ({scope})", + "{label} ({scope})", "Application", "Current shortcuts:", "New shortcut:", + "New shortcut: {chord}", "press keys", "{chord} (press again for a double press)", "Type…", @@ -166,7 +181,6 @@ pub const STRINGS: &[&str] = &[ "Assign", "Use Default", "Clear", - "Remove", "Default: {chord}", "None", "Currently assigned to:", @@ -178,6 +192,13 @@ pub const STRINGS: &[&str] = &[ "{label}: {chord}", "{label}: press keys", "Now used by {label}", + "{label}: {chord} (Now used by {holder})", + // Typed shortcuts and saved texts that do not parse. + "the shortcut has no key", + "unknown modifier {key}", + "modifier {key} given twice", + "key sequences are not supported", + "the scope name is not valid", // On-screen keyboard. "Keyboard", "Keyboard: US", @@ -263,7 +284,8 @@ pub const STRINGS: &[&str] = &[ "Not matched", "Unreadable", "Approximated", - "Suggestions", + "{title} ({n})", + "Suggestions ({n})", "numeric keypad key read as main key", "Matched without change", "Command for {item}", diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 373bbb82..155ee2e9 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -24,9 +24,9 @@ use crate::keymap::{Binding, MAX_BINDINGS, Problem}; use crate::scope::Scope; use crate::ui::capture; use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; -use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target, binding_error}; use crate::ui::style::{self, DialogStyle}; -use crate::ui::text::trigger_text; +use crate::ui::text::{trigger_text, visual_around}; #[path = "keyboard.rs"] pub(crate) mod keyboard; @@ -165,13 +165,7 @@ impl Draw<'_, '_> { /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes /// through [`Text::visual`](crate::ui::Text::visual). fn vis_chord(&self, s: &str, chord: &str) -> String { - match s.find(chord).filter(|_| !chord.is_empty()) { - None => self.vis(s), - Some(i) => { - let (before, rest) = s.split_at(i); - self.joined(vec![self.vis(before), chord.to_string(), self.vis(&rest[chord.len()..])]) - } - } + visual_around(self.env.text, s, chord) } /// `text` after a ⚠, in display order. @@ -192,20 +186,11 @@ impl Draw<'_, '_> { if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } } - /// [`binding_text`](Self::binding_text) in display order. - fn binding_shown(&self, b: &Binding, home: Option<&Scope>) -> String { - let chord = self.chord(&b.trigger); - match &b.scope { - Some(s) if Some(s) != home => self.joined(vec![chord, " ".into(), self.vis(&format!("({})", self.scope_name(s)))]), - _ => chord, - } - } - - /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. + /// A binding as text, with its scope when that is not the command's home scope. fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { let chord = self.chord(&b.trigger); match &b.scope { - Some(s) if Some(s) != home => format!("{chord} ({})", self.scope_name(s)), + Some(s) if Some(s) != home => fill(self.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &self.scope_name(s))]), _ => chord, } } @@ -832,10 +817,10 @@ impl Draw<'_, '_> { let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); let mut a11y = r.label.clone(); if r.cycles { - a11y = format!("{a11y} {}", self.ui("(cycles)")); + a11y = fill(self.ui("{label} (cycles)"), &[("label", &a11y)]); } if r.changed { - a11y = format!("{a11y} ({})", self.ui("Changed")); + a11y = fill(self.ui("{label} (Changed)"), &[("label", &a11y)]); } resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, selected, &a11y)); ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); @@ -983,18 +968,20 @@ impl Draw<'_, '_> { let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); - let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); let mut tip = Vec::new(); if cut { tip.push(self.vis(&f.label)); } match &f.now_used_by { Some(by) => { - let used = fill(self.ui("Now used by {label}"), &[("label", by)]); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({used})"))); - tip.push(self.vis(&used)); + let label = fill(self.ui("{label}: {chord} (Now used by {holder})"), &[("label", &f.label), ("chord", &f.keys), ("holder", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); + tip.push(self.vis(&fill(self.ui("Now used by {label}"), &[("label", by)]))); + } + None => { + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); } - None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } if !tip.is_empty() { resp.on_hover_text(tip.join("\n")); @@ -1040,12 +1027,8 @@ impl Draw<'_, '_> { let text = self.binding_text(binding, home); ui.horizontal(|ui| { ui.add_space(8.0); - let shown = if defaults.contains(binding) { - self.joined(vec![self.binding_shown(binding, home), " ".into(), self.vis(&format!("({})", self.ui("Default")))]) - } else { - self.binding_shown(binding, home) - }; - ui.label(shown); + let shown = if defaults.contains(binding) { fill(self.ui("{chord} (Default)"), &[("chord", &text)]) } else { text.clone() }; + ui.label(self.vis_chord(&shown, &self.chord(&binding.trigger))); if editable { let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); @@ -1117,7 +1100,7 @@ impl Draw<'_, '_> { } } if let Some(e) = &self.state.type_error { - ui.add(Label::new(RichText::new(format!("⚠ {e}")).color(self.style.warning)).wrap()); + ui.add(Label::new(RichText::new(self.warned(self.vis(e))).color(self.style.warning)).wrap()); } return; } @@ -1130,7 +1113,8 @@ impl Draw<'_, '_> { let size = vec2(FIELD_W, ui.spacing().interact_size.y); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, field_id, Sense::click()); - let a11y = format!("{title} {}", if shown.is_empty() { self.ui("None") } else { shown.clone() }); + let value = if shown.is_empty() { self.ui("None") } else { shown.clone() }; + let a11y = fill(self.ui("New shortcut: {chord}"), &[("chord", &value)]); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &a11y)); let v = ui.visuals(); let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; @@ -1201,7 +1185,7 @@ impl Draw<'_, '_> { let label = self.label_of(&h.command); let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); let text = match &h.binding.scope { - Some(s) if Some(s) != home.as_ref() => format!("{label} ({})", self.scope_name(s)), + Some(s) if Some(s) != home.as_ref() => fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(s))]), _ => label, }; ui.horizontal(|ui| { @@ -1361,7 +1345,8 @@ impl Draw<'_, '_> { match p { Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), Problem::BadBinding { key, text, error } => { - fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error.to_string())]) + let error = binding_error(self.env, error); + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error)]) } Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index aed56d40..64500ac2 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -549,9 +549,17 @@ fn typed_entry_parses_and_shows_errors() { h.get_by_label("Save").click(); h.run(); click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Nope"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "⚠ unknown key Nope")); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").focus(); + h.run(); + press(&mut h, Modifiers::COMMAND, EguiKey::A); h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); h.run(); press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "⚠ unknown key Nope")); click(&mut h, "Assign"); assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); } From 60daca37700a72aa145aaeb4670558018af962e1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:46:56 +0000 Subject: [PATCH 20/33] keymap: name the selected row to screen readers, announce conflicts and messages, and make group headers and key caps Tab stops --- crates/keymap/src/ui/keyboard.rs | 4 +++- crates/keymap/src/ui/view.rs | 29 +++++++++++++++++++++++----- crates/keymap/tests/ui_dialog.rs | 33 +++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 0d0bc2a5..f01ed522 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -559,7 +559,7 @@ impl Draw<'_, '_> { } let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) }; let selected = self.state.keyboard.key == Some(key); - let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::CLICK); + let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::click()); resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y)); let description = match class(holders) { Class::Free => self.ui("Not assigned"), @@ -572,6 +572,8 @@ impl Draw<'_, '_> { let v = ui.visuals(); if selected { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside); + } else if resp.has_focus() { + ui.painter().rect_stroke(r, CornerRadius::same(3), v.selection.stroke, StrokeKind::Outside); } else if resp.hovered() { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); } diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 155ee2e9..57b32420 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -12,6 +12,7 @@ use craft_ui::buttons::{IconButton, IconButtonStyle}; use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; use craft_ui::tabs::{self, Tab}; use egui::Galley; +use egui::accesskit::Live; use egui::text::{LayoutJob, TextFormat, TextWrapping}; use egui::{ Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, ScrollArea, @@ -686,13 +687,24 @@ impl Draw<'_, '_> { action = self.rows(ui, range, shortcut_w, has_focus); }); }); + // Screen readers follow the selected row while the table has focus. + let row_id = position.filter(|p| shown.contains(p)).and_then(|p| match self.state.lines_cached().get(p) { + Some(Line::Row(r)) => Some(self.base.with(("row", &r.key))), + _ => None, + }); + if let Some(id) = row_id { + ui.ctx().accesskit_node_builder(table_id, |node| node.set_active_descendant(id.accesskit_id())); + } self.state.visible = shown; if has_focus { ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); } if let Some(a) = action { - focus.request_focus(); + // A group header is a Tab stop of its own and keeps focus. + if !matches!(a, RowAction::Toggle(_)) { + focus.request_focus(); + } match a { RowAction::Select(c) => self.state.select_row(&c), RowAction::Record(c, old, chip) => { @@ -788,9 +800,14 @@ impl Draw<'_, '_> { action } + /// A group's header; a Tab stop, toggled with Enter or Space. fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { - let resp = ui.interact(rect, self.base.with(("group", name)), Sense::CLICK); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::click()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, true, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_expanded(!collapsed)); + if resp.has_focus() { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } let v = ui.visuals(); let color = v.strong_text_color(); let m = self.mirror(rect); @@ -1254,7 +1271,8 @@ impl Draw<'_, '_> { fn conflict_bar(&mut self, ui: &mut Ui) { let Some(c) = self.state.conflict.clone() else { return }; let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); - ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); let mut act = 0; ui.horizontal_wrapped(|ui| { if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { @@ -1289,7 +1307,8 @@ impl Draw<'_, '_> { let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); let text = if m.refused { self.warned(text) } else { text }; let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; - ui.add(Label::new(RichText::new(text).color(color)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(color)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { act = 1; } diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 64500ac2..702ab84f 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -114,8 +114,9 @@ fn has(h: &Harness<'_, Fx>, label: &str) -> bool { h.query_by_label(label).is_some() } +/// The table itself has focus (a screen reader sees its selected row, the active descendant). fn table_focused(h: &Harness<'_, Fx>) -> bool { - h.get_by_label("Commands").is_focused() + h.get_by_label("Commands").accesskit_node().is_focused_in_tree() } fn chord(text: &str, platform: Platform) -> String { @@ -359,7 +360,8 @@ fn search(h: &mut Harness<'_, Fx>, text: &str) { fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { h.get_by_role_and_label(Role::ComboBox, "Show:").click(); h.run(); - h.get_by_role_and_label(Role::Button, area).click(); + // The popup's item, after a group header of the same name. + h.get_all_by_role_and_label(Role::Button, area).last().unwrap_or_else(|| panic!("{area}")).click(); h.run(); } @@ -409,7 +411,7 @@ fn areas_follow_paths_kind_and_scope() { for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { assert!(h.get_all_by_label(area).count() >= 1, "{area}"); } - h.get_by_role_and_label(Role::Button, "Tools").click(); + h.get_all_by_role_and_label(Role::Button, "Tools").last().expect("the Tools item").click(); h.run(); assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); assert!(!has(&h, "Save: ⌘S")); @@ -463,6 +465,31 @@ fn table_keys_move_record_and_delete() { assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); } +#[test] +fn group_headers_take_focus_and_toggle_with_enter() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(tab_to(&mut h, "File", 3)); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "Export…: ⌘E"), "File is collapsed"); + assert!(h.get_by_label("File").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "Export…: ⌘E")); +} + +#[test] +fn table_names_the_selected_row_and_messages_are_live() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + let row = h.get_by_role_and_label(Role::Button, "Save").accesskit_node().id(); + assert_eq!(h.get_by_label("Commands").accesskit_node().active_descendant().map(|n| n.id()), Some(row)); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert_eq!(h.get_by_label("⚠ ⌘S is used by Save").accesskit_node().live(), egui::accesskit::Live::Polite); +} + #[test] fn small_window_keeps_ok_and_long_labels_truncate() { let long = "A very long command label ".repeat(12); From eff8b5253dcbeb978a9d5bdedd2759818f07d040 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:46:56 +0000 Subject: [PATCH 21/33] keymap: run CI on craft-ui and craft-i18n changes and lint the snapshot code --- .github/workflows/keymap.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 133cbe35..2e42b4ef 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -3,9 +3,9 @@ name: Keymap on: push: branches: [main] - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] pull_request: - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] workflow_dispatch: permissions: @@ -30,9 +30,10 @@ jobs: key: craft-keymap-linux - run: cargo fmt -p craft-keymap --check - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings - - run: cargo clippy -p craft-keymap --all-targets --features ui --locked -- -D warnings - # Doctests run once, in the `ui` step. `snapshot` (dialog screenshots) needs a GPU and is - # left out; `ui` covers `egui`. + # `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU + # to run, so the test steps leave them out. + - run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings + # Doctests run once, in the `ui` step. - name: Test core run: cargo test -p craft-keymap --no-default-features --lib --tests --locked - name: Test egui dispatch and the shortcuts dialog From f501cae73bd141eb499934befb993a906cc5c390 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:49:51 +0000 Subject: [PATCH 22/33] keymap: settle the public API before release Outcome, ConflictMode, ProfileError and Options are non_exhaustive, with Options::with for options built in one expression. Profiles keeps its fields private behind active() and sets(), lists the sets loading dropped in problems(), and stores a format version (a store without one reads as version 1). --- crates/keymap/README.md | 7 +- crates/keymap/src/lib.rs | 2 +- crates/keymap/src/profiles.rs | 200 +++++++++++++++++++++++------ crates/keymap/src/ui/mod.rs | 15 ++- crates/keymap/src/ui/state.rs | 30 ++--- crates/keymap/tests/profiles.rs | 54 ++++++-- crates/keymap/tests/settings.rs | 10 +- crates/keymap/tests/ui_dialog.rs | 14 +- crates/keymap/tests/ui_snapshot.rs | 2 +- 9 files changed, 249 insertions(+), 85 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index f5ff51eb..ec3b55a9 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -163,7 +163,8 @@ let keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overr store.update(|p| p.shortcut_sets = sets.clone()); ``` -- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). +- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`); + `sets` (the user's sets) and `active` (the stored name, which `active_name` resolves to a set). - `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, `rename` and `delete` work on user sets only. - `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a @@ -173,6 +174,10 @@ store.update(|p| p.shortcut_sets = sets.clone()); - `export(name)` writes one set as JSON (`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into a new set. A file from a newer version is refused (`ProfileError::NewerVersion`). +- Loading never fails. `problems()` lists what loading dropped: sets with a bad or repeated name, + overrides that are neither a list nor a map, sets past `MAX_SETS`, and a `version` newer than + this crate writes. The app shows them before the next save writes the sets without them. + Stored `Profiles` carry `version = 1`; a file without it reads as version 1. - `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a copy of the keymap and keeps the result as a new set, so another application's set becomes one the user can switch to and away from. diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 513df955..35c7a35f 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -157,5 +157,5 @@ pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, }; -pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles}; +pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles, SetProblem}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index baa4b4b9..df9634db 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::fmt; use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; @@ -34,6 +35,10 @@ pub const MAX_EXPORT_BYTES: usize = 4 << 20; const FORMAT: &str = "craft-keymap"; const VERSION: u64 = 1; +/// The version stored [`Profiles`] carry; stored profiles without one read as version 1. +const STORE_VERSION: u64 = 1; +// Load notes are kept for `Profiles::problems`; a hostile file must not grow them without bound. +const MAX_NOTES: usize = 64; const IMPORTED: &str = "Imported"; /// The app's built-in sets: name and overrides against the catalog defaults. @@ -42,6 +47,7 @@ pub type Builtins<'a> = [(&'a str, Overrides)]; /// Why a set operation was refused. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ProfileError { /// Empty, over [`MAX_SET_NAME`] chars, or with control characters. BadName, @@ -80,23 +86,51 @@ impl fmt::Display for ProfileError { impl std::error::Error for ProfileError {} +/// Something loading dropped from stored [`Profiles`]; [`Profiles::problems`] lists them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum SetProblem { + /// A set without a valid name: none, empty, over [`MAX_SET_NAME`] chars, with control + /// characters or with spaces around it. `name` is what was stored, cut to [`MAX_SET_NAME`] + /// chars. + BadName { name: String }, + /// A second set of this name; the first one stays. + Duplicate { name: String }, + /// Overrides that are neither a list nor a map. + BadOverrides { name: String }, + /// Sets past [`MAX_SETS`]. + TooMany { ignored: usize }, + /// Stored by a newer craft-keymap; what this version reads is loaded. + NewerVersion(u64), +} + /// The user's sets and which set is active. The built-in sets are passed to each call. /// -/// An `active` name that matches no set means the first built-in set (or no overrides when the app +/// An active name that matches no set means the first built-in set (or no overrides when the app /// has none). /// /// A user set may carry a built-in's name, for instance after an update adds that built-in. Until /// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call /// `reconcile` after loading; every method that changes the profiles calls it first. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +/// +/// Stored as `{ version, active, sets }`. Loading never fails: what it drops is listed by +/// [`Profiles::problems`], and the next save writes the profiles without it. +#[derive(Clone, Debug, Default)] pub struct Profiles { - #[serde(deserialize_with = "lenient_name")] - pub active: String, - #[serde(serialize_with = "sets_as_list", deserialize_with = "capped_sets")] - pub sets: BTreeMap, + active: String, + sets: BTreeMap, + notes: Vec, } +impl PartialEq for Profiles { + fn eq(&self, other: &Profiles) -> bool { + self.active == other.active && self.sets == other.sets + } +} + +impl Eq for Profiles {} + static NO_OVERRIDES: Overrides = Overrides::new(); /// A name as given, trimmed; `None` when empty, too long or with control characters. @@ -107,6 +141,22 @@ pub fn set_name(name: &str) -> Option { } impl Profiles { + /// The stored active name, which may match no set; [`active_name`](Self::active_name) is the + /// set in use. + pub fn active(&self) -> &str { + &self.active + } + + /// The user's sets. + pub fn sets(&self) -> &BTreeMap { + &self.sets + } + + /// What loading dropped. + pub fn problems(&self) -> &[SetProblem] { + &self.notes + } + /// Built-in names first, then the user's sets. pub fn names(&self, builtins: &Builtins) -> Vec { builtins.iter().map(|(n, _)| n.to_string()).chain(self.sets.keys().cloned()).collect() @@ -345,25 +395,105 @@ impl Profiles { } } +impl Serialize for Profiles { + fn serialize(&self, serializer: S) -> Result { + let mut s = serializer.serialize_struct("Profiles", 3)?; + s.serialize_field("version", &STORE_VERSION)?; + s.serialize_field("active", &self.active)?; + s.serialize_field("sets", &SetList(&self.sets))?; + s.end() + } +} + +impl<'de> Deserialize<'de> for Profiles { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize, Default)] + #[serde(default)] + struct Stored { + #[serde(deserialize_with = "lenient_version")] + version: Option, + #[serde(deserialize_with = "lenient_name")] + active: String, + #[serde(deserialize_with = "capped_sets")] + sets: Loaded, + } + let Stored { version, active, sets: Loaded { sets, mut notes, .. } } = Stored::deserialize(deserializer)?; + if let Some(v) = version.filter(|v| *v > STORE_VERSION) { + notes.insert(0, SetProblem::NewerVersion(v)); + notes.truncate(MAX_NOTES); + } + Ok(Profiles { active, sets, notes }) + } +} + fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result { let v = Value::deserialize(deserializer)?; Ok(v.as_str().and_then(set_name).unwrap_or_default()) } +fn lenient_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Ok(Value::deserialize(deserializer)?.as_u64()) +} + // An array of `{ name, overrides }` rather than a table, and each set's overrides in their array // form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the // file. -fn sets_as_list(sets: &BTreeMap, serializer: S) -> Result { - #[derive(Serialize)] - struct Set<'a> { - name: &'a str, - #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] - overrides: &'a Overrides, +struct SetList<'a>(&'a BTreeMap); + +impl Serialize for SetList<'_> { + fn serialize(&self, serializer: S) -> Result { + #[derive(Serialize)] + struct Set<'a> { + name: &'a str, + #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] + overrides: &'a Overrides, + } + serializer.collect_seq(self.0.iter().map(|(name, overrides)| Set { name, overrides })) } - serializer.collect_seq(sets.iter().map(|(name, overrides)| Set { name, overrides })) } -fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { +/// The sets that loaded, and notes on the ones that did not. +#[derive(Default)] +struct Loaded { + sets: BTreeMap, + notes: Vec, + ignored: usize, +} + +impl Loaded { + fn note(&mut self, problem: SetProblem) { + if self.notes.len() < MAX_NOTES { + self.notes.push(problem); + } + } + + /// Keeps a set with a valid name, stored as written, that is new and fits under [`MAX_SETS`]; + /// notes why not otherwise. + fn add(&mut self, name: &str, overrides: Option) { + let shown = || name.chars().take(MAX_SET_NAME).collect::(); + if !set_name(name).is_some_and(|n| n == name) { + self.note(SetProblem::BadName { name: shown() }); + } else if self.sets.contains_key(name) { + self.note(SetProblem::Duplicate { name: shown() }); + } else if self.sets.len() >= MAX_SETS { + self.ignored += 1; + } else if let Some(o) = overrides { + self.sets.insert(name.to_string(), o); + } else { + self.note(SetProblem::BadOverrides { name: shown() }); + } + } + + fn done(mut self) -> Loaded { + if self.ignored > 0 { + let ignored = std::mem::take(&mut self.ignored); + self.note(SetProblem::TooMany { ignored }); + } + self + } +} + +fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result { deserializer.deserialize_any(SetsVisitor) } @@ -373,13 +503,13 @@ struct SetsVisitor; macro_rules! no_sets { ($($name:ident: $t:ty),*) => { $(fn $name(self, _: $t) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) })* }; } impl<'de> Visitor<'de> for SetsVisitor { - type Value = BTreeMap; + type Value = Loaded; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides") @@ -388,11 +518,11 @@ impl<'de> Visitor<'de> for SetsVisitor { no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); fn visit_unit(self) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) } fn visit_none(self) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) } fn visit_some>(self, deserializer: D) -> Result { @@ -400,39 +530,29 @@ impl<'de> Visitor<'de> for SetsVisitor { } fn visit_seq>(self, mut access: A) -> Result { - let mut out = BTreeMap::new(); + let mut out = Loaded::default(); while let Some(item) = access.next_element::()? { - let Some(name) = item.get("name").and_then(Value::as_str) else { continue }; - if !admits(&out, name) { - continue; - } - if let Some(o) = item.get("overrides").and_then(overrides_of) { - out.insert(name.to_string(), o); - } + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + out.add(name, item.get("overrides").and_then(overrides_of)); } - Ok(out) + Ok(out.done()) } fn visit_map>(self, mut access: A) -> Result { - let mut out = BTreeMap::new(); + let mut out = Loaded::default(); while let Some(key) = access.next_key::()? { - if admits(&out, &key) { - if let Some(o) = overrides_of(&access.next_value::()?) { - out.insert(key, o); - } - } else { + if out.sets.len() >= MAX_SETS && !out.sets.contains_key(&key) { access.next_value::()?; + out.ignored += 1; + } else { + let overrides = overrides_of(&access.next_value::()?); + out.add(&key, overrides); } } - Ok(out) + Ok(out.done()) } } -/// A valid name, stored as written, with room under [`MAX_SETS`]. -fn admits(sets: &BTreeMap, name: &str) -> bool { - set_name(name).is_some_and(|n| n == name) && (sets.len() < MAX_SETS || sets.contains_key(name)) -} - /// A set's overrides: a list or a map, anything else is no set. fn overrides_of(v: &Value) -> Option { match v { diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index cf73737f..d0da3424 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -31,6 +31,7 @@ use state::Env; /// What the dialog reports for a frame. #[derive(Clone, Debug)] +#[non_exhaustive] pub enum Outcome { /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. Ok, @@ -55,6 +56,7 @@ pub enum Outcome { /// What happens when a recorded chord is held by other commands. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] pub enum ConflictMode { /// Ask with the conflict bar: Take It, Take It and Go to Conflict, Keep Both, Cancel. #[default] @@ -97,7 +99,9 @@ pub type Accept = Box Result<(), String>>; /// Called for every control once per frame with (automation id, rect, label). pub type OnWidget = Box; -/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field. +/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field; +/// set fields on it, or with [`Options::with`]. +#[non_exhaustive] pub struct Options { /// "Keyboard Shortcuts". pub title: &'static str, @@ -160,6 +164,15 @@ impl Default for Options { } } +impl Options { + /// `self` with `f` applied, for options built in one expression: + /// `Options::default().with(|o| o.keyboard = true)`. + pub fn with(mut self, f: impl FnOnce(&mut Options)) -> Options { + f(&mut self); + self + } +} + /// The format id of craft-keymap's own JSON in [`Outcome::Import`]. pub const JSON_FORMAT: &str = "craft-keymap"; diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 701ba546..83678353 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -1354,21 +1354,21 @@ mod tests { st.new_set(&mut fx.env(), "Mine", None); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.save_set(&mut fx.env()); - let kept = fx.p.sets["Mine"].clone(); + let kept = fx.p.sets()["Mine"].clone(); assert!(!kept.is_empty()); st.switch_set(&mut fx.env(), "Fixture Defaults"); for name in ["Mine", " mine ", "fixture defaults"] { for based_on in [None, Some("Fixture Defaults")] { st.new_set(&mut fx.env(), name, based_on); assert!(st.message.as_ref().is_some_and(|m| m.refused), "{name:?} from {based_on:?}"); - assert_eq!((fx.p.sets.len(), fx.p.active.as_str()), (1, "Fixture Defaults")); - assert_eq!(fx.p.sets["Mine"], kept); + assert_eq!((fx.p.sets().len(), fx.p.active()), (1, "Fixture Defaults")); + assert_eq!(fx.p.sets()["Mine"], kept); } } st.new_set(&mut fx.env(), "Other", None); st.rename_set(&mut fx.env(), "MINE"); assert!(st.message.as_ref().is_some_and(|m| m.refused)); - assert_eq!(fx.p.sets["Mine"], kept); + assert_eq!(fx.p.sets()["Mine"], kept); } #[test] @@ -1379,11 +1379,11 @@ mod tests { let mut st = open(&mut fx); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.new_set(&mut fx.env(), "Other", None); - assert_eq!(fx.p.active, "Other"); + assert_eq!(fx.p.active(), "Other"); st.ask_delete_set(&fx.env()); assert_eq!(st.prompt, Some(Prompt::DeleteSet("Other".into()))); st.answer(&mut fx.env(), Answer::Yes); - assert!(!fx.p.sets.contains_key("Other")); + assert!(!fx.p.sets().contains_key("Other")); st.cancel(&mut fx.env()); assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); } @@ -1397,13 +1397,13 @@ mod tests { st.ask_delete_set(&fx.env()); assert!(st.modal()); st.switch_set(&mut fx.env(), "Mine"); - assert_eq!((st.prompt.clone(), fx.p.active.as_str()), (None, "Mine"), "switching closes the prompt"); + assert_eq!((st.prompt.clone(), fx.p.active()), (None, "Mine"), "switching closes the prompt"); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.prompt = Some(Prompt::DeleteSet("Other".into())); st.answer(&mut fx.env(), Answer::Yes); - assert!(!fx.p.sets.contains_key("Other")); - assert_eq!((fx.p.active.as_str(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); + assert!(!fx.p.sets().contains_key("Other")); + assert_eq!((fx.p.active(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); assert!(st.can_undo()); } @@ -1419,9 +1419,9 @@ mod tests { st.ask_delete_set(&fx.env()); st.answer(&mut fx.env(), Answer::Yes); assert!(st.message.is_none(), "{:?}", st.message); - assert!(fx.p.sets.is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); + assert!(fx.p.sets().is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); st.ok(&mut fx.env()); - assert!(fx.p.sets.is_empty(), "OK stores no copy of the deleted set"); + assert!(fx.p.sets().is_empty(), "OK stores no copy of the deleted set"); } #[test] @@ -1432,7 +1432,7 @@ mod tests { assert!(DialogState::is_modified(&fx.env())); st.ok(&mut fx.env()); assert!(matches!(st.finish(), Some(Outcome::Ok))); - assert_eq!(fx.p.active, "Custom"); + assert_eq!(fx.p.active(), "Custom"); assert_eq!(fx.p.get("Custom", &fx.b).and_then(|o| o.get("object.front")), Some(&["F2".to_string()][..])); } @@ -1445,7 +1445,7 @@ mod tests { st.switch_set(&mut fx.env(), "Fixture Defaults"); assert!(matches!(&st.prompt, Some(Prompt::SaveChanges { set, .. }) if set == "Mine")); st.answer(&mut fx.env(), Answer::No); - assert_eq!(fx.p.active, "Fixture Defaults"); + assert_eq!(fx.p.active(), "Fixture Defaults"); assert!(fx.km.overrides().is_empty() && !st.can_undo(), "the set loads with a fresh undo history"); assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); } @@ -1466,7 +1466,7 @@ mod tests { let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); st.import_reviewed(&mut fx.env()); - assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.active(), "Theirs"); assert_eq!(fx.p.get("Theirs", &fx.b).and_then(|o| o.get("object.front")), None); assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); } @@ -1550,7 +1550,7 @@ mod tests { assert_eq!(r.map(|r| (r.name.as_str(), r.plan.commands.len(), r.plan.report.unmatched.len())), Some(("Theirs", 1, 1))); assert!(fx.km.overrides().is_empty(), "review changes nothing"); st.import_reviewed(&mut fx.env()); - assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.active(), "Theirs"); assert_eq!(fx.keys("file.save"), ["Cmd+Alt+S"]); assert!(st.review.as_ref().is_some_and(|r| r.done.is_some())); st.escape(&mut fx.env()); diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 348420df..05183b11 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -1,6 +1,6 @@ use craft_keymap::{ Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, - Policy, ProfileError, Profiles, Scope, Scopes, Start, plan, + Policy, ProfileError, SetProblem, Profiles, Scope, Scopes, Start, plan, }; use serde_json::json; @@ -46,7 +46,7 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); - assert_eq!(p.active, "Mine"); + assert_eq!(p.active(), "Mine"); assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); p.delete("Mine", &builtins).unwrap(); assert_eq!(p.active_name(&builtins), "Craft"); @@ -59,10 +59,10 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { let text = serde_json::to_string(&p).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), p); let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); - assert_eq!(junk.sets.keys().collect::>(), ["ok"]); + assert_eq!(junk.sets().keys().collect::>(), ["ok"]); let junk: Profiles = serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); - assert_eq!(junk.sets["ok"].get("a"), Some(&["A".to_string()][..])); + assert_eq!(junk.sets()["ok"].get("a"), Some(&["A".to_string()][..])); assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); } @@ -71,7 +71,7 @@ fn rename_keeps_a_set_whose_name_differs_only_in_case() { let builtins = [("Craft", Overrides::new())]; let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); - assert_eq!(p.sets["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.sets()["mine"].get("b"), Some(&["B".to_string()][..])); assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); p.delete("mine", &builtins).unwrap(); assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); @@ -81,8 +81,8 @@ fn rename_keeps_a_set_whose_name_differs_only_in_case() { fn sets_that_are_not_a_list_or_map_load_as_none() { for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); - assert!(p.sets.is_empty(), "{text}"); - assert_eq!(p.active, "Mine", "{text}"); + assert!(p.sets().is_empty(), "{text}"); + assert_eq!(p.active(), "Mine", "{text}"); } } @@ -107,9 +107,7 @@ fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { assert_eq!(p.get("Classic", &builtins), Some(&classic)); assert_eq!(builtins[1].1, classic); - let mut q = Profiles::default(); - q.sets.insert("CRAFT".into(), mine.clone()); - q.active = "CRAFT".into(); + let mut q: Profiles = serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); assert!(q.reconcile(&builtins)); assert_eq!(q.active_name(&builtins), "CRAFT 2"); assert_eq!(q.active_overrides(&builtins), &mine); @@ -128,7 +126,7 @@ fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { let text = p.export("Mine", &builtins).unwrap(); assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); - assert_eq!(p.active, "Mine 2"); + assert_eq!(p.active(), "Mine 2"); let mut again = keymap(); again.set_overrides(p.active_overrides(&builtins).clone()); @@ -146,14 +144,14 @@ fn duplicate_copies_built_in_and_user_sets() { let mut p = Profiles::default(); assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); - assert_eq!(p.active, "Classic 2"); + assert_eq!(p.active(), "Classic 2"); assert_eq!(p.active_overrides(&builtins), &classic); assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); - while p.sets.len() < MAX_SETS { + while p.sets().len() < MAX_SETS { p.duplicate("Craft", "Spare", &builtins).unwrap(); } assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); @@ -173,7 +171,7 @@ fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { assert_eq!(name, "Other App"); assert_eq!(report.changed.len(), 1); assert!(km.overrides().is_empty()); - assert_eq!(p.active, "Other App"); + assert_eq!(p.active(), "Other App"); let mut imported = keymap(); imported.set_overrides(p.active_overrides(&builtins).clone()); assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); @@ -181,3 +179,31 @@ fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); assert_eq!(name, "Craft 2", "a built-in's name is not reused"); } + +#[test] +fn loading_lists_the_sets_it_drops_and_the_store_has_a_version() { + let text = r#"{"version": 2, "active": "ok", "sets": [ + {"name": "ok", "overrides": {}}, {"name": " pad ", "overrides": {}}, {"name": "ok", "overrides": {"a": "A"}}, {"name": "bad", "overrides": 3} + ]}"#; + let p: Profiles = serde_json::from_str(text).unwrap(); + assert_eq!(p.sets().keys().collect::>(), ["ok"]); + assert!(p.sets()["ok"].is_empty(), "the first of two sets with one name stays"); + let name = |n: &str| n.to_string(); + assert_eq!( + p.problems(), + [ + SetProblem::NewerVersion(2), + SetProblem::BadName { name: name(" pad ") }, + SetProblem::Duplicate { name: name("ok") }, + SetProblem::BadOverrides { name: name("bad") } + ] + ); + + let many: Vec<_> = (0..MAX_SETS + 3).map(|i| json!({"name": format!("s{i}"), "overrides": {}})).collect(); + let p: Profiles = serde_json::from_value(json!({ "sets": many })).unwrap(); + assert_eq!((p.sets().len(), p.problems()), (MAX_SETS, &[SetProblem::TooMany { ignored: 3 }][..])); + + assert_eq!(serde_json::to_value(&p).unwrap()["version"], 1); + let unversioned: Profiles = serde_json::from_str(r#"{"active": "x", "sets": []}"#).unwrap(); + assert!(unversioned.problems().is_empty()); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index ac76296e..2361693e 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -77,7 +77,7 @@ fn overrides_round_trip_through_craft_settings() { let problems = km.problems(); assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); - assert_eq!(store.get().profiles.sets.len(), 2); + assert_eq!(store.get().profiles.sets().len(), 2); store.update(|p| p.general.language = "de".into()); store.flush().unwrap(); @@ -101,8 +101,8 @@ fn overrides_round_trip_through_craft_settings() { assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); let reloaded = open(tmp.path()); assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); - assert_eq!(reloaded.get().profiles.sets.keys().collect::>(), ["Mine"]); - assert_eq!(reloaded.get().profiles.sets["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); + assert_eq!(reloaded.get().profiles.sets().keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets()["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); } #[test] @@ -140,7 +140,7 @@ fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); std::fs::write(tmp.path().join("config.toml"), text).unwrap(); let store = open(tmp.path()); - assert_eq!(store.get().profiles.active, "Mine", "{value}"); - assert!(store.get().profiles.sets.is_empty(), "{value}"); + assert_eq!(store.get().profiles.active(), "Mine", "{value}"); + assert!(store.get().profiles.sets().is_empty(), "{value}"); } } diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 702ab84f..52d6ee78 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -648,17 +648,17 @@ fn cancel_restores_keymap_and_sets() { let mut h = with_sets(p); click(&mut h, "Set:"); click_last(&mut h, "Mine"); - assert_eq!(profiles(&h).active, "Mine"); + assert_eq!(profiles(&h).active(), "Mine"); click(&mut h, "Save: ⌘S"); press(&mut h, Modifiers::NONE, EguiKey::F2); assert!(has(&h, "(modified)")); new_set(&mut h, "Other"); - assert_eq!(profiles(&h).active, "Other"); + assert_eq!(profiles(&h).active(), "Other"); assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); click(&mut h, "Delete"); assert!(has(&h, "Delete the set “Other”?")); click_last(&mut h, "Delete"); - assert!(!profiles(&h).sets.contains_key("Other")); + assert!(!profiles(&h).sets().contains_key("Other")); click(&mut h, "Cancel"); assert!(h.state().canceled()); assert_eq!(profiles(&h), &before); @@ -692,8 +692,8 @@ fn ok_stores_edits_of_a_builtin_as_a_copy() { click(&mut h, "OK"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); let p = profiles(&h); - assert_eq!(p.active, "Custom"); - assert_eq!(p.sets.get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); + assert_eq!(p.active(), "Custom"); + assert_eq!(p.sets().get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); } #[test] @@ -717,7 +717,7 @@ fn set_export_import_round_trip() { let (d, state) = dialog(fresh.state_mut()); assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); fresh.run(); - assert_eq!(profiles(&fresh).active, "Name 2"); + assert_eq!(profiles(&fresh).active(), "Name 2"); assert_eq!(fresh.state().km.overrides(), &edited); assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); } @@ -756,7 +756,7 @@ fn foreign_import_review_lists_skipped_and_unreadable() { assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); click(&mut h, "Import as New Set"); - assert_eq!(profiles(&h).active, "Theirs"); + assert_eq!(profiles(&h).active(), "Theirs"); assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); assert!(has(&h, "Imported as “Theirs”")); click(&mut h, "Done"); diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index a6176af7..9b90383a 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -230,7 +230,7 @@ fn dialog_screenshots() { } (p, builtins) }); - let options = Options { keyboard: case.keyboard, ..Options::default() }; + let options = Options::default().with(|o| o.keyboard = case.keyboard); let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; let text: Box = match case.words { Words::English => Box::new(English), From b778a764afcde120f6bbbc992836e2d1288924d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:52:45 +0000 Subject: [PATCH 23/33] keymap: format the profiles test --- crates/keymap/tests/profiles.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 05183b11..ce94abfd 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -1,6 +1,6 @@ use craft_keymap::{ Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, - Policy, ProfileError, SetProblem, Profiles, Scope, Scopes, Start, plan, + Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, Start, plan, }; use serde_json::json; @@ -107,7 +107,8 @@ fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { assert_eq!(p.get("Classic", &builtins), Some(&classic)); assert_eq!(builtins[1].1, classic); - let mut q: Profiles = serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); + let mut q: Profiles = + serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); assert!(q.reconcile(&builtins)); assert_eq!(q.active_name(&builtins), "CRAFT 2"); assert_eq!(q.active_overrides(&builtins), &mine); From 0bff6f4c9608d3e507615f1ac21146c21e5c03b9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:48:54 +0000 Subject: [PATCH 24/33] keymap ui: commands that share a default key are no conflict in the dialog The dialog's conflict count, Conflicts filter and row warnings leave out pairs whose commands both have the chord among their defaults, as Keymap::clashing does. --- crates/keymap/src/keymap.rs | 7 +++++++ crates/keymap/src/ui/rows.rs | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index fff50a2e..8dc9fd94 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -774,6 +774,13 @@ impl Keymap { t == by.0.effective(self.platform) && !self.twins(i, j, t) && self.scopes.relation(&bx.1, &by.1) == Some(true) } + /// Whether both commands of `conflict` have its chord among their defaults, so they share it by + /// design and [`Keymap::clashing`] leaves them out. + pub(crate) fn twins_in(&self, conflict: &Conflict) -> bool { + let (Some(i), Some(j)) = (self.find(&conflict.a.0), self.find(&conflict.b.0)) else { return false }; + self.twins(i, j, conflict.chord) + } + fn find(&self, command: &CommandRef) -> Option { self.index.get(&command.key()).copied() } diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 2f85cf31..7f8658d6 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -317,7 +317,7 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu let catalog = keymap.catalog(); let mut built = Built::default(); let mut clashing: HashSet = HashSet::new(); - for c in keymap.conflicts() { + for c in keymap.conflicts().into_iter().filter(|c| !keymap.twins_in(c)) { match c.kind { ConflictKind::Clash => { built.clashes += 1; @@ -600,6 +600,30 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn commands_sharing_a_default_key_are_no_conflict() -> Result<(), crate::AssignError> { + let menu = Bindable::new("app.characterPanel", "Character", Scope::app()).with_path(vec!["Window".into(), "Character".into()]); + let catalog = vec![ + menu.with_default(key("Cmd+T")), + Bindable::new("panel.character", "Character Panel", Scope::app()).with_default(key("Cmd+T")), + Bindable::new("file.save", "Save", Scope::app()).with_default(key("Cmd+S")), + ]; + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac); + let o = Options::default(); + let none = BTreeSet::new(); + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!((built.clashes, built.shadows), (0, 0)); + assert!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts).is_empty()); + + km.assign(&"file.save".into(), key("Cmd+T"), crate::Policy::Keep)?; + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!(built.clashes, 2, "Save clashes with both twins"); + let flagged: Vec = + built.lines.iter().filter_map(|l| if let Line::Row(r) = l { r.conflict.then(|| r.key.clone()) } else { None }).collect(); + assert_eq!(flagged, ["app.characterPanel", "panel.character", "file.save"]); + Ok(()) + } + #[test] fn fixed_rows_strike_through_when_a_command_takes_their_chord() { let km = fixture(Platform::Mac); From 02ba09ee9ace4cf2d6c59cd7d3eaa5e8c78a945d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:53:13 +0000 Subject: [PATCH 25/33] keymap ui: Options::visible hides catalog entries from the table Hidden entries keep their bindings and still count in conflicts; they are left out of the rows, search and the Show entries. --- crates/keymap/README.md | 5 +++-- crates/keymap/src/ui/mod.rs | 5 +++++ crates/keymap/src/ui/rows.rs | 41 ++++++++++++++++++++++++++++-------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0984465b..472c2883 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -378,8 +378,9 @@ Words come from `ui::Text`. Apps on craft-i18n pass `I18nText`; others implement right-to-left languages, which mirror the layout. Chord text is never reordered. `ui::STRINGS` and `ui::PLURALS` list every string the dialog looks up, for the translation catalog. `Options` sets the conflict mode, double-press recording, area tabs, the on-screen keyboard, -read-only rows for keys outside the catalog, the app's import and export formats, and an -`accept` rule for which keys a command may get. +read-only rows for keys outside the catalog, the app's import and export formats, an `accept` +rule for which keys a command may get, and a `visible` rule for which catalog entries the table +lists (hidden entries keep their keys and still count in conflicts). ## Features diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index d0da3424..1eb67ab0 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -125,6 +125,10 @@ pub struct Options { pub area_tabs: bool, /// The app's own Show entries. pub areas: Vec, + /// `None`: every catalog entry is listed. Otherwise the table, its Show entries and search + /// list only the entries it returns true for; the others keep their bindings and still count + /// in conflicts. + pub visible: Option bool>, /// false: the on-screen keyboard pane. pub keyboard: bool, pub fixed: Vec, @@ -152,6 +156,7 @@ impl Default for Options { sort_by_label: false, area_tabs: false, areas: Vec::new(), + visible: None, keyboard: false, fixed: Vec::new(), import_formats: Vec::new(), diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 7f8658d6..11825af0 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -186,12 +186,18 @@ fn is_tool(b: &Bindable) -> bool { matches!(b.kind, Kind::Tool { .. }) } +/// The catalog entries the table lists ([`Options::visible`]). +fn listed<'k>(keymap: &'k Keymap, options: &Options) -> impl Iterator + Clone { + let visible = options.visible; + keymap.catalog().iter().filter(move |b| visible.is_none_or(|f| f(b))) +} + /// The Show entries, in catalog order, leaving out areas with no rows. pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { - let catalog = keymap.catalog(); + let catalog = listed(keymap, options); let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; let mut menus: Vec<&str> = Vec::new(); - for m in catalog.iter().filter_map(top_menu) { + for m in catalog.clone().filter_map(top_menu) { if !menus.contains(&m) { menus.push(m); } @@ -200,22 +206,22 @@ pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec< let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); } - if catalog.iter().any(is_tool) { + if catalog.clone().any(is_tool) { out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); } let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); - for b in catalog.iter().filter(|b| !b.scope.is_app()) { + for b in catalog.clone().filter(|b| !b.scope.is_app()) { if !panels.contains(&b.scope.as_str()) { panels.push(b.scope.as_str()); } } - for p in panels.into_iter().filter(|p| catalog.iter().any(|b| b.scope.as_str() == *p)) { + for p in panels.into_iter().filter(|p| catalog.clone().any(|b| b.scope.as_str() == *p)) { out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); } - if catalog.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + if catalog.clone().any(|b| top_menu(b).is_none() && !is_tool(b)) { out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); } - for a in &options.areas { + for a in options.areas.iter().filter(|a| catalog.clone().any(a.rows)) { out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() }); } if !options.fixed.is_empty() { @@ -344,14 +350,15 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu } } let submenus = match view.area { - AreaId::Menu(m) => catalog.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + AreaId::Menu(m) => listed(keymap, options).any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), _ => false, }; // Groups in first-seen order within their rank. let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() { - if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { + let hidden = options.visible.is_some_and(|f| !f(b)); + if hidden || !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { continue; } let key = b.command.key(); @@ -600,6 +607,22 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn hidden_entries_are_not_listed_but_still_conflict() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Mac); + let o = Options { visible: Some(|b| b.command.id != "file.export" && !b.command.id.starts_with("timeline.")), ..Options::default() }; + let names: Vec = areas(&km, &o, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Other"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("File".into()), Filter::All), ["Save: ⌘S", "Save As…: ⇧⌘S"]); + assert!(lines(&km, &o, &English, "export", AreaId::All, Filter::All).is_empty()); + + km.assign(&"file.save".into(), key("Cmd+E"), crate::Policy::Keep)?; + let none = BTreeSet::new(); + assert_eq!(build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)).clashes, 1); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: ⌘S, ⌘E"]); + Ok(()) + } + #[test] fn commands_sharing_a_default_key_are_no_conflict() -> Result<(), crate::AssignError> { let menu = Bindable::new("app.characterPanel", "Character", Scope::app()).with_path(vec!["Window".into(), "Character".into()]); From 636fc8b8504555cab7354deb8d062fa6fc88d61d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:59:43 +0000 Subject: [PATCH 26/33] keymap: import-only paths on Bindable Bindable::import_paths (with_import_path) are read by import matching like menu paths, and make no areas or groups in the dialog, so a tool's ["Tools", label] path no longer shows a Tools Menu entry. --- crates/keymap/README.md | 4 +++- crates/keymap/src/import.rs | 10 +++++++--- crates/keymap/src/keymap.rs | 11 +++++++++++ crates/keymap/src/ui/rows.rs | 15 +++++++++++++++ crates/keymap/tests/import.rs | 14 ++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 472c2883..b9291572 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -242,7 +242,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File it `in_panel_menu()`. Tool entries match only tools. - Names are compared after `import::normalize_label`: case, `…`, `...`, `&`, colons and extra spaces aside, so the alias `Preferences: General…` matches `Edit > Preferences > General...`. - Aliases (`Bindable::with_alias`) count wherever labels do. + Aliases (`Bindable::with_alias`) count wherever labels do, and import paths + (`Bindable::with_import_path`, such as `["Tools", label]` for a tool) wherever menu paths do; + the dialog makes no areas or groups from import paths. - Panel menus: sources list panel-menu items with the panel's name in front (InDesign's Panel Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas). The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`. diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index a387ad67..5277c9c8 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -860,7 +860,7 @@ impl Names { for name in &names { add(&mut self.anywhere, name.clone(), i); } - for path in &b.paths { + for path in b.paths.iter().chain(&b.import_paths) { let mut segs: Vec = path.iter().map(|s| normalize_label(s)).collect(); if let [_, .., last] = segs.as_mut_slice() { *last = form(last); @@ -936,7 +936,7 @@ impl Index { ix.tool.push(matches!(b.kind, Kind::Tool { .. })); ix.keys.insert(b.command.key(), i); ix.names.add(i, b, str::to_string); - ix.segments.extend(b.paths.iter().flatten().map(|s| normalize_label(s))); + ix.segments.extend(b.paths.iter().chain(&b.import_paths).flatten().map(|s| normalize_label(s))); if b.is_toggle { ix.toggles.add(i, b, toggle_name); } @@ -1000,7 +1000,11 @@ fn suggest(keymap: &Keymap, entries: &[ToSuggest]) -> Vec { let candidates: Vec = pool .iter() .map(|b| { - let labels = std::iter::once(&b.label).chain(&b.aliases).chain(b.paths.iter().filter_map(|p| p.last())).cloned().collect(); + let labels = std::iter::once(&b.label) + .chain(&b.aliases) + .chain(b.paths.iter().chain(&b.import_paths).filter_map(|p| p.last())) + .cloned() + .collect(); namematch::Candidate { id: b.command.id.clone(), labels } }) .collect(); diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 8dc9fd94..8c026945 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -141,6 +141,10 @@ pub struct Bindable { /// Menu paths, the main one first; used by search and import. #[serde(default)] pub paths: Vec>, + /// Paths only import matching reads: where foreign sets file the command and the app's menus + /// do not, such as `["Tools", label]` for a tool. They make no areas or groups in the dialog. + #[serde(default)] + pub import_paths: Vec>, /// Other names: labels in other UI languages, older names. #[serde(default)] pub aliases: Vec, @@ -163,6 +167,7 @@ impl Bindable { command: command.into(), label: label.into(), paths: Vec::new(), + import_paths: Vec::new(), aliases: Vec::new(), scope, kind: Kind::Command, @@ -192,6 +197,12 @@ impl Bindable { self } + /// Adds a path only import matching reads ([`Bindable::import_paths`]). + pub fn with_import_path(mut self, path: Vec) -> Bindable { + self.import_paths.push(path); + self + } + pub fn with_alias(mut self, alias: impl Into) -> Bindable { self.aliases.push(alias.into()); self diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 11825af0..6b17b67f 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -607,6 +607,21 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn import_paths_make_no_areas_or_groups() { + let tool = |id: &str, label: &str| { + Bindable::new(id, label, Scope::app()) + .with_kind(Kind::Tool { group: None }) + .with_import_path(vec!["Tools".into(), label.into()]) + .with_default(key("P")) + }; + let km = Keymap::new(vec![tool("tool.pen", "Pen Tool")], Scopes::new(), Platform::Mac); + let o = Options::default(); + let names: Vec = areas(&km, &o, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "Tools"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::All), ["# Tools", "Pen Tool: P"]); + } + #[test] fn hidden_entries_are_not_listed_but_still_conflict() -> Result<(), crate::AssignError> { let mut km = fixture(Platform::Mac); diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index dd66bcb6..77346e14 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -676,6 +676,20 @@ fn aliases_match_like_labels() { assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); } +#[test] +fn import_paths_match_like_menu_paths() { + let catalog = vec![ + command("object.color", "Apply Color", &["Object", "Apply Color"], &[]), + command("color.applyColor", "Apply Color", &[], &[]).with_import_path(vec!["Tools".into(), "Apply Color".into()]), + ]; + let d = "Default"; + let entries = vec![win("Tools", &["Apply Color"], &[(d, "Ctrl+,")])]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "color.applyColor", &["Cmd+,"]); + has(&km, "object.color", &[]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + #[test] fn show_and_hide_are_read_off_only_for_toggles() { let catalog = vec![ From a03f6ab0c3efbad9eac65180b05c2796deaf93df Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:05:56 +0000 Subject: [PATCH 27/33] keymap ui: app errors in the dialog, and the reader's dropped entries DialogState::show_error shows an app's message under the table until the user dismisses it (a new Dismiss button on every message) or the next action replaces it. ForeignSet::truncated carries the entries the app's reader dropped; plan counts them in ImportReport::truncated, which the import review shows. --- crates/keymap/README.md | 7 ++++++- crates/keymap/src/import.rs | 9 ++++++--- crates/keymap/src/lib.rs | 1 + crates/keymap/src/ui/state.rs | 14 +++++++++++++- crates/keymap/src/ui/text.rs | 1 + crates/keymap/src/ui/view.rs | 7 +++++++ crates/keymap/tests/import.rs | 31 ++++++++++++++++++++++-------- crates/keymap/tests/profiles.rs | 6 +++++- crates/keymap/tests/ui_dialog.rs | 11 +++++++++++ crates/keymap/tests/ui_snapshot.rs | 1 + 10 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index b9291572..d8500ea1 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -226,6 +226,7 @@ let set = ForeignSet { ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; let planned = plan(&set, &keymap, &options); @@ -272,6 +273,8 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File they are reported, never applied. Only the first 200 unmatched entries with keys get them, and only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. +- `truncated` counts what was cut at the caps (`MAX_IMPORT_ENTRIES` and the others), plus the + entries the app's reader dropped at its own limits, which it passes as `ForeignSet.truncated`. ## Editor @@ -364,7 +367,9 @@ Outcomes: | `Summary { name, html, text }` | Shows, saves or prints the summary. | | `Remembered(answers)` | Keeps the user's import answers for the next import (`ImportOptions::remembered`). | -The crate never opens a file dialog or touches the file system. +The crate never opens a file dialog or touches the file system. When the app's own step fails +while the dialog is open (a file it cannot read), `state.show_error(text)` shows the translated +message under the table until the user dismisses it or the next action replaces it. Key dispatch while the dialog is open: diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 5277c9c8..560eeb88 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -34,6 +34,9 @@ pub const MAX_SUGGESTIONS: usize = 200; pub struct ForeignSet { pub name: Option, pub entries: Vec, + /// Entries the reader dropped at its own limits; [`plan`] counts them in + /// [`ImportReport::truncated`]. + pub truncated: usize, } /// One item of a foreign set. @@ -411,8 +414,8 @@ pub struct ImportReport { pub unreadable: Vec, pub approximated: Vec, pub suggestions: Vec, - /// Entries, keys, path segments, bindings and text fields cut at the caps, and unmatched - /// entries past [`MAX_SUGGESTIONS`] that got no suggestion. + /// Entries, keys, path segments, bindings and text fields cut at the caps, unmatched entries + /// past [`MAX_SUGGESTIONS`] that got no suggestion, and [`ForeignSet::truncated`]. pub truncated: usize, } @@ -435,7 +438,7 @@ fn toggle_name(s: &str) -> String { pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> ImportPlan { let mut cut = 0usize; let mut report = ImportReport { set: set.name.as_deref().map(|n| cap_text(n, &mut cut)), ..ImportReport::default() }; - cut += set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES); + cut = cut.saturating_add(set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES)).saturating_add(set.truncated); let index = Index::new(keymap); let contexts: Vec<(String, &ContextRule)> = options.contexts.iter().map(|(n, r)| (n.to_lowercase(), r)).collect(); let mut commands: Vec = Vec::new(); diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index c13aab47..fb9a86a9 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -124,6 +124,7 @@ //! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), //! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), //! ], +//! truncated: 0, //! }; //! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; //! let planned = plan(&set, &keymap, &options); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 83678353..7b2a59c3 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -289,6 +289,13 @@ impl DialogState { self.field = None; } + /// Shows `text`, already translated, as a warning under the table until the user dismisses it + /// or the next action's message replaces it: for the app's own errors while the dialog is + /// open, such as an import file it could not read. + pub fn show_error(&mut self, text: impl Into) { + self.message = Some(Message { text: text.into(), refused: true, goto: None, undo: false, chord: None }); + } + // ---- Frame ---- /// The first frame: snapshot for Cancel, an edit session, the Show entries. @@ -1462,7 +1469,11 @@ mod tests { assert!(matches!(st.finish(), Some(Outcome::Import { .. }))); assert!(fx.km.overrides().is_empty() && !st.can_undo()); let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); - let set = ForeignSet { name: Some("Theirs".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))] }; + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))], + truncated: 0, + }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); st.import_reviewed(&mut fx.env()); @@ -1543,6 +1554,7 @@ mod tests { ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index d248c5c5..12dd0941 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -235,6 +235,7 @@ pub const STRINGS: &[&str] = &[ "Keep Both", "Go to Conflict", "Cancel the new shortcut", + "Dismiss", // Saved shortcuts that could not be used. "{key}: no command has this name", "{key}: “{text}” is not a shortcut ({error})", diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 57b32420..40ac6861 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -1315,10 +1315,17 @@ impl Draw<'_, '_> { if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { act = 2; } + let label = self.ui("Dismiss"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.message.dismiss".into(), r.rect, &label); + if r.clicked() { + act = 3; + } }); match (act, &m.goto) { (1, Some(c)) => self.state.go_to(c), (2, _) => self.state.undo(self.env), + (3, _) => self.state.message = None, _ => {} } } diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 77346e14..e87f89e7 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -110,11 +110,11 @@ fn windows_set() -> ForeignSet { entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), ]; - ForeignSet { name: Some("My Set".into()), entries } + ForeignSet { name: Some("My Set".into()), entries, truncated: 0 } } fn numpad_set() -> ForeignSet { - ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)] } + ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)], truncated: 0 } } fn windows_options() -> ImportOptions { @@ -262,6 +262,7 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), ], + truncated: 0, }; let text = Scope::new("Text").unwrap(); let options = ImportOptions { @@ -282,7 +283,8 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { #[test] fn start_and_none_defined_options() { - let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])] }; + let set = + ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])], truncated: 0 }; let user = || { let mut o = Overrides::new(); o.insert("file.save", vec!["Cmd+Alt+S".into()]); @@ -318,6 +320,7 @@ fn changes_that_find_the_overrides_full_are_reported() { win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), win("Edit", &["Duplicate"], &[]), ], + truncated: 0, }; let full = || { let mut o = Overrides::new(); @@ -373,6 +376,7 @@ fn changes_that_find_the_overrides_full_are_reported() { let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])], + truncated: 0, }; let mut km = keymap(Platform::Windows).with_overrides(full()); let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); @@ -385,7 +389,11 @@ fn changes_that_find_the_overrides_full_are_reported() { #[test] fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { - let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])] }; + let set = ForeignSet { + name: None, + entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])], + truncated: 0, + }; let options = ImportOptions { contexts: vec![ ("Default".into(), ContextRule::Home), @@ -409,7 +417,7 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { #[test] fn suggestions_are_reported_never_applied() { let mut km = keymap(Platform::Windows); - let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])] }; + let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])], truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let p = plan(&set, &km, &options); assert!(p.commands.is_empty()); @@ -422,6 +430,12 @@ fn suggestions_are_reported_never_applied() { assert_eq!(r.imported, 0); } +#[test] +fn entries_the_reader_dropped_count_as_truncated() { + let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])], truncated: 7 }; + assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7); +} + #[test] fn hostile_sets_are_capped_and_counted() { let mut km = keymap(Platform::Windows); @@ -439,7 +453,7 @@ fn hostile_sets_are_capped_and_counted() { // Entries without keys fill the set to the cap and 500 past it. let keyless = MAX_IMPORT_ENTRIES - entries.len(); entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); - let set = ForeignSet { name: Some(long.clone()), entries }; + let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); @@ -482,7 +496,7 @@ fn hostile_sets_finish_in_time() { } entries.insert(0, big); entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); - let set = ForeignSet { name: Some(long.clone()), entries }; + let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let started = std::time::Instant::now(); let r = plan(&set, &km, &options).apply(&mut km); @@ -515,6 +529,7 @@ fn rows_carry_source_lines_and_approximated_keys() { win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); @@ -591,7 +606,7 @@ fn report_json_uses_camel_case_names() { fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); - let report = plan(&ForeignSet { name: None, entries }, &km, options).apply(&mut km); + let report = plan(&ForeignSet { name: None, entries, truncated: 0 }, &km, options).apply(&mut km); (km, report) } diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index ce94abfd..d9ce152c 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -162,7 +162,11 @@ fn duplicate_copies_built_in_and_user_sets() { fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { let km = keymap(); let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); - let set = ForeignSet { name: Some("Other App".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))] }; + let set = ForeignSet { + name: Some("Other App".into()), + entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))], + truncated: 0, + }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; let planned = plan(&set, &km, &options); let builtins = [("Craft", Overrides::new())]; diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 52d6ee78..d393a609 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -733,6 +733,7 @@ fn foreign_set() -> ForeignSet { ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), ], + truncated: 0, } } @@ -988,3 +989,13 @@ fn pseudo_locale_marks_every_string_and_fits() { assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); } } + +#[test] +fn an_app_error_shows_until_dismissed() { + let mut h = mac(); + h.state_mut().state.show_error("The file could not be read"); + h.run(); + assert!(has(&h, "⚠ The file could not be read")); + click(&mut h, "Dismiss"); + assert!(!has(&h, "⚠ The file could not be read")); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index 9b90383a..ba27ab05 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -126,6 +126,7 @@ fn foreign_review(h: &mut Harness<'_, Fx>) { ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let (d, state) = h.state_mut().dialog(); From 1aef62823a4a3466d2289b97142ee8bc7d486a30 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:06:07 +0000 Subject: [PATCH 28/33] keymap ui: no command id in the row tooltip; say who draws the title The detail pane already shows the command id. Options::title is drawn by show(); with ui() the host's container draws its own title. --- crates/keymap/src/ui/mod.rs | 7 ++++--- crates/keymap/src/ui/view.rs | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 1eb67ab0..866b1024 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -103,7 +103,8 @@ pub type OnWidget = Box; /// set fields on it, or with [`Options::with`]. #[non_exhaustive] pub struct Options { - /// "Keyboard Shortcuts". + /// "Keyboard Shortcuts": the window title [`ShortcutsDialog::show`] draws, and the summary's + /// heading. [`ShortcutsDialog::ui`] draws no title; the host's container shows its own. pub title: &'static str, /// true; false shows the table read-only with Close and Summarize. pub editable: bool, @@ -251,8 +252,8 @@ impl<'a> ShortcutsDialog<'a> { }) } - /// The body only, for a host's own modal, preferences page or backstage. It fills the - /// available rectangle. + /// The body only, for a host's own modal, preferences page or backstage, which draws the + /// title. It fills the available rectangle. pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { self.with_env(|env| frame(ui, env, state)) } diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 40ac6861..ae8ba041 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -960,7 +960,6 @@ impl Draw<'_, '_> { if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { tip.push(self.path_text(path)); } - tip.push(r.command.id.clone()); if let Some(h) = self.env.text.hint(b) { tip.push(self.vis(&h)); } From 5a8f119a70d59c050b98bde02a0a998f9ff37985 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:30:40 +0000 Subject: [PATCH 29/33] keymap ui: test that one pointer click on another row's chip records --- crates/keymap/tests/ui_dialog.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index d393a609..35c69914 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -999,3 +999,19 @@ fn an_app_error_shows_until_dismissed() { click(&mut h, "Dismiss"); assert!(!has(&h, "⚠ The file could not be read")); } + +#[test] +fn one_pointer_click_on_a_chip_of_another_row_records() { + let mut h = mac(); + click(&mut h, "Save"); + let c = h.get_by_label("Export…: ⌘E").rect().center(); + let at = egui::pos2(c.x as f32, c.y as f32); + h.event(Event::PointerMoved(at)); + h.run(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run(); + assert!(has(&h, "Export…: press keys")); +} From f9a65e813f5cb7986fc80b11bc0abfd410f1e224 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:42:25 +0000 Subject: [PATCH 30/33] keymap ui: the chip-click test uses the node's rect as is --- crates/keymap/tests/ui_dialog.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 35c69914..751d60ed 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -1004,8 +1004,7 @@ fn an_app_error_shows_until_dismissed() { fn one_pointer_click_on_a_chip_of_another_row_records() { let mut h = mac(); click(&mut h, "Save"); - let c = h.get_by_label("Export…: ⌘E").rect().center(); - let at = egui::pos2(c.x as f32, c.y as f32); + let at = h.get_by_label("Export…: ⌘E").rect().center(); h.event(Event::PointerMoved(at)); h.run(); for pressed in [true, false] { From b43a39df6536ee96dce44347f85fa8c79cd3ab31 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 18:14:58 +0000 Subject: [PATCH 31/33] keymap ui: the failed-change test sets the import's truncated count --- crates/keymap/tests/import.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 7d6b2a2b..83d96795 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -323,6 +323,7 @@ fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() { win("File", &["Close"], &[("Default", "Ctrl+W")]), win("View", &["Zoom In"], &[("Default", "Ctrl+S")]), ], + truncated: 0, }; let mut o = Overrides::new(); o.insert("file.close", vec!["Cmd+Q".into()]); From 0ccb6a93048dfcd49d861da81e60598308fbd716 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 07:19:55 +0000 Subject: [PATCH 32/33] keymap: ForeignSet is non_exhaustive and built with ForeignSet::new, with_name and with_truncated --- crates/keymap/README.md | 20 ++++--- crates/keymap/src/import.rs | 22 ++++++- crates/keymap/src/lib.rs | 13 ++--- crates/keymap/src/ui/state.rs | 19 ++---- crates/keymap/tests/import.rs | 92 +++++++++++------------------- crates/keymap/tests/profiles.rs | 6 +- crates/keymap/tests/ui_dialog.rs | 19 +++--- crates/keymap/tests/ui_snapshot.rs | 19 +++--- 8 files changed, 94 insertions(+), 116 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index d8500ea1..cc25b79a 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -206,7 +206,9 @@ assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap( The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an [`import::ForeignSet`](src/import.rs): entries with a menu, a path and keys read by -`ForeignKey::read`. `import::plan` matches each entry to one command and changes nothing; +`ForeignKey::read`. The reader builds it with `ForeignSet::new(entries)`, `with_name` and +`with_truncated`; the struct is `#[non_exhaustive]`, so struct literals outside the crate do not +compile. `import::plan` matches each entry to one command and changes nothing; `ImportPlan::apply` applies the plan and returns an `ImportReport` (serde, camelCase). Report rows carry the entry's `item` (its path) and `line`. @@ -220,14 +222,11 @@ let save = Bindable::new("file.save", "Save", Scope::app()) let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows); let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); -let set = ForeignSet { - name: Some("My Set".into()), - entries: vec![ - ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), - ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), - ], - truncated: 0, -}; +let set = ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), +]) +.with_name("My Set"); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; let planned = plan(&set, &keymap, &options); // A review UI can show planned.commands and planned.report here. @@ -328,6 +327,9 @@ Adopt it in three steps: for the app's own modal or preferences page) every frame. 3. Act on the `Outcome` it returns, and drop the state when `is_open()` turns false. +Apps that import other applications' sets read each file into a `ForeignSet` built with +`ForeignSet::new` ([Importing a shortcut set](#importing-a-shortcut-set)). + ```rust,ignore use craft_keymap::ui::{DialogState, I18nText, Options, Outcome, ShortcutsDialog}; diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index e941c4b2..2cb03ddc 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -29,8 +29,9 @@ pub const MAX_PATH_SEGMENTS: usize = 32; /// in [`ImportReport::truncated`]. Entries without keys get no suggestion and do not count. pub const MAX_SUGGESTIONS: usize = 200; -/// A shortcut set as an app's reader found it. +/// A shortcut set as an app's reader found it. Build it with [`ForeignSet::new`]. #[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] pub struct ForeignSet { pub name: Option, pub entries: Vec, @@ -62,6 +63,25 @@ pub struct ForeignEntry { pub panel_menu: bool, } +impl ForeignSet { + /// A set without a name. + pub fn new(entries: Vec) -> ForeignSet { + ForeignSet { entries, ..ForeignSet::default() } + } + + /// The set's name as the source gives it. + pub fn with_name(mut self, name: impl Into) -> ForeignSet { + self.name = Some(name.into()); + self + } + + /// Entries the reader dropped at its own limits ([`ForeignSet::truncated`]). + pub fn with_truncated(mut self, truncated: usize) -> ForeignSet { + self.truncated = truncated; + self + } +} + impl ForeignEntry { /// An entry without keys. pub fn new>(menu: Option<&str>, path: impl IntoIterator) -> ForeignEntry { diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index fb9a86a9..d5a82abb 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -118,14 +118,11 @@ //! let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows); //! //! let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); -//! let set = ForeignSet { -//! name: Some("My Set".into()), -//! entries: vec![ -//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), -//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), -//! ], -//! truncated: 0, -//! }; +//! let set = ForeignSet::new(vec![ +//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), +//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), +//! ]) +//! .with_name("My Set"); //! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; //! let planned = plan(&set, &keymap, &options); //! assert_eq!(planned.commands.len(), 1); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 7b2a59c3..f83b4392 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -1469,11 +1469,7 @@ mod tests { assert!(matches!(st.finish(), Some(Outcome::Import { .. }))); assert!(fx.km.overrides().is_empty() && !st.can_undo()); let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); - let set = ForeignSet { - name: Some("Theirs".into()), - entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))], - truncated: 0, - }; + let set = ForeignSet::new(vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))]).with_name("Theirs"); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); st.import_reviewed(&mut fx.env()); @@ -1548,14 +1544,11 @@ mod tests { let mut fx = Fx::new(Options::default()); let mut st = open(&mut fx); let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); - let set = ForeignSet { - name: Some("Theirs".into()), - entries: vec![ - ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), - ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ]) + .with_name("Theirs"); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); let r = st.review.as_ref(); diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 83d96795..60a9f321 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -110,11 +110,11 @@ fn windows_set() -> ForeignSet { entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), ]; - ForeignSet { name: Some("My Set".into()), entries, truncated: 0 } + ForeignSet::new(entries).with_name("My Set") } fn numpad_set() -> ForeignSet { - ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)], truncated: 0 } + ForeignSet::new(vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)]) } fn windows_options() -> ImportOptions { @@ -254,16 +254,13 @@ fn the_set_wins_and_the_report_says_how() { fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { let mut km = keymap(Platform::Mac); let mac = |menu: &str, path: &[&str], keys: &[(&str, &str)]| entry(Some(menu), path, keys, Flavor::Mac); - let set = ForeignSet { - name: Some("Mac".into()), - entries: vec![ - mac("File", &["Save"], &[("Default", "⇧⌘S")]), - mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]), - mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), - mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + mac("File", &["Save"], &[("Default", "⇧⌘S")]), + mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]), + mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), + mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), + ]) + .with_name("Mac"); let text = Scope::new("Text").unwrap(); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home), ("Text".into(), ContextRule::Scope(text))], @@ -283,8 +280,7 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { #[test] fn start_and_none_defined_options() { - let set = - ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])], truncated: 0 }; + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])]); let user = || { let mut o = Overrides::new(); o.insert("file.save", vec!["Cmd+Alt+S".into()]); @@ -316,15 +312,11 @@ fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() { // Save's change finds the overrides full. Close's change then frees an entry, and Zoom In // takes Cmd+S. Removing Cmd+S from Save would fit now, but Save's import failed, so Save // keeps it and Zoom In stays as it was. - let set = ForeignSet { - name: None, - entries: vec![ - win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), - win("File", &["Close"], &[("Default", "Ctrl+W")]), - win("View", &["Zoom In"], &[("Default", "Ctrl+S")]), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("File", &["Close"], &[("Default", "Ctrl+W")]), + win("View", &["Zoom In"], &[("Default", "Ctrl+S")]), + ]); let mut o = Overrides::new(); o.insert("file.close", vec!["Cmd+Q".into()]); o.insert("view.zoomIn", vec!["Cmd+Alt+Z".into()]); @@ -346,15 +338,11 @@ fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() { #[test] fn changes_that_find_the_overrides_full_are_reported() { - let set = ForeignSet { - name: None, - entries: vec![ - win("File", &["Close"], &[("Default", "Ctrl+J")]), - win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), - win("Edit", &["Duplicate"], &[]), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + win("File", &["Close"], &[("Default", "Ctrl+J")]), + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("Edit", &["Duplicate"], &[]), + ]); let full = || { let mut o = Overrides::new(); o.insert("file.close", vec!["Cmd+Q".into()]); @@ -406,11 +394,7 @@ fn changes_that_find_the_overrides_full_are_reported() { has(&km, "edit.duplicate", &[]); // A later entry whose change finds the overrides full leaves the chord with the earlier one. - let set = ForeignSet { - name: None, - entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])], - truncated: 0, - }; + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])]); let mut km = keymap(Platform::Windows).with_overrides(full()); let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); has(&km, "file.close", &["Cmd+Alt+J"]); @@ -422,11 +406,7 @@ fn changes_that_find_the_overrides_full_are_reported() { #[test] fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { - let set = ForeignSet { - name: None, - entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])], - truncated: 0, - }; + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])]); let options = ImportOptions { contexts: vec![ ("Default".into(), ContextRule::Home), @@ -450,7 +430,7 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { #[test] fn suggestions_are_reported_never_applied() { let mut km = keymap(Platform::Windows); - let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])], truncated: 0 }; + let set = ForeignSet::new(vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])]); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let p = plan(&set, &km, &options); assert!(p.commands.is_empty()); @@ -465,7 +445,7 @@ fn suggestions_are_reported_never_applied() { #[test] fn entries_the_reader_dropped_count_as_truncated() { - let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])], truncated: 7 }; + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])]).with_truncated(7); assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7); } @@ -486,7 +466,7 @@ fn hostile_sets_are_capped_and_counted() { // Entries without keys fill the set to the cap and 500 past it. let keyless = MAX_IMPORT_ENTRIES - entries.len(); entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); - let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; + let set = ForeignSet::new(entries).with_name(long.clone()); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); @@ -529,7 +509,7 @@ fn hostile_sets_finish_in_time() { } entries.insert(0, big); entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); - let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; + let set = ForeignSet::new(entries).with_name(long.clone()); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let started = std::time::Instant::now(); let r = plan(&set, &km, &options).apply(&mut km); @@ -553,17 +533,13 @@ fn hostile_sets_finish_in_time() { fn rows_carry_source_lines_and_approximated_keys() { let mut km = keymap(Platform::Windows); let d = "Default"; - let set = ForeignSet { - name: None, - entries: vec![ - win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3), - win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4), - win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5), - win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), - win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3), + win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4), + win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5), + win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), + ]); let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); // A keypad key binds the main key and is reported. Paste, a later entry, takes Cmd+5. @@ -639,7 +615,7 @@ fn report_json_uses_camel_case_names() { fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); - let report = plan(&ForeignSet { name: None, entries, truncated: 0 }, &km, options).apply(&mut km); + let report = plan(&ForeignSet::new(entries), &km, options).apply(&mut km); (km, report) } diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index d9ce152c..e3b789b8 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -162,11 +162,7 @@ fn duplicate_copies_built_in_and_user_sets() { fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { let km = keymap(); let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); - let set = ForeignSet { - name: Some("Other App".into()), - entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))], - truncated: 0, - }; + let set = ForeignSet::new(vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))]).with_name("Other App"); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; let planned = plan(&set, &km, &options); let builtins = [("Craft", Overrides::new())]; diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 751d60ed..59d68b8c 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -724,17 +724,14 @@ fn set_export_import_round_trip() { fn foreign_set() -> ForeignSet { let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); - ForeignSet { - name: Some("Theirs".into()), - entries: vec![ - ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), - ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), - ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), - ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), - ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), - ], - truncated: 0, - } + ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs") } fn review(h: &mut Harness<'_, Fx>) { diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index ba27ab05..cbb64482 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -117,17 +117,14 @@ fn fonts() -> egui::FontDefinitions { fn foreign_review(h: &mut Harness<'_, Fx>) { let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); - let set = ForeignSet { - name: Some("Theirs".into()), - entries: vec![ - ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), - ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), - ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), - ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), - ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), - ], - truncated: 0, - }; + let set = ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs"); let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let (d, state) = h.state_mut().dialog(); d.review_import(state, set, options); From fe4a4450f7d994435580477bce7ac9803fb741f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 07:28:24 +0000 Subject: [PATCH 33/33] keymap: compile Keymap::twins_in only with the ui feature, its one user --- crates/keymap/src/keymap.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 8c026945..33028d6f 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -787,6 +787,7 @@ impl Keymap { /// Whether both commands of `conflict` have its chord among their defaults, so they share it by /// design and [`Keymap::clashing`] leaves them out. + #[cfg(feature = "ui")] pub(crate) fn twins_in(&self, conflict: &Conflict) -> bool { let (Some(i), Some(j)) = (self.find(&conflict.a.0), self.find(&conflict.b.0)) else { return false }; self.twins(i, j, conflict.chord)