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.
This commit is contained in:
aiongg
2026-10-19 12:03:23 +00:00
parent 362eb59fd2
commit 70c84ec76c
8 changed files with 1181 additions and 15 deletions
+79 -4
View File
@@ -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
+254
View File
@@ -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<Overrides>,
}
/// 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::<String>() + 64;
/// Bytes a text costs beyond its own: its `String` and allocator rounding, estimated.
const TEXT_OVERHEAD: usize = size_of::<String>() + 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::<usize>()).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<R>(&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<Assigned, AssignError> {
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<Assigned, AssignError> {
let list: Vec<Binding> = 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<Holder> {
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()
}
+1 -2
View File
@@ -135,8 +135,7 @@ fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec<Chord> {
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)]
+11 -5
View File
@@ -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};
+437
View File
@@ -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<String, Overrides>,
}
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<String> {
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<String> {
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<String> = 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<Option<String>, 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<String, ProfileError> {
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<String, ProfileError> {
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<String, ProfileError> {
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<String, ProfileError> {
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<String, ProfileError> {
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<String, ProfileError> {
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<String, ProfileError> {
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::<String>().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<String, D::Error> {
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<S: Serializer>(sets: &BTreeMap<String, Overrides>, serializer: S) -> Result<S::Ok, S::Error> {
#[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<BTreeMap<String, Overrides>, 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<E: de::Error>(self, _: $t) -> Result<Self::Value, E> {
Ok(BTreeMap::new())
})*
};
}
impl<'de> Visitor<'de> for SetsVisitor {
type Value = BTreeMap<String, Overrides>;
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<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(BTreeMap::new())
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(BTreeMap::new())
}
fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
deserializer.deserialize_any(SetsVisitor)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut out = BTreeMap::new();
while let Some(item) = access.next_element::<Value>()? {
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<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut out = BTreeMap::new();
while let Some(key) = access.next_key::<String>()? {
if admits(&out, &key) {
if let Some(o) = overrides_of(&access.next_value::<Value>()?) {
out.insert(key, o);
}
} else {
access.next_value::<IgnoredAny>()?;
}
}
Ok(out)
}
}
/// A valid name, stored as written, with room under [`MAX_SETS`].
fn admits(sets: &BTreeMap<String, Overrides>, 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<Overrides> {
match v {
Value::Object(_) | Value::Array(_) => Overrides::deserialize(v).ok(),
_ => None,
}
}
+200
View File
@@ -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<String> {
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::<String>();
let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::<usize>()).sum::<usize>();
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::<Vec<_>>();
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::<String>::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, &copy, &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");
}
+172
View File
@@ -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::<Profiles>(&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::<Vec<_>>(), ["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");
}
+27 -4
View File
@@ -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::<Vec<_>>(), ["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}");
}
}