Merge branch 'craft-keymap' into keymap-ui

# Conflicts:
#	crates/keymap/README.md
#	crates/keymap/src/lib.rs
This commit is contained in:
aiongg committed 2026-10-19 21:40:28 +00:00
commit d0c8d3d0c7
7 files changed
+143 -13

No files matched your search

+66
View File
@@ -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
+3
View File
@@ -14,6 +14,9 @@ supplies its command catalogue, 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; `craft-ui` and
`craft-i18n` with the `ui` feature.
+2 -1
View File
@@ -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<Chord>) -> Keymap {
self.reserved = chords;
self.reload();
+6 -4
View File
@@ -7,10 +7,12 @@
//! [`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. [`profiles`] keeps named sets of overrides; [`reserved`] lists the
//! chords each operating system takes. [`import`] brings in other applications' shortcut sets.
//! [`editor`] is the model of a shortcut editor: undo, a chord recorder, conflict queries and
//! search. With the `egui` feature, the `egui` module converts to and from egui keys, matches key
//! presses against bindings and dispatches a frame's key events.
//! 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. [`editor`] is the model of a shortcut editor: undo, a
//! chord recorder, conflict queries and search. With the `egui` feature, the `egui` module
//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's
//! key events.
//!
//! ```
//! use craft_keymap::{Chord, Platform};
+5 -1
View File
@@ -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<Chord> {
let list = match platform {
Platform::Mac => MAC,
+34
View File
@@ -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<ForeignEntry> = 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::<String>::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();
+27 -7
View File
@@ -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<Bindable> = (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<Bindable> = (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());