diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 2f0bdb9f..bf589f66 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -3,9 +3,9 @@ name: Keymap on: push: branches: [main] - paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] pull_request: - paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] workflow_dispatch: permissions: @@ -32,12 +32,14 @@ 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. + # `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 - 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 }}) @@ -54,7 +56,7 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 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 @@ -69,4 +71,4 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 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 diff --git a/Cargo.lock b/Cargo.lock index 8748c466..a1445b5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,8 +841,12 @@ dependencies = [ name = "craft-keymap" version = "0.1.0" dependencies = [ + "craft-i18n", "craft-settings", + "craft-ui", "egui", + "egui_kittest", + "image", "serde", "serde_json", "tempfile", diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 2c3dcc41..bfc1b56f 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -14,12 +14,21 @@ 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] -# For setting `RawInput` fields only the suite's egui fork has. -egui = { workspace = true, features = ["serde"] } +# `serde` for setting `RawInput` fields only the suite's egui fork has; `default_fonts` for the +# dialog's tests. +egui = { workspace = true, features = ["serde", "default_fonts"] } craft-settings = { workspace = true } tempfile = "3" +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/README.md b/crates/keymap/README.md index 8982dce8..1e07920d 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -1,14 +1,15 @@ # 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 catalog, -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 catalog, its file-format readers and its UI. ## Boundaries -- Data and logic only. The apps keep the command catalog (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 catalog (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, @@ -16,7 +17,8 @@ its file-format readers and its UI. - Reserved chords are opt-in. A keymap reserves no chord unless the app passes a list to `Keymap::with_reserved`, e.g. `reserved::system(platform)` for the operating system's window switching and closing chords. A reserved chord cannot be assigned or bound. -- 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 catalog and dispatch @@ -171,11 +173,75 @@ 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. +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`); + `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 + 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`). +- 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. + +```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 [`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`. @@ -189,13 +255,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), - ], -}; +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. @@ -211,7 +275,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()`. @@ -239,6 +305,124 @@ 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 + +`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 catalog 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); +``` + +## Shortcuts dialog + +With the `ui` feature, `ui::ShortcutsDialog` draws the Keyboard Shortcuts dialog over a `Keymap` +and, optionally, `Profiles`: a table of the catalog 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 catalog 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. + +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}; + +// "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(), + // 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), + 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 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. | +| `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. 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: + +- 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 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, 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 @@ -276,3 +460,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File typing, and stays when that chord has no binding. egui-winit sends no Paste event when the clipboard is empty or holds an image, so a binding on a Cmd+V chord cannot run then on the desktop. + +- `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/chord.rs b/crates/keymap/src/chord.rs index 66651de3..3d079868 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -254,7 +254,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 => "↓", @@ -270,7 +270,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/editor.rs b/crates/keymap/src/editor.rs new file mode 100644 index 00000000..e71f687c --- /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 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(catalog, 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 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 + .catalog() + .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 f7c2559e..16a28ddb 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -120,8 +120,7 @@ pub fn matches(binding: &Chord, key: EguiKey, mods: &Modifiers, platform: Platfo Layout::new().matches(binding, &KeyInput::new(Mods::from_egui(mods, platform)).with_typed(pressed), platform) } -/// 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)] @@ -333,7 +332,7 @@ impl Dispatcher { Some(true) => continue, Some(false) => {} None => { - let (key, held) = clipboard_chord(&event, frame_mods); + let (key, held, _) = clipboard_chord(&event, frame_mods); // A focused text field copies and pastes on the plain chords itself. let look = !is_plain_clipboard(key, held) || (self.clipboard && !active.typing); let hit = if look { self.press(&KeyInput::new(held).with_typed(key), false, now, keymap, active) } else { None }; @@ -403,7 +402,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, @@ -503,7 +502,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), @@ -526,17 +525,17 @@ fn is_plain_clipboard(key: Key, held: Mods) -> bool { } } -/// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also -/// sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { +/// 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. +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 { - Event::Cut if shift_only => (Key::Delete, held), - Event::Cut => (Key::X, with_cmd), - Event::Paste(_) if shift_only => (Key::Insert, held), - Event::Paste(_) => (Key::V, with_cmd), - _ => (Key::C, with_cmd), + Event::Cut if shift_only => (Key::Delete, held, Clip::Cut), + Event::Cut => (Key::X, with_cmd, Clip::Cut), + Event::Paste(_) if shift_only => (Key::Insert, held, Clip::Paste), + Event::Paste(_) => (Key::V, with_cmd, Clip::Paste), + _ => (Key::C, with_cmd, Clip::Copy), } } diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 87d41048..2cb03ddc 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -29,11 +29,15 @@ 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, + /// Entries the reader dropped at its own limits; [`plan`] counts them in + /// [`ImportReport::truncated`]. + pub truncated: usize, } /// One item of a foreign set. @@ -59,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 { @@ -411,8 +434,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 +458,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(); @@ -867,7 +890,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); @@ -943,7 +966,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); } @@ -1007,7 +1030,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 fff50a2e..33028d6f 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 @@ -774,6 +785,14 @@ 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. + #[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) + } + fn find(&self, command: &CommandRef) -> Option { self.index.get(&command.key()).copied() } diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 7493a998..f121091d 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,11 +6,13 @@ //! 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 //! catalog with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes; a keymap -//! reserves no chord unless the app opts in with [`Keymap::with_reserved`], e.g. -//! `with_reserved(reserved::system(platform))`. [`import`] brings in other applications' shortcut -//! sets. [`layout`] decides which chord a key press means on the user's keyboard layout, for -//! matching, recording and labels. With the `egui` feature, the `egui` module +//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the +//! chords each operating system takes, and a keymap reserves no chord unless the app opts in with +//! [`Keymap::with_reserved`], e.g. `with_reserved(reserved::system(platform))`. [`import`] brings +//! in other applications' shortcut sets. [`layout`] decides which chord a key press means on the +//! user's keyboard layout, for matching, recording and labels. [`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. //! @@ -99,6 +101,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 @@ -114,13 +120,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), -//! ], -//! }; +//! 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); @@ -133,21 +137,27 @@ pub mod chord; pub mod command; +pub mod editor; #[cfg(feature = "egui")] pub mod egui; pub mod import; pub mod keymap; pub mod layout; +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}; 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, text_editing_chords, }; pub use layout::{KeyInput, Layout}; +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 new file mode 100644 index 00000000..df9634db --- /dev/null +++ b/crates/keymap/src/profiles.rs @@ -0,0 +1,562 @@ +//! 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::ser::SerializeStruct; +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; +/// 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. +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, + 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 {} + +/// 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 +/// 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. +/// +/// 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 { + 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. +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 { + /// 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() + } + + 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); + } + // 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) { + 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) + } + + /// 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) + } + + /// `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) + } +} + +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. +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 })) + } +} + +/// 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) +} + +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(Loaded::default()) + })* + }; +} + +impl<'de> Visitor<'de> for SetsVisitor { + 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") + } + + 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(Loaded::default()) + } + + fn visit_none(self) -> Result { + Ok(Loaded::default()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(SetsVisitor) + } + + fn visit_seq>(self, mut access: A) -> Result { + let mut out = Loaded::default(); + while let Some(item) = access.next_element::()? { + 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.done()) + } + + fn visit_map>(self, mut access: A) -> Result { + let mut out = Loaded::default(); + while let Some(key) = access.next_key::()? { + 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.done()) + } +} + +/// 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/src/ui/capture.rs b/crates/keymap/src/ui/capture.rs new file mode 100644 index 00000000..612458fc --- /dev/null +++ b/crates/keymap/src/ui/capture.rs @@ -0,0 +1,102 @@ +//! 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)); +} + +/// 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), +/// 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/keyboard.rs b/crates/keymap/src/ui/keyboard.rs new file mode 100644 index 00000000..f01ed522 --- /dev/null +++ b/crates/keymap/src/ui/keyboard.rs @@ -0,0 +1,639 @@ +//! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout +//! 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; +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.catalog() { + 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 { fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &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.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); + } + 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_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() { + 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; + 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| { + 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), 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/mod.rs b/crates/keymap/src/ui/mod.rs new file mode 100644 index 00000000..866b1024 --- /dev/null +++ b/crates/keymap/src/ui/mod.rs @@ -0,0 +1,309 @@ +//! 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`] +//! (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}; + +mod capture; +mod rows; +mod state; +mod style; +pub mod summary; +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; + +/// 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, + /// 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. + 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)] +#[non_exhaustive] +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 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 catalog 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], +} + +/// 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; +/// set fields on it, or with [`Options::with`]. +#[non_exhaustive] +pub struct Options { + /// "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, + /// "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 catalog 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, + /// `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, + 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(), + visible: None, + keyboard: false, + fixed: Vec::new(), + import_formats: Vec::new(), + export_formats: Vec::new(), + summary: true, + accept: None, + on_widget: None, + style: None, + } + } +} + +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"; + +/// 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, 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 { + 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)); + 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_showing() { + state.cancel(env); + out = state.finish(); + } + if state.change_pending() { + ctx.request_repaint(); + } + out + }) + } + + /// 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)) + } + + /// 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); + }); + } +} + +/// 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_showing() { + view::body(ui, env, state); + } + let out = state.finish(); + if state.change_pending() { + ui.ctx().request_repaint(); + } + out +} diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs new file mode 100644 index 00000000..61ef3da7 --- /dev/null +++ b/crates/keymap/src/ui/review.rs @@ -0,0 +1,378 @@ +//! 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::{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 color, not dimmed. + to: bool, + /// Chord text inside the note, kept out of bidi reordering. + note_chord: String, +} + +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 color. + 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 text = ui.visuals().text_color(); + let mut parts = vec![(self.vis(&e.item), strong)]; + if let Some(line) = self.line_text(e.line) { + parts.extend([(" · ".to_string(), dim), (self.vis(&line), dim)]); + } + if !e.keys.is_empty() { + parts.extend([(" · ".to_string(), dim), (e.keys.clone(), text)]); + } + 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()); + } + + 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(&fill(self.ui("{title} ({n})"), &[("title", title), ("n", &entries.len().to_string())]))).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| { + 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); + + if !report.suggestions.is_empty() { + ui.add_space(4.0); + 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( + 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| { + 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, + 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, + 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, + note_chord: String::new(), + }) + .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, + note_chord: String::new(), + }) + .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"), + } + } + + /// 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, + 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, + 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, + note_chord: String::new(), + }) + .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 new file mode 100644 index 00000000..6b17b67f --- /dev/null +++ b/crates/keymap/src/ui/rows.rs @@ -0,0 +1,692 @@ +//! 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, visual_around}; + +/// 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 (display order). + pub text: String, + /// Not one of the command's defaults. + pub changed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Row { + /// Position in the catalog. + 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 catalog 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 + } + + /// The lines last built. + pub(crate) fn built(&self) -> &Built { + &self.built + } +} + +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 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 = 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.clone().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 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.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.clone().any(|b| b.scope.as_str() == *p)) { + out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); + } + 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.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() { + 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, capitalized: `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. +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 { + 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 catalog = keymap.catalog(); + let mut built = Built::default(); + let mut clashing: HashSet = HashSet::new(); + for c in keymap.conflicts().into_iter().filter(|c| !keymap.twins_in(c)) { + 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> = 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 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; + } + } + } + let submenus = match view.area { + 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() { + 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(); + 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 { + 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) } + }) + .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 + .catalog() + .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 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")), + 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(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> { + 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 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); + 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()]); + 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); + 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/sets.rs b/crates/keymap/src/ui/sets.rs new file mode 100644 index 00000000..7b4cd479 --- /dev/null +++ b/crates/keymap/src/ui/sets.rs @@ -0,0 +1,212 @@ +//! 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 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 items = self.set_items(builtin, modified); + let mut act = None; + 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)); + let mut pick = None; + 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()); + } + } + }); + 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 export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; + 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)); + } + 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 new file mode 100644 index 00000000..f83b4392 --- /dev/null +++ b/crates/keymap/src/ui/state.rs @@ -0,0 +1,1564 @@ +//! 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, 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, 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}; +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. +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) => self.ui("{label} ({scope})", &[("label", &label), ("scope", &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, + /// The chord text inside `text`, which is never put through `Text::visual`. + pub chord: Option, +} + +/// 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)>, +} + +/// 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, + 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, + // ---- 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, + pub(crate) set_form: Option, + pub(crate) keyboard: KeyboardState, +} + +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, + split: 0.62, + chip: 0, + typing: None, + type_error: None, + bottom_h: 0.0, + text_focus: false, + visible: 0..0, + set_form: None, + keyboard: KeyboardState::default(), + } + } + + /// 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 + } + + /// 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; + } + + /// 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. + 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. An edit + /// that comes with another outcome stays pending and is reported by the next call. + pub(crate) fn finish(&mut self) -> Option { + 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. + 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) + } + + /// 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) { + if self.selected.as_ref() != Some(command) { + self.selected = Some(command.clone()); + self.context = None; + self.field = None; + self.chip = 0; + self.typing = None; + self.type_error = 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.catalog() { + 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, 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| binding_error(env, &e))?; + 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_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)) + && 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, 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 { + 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_chord(text, chord); + 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, chord: Some(chord) }); + } + + /// 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. + /// 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) { + self.phase = Phase::Closed; + self.recording = None; + self.conflict = None; + self.prompt = None; + // Ok and Cancel cover every edit. + self.changed = false; + 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.set_form.is_some() { + self.set_form = 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); + } + } +} + +impl DialogState { + // ---- 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)?; + 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; + } + + /// 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>) { + 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.prompt = None; + 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. 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); + } 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 } => { + // 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), + }; + 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); + } + + 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() { + 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())); + } + } + } + + /// 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)) => { + let was_active = p.active_name(b) == name; + p.delete(name, b).map(|()| was_active) + } + None => Err(ProfileError::NotFound), + }; + 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. + 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 + } + + /// 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 }; + 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)), + }, + // 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) => {} + } + } + + // ---- 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))]), + } +} + +/// 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", &[]), + 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!(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] + 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_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope"), Err("unknown key Nope".into())); + } + + #[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()), (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()); + 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 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()), (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(), 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()); + 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 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::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()); + 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 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()); + 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::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(); + 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/style.rs b/crates/keymap/src/ui/style.rs new file mode 100644 index 00000000..5abfc97c --- /dev/null +++ b/crates/keymap/src/ui/style.rs @@ -0,0 +1,54 @@ +//! 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 colors. +#[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 colors 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/summary.rs b/crates/keymap/src/ui/summary.rs new file mode 100644 index 00000000..01603962 --- /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() }; + craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &scope)]) + } + _ => chord, + } +} + +/// 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.catalog() { + 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 new file mode 100644 index 00000000..12dd0941 --- /dev/null +++ b/crates/keymap/src/ui/text.rs @@ -0,0 +1,388 @@ +//! 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 } +} + +/// `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"), + ("{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"), +]; + +/// 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)", + "{label} (cycles)", + "{label} (Changed)", + // Table and detail pane. + "Command", + "Commands", + "Detail pane size", + "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…", + "Context:", + "Assign", + "Use Default", + "Clear", + "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}", + "{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", + "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}", + "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", + "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})", + "{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)", + "New Set…", + "Rename…", + "Delete", + "Save", + "Revert Set", + "Import…", + "Export", + "Export…", + "Summarize…", + "{title}: {set}", + "Create", + "Rename", + "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. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", + "Will change", + "Not matched", + "Unreadable", + "Approximated", + "{title} ({n})", + "Suggestions ({n})", + "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", + "Copy Report", + "Done", +]; + +#[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"), + include_str!("view.rs"), + include_str!("keyboard.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 { + 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"); + } +} diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs new file mode 100644 index 00000000..ae8ba041 --- /dev/null +++ b/crates/keymap/src/ui/view.rs @@ -0,0 +1,1499 @@ +//! 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 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::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, + 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, binding_error}; +use crate::ui::style::{self, DialogStyle}; +use crate::ui::text::{trigger_text, visual_around}; + +#[path = "keyboard.rs"] +pub(crate) mod keyboard; +#[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. +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; +/// 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) { + 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 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 { + visual_around(self.env.text, s, chord) + } + + /// `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()) + } + + 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 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 => fill(self.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &self.scope_name(s))]), + _ => chord, + } + } + + /// 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()) + } + + /// "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, _)) => { + 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"), + } + } + + /// 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() + } + + /// 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)) + } + + /// 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)); + 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 || self.state.modal() { + 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; + 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)); + 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) { + 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); + 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"); + } + }); + 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)) { + 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); + }); + } + + // ---- 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); + } + // 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); + } + self.filter_combo(ui); + if one_row { + self.search(ui, toggle); + } + }); + 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); + } + } else if r.has_focus() { + capture::unlock_focus(ui, r.id); + } + self.keyboard_toggle(ui); + } + + 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(); + 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); + if r.clicked() { + pick = Some(a.id.clone()); + } + } + }); + 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 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 = 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)); + 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, _) = 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); + if resp.clicked() { + pick = Some(a.id.clone()); + } + } + if !fit.overflow.is_empty() { + 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| { + 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 ---- + + /// 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. + 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 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.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) + } + + 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 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 { + 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); + }); + }); + // 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 { + // 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) => { + 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 + } + + /// 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::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); + 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, 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 { + 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 = fill(self.ui("{label} (cycles)"), &[("label", &a11y)]); + } + if r.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)); + 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); + } + let m = self.mirror(rect); + if r.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(); + 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 fmt = |c: Color32| TextFormat::simple(font.clone(), c); + let mut parts = Vec::new(); + if r.conflict { + parts.extend([("⚠".to_string(), fmt(self.style.warning)), (" ".into(), fmt(dim))]); + } + for p in &r.prefix { + parts.extend([(self.vis(p), fmt(dim)), (PATH_SEP.into(), fmt(dim))]); + } + parts.push((self.vis(&r.label), fmt(if r.changed { strong } else { text_color }))); + if r.cycles { + parts.extend([(" ".into(), fmt(dim)), (self.vis(&self.ui("(cycles)")), fmt(dim))]); + } + 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(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()), + _ => 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 = 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 { + 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(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() { + 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 = 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)); + 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 = 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); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); + } + } + if editable { + 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); + 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(self.path_text(path)); + } + 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 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 mut tip = Vec::new(); + if cut { + tip.push(self.vis(&f.label)); + } + match &f.now_used_by { + Some(by) => { + 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)); + } + } + if !tip.is_empty() { + resp.on_hover_text(tip.join("\n")); + } + } + + 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 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()); + } + 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) { 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)); + 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(", ") }; + 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. + 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(self.warned(self.vis(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 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 }; + 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); + } + } else if resp.has_focus() { + capture::unlock_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() => fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(s))]), + _ => label, + }; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.warned(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 }; + let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); + 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") { + 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 = 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() }; + 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; + } + 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, + _ => {} + } + } + + /// 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(self.warned(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 } => { + 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)]), + 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.close")] + }; + let idle = !self.state.modal(); + ui.horizontal(|ui| { + if editable { + if self.button(ui, idle && self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + self.state.undo(self.env); + } + if self.button(ui, idle && self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + self.state.redo(self.env); + } + if self.button(ui, idle && !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + self.state.ask_reset_all(); + } + } + // 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); + } + } + let gap = ui.spacing().item_spacing.x; + 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 { + // 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 { + self.state.cancel(self.env); + } + } + } + }); + } +} + +/// 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 ---- + +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/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..02a44052 --- /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 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(catalog, 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 catalog 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/import.rs b/crates/keymap/tests/import.rs index d2414b57..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 } + 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)] } + ForeignSet::new(vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)]) } fn windows_options() -> ImportOptions { @@ -254,15 +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")]), - ], - }; + 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))], @@ -282,7 +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"], &[])] }; + 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()]); @@ -314,14 +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")]), - ], - }; + 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()]); @@ -343,14 +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"], &[]), - ], - }; + 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()]); @@ -402,10 +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")])], - }; + 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"]); @@ -417,7 +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")])] }; + 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), @@ -441,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")])] }; + 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()); @@ -454,6 +443,12 @@ fn suggestions_are_reported_never_applied() { assert_eq!(r.imported, 0); } +#[test] +fn entries_the_reader_dropped_count_as_truncated() { + 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); +} + #[test] fn hostile_sets_are_capped_and_counted() { let mut km = keymap(Platform::Windows); @@ -471,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 }; + 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); @@ -514,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 }; + 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); @@ -538,16 +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), - ], - }; + 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. @@ -623,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 }, &km, options).apply(&mut km); + let report = plan(&ForeignSet::new(entries), &km, options).apply(&mut km); (km, report) } @@ -708,6 +700,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![ diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..e3b789b8 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,210 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, 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 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(catalog, 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 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"}"#] { + 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 = + 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); + 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.catalog() { + 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::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())]; + 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"); +} + +#[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 c05c8448..2361693e 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}"); + } +} diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs new file mode 100644 index 00000000..59d68b8c --- /dev/null +++ b/crates/keymap/tests/ui_dialog.rs @@ -0,0 +1,1013 @@ +#![cfg(feature = "ui")] +//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, +//! platform display, accessibility labels, small windows, sets, import review and the summary. + +use std::borrow::Cow; + +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, +}; +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::{Pseudo, fixture, with_long_label}; + +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + 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 { + km, + options: Options::default(), + state: DialogState::new(), + text: Box::new(English), + window: false, + sets: None, + 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 canceled(&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, 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, .. }))); + }, + 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() +} + +/// 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").accesskit_node().is_focused_in_tree() +} + +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().canceled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().canceled()); + 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"]); +} + +/// 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] +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(); + // 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(); +} + +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_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")); + 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 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); + let mut km = fixture(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); + 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().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] +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}"); + 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", + "shortcuts.keyboard", + "shortcuts.mod.cmd", + "shortcuts.key.F7", + ] { + 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+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"]); +} + +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:?}"); + 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 ---- + +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().canceled()); + assert_eq!(profiles(&h), &before); + 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()); + 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::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>) { + 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 catalog = km.catalog().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + 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(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 + .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"))); +} + +// ---- 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 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_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"); + 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().canceled()); +} + +// ---- 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} labeled nodes"); +} + +#[test] +fn pseudo_locale_marks_every_string_and_fits() { + let p = Pseudo::mark; + // 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()); + } +} + +#[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")); +} + +#[test] +fn one_pointer_click_on_a_chip_of_another_row_records() { + let mut h = mac(); + click(&mut h, "Save"); + let at = h.get_by_label("Export…: ⌘E").rect().center(); + 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")); +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs new file mode 100644 index 00000000..f2f930cc --- /dev/null +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -0,0 +1,156 @@ +//! The catalog the dialog tests and screenshots use. + +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}")) +} + +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 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")), + 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(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 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(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 +/// 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 new file mode 100644 index 00000000..cbb64482 --- /dev/null +++ b/crates/keymap/tests/ui_snapshot.rs @@ -0,0 +1,257 @@ +#![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` +//! +//! 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, 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::{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)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + 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, + }; + (d, state) + } +} + +/// A named state of the dialog: window size and what to do before the picture. +struct Case { + name: &'static str, + size: Vec2, + platform: Platform, + /// Show named sets. + 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::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); + h.run(); +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn select(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +const CASES: &[Case] = &[ + 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] +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())); + // `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(|| { + 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::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), + 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| { + let (d, state) = fx.dialog(); + d.ui(ui, state); + }); + }, + 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)); + let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name)); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } + } +}