mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 03:23:24 +00:00
Merge branch 'craft-keymap' into keymap-ui
# Conflicts: # crates/keymap/README.md # crates/keymap/src/lib.rs
This commit is contained in:
commit
d0c8d3d0c7
7 files changed
+143
-13
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user