mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 22:22:31 +00:00
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:
+79
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, ©, &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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user