mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
Merge #186: keymap: Keyboard Shortcuts dialog, named shortcut sets and the shortcut editor
# Conflicts: # crates/keymap/src/lib.rs
This commit is contained in:
commit
332a4456dd
29 files changed
+9001
-110
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(¤t)).clicked() {
|
||||
*based_on = None;
|
||||
}
|
||||
for n in &names {
|
||||
if ui.selectable_label(based_on.as_ref() == Some(n), self.vis(n)).clicked() {
|
||||
*based_on = Some(n.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
let combo = combo.response.labelled_by(l.id);
|
||||
self.tag(|| "shortcuts.setForm.basedOn".into(), combo.rect, &based_label);
|
||||
}
|
||||
let ok = match form.kind {
|
||||
SetFormKind::New { .. } => self.ui("Create"),
|
||||
SetFormKind::Rename => self.ui("Rename"),
|
||||
};
|
||||
submit |= self.button(ui, set_name(&form.name).is_some(), &ok, "shortcuts.setForm.ok");
|
||||
cancel |= self.button(ui, true, &self.ui("Cancel"), "shortcuts.setForm.cancel");
|
||||
});
|
||||
if cancel {
|
||||
self.state.set_form = None;
|
||||
return;
|
||||
}
|
||||
if !submit || set_name(&form.name).is_none() {
|
||||
self.state.set_form = Some(form);
|
||||
return;
|
||||
}
|
||||
self.state.message = None;
|
||||
match &form.kind {
|
||||
SetFormKind::New { based_on } => self.state.new_set(self.env, &form.name, based_on.as_deref()),
|
||||
SetFormKind::Rename => self.state.rename_set(self.env, &form.name),
|
||||
}
|
||||
// A refused name keeps the form open for another try.
|
||||
let refused = self.state.message.as_ref().is_some_and(|m| m.refused);
|
||||
self.state.set_form = refused.then_some(form);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One line of plain text: control characters (line breaks, tabs) become spaces, so a label
|
||||
/// cannot break the layout.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect()
|
||||
}
|
||||
|
||||
const CSS: &str = "body{font-family:system-ui,sans-serif;margin:2em;color:#111;background:#fff}\
|
||||
h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em;border-bottom:1px solid #ccc}\
|
||||
table{border-collapse:collapse;width:100%}th,td{padding:.2em .6em;vertical-align:top}\
|
||||
th{font-weight:normal;text-align:start;width:60%}tr:nth-child(even){background:#f4f4f4}\
|
||||
kbd{font-family:inherit;border:1px solid #bbb;border-radius:3px;padding:0 .3em;margin-inline-end:.3em;white-space:nowrap}\
|
||||
@media print{body{margin:0}tr{break-inside:avoid}}";
|
||||
|
||||
/// A standalone HTML page titled `title`. Chords are marked left to right so a right-to-left page
|
||||
/// keeps them as written.
|
||||
pub fn html(km: &Keymap, text: &dyn Text, title: &str) -> String {
|
||||
let dir = if text.rtl() { "rtl" } else { "ltr" };
|
||||
let title = escape_html(title);
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
"<!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
|
||||
}
|
||||
@@ -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
@@ -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, ©, &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");
|
||||
}
|
||||
@@ -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![
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user