From 4a721df085b82f1d64e229b941c64c12aca44564 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:47:23 +0000 Subject: [PATCH 1/4] keymap: state that chord reservations are opt-in through with_reserved --- crates/keymap/README.md | 3 +++ crates/keymap/src/keymap.rs | 3 ++- crates/keymap/src/lib.rs | 6 ++++-- crates/keymap/src/reserved.rs | 6 +++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 81b55ec9..1f2d1b75 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -13,6 +13,9 @@ its file-format readers and its UI. kept or reported (`Keymap::problems`, the import report). - Hostile input is capped: commands, bindings per command, text lengths, import entries, keys, path segments and fuzzy suggestions (the `MAX_*` constants). What is cut is counted. +- 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. ## A catalogue and dispatch diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index d2a81ae9..4ceb76d1 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -434,7 +434,8 @@ impl Keymap { self } - /// Chords that cannot be assigned, e.g. [`reserved::system`](crate::reserved::system). + /// Chords that cannot be assigned or bound, e.g. [`reserved::system`](crate::reserved::system). + /// Without this call the keymap reserves no chord. pub fn with_reserved(mut self, chords: Vec) -> Keymap { self.reserved = chords; self.reload(); diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index f669e6d7..e673848c 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,8 +6,10 @@ //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's //! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] -//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module +//! 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. 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. //! diff --git a/crates/keymap/src/reserved.rs b/crates/keymap/src/reserved.rs index 7dbf847b..4d64d36a 100644 --- a/crates/keymap/src/reserved.rs +++ b/crates/keymap/src/reserved.rs @@ -1,4 +1,7 @@ //! Chords the operating system takes before the app sees them. +//! +//! A keymap reserves these only when the app passes them to +//! [`Keymap::with_reserved`](crate::Keymap::with_reserved). use crate::chord::{Chord, Platform}; @@ -25,7 +28,8 @@ const LINUX: &[&str] = &[ "Cmd+Alt+F12", ]; -/// The window switching, closing and session chords of `platform`. +/// The window switching, closing and session chords of `platform`, for +/// [`Keymap::with_reserved`](crate::Keymap::with_reserved). pub fn system(platform: Platform) -> Vec { let list = match platform { Platform::Mac => MAC, From a160c1cdeca75bb45375f4810344138b9df0d91e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:47:24 +0000 Subject: [PATCH 2/4] ci: add keymap workflow for core, egui dispatch, macOS, Windows and WASM --- .github/workflows/keymap.yml | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/keymap.yml diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml new file mode 100644 index 00000000..97b1124e --- /dev/null +++ b/.github/workflows/keymap.yml @@ -0,0 +1,66 @@ +name: Keymap + +on: + push: + branches: [main] + paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + pull_request: + paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: keymap-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + timeout-minutes: 15 + name: Keymap (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + 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. + - 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 + native: + timeout-minutes: 15 + name: Keymap (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: craft-keymap-${{ matrix.os }} + - run: cargo test -p craft-keymap --all-features --locked + wasm: + timeout-minutes: 10 + name: Keymap WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: craft-keymap-wasm + - run: cargo check -p craft-keymap --all-features --locked --target wasm32-unknown-unknown From 0b34fdb1527cad02df8361936d72e34938dfe05d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:30:56 +0000 Subject: [PATCH 3/4] keymap: split the hostile import test into a fast cap test and an ignored worst-case timing test --- crates/keymap/tests/import.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 425f5d59..e89428b6 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -428,6 +428,40 @@ fn suggestions_are_reported_never_applied() { #[test] fn hostile_sets_are_capped_and_counted() { + let mut km = keymap(Platform::Windows); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + // Unmatched entries with keys, more than the suggestions take. + let near = win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+F12")]); + let mut entries: Vec = std::iter::repeat_n(near, MAX_SUGGESTIONS + 10).collect(); + entries.push(big); + entries.push(win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + // 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::::new()), keyless + 500)); + let set = ForeignSet { name: Some(long.clone()), entries }; + 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); + // The set name, entries and keys past the caps, and suggestions past the cap (with the big entry's). + assert!(r.truncated >= 1 + 500 + 10 + 11, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, keyless); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn hostile_sets_finish_in_time() { // A full catalogue whose labels share words with every unmatched entry below, so each // suggestion scores every command. let mut catalogue = catalogue(); From c519b7a856f4c7987c4a90c50bb1ffa9faa9e3e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:37:38 +0000 Subject: [PATCH 4/4] keymap: split crowded_chords_stay_fast into a small cap test and an ignored worst-case timing test --- crates/keymap/tests/keymap.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 475109d4..502c3ced 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -279,11 +279,11 @@ fn conflicts_tell_clashes_from_shadows() { assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); } -/// 4096 commands, each with 16 bindings on one chord in 16 panels. -fn crowded(scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { - let catalogue: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); +/// `commands` commands, each with 16 bindings on one chord in 16 panels. +fn crowded(commands: usize, scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { + let catalogue: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); let mut o = Overrides::new(); - for n in 0..MAX_COMMANDS { + for n in 0..commands { assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); } (Keymap::new(catalogue, scopes, Platform::Mac), o) @@ -294,11 +294,31 @@ fn panels(n: usize) -> Scopes { } #[test] +fn crowded_chords_are_capped() { + // Panels the app never registered are not bound. + let (km, o) = crowded(2, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + assert!(km.conflicts().is_empty()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: 32 commands make 16 × 496 pairs, capped. + let (km, o) = crowded(32, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + assert_eq!(km.with_overrides(o).conflicts().len(), MAX_CONFLICTS); + + // Tools of one group: no conflicts. + let (km, o) = crowded(32, panels(MAX_BINDINGS), Kind::Tool { group: None }); + assert!(km.with_overrides(o).conflicts().is_empty()); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] fn crowded_chords_stay_fast() { let limit = Duration::from_secs(8); // Panels the app never registered are not bound. - let (km, o) = crowded(Scopes::new(), Kind::Command); + let (km, o) = crowded(MAX_COMMANDS, Scopes::new(), Kind::Command); let km = km.with_overrides(o); let t = Instant::now(); assert!(km.conflicts().is_empty()); @@ -308,14 +328,14 @@ fn crowded_chords_stay_fast() { assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); // Registered panels that never share: many conflicts, capped. - let (km, o) = crowded(panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); let km = km.with_overrides(o); let t = Instant::now(); assert_eq!(km.conflicts().len(), MAX_CONFLICTS); assert!(t.elapsed() < limit, "{:?}", t.elapsed()); // Tools of one group: no conflicts, and no pair is compared. - let (km, o) = crowded(panels(MAX_BINDINGS), Kind::Tool { group: None }); + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS), Kind::Tool { group: None }); let km = km.with_overrides(o); let t = Instant::now(); assert!(km.conflicts().is_empty());