Merge #186: keymap: Keyboard Shortcuts dialog, named shortcut sets and the shortcut editor

# Conflicts:
#	crates/keymap/src/lib.rs
This commit is contained in:
aiongg committed 2026-10-24 02:29:57 +00:00
commit 332a4456dd
29 files changed
+9001 -110

No files matched your search

+10 -8
View File
@@ -3,9 +3,9 @@ name: Keymap
on:
push:
branches: [main]
paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
pull_request:
paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
workflow_dispatch:
permissions:
@@ -32,12 +32,14 @@ jobs:
key: craft-keymap-linux
- run: cargo fmt -p craft-keymap --check
- run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings
- run: cargo clippy -p craft-keymap --all-targets --all-features --locked -- -D warnings
# Doctests run once, in the all-features step.
# `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU
# to run, so the test steps leave them out.
- run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings
# Doctests run once, in the `ui` step.
- name: Test core
run: cargo test -p craft-keymap --no-default-features --lib --tests --locked
- name: Test egui dispatch
run: cargo test -p craft-keymap --all-features --locked
- name: Test egui dispatch and the shortcuts dialog
run: cargo test -p craft-keymap --features ui --locked
native:
timeout-minutes: 15
name: Keymap (${{ matrix.os }})
@@ -54,7 +56,7 @@ jobs:
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-keymap-${{ matrix.os }}
- run: cargo test -p craft-keymap --all-features --locked
- run: cargo test -p craft-keymap --features ui --locked
wasm:
timeout-minutes: 10
name: Keymap WASM
@@ -69,4 +71,4 @@ jobs:
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-keymap-wasm
- run: cargo check -p craft-keymap --all-features --locked --target wasm32-unknown-unknown
- run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown
Generated
+4
View File
@@ -841,8 +841,12 @@ dependencies = [
name = "craft-keymap"
version = "0.1.0"
dependencies = [
"craft-i18n",
"craft-settings",
"craft-ui",
"egui",
"egui_kittest",
"image",
"serde",
"serde_json",
"tempfile",
+11 -2
View File
@@ -14,12 +14,21 @@ workspace = true
serde = { version = "1", features = ["derive"] }
serde_json = "1"
egui = { workspace = true, optional = true }
craft-ui = { workspace = true, optional = true }
craft-i18n = { workspace = true, optional = true }
[dev-dependencies]
# For setting `RawInput` fields only the suite's egui fork has.
egui = { workspace = true, features = ["serde"] }
# `serde` for setting `RawInput` fields only the suite's egui fork has; `default_fonts` for the
# dialog's tests.
egui = { workspace = true, features = ["serde", "default_fonts"] }
craft-settings = { workspace = true }
tempfile = "3"
egui_kittest = "0.36"
image = { version = "0.25", default-features = false, features = ["png"] }
[features]
egui = ["dep:egui"]
# The Keyboard Shortcuts dialog.
ui = ["egui", "dep:craft-ui", "dep:craft-i18n"]
# Screenshots of the dialog for review (tests/ui_snapshot.rs; needs a GPU or software adapter).
snapshot = ["ui", "egui_kittest/wgpu"]
+205 -15
View File
@@ -1,14 +1,15 @@
# Craft Keymap
`craft-keymap` holds the data and logic for keyboard bindings in the Craft apps: chords and their
text forms, bindings from chords to commands, scopes, conflicts, user overrides, and an import
pipeline for other applications' shortcut sets. Each application supplies its command catalog,
its file-format readers and its UI.
text forms, bindings from chords to commands, scopes, conflicts, user overrides, named sets, an
import pipeline for other applications' shortcut sets, and an editor model. Each application
supplies its command catalog, its file-format readers and its UI.
## Boundaries
- Data and logic only. The apps keep the command catalog (ids, labels, menu paths, defaults),
the readers for other applications' files, the shortcut editor UI and running the commands.
- Data and logic, plus a shared Keyboard Shortcuts dialog behind the `ui` feature. The apps keep
the command catalog (ids, labels, menu paths, defaults), the readers for other applications'
files, file dialogs and running the commands.
- Never crashes: no panics, no `unsafe`. Loading settings never fails; what cannot be used is
kept or reported (`Keymap::problems`, the import report).
- Hostile input is capped: commands, bindings per command, text lengths, import entries, keys,
@@ -16,7 +17,8 @@ its file-format readers and its UI.
- Reserved chords are opt-in. A keymap reserves no chord unless the app passes a list to
`Keymap::with_reserved`, e.g. `reserved::system(platform)` for the operating system's window
switching and closing chords. A reserved chord cannot be assigned or bound.
- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature.
- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature; `craft-ui` and
`craft-i18n` with the `ui` feature.
## A catalog and dispatch
@@ -171,11 +173,75 @@ store.update(|p| p.shortcuts = keymap.overrides().clone());
- `conflicts()` lists every pair of commands on one chord; `conflicts_with(&command)` lists the
commands that share one command's chords, with that command as `a`.
## Shortcut sets
`Profiles` holds the user's named sets of overrides and which one is active. The app's built-in
sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change.
An app with sets stores only `Profiles`, in place of the `Overrides` field above: the keymap's
overrides are the active set's, and the dialog's OK stores them there. The sets, and each set's
overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file.
```rust
use craft_keymap::{Keymap, Platform, Profiles, Scopes};
use craft_settings::{Location, SettingsStore};
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct Prefs {
shortcut_sets: Profiles,
}
// `builtins` is the app's &[(&str, Overrides)], the first set the default.
let (mut store, _report) = SettingsStore::<Prefs>::open(Location::Auto);
let mut sets = store.get().shortcut_sets.clone();
sets.reconcile(&builtins);
let keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(sets.active_overrides(&builtins).clone());
// The dialog edits `sets` and `keymap`. On Outcome::Ok (or Changed, to save live):
store.update(|p| p.shortcut_sets = sets.clone());
```
- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`);
`sets` (the user's sets) and `active` (the stored name, which `active_name` resolves to a set).
- `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`,
`rename` and `delete` work on user sets only.
- `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a
built-in set makes a user copy named after `copy_name`, which becomes active.
- New names are made unique (`Custom 2`, `Custom 3`) and compare without case. A user set named
like a built-in added later is renamed by `reconcile`.
- `export(name)` writes one set as JSON
(`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into
a new set. A file from a newer version is refused (`ProfileError::NewerVersion`).
- Loading never fails. `problems()` lists what loading dropped: sets with a bad or repeated name,
overrides that are neither a list nor a map, sets past `MAX_SETS`, and a `version` newer than
this crate writes. The app shows them before the next save writes the sets without them.
Stored `Profiles` carry `version = 1`; a file without it reads as version 1.
- `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a
copy of the keymap and keeps the result as a new set, so another application's set becomes one
the user can switch to and away from.
```rust
use craft_keymap::{Overrides, Profiles};
let mut classic = Overrides::new();
classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]);
let builtins = [("Craft", Overrides::new()), ("Classic", classic)];
let mut sets = Profiles::default();
assert_eq!(sets.active_name(&builtins), "Craft");
sets.duplicate("Classic", "Mine", &builtins).unwrap();
let text = sets.export("Mine", &builtins).unwrap();
assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into()));
assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap());
```
## Importing a shortcut set
The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an
[`import::ForeignSet`](src/import.rs): entries with a menu, a path and keys read by
`ForeignKey::read`. `import::plan` matches each entry to one command and changes nothing;
`ForeignKey::read`. The reader builds it with `ForeignSet::new(entries)`, `with_name` and
`with_truncated`; the struct is `#[non_exhaustive]`, so struct literals outside the crate do not
compile. `import::plan` matches each entry to one command and changes nothing;
`ImportPlan::apply` applies the plan and returns an `ImportReport` (serde, camelCase). Report
rows carry the entry's `item` (its path) and `line`.
@@ -189,13 +255,11 @@ let save = Bindable::new("file.save", "Save", Scope::app())
let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows);
let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows);
let set = ForeignSet {
name: Some("My Set".into()),
entries: vec![
ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12),
ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13),
],
};
let set = ForeignSet::new(vec![
ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12),
ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13),
])
.with_name("My Set");
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() };
let planned = plan(&set, &keymap, &options);
// A review UI can show planned.commands and planned.report here.
@@ -211,7 +275,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File
it `in_panel_menu()`. Tool entries match only tools.
- Names are compared after `import::normalize_label`: case, `…`, `...`, `&`, colons and extra
spaces aside, so the alias `Preferences: General…` matches `Edit > Preferences > General...`.
Aliases (`Bindable::with_alias`) count wherever labels do.
Aliases (`Bindable::with_alias`) count wherever labels do, and import paths
(`Bindable::with_import_path`, such as `["Tools", label]` for a tool) wherever menu paths do;
the dialog makes no areas or groups from import paths.
- Panel menus: sources list panel-menu items with the panel's name in front (InDesign's Panel
Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas).
The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`.
@@ -239,6 +305,124 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File
they are reported, never applied. Only the first 200 unmatched entries with keys get them, and
only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do
not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`.
- `truncated` counts what was cut at the caps (`MAX_IMPORT_ENTRIES` and the others), plus the
entries the app's reader dropped at its own limits, which it passes as `ForeignSet.truncated`.
## Editor
`editor` is the model behind a shortcut dialog; the app draws it.
- `EditSession::begin(&keymap)` when the dialog opens. Each change is one undo step:
`assign`, `replace` (record over one key of a command), `remove`, `clear`, `reset`, `reset_all`,
or any keymap edit through `session.edit(&mut keymap, |km| …)`. `undo`, `redo`, `cancel` (back
to the state at open) and `is_modified`.
- Conflicts: `editor::holders(&keymap, &command, &binding)` lists the commands that hold the key
while the user records; `Keymap::conflicts_with(&command)` marks the rows whose keys are
shared after a `Keep`. Assigning with `Policy::Refuse` returns `AssignError::Taken` and changes
nothing; the user's answer is a second call with `Policy::Steal` or `Policy::Keep`.
- `Recorder::press(chord, time)` gives `Single`, `Double` (the same chord again within
`DOUBLE_PRESS`, 0.6 s) or `Cancel` (Escape).
- `editor::search(&keymap, query, platform)` filters the catalog by label, alias, menu path,
command id and key text, canonical or as shown on `platform`.
```rust
use craft_keymap::editor::holders;
use craft_keymap::{AssignError, Binding, Chord, EditSession, Policy, Recorded, Recorder};
// `keymap` is the app's Keymap, `now` the input time in seconds.
let mut session = EditSession::begin(&keymap);
let mut recorder = Recorder::new();
let save = "file.save".into();
// The user presses Cmd+C in the shortcut field of Save.
if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) {
let binding = Binding::new(trigger);
let _shown = holders(&keymap, &save, &binding);
if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) {
// The user chose to take the key from Copy.
session.assign(&mut keymap, &save, binding, Policy::Steal).ok();
}
}
// OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel:
session.cancel(&mut keymap);
```
## Shortcuts dialog
With the `ui` feature, `ui::ShortcutsDialog` draws the Keyboard Shortcuts dialog over a `Keymap`
and, optionally, `Profiles`: a table of the catalog grouped by menu, recording, conflicts, undo,
named sets, import review and a summary. It edits the keymap in place, so menus show new keys at
once, and Cancel puts everything back as it was when the dialog opened.
Adopt it in three steps:
1. Build the `Keymap` from the catalog and keep the built-in sets (`Builtins`), as above.
2. While the dialog is open, keep a `DialogState` and call `show` (a window) or `ui` (the body,
for the app's own modal or preferences page) every frame.
3. Act on the `Outcome` it returns, and drop the state when `is_open()` turns false.
Apps that import other applications' sets read each file into a `ForeignSet` built with
`ForeignSet::new` ([Importing a shortcut set](#importing-a-shortcut-set)).
```rust,ignore
use craft_keymap::ui::{DialogState, I18nText, Options, Outcome, ShortcutsDialog};
// "Keyboard Shortcuts…" opens it; "Edit Shortcut…" also calls `state.select(&command)`.
self.shortcuts = Some(DialogState::new());
// Every frame, before the app's own dispatch:
if let Some(state) = &mut self.shortcuts {
let text = I18nText { i18n: &self.i18n, lang: self.lang };
let dialog = ShortcutsDialog::new(&mut self.keymap, &text).options(&self.shortcut_options);
let dialog = dialog.sets(&mut self.settings.shortcut_sets, &BUILTINS);
match dialog.show(ctx, state) {
Some(Outcome::Ok) => self.save_settings(),
// After each edit, and after a Cancel that undid edits.
Some(Outcome::Changed) => self.rebuild_menus(),
Some(Outcome::Import { formats }) => self.pick_import_file(formats),
Some(Outcome::Export { name, json }) => self.save_file(&name, json),
Some(Outcome::Summary { name, html, .. }) => self.show_summary(&name, html),
Some(Outcome::Remembered(answers)) => self.settings.import_answers.extend(answers),
_ => {}
}
if !state.is_open() {
self.shortcuts = None;
}
}
```
Outcomes:
| Outcome | The app |
|---|---|
| `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. |
| `Cancel` | Nothing to save: the keymap and the sets are as they were at open. When Cancel undid edits, `Changed` follows. |
| `Changed` | Updates its menus, or saves at once if it saves live. It comes after each edit and after a Cancel that undid edits. When a frame also has another outcome, `Changed` comes on the next frame; `is_open()` stays true until it has. |
| `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. |
| `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). |
| `Summary { name, html, text }` | Shows, saves or prints the summary. |
| `Remembered(answers)` | Keeps the user's import answers for the next import (`ImportOptions::remembered`). |
The crate never opens a file dialog or touches the file system. When the app's own step fails
while the dialog is open (a file it cannot read), `state.show_error(text)` shows the translated
message under the table until the user dismisses it or the next action replaces it.
Key dispatch while the dialog is open:
- Skip `Dispatcher::dispatch` while `DialogState::is_open()` is true. The dialog takes the
frame's key, text and clipboard events itself while it records, so the chord the user presses
reaches the recorder and runs nothing.
- A native macOS menu handles its key equivalents before egui sees them, so Cmd+Q, Cmd+, and
the other menu chords never reach the recorder. While `is_recording()` is true, pause them:
feed them to egui as key presses, or rebuild the menu without accelerators.
Words come from `ui::Text`. Apps on craft-i18n pass `I18nText`; others implement `ui` and
`plural` over their own lookup, and `rtl` and `visual` (the app's bidi reordering) for
right-to-left languages, which mirror the layout. Chord text is never reordered. `ui::STRINGS`
and `ui::PLURALS` list every string the dialog looks up, for the translation catalog.
`Options` sets the conflict mode, double-press recording, area tabs, the on-screen keyboard,
read-only rows for keys outside the catalog, the app's import and export formats, an `accept`
rule for which keys a command may get, and a `visible` rule for which catalog entries the table
lists (hidden entries keep their keys and still count in conflicts).
## Features
@@ -276,3 +460,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File
typing, and stays when that chord has no binding. egui-winit sends no Paste event when the
clipboard is empty or holds an image, so a binding on a Cmd+V chord cannot run then on the
desktop.
- `ui`: the Shortcuts dialog (see above). Implies `egui`.
- `snapshot` (development): `tests/ui_snapshot.rs` writes PNGs of the dialog to
`CRAFT_KEYMAP_SNAPSHOT_DIR` for review. egui's default fonts lack ⇧ ⌥ ⌃ and the arrows; the
test adds the font in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans when it is installed, and BIZ
UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR` (⇧ ⌘ and arrows only).
+2 -2
View File
@@ -254,7 +254,7 @@ impl Key {
}
/// The key as macOS menus show it: arrows and editing keys as glyphs.
fn mac_symbol(self) -> &'static str {
pub(crate) fn mac_symbol(self) -> &'static str {
match self {
Key::Up => "↑",
Key::Down => "↓",
@@ -270,7 +270,7 @@ impl Key {
}
/// The key as Windows and Linux show it.
fn text_name(self) -> &'static str {
pub(crate) fn text_name(self) -> &'static str {
match self {
Key::PageUp => "Page Up",
Key::PageDown => "Page Down",
+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 catalog = vec![
//! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()),
//! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()),
//! ];
//! let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::Mac);
//! let now = 12.5;
//!
//! let mut session = EditSession::begin(&keymap);
//! let mut recorder = Recorder::new();
//! let save = "file.save".into();
//! // The user presses Cmd+C in the shortcut field of Save.
//! if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) {
//! let binding = Binding::new(trigger);
//! assert_eq!(holders(&keymap, &save, &binding)[0].command, "edit.copy".into());
//! if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) {
//! // The user chose to take the key from Copy.
//! session.assign(&mut keymap, &save, binding, Policy::Steal).ok();
//! }
//! }
//! assert!(keymap.bindings(&"edit.copy".into()).is_empty());
//! // OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel:
//! session.cancel(&mut keymap);
//! assert_eq!(keymap.bindings(&"edit.copy".into()), [Binding::parse("Cmd+C").unwrap()]);
//! ```
use std::collections::VecDeque;
use crate::chord::{Chord, Key, Platform, Trigger};
use crate::command::CommandRef;
use crate::keymap::{AssignError, Assigned, Bindable, Binding, Holder, Keymap, Overrides, Policy};
/// Undo steps an [`EditSession`] keeps; older ones are dropped.
pub const MAX_UNDO: usize = 500;
/// Most bytes of undo snapshots an [`EditSession`] keeps (keys and texts, plus a fixed estimate
/// per entry and per text for their allocations); older steps are dropped, though the latest one
/// always stays.
pub const MAX_UNDO_BYTES: usize = 64 << 20;
/// Seconds within which a second press of the same chord counts as a double press.
pub const DOUBLE_PRESS: f64 = 0.6;
/// One opening of a shortcut editor: the overrides it started from, and undo and redo as
/// snapshots of [`Overrides`].
#[derive(Clone, Debug)]
pub struct EditSession {
start: Overrides,
/// Snapshots with their [`size`].
undo: VecDeque<(Overrides, usize)>,
undo_bytes: usize,
redo: Vec<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 catalog order: a case-insensitive substring of the label,
/// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display
/// text. An empty query matches everything.
pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> {
let q = query.trim().to_lowercase();
keymap
.catalog()
.iter()
.filter(|b| {
if q.is_empty() {
return true;
}
let hit = |s: &str| s.to_lowercase().contains(&q);
hit(&b.label)
|| b.aliases.iter().any(|a| hit(a))
|| b.paths.iter().flatten().any(|s| hit(s))
|| hit(&b.command.id)
|| keymap.bindings(&b.command).iter().any(|binding| hit(&binding.trigger.to_string()) || hit(&binding.trigger.display(platform)))
})
.collect()
}
+12 -13
View File
@@ -120,8 +120,7 @@ pub fn matches(binding: &Chord, key: EguiKey, mods: &Modifiers, platform: Platfo
Layout::new().matches(binding, &KeyInput::new(Mods::from_egui(mods, platform)).with_typed(pressed), platform)
}
/// Seconds within which a second press of the same chord counts as a double press.
pub const DOUBLE_PRESS: f64 = 0.6;
pub use crate::editor::DOUBLE_PRESS;
/// A command a key press runs.
#[derive(Clone, Debug, PartialEq)]
@@ -333,7 +332,7 @@ impl Dispatcher {
Some(true) => continue,
Some(false) => {}
None => {
let (key, held) = clipboard_chord(&event, frame_mods);
let (key, held, _) = clipboard_chord(&event, frame_mods);
// A focused text field copies and pastes on the plain chords itself.
let look = !is_plain_clipboard(key, held) || (self.clipboard && !active.typing);
let hit = if look { self.press(&KeyInput::new(held).with_typed(key), false, now, keymap, active) } else { None };
@@ -403,7 +402,7 @@ impl Dispatcher {
/// A clipboard event: Copy, Cut or Paste.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Clip {
pub(crate) enum Clip {
Copy,
Cut,
Paste,
@@ -503,7 +502,7 @@ fn event_clip(event: &Event) -> Option<Clip> {
}
/// The clipboard event a backend sends for a press, if any.
fn clipboard_kind(key: Key, held: Mods) -> Option<Clip> {
pub(crate) fn clipboard_kind(key: Key, held: Mods) -> Option<Clip> {
match key {
Key::C if held.cmd => Some(Clip::Copy),
Key::X if held.cmd => Some(Clip::Cut),
@@ -526,17 +525,17 @@ fn is_plain_clipboard(key: Key, held: Mods) -> bool {
}
}
/// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also
/// sends Cut for Shift+Delete and Paste for Shift+Insert.
fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) {
/// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off
/// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert.
pub(crate) fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) {
let with_cmd = Mods { cmd: true, ..held };
let shift_only = held.shift && !held.cmd;
match event {
Event::Cut if shift_only => (Key::Delete, held),
Event::Cut => (Key::X, with_cmd),
Event::Paste(_) if shift_only => (Key::Insert, held),
Event::Paste(_) => (Key::V, with_cmd),
_ => (Key::C, with_cmd),
Event::Cut if shift_only => (Key::Delete, held, Clip::Cut),
Event::Cut => (Key::X, with_cmd, Clip::Cut),
Event::Paste(_) if shift_only => (Key::Insert, held, Clip::Paste),
Event::Paste(_) => (Key::V, with_cmd, Clip::Paste),
_ => (Key::C, with_cmd, Clip::Copy),
}
}
+34 -7
View File
@@ -29,11 +29,15 @@ pub const MAX_PATH_SEGMENTS: usize = 32;
/// in [`ImportReport::truncated`]. Entries without keys get no suggestion and do not count.
pub const MAX_SUGGESTIONS: usize = 200;
/// A shortcut set as an app's reader found it.
/// A shortcut set as an app's reader found it. Build it with [`ForeignSet::new`].
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct ForeignSet {
pub name: Option<String>,
pub entries: Vec<ForeignEntry>,
/// Entries the reader dropped at its own limits; [`plan`] counts them in
/// [`ImportReport::truncated`].
pub truncated: usize,
}
/// One item of a foreign set.
@@ -59,6 +63,25 @@ pub struct ForeignEntry {
pub panel_menu: bool,
}
impl ForeignSet {
/// A set without a name.
pub fn new(entries: Vec<ForeignEntry>) -> ForeignSet {
ForeignSet { entries, ..ForeignSet::default() }
}
/// The set's name as the source gives it.
pub fn with_name(mut self, name: impl Into<String>) -> ForeignSet {
self.name = Some(name.into());
self
}
/// Entries the reader dropped at its own limits ([`ForeignSet::truncated`]).
pub fn with_truncated(mut self, truncated: usize) -> ForeignSet {
self.truncated = truncated;
self
}
}
impl ForeignEntry {
/// An entry without keys.
pub fn new<S: Into<String>>(menu: Option<&str>, path: impl IntoIterator<Item = S>) -> ForeignEntry {
@@ -411,8 +434,8 @@ pub struct ImportReport {
pub unreadable: Vec<Unreadable>,
pub approximated: Vec<Approximated>,
pub suggestions: Vec<Suggestion>,
/// Entries, keys, path segments, bindings and text fields cut at the caps, and unmatched
/// entries past [`MAX_SUGGESTIONS`] that got no suggestion.
/// Entries, keys, path segments, bindings and text fields cut at the caps, unmatched entries
/// past [`MAX_SUGGESTIONS`] that got no suggestion, and [`ForeignSet::truncated`].
pub truncated: usize,
}
@@ -435,7 +458,7 @@ fn toggle_name(s: &str) -> String {
pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> ImportPlan {
let mut cut = 0usize;
let mut report = ImportReport { set: set.name.as_deref().map(|n| cap_text(n, &mut cut)), ..ImportReport::default() };
cut += set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES);
cut = cut.saturating_add(set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES)).saturating_add(set.truncated);
let index = Index::new(keymap);
let contexts: Vec<(String, &ContextRule)> = options.contexts.iter().map(|(n, r)| (n.to_lowercase(), r)).collect();
let mut commands: Vec<Planned> = Vec::new();
@@ -867,7 +890,7 @@ impl Names {
for name in &names {
add(&mut self.anywhere, name.clone(), i);
}
for path in &b.paths {
for path in b.paths.iter().chain(&b.import_paths) {
let mut segs: Vec<String> = path.iter().map(|s| normalize_label(s)).collect();
if let [_, .., last] = segs.as_mut_slice() {
*last = form(last);
@@ -943,7 +966,7 @@ impl Index {
ix.tool.push(matches!(b.kind, Kind::Tool { .. }));
ix.keys.insert(b.command.key(), i);
ix.names.add(i, b, str::to_string);
ix.segments.extend(b.paths.iter().flatten().map(|s| normalize_label(s)));
ix.segments.extend(b.paths.iter().chain(&b.import_paths).flatten().map(|s| normalize_label(s)));
if b.is_toggle {
ix.toggles.add(i, b, toggle_name);
}
@@ -1007,7 +1030,11 @@ fn suggest(keymap: &Keymap, entries: &[ToSuggest]) -> Vec<Suggestion> {
let candidates: Vec<namematch::Candidate> = pool
.iter()
.map(|b| {
let labels = std::iter::once(&b.label).chain(&b.aliases).chain(b.paths.iter().filter_map(|p| p.last())).cloned().collect();
let labels = std::iter::once(&b.label)
.chain(&b.aliases)
.chain(b.paths.iter().chain(&b.import_paths).filter_map(|p| p.last()))
.cloned()
.collect();
namematch::Candidate { id: b.command.id.clone(), labels }
})
.collect();
+19
View File
@@ -141,6 +141,10 @@ pub struct Bindable {
/// Menu paths, the main one first; used by search and import.
#[serde(default)]
pub paths: Vec<Vec<String>>,
/// Paths only import matching reads: where foreign sets file the command and the app's menus
/// do not, such as `["Tools", label]` for a tool. They make no areas or groups in the dialog.
#[serde(default)]
pub import_paths: Vec<Vec<String>>,
/// Other names: labels in other UI languages, older names.
#[serde(default)]
pub aliases: Vec<String>,
@@ -163,6 +167,7 @@ impl Bindable {
command: command.into(),
label: label.into(),
paths: Vec::new(),
import_paths: Vec::new(),
aliases: Vec::new(),
scope,
kind: Kind::Command,
@@ -192,6 +197,12 @@ impl Bindable {
self
}
/// Adds a path only import matching reads ([`Bindable::import_paths`]).
pub fn with_import_path(mut self, path: Vec<String>) -> Bindable {
self.import_paths.push(path);
self
}
pub fn with_alias(mut self, alias: impl Into<String>) -> Bindable {
self.aliases.push(alias.into());
self
@@ -774,6 +785,14 @@ impl Keymap {
t == by.0.effective(self.platform) && !self.twins(i, j, t) && self.scopes.relation(&bx.1, &by.1) == Some(true)
}
/// Whether both commands of `conflict` have its chord among their defaults, so they share it by
/// design and [`Keymap::clashing`] leaves them out.
#[cfg(feature = "ui")]
pub(crate) fn twins_in(&self, conflict: &Conflict) -> bool {
let (Some(i), Some(j)) = (self.find(&conflict.a.0), self.find(&conflict.b.0)) else { return false };
self.twins(i, j, conflict.chord)
}
fn find(&self, command: &CommandRef) -> Option<usize> {
self.index.get(&command.key()).copied()
}
+22 -12
View File
@@ -6,11 +6,13 @@
//! protocols store, display text per platform, and a reader for other applications' key text.
//! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's
//! catalog with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]),
//! editing and conflicts. [`reserved`] lists the chords each operating system takes; a keymap
//! reserves no chord unless the app opts in with [`Keymap::with_reserved`], e.g.
//! `with_reserved(reserved::system(platform))`. [`import`] brings in other applications' shortcut
//! sets. [`layout`] decides which chord a key press means on the user's keyboard layout, for
//! matching, recording and labels. With the `egui` feature, the `egui` module
//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the
//! chords each operating system takes, and a keymap reserves no chord unless the app opts in with
//! [`Keymap::with_reserved`], e.g. `with_reserved(reserved::system(platform))`. [`import`] brings
//! in other applications' shortcut sets. [`layout`] decides which chord a key press means on the
//! user's keyboard layout, for matching, recording and labels. [`editor`] is the model of a
//! shortcut editor: undo, a chord recorder, conflict queries and search. With the `egui` feature,
//! the `egui` module
//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's
//! key events.
//!
@@ -99,6 +101,10 @@
//! store.update(|p| p.shortcuts = keymap.overrides().clone());
//! ```
//!
//! An app with named sets stores only [`Profiles`] in place of the `Overrides` field and builds
//! the keymap from the active set:
//! `with_overrides(prefs.shortcut_sets.active_overrides(&builtins).clone())`.
//!
//! # Importing a shortcut set
//!
//! The app's reader builds a [`ForeignSet`]; [`plan`] matches it without changing anything, so a
@@ -114,13 +120,11 @@
//! let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows);
//!
//! let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows);
//! let set = ForeignSet {
//! name: Some("My Set".into()),
//! entries: vec![
//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12),
//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13),
//! ],
//! };
//! let set = ForeignSet::new(vec![
//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12),
//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13),
//! ])
//! .with_name("My Set");
//! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() };
//! let planned = plan(&set, &keymap, &options);
//! assert_eq!(planned.commands.len(), 1);
@@ -133,21 +137,27 @@
pub mod chord;
pub mod command;
pub mod editor;
#[cfg(feature = "egui")]
pub mod egui;
pub mod import;
pub mod keymap;
pub mod layout;
pub mod profiles;
pub mod reserved;
pub mod scope;
#[cfg(feature = "ui")]
pub mod ui;
pub use chord::foreign::Flavor;
pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger};
pub use command::{CommandRef, MAX_KEY};
pub use editor::{DOUBLE_PRESS, EditSession, MAX_UNDO, MAX_UNDO_BYTES, Recorded, Recorder};
pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan};
pub use keymap::{
AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT,
MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, text_editing_chords,
};
pub use layout::{KeyInput, Layout};
pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles, SetProblem};
pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes};
+562
View File
@@ -0,0 +1,562 @@
//! Named sets of overrides: the app's built-in sets (read only) and the user's own.
//!
//! ```
//! use craft_keymap::{Overrides, Profiles};
//!
//! let mut classic = Overrides::new();
//! classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]);
//! let builtins = [("Craft", Overrides::new()), ("Classic", classic)];
//! let mut sets = Profiles::default();
//! assert_eq!(sets.active_name(&builtins), "Craft");
//!
//! sets.duplicate("Classic", "Mine", &builtins).unwrap();
//! let text = sets.export("Mine", &builtins).unwrap();
//! assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into()));
//! assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap());
//! ```
use std::collections::BTreeMap;
use std::fmt;
use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Value, json};
use crate::import::{ImportPlan, ImportReport};
use crate::keymap::{Keymap, Overrides};
/// Most user sets.
pub const MAX_SETS: usize = 64;
/// Longest set name, in chars.
pub const MAX_SET_NAME: usize = 64;
/// Largest export document [`Profiles::import`] reads, in bytes.
pub const MAX_EXPORT_BYTES: usize = 4 << 20;
const FORMAT: &str = "craft-keymap";
const VERSION: u64 = 1;
/// The version stored [`Profiles`] carry; stored profiles without one read as version 1.
const STORE_VERSION: u64 = 1;
// Load notes are kept for `Profiles::problems`; a hostile file must not grow them without bound.
const MAX_NOTES: usize = 64;
const IMPORTED: &str = "Imported";
/// The app's built-in sets: name and overrides against the catalog defaults.
pub type Builtins<'a> = [(&'a str, Overrides)];
/// Why a set operation was refused.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum ProfileError {
/// Empty, over [`MAX_SET_NAME`] chars, or with control characters.
BadName,
NameTaken,
NotFound,
/// Built-in sets cannot be changed, renamed or deleted.
BuiltIn,
/// [`MAX_SETS`] user sets exist.
TooMany,
/// Not a JSON export document.
BadFile,
/// A `format` other than `craft-keymap`.
WrongFormat,
/// A `version` newer than this crate reads.
NewerVersion(u64),
/// Over [`MAX_EXPORT_BYTES`].
TooLarge,
}
impl fmt::Display for ProfileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ProfileError::BadName => "bad set name",
ProfileError::NameTaken => "a set with this name exists",
ProfileError::NotFound => "no such set",
ProfileError::BuiltIn => "built-in sets are read only",
ProfileError::TooMany => "too many sets",
ProfileError::BadFile => "not a shortcut set file",
ProfileError::WrongFormat => "not a craft-keymap file",
ProfileError::NewerVersion(v) => return write!(f, "shortcut set file version {v} is newer than this app reads"),
ProfileError::TooLarge => "shortcut set file too large",
};
f.write_str(s)
}
}
impl std::error::Error for ProfileError {}
/// Something loading dropped from stored [`Profiles`]; [`Profiles::problems`] lists them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum SetProblem {
/// A set without a valid name: none, empty, over [`MAX_SET_NAME`] chars, with control
/// characters or with spaces around it. `name` is what was stored, cut to [`MAX_SET_NAME`]
/// chars.
BadName { name: String },
/// A second set of this name; the first one stays.
Duplicate { name: String },
/// Overrides that are neither a list nor a map.
BadOverrides { name: String },
/// Sets past [`MAX_SETS`].
TooMany { ignored: usize },
/// Stored by a newer craft-keymap; what this version reads is loaded.
NewerVersion(u64),
}
/// The user's sets and which set is active. The built-in sets are passed to each call.
///
/// An active name that matches no set means the first built-in set (or no overrides when the app
/// has none).
///
/// A user set may carry a built-in's name, for instance after an update adds that built-in. Until
/// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call
/// `reconcile` after loading; every method that changes the profiles calls it first.
///
/// Stored as `{ version, active, sets }`. Loading never fails: what it drops is listed by
/// [`Profiles::problems`], and the next save writes the profiles without it.
#[derive(Clone, Debug, Default)]
pub struct Profiles {
active: String,
sets: BTreeMap<String, Overrides>,
notes: Vec<SetProblem>,
}
impl PartialEq for Profiles {
fn eq(&self, other: &Profiles) -> bool {
self.active == other.active && self.sets == other.sets
}
}
impl Eq for Profiles {}
static NO_OVERRIDES: Overrides = Overrides::new();
/// A name as given, trimmed; `None` when empty, too long or with control characters.
pub fn set_name(name: &str) -> Option<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 {
/// The stored active name, which may match no set; [`active_name`](Self::active_name) is the
/// set in use.
pub fn active(&self) -> &str {
&self.active
}
/// The user's sets.
pub fn sets(&self) -> &BTreeMap<String, Overrides> {
&self.sets
}
/// What loading dropped.
pub fn problems(&self) -> &[SetProblem] {
&self.notes
}
/// Built-in names first, then the user's sets.
pub fn names(&self, builtins: &Builtins) -> Vec<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);
}
// A settings file can hold names that differ only in case, so a case-only rename still
// checks the other sets.
let lower = to.to_lowercase();
let clash = self.sets.keys().any(|k| k != from && k.to_lowercase() == lower);
if to != from && (clash || (self.builtin_like(&to, builtins) && lower != from.to_lowercase())) {
return Err(ProfileError::NameTaken);
}
if let Some(set) = self.sets.remove(from) {
self.sets.insert(to.clone(), set);
}
if self.active == from {
self.active = to.clone();
}
Ok(to)
}
pub fn delete(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> {
self.reconcile(builtins);
if Self::is_builtin(name, builtins) {
return Err(ProfileError::BuiltIn);
}
self.sets.remove(name).ok_or(ProfileError::NotFound)?;
if self.active == name {
self.active = builtins.first().map_or(String::new(), |(n, _)| n.to_string());
}
Ok(())
}
/// Copies a set (built-in or user) to a new user set named `to` (made unique), which becomes
/// active. Returns its name.
pub fn duplicate(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result<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)
}
/// Whether `name` matches a built-in or user set, ignoring case.
pub(crate) fn taken(&self, name: &str, builtins: &Builtins) -> bool {
let lower = name.to_lowercase();
self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower)
}
/// `base`, or `base 2`, `base 3`… cut to fit [`MAX_SET_NAME`].
fn unique(&self, base: &str, builtins: &Builtins) -> Result<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)
}
}
impl Serialize for Profiles {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut s = serializer.serialize_struct("Profiles", 3)?;
s.serialize_field("version", &STORE_VERSION)?;
s.serialize_field("active", &self.active)?;
s.serialize_field("sets", &SetList(&self.sets))?;
s.end()
}
}
impl<'de> Deserialize<'de> for Profiles {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Profiles, D::Error> {
#[derive(Deserialize, Default)]
#[serde(default)]
struct Stored {
#[serde(deserialize_with = "lenient_version")]
version: Option<u64>,
#[serde(deserialize_with = "lenient_name")]
active: String,
#[serde(deserialize_with = "capped_sets")]
sets: Loaded,
}
let Stored { version, active, sets: Loaded { sets, mut notes, .. } } = Stored::deserialize(deserializer)?;
if let Some(v) = version.filter(|v| *v > STORE_VERSION) {
notes.insert(0, SetProblem::NewerVersion(v));
notes.truncate(MAX_NOTES);
}
Ok(Profiles { active, sets, notes })
}
}
fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
let v = Value::deserialize(deserializer)?;
Ok(v.as_str().and_then(set_name).unwrap_or_default())
}
fn lenient_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
Ok(Value::deserialize(deserializer)?.as_u64())
}
// An array of `{ name, overrides }` rather than a table, and each set's overrides in their array
// form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the
// file.
struct SetList<'a>(&'a BTreeMap<String, Overrides>);
impl Serialize for SetList<'_> {
fn serialize<S: Serializer>(&self, 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(self.0.iter().map(|(name, overrides)| Set { name, overrides }))
}
}
/// The sets that loaded, and notes on the ones that did not.
#[derive(Default)]
struct Loaded {
sets: BTreeMap<String, Overrides>,
notes: Vec<SetProblem>,
ignored: usize,
}
impl Loaded {
fn note(&mut self, problem: SetProblem) {
if self.notes.len() < MAX_NOTES {
self.notes.push(problem);
}
}
/// Keeps a set with a valid name, stored as written, that is new and fits under [`MAX_SETS`];
/// notes why not otherwise.
fn add(&mut self, name: &str, overrides: Option<Overrides>) {
let shown = || name.chars().take(MAX_SET_NAME).collect::<String>();
if !set_name(name).is_some_and(|n| n == name) {
self.note(SetProblem::BadName { name: shown() });
} else if self.sets.contains_key(name) {
self.note(SetProblem::Duplicate { name: shown() });
} else if self.sets.len() >= MAX_SETS {
self.ignored += 1;
} else if let Some(o) = overrides {
self.sets.insert(name.to_string(), o);
} else {
self.note(SetProblem::BadOverrides { name: shown() });
}
}
fn done(mut self) -> Loaded {
if self.ignored > 0 {
let ignored = std::mem::take(&mut self.ignored);
self.note(SetProblem::TooMany { ignored });
}
self
}
}
fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Loaded, 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(Loaded::default())
})*
};
}
impl<'de> Visitor<'de> for SetsVisitor {
type Value = Loaded;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides")
}
no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char);
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(Loaded::default())
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(Loaded::default())
}
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 = Loaded::default();
while let Some(item) = access.next_element::<Value>()? {
let name = item.get("name").and_then(Value::as_str).unwrap_or_default();
out.add(name, item.get("overrides").and_then(overrides_of));
}
Ok(out.done())
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut out = Loaded::default();
while let Some(key) = access.next_key::<String>()? {
if out.sets.len() >= MAX_SETS && !out.sets.contains_key(&key) {
access.next_value::<IgnoredAny>()?;
out.ignored += 1;
} else {
let overrides = overrides_of(&access.next_value::<Value>()?);
out.add(&key, overrides);
}
}
Ok(out.done())
}
}
/// A set's overrides: a list or a map, anything else is no set.
fn overrides_of(v: &Value) -> Option<Overrides> {
match v {
Value::Object(_) | Value::Array(_) => Overrides::deserialize(v).ok(),
_ => None,
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Taking a frame's key input while the dialog records, before the app's dispatcher or egui's
//! widgets see it.
use ::egui::{Event, EventFilter, Id, InputState, Ui};
use crate::chord::{Chord, Key, Mods, Platform};
use crate::egui::{Clip, clipboard_chord, clipboard_kind};
/// The keys a recording widget keeps from egui's focus navigation, so Tab, arrows and Escape are
/// recorded instead of moving focus.
pub(crate) const FOCUS_LOCK: EventFilter = EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true };
/// Holds egui's focus on `id` with [`FOCUS_LOCK`]; call each frame the widget records.
pub(crate) fn lock_focus(ui: &Ui, id: Id) {
ui.memory_mut(|m| m.set_focus_lock_filter(id, FOCUS_LOCK));
}
/// Gives Tab, arrows and Escape back to egui's focus navigation. egui keeps a focused widget's
/// filter until focus moves, so a widget that recorded calls this each frame it has focus and
/// does not record.
pub(crate) fn unlock_focus(ui: &Ui, id: Id) {
ui.memory_mut(|m| m.set_focus_lock_filter(id, EventFilter::default()));
}
/// Removes every key, text, IME and clipboard event from `input` and returns the chords pressed,
/// in order. Repeats, releases and modifier-only presses give no chord. Copy, Cut and Paste
/// count as the presses that cause them (Cmd+C, Cmd+X, Cmd+V, or Shift+Delete and Shift+Insert),
/// once when a backend sends both the press and the event.
pub(crate) fn take(input: &mut InputState, platform: Platform) -> Vec<Chord> {
let frame_mods = Mods::from_egui(&input.modifiers, platform);
let mut chords = Vec::new();
let mut kept = Vec::with_capacity(input.events.len());
let mut clipboard_press: Option<Clip> = None;
for event in std::mem::take(&mut input.events) {
match &event {
Event::Key { key, pressed, repeat, modifiers, .. } => {
let Some(key) = Key::from_egui(*key) else { continue };
if *pressed && !*repeat {
let held = Mods::from_egui(modifiers, platform);
if let Some(clip) = clipboard_kind(key, held) {
clipboard_press = Some(clip);
}
chords.push(Chord::new(held, key));
}
}
Event::Copy | Event::Cut | Event::Paste(_) => {
let (key, held, clip) = clipboard_chord(&event, frame_mods);
if clipboard_press == Some(clip) {
clipboard_press = None;
} else {
chords.push(Chord::new(held, key));
}
}
Event::Text(_) | Event::Ime(_) => {}
_ => kept.push(event),
}
}
input.events = kept;
chords
}
#[cfg(test)]
mod tests {
use ::egui::{Context, Key as EguiKey, Modifiers, RawInput};
use super::*;
fn run(events: Vec<Event>) -> (Vec<String>, usize) {
let ctx = Context::default();
let mut out = (Vec::new(), 0);
let mut full = ctx.run_ui(RawInput { events, ..RawInput::default() }, |ui| {
let chords = ui.ctx().input_mut(|i| take(i, Platform::Mac));
out = (chords.iter().map(Chord::to_string).collect(), ui.ctx().input(|i| i.events.len()));
});
full.textures_delta.clear();
out
}
fn press(key: EguiKey, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
#[test]
fn takes_presses_and_clipboard_events_and_leaves_nothing() {
let cmd = Modifiers::MAC_CMD;
let events = vec![
press(EguiKey::E, cmd),
Event::Text("e".into()),
Event::Key { key: EguiKey::E, physical_key: None, pressed: false, repeat: false, modifiers: cmd },
press(EguiKey::Tab, Modifiers::NONE),
// The web build: the press, then the event it causes, counted once.
press(EguiKey::C, cmd),
Event::Copy,
// egui-winit: only the event.
Event::Paste("x".into()),
Event::PointerGone,
];
let (chords, left) = run(events);
assert_eq!(chords, ["Cmd+E", "Tab", "Cmd+C", "Cmd+V"]);
assert_eq!(left, 1, "only the pointer event stays");
}
}
+639
View File
@@ -0,0 +1,639 @@
//! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout
//! colored by who holds each key with the chosen modifiers, modifier toggles that also follow the
//! held modifiers, the shortcuts of the clicked key, and Assign to Selected.
use std::collections::HashMap;
use std::sync::Arc;
use egui::text::{LayoutJob, TextFormat, TextWrapping};
use egui::{
Align, Button, Color32, CornerRadius, FontId, Layout, Rect, RichText, ScrollArea, Sense, Stroke, StrokeKind, Ui, WidgetInfo, WidgetType, pos2,
vec2,
};
use super::{Draw, fill};
use crate::chord::{Chord, Key, Mods, Trigger};
use crate::command::CommandRef;
use crate::keymap::{Binding, Holder, Keymap, Overrides};
use crate::scope::Scope;
/// The keyboard's width and height in key units: 15 for the main block, a gap, 3 for the
/// navigation cluster; six rows and the gap under the function row.
const UNITS_W: f32 = 18.5;
const UNITS_H: f32 = 6.25;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mod {
Cmd,
Ctrl,
Alt,
Shift,
}
impl Mod {
fn id(self) -> &'static str {
match self {
Mod::Cmd => "cmd",
Mod::Ctrl => "ctrl",
Mod::Alt => "alt",
Mod::Shift => "shift",
}
}
fn get(self, m: Mods) -> bool {
match self {
Mod::Cmd => m.cmd,
Mod::Ctrl => m.ctrl,
Mod::Alt => m.alt,
Mod::Shift => m.shift,
}
}
fn flip(self, m: &mut Mods) {
match self {
Mod::Cmd => m.cmd = !m.cmd,
Mod::Ctrl => m.ctrl = !m.ctrl,
Mod::Alt => m.alt = !m.alt,
Mod::Shift => m.shift = !m.shift,
}
}
}
/// A keycap: a key, a modifier, a key the dialog does not bind (Caps Lock, Win), or a gap.
#[derive(Clone, Copy)]
enum Cap {
Key(Key),
Mod(Mod),
CapsLock,
/// Win on Windows, Super on Linux; a Mac has none.
System,
Gap,
}
type Row = &'static [(Cap, f32)];
const fn k(key: Key) -> (Cap, f32) {
(Cap::Key(key), 1.0)
}
const MAIN_ROWS: [Row; 5] = [
&[
(Cap::Key(Key::Backtick), 1.0),
k(Key::Num1),
k(Key::Num2),
k(Key::Num3),
k(Key::Num4),
k(Key::Num5),
k(Key::Num6),
k(Key::Num7),
k(Key::Num8),
k(Key::Num9),
k(Key::Num0),
k(Key::Minus),
k(Key::Equals),
(Cap::Key(Key::Backspace), 2.0),
],
&[
(Cap::Key(Key::Tab), 1.5),
k(Key::Q),
k(Key::W),
k(Key::E),
k(Key::R),
k(Key::T),
k(Key::Y),
k(Key::U),
k(Key::I),
k(Key::O),
k(Key::P),
k(Key::OpenBracket),
k(Key::CloseBracket),
(Cap::Key(Key::Backslash), 1.5),
],
&[
(Cap::CapsLock, 1.75),
k(Key::A),
k(Key::S),
k(Key::D),
k(Key::F),
k(Key::G),
k(Key::H),
k(Key::J),
k(Key::K),
k(Key::L),
k(Key::Semicolon),
k(Key::Quote),
(Cap::Key(Key::Enter), 2.25),
],
&[
(Cap::Mod(Mod::Shift), 2.25),
k(Key::Z),
k(Key::X),
k(Key::C),
k(Key::V),
k(Key::B),
k(Key::N),
k(Key::M),
k(Key::Comma),
k(Key::Period),
k(Key::Slash),
(Cap::Mod(Mod::Shift), 2.75),
],
&[],
];
const FUNCTION_ROW: Row = &[
k(Key::Escape),
(Cap::Gap, 1.0),
k(Key::F1),
k(Key::F2),
k(Key::F3),
k(Key::F4),
(Cap::Gap, 0.5),
k(Key::F5),
k(Key::F6),
k(Key::F7),
k(Key::F8),
(Cap::Gap, 0.5),
k(Key::F9),
k(Key::F10),
k(Key::F11),
k(Key::F12),
];
const MAC_BOTTOM: Row = &[
(Cap::Mod(Mod::Ctrl), 1.5),
(Cap::Mod(Mod::Alt), 1.25),
(Cap::Mod(Mod::Cmd), 1.5),
(Cap::Key(Key::Space), 6.5),
(Cap::Mod(Mod::Cmd), 1.5),
(Cap::Mod(Mod::Alt), 1.25),
(Cap::Mod(Mod::Ctrl), 1.5),
];
/// Off macOS the Ctrl keys are the command modifier.
const PC_BOTTOM: Row = &[
(Cap::Mod(Mod::Cmd), 1.5),
(Cap::System, 1.25),
(Cap::Mod(Mod::Alt), 1.5),
(Cap::Key(Key::Space), 6.5),
(Cap::Mod(Mod::Alt), 1.5),
(Cap::System, 1.25),
(Cap::Mod(Mod::Cmd), 1.5),
];
/// The navigation cluster: (row, column in units, key). Row 0 is the function row.
const NAV: &[(usize, f32, Key)] = &[
(1, 0.0, Key::Insert),
(1, 1.0, Key::Home),
(1, 2.0, Key::PageUp),
(2, 0.0, Key::Delete),
(2, 1.0, Key::End),
(2, 2.0, Key::PageDown),
(4, 1.0, Key::Up),
(5, 0.0, Key::Left),
(5, 1.0, Key::Down),
(5, 2.0, Key::Right),
];
/// A binding on a key and the scope it works in.
#[derive(Clone, Debug)]
pub(crate) struct KeyHolder {
pub holder: Holder,
pub scope: Scope,
}
/// Who holds a key with given modifiers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Class {
Free,
App,
Scoped,
Both,
}
fn class(holders: &[KeyHolder]) -> Class {
match (holders.iter().any(|h| h.scope.is_app()), holders.iter().any(|h| !h.scope.is_app())) {
(false, false) => Class::Free,
(true, false) => Class::App,
(false, true) => Class::Scoped,
(true, true) => Class::Both,
}
}
/// The pane's state between frames.
#[derive(Clone, Debug, Default)]
pub(crate) struct KeyboardState {
/// Hidden with the header's Keyboard toggle.
pub hidden: bool,
/// The modifiers toggled on; the held ones are added each frame.
pub mods: Mods,
/// The key whose shortcuts are listed.
pub key: Option<Key>,
/// Every binding by effective chord, rebuilt when the overrides change.
index: Option<Arc<(Overrides, Index)>>,
}
type Index = HashMap<Chord, Vec<KeyHolder>>;
impl KeyboardState {
fn index(&mut self, keymap: &Keymap) -> Arc<(Overrides, Index)> {
if let Some(i) = self.index.as_ref().filter(|i| i.0 == *keymap.overrides()) {
return i.clone();
}
let platform = keymap.platform();
let mut map: HashMap<Chord, Vec<KeyHolder>> = HashMap::new();
for b in keymap.catalog() {
for binding in keymap.bindings(&b.command) {
let scope = binding.scope_or(&b.scope).clone();
let holder = Holder { command: b.command.clone(), binding };
map.entry(holder.binding.trigger.chord.effective(platform)).or_default().push(KeyHolder { holder, scope });
}
}
let index = Arc::new((keymap.overrides().clone(), map));
self.index = Some(index.clone());
index
}
}
fn mod_glyph(m: Mod) -> &'static str {
match m {
Mod::Cmd => "⌘",
Mod::Ctrl => "⌃",
Mod::Alt => "⌥",
Mod::Shift => "⇧",
}
}
/// The English legend printed on a key: Apple's words on macOS, PC words elsewhere.
fn cap_name(key: Key, mac: bool) -> &'static str {
match (key, mac) {
(Key::Escape, true) => "esc",
(Key::Escape, false) => "Esc",
(Key::Backspace, true) => "delete",
(Key::Delete, true) => "fwd del",
(Key::Insert, true) => "ins",
(Key::Enter, true) => "return",
(Key::Tab, true) => "tab",
(Key::PageUp, true) => "page up",
(Key::PageDown, true) => "page down",
(Key::Home, true) => "home",
(Key::End, true) => "end",
(Key::Insert, false) => "Ins",
(Key::PageUp, false) => "PgUp",
(Key::PageDown, false) => "PgDn",
(Key::Delete, false) => "Del",
(Key::Space, true) => "",
(Key::Up | Key::Down | Key::Left | Key::Right, _) => key.mac_symbol(),
(k, _) => k.text_name(),
}
}
fn union(a: Mods, b: Mods) -> Mods {
Mods { cmd: a.cmd || b.cmd, ctrl: a.ctrl || b.ctrl, alt: a.alt || b.alt, shift: a.shift || b.shift }
}
/// Black or white, whichever reads better on `fill`.
fn ink(fill: Color32) -> Color32 {
let l = 0.299 * f32::from(fill.r()) + 0.587 * f32::from(fill.g()) + 0.114 * f32::from(fill.b());
if l > 150.0 { Color32::from_gray(24) } else { Color32::from_gray(240) }
}
/// What a frame of the pane asked for.
#[derive(Default)]
struct Clicks {
key: Option<Key>,
toggle: Option<Mod>,
go: Option<CommandRef>,
assign: bool,
}
impl Draw<'_, '_> {
/// The pane's height for a middle area of `height`, or `None` when it is off or does not fit.
pub(super) fn keyboard_height(&self, height: f32) -> Option<f32> {
if !self.env.options.keyboard || self.state.keyboard.hidden {
return None;
}
let h = (height * 0.45).min(320.0).min(height - 160.0);
(h >= 120.0).then_some(h)
}
/// The header's toggle that shows and hides the pane.
pub(super) fn keyboard_toggle(&mut self, ui: &mut Ui) {
if !self.env.options.keyboard {
return;
}
let label = self.ui("Keyboard");
let r = ui.add(Button::selectable(!self.state.keyboard.hidden, self.vis(&label)));
self.tag(|| "shortcuts.keyboard".into(), r.rect, &label);
if r.clicked() {
self.state.keyboard.hidden = !self.state.keyboard.hidden;
}
}
fn mods_shown(&self) -> &'static [Mod] {
if self.env.keymap.platform().is_mac() { &[Mod::Cmd, Mod::Ctrl, Mod::Alt, Mod::Shift] } else { &[Mod::Cmd, Mod::Alt, Mod::Shift] }
}
/// A modifier's name, with its glyph on macOS.
fn mod_name(&self, m: Mod) -> String {
let word = self.mod_word(m);
if self.env.keymap.platform().is_mac() { format!("{} {word}", mod_glyph(m)) } else { word }
}
/// The key's name on its cap: the app's localized name, else the glyph on macOS.
fn cap_legend(&self, key: Key) -> String {
let p = self.env.keymap.platform();
self.env.text.key(key, p).unwrap_or_else(|| cap_name(key, p.is_mac()).to_string())
}
/// A modifier cap's word, without the glyph (which goes above it on macOS).
fn mod_word(&self, m: Mod) -> String {
match (m, self.env.keymap.platform().is_mac()) {
(Mod::Cmd, true) => self.ui("Command"),
(Mod::Ctrl, true) => self.ui("Control"),
(Mod::Alt, true) => self.ui("Option"),
(Mod::Cmd | Mod::Ctrl, false) => self.ui("Ctrl"),
(Mod::Alt, false) => self.ui("Alt"),
(Mod::Shift, _) => self.ui("Shift"),
}
}
/// The key's spoken name: the app's localized name, else the name Windows and Linux show.
fn key_name(&self, key: Key) -> String {
self.env.text.key(key, self.env.keymap.platform()).unwrap_or_else(|| key.text_name().to_string())
}
/// The holder's label, with its scope when that is not Application.
fn holder_text(&self, h: &KeyHolder) -> String {
let label = self.label_of(&h.holder.command);
if h.scope.is_app() { label } else { fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(&h.scope))]) }
}
pub(super) fn keyboard_pane(&mut self, ui: &mut Ui) {
let platform = self.env.keymap.platform();
let held = if self.state.recording.is_none() { ui.input(|i| Mods::from_egui(&i.modifiers, platform)) } else { Mods::NONE };
let mut clicks = Clicks::default();
let rect = ui.max_rect();
ui.painter().hline(rect.x_range(), rect.min.y, ui.visuals().widgets.noninteractive.bg_stroke);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
let layout = if platform.is_mac() { self.ui("Keyboard: US (macOS)") } else { self.ui("Keyboard: US") };
ui.label(RichText::new(self.vis(&layout)).color(self.style.dim));
ui.add_space(8.0);
for &m in self.mods_shown() {
let name = self.mod_name(m);
let on = m.get(union(self.state.keyboard.mods, held));
let r = ui.add(Button::selectable(on, self.vis(&name)));
self.tag(|| format!("shortcuts.mod.{}", m.id()), r.rect, &name);
if r.clicked() {
clicks.toggle = Some(m);
}
}
ui.add_space(12.0);
self.keyboard_legend(ui);
});
let mods = union(self.state.keyboard.mods, held);
let body = ui.available_rect_before_wrap();
let gap = 12.0;
let list_w = (body.width() * 0.3).clamp(160.0, 320.0).min(body.width() / 2.0);
let rtl = self.env.text.rtl();
let (kb, list) = if rtl {
(
Rect::from_min_max(pos2(body.min.x + list_w + gap, body.min.y), body.max),
Rect::from_min_max(body.min, pos2(body.min.x + list_w, body.max.y)),
)
} else {
(
Rect::from_min_max(body.min, pos2(body.max.x - list_w - gap, body.max.y)),
Rect::from_min_max(pos2(body.max.x - list_w, body.min.y), body.max),
)
};
self.keys_drawn(ui, kb, mods, &mut clicks);
self.child(ui, list, |d, ui| d.key_list(ui, mods, &mut clicks));
ui.advance_cursor_after_rect(body);
let kb_state = &mut self.state.keyboard;
if let Some(m) = clicks.toggle {
// A held modifier is on anyway; clicking it turns the toggle off.
if m.get(held) {
let mut off = kb_state.mods;
if m.get(off) {
m.flip(&mut off);
}
kb_state.mods = off;
} else {
m.flip(&mut kb_state.mods);
}
}
if let Some(key) = clicks.key {
kb_state.key = Some(key);
}
if let Some(c) = clicks.go {
self.state.go_to(&c);
}
if clicks.assign
&& let (Some(command), Some(key)) = (self.state.selected.clone(), self.state.keyboard.key)
{
let trigger = Trigger::single(Chord { mods, key });
let binding = match &self.state.context {
Some(scope) => Binding::in_scope(trigger, scope.clone()),
None => Binding::new(trigger),
};
self.state.stop_recording();
self.state.commit(self.env, &command, None, binding);
}
}
fn keyboard_legend(&self, ui: &mut Ui) {
let s = &self.style;
for (color, text) in [
(s.app_key, self.ui("Application")),
(s.scoped_key, self.ui("Panel or context")),
(s.both_key, self.ui("Both")),
(s.modifier_key, self.ui("Modifier on")),
] {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, color);
ui.label(RichText::new(self.vis(&text)).color(self.style.dim));
}
}
/// The keycaps in `area`, scaled to fit.
fn keys_drawn(&mut self, ui: &mut Ui, area: Rect, mods: Mods, clicks: &mut Clicks) {
let mac = self.env.keymap.platform().is_mac();
let u = (area.width() / UNITS_W).min(area.height() / UNITS_H).max(1.0);
let origin = area.min;
let row_y = |row: usize| origin.y + row as f32 * u + if row > 0 { 0.25 * u } else { 0.0 };
let index = self.state.keyboard.index(self.env.keymap);
let index = &index.1;
let bottom = if mac { MAC_BOTTOM } else { PC_BOTTOM };
let rows = std::iter::once(FUNCTION_ROW).chain(MAIN_ROWS[..4].iter().copied()).chain(std::iter::once(bottom));
for (r, row) in rows.enumerate() {
let mut x = origin.x;
for &(cap, w) in row {
let rect = Rect::from_min_size(pos2(x, row_y(r)), vec2(w * u, u));
x += w * u;
self.cap(ui, rect, cap, u, mods, index, clicks);
}
}
let nav_x = origin.x + 15.5 * u;
for &(r, col, key) in NAV {
let rect = Rect::from_min_size(pos2(nav_x + col * u, row_y(r)), vec2(u, u));
self.cap(ui, rect, Cap::Key(key), u, mods, index, clicks);
}
}
#[allow(clippy::too_many_arguments)]
fn cap(&mut self, ui: &mut Ui, rect: Rect, cap: Cap, u: f32, mods: Mods, index: &Index, clicks: &mut Clicks) {
if matches!(cap, Cap::Gap) {
return;
}
let r = rect.shrink((u * 0.05).clamp(1.0, 2.0));
let font = FontId::proportional((u * 0.26).clamp(7.0, 12.0));
let s = self.style;
let (bg, legend, title) = match cap {
Cap::Key(key) => {
let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice);
let bg = match class(holders) {
Class::Free => s.free_key,
Class::App => s.app_key,
Class::Scoped => s.scoped_key,
Class::Both => s.both_key,
};
// The application-wide holder names the key; a scoped one when there is none.
let first = holders.iter().find(|h| h.scope.is_app()).or(holders.first());
(bg, self.cap_legend(key), first.map(|h| self.label_of(&h.holder.command)))
}
Cap::Mod(m) => {
let glyph = self.env.keymap.platform().is_mac().then(|| mod_glyph(m).to_string());
(if m.get(mods) { s.modifier_key } else { s.free_key }, self.mod_word(m), glyph)
}
Cap::CapsLock => (s.free_key.gamma_multiply(0.7), self.ui("Caps Lock"), None),
Cap::System => {
let name = if self.env.keymap.platform() == crate::chord::Platform::Windows { self.ui("Win") } else { self.ui("Super") };
(s.free_key.gamma_multiply(0.7), name, None)
}
Cap::Gap => return,
};
let ink = ink(bg);
ui.painter().rect_filled(r, CornerRadius::same(3), bg);
let pad = (u * 0.1).clamp(2.0, 4.0);
let text_w = (r.width() - 2.0 * pad).max(1.0);
let lines = |text: &str, rows: usize, color: Color32| {
let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font.clone(), color));
job.wrap = TextWrapping { max_width: text_w, max_rows: rows, break_anywhere: false, overflow_character: Some('…') };
ui.painter().layout_job(job)
};
if let Some(t) = &title {
let rows = if r.height() > 4.0 * font.size { 2 } else { 1 };
let g = lines(&self.vis(t), rows, ink);
ui.painter().galley(r.min + vec2(pad, pad * 0.5), g, ink);
}
// A cap without a command name has room for a two-line legend ("page up").
let legend_rows = if title.is_none() && r.height() > 3.0 * font.size { 2 } else { 1 };
let g = lines(&self.vis(&legend), legend_rows, ink.gamma_multiply(0.85));
ui.painter().galley(pos2(r.min.x + pad, r.max.y - pad * 0.5 - g.size().y), g, ink);
let Cap::Key(key) = cap else {
if let Cap::Mod(m) = cap {
// Modifier caps toggle like the buttons above; the buttons are their accessible form.
let resp = ui.interact(r, self.base.with(("kbmod", m.id(), r.min.x as i32)), Sense::CLICK);
if resp.clicked() {
clicks.toggle = Some(m);
}
if resp.hovered() {
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside);
}
}
return;
};
let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice);
let chord = self.chord(&Trigger::single(Chord { mods, key }));
let spoken = if mods.is_none() { self.key_name(key) } else { chord };
let mut names: Vec<String> = Vec::new();
for h in holders {
let t = self.holder_text(h);
if !names.contains(&t) {
names.push(t);
}
}
let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) };
let selected = self.state.keyboard.key == Some(key);
let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::click());
resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y));
let description = match class(holders) {
Class::Free => self.ui("Not assigned"),
Class::App => self.ui("Application"),
Class::Scoped => self.ui("Panel or context"),
Class::Both => self.ui("Both"),
};
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_description(description));
self.tag(|| format!("shortcuts.key.{}", key.name()), r, &a11y);
let v = ui.visuals();
if selected {
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside);
} else if resp.has_focus() {
ui.painter().rect_stroke(r, CornerRadius::same(3), v.selection.stroke, StrokeKind::Outside);
} else if resp.hovered() {
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside);
}
if resp.clicked() {
clicks.key = Some(key);
}
if !names.is_empty() {
resp.on_hover_text(self.vis(&names.join("\n")));
}
}
/// The clicked key's shortcuts with any modifiers, and Assign to Selected.
fn key_list(&mut self, ui: &mut Ui, mods: Mods, clicks: &mut Clicks) {
let Some(key) = self.state.keyboard.key else {
ui.label(RichText::new(self.vis(&self.ui("Click a key to see its shortcuts"))).color(self.style.dim));
return;
};
let platform = self.env.keymap.platform();
let title = fill(self.ui("Key: {key}"), &[("key", &self.key_name(key))]);
ui.label(RichText::new(self.vis(&title)).strong());
if self.env.options.editable {
let chord = self.chord(&Trigger::single(Chord { mods, key }));
let selected = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some());
let label = self.ui("Assign to Selected");
let r = ui.add_enabled(selected.is_some(), Button::new(self.vis(&label)));
self.tag(|| "shortcuts.assignKey".into(), r.rect, &label);
let r = match &selected {
Some(c) => r.on_hover_text(
self.vis_chord(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]), &chord),
),
None => r.on_disabled_hover_text(self.vis(&self.ui("Select a command first"))),
};
if r.clicked() {
clicks.assign = true;
}
}
let index = self.state.keyboard.index(self.env.keymap);
let mut entries: Vec<(Chord, KeyHolder)> =
index.1.iter().filter(|(c, _)| c.key == key).flat_map(|(c, hs)| hs.iter().map(|h| (*c, h.clone()))).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.holder.command.id.cmp(&b.1.holder.command.id)));
if entries.is_empty() {
ui.label(RichText::new(self.vis(&self.ui("Nothing uses this key"))).color(self.style.dim));
return;
}
let current = Chord { mods, key }.effective(platform);
let chord_w = 72.0;
let chord_layout = if self.env.text.rtl() { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) };
ScrollArea::vertical().id_salt(self.base.with("keylist")).auto_shrink([false, false]).show(ui, |ui| {
for (c, h) in &entries {
ui.horizontal(|ui| {
let mut text = RichText::new(self.chord(&h.holder.binding.trigger));
if *c == current {
text = text.strong();
}
ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), chord_layout, |ui| ui.label(text));
if ui.link(self.vis(&self.holder_text(h))).clicked() {
clicks.go = Some(h.holder.command.clone());
}
});
}
});
}
}
+309
View File
@@ -0,0 +1,309 @@
//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalog with recording,
//! conflicts, undo, named sets and import review, over [`Keymap`] and optionally [`Profiles`].
//!
//! The app keeps a [`DialogState`] while the dialog is open and calls [`ShortcutsDialog::show`]
//! (or [`ShortcutsDialog::ui`] inside its own container) every frame. While
//! [`DialogState::is_open`] is true the app skips its own key dispatch; the dialog takes the
//! frame's key events itself while it records. The app saves on [`Outcome::Ok`] (or on
//! [`Outcome::Changed`] for live saving) and drops the state once it is closed.
use std::collections::BTreeMap;
use crate::chord::Trigger;
use crate::command::CommandRef;
use crate::import::{ForeignSet, ImportOptions};
use crate::keymap::{Bindable, Binding, Keymap};
use crate::profiles::{Builtins, ProfileError, Profiles};
mod capture;
mod rows;
mod state;
mod style;
pub mod summary;
mod text;
mod view;
pub use state::DialogState;
pub use style::DialogStyle;
pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text};
use state::Env;
/// What the dialog reports for a frame.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum Outcome {
/// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`.
Ok,
/// Closed with Cancel: the keymap and profiles are back as they were when it opened. When that
/// undid edits, `Changed` follows on the next frame.
Cancel,
/// An edit (for live menus and live saving). When a frame also has another outcome, such as
/// `Import` after the Save changes prompt, `Changed` follows on the next frame.
Changed,
/// The user asked to import: the app opens a file of one of these formats and calls
/// [`ShortcutsDialog::import_json`] or [`ShortcutsDialog::review_import`].
Import { formats: Vec<&'static str> },
/// A set as craft-keymap JSON, for the app to write.
Export { name: String, json: String },
/// A set in one of [`Options::export_formats`], for the app to write.
ExportAs { format: &'static str, name: String, keymap: Box<Keymap> },
/// The shortcut summary, for the app to show, save or print.
Summary { name: String, html: String, text: String },
/// Import answers the user gave (item → command), for the app to keep for later imports.
Remembered(BTreeMap<String, CommandRef>),
}
/// What happens when a recorded chord is held by other commands.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConflictMode {
/// Ask with the conflict bar: Take It, Take It and Go to Conflict, Keep Both, Cancel.
#[default]
Ask,
/// Take the chord from the other commands, then say so.
Steal,
/// Keep both bindings, then say so.
Keep,
}
/// An app's own Show entry.
#[derive(Clone, Copy, Debug)]
pub struct Area {
pub id: &'static str,
pub label: &'static str,
pub rows: fn(&Bindable) -> bool,
}
/// A read-only row for a key outside the catalog.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FixedRow {
pub group: String,
pub label: String,
/// The keys as shown.
pub keys: String,
/// The trigger, when the row has one, so a catalog binding on it can be pointed out.
pub chord: Option<Trigger>,
}
/// A file format an app reads or writes itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Format {
pub id: &'static str,
pub label: &'static str,
pub extensions: &'static [&'static str],
}
/// A rule an app adds for which bindings a command may get; `Err` carries the message shown.
pub type Accept = Box<dyn Fn(&Bindable, &Binding) -> Result<(), String>>;
/// Called for every control once per frame with (automation id, rect, label).
pub type OnWidget = Box<dyn Fn(&str, egui::Rect, &str)>;
/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field;
/// set fields on it, or with [`Options::with`].
#[non_exhaustive]
pub struct Options {
/// "Keyboard Shortcuts": the window title [`ShortcutsDialog::show`] draws, and the summary's
/// heading. [`ShortcutsDialog::ui`] draws no title; the host's container shows its own.
pub title: &'static str,
/// true; false shows the table read-only with Close and Summarize.
pub editable: bool,
/// "Custom": the name edits of a built-in set are saved under.
pub custom_set_name: &'static str,
/// Ask.
pub conflicts: ConflictMode,
/// true: Keep Both is offered.
pub allow_keep: bool,
/// false: a recorded chord waits [`DOUBLE_PRESS`](crate::DOUBLE_PRESS) for a second press.
pub double_press: bool,
/// false: Backspace or Delete without modifiers removes the binding being recorded over.
pub backspace_clears: bool,
/// false: Enter when idle is OK.
pub enter_is_ok: bool,
/// false: rows sort by translated label instead of catalog order.
pub sort_by_label: bool,
/// false: Show as tabs instead of a dropdown.
pub area_tabs: bool,
/// The app's own Show entries.
pub areas: Vec<Area>,
/// `None`: every catalog entry is listed. Otherwise the table, its Show entries and search
/// list only the entries it returns true for; the others keep their bindings and still count
/// in conflicts.
pub visible: Option<fn(&Bindable) -> bool>,
/// false: the on-screen keyboard pane.
pub keyboard: bool,
pub fixed: Vec<FixedRow>,
pub import_formats: Vec<Format>,
pub export_formats: Vec<Format>,
/// true: Summarize is offered.
pub summary: bool,
pub accept: Option<Accept>,
pub on_widget: Option<OnWidget>,
/// `None`: [`DialogStyle::from_visuals`].
pub style: Option<DialogStyle>,
}
impl Default for Options {
fn default() -> Options {
Options {
title: "Keyboard Shortcuts",
editable: true,
custom_set_name: "Custom",
conflicts: ConflictMode::Ask,
allow_keep: true,
double_press: false,
backspace_clears: false,
enter_is_ok: false,
sort_by_label: false,
area_tabs: false,
areas: Vec::new(),
visible: None,
keyboard: false,
fixed: Vec::new(),
import_formats: Vec::new(),
export_formats: Vec::new(),
summary: true,
accept: None,
on_widget: None,
style: None,
}
}
}
impl Options {
/// `self` with `f` applied, for options built in one expression:
/// `Options::default().with(|o| o.keyboard = true)`.
pub fn with(mut self, f: impl FnOnce(&mut Options)) -> Options {
f(&mut self);
self
}
}
/// The format id of craft-keymap's own JSON in [`Outcome::Import`].
pub const JSON_FORMAT: &str = "craft-keymap";
/// The dialog for one frame: what it edits, borrowed, and how it looks.
pub struct ShortcutsDialog<'a> {
keymap: &'a mut Keymap,
text: &'a dyn Text,
sets: Option<(&'a mut Profiles, &'a Builtins<'a>)>,
options: Option<&'a Options>,
}
impl<'a> ShortcutsDialog<'a> {
pub fn new(keymap: &'a mut Keymap, text: &'a dyn Text) -> Self {
ShortcutsDialog { keymap, text, sets: None, options: None }
}
/// Shows and edits named sets.
pub fn sets(mut self, profiles: &'a mut Profiles, builtins: &'a Builtins<'a>) -> Self {
self.sets = Some((profiles, builtins));
self
}
pub fn options(mut self, options: &'a Options) -> Self {
self.options = Some(options);
self
}
fn with_env<R>(self, f: impl FnOnce(&mut Env) -> R) -> R {
let fallback;
let options = match self.options {
Some(o) => o,
None => {
fallback = Options::default();
&fallback
}
};
let mut env = Env { keymap: self.keymap, sets: self.sets, options, text: self.text };
f(&mut env)
}
/// A window with title, body and Cancel/OK, sized to the screen (at least 640×480 where the
/// screen allows, at most 1320×900). Closing it with × is Cancel.
pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option<Outcome> {
self.with_env(|env| {
if !state.is_showing() {
return state.finish();
}
let title = env.text.visual(&env.text.ui(env.options.title));
let screen = ctx.content_rect().size();
let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0));
let min = egui::vec2(640.0, 480.0).min(max);
let mut open = true;
let mut out = None;
egui::Window::new(title)
.id(egui::Id::new("craft_keymap.shortcuts"))
.collapsible(false)
.resizable(true)
.open(&mut open)
.default_size((screen * 0.8).clamp(min, max))
.min_size(min)
.max_size(max)
.show(ctx, |ui| out = frame(ui, env, state));
if !open && state.is_showing() {
state.cancel(env);
out = state.finish();
}
if state.change_pending() {
ctx.request_repaint();
}
out
})
}
/// The body only, for a host's own modal, preferences page or backstage, which draws the
/// title. It fills the available rectangle.
pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option<Outcome> {
self.with_env(|env| frame(ui, env, state))
}
/// OK, for hosts that draw their own button.
pub fn ok(self, state: &mut DialogState) {
self.with_env(|env| {
state.begin(env);
state.ok(env);
});
}
/// Cancel, for hosts that draw their own button.
pub fn cancel(self, state: &mut DialogState) {
self.with_env(|env| {
state.begin(env);
state.cancel(env);
});
}
/// Reads a craft-keymap JSON set the app loaded after [`Outcome::Import`]. With sets it becomes
/// a new set, which is loaded; without, its overrides replace the keymap's as one undo step.
/// Returns the set's name.
pub fn import_json(self, state: &mut DialogState, text: &str) -> Result<String, ProfileError> {
self.with_env(|env| {
state.begin(env);
state.import_json(env, text)
})
}
/// Opens the review of a foreign set the app read: what will change, what did not match and
/// why, with suggestions to pick from.
pub fn review_import(self, state: &mut DialogState, set: ForeignSet, options: ImportOptions) {
self.with_env(|env| {
state.begin(env);
state.review_import(env, set, options);
});
}
}
/// One frame: take recorded keys before anything else sees them, draw, report.
fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option<Outcome> {
state.begin(env);
state.input(env, ui.ctx());
if state.is_showing() {
view::body(ui, env, state);
}
let out = state.finish();
if state.change_pending() {
ui.ctx().request_repaint();
}
out
}
+378
View File
@@ -0,0 +1,378 @@
//! The foreign import review (§2.7 of the dialog plan). It takes the place of the table and
//! detail pane: what the set will change, what did not match and why, suggestions to pick from,
//! and after the import the report of what was skipped, taken or failed.
use egui::text::{LayoutJob, TextFormat};
use egui::{ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui};
use super::{Draw, FIELD_W, fill};
use crate::chord::foreign::{ForeignError, ReadNote};
use crate::command::CommandRef;
use crate::import::{FailedChange, ImportPlan, ImportReport, NoChangeReason, SkipReason, UnmatchedReason};
/// One list row: the item, its line, its keys and a note.
struct Entry {
item: String,
line: Option<usize>,
keys: String,
note: String,
/// The note is what the item becomes (Will change), drawn in the text color, not dimmed.
to: bool,
/// Chord text inside the note, kept out of bidi reordering.
note_chord: String,
}
enum Act {
Pick(usize, usize),
Import,
Close,
Copy(String),
}
impl Draw<'_, '_> {
/// Draws the review in `rect` while one is open; returns whether it drew.
pub(super) fn review(&mut self, ui: &mut Ui, rect: Rect) -> bool {
let Some(r) = self.state.review.as_ref() else { return false };
let (plan, name, done) = (r.plan.clone(), r.name.clone(), r.done.clone());
let mut act = None;
self.child(ui, rect, |d, ui| {
act = match &done {
Some((set, report)) => d.after_report(ui, set, report),
None => d.plan_view(ui, &plan, name),
};
});
match act {
Some(Act::Pick(s, c)) => self.state.pick_suggestion(self.env, s, c),
Some(Act::Import) => self.state.import_reviewed(self.env),
Some(Act::Close) => self.state.close_review(),
Some(Act::Copy(text)) => ui.ctx().copy_text(text),
None => {}
}
true
}
fn line_text(&self, line: Option<usize>) -> Option<String> {
line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())]))
}
/// "item · line n · keys — note" on one wrapping line, the item in the strong color.
fn entry(&self, ui: &mut Ui, e: &Entry) {
let font = TextStyle::Body.resolve(ui.style());
let strong = ui.visuals().strong_text_color();
let dim = self.style.dim;
let text = ui.visuals().text_color();
let mut parts = vec![(self.vis(&e.item), strong)];
if let Some(line) = self.line_text(e.line) {
parts.extend([(" · ".to_string(), dim), (self.vis(&line), dim)]);
}
if !e.keys.is_empty() {
parts.extend([(" · ".to_string(), dim), (e.keys.clone(), text)]);
}
if !e.note.is_empty() {
let color = if e.to { text } else { dim };
parts.extend([(" — ".to_string(), color), (self.vis_chord(&e.note, &e.note_chord), color)]);
}
let mut job = LayoutJob::default();
for (s, color) in self.ordered(parts) {
job.append(&s, 0.0, TextFormat::simple(font.clone(), color));
}
ui.add(Label::new(job).wrap());
}
fn section(&self, ui: &mut Ui, title: &str, entries: &[Entry]) {
if entries.is_empty() {
return;
}
ui.add_space(4.0);
ui.label(RichText::new(self.vis(&fill(self.ui("{title} ({n})"), &[("title", title), ("n", &entries.len().to_string())]))).strong());
for e in entries {
self.entry(ui, e);
}
}
fn unmatched_reason(&self, r: UnmatchedReason) -> String {
match r {
UnmatchedReason::NoMatch => self.ui("no command has this name"),
UnmatchedReason::Ambiguous => self.ui("more than one command fits"),
UnmatchedReason::Context => self.ui("in a context that is not imported"),
UnmatchedReason::Tool => self.ui("only a command of the other kind fits (tool or menu command)"),
}
}
fn foreign_error(&self, e: &ForeignError) -> String {
match e {
ForeignError::Empty => self.ui("empty shortcut"),
ForeignError::TooLong => self.ui("the shortcut is too long"),
ForeignError::UnknownKey(k) => fill(self.ui("unknown key {key}"), &[("key", k)]),
ForeignError::UnsupportedModifier(m) => fill(self.ui("unsupported modifier {key}"), &[("key", m)]),
}
}
fn read_note(&self, n: ReadNote) -> String {
match n {
ReadNote::Numpad => self.ui("numeric keypad key read as main key"),
}
}
fn plan_view(&mut self, ui: &mut Ui, plan: &ImportPlan, mut name: String) -> Option<Act> {
let mut act = None;
let report = &plan.report;
let set = report.set.clone().unwrap_or_else(|| self.ui("Imported"));
ui.label(RichText::new(self.vis(&fill(self.ui("Import “{set}”"), &[("set", &set)]))).strong());
let t = self.env.text;
let mut counts = vec![t.plural(plan.commands.len() as u64, "{n} command will change", "{n} commands will change")];
let more = [
(report.unmatched.len(), t.plural(report.unmatched.len() as u64, "{n} item not matched", "{n} items not matched")),
(
report.unmatched_without_shortcut,
t.plural(report.unmatched_without_shortcut as u64, "{n} item without a shortcut", "{n} items without a shortcut"),
),
(report.unreadable.len(), t.plural(report.unreadable.len() as u64, "{n} item could not be read", "{n} items could not be read")),
(report.approximated.len(), t.plural(report.approximated.len() as u64, "{n} key read approximately", "{n} keys read approximately")),
(report.truncated, t.plural(report.truncated as u64, "{n} entry cut at a limit", "{n} entries cut at a limit")),
];
counts.extend(more.into_iter().filter(|(n, _)| *n > 0).map(|(_, s)| s));
ui.label(RichText::new(self.vis(&counts.join(" · "))).color(self.style.dim));
ui.horizontal_wrapped(|ui| {
let name_label = self.ui("Set name:");
let l = ui.label(self.vis(&name_label));
let edit = ui.add(TextEdit::singleline(&mut name).id(self.base.with("import_name")).desired_width(FIELD_W)).labelled_by(l.id);
self.text_field(&edit);
self.tag(|| "shortcuts.import.name".into(), edit.rect, &name_label);
let import = if self.env.sets.is_some() { self.ui("Import as New Set") } else { self.ui("Import") };
let valid = self.env.sets.is_none() || crate::profiles::set_name(&name).is_some();
if self.button(ui, valid, &import, "shortcuts.import.ok") {
act = Some(Act::Import);
}
if self.button(ui, true, &self.ui("Cancel Import"), "shortcuts.import.cancel") {
act = Some(Act::Close);
}
});
if let Some(r) = self.state.review.as_mut() {
r.name = name;
}
ScrollArea::vertical().id_salt(self.base.with("review")).auto_shrink([false, false]).show(ui, |ui| {
let chord_list = |d: &Self, c: &crate::import::Planned| -> String {
let home = d.env.keymap.bindable(&c.command).map(|b| b.scope.clone());
if c.bindings.is_empty() {
return d.ui("None");
}
c.bindings.iter().map(|b| d.binding_text(&b.binding, home.as_ref())).collect::<Vec<_>>().join(", ")
};
let changes: Vec<Entry> = plan
.commands
.iter()
.map(|c| {
let chords = chord_list(self, c);
Entry {
item: c.item.clone(),
line: c.line,
keys: String::new(),
note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chords)]),
to: true,
note_chord: chords,
}
})
.collect();
self.section(ui, &self.ui("Will change"), &changes);
if !report.suggestions.is_empty() {
ui.add_space(4.0);
let title = fill(self.ui("Suggestions ({n})"), &[("n", &report.suggestions.len().to_string())]);
ui.label(RichText::new(self.vis(&title)).strong());
for (si, s) in report.suggestions.iter().enumerate() {
ui.horizontal_wrapped(|ui| {
self.entry(
ui,
&Entry {
item: s.item.clone(),
line: s.line,
keys: String::new(),
note: String::new(),
to: false,
note_chord: String::new(),
},
);
let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]);
let choose = self.ui("Choose a command…");
let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| {
for (ci, c) in s.candidates.iter().enumerate() {
if ui.selectable_label(false, self.vis(&self.label_of(c))).clicked() {
act = Some(Act::Pick(si, ci));
}
}
});
let resp = combo.response.on_hover_text(self.vis(&label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label));
self.tag(|| format!("shortcuts.import.suggestion.{si}"), resp.rect, &label);
});
}
}
let unmatched: Vec<Entry> = report
.unmatched
.iter()
.map(|u| Entry {
item: u.item.clone(),
line: u.line,
keys: u.keys.clone(),
note: self.unmatched_reason(u.reason),
to: false,
note_chord: String::new(),
})
.collect();
self.section(ui, &self.ui("Not matched"), &unmatched);
let unreadable: Vec<Entry> = report
.unreadable
.iter()
.map(|u| Entry {
item: u.item.clone(),
line: u.line,
keys: u.keys.clone(),
note: self.foreign_error(&u.reason),
to: false,
note_chord: String::new(),
})
.collect();
self.section(ui, &self.ui("Unreadable"), &unreadable);
let approximated: Vec<Entry> = report
.approximated
.iter()
.map(|a| Entry {
item: a.item.clone(),
line: a.line,
keys: a.keys.clone(),
note: self.read_note(a.note),
to: false,
note_chord: String::new(),
})
.collect();
self.section(ui, &self.ui("Approximated"), &approximated);
let kept: Vec<Entry> = report
.matched_without_change
.iter()
.map(|m| Entry {
item: m.item.clone(),
line: m.line,
keys: String::new(),
note: match m.reason {
NoChangeReason::PanelMenu => {
fill(self.ui("{label} keeps its shortcuts (matched by a panel menu name)"), &[("label", &self.label_of(&m.command))])
}
},
to: false,
note_chord: String::new(),
})
.collect();
self.section(ui, &self.ui("Matched without change"), &kept);
});
act
}
fn skip_note(&self, reason: SkipReason, kept_by: Option<&CommandRef>) -> String {
match (reason, kept_by) {
(SkipReason::Taken, Some(c)) => fill(self.ui("taken by {label}"), &[("label", &self.label_of(c))]),
(SkipReason::Taken, None) => self.ui("taken by a later entry"),
(SkipReason::Reserved, _) => self.ui("reserved by the system"),
(SkipReason::UnknownScope, _) => self.ui("the scope is unknown"),
}
}
fn failed_note(&self, change: FailedChange) -> String {
match change {
FailedChange::Bind => self.ui("the set's shortcuts could not be added"),
FailedChange::Clear => self.ui("the shortcuts could not be removed"),
}
}
/// The report's lists: skipped keys, shortcuts taken from other commands, failed changes.
fn report_lists(&self, report: &ImportReport) -> [(String, Vec<Entry>); 3] {
let skipped = report
.skipped
.iter()
.map(|s| Entry {
item: s.item.clone(),
line: s.line,
keys: s.shortcut.clone(),
note: self.skip_note(s.reason, s.kept_by.as_ref()),
to: false,
note_chord: String::new(),
})
.collect();
let cleared = report
.conflicts_cleared
.iter()
.map(|c| Entry {
item: self.label_of(&c.command),
line: None,
keys: c.shortcut.clone(),
note: String::new(),
to: false,
note_chord: String::new(),
})
.collect();
let failed = report
.failed
.iter()
.map(|f| Entry {
item: self.label_of(&f.command),
line: None,
keys: f.shortcuts.join(", "),
note: self.failed_note(f.change),
to: false,
note_chord: String::new(),
})
.collect();
[(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)]
}
fn after_report(&mut self, ui: &mut Ui, set: &str, report: &ImportReport) -> Option<Act> {
let mut act = None;
let heading = fill(self.ui("Imported as “{set}”"), &[("set", set)]);
ui.label(RichText::new(self.vis(&heading)).strong());
let changed = self.env.text.plural(report.imported as u64, "{n} command changed", "{n} commands changed");
ui.label(RichText::new(self.vis(&changed)).color(self.style.dim));
let lists = self.report_lists(report);
ui.horizontal_wrapped(|ui| {
if self.button(ui, true, &self.ui("Copy Report"), "shortcuts.import.copy") {
act = Some(Act::Copy(self.report_text(&heading, &changed, &lists)));
}
if self.button(ui, true, &self.ui("Done"), "shortcuts.import.done") {
act = Some(Act::Close);
}
});
ScrollArea::vertical().id_salt(self.base.with("report")).auto_shrink([false, false]).show(ui, |ui| {
for (title, entries) in &lists {
self.section(ui, title, entries);
}
});
act
}
/// The report as plain text for Copy Report (logical order).
fn report_text(&self, heading: &str, changed: &str, lists: &[(String, Vec<Entry>)]) -> String {
let mut out = format!("{heading}\n{changed}\n");
for (title, entries) in lists.iter().filter(|(_, e)| !e.is_empty()) {
out.push_str(&format!("\n{title}\n"));
for e in entries {
let mut line = format!(" {}", e.item);
for part in [self.line_text(e.line).unwrap_or_default(), e.keys.clone()] {
if !part.is_empty() {
line.push_str(" · ");
line.push_str(&part);
}
}
if !e.note.is_empty() {
line.push_str(" — ");
line.push_str(&e.note);
}
out.push_str(&line);
out.push('\n');
}
}
out
}
}
+692
View File
@@ -0,0 +1,692 @@
//! The table's model: areas (Show), filters, search, group headers, sorting and the cached list of
//! lines the table draws.
use std::collections::{BTreeSet, HashMap, HashSet};
use crate::chord::{Platform, Trigger};
use crate::command::CommandRef;
use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides};
use crate::ui::Options;
use crate::ui::text::{Text, trigger_text, visual_around};
/// One entry of the Show menu.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum AreaId {
All,
/// A top menu, `paths[0][0]`.
Menu(String),
Tools,
/// A panel scope, by name.
Panel(String),
/// Commands with no path that are not tools.
Other,
/// An app area from [`Options::areas`], by id.
App(&'static str),
/// [`Options::fixed`].
Fixed,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct AreaEntry {
pub id: AreaId,
pub label: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub(crate) enum Filter {
#[default]
All,
WithShortcut,
/// An overrides entry exists for the command.
Changed,
/// The command is in a [`ConflictKind::Clash`].
Conflicts,
}
impl Filter {
pub(crate) const ALL: [Filter; 4] = [Filter::All, Filter::WithShortcut, Filter::Changed, Filter::Conflicts];
pub(crate) fn label(self, text: &dyn Text) -> String {
match self {
Filter::All => text.ui("All"),
Filter::WithShortcut => text.ui("With Shortcut"),
Filter::Changed => text.ui("Changed"),
Filter::Conflicts => text.ui("Conflicts"),
}
.into_owned()
}
}
/// A binding as a row shows it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Chip {
pub binding: Binding,
/// Trigger text, plus ` (Scope)` for a scope other than the command's home scope (display order).
pub text: String,
/// Not one of the command's defaults.
pub changed: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Row {
/// Position in the catalog.
pub index: usize,
pub command: CommandRef,
/// [`CommandRef::key`], for automation ids and the overrides.
pub key: String,
/// The translated label.
pub label: String,
/// Translated path parts between the group header and the label, shown dim before it.
pub prefix: Vec<String>,
pub chips: Vec<Chip>,
/// An overrides entry exists.
pub changed: bool,
/// A tool sharing a key with another tool of its group.
pub cycles: bool,
/// In a clash.
pub conflict: bool,
}
/// A read-only row from [`Options::fixed`].
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct FixedLine {
pub label: String,
pub keys: String,
/// The label of a catalog command now bound to the row's chord: drawn struck through.
pub now_used_by: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Line {
Header { name: String, collapsed: bool },
Row(Row),
Fixed(FixedLine),
}
/// The lines for one query, area, filter and set of overrides.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Built {
pub lines: Vec<Line>,
/// Command and fixed rows shown (headers not counted); 0 shows "No matching commands".
pub matches: usize,
/// Clash pairs in the whole keymap.
pub clashes: usize,
/// Shadow pairs in the whole keymap, shown as an information line.
pub shadows: usize,
}
impl Built {
/// The line index of a command's row.
pub(crate) fn position(&self, command: &CommandRef) -> Option<usize> {
self.lines.iter().position(|l| matches!(l, Line::Row(r) if r.command == *command))
}
}
/// What the lines were built from.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct View<'a> {
pub query: &'a str,
pub area: &'a AreaId,
pub filter: Filter,
pub collapsed: &'a BTreeSet<String>,
}
#[derive(Clone, Debug, PartialEq)]
struct Key {
query: String,
area: AreaId,
filter: Filter,
collapsed: BTreeSet<String>,
overrides: Overrides,
platform: Platform,
}
/// The built lines, rebuilt only when the view or the keymap's overrides change.
#[derive(Clone, Debug, Default)]
pub(crate) struct RowCache {
key: Option<Key>,
built: Built,
}
impl RowCache {
pub(crate) fn get(&mut self, keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> &Built {
let fresh = self.key.as_ref().is_some_and(|k| {
k.query == view.query
&& k.area == *view.area
&& k.filter == view.filter
&& k.collapsed == *view.collapsed
&& k.platform == keymap.platform()
&& k.overrides == *keymap.overrides()
});
if !fresh {
self.built = build(keymap, options, text, view);
self.key = Some(Key {
query: view.query.to_string(),
area: view.area.clone(),
filter: view.filter,
collapsed: view.collapsed.clone(),
overrides: keymap.overrides().clone(),
platform: keymap.platform(),
});
}
&self.built
}
/// The lines last built.
pub(crate) fn built(&self) -> &Built {
&self.built
}
}
fn top_menu(b: &Bindable) -> Option<&str> {
b.paths.first().and_then(|p| p.first()).map(String::as_str)
}
fn is_tool(b: &Bindable) -> bool {
matches!(b.kind, Kind::Tool { .. })
}
/// The catalog entries the table lists ([`Options::visible`]).
fn listed<'k>(keymap: &'k Keymap, options: &Options) -> impl Iterator<Item = &'k Bindable> + Clone {
let visible = options.visible;
keymap.catalog().iter().filter(move |b| visible.is_none_or(|f| f(b)))
}
/// The Show entries, in catalog order, leaving out areas with no rows.
pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec<AreaEntry> {
let catalog = listed(keymap, options);
let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }];
let mut menus: Vec<&str> = Vec::new();
for m in catalog.clone().filter_map(top_menu) {
if !menus.contains(&m) {
menus.push(m);
}
}
for m in menus {
let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]);
out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label });
}
if catalog.clone().any(is_tool) {
out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() });
}
let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect();
for b in catalog.clone().filter(|b| !b.scope.is_app()) {
if !panels.contains(&b.scope.as_str()) {
panels.push(b.scope.as_str());
}
}
for p in panels.into_iter().filter(|p| catalog.clone().any(|b| b.scope.as_str() == *p)) {
out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) });
}
if catalog.clone().any(|b| top_menu(b).is_none() && !is_tool(b)) {
out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() });
}
for a in options.areas.iter().filter(|a| catalog.clone().any(a.rows)) {
out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() });
}
if !options.fixed.is_empty() {
out.push(AreaEntry { id: AreaId::Fixed, label: text.ui("Fixed Keys").into_owned() });
}
out
}
fn panel_label(text: &dyn Text, panel: &str) -> String {
craft_i18n::fmt(&text.ui("{panel} Panel"), &[("panel", &text.segment(panel))])
}
fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool {
match area {
AreaId::All => true,
AreaId::Menu(m) => top_menu(b) == Some(m.as_str()),
AreaId::Tools => is_tool(b),
AreaId::Panel(p) => b.scope.as_str() == p,
AreaId::Other => top_menu(b).is_none() && !is_tool(b),
AreaId::App(id) => options.areas.iter().any(|a| a.id == *id && (a.rows)(b)),
AreaId::Fixed => false,
}
}
/// The first id segment, capitalized: `layer.lock` → `Layer`.
fn id_group(id: &str) -> String {
let first = id.split('.').next().unwrap_or(id);
let mut chars = first.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
}
/// A row's group header (`None`: no header) with its rank among groups, and how many path parts
/// the header stands for.
pub(crate) fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option<String>, usize) {
let path = b.paths.first().map(Vec::as_slice).unwrap_or_default();
let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth));
match area {
AreaId::All => menu(1).unwrap_or_else(|| {
if let Kind::Tool { .. } = b.kind {
(1, Some(text.ui("Tools").into_owned()), 0)
} else if !b.scope.is_app() {
(2, Some(panel_label(text, b.scope.as_str())), 0)
} else {
(3, Some(text.ui("Other").into_owned()), 0)
}
}),
AreaId::Menu(_) if submenus && path.len() >= 3 => (0, path.get(1).map(|s| text.segment(s).into_owned()), 2),
AreaId::Menu(_) => (0, None, 1),
AreaId::Tools => match &b.kind {
Kind::Tool { group: Some(g) } => (0, Some(text.segment(g).into_owned()), usize::MAX),
_ => (0, None, usize::MAX),
},
AreaId::Other => (0, Some(id_group(&b.command.id)), usize::MAX),
AreaId::Panel(_) | AreaId::App(_) | AreaId::Fixed => menu(1).unwrap_or((0, None, usize::MAX)),
}
}
/// What a query asks for: lowercase text, and the trigger it spells, if any.
struct Query {
text: String,
trigger: Option<Trigger>,
}
impl Query {
fn new(q: &str, platform: Platform) -> Query {
let text = q.trim().to_lowercase();
let trigger = Trigger::parse(q.trim()).ok().map(|t| t.effective(platform));
Query { text, trigger }
}
fn hit(&self, s: &str) -> bool {
s.to_lowercase().contains(&self.text)
}
/// Label, aliases, path parts and id, as written and translated; then the bindings: the same
/// effective trigger when the query spells one, else their canonical or display text. A query
/// that spells a chord (`cmd+s`, `⌘S`) does not also match longer chords containing its text.
fn matches(&self, b: &Bindable, bindings: &[Binding], text: &dyn Text, platform: Platform) -> bool {
if self.text.is_empty() {
return true;
}
let named = self.hit(&b.label)
|| self.hit(&text.command(b))
|| b.aliases.iter().any(|a| self.hit(a))
|| b.paths.iter().flatten().any(|s| self.hit(s) || self.hit(&text.segment(s)))
|| self.hit(&b.command.id);
named
|| match self.trigger {
Some(t) => bindings.iter().any(|x| x.trigger.effective(platform) == t),
None => bindings.iter().any(|x| {
self.hit(&x.trigger.to_string()) || self.hit(&x.trigger.display(platform)) || self.hit(&trigger_text(text, &x.trigger, platform))
}),
}
}
}
fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built {
let platform = keymap.platform();
let catalog = keymap.catalog();
let mut built = Built::default();
let mut clashing: HashSet<String> = HashSet::new();
for c in keymap.conflicts().into_iter().filter(|c| !keymap.twins_in(c)) {
match c.kind {
ConflictKind::Clash => {
built.clashes += 1;
clashing.insert(c.a.0.key());
clashing.insert(c.b.0.key());
}
ConflictKind::Shadow => built.shadows += 1,
}
}
let query = Query::new(view.query, platform);
if *view.area == AreaId::Fixed {
build_fixed(keymap, options, text, view, &query, &mut built);
return built;
}
let bindings: Vec<Vec<Binding>> = catalog.iter().map(|b| keymap.bindings(&b.command)).collect();
// How many tools of a group hold each effective trigger.
let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new();
for (b, list) in catalog.iter().zip(&bindings) {
if let Kind::Tool { group } = &b.kind {
for x in list {
*tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1;
}
}
}
let submenus = match view.area {
AreaId::Menu(m) => listed(keymap, options).any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)),
_ => false,
};
// Groups in first-seen order within their rank.
let mut groups: Vec<(u8, Option<String>, Vec<Row>)> = Vec::new();
for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() {
let hidden = options.visible.is_some_and(|f| !f(b));
if hidden || !in_area(view.area, b, options) || !query.matches(b, list, text, platform) {
continue;
}
let key = b.command.key();
let changed = keymap.overrides().get(&key).is_some();
let conflict = clashing.contains(&key);
let keep = match view.filter {
Filter::All => true,
Filter::WithShortcut => !list.is_empty(),
Filter::Changed => changed,
Filter::Conflicts => conflict,
};
if !keep {
continue;
}
let (rank, header, depth) = group(view.area, b, submenus, text);
let path = b.paths.first().map(Vec::as_slice).unwrap_or_default();
let prefix = path.get(depth..path.len().saturating_sub(1)).unwrap_or_default().iter().map(|s| text.segment(s).into_owned()).collect();
let defaults = &b.defaults;
let chips = list
.iter()
.map(|x| {
let mut t = trigger_text(text, &x.trigger, platform);
if x.scope_or(&b.scope) != &b.scope {
let scope = text.segment(x.scope_or(&b.scope).as_str());
let s = craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &t), ("scope", &scope)]);
t = visual_around(text, &s, &t);
}
Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) }
})
.collect();
let cycles = match &b.kind {
Kind::Tool { group } => list.iter().any(|x| tool_keys.get(&(group.as_deref(), x.trigger.effective(platform))).is_some_and(|n| *n > 1)),
Kind::Command => false,
};
let row = Row { index, command: b.command.clone(), key, label: text.command(b).into_owned(), prefix, chips, changed, cycles, conflict };
match groups.iter_mut().find(|(r, h, _)| *r == rank && *h == header) {
Some((_, _, rows)) => rows.push(row),
None => groups.push((rank, header, vec![row])),
}
}
groups.sort_by_key(|(rank, _, _)| *rank);
for (_, header, mut rows) in groups {
if options.sort_by_label {
rows.sort_by_cached_key(|r| r.label.to_lowercase());
}
built.matches += rows.len();
let collapsed = header.as_ref().is_some_and(|h| view.collapsed.contains(h));
if let Some(name) = header {
built.lines.push(Line::Header { name, collapsed });
}
if !collapsed {
built.lines.extend(rows.into_iter().map(Line::Row));
}
}
built
}
fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, query: &Query, built: &mut Built) {
if matches!(view.filter, Filter::Changed | Filter::Conflicts) {
return;
}
let platform = keymap.platform();
let mut groups: Vec<(&str, Vec<FixedLine>)> = Vec::new();
for f in &options.fixed {
if !query.text.is_empty()
&& !(query.hit(&f.label)
|| query.hit(&f.keys)
|| query.hit(&f.group)
|| query.trigger.is_some_and(|t| f.chord.is_some_and(|c| c.effective(platform) == t)))
{
continue;
}
let now_used_by = f.chord.and_then(|c| {
let c = c.effective(platform);
keymap
.catalog()
.iter()
.find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c))
.map(|b| text.command(b).into_owned())
});
let line = FixedLine { label: f.label.clone(), keys: f.keys.clone(), now_used_by };
match groups.iter_mut().find(|(g, _)| *g == f.group) {
Some((_, lines)) => lines.push(line),
None => groups.push((&f.group, vec![line])),
}
}
for (name, lines) in groups {
built.matches += lines.len();
let collapsed = view.collapsed.contains(name);
built.lines.push(Line::Header { name: name.to_string(), collapsed });
if !collapsed {
built.lines.extend(lines.into_iter().map(Line::Fixed));
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::borrow::Cow;
use super::*;
use crate::keymap::Bindable;
use crate::scope::{Scope, Scopes};
use crate::ui::text::English;
use crate::ui::{Area, FixedRow};
fn key(text: &str) -> Binding {
Binding::parse(text).unwrap_or_else(|_| Binding::new(Trigger::single(crate::Chord::from(crate::Key::F35))))
}
/// The fixture of the dialog tests: Save, Save As, Export, Arrange › Bring to Front, two
/// tools sharing M, Timeline Loop, Bold in the Text context.
pub(crate) fn fixture(platform: Platform) -> Keymap {
let tool = Kind::Tool { group: Some("Marquee".into()) };
let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app);
let catalog = vec![
Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")),
Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")),
Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")),
Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])),
Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(key("M")),
Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(key("M")),
Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")),
Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")),
];
Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform)
}
fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet<String>) -> View<'a> {
View { query, area, filter, collapsed }
}
fn labels(built: &Built) -> Vec<String> {
built
.lines
.iter()
.map(|l| match l {
Line::Header { name, .. } => format!("# {name}"),
Line::Row(r) => {
let chips: Vec<&str> = r.chips.iter().map(|c| c.text.as_str()).collect();
let cycles = if r.cycles { " (cycles)" } else { "" };
format!("{}{}: {}{cycles}", r.prefix.iter().map(|p| format!("{p} › ")).collect::<String>(), r.label, chips.join(", "))
}
Line::Fixed(f) => format!("{} = {}{}", f.label, f.keys, f.now_used_by.as_ref().map(|l| format!(" (now {l})")).unwrap_or_default()),
})
.collect()
}
fn lines(km: &Keymap, options: &Options, text: &dyn Text, query: &str, area: AreaId, filter: Filter) -> Vec<String> {
let none = BTreeSet::new();
labels(&build(km, options, text, &view(query, &area, filter, &none)))
}
#[test]
fn areas_follow_paths_kind_and_scope() {
let km = fixture(Platform::Mac);
let options = Options { areas: vec![Area { id: "type", label: "Type", rows: |b| b.command.id.starts_with("type.") }], ..Options::default() };
let names: Vec<String> = areas(&km, &options, &English).into_iter().map(|a| a.label).collect();
assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other", "Type"]);
let all = lines(&km, &options, &English, "", AreaId::All, Filter::All);
assert_eq!(
all,
[
"# File",
"Save: ⌘S",
"Save As…: ⇧⌘S",
"Export…: ⌘E",
"# Object",
"Arrange › Bring to Front: ",
"# Tools",
"Marquee Tool: M (cycles)",
"Elliptical Marquee Tool: M (cycles)",
"# Timeline Panel",
"Loop: L",
"# Other",
"Bold: ⌘B (Text)",
]
);
assert_eq!(lines(&km, &options, &English, "", AreaId::Menu("Object".into()), Filter::All), ["# Arrange", "Bring to Front: "]);
assert_eq!(lines(&km, &options, &English, "", AreaId::Panel("Timeline".into()), Filter::All), ["Loop: L"]);
assert_eq!(lines(&km, &options, &English, "", AreaId::Other, Filter::All), ["# Timeline", "Loop: L", "# Type", "Bold: ⌘B (Text)"]);
}
#[test]
fn sort_by_label_and_collapsed_groups() {
let km = fixture(Platform::Mac);
let options = Options { sort_by_label: true, ..Options::default() };
let area = AreaId::Menu("File".into());
let none = BTreeSet::new();
assert_eq!(labels(&build(&km, &options, &English, &view("", &area, Filter::All, &none))), ["Export…: ⌘E", "Save: ⌘S", "Save As…: ⇧⌘S"]);
let collapsed: BTreeSet<String> = ["File".to_string()].into();
let built = build(&km, &options, &English, &view("", &AreaId::All, Filter::All, &collapsed));
assert_eq!(built.lines.first(), Some(&Line::Header { name: "File".into(), collapsed: true }));
assert!(matches!(built.lines.get(1), Some(Line::Header { .. })));
}
struct Spanish;
impl Text for Spanish {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
English.plural(n, one, other)
}
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
if b.command.id == "file.export" { "Exportar…".into() } else { b.label.as_str().into() }
}
}
#[test]
fn search_by_name_translation_or_chord() {
let km = fixture(Platform::Mac);
let o = Options::default();
let find = |q: &str| -> Vec<String> {
let none = BTreeSet::new();
build(&km, &o, &Spanish, &view(q, &AreaId::All, Filter::All, &none))
.lines
.into_iter()
.filter_map(|l| if let Line::Row(r) = l { Some(r.key) } else { None })
.collect()
};
assert_eq!(find("save"), ["file.save", "file.saveAs"]);
assert_eq!(find("exportar"), ["file.export"]);
assert_eq!(find("cmd+s"), ["file.save"]);
assert_eq!(find("⌘S"), ["file.save"]);
assert_eq!(find("⇧⌘S"), ["file.saveAs"]);
assert_eq!(find("Text: Cmd+B"), Vec::<String>::new(), "a binding text with a scope is not a trigger");
assert_eq!(find("cmd+b"), ["type.bold"], "a trigger matches in any scope");
assert_eq!(find("arrange"), ["object.front"]);
assert!(find("zzz").is_empty());
}
#[test]
fn filters_and_conflict_counts() -> Result<(), crate::AssignError> {
let mut km = fixture(Platform::Windows);
let o = Options::default();
km.assign(&"file.export".into(), key("Cmd+S"), crate::Policy::Keep)?;
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Changed), ["# File", "Export…: Ctrl+E, Ctrl+S"]);
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: Ctrl+S", "Export…: Ctrl+E, Ctrl+S"]);
assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("Object".into()), Filter::WithShortcut), Vec::<String>::new());
let none = BTreeSet::new();
let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none));
assert_eq!((built.clashes, built.matches), (1, 8));
Ok(())
}
#[test]
fn import_paths_make_no_areas_or_groups() {
let tool = |id: &str, label: &str| {
Bindable::new(id, label, Scope::app())
.with_kind(Kind::Tool { group: None })
.with_import_path(vec!["Tools".into(), label.into()])
.with_default(key("P"))
};
let km = Keymap::new(vec![tool("tool.pen", "Pen Tool")], Scopes::new(), Platform::Mac);
let o = Options::default();
let names: Vec<String> = areas(&km, &o, &English).into_iter().map(|a| a.label).collect();
assert_eq!(names, ["All Commands", "Tools"]);
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::All), ["# Tools", "Pen Tool: P"]);
}
#[test]
fn hidden_entries_are_not_listed_but_still_conflict() -> Result<(), crate::AssignError> {
let mut km = fixture(Platform::Mac);
let o = Options { visible: Some(|b| b.command.id != "file.export" && !b.command.id.starts_with("timeline.")), ..Options::default() };
let names: Vec<String> = areas(&km, &o, &English).into_iter().map(|a| a.label).collect();
assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Other"]);
assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("File".into()), Filter::All), ["Save: ⌘S", "Save As…: ⇧⌘S"]);
assert!(lines(&km, &o, &English, "export", AreaId::All, Filter::All).is_empty());
km.assign(&"file.save".into(), key("Cmd+E"), crate::Policy::Keep)?;
let none = BTreeSet::new();
assert_eq!(build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)).clashes, 1);
assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: ⌘S, ⌘E"]);
Ok(())
}
#[test]
fn commands_sharing_a_default_key_are_no_conflict() -> Result<(), crate::AssignError> {
let menu = Bindable::new("app.characterPanel", "Character", Scope::app()).with_path(vec!["Window".into(), "Character".into()]);
let catalog = vec![
menu.with_default(key("Cmd+T")),
Bindable::new("panel.character", "Character Panel", Scope::app()).with_default(key("Cmd+T")),
Bindable::new("file.save", "Save", Scope::app()).with_default(key("Cmd+S")),
];
let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac);
let o = Options::default();
let none = BTreeSet::new();
let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none));
assert_eq!((built.clashes, built.shadows), (0, 0));
assert!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts).is_empty());
km.assign(&"file.save".into(), key("Cmd+T"), crate::Policy::Keep)?;
let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none));
assert_eq!(built.clashes, 2, "Save clashes with both twins");
let flagged: Vec<String> =
built.lines.iter().filter_map(|l| if let Line::Row(r) = l { r.conflict.then(|| r.key.clone()) } else { None }).collect();
assert_eq!(flagged, ["app.characterPanel", "panel.character", "file.save"]);
Ok(())
}
#[test]
fn fixed_rows_strike_through_when_a_command_takes_their_chord() {
let km = fixture(Platform::Mac);
let fixed = |label: &str, keys: &str, chord: Option<&str>| FixedRow {
group: "Rating".into(),
label: label.into(),
keys: keys.into(),
chord: chord.and_then(|c| Trigger::parse(c).ok()),
};
let o = Options { fixed: vec![fixed("Rate 1", "1", Some("1")), fixed("Loop", "L", Some("L"))], ..Options::default() };
assert_eq!(lines(&km, &o, &English, "", AreaId::Fixed, Filter::All), ["# Rating", "Rate 1 = 1", "Loop = L (now Loop)"]);
}
#[test]
fn cache_rebuilds_on_overrides_and_view_changes() -> Result<(), crate::AssignError> {
let mut km = fixture(Platform::Mac);
let o = Options::default();
let mut cache = RowCache::default();
let none = BTreeSet::new();
let area = AreaId::Menu("File".into());
let before = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone();
km.assign(&"file.save".into(), key("F2"), crate::Policy::Steal)?;
let after = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone();
assert_ne!(before, after);
assert_eq!(cache.get(&km, &o, &English, &view("save as", &area, Filter::All, &none)).matches, 1);
Ok(())
}
}
+212
View File
@@ -0,0 +1,212 @@
//! The set row (§2.6 of the dialog plan): the set picker, then New Set…, Rename…, Delete, Save,
//! Revert Set, Export, Import… and Summarize…; actions that do not fit go into a More menu. New
//! Set… and Rename… open an inline form under the row.
use craft_ui::layout::fit_toolbar;
use egui::{ComboBox, Key as EguiKey, RichText, TextEdit, Ui};
use super::{Draw, FIELD_W};
use crate::profiles::{Profiles, set_name};
use crate::ui::state::{DialogState, SetForm, SetFormKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Act {
New,
Rename,
Delete,
Save,
Revert,
/// Export as craft-keymap JSON; with app formats the item is a menu.
Export,
ExportAs(&'static str),
Import,
Summarize,
}
struct Item {
act: Act,
text: String,
id: &'static str,
enabled: bool,
}
impl Draw<'_, '_> {
/// Drawn above the header when the app passes sets and the dialog is editable.
pub(super) fn sets_row(&mut self, ui: &mut Ui) {
if !self.env.options.editable {
return;
}
let Some((p, b)) = self.env.sets.as_ref() else { return };
let names = p.names(b);
let active = p.active_name(b).to_string();
let builtin = Profiles::is_builtin(&active, b);
let modified = DialogState::is_modified(self.env);
let items = self.set_items(builtin, modified);
let mut act = None;
ui.add_enabled_ui(!self.state.modal(), |ui| {
ui.horizontal(|ui| {
let set_label = self.ui("Set:");
let l = ui.label(self.vis(&set_label));
let mut pick = None;
self.combo(ui, "set", &l, &set_label, &active, |ui| {
for n in &names {
if ui.selectable_label(*n == active, self.vis(n)).clicked() {
pick = Some(n.clone());
}
}
});
if modified {
ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim));
}
if let Some(n) = pick.filter(|n| *n != active) {
self.state.set_form = None;
self.state.switch_set(self.env, &n);
}
act = self.set_toolbar(ui, &items);
});
self.set_form(ui);
});
if let Some(a) = act {
self.set_act(a, &active);
}
}
fn set_items(&self, builtin: bool, modified: bool) -> Vec<Item> {
let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled };
let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" };
let mut items = vec![
item(Act::New, "New Set…", "shortcuts.set.new", true),
item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin),
item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin),
item(Act::Save, "Save", "shortcuts.set.save", modified),
item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified),
item(Act::Export, export, "shortcuts.set.export", true),
item(Act::Import, "Import…", "shortcuts.set.import", true),
];
if self.env.options.summary {
items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true));
}
items
}
/// The actions that fit in the row's remaining width, then More with the rest.
fn set_toolbar(&self, ui: &mut Ui, items: &[Item]) -> Option<Act> {
let more = self.ui("More");
let natural: Vec<f32> = items.iter().map(|i| self.button_width(ui, &i.text)).collect();
let spacing = ui.spacing().item_spacing.x;
let (shown, overflow): (Vec<usize>, Vec<usize>) = match fit_toolbar(&natural, ui.available_width(), spacing, self.button_width(ui, &more)) {
Some(fit) => (fit.shown.iter().map(|&(i, _)| i).collect(), fit.overflow),
None => (Vec::new(), (0..items.len()).collect()),
};
let mut act = None;
for i in shown {
act = act.or(self.set_item(ui, &items[i]));
}
if !overflow.is_empty() {
let r = ui.menu_button(self.vis(&more), |ui| {
for &i in &overflow {
act = act.or(self.set_item(ui, &items[i]));
}
});
self.tag(|| "shortcuts.set.more".into(), r.response.rect, &more);
}
act
}
fn set_item(&self, ui: &mut Ui, item: &Item) -> Option<Act> {
if item.act == Act::Export && !self.env.options.export_formats.is_empty() {
let mut act = None;
let r = ui.menu_button(self.vis(&item.text), |ui| {
if self.button(ui, true, &self.ui("Keyboard Shortcuts (JSON)"), "shortcuts.set.export.json") {
act = Some(Act::Export);
}
for f in &self.env.options.export_formats {
let label = self.env.text.ui(f.label).into_owned();
if self.button(ui, true, &label, &format!("shortcuts.set.export.{}", f.id)) {
act = Some(Act::ExportAs(f.id));
}
}
});
self.tag(|| item.id.into(), r.response.rect, &item.text);
return act;
}
self.button(ui, item.enabled, &item.text, item.id).then_some(item.act)
}
fn set_act(&mut self, act: Act, active: &str) {
let form = |kind, name: &str| Some(SetForm { kind, name: name.to_string(), focus: true });
match act {
Act::New => self.state.set_form = form(SetFormKind::New { based_on: None }, ""),
Act::Rename => self.state.set_form = form(SetFormKind::Rename, active),
Act::Delete => self.state.ask_delete_set(self.env),
Act::Save => self.state.save_set(self.env),
Act::Revert => self.state.revert_set(self.env),
Act::Export => self.state.export_set(self.env),
Act::ExportAs(f) => self.state.export_as(self.env, f),
Act::Import => self.state.request_import(self.env),
Act::Summarize => self.state.summarize(self.env),
}
}
/// New Set… (Name, Based on) or Rename… (Name), under the set row.
fn set_form(&mut self, ui: &mut Ui) {
let Some(mut form) = self.state.set_form.clone() else { return };
let names = self.env.sets.as_ref().map(|(p, b)| p.names(b)).unwrap_or_default();
let (mut submit, mut cancel) = (false, false);
ui.horizontal_wrapped(|ui| {
let name_label = self.ui("Name:");
let l = ui.label(self.vis(&name_label));
let edit = ui.add(TextEdit::singleline(&mut form.name).id(self.base.with("set_name")).desired_width(FIELD_W)).labelled_by(l.id);
self.text_field(&edit);
self.tag(|| "shortcuts.setForm.name".into(), edit.rect, &name_label);
if std::mem::take(&mut form.focus) {
edit.request_focus();
}
if edit.lost_focus() {
let (enter, escape) = ui.input(|i| (i.key_pressed(EguiKey::Enter), i.key_pressed(EguiKey::Escape)));
submit |= enter;
cancel |= escape;
}
if let SetFormKind::New { based_on } = &mut form.kind {
let based_label = self.ui("Based on:");
let l = ui.label(self.vis(&based_label));
let current = self.ui("Current shortcuts");
let shown = based_on.clone().unwrap_or_else(|| current.clone());
let combo = ComboBox::from_id_salt(self.base.with("based_on")).selected_text(self.vis(&shown)).show_ui(ui, |ui| {
if ui.selectable_label(based_on.is_none(), self.vis(&current)).clicked() {
*based_on = None;
}
for n in &names {
if ui.selectable_label(based_on.as_ref() == Some(n), self.vis(n)).clicked() {
*based_on = Some(n.clone());
}
}
});
let combo = combo.response.labelled_by(l.id);
self.tag(|| "shortcuts.setForm.basedOn".into(), combo.rect, &based_label);
}
let ok = match form.kind {
SetFormKind::New { .. } => self.ui("Create"),
SetFormKind::Rename => self.ui("Rename"),
};
submit |= self.button(ui, set_name(&form.name).is_some(), &ok, "shortcuts.setForm.ok");
cancel |= self.button(ui, true, &self.ui("Cancel"), "shortcuts.setForm.cancel");
});
if cancel {
self.state.set_form = None;
return;
}
if !submit || set_name(&form.name).is_none() {
self.state.set_form = Some(form);
return;
}
self.state.message = None;
match &form.kind {
SetFormKind::New { based_on } => self.state.new_set(self.env, &form.name, based_on.as_deref()),
SetFormKind::Rename => self.state.rename_set(self.env, &form.name),
}
// A refused name keeps the form open for another try.
let refused = self.state.message.as_ref().is_some_and(|m| m.refused);
self.state.set_form = refused.then_some(form);
}
}
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
//! The dialog's colors and the icon-button style built from them.
use craft_ui::buttons::{ButtonVisuals, IconButtonStyle};
use craft_ui::layout::ResizeHandleStyle;
use egui::{Color32, Stroke, Visuals};
/// The dialog's colors.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DialogStyle {
pub accent: Color32,
pub warning: Color32,
pub dim: Color32,
pub changed: Color32,
pub app_key: Color32,
pub scoped_key: Color32,
pub both_key: Color32,
pub modifier_key: Color32,
pub free_key: Color32,
}
impl DialogStyle {
pub fn from_visuals(v: &Visuals) -> DialogStyle {
let accent = v.selection.bg_fill;
DialogStyle {
accent,
warning: v.warn_fg_color,
dim: v.weak_text_color(),
changed: accent,
app_key: accent,
scoped_key: v.warn_fg_color,
both_key: v.error_fg_color,
modifier_key: v.widgets.active.bg_fill,
free_key: v.widgets.inactive.bg_fill,
}
}
}
/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colors of the app's visuals.
pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle {
let w = &v.widgets;
IconButtonStyle {
normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: w.inactive.fg_stroke.color },
hovered: ButtonVisuals { fill: w.hovered.weak_bg_fill, stroke: w.hovered.bg_stroke, icon: w.hovered.fg_stroke.color },
pressed: Some(ButtonVisuals { fill: w.active.weak_bg_fill, stroke: w.active.bg_stroke, icon: w.active.fg_stroke.color }),
selected: ButtonVisuals { fill: v.selection.bg_fill, stroke: Stroke::NONE, icon: v.selection.stroke.color },
corner_radius: w.inactive.corner_radius,
focus_stroke: v.selection.stroke,
}
}
/// The table/detail divider.
pub(crate) fn divider(v: &Visuals) -> ResizeHandleStyle {
ResizeHandleStyle { hovered: v.widgets.hovered.bg_fill, dragged: v.widgets.active.bg_fill, focused: v.selection.bg_fill }
}
+113
View File
@@ -0,0 +1,113 @@
//! The shortcut summary behind Summarize…: every command that has a shortcut, grouped as in the
//! dialog's All Commands view, as an HTML page and as plain text. Apps can also call these for a
//! cheat sheet outside the dialog.
use std::fmt::Write as _;
use crate::keymap::{Binding, Keymap};
use crate::scope::Scope;
use crate::ui::rows::{AreaId, group};
use crate::ui::text::{Text, trigger_text};
struct Group {
rank: u8,
name: String,
/// (label, shortcuts as shown)
rows: Vec<(String, Vec<String>)>,
}
fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> String {
let chord = trigger_text(text, &b.trigger, km.platform());
match &b.scope {
Some(s) if s != home => {
let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() };
craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &scope)])
}
_ => chord,
}
}
/// Groups in the table's order; commands in catalog order within a group.
fn groups(km: &Keymap, text: &dyn Text) -> Vec<Group> {
let mut out: Vec<Group> = Vec::new();
for b in km.catalog() {
let bindings = km.bindings(&b.command);
if bindings.is_empty() {
continue;
}
let keys = bindings.iter().map(|x| binding_text(km, text, x, &b.scope)).collect();
let (rank, name, _) = group(&AreaId::All, b, false, text);
let name = name.unwrap_or_default();
let row = (text.command(b).into_owned(), keys);
match out.iter_mut().find(|g| g.rank == rank && g.name == name) {
Some(g) => g.rows.push(row),
None => out.push(Group { rank, name, rows: vec![row] }),
}
}
out.sort_by_key(|g| g.rank);
out
}
/// Text for HTML element content and attribute values.
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
/// One line of plain text: control characters (line breaks, tabs) become spaces, so a label
/// cannot break the layout.
fn one_line(s: &str) -> String {
s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect()
}
const CSS: &str = "body{font-family:system-ui,sans-serif;margin:2em;color:#111;background:#fff}\
h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em;border-bottom:1px solid #ccc}\
table{border-collapse:collapse;width:100%}th,td{padding:.2em .6em;vertical-align:top}\
th{font-weight:normal;text-align:start;width:60%}tr:nth-child(even){background:#f4f4f4}\
kbd{font-family:inherit;border:1px solid #bbb;border-radius:3px;padding:0 .3em;margin-inline-end:.3em;white-space:nowrap}\
@media print{body{margin:0}tr{break-inside:avoid}}";
/// A standalone HTML page titled `title`. Chords are marked left to right so a right-to-left page
/// keeps them as written.
pub fn html(km: &Keymap, text: &dyn Text, title: &str) -> String {
let dir = if text.rtl() { "rtl" } else { "ltr" };
let title = escape_html(title);
let mut s = String::new();
let _ = write!(
s,
"<!DOCTYPE html>\n<html dir=\"{dir}\">\n<head>\n<meta charset=\"utf-8\">\n<title>{title}</title>\n<style>{CSS}</style>\n</head>\n<body>\n<h1>{title}</h1>\n"
);
for g in groups(km, text) {
let _ = writeln!(s, "<h2>{}</h2>\n<table>", escape_html(&g.name));
for (label, keys) in &g.rows {
let keys: String = keys.iter().map(|k| format!("<kbd dir=\"ltr\">{}</kbd>", escape_html(k))).collect();
let _ = writeln!(s, "<tr><th scope=\"row\">{}</th><td>{keys}</td></tr>", escape_html(label));
}
s.push_str("</table>\n");
}
s.push_str("</body>\n</html>\n");
s
}
/// Plain text titled `title`: a line per group, then ` label<TAB>shortcuts` per command.
pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String {
let mut s = one_line(title);
s.push('\n');
for g in groups(km, text) {
let _ = write!(s, "\n{}\n", one_line(&g.name));
for (label, keys) in &g.rows {
let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", ")));
}
}
s
}
+388
View File
@@ -0,0 +1,388 @@
//! The dialog's words: its own strings, command labels, menu path parts, key names and text
//! direction, all supplied by the app through [`Text`].
use std::borrow::Cow;
use crate::chord::{Key, Platform, Trigger};
use crate::keymap::Bindable;
/// Where the dialog gets its words. Apps on craft-i18n pass [`I18nText`]; others implement the two
/// required methods with their own lookup.
pub trait Text {
/// The dialog's own strings, by English source text (listed in [`STRINGS`]). Placeholders
/// (`{chord}`, `{label}`, `{set}`, `{n}`) are filled after the lookup.
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str>;
/// A count's wording with `{n}` filled in. `one` and `other` are the English forms.
fn plural(&self, n: u64, one: &str, other: &str) -> String;
/// A command's label.
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
b.label.as_str().into()
}
/// A menu path part or a scope name.
fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> {
s.into()
}
/// Extra lines for a command's tooltip.
fn hint(&self, _b: &Bindable) -> Option<String> {
None
}
/// A key cap's name on `platform` (`Strg`, `Umschalt`); `None` keeps the crate's name.
fn key(&self, _k: Key, _p: Platform) -> Option<String> {
None
}
/// Whether the interface language is written right to left.
fn rtl(&self) -> bool {
false
}
/// A label or message put into display order (the app's bidi reordering). Chord text is never
/// passed here.
fn visual(&self, s: &str) -> String {
s.to_string()
}
}
/// [`Text`] over a craft-i18n catalog.
pub struct I18nText<'a> {
pub i18n: &'a craft_i18n::I18n,
pub lang: craft_i18n::Lang,
}
impl Text for I18nText<'_> {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
self.i18n.t(self.lang, source).into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
self.i18n.trn(self.lang, n, one, other)
}
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
self.i18n.tid(self.lang, &b.command.id, &b.label).into()
}
fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> {
self.i18n.t(self.lang, s).into()
}
fn key(&self, k: Key, p: Platform) -> Option<String> {
// Glyphs (⌫, ↑) need no translation.
if p.is_mac() && k.mac_symbol() != k.text_name() {
return None;
}
self.i18n.get(self.lang, "key", k.text_name()).map(str::to_string)
}
fn rtl(&self) -> bool {
self.i18n.rtl(self.lang)
}
}
/// English [`Text`]: every string as written.
pub struct English;
impl Text for English {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
craft_i18n::fmt(if n == 1 { one } else { other }, &[("n", &n.to_string())])
}
}
/// A trigger as `platform` shows it, with key names from [`Text::key`]. Modifiers come in the
/// order of [`Chord::display`](crate::Chord::display).
pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> String {
let key = trigger.chord.key;
let Some(name) = text.key(key, platform) else { return trigger.display(platform) };
let base = if platform.is_mac() { key.mac_symbol() } else { key.text_name() };
let one = trigger.chord.display(platform);
let one = match one.strip_suffix(base) {
Some(mods) => format!("{mods}{name}"),
None => one,
};
if trigger.double { format!("{one} {one}") } else { one }
}
/// `s` in display order with `chord`, text inside it, left as it is: chord text never goes
/// through [`Text::visual`]. Right to left, the parts before and after the chord change sides.
pub(crate) fn visual_around(text: &dyn Text, s: &str, chord: &str) -> String {
let Some(i) = s.find(chord).filter(|_| !chord.is_empty()) else { return text.visual(s) };
let (before, rest) = s.split_at(i);
let (before, after) = (text.visual(before), text.visual(&rest[chord.len()..]));
if text.rtl() { format!("{after}{chord}{before}") } else { format!("{before}{chord}{after}") }
}
/// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows.
pub const PLURALS: &[(&str, &str)] = &[
("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"),
("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"),
("{n} command will change", "{n} commands will change"),
("{n} item without a shortcut", "{n} items without a shortcut"),
("{n} item not matched", "{n} items not matched"),
("{n} item could not be read", "{n} items could not be read"),
("{n} key read approximately", "{n} keys read approximately"),
("{n} entry cut at a limit", "{n} entries cut at a limit"),
("{n} command changed", "{n} commands changed"),
("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"),
("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"),
];
/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog.
pub const STRINGS: &[&str] = &[
// Window and footer.
"Keyboard Shortcuts",
"Custom",
"OK",
"Cancel",
"Close",
"Undo",
"Redo",
"Reset All…",
"Restore every default shortcut in this set?",
"Reset All",
// Header.
"Show:",
"Filter:",
"Search:",
"Search by key",
"All Commands",
"{menu} Menu",
"Tools",
"{panel} Panel",
"Other",
"Fixed Keys",
"All",
"With Shortcut",
"Changed",
"Conflicts",
"No matching commands",
"(cycles)",
"{label} (cycles)",
"{label} (Changed)",
// Table and detail pane.
"Command",
"Commands",
"Detail pane size",
"Select a command to see and change its shortcuts",
"Shortcut",
"Default",
"{chord} (Default)",
"{chord} ({scope})",
"{label} ({scope})",
"Application",
"Current shortcuts:",
"New shortcut:",
"New shortcut: {chord}",
"press keys",
"{chord} (press again for a double press)",
"Type…",
"Context:",
"Assign",
"Use Default",
"Clear",
"Default: {chord}",
"None",
"Currently assigned to:",
"Not assigned",
"{label} ({scope}, both work)",
"Add a shortcut to {label}",
"Remove {chord} from {label}",
"Restore the default for {label}",
"{label}: {chord}",
"{label}: press keys",
"Now used by {label}",
"{label}: {chord} (Now used by {holder})",
// Typed shortcuts and saved texts that do not parse.
"the shortcut has no key",
"unknown modifier {key}",
"modifier {key} given twice",
"key sequences are not supported",
"the scope name is not valid",
// On-screen keyboard.
"Keyboard",
"Keyboard: US",
"Keyboard: US (macOS)",
"Control",
"Option",
"Shift",
"Ctrl",
"Alt",
"Caps Lock",
"Win",
"Super",
"Panel or context",
"Both",
"Modifier on",
"{chord}: {label}",
"Click a key to see its shortcuts",
"Key: {key}",
"Assign to Selected",
"Assign {chord} to {label}",
"Select a command first",
"Nothing uses this key",
// Messages and conflicts.
"⚠ {chord} is used by {label}",
"{chord} was removed from {label}",
"Also used by {label}",
"{chord} is reserved by the system",
"A command can have at most {n} shortcuts",
"The scope {scope} is unknown",
"Too many shortcuts are assigned",
"This command is unknown",
"Take It",
"Take It and Go to Conflict",
"Keep Both",
"Go to Conflict",
"Cancel the new shortcut",
"Dismiss",
// Saved shortcuts that could not be used.
"{key}: no command has this name",
"{key}: “{text}” is not a shortcut ({error})",
"{key}: {text} is reserved by the system",
"{key}: the scope in “{text}” is unknown",
"{key}: listed more than once",
"{key}: part of the entry was dropped",
// Sets.
"Set:",
"(modified)",
"New Set…",
"Rename…",
"Delete",
"Save",
"Revert Set",
"Import…",
"Export",
"Export…",
"Summarize…",
"{title}: {set}",
"Create",
"Rename",
"More",
"Name:",
"Based on:",
"Current shortcuts",
"Save changes to “{set}”?",
"Don't Save",
"Delete the set “{set}”?",
"Keyboard Shortcuts (JSON)",
"The name is empty, too long or has control characters",
"A set with this name exists",
"The set was not found",
"Built-in sets cannot be changed",
"There are too many sets",
"This is not a shortcut set file",
"This is not a craft-keymap file",
"This file is from a newer version (format {n})",
"The file is too large",
// Import review.
"Import “{set}”",
"Set name:",
"Import",
"Cancel Import",
"line {n}",
"Will change",
"Not matched",
"Unreadable",
"Approximated",
"{title} ({n})",
"Suggestions ({n})",
"numeric keypad key read as main key",
"Matched without change",
"Command for {item}",
"Choose a command…",
"no command has this name",
"more than one command fits",
"in a context that is not imported",
"only a command of the other kind fits (tool or menu command)",
"empty shortcut",
"the shortcut is too long",
"unknown key {key}",
"unsupported modifier {key}",
"{label} keeps its shortcuts (matched by a panel menu name)",
"Import as New Set",
"Imported",
"Imported as “{set}”",
"Skipped",
"Taken from other commands",
"Not changed",
"taken by {label}",
"taken by a later entry",
"reserved by the system",
"the scope is unknown",
"the set's shortcuts could not be added",
"the shortcuts could not be removed",
"Copy Report",
"Done",
];
#[cfg(test)]
mod tests {
use super::*;
use crate::chord::Chord;
struct German;
impl Text for German {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, n: u64, _one: &str, other: &str) -> String {
other.replace("{n}", &n.to_string())
}
fn key(&self, k: Key, _p: Platform) -> Option<String> {
(k == Key::Delete).then(|| "Entf".to_string())
}
}
#[test]
fn trigger_text_uses_localized_key_names() -> Result<(), crate::ParseError> {
let t = Trigger::parse("Cmd+Shift+Delete")?;
assert_eq!(trigger_text(&German, &t, Platform::Windows), "Ctrl+Shift+Entf");
assert_eq!(trigger_text(&German, &Trigger::parse("F7 F7")?, Platform::Windows), "F7 F7");
assert_eq!(trigger_text(&English, &Trigger::single(Chord::parse("⇧⌘Z")?), Platform::Mac), "⇧⌘Z");
Ok(())
}
/// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed.
#[test]
fn every_looked_up_string_is_listed() {
let sources = [
include_str!("mod.rs"),
include_str!("state.rs"),
include_str!("rows.rs"),
include_str!("capture.rs"),
include_str!("view.rs"),
include_str!("keyboard.rs"),
include_str!("style.rs"),
include_str!("sets.rs"),
include_str!("review.rs"),
include_str!("summary.rs"),
];
let mut missing = Vec::new();
for src in sources {
let src = src.split("#[cfg(test)]").next().unwrap_or(src);
for (i, open) in src.match_indices(".ui(\"") {
let rest = &src[i + open.len()..];
if let Some(end) = rest.find('"') {
let s = &rest[..end];
if !STRINGS.contains(&s) {
missing.push(s.to_string());
}
}
}
for (i, _) in src.match_indices(".plural(") {
let quoted: Vec<&str> = src[i..].split('"').skip(1).step_by(2).take(2).collect();
if let [one, other] = quoted[..]
&& !PLURALS.contains(&(one, other))
{
missing.push(format!("{one}|{other}"));
}
}
}
assert!(missing.is_empty(), "not in STRINGS or PLURALS: {missing:?}");
let mut sorted = STRINGS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), STRINGS.len(), "STRINGS has duplicates");
}
}
File diff suppressed because it is too large. Load diff
+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 catalog = vec![
Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]),
Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"),
Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")),
Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]),
];
Keymap::new(catalog, Scopes::new(), platform)
}
fn keys(km: &Keymap, id: &str) -> Vec<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 catalog does not have.
let big = |tag: char| {
let mut o = Overrides::new();
for n in 0..MAX_COMMANDS {
assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS]));
}
o
};
let (a, b) = (big('a'), big('b'));
let string = size_of::<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");
}
+53 -47
View File
@@ -110,11 +110,11 @@ fn windows_set() -> ForeignSet {
entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows),
win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]),
];
ForeignSet { name: Some("My Set".into()), entries }
ForeignSet::new(entries).with_name("My Set")
}
fn numpad_set() -> ForeignSet {
ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)] }
ForeignSet::new(vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)])
}
fn windows_options() -> ImportOptions {
@@ -254,15 +254,13 @@ fn the_set_wins_and_the_report_says_how() {
fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() {
let mut km = keymap(Platform::Mac);
let mac = |menu: &str, path: &[&str], keys: &[(&str, &str)]| entry(Some(menu), path, keys, Flavor::Mac);
let set = ForeignSet {
name: Some("Mac".into()),
entries: vec![
mac("File", &["Save"], &[("Default", "⇧⌘S")]),
mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]),
mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]),
mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]),
],
};
let set = ForeignSet::new(vec![
mac("File", &["Save"], &[("Default", "⇧⌘S")]),
mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]),
mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]),
mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]),
])
.with_name("Mac");
let text = Scope::new("Text").unwrap();
let options = ImportOptions {
contexts: vec![("Default".into(), ContextRule::Home), ("Text".into(), ContextRule::Scope(text))],
@@ -282,7 +280,7 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() {
#[test]
fn start_and_none_defined_options() {
let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])] };
let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])]);
let user = || {
let mut o = Overrides::new();
o.insert("file.save", vec!["Cmd+Alt+S".into()]);
@@ -314,14 +312,11 @@ fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() {
// Save's change finds the overrides full. Close's change then frees an entry, and Zoom In
// takes Cmd+S. Removing Cmd+S from Save would fit now, but Save's import failed, so Save
// keeps it and Zoom In stays as it was.
let set = ForeignSet {
name: None,
entries: vec![
win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]),
win("File", &["Close"], &[("Default", "Ctrl+W")]),
win("View", &["Zoom In"], &[("Default", "Ctrl+S")]),
],
};
let set = ForeignSet::new(vec![
win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]),
win("File", &["Close"], &[("Default", "Ctrl+W")]),
win("View", &["Zoom In"], &[("Default", "Ctrl+S")]),
]);
let mut o = Overrides::new();
o.insert("file.close", vec!["Cmd+Q".into()]);
o.insert("view.zoomIn", vec!["Cmd+Alt+Z".into()]);
@@ -343,14 +338,11 @@ fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() {
#[test]
fn changes_that_find_the_overrides_full_are_reported() {
let set = ForeignSet {
name: None,
entries: vec![
win("File", &["Close"], &[("Default", "Ctrl+J")]),
win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]),
win("Edit", &["Duplicate"], &[]),
],
};
let set = ForeignSet::new(vec![
win("File", &["Close"], &[("Default", "Ctrl+J")]),
win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]),
win("Edit", &["Duplicate"], &[]),
]);
let full = || {
let mut o = Overrides::new();
o.insert("file.close", vec!["Cmd+Q".into()]);
@@ -402,10 +394,7 @@ fn changes_that_find_the_overrides_full_are_reported() {
has(&km, "edit.duplicate", &[]);
// A later entry whose change finds the overrides full leaves the chord with the earlier one.
let set = ForeignSet {
name: None,
entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])],
};
let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])]);
let mut km = keymap(Platform::Windows).with_overrides(full());
let r = plan(&set, &km, &options(Start::Current)).apply(&mut km);
has(&km, "file.close", &["Cmd+Alt+J"]);
@@ -417,7 +406,7 @@ fn changes_that_find_the_overrides_full_are_reported() {
#[test]
fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() {
let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])] };
let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])]);
let options = ImportOptions {
contexts: vec![
("Default".into(), ContextRule::Home),
@@ -441,7 +430,7 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() {
#[test]
fn suggestions_are_reported_never_applied() {
let mut km = keymap(Platform::Windows);
let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])] };
let set = ForeignSet::new(vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])]);
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() };
let p = plan(&set, &km, &options);
assert!(p.commands.is_empty());
@@ -454,6 +443,12 @@ fn suggestions_are_reported_never_applied() {
assert_eq!(r.imported, 0);
}
#[test]
fn entries_the_reader_dropped_count_as_truncated() {
let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])]).with_truncated(7);
assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7);
}
#[test]
fn hostile_sets_are_capped_and_counted() {
let mut km = keymap(Platform::Windows);
@@ -471,7 +466,7 @@ fn hostile_sets_are_capped_and_counted() {
// Entries without keys fill the set to the cap and 500 past it.
let keyless = MAX_IMPORT_ENTRIES - entries.len();
entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::<String>::new()), keyless + 500));
let set = ForeignSet { name: Some(long.clone()), entries };
let set = ForeignSet::new(entries).with_name(long.clone());
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() };
let r = plan(&set, &km, &options).apply(&mut km);
assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS);
@@ -514,7 +509,7 @@ fn hostile_sets_finish_in_time() {
}
entries.insert(0, big);
entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")]));
let set = ForeignSet { name: Some(long.clone()), entries };
let set = ForeignSet::new(entries).with_name(long.clone());
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() };
let started = std::time::Instant::now();
let r = plan(&set, &km, &options).apply(&mut km);
@@ -538,16 +533,13 @@ fn hostile_sets_finish_in_time() {
fn rows_carry_source_lines_and_approximated_keys() {
let mut km = keymap(Platform::Windows);
let d = "Default";
let set = ForeignSet {
name: None,
entries: vec![
win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3),
win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4),
win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5),
win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8),
win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9),
],
};
let set = ForeignSet::new(vec![
win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3),
win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4),
win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5),
win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8),
win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9),
]);
let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() };
let r = plan(&set, &km, &options).apply(&mut km);
// A keypad key binds the main key and is reported. Paste, a later entry, takes Cmd+5.
@@ -623,7 +615,7 @@ fn report_json_uses_camel_case_names() {
fn import_into(catalog: Vec<Bindable>, entries: Vec<ForeignEntry>, options: &ImportOptions) -> (Keymap, ImportReport) {
let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows);
let report = plan(&ForeignSet { name: None, entries }, &km, options).apply(&mut km);
let report = plan(&ForeignSet::new(entries), &km, options).apply(&mut km);
(km, report)
}
@@ -708,6 +700,20 @@ fn aliases_match_like_labels() {
assert!(r.unmatched.is_empty(), "{:?}", r.unmatched);
}
#[test]
fn import_paths_match_like_menu_paths() {
let catalog = vec![
command("object.color", "Apply Color", &["Object", "Apply Color"], &[]),
command("color.applyColor", "Apply Color", &[], &[]).with_import_path(vec!["Tools".into(), "Apply Color".into()]),
];
let d = "Default";
let entries = vec![win("Tools", &["Apply Color"], &[(d, "Ctrl+,")])];
let (km, r) = import_into(catalog, entries, &default_context());
has(&km, "color.applyColor", &["Cmd+,"]);
has(&km, "object.color", &[]);
assert!(r.unmatched.is_empty(), "{:?}", r.unmatched);
}
#[test]
fn show_and_hide_are_read_off_only_for_toggles() {
let catalog = vec![
+210
View File
@@ -0,0 +1,210 @@
use craft_keymap::{
Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform,
Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, Start, plan,
};
use serde_json::json;
fn b(text: &str) -> Binding {
Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"))
}
fn keymap() -> Keymap {
let catalog = vec![
Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")),
Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")),
Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")),
Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")),
];
Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Mac)
}
#[test]
fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() {
let mut classic = Overrides::new();
classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]);
let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())];
let mut p = Profiles::default();
assert_eq!(p.active_name(&builtins), "Craft");
let mut edited = Overrides::new();
edited.insert("edit.copy", vec![]);
assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into())));
assert_eq!(p.active_name(&builtins), "Custom");
assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place");
assert!(p.active_overrides(&builtins).is_empty());
p.select("Classic", &builtins).unwrap();
assert_eq!(p.active_overrides(&builtins), &classic);
assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy");
assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into())));
assert_eq!(builtins[1].1, classic);
let exported = p.export("Custom 2", &builtins).unwrap();
assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into()));
assert_eq!(p.active_overrides(&builtins), &edited);
assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn));
assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken));
assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into()));
assert_eq!(p.active(), "Mine");
assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName));
p.delete("Mine", &builtins).unwrap();
assert_eq!(p.active_name(&builtins), "Craft");
let wrong = exported.replace("craft-keymap", "other");
assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat));
let newer = exported.replace("\"version\": 1", "\"version\": 2");
assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2)));
let text = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<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 rename_keeps_a_set_whose_name_differs_only_in_case() {
let builtins = [("Craft", Overrides::new())];
let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap();
assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken));
assert_eq!(p.sets()["mine"].get("b"), Some(&["B".to_string()][..]));
assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken));
p.delete("mine", &builtins).unwrap();
assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into()));
}
#[test]
fn sets_that_are_not_a_list_or_map_load_as_none() {
for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] {
let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}"));
assert!(p.sets().is_empty(), "{text}");
assert_eq!(p.active(), "Mine", "{text}");
}
}
#[test]
fn a_user_set_named_like_a_new_built_in_keeps_its_edits() {
let mut mine = Overrides::new();
mine.insert("edit.copy", vec!["Cmd+K".into()]);
let mut p = Profiles::default();
p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap();
// An update adds a built-in of that name.
let mut classic = Overrides::new();
classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]);
let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())];
assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads");
let mut edited = mine.clone();
edited.insert("edit.paste", vec![]);
assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into())));
assert_eq!(p.active_name(&builtins), "Classic 2");
assert_eq!(p.active_overrides(&builtins), &edited);
assert_eq!(p.get("Classic", &builtins), Some(&classic));
assert_eq!(builtins[1].1, classic);
let mut q: Profiles =
serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap();
assert!(q.reconcile(&builtins));
assert_eq!(q.active_name(&builtins), "CRAFT 2");
assert_eq!(q.active_overrides(&builtins), &mine);
assert!(!q.reconcile(&builtins));
}
#[test]
fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() {
let mut km = keymap();
km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap();
km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap();
km.clear(&"edit.copy".into());
let builtins = [("Craft", Overrides::new())];
let mut p = Profiles::default();
p.save_as("Mine", km.overrides().clone(), &builtins).unwrap();
let text = p.export("Mine", &builtins).unwrap();
assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into()));
assert_eq!(p.active(), "Mine 2");
let mut again = keymap();
again.set_overrides(p.active_overrides(&builtins).clone());
for bindable in km.catalog() {
assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key());
}
assert_eq!(again.overrides(), km.overrides());
}
#[test]
fn duplicate_copies_built_in_and_user_sets() {
let mut classic = Overrides::new();
classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]);
let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())];
let mut p = Profiles::default();
assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into()));
assert_eq!(p.active(), "Classic 2");
assert_eq!(p.active_overrides(&builtins), &classic);
assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into()));
assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case");
assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound));
assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName));
while p.sets().len() < MAX_SETS {
p.duplicate("Craft", "Spare", &builtins).unwrap();
}
assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany));
}
#[test]
fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() {
let km = keymap();
let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac);
let set = ForeignSet::new(vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))]).with_name("Other App");
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() };
let planned = plan(&set, &km, &options);
let builtins = [("Craft", Overrides::new())];
let mut p = Profiles::default();
let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap();
assert_eq!(name, "Other App");
assert_eq!(report.changed.len(), 1);
assert!(km.overrides().is_empty());
assert_eq!(p.active(), "Other App");
let mut imported = keymap();
imported.set_overrides(p.active_overrides(&builtins).clone());
assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]);
let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap();
assert_eq!(name, "Craft 2", "a built-in's name is not reused");
}
#[test]
fn loading_lists_the_sets_it_drops_and_the_store_has_a_version() {
let text = r#"{"version": 2, "active": "ok", "sets": [
{"name": "ok", "overrides": {}}, {"name": " pad ", "overrides": {}}, {"name": "ok", "overrides": {"a": "A"}}, {"name": "bad", "overrides": 3}
]}"#;
let p: Profiles = serde_json::from_str(text).unwrap();
assert_eq!(p.sets().keys().collect::<Vec<_>>(), ["ok"]);
assert!(p.sets()["ok"].is_empty(), "the first of two sets with one name stays");
let name = |n: &str| n.to_string();
assert_eq!(
p.problems(),
[
SetProblem::NewerVersion(2),
SetProblem::BadName { name: name(" pad ") },
SetProblem::Duplicate { name: name("ok") },
SetProblem::BadOverrides { name: name("bad") }
]
);
let many: Vec<_> = (0..MAX_SETS + 3).map(|i| json!({"name": format!("s{i}"), "overrides": {}})).collect();
let p: Profiles = serde_json::from_value(json!({ "sets": many })).unwrap();
assert_eq!((p.sets().len(), p.problems()), (MAX_SETS, &[SetProblem::TooMany { ignored: 3 }][..]));
assert_eq!(serde_json::to_value(&p).unwrap()["version"], 1);
let unversioned: Profiles = serde_json::from_str(r#"{"active": "x", "sets": []}"#).unwrap();
assert!(unversioned.problems().is_empty());
}
+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}");
}
}
File diff suppressed because it is too large. Load diff
+156
View File
@@ -0,0 +1,156 @@
//! The catalog the dialog tests and screenshots use.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::rc::Rc;
use craft_keymap::ui::{PLURALS, STRINGS, Text};
use craft_keymap::{Bindable, Binding, Key, Keymap, Kind, Platform, Scope, Scopes};
fn binding(text: &str) -> Binding {
Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"))
}
pub fn fixture(platform: Platform) -> Keymap {
let tool = Kind::Tool { group: Some("Marquee".into()) };
let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app);
let catalog = vec![
Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")),
Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")),
Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")),
Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])),
Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")),
Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")),
Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")),
Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")),
];
Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform)
}
/// `km` with a command whose label and menu path are far too long for a row.
#[allow(dead_code)]
pub fn with_long_label(km: Keymap) -> Keymap {
let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string();
let mut catalog = km.catalog().to_vec();
catalog.insert(
3,
Bindable::new("object.distribute", long.as_str(), Scope::app())
.with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()])
.with_default(binding("Cmd+Alt+Shift+D"))
.with_default(binding("Ctrl+Alt+Shift+F12")),
);
Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform())
}
/// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and
/// in `⟦…⟧`, so text drawn without the marks never went through [`Text`]. Placeholders stay as
/// they are. Labels and path parts are marked too. Looked-up dialog strings missing from
/// `STRINGS` or `PLURALS` are collected in `unlisted`.
#[allow(dead_code)]
#[derive(Default)]
pub struct Pseudo {
pub unlisted: Rc<RefCell<BTreeSet<String>>>,
/// Lay the dialog out right to left.
pub rtl: bool,
}
#[allow(dead_code)]
impl Pseudo {
pub fn mark(source: &str) -> String {
let mut out = String::from("⟦\u{200F}");
let mut in_placeholder = false;
let mut letters = 0;
for c in source.chars() {
match c {
'{' => in_placeholder = true,
'}' => in_placeholder = false,
_ => {}
}
let accented = match c {
_ if in_placeholder => c,
'a' => 'á',
'e' => 'ë',
'i' => 'ï',
'o' => 'ö',
'u' => 'ü',
'A' => 'Å',
'E' => 'É',
'O' => 'Ø',
_ => c,
};
if !in_placeholder && c.is_alphabetic() {
letters += 1;
}
out.push(accented);
}
out.extend(std::iter::repeat_n('~', letters * 2 / 5));
out.push_str("\u{200F}⟧");
out
}
}
impl Text for Pseudo {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
if !STRINGS.contains(&source) {
self.unlisted.borrow_mut().insert(source.to_string());
}
Pseudo::mark(source).into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
if !PLURALS.contains(&(one, other)) {
self.unlisted.borrow_mut().insert(format!("{one}|{other}"));
}
Pseudo::mark(if n == 1 { one } else { other }).replace("{n}", &n.to_string())
}
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
Pseudo::mark(&b.label).into()
}
fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> {
Pseudo::mark(s).into()
}
/// Key names spelled out in words (Escape, Space), on every platform.
fn key(&self, k: Key, _p: Platform) -> Option<String> {
let name = k.to_string();
name.chars().any(|c| c.is_ascii_lowercase()).then(|| Pseudo::mark(&name))
}
fn rtl(&self) -> bool {
self.rtl
}
}
/// English laid out right to left, to look at the mirrored layout.
#[allow(dead_code)]
pub struct EnglishRtl;
impl Text for EnglishRtl {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
(if n == 1 { one } else { other }).replace("{n}", &n.to_string())
}
fn rtl(&self) -> bool {
true
}
/// The words stay in English order; spaces at either end change sides, as in a right-to-left
/// paragraph.
fn visual(&self, s: &str) -> String {
let core = s.trim();
if core.is_empty() {
return s.to_string();
}
let start = s.len() - s.trim_start().len();
let end = s.trim_end().len();
format!("{}{core}{}", &s[end..], &s[..start])
}
}
+257
View File
@@ -0,0 +1,257 @@
#![cfg(feature = "snapshot")]
//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With
//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×;
//! without it the test does nothing.
//!
//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot`
//!
//! egui's default fonts have no ⇧ ⌥ ⌃ or arrows. Apps load their own fonts; here the pictures add
//! fallback fonts when they are found: the file in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans
//! from the usual Linux path, and BIZ UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR`
//! (⇧ ⌘ and arrows, not ⌥ ⌃). Without any of them those glyphs show as boxes.
use std::path::PathBuf;
use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog, Text};
use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles};
use egui::{Key as EguiKey, Modifiers, Theme, Vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ui_fixture::{EnglishRtl, Pseudo, fixture, with_long_label};
mod ui_fixture;
struct Fx {
km: Keymap,
text: Box<dyn Text>,
options: Options,
state: DialogState,
sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>,
}
impl Fx {
fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) {
let Fx { km, text, options, state, sets } = self;
let d = ShortcutsDialog::new(km, &**text).options(options);
let d = match sets {
Some((p, b)) => d.sets(p, b),
None => d,
};
(d, state)
}
}
/// A named state of the dialog: window size and what to do before the picture.
struct Case {
name: &'static str,
size: Vec2,
platform: Platform,
/// Show named sets.
sets: bool,
/// Show the on-screen keyboard.
keyboard: bool,
words: Words,
/// Add a command with a very long label and name the user set at length.
long: bool,
act: fn(&mut Harness<'_, Fx>),
}
#[derive(Clone, Copy)]
enum Words {
English,
/// English laid out right to left.
Rtl,
/// The pseudo translation of the tests, left to right.
Pseudo,
}
impl Case {
const fn new(name: &'static str, width: f32, height: f32, act: fn(&mut Harness<'_, Fx>)) -> Case {
Case { name, size: Vec2::new(width, height), platform: Platform::Mac, sets: false, keyboard: false, words: Words::English, long: false, act }
}
const fn windows(mut self) -> Case {
self.platform = Platform::Windows;
self
}
const fn sets(mut self) -> Case {
self.sets = true;
self
}
const fn keyboard(mut self) -> Case {
self.keyboard = true;
self
}
const fn words(mut self, words: Words) -> Case {
self.words = words;
self
}
const fn long(mut self) -> Case {
self.long = true;
self
}
}
/// Fallback fonts with the key glyphs, when this machine has them.
fn fonts() -> egui::FontDefinitions {
let mut defs = egui::FontDefinitions::default();
let mut files: Vec<PathBuf> = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_FONT").map(PathBuf::from).into_iter().collect();
files.push("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf".into());
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") {
files.push(PathBuf::from(dir).join("fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf"));
}
for (i, file) in files.iter().enumerate() {
let Ok(bytes) = std::fs::read(file) else { continue };
let name = format!("fallback-{i}");
defs.font_data.insert(name.clone(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
for family in defs.families.values_mut() {
family.push(name.clone());
}
}
defs
}
fn foreign_review(h: &mut Harness<'_, Fx>) {
let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows);
let set = ForeignSet::new(vec![
ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3),
ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4),
ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5),
ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6),
ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7),
])
.with_name("Theirs");
let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() };
let (d, state) = h.state_mut().dialog();
d.review_import(state, set, options);
h.run();
}
fn click(h: &mut Harness<'_, Fx>, label: &str) {
h.get_by_label(label).click();
h.run();
}
fn select(h: &mut Harness<'_, Fx>, label: &str) {
h.get_by_label(label).click();
h.run();
}
const CASES: &[Case] = &[
Case::new("selected", 1000.0, 640.0, |h| select(h, "Save As…")),
Case::new("conflict", 1000.0, 640.0, |h| {
click(h, "Export…: ⌘E");
h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S);
h.run();
}),
Case::new("recording", 1000.0, 640.0, |h| click(h, "Save: Ctrl+S")).windows(),
Case::new("narrow", 560.0, 640.0, |h| select(h, "Save")),
Case::new("sets", 1000.0, 640.0, |h| {
click(h, "Save: ⌘S");
h.key_press(EguiKey::F2);
h.run();
click(h, "New Set…");
})
.sets(),
Case::new("sets-narrow", 560.0, 640.0, |h| click(h, "More")).sets(),
Case::new("keyboard", 1100.0, 720.0, |h| {
click(h, "Save As…");
click(h, "⌘ Command");
click(h, "⌘S: Save");
})
.keyboard(),
Case::new("keyboard-windows", 860.0, 640.0, |h| click(h, "L: Loop (Timeline)")).windows().keyboard(),
Case::new("review", 1000.0, 640.0, foreign_review).sets(),
Case::new("long", 1000.0, 640.0, |h| {
let state = &mut h.state_mut().state;
state.select(&craft_keymap::CommandRef::from("object.distribute"));
h.run();
})
.sets()
.long(),
Case::new("long-narrow", 560.0, 640.0, |h| {
h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute"));
h.run();
})
.sets()
.long(),
Case::new("rtl", 1000.0, 640.0, |h| {
click(h, "Export…: ⌘E");
h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S);
h.run();
})
.sets()
.long()
.words(Words::Rtl),
Case::new("rtl-narrow", 560.0, 640.0, |h| select(h, "Bold")).words(Words::Rtl).long(),
Case::new("rtl-keyboard", 1100.0, 720.0, |h| click(h, "L: Loop (Timeline)")).keyboard().words(Words::Rtl),
Case::new("rtl-review", 1000.0, 640.0, foreign_review).sets().words(Words::Rtl),
Case::new("pseudo", 1100.0, 720.0, |h| {
h.state_mut().state.select(&craft_keymap::CommandRef::from("file.save"));
h.run();
})
.sets()
.keyboard()
.long()
.words(Words::Pseudo),
Case::new("pseudo-narrow", 560.0, 640.0, |h| {
h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute"));
h.run();
})
.sets()
.long()
.words(Words::Pseudo),
];
#[test]
fn dialog_screenshots() {
let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return };
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
// `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it.
let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default();
let fonts = fonts();
for case in CASES.iter().filter(|c| c.name.starts_with(&only)) {
for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] {
for scale in [1.0, 2.0] {
let sets = case.sets.then(|| {
let mut p = Profiles::default();
let builtins = vec![("Fixture Defaults", Overrides::new())];
if case.long {
let name = "My Shortcuts for Long Evenings of Retouching Photographs";
if let Err(e) = p.save_as(name, Overrides::new(), &builtins).and_then(|n| p.select(&n, &builtins)) {
panic!("{name}: {e}");
}
}
(p, builtins)
});
let options = Options::default().with(|o| o.keyboard = case.keyboard);
let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) };
let text: Box<dyn Text> = match case.words {
Words::English => Box::new(English),
Words::Rtl => Box::new(EnglishRtl),
Words::Pseudo => Box::new(Pseudo::default()),
};
let fx = Fx { km, text, options, state: DialogState::new(), sets };
let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state(
|ui, fx: &mut Fx| {
egui::Frame::central_panel(ui.style()).show(ui, |ui| {
let (d, state) = fx.dialog();
d.ui(ui, state);
});
},
fx,
);
h.ctx.set_fonts(fonts.clone());
h.run();
(case.act)(&mut h);
let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name));
let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name));
image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
}
}
}
}