From 70c84ec76c094a78e9b03cfca91e810519ca048d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:24:18 +0000 Subject: [PATCH] keymap: named shortcut sets with JSON export and import, and the shortcut editor model Profiles keeps the user's sets beside the app's built-in ones: switch, duplicate, rename, delete, export one set as JSON and import it back, and import another application's set through an import plan as a new set. The editor model holds an edit session with undo and cancel, conflict queries for steal or keep, a chord recorder with double press, and search. --- crates/keymap/README.md | 83 +++++- crates/keymap/src/editor.rs | 254 +++++++++++++++++++ crates/keymap/src/egui.rs | 3 +- crates/keymap/src/lib.rs | 16 +- crates/keymap/src/profiles.rs | 437 ++++++++++++++++++++++++++++++++ crates/keymap/tests/editor.rs | 200 +++++++++++++++ crates/keymap/tests/profiles.rs | 172 +++++++++++++ crates/keymap/tests/settings.rs | 31 ++- 8 files changed, 1181 insertions(+), 15 deletions(-) create mode 100644 crates/keymap/src/editor.rs create mode 100644 crates/keymap/src/profiles.rs create mode 100644 crates/keymap/tests/editor.rs create mode 100644 crates/keymap/tests/profiles.rs diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 81b55ec9..0d4bd1a0 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -1,9 +1,9 @@ # Craft Keymap `craft-keymap` holds the data and logic for keyboard bindings in the Craft apps: chords and their -text forms, bindings from chords to commands, scopes, conflicts, user overrides, and an import -pipeline for other applications' shortcut sets. Each application supplies its command catalogue, -its file-format readers and its UI. +text forms, bindings from chords to commands, scopes, conflicts, user overrides, named sets, an +import pipeline for other applications' shortcut sets, and an editor model. Each application +supplies its command catalogue, its file-format readers and its UI. ## Boundaries @@ -90,7 +90,7 @@ stay in the entry when the command is edited, unless the edit leaves it without `Keymap::problems` lists them. ```rust -use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; use craft_settings::{Location, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; @@ -99,6 +99,7 @@ use serde::{Deserialize, Serialize}; struct Prefs { #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + shortcut_sets: Profiles, } impl Settings for Prefs { @@ -130,6 +131,42 @@ store.update(|p| p.shortcuts = keymap.overrides().clone()); - `conflicts()` lists every pair of commands on one chord; `conflicts_with(&command)` lists the commands that share one command's chords, with that command as `a`. +## Shortcut sets + +`Profiles` holds the user's named sets of overrides and which one is active. The app's built-in +sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change. +Put `Profiles` next to the `Overrides` in the app's settings type. The sets, and each set's +overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file. + +- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). +- `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, + `rename` and `delete` work on user sets only. +- `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a + built-in set makes a user copy named after `copy_name`, which becomes active. +- New names are made unique (`Custom 2`, `Custom 3`) and compare without case. A user set named + like a built-in added later is renamed by `reconcile`. +- `export(name)` writes one set as JSON + (`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into + a new set. A file from a newer version is refused (`ProfileError::NewerVersion`). +- `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a + copy of the keymap and keeps the result as a new set, so another application's set becomes one + the user can switch to and away from. + +```rust +use craft_keymap::{Overrides, Profiles}; + +let mut classic = Overrides::new(); +classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +let mut sets = Profiles::default(); +assert_eq!(sets.active_name(&builtins), "Craft"); + +sets.duplicate("Classic", "Mine", &builtins).unwrap(); +let text = sets.export("Mine", &builtins).unwrap(); +assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +``` + ## Importing a shortcut set The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an @@ -199,6 +236,44 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. +## Editor + +`editor` is the model behind a shortcut dialog; the app draws it. + +- `EditSession::begin(&keymap)` when the dialog opens. Each change is one undo step: + `assign`, `replace` (record over one key of a command), `remove`, `clear`, `reset`, `reset_all`, + or any keymap edit through `session.edit(&mut keymap, |km| …)`. `undo`, `redo`, `cancel` (back + to the state at open) and `is_modified`. +- Conflicts: `editor::holders(&keymap, &command, &binding)` lists the commands that hold the key + while the user records; `Keymap::conflicts_with(&command)` marks the rows whose keys are + shared after a `Keep`. Assigning with `Policy::Refuse` returns `AssignError::Taken` and changes + nothing; the user's answer is a second call with `Policy::Steal` or `Policy::Keep`. +- `Recorder::press(chord, time)` gives `Single`, `Double` (the same chord again within + `DOUBLE_PRESS`, 0.6 s) or `Cancel` (Escape). +- `editor::search(&keymap, query, platform)` filters the catalogue by label, alias, menu path, + command id and key text, canonical or as shown on `platform`. + +```rust +use craft_keymap::editor::holders; +use craft_keymap::{AssignError, Binding, Chord, EditSession, Policy, Recorded, Recorder}; + +// `keymap` is the app's Keymap, `now` the input time in seconds. +let mut session = EditSession::begin(&keymap); +let mut recorder = Recorder::new(); +let save = "file.save".into(); +// The user presses Cmd+C in the shortcut field of Save. +if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { + let binding = Binding::new(trigger); + let _shown = holders(&keymap, &save, &binding); + if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { + // The user chose to take the key from Copy. + session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); + } +} +// OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +session.cancel(&mut keymap); +``` + ## Features - `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the diff --git a/crates/keymap/src/editor.rs b/crates/keymap/src/editor.rs new file mode 100644 index 00000000..2598552b --- /dev/null +++ b/crates/keymap/src/editor.rs @@ -0,0 +1,254 @@ +//! The model behind a shortcut editor: an edit session with undo, a chord recorder, conflict +//! queries and search. +//! +//! A shortcut dialog drives it like this: +//! +//! ``` +//! use craft_keymap::editor::holders; +//! use craft_keymap::{AssignError, Bindable, Binding, Chord, EditSession, Keymap, Platform, Policy, Recorded, Recorder, Scope, Scopes}; +//! +//! let catalogue = vec![ +//! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), +//! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), +//! ]; +//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::Mac); +//! let now = 12.5; +//! +//! let mut session = EditSession::begin(&keymap); +//! let mut recorder = Recorder::new(); +//! let save = "file.save".into(); +//! // The user presses Cmd+C in the shortcut field of Save. +//! if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { +//! let binding = Binding::new(trigger); +//! assert_eq!(holders(&keymap, &save, &binding)[0].command, "edit.copy".into()); +//! if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { +//! // The user chose to take the key from Copy. +//! session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); +//! } +//! } +//! assert!(keymap.bindings(&"edit.copy".into()).is_empty()); +//! // OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +//! session.cancel(&mut keymap); +//! assert_eq!(keymap.bindings(&"edit.copy".into()), [Binding::parse("Cmd+C").unwrap()]); +//! ``` + +use std::collections::VecDeque; + +use crate::chord::{Chord, Key, Platform, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{AssignError, Assigned, Bindable, Binding, Holder, Keymap, Overrides, Policy}; + +/// Undo steps an [`EditSession`] keeps; older ones are dropped. +pub const MAX_UNDO: usize = 500; +/// Most bytes of undo snapshots an [`EditSession`] keeps (keys and texts, plus a fixed estimate +/// per entry and per text for their allocations); older steps are dropped, though the latest one +/// always stays. +pub const MAX_UNDO_BYTES: usize = 64 << 20; + +/// Seconds within which a second press of the same chord counts as a double press. +pub const DOUBLE_PRESS: f64 = 0.6; + +/// One opening of a shortcut editor: the overrides it started from, and undo and redo as +/// snapshots of [`Overrides`]. +#[derive(Clone, Debug)] +pub struct EditSession { + start: Overrides, + /// Snapshots with their [`size`]. + undo: VecDeque<(Overrides, usize)>, + undo_bytes: usize, + redo: Vec, +} + +/// Heap bytes a snapshot entry costs beyond its strings' bytes: the key's `String`, the list and +/// the map node, estimated. +const ENTRY_OVERHEAD: usize = size_of::() + 64; +/// Bytes a text costs beyond its own: its `String` and allocator rounding, estimated. +const TEXT_OVERHEAD: usize = size_of::() + 16; + +/// The estimated bytes of a snapshot, so many short texts count as more than their length. +fn size(o: &Overrides) -> usize { + o.iter().map(|(k, texts)| ENTRY_OVERHEAD + k.len() + texts.iter().map(|t| TEXT_OVERHEAD + t.len()).sum::()).sum() +} + +impl EditSession { + /// Starts a session on `keymap`'s current overrides; [`cancel`](Self::cancel) returns to them. + pub fn begin(keymap: &Keymap) -> EditSession { + EditSession { start: keymap.overrides().clone(), undo: VecDeque::new(), undo_bytes: 0, redo: Vec::new() } + } + + /// Runs one edit. When it changes the overrides it becomes an undo step and clears redo. + pub fn edit(&mut self, keymap: &mut Keymap, f: impl FnOnce(&mut Keymap) -> R) -> R { + let before = keymap.overrides().clone(); + let out = f(keymap); + if *keymap.overrides() != before { + self.push_undo(before); + self.redo.clear(); + } + out + } + + /// Adds a binding as one undo step. With [`Policy::Refuse`] a chord other commands hold gives + /// [`AssignError::Taken`] and changes nothing, so the dialog can ask whether to steal or keep; + /// the answer is a second call with [`Policy::Steal`] or [`Policy::Keep`]. + pub fn assign(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: Binding, policy: Policy) -> Result { + self.edit(keymap, |km| km.assign(command, binding, policy)) + } + + /// Replaces one binding of a command with another as one undo step, as when the user records + /// over a key in the list. Conflicts follow `policy` as in [`EditSession::assign`]. + pub fn replace( + &mut self, + keymap: &mut Keymap, + command: &CommandRef, + old: &Binding, + new: Binding, + policy: Policy, + ) -> Result { + let list: Vec = keymap.bindings(command).into_iter().map(|b| if b == *old { new.clone() } else { b }).collect(); + self.edit(keymap, |km| km.set_bindings(command, list, policy)) + } + + /// Removes one binding of a command as one undo step. + pub fn remove(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: &Binding) -> bool { + self.edit(keymap, |km| km.remove(command, binding)) + } + + /// Leaves a command without bindings as one undo step. + pub fn clear(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.clear(command)) + } + + /// Gives a command its default bindings back as one undo step. + pub fn reset(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.reset(command)) + } + + /// Gives every command its defaults as one undo step. + pub fn reset_all(&mut self, keymap: &mut Keymap) { + self.edit(keymap, Keymap::reset_all); + } + + fn push_undo(&mut self, snapshot: Overrides) { + let bytes = size(&snapshot); + self.undo.push_back((snapshot, bytes)); + self.undo_bytes += bytes; + while self.undo.len() > 1 && (self.undo.len() > MAX_UNDO || self.undo_bytes > MAX_UNDO_BYTES) { + if let Some((_, b)) = self.undo.pop_front() { + self.undo_bytes -= b; + } + } + } + + /// Reverts the last edit; false when there is none. + pub fn undo(&mut self, keymap: &mut Keymap) -> bool { + let Some((previous, bytes)) = self.undo.pop_back() else { return false }; + self.undo_bytes -= bytes; + self.redo.push(keymap.overrides().clone()); + keymap.set_overrides(previous); + true + } + + /// Applies the last undone edit again; false when there is none. + pub fn redo(&mut self, keymap: &mut Keymap) -> bool { + let Some(next) = self.redo.pop() else { return false }; + self.push_undo(keymap.overrides().clone()); + keymap.set_overrides(next); + true + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// Restores the overrides the session started with and forgets its undo history. + pub fn cancel(&mut self, keymap: &mut Keymap) { + keymap.set_overrides(self.start.clone()); + self.undo.clear(); + self.undo_bytes = 0; + self.redo.clear(); + } + + /// Whether `keymap`'s overrides differ from those the session started with. + pub fn is_modified(&self, keymap: &Keymap) -> bool { + *keymap.overrides() != self.start + } +} + +/// The other commands whose bindings clash with `binding` given to `command`: what +/// [`Policy::Steal`] would take and [`Policy::Keep`] would leave clashing. Tools of the command's +/// group and twins sharing a default key are not listed. A dialog shows these while the user +/// records, before anything is assigned. +pub fn holders(keymap: &Keymap, command: &CommandRef, binding: &Binding) -> Vec { + keymap.clashing(command, std::slice::from_ref(binding)) +} + +/// What a press means to the recorder. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recorded { + Single(Trigger), + /// The chord just recorded, pressed again within [`DOUBLE_PRESS`]: it replaces that single. + Double(Trigger), + /// Escape without modifiers: stop recording. + Cancel, +} + +/// Turns the presses of a shortcut field into triggers. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + last: Option<(Chord, f64)>, +} + +impl Recorder { + pub fn new() -> Recorder { + Recorder::default() + } + + /// Records a press at `now_secs` (any monotonic clock, such as egui's `input.time`). + pub fn press(&mut self, chord: Chord, now_secs: f64) -> Recorded { + if chord.key == Key::Escape && chord.mods.is_none() { + self.last = None; + return Recorded::Cancel; + } + // After a double the next press starts over, so a third press is a single again. + if let Some((last, at)) = self.last.take() + && last == chord + && now_secs >= at + && now_secs - at < DOUBLE_PRESS + { + return Recorded::Double(Trigger::double(chord)); + } + self.last = Some((chord, now_secs)); + Recorded::Single(Trigger::single(chord)) + } + + /// Forgets the last press, so the next one cannot become a double. + pub fn reset(&mut self) { + self.last = None; + } +} + +/// The bindables matching `query`, in catalogue order: a case-insensitive substring of the label, +/// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display +/// text. An empty query matches everything. +pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> { + let q = query.trim().to_lowercase(); + keymap + .catalogue() + .iter() + .filter(|b| { + if q.is_empty() { + return true; + } + let hit = |s: &str| s.to_lowercase().contains(&q); + hit(&b.label) + || b.aliases.iter().any(|a| hit(a)) + || b.paths.iter().flatten().any(|s| hit(s)) + || hit(&b.command.id) + || keymap.bindings(&b.command).iter().any(|binding| hit(&binding.trigger.to_string()) || hit(&binding.trigger.display(platform))) + }) + .collect() +} diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fd4039d5..46511e29 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -135,8 +135,7 @@ fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { out } -/// Seconds within which a second press of the same chord counts as a double press. -pub const DOUBLE_PRESS: f64 = 0.6; +pub use crate::editor::DOUBLE_PRESS; /// A command a key press runs. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index bebf33f7..fd93f7bb 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,10 +6,11 @@ //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's //! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] -//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module -//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's -//! key events. +//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the +//! chords each operating system takes. [`import`] brings in other applications' shortcut sets. +//! [`editor`] is the model of a shortcut editor: undo, a chord recorder, conflict queries and +//! search. With the `egui` feature, the `egui` module converts to and from egui keys, matches key +//! presses against bindings and dispatches a frame's key events. //! //! ``` //! use craft_keymap::{Chord, Platform}; @@ -68,7 +69,7 @@ //! removed) stays removed after saving. //! //! ```no_run -//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; //! use craft_settings::{Location, Settings, SettingsStore}; //! use serde::{Deserialize, Serialize}; //! @@ -76,6 +77,7 @@ //! #[serde(default)] //! struct Prefs { //! shortcuts: Overrides, +//! shortcut_sets: Profiles, //! } //! //! impl Settings for Prefs { @@ -128,19 +130,23 @@ pub mod chord; pub mod command; +pub mod editor; #[cfg(feature = "egui")] pub mod egui; pub mod import; pub mod keymap; +pub mod profiles; pub mod reserved; pub mod scope; pub use chord::foreign::Flavor; pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; pub use command::{CommandRef, MAX_KEY}; +pub use editor::{DOUBLE_PRESS, EditSession, MAX_UNDO, MAX_UNDO_BYTES, Recorded, Recorder}; pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan}; pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, }; +pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs new file mode 100644 index 00000000..3f7e92f6 --- /dev/null +++ b/crates/keymap/src/profiles.rs @@ -0,0 +1,437 @@ +//! Named sets of overrides: the app's built-in sets (read only) and the user's own. +//! +//! ``` +//! use craft_keymap::{Overrides, Profiles}; +//! +//! let mut classic = Overrides::new(); +//! classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +//! let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +//! let mut sets = Profiles::default(); +//! assert_eq!(sets.active_name(&builtins), "Craft"); +//! +//! sets.duplicate("Classic", "Mine", &builtins).unwrap(); +//! let text = sets.export("Mine", &builtins).unwrap(); +//! assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +//! assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +//! ``` + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Value, json}; + +use crate::import::{ImportPlan, ImportReport}; +use crate::keymap::{Keymap, Overrides}; + +/// Most user sets. +pub const MAX_SETS: usize = 64; +/// Longest set name, in chars. +pub const MAX_SET_NAME: usize = 64; +/// Largest export document [`Profiles::import`] reads, in bytes. +pub const MAX_EXPORT_BYTES: usize = 4 << 20; + +const FORMAT: &str = "craft-keymap"; +const VERSION: u64 = 1; +const IMPORTED: &str = "Imported"; + +/// The app's built-in sets: name and overrides against the catalogue defaults. +pub type Builtins<'a> = [(&'a str, Overrides)]; + +/// Why a set operation was refused. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProfileError { + /// Empty, over [`MAX_SET_NAME`] chars, or with control characters. + BadName, + NameTaken, + NotFound, + /// Built-in sets cannot be changed, renamed or deleted. + BuiltIn, + /// [`MAX_SETS`] user sets exist. + TooMany, + /// Not a JSON export document. + BadFile, + /// A `format` other than `craft-keymap`. + WrongFormat, + /// A `version` newer than this crate reads. + NewerVersion(u64), + /// Over [`MAX_EXPORT_BYTES`]. + TooLarge, +} + +impl fmt::Display for ProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ProfileError::BadName => "bad set name", + ProfileError::NameTaken => "a set with this name exists", + ProfileError::NotFound => "no such set", + ProfileError::BuiltIn => "built-in sets are read only", + ProfileError::TooMany => "too many sets", + ProfileError::BadFile => "not a shortcut set file", + ProfileError::WrongFormat => "not a craft-keymap file", + ProfileError::NewerVersion(v) => return write!(f, "shortcut set file version {v} is newer than this app reads"), + ProfileError::TooLarge => "shortcut set file too large", + }; + f.write_str(s) + } +} + +impl std::error::Error for ProfileError {} + +/// The user's sets and which set is active. The built-in sets are passed to each call. +/// +/// An `active` name that matches no set means the first built-in set (or no overrides when the app +/// has none). +/// +/// A user set may carry a built-in's name, for instance after an update adds that built-in. Until +/// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call +/// `reconcile` after loading; every method that changes the profiles calls it first. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Profiles { + #[serde(deserialize_with = "lenient_name")] + pub active: String, + #[serde(serialize_with = "sets_as_list", deserialize_with = "capped_sets")] + pub sets: BTreeMap, +} + +static NO_OVERRIDES: Overrides = Overrides::new(); + +/// A name as given, trimmed; `None` when empty, too long or with control characters. +pub fn set_name(name: &str) -> Option { + let n = name.trim(); + let ok = !n.is_empty() && n.chars().count() <= MAX_SET_NAME && !n.chars().any(char::is_control); + ok.then(|| n.to_string()) +} + +impl Profiles { + /// Built-in names first, then the user's sets. + pub fn names(&self, builtins: &Builtins) -> Vec { + builtins.iter().map(|(n, _)| n.to_string()).chain(self.sets.keys().cloned()).collect() + } + + pub fn is_builtin(name: &str, builtins: &Builtins) -> bool { + builtins.iter().any(|(n, _)| *n == name) + } + + /// The name of the set in use. + pub fn active_name<'a>(&'a self, builtins: &'a Builtins) -> &'a str { + if self.sets.contains_key(&self.active) || Self::is_builtin(&self.active, builtins) { + &self.active + } else { + builtins.first().map_or("", |(n, _)| n) + } + } + + /// The overrides of the set in use, to hand to [`Keymap::set_overrides`](crate::Keymap::set_overrides). + pub fn active_overrides<'a>(&'a self, builtins: &'a Builtins) -> &'a Overrides { + self.get(self.active_name(builtins), builtins).unwrap_or(&NO_OVERRIDES) + } + + pub fn get<'a>(&'a self, name: &str, builtins: &'a Builtins) -> Option<&'a Overrides> { + self.sets.get(name).or_else(|| builtins.iter().find(|(n, _)| *n == name).map(|(_, o)| o)) + } + + /// Renames user sets whose name matches a built-in's (ignoring case) to a free name; `active` + /// follows. Returns whether anything was renamed. + pub fn reconcile(&mut self, builtins: &Builtins) -> bool { + let clashing: Vec = self.sets.keys().filter(|k| self.builtin_like(k, builtins)).cloned().collect(); + let mut renamed = false; + for old in clashing { + let Some(set) = self.sets.remove(&old) else { continue }; + // `unique` cannot fail here: a set was just removed, so there is room and the + // candidate names outnumber the sets. + let name = self.unique(&old, builtins).unwrap_or_else(|_| old.clone()); + if self.active == old { + self.active = name.clone(); + } + renamed |= name != old; + self.sets.insert(name, set); + } + renamed + } + + pub fn select(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if self.get(name, builtins).is_none() { + return Err(ProfileError::NotFound); + } + self.active = name.to_string(); + Ok(()) + } + + /// Saves edited overrides to the set in use. A built-in set is not changed: the edit goes to a + /// new set named after `copy_name` (made unique), which becomes active and is returned. The + /// new name is returned too when [`Profiles::reconcile`] renamed the set in use. + pub fn store(&mut self, overrides: Overrides, builtins: &Builtins, copy_name: &str) -> Result, ProfileError> { + let was = self.active_name(builtins).to_string(); + self.reconcile(builtins); + let active = self.active_name(builtins).to_string(); + if let Some(set) = self.sets.get_mut(&active) { + *set = overrides; + return Ok((active != was).then_some(active)); + } + if self.get(&active, builtins) == Some(&overrides) || (active.is_empty() && overrides.is_empty()) { + return Ok(None); + } + let base = set_name(copy_name).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins).map(Some) + } + + /// Saves `overrides` as a user set (replacing one of that name) and makes it active. + pub fn save_as(&mut self, name: &str, overrides: Overrides, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let name = set_name(name).ok_or(ProfileError::BadName)?; + if self.builtin_like(&name, builtins) { + return Err(ProfileError::BuiltIn); + } + if !self.sets.contains_key(&name) { + if self.sets.keys().any(|k| k.to_lowercase() == name.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + } + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + pub fn rename(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + if Self::is_builtin(from, builtins) { + return Err(ProfileError::BuiltIn); + } + let to = set_name(to).ok_or(ProfileError::BadName)?; + if !self.sets.contains_key(from) { + return Err(ProfileError::NotFound); + } + if to != from && (self.taken(&to, builtins) && to.to_lowercase() != from.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if let Some(set) = self.sets.remove(from) { + self.sets.insert(to.clone(), set); + } + if self.active == from { + self.active = to.clone(); + } + Ok(to) + } + + pub fn delete(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if Self::is_builtin(name, builtins) { + return Err(ProfileError::BuiltIn); + } + self.sets.remove(name).ok_or(ProfileError::NotFound)?; + if self.active == name { + self.active = builtins.first().map_or(String::new(), |(n, _)| n.to_string()); + } + Ok(()) + } + + /// Copies a set (built-in or user) to a new user set named `to` (made unique), which becomes + /// active. Returns its name. + pub fn duplicate(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let overrides = self.get(from, builtins).ok_or(ProfileError::NotFound)?.clone(); + let base = set_name(to).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins) + } + + /// A set as a JSON document: `{"format":"craft-keymap","version":1,"name":…,"overrides":…}`, + /// overrides as [`Overrides`] serializes them. + pub fn export(&self, name: &str, builtins: &Builtins) -> Result { + let overrides = self.get(name, builtins).ok_or(ProfileError::NotFound)?; + let doc = json!({"format": FORMAT, "version": VERSION, "name": name, "overrides": overrides}); + serde_json::to_string_pretty(&doc).map_err(|_| ProfileError::BadFile) + } + + /// Reads an exported set into a new user set (renamed when the name is taken) and makes it + /// active. Returns its name. + pub fn import(&mut self, text: &str, builtins: &Builtins) -> Result { + if text.len() > MAX_EXPORT_BYTES { + return Err(ProfileError::TooLarge); + } + let doc: Value = serde_json::from_str(text).map_err(|_| ProfileError::BadFile)?; + if doc.get("format").and_then(Value::as_str) != Some(FORMAT) { + return Err(ProfileError::WrongFormat); + } + match doc.get("version").and_then(Value::as_u64) { + Some(VERSION) => {} + Some(v) if v > VERSION => return Err(ProfileError::NewerVersion(v)), + _ => return Err(ProfileError::BadFile), + } + let overrides = match doc.get("overrides") { + Some(v @ (Value::Object(_) | Value::Array(_))) => Overrides::deserialize(v).map_err(|_| ProfileError::BadFile)?, + None => Overrides::new(), + Some(_) => return Err(ProfileError::BadFile), + }; + self.reconcile(builtins); + let base = doc.get("name").and_then(Value::as_str).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + self.add(&base, overrides, builtins) + } + + /// Applies an import plan to a copy of `keymap` (the keymap the plan was made from) and keeps + /// the resulting overrides as a new user set, which becomes active; `keymap` is not changed. + /// The set is named `name`, else the foreign set's name, else `Imported`, made unique. + /// Returns its name and the report of [`ImportPlan::apply`]. + pub fn import_plan( + &mut self, + plan: &ImportPlan, + keymap: &Keymap, + name: Option<&str>, + builtins: &Builtins, + ) -> Result<(String, ImportReport), ProfileError> { + self.reconcile(builtins); + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + let mut copy = keymap.clone(); + let report = plan.apply(&mut copy); + let base = name.or(plan.report.set.as_deref()).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + let name = self.add(&base, copy.overrides().clone(), builtins)?; + Ok((name, report)) + } + + /// Adds a user set under a unique name from `base` and makes it active. + fn add(&mut self, base: &str, overrides: Overrides, builtins: &Builtins) -> Result { + let name = self.unique(base, builtins)?; + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + fn builtin_like(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + builtins.iter().any(|(n, _)| n.to_lowercase() == lower) + } + + fn taken(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower) + } + + /// `base`, or `base 2`, `base 3`… cut to fit [`MAX_SET_NAME`]. + fn unique(&self, base: &str, builtins: &Builtins) -> Result { + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + if !self.taken(base, builtins) { + return Ok(base.to_string()); + } + // `Custom 2` taken: try `Custom 3`, not `Custom 2 2`. + let base = match base.rsplit_once(' ') { + Some((stem, n)) if !stem.is_empty() && (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()) => stem, + _ => base, + }; + for n in 2..(MAX_SETS + builtins.len() + 3) { + let suffix = format!(" {n}"); + let keep = MAX_SET_NAME.saturating_sub(suffix.chars().count()); + let name: String = base.chars().take(keep).collect::().trim_end().to_string() + &suffix; + if !self.taken(&name, builtins) { + return Ok(name); + } + } + Err(ProfileError::NameTaken) + } +} + +fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let v = Value::deserialize(deserializer)?; + Ok(v.as_str().and_then(set_name).unwrap_or_default()) +} + +// An array of `{ name, overrides }` rather than a table, and each set's overrides in their array +// form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the +// file. +fn sets_as_list(sets: &BTreeMap, serializer: S) -> Result { + #[derive(Serialize)] + struct Set<'a> { + name: &'a str, + #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] + overrides: &'a Overrides, + } + serializer.collect_seq(sets.iter().map(|(name, overrides)| Set { name, overrides })) +} + +fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + deserializer.deserialize_any(SetsVisitor) +} + +struct SetsVisitor; + +// Anything other than a list or a map loads as no sets, so the rest of `Profiles` still loads. +macro_rules! no_sets { + ($($name:ident: $t:ty),*) => { + $(fn $name(self, _: $t) -> Result { + Ok(BTreeMap::new()) + })* + }; +} + +impl<'de> Visitor<'de> for SetsVisitor { + type Value = BTreeMap; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides") + } + + no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); + + fn visit_unit(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_none(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(SetsVisitor) + } + + fn visit_seq>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(item) = access.next_element::()? { + let Some(name) = item.get("name").and_then(Value::as_str) else { continue }; + if !admits(&out, name) { + continue; + } + if let Some(o) = item.get("overrides").and_then(overrides_of) { + out.insert(name.to_string(), o); + } + } + Ok(out) + } + + fn visit_map>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(key) = access.next_key::()? { + if admits(&out, &key) { + if let Some(o) = overrides_of(&access.next_value::()?) { + out.insert(key, o); + } + } else { + access.next_value::()?; + } + } + Ok(out) + } +} + +/// A valid name, stored as written, with room under [`MAX_SETS`]. +fn admits(sets: &BTreeMap, name: &str) -> bool { + set_name(name).is_some_and(|n| n == name) && (sets.len() < MAX_SETS || sets.contains_key(name)) +} + +/// A set's overrides: a list or a map, anything else is no set. +fn overrides_of(v: &Value) -> Option { + match v { + Value::Object(_) | Value::Array(_) => Overrides::deserialize(v).ok(), + _ => None, + } +} diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..b33c9045 --- /dev/null +++ b/crates/keymap/tests/editor.rs @@ -0,0 +1,200 @@ +use craft_keymap::editor::{MAX_UNDO_BYTES, holders, search}; +use craft_keymap::{ + AssignError, Bindable, Binding, Chord, CommandRef, DOUBLE_PRESS, EditSession, Holder, Keymap, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, + MAX_UNDO, Overrides, Platform, Policy, Recorded, Recorder, Scope, Scopes, Trigger, +}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap(platform: Platform) -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), + Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), + Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), + ]; + Keymap::new(catalogue, Scopes::new(), platform) +} + +fn keys(km: &Keymap, id: &str) -> Vec { + km.bindings(&CommandRef::new(id)).iter().map(|b| b.to_string()).collect() +} + +#[test] +fn edits_undo_redo_and_cancel() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + + let assigned = session.edit(&mut km, |km| km.assign(&save, b("Cmd+Shift+S"), Policy::Steal)); + assert!(assigned.is_ok()); + session.edit(&mut km, |km| km.clear(&CommandRef::new("edit.copy"))); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + assert!(keys(&km, "edit.copy").is_empty()); + assert!(session.is_modified(&km)); + + // An edit that changes nothing is no undo step. + session.edit(&mut km, |km| km.reset(&CommandRef::new("tool.brush"))); + + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert!(!session.undo(&mut km)); + assert!(!session.is_modified(&km)); + + assert!(session.redo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + // A new edit drops what could be redone. + session.edit(&mut km, |km| km.clear(&CommandRef::new("tool.brush"))); + assert!(!session.redo(&mut km)); + + session.cancel(&mut km); + assert!(!session.is_modified(&km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert_eq!(keys(&km, "tool.brush"), ["B"]); + assert!(!session.can_undo()); +} + +#[test] +fn undo_keeps_the_newest_steps() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let brush = CommandRef::new("tool.brush"); + // Each edit changes the key: N, B, N, … (B is the default). + let edits = MAX_UNDO + 11; + for i in 0..edits { + let key = if i % 2 == 0 { "N" } else { "B" }; + assert!(session.edit(&mut km, |km| km.set_bindings(&brush, vec![b(key)], Policy::Keep)).is_ok()); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert_eq!(steps, MAX_UNDO); + // The 11 oldest steps are gone: undo stops after the 11th edit, which set N. + assert_eq!(keys(&km, "tool.brush"), ["N"]); + assert!(session.is_modified(&km)); +} + +#[test] +fn undo_history_is_capped_by_size() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + // Full-size snapshots of entries for commands the catalogue does not have. + let big = |tag: char| { + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS])); + } + o + }; + let (a, b) = (big('a'), big('b')); + let string = size_of::(); + let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::()).sum::(); + let edits = 2 * MAX_UNDO_BYTES / per + 4; + for n in 0..edits { + let next = if n % 2 == 0 { a.clone() } else { b.clone() }; + session.edit(&mut km, |km| km.set_overrides(next)); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert!(steps >= 2 && steps <= MAX_UNDO_BYTES / per, "{steps} steps of {per} bytes"); +} + +#[test] +fn recorder_turns_presses_into_triggers() { + let mut r = Recorder::new(); + let a = chord("A"); + assert_eq!(r.press(a, 1.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 1.0 + DOUBLE_PRESS / 2.0), Recorded::Double(Trigger::double(a))); + // The press after a double starts over. + assert_eq!(r.press(a, 1.4), Recorded::Single(Trigger::single(a))); + // Too late for a double. + assert_eq!(r.press(a, 1.4 + DOUBLE_PRESS + 0.1), Recorded::Single(Trigger::single(a))); + // Another chord in between. + assert_eq!(r.press(chord("B"), 3.0), Recorded::Single(Trigger::single(chord("B")))); + assert_eq!(r.press(a, 3.1), Recorded::Single(Trigger::single(a))); + + assert_eq!(r.press(chord("Escape"), 3.2), Recorded::Cancel); + let shift_escape = chord("Shift+Escape"); + assert_eq!(r.press(shift_escape, 3.3), Recorded::Single(Trigger::single(shift_escape))); + // Two presses in one egui frame share its time. + assert_eq!(r.press(a, 4.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 4.0), Recorded::Double(Trigger::double(a))); +} + +#[test] +fn search_reads_labels_aliases_paths_ids_and_keys() { + let ids = |km: &Keymap, q: &str, p: Platform| search(km, q, p).iter().map(|b| b.command.id.clone()).collect::>(); + let win = keymap(Platform::Windows); + let mac = keymap(Platform::Mac); + assert_eq!(ids(&win, "zoom", Platform::Windows), ["view.zoomIn"]); + assert_eq!(ids(&win, "KOPIEREN", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&win, "paint", Platform::Windows), ["tool.brush"]); + assert_eq!(ids(&win, "file.sa", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "ctrl+s", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "cmd+c", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&mac, "⌘s", Platform::Mac), ["file.save"]); + assert_eq!(ids(&mac, "⌘s", Platform::Windows), Vec::::new()); + assert_eq!(ids(&mac, " ", Platform::Mac).len(), 4); +} + +#[test] +fn a_dialog_asks_before_stealing_and_undoes_each_answer() { + let mut km = keymap(Platform::Mac); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + let copy = CommandRef::new("edit.copy"); + let held = vec![Holder { command: copy.clone(), binding: b("Cmd+C") }]; + + // While recording: who else has the key. + assert_eq!(holders(&km, &save, &b("Cmd+C")), held); + assert!(holders(&km, ©, &b("Cmd+C")).is_empty(), "its own key"); + assert_eq!(session.assign(&mut km, &save, b("Cmd+C"), Policy::Refuse), Err(AssignError::Taken(held.clone()))); + assert!(!session.can_undo(), "a refused assignment changes nothing"); + + // Keep: both hold it. + let kept = session.assign(&mut km, &save, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(kept.clashes, held); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + + // Steal: the other command loses it. + let stolen = session.assign(&mut km, &save, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(stolen.taken_from, held); + assert!(keys(&km, "edit.copy").is_empty()); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + + // Recording over one key of the list replaces it in place. + session.replace(&mut km, &save, &b("Cmd+S"), b("Cmd+Shift+S"), Policy::Refuse).unwrap(); + assert_eq!(keys(&km, "file.save"), ["Cmd+Shift+S", "Cmd+C"]); + assert!(session.remove(&mut km, &save, &b("Cmd+C"))); + assert!(session.clear(&mut km, &CommandRef::new("tool.brush"))); + + assert!(session.reset(&mut km, &save)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + session.reset_all(&mut km); + assert!(km.overrides().is_empty()); + assert!(!session.is_modified(&km)); + + // One undo per action, back to the steal. + for _ in 0..5 { + assert!(session.undo(&mut km)); + } + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + assert!(session.undo(&mut km)); + assert!(!session.can_undo()); + + session.redo(&mut km); + session.cancel(&mut km); + assert!(km.overrides().is_empty(), "cancel restores the state at open"); +} diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..84be8f09 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,172 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, Start, plan, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap() -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), + Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Mac) +} + +#[test] +fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let mut edited = Overrides::new(); + edited.insert("edit.copy", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into()))); + assert_eq!(p.active_name(&builtins), "Custom"); + assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place"); + assert!(p.active_overrides(&builtins).is_empty()); + + p.select("Classic", &builtins).unwrap(); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy"); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into()))); + assert_eq!(builtins[1].1, classic); + + let exported = p.export("Custom 2", &builtins).unwrap(); + assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into())); + assert_eq!(p.active_overrides(&builtins), &edited); + + assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); + assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); + assert_eq!(p.active, "Mine"); + assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); + p.delete("Mine", &builtins).unwrap(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let wrong = exported.replace("craft-keymap", "other"); + assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat)); + let newer = exported.replace("\"version\": 1", "\"version\": 2"); + assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2))); + + let text = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); + assert_eq!(junk.sets.keys().collect::>(), ["ok"]); + let junk: Profiles = + serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); + assert_eq!(junk.sets["ok"].get("a"), Some(&["A".to_string()][..])); + assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); +} + +#[test] +fn sets_that_are_not_a_list_or_map_load_as_none() { + for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { + let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(p.sets.is_empty(), "{text}"); + assert_eq!(p.active, "Mine", "{text}"); + } +} + +#[test] +fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { + let mut mine = Overrides::new(); + mine.insert("edit.copy", vec!["Cmd+K".into()]); + let mut p = Profiles::default(); + p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap(); + + // An update adds a built-in of that name. + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads"); + + let mut edited = mine.clone(); + edited.insert("edit.paste", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into()))); + assert_eq!(p.active_name(&builtins), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &edited); + assert_eq!(p.get("Classic", &builtins), Some(&classic)); + assert_eq!(builtins[1].1, classic); + + let mut q = Profiles::default(); + q.sets.insert("CRAFT".into(), mine.clone()); + q.active = "CRAFT".into(); + assert!(q.reconcile(&builtins)); + assert_eq!(q.active_name(&builtins), "CRAFT 2"); + assert_eq!(q.active_overrides(&builtins), &mine); + assert!(!q.reconcile(&builtins)); +} + +#[test] +fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { + let mut km = keymap(); + km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap(); + km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + p.save_as("Mine", km.overrides().clone(), &builtins).unwrap(); + + let text = p.export("Mine", &builtins).unwrap(); + assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); + assert_eq!(p.active, "Mine 2"); + + let mut again = keymap(); + again.set_overrides(p.active_overrides(&builtins).clone()); + for bindable in km.catalogue() { + assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); + } + assert_eq!(again.overrides(), km.overrides()); +} + +#[test] +fn duplicate_copies_built_in_and_user_sets() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + + assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); + assert_eq!(p.active, "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); + assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); + assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); + assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); + + while p.sets.len() < MAX_SETS { + p.duplicate("Craft", "Spare", &builtins).unwrap(); + } + assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); +} + +#[test] +fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { + let km = keymap(); + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); + let set = ForeignSet { name: Some("Other App".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; + let planned = plan(&set, &km, &options); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + + let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap(); + assert_eq!(name, "Other App"); + assert_eq!(report.changed.len(), 1); + assert!(km.overrides().is_empty()); + assert_eq!(p.active, "Other App"); + let mut imported = keymap(); + imported.set_overrides(p.active_overrides(&builtins).clone()); + assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); + + let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); + assert_eq!(name, "Craft 2", "a built-in's name is not reused"); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index 89c744bd..6d2351b6 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Scope, Scopes}; +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Profiles, Scope, Scopes}; use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -11,6 +11,7 @@ struct Prefs { general: General, #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + profiles: Profiles, } #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] @@ -52,7 +53,11 @@ fn overrides_round_trip_through_craft_settings() { km.clear(&"edit.copy".into()); let mut store = open(tmp.path()); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.save_as("Mine", km.overrides().clone(), &[]).unwrap(); + p.profiles.save_as("Spare", Overrides::new(), &[]).unwrap(); + }); store.flush().unwrap(); // A hand edit: one text that does not parse, one value that is not a list. @@ -72,6 +77,7 @@ fn overrides_round_trip_through_craft_settings() { let problems = km.problems(); assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + assert_eq!(store.get().profiles.sets.len(), 2); store.update(|p| p.general.language = "de".into()); store.flush().unwrap(); @@ -85,13 +91,18 @@ fn overrides_round_trip_through_craft_settings() { // Removals reach the file. km.reset(&layers); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.delete("Spare", &[]).unwrap(); + }); store.flush().unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); - assert!(!line.contains("window.panel"), "{text}"); + assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); let reloaded = open(tmp.path()); assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); + assert_eq!(reloaded.get().profiles.sets.keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); } #[test] @@ -121,3 +132,15 @@ fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); } } + +#[test] +fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert_eq!(store.get().profiles.active, "Mine", "{value}"); + assert!(store.get().profiles.sets.is_empty(), "{value}"); + } +}