diff --git a/Cargo.lock b/Cargo.lock index 7ae3ad37..e1ee9155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -35,6 +37,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -908,6 +911,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -957,6 +961,7 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -1048,6 +1053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1084,6 +1090,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -1102,6 +1119,7 @@ dependencies = [ "parking_lot", "profiling", "self_cell", + "serde", "skrifa 0.44.0", "smallvec", "unicode-general-category", @@ -1265,6 +1283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -4013,6 +4032,9 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -4753,6 +4775,9 @@ name = "uuid" version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "serde_core", +] [[package]] name = "vello_common" diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 0e5e80f8..2c3dcc41 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -16,6 +16,8 @@ serde_json = "1" egui = { workspace = true, optional = true } [dev-dependencies] +# For setting `RawInput` fields only the suite's egui fork has. +egui = { workspace = true, features = ["serde"] } craft-settings = { workspace = true } tempfile = "3" diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 3912f657..daa389c8 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -86,10 +86,11 @@ macOS it folds into `cmd` (`Chord::effective`). (`Chord::display_for`): 1. Letters A–Z: the layout's Latin letter for the key. On a layout that is not Latin (Russian, - Arabic, Hebrew, Greek, Thai), the letter the toolkit reports for shortcuts, else the key's - position: the suite's egui-winit reports the Latin layout's letter, so Ctrl+С on Dvorak + - Russian is Dvorak's Ctrl+J. On a Latin layout (Dvorak, AZERTY), the letter typed. Arabic - Ctrl+Shift+D is Ctrl+Shift+D, not the `]` it types. + Arabic, Hebrew, Greek, Thai), the suite's egui reports the Latin layout's character for each + key: Ctrl+С on Dvorak + Russian is Dvorak's Ctrl+J, and Ctrl+Й, `'` on Dvorak, runs no letter + binding. Without that report, the letter the toolkit reports for shortcuts, else the key's + position. On a Latin layout (Dvorak, AZERTY), the letter typed. Arabic Ctrl+Shift+D is + Ctrl+Shift+D, not the `]` it types. 2. Digits: the digit typed, else the key's position (AZERTY's `&é"` row). A digit-row key that types punctuation also matches its digit when the punctuation has no binding. 3. Punctuation: the character typed, without Shift when the layout needs Shift for it: German diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index ce5ecdda..f7c2559e 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -93,9 +93,10 @@ impl Chord { impl KeyInput { /// What an egui key press reports: the logical key as typed and the physical key. On a layout /// that is not Latin, the suite's egui-winit reports the Latin layout's letter as the logical - /// key (Linux, Windows, and macOS with ⌘ held), and crates.io egui the physical key, so [`Layout::resolve`] takes a - /// letter there before the position. egui reports no Shift level, so [`Layout`] infers it. - /// `None` for events other than key presses and releases. + /// key (Linux, Windows, and macOS with ⌘ held), and crates.io egui the physical key, so + /// [`Layout::resolve`] takes a letter there before the position. The Latin layout's character + /// comes from the frame's input ([`Dispatcher::dispatch`] fills it in). egui reports no Shift + /// level, so [`Layout`] infers it. `None` for events other than key presses and releases. pub fn from_egui(event: &Event, platform: Platform) -> Option { let Event::Key { key, physical_key, modifiers, .. } = event else { return None }; let mut input = KeyInput::new(Mods::from_egui(modifiers, platform)); @@ -285,12 +286,12 @@ impl Dispatcher { let mut drop = vec![false; events.len()]; // The web build sends both a clipboard key press and the clipboard event it causes. The // press decides: its event goes with it when it runs a command and stays otherwise. - let causes = clipboard_event_follows(&events, &self.layout, platform); + let causes = clipboard_event_follows(&events, &self.layout, platform, input); let mut caused_taken: Vec> = vec![None; events.len()]; for (i, event) in events.into_iter().enumerate() { match &event { Event::Key { pressed: true, repeat, .. } if !*repeat || self.repeat => { - let input = KeyInput::from_egui(&event, platform).unwrap_or_default(); + let input = key_input(&event, platform, input); let held = input.mods; if let Some(chord) = self.layout.resolve(&input) { let plain_clip = is_plain_clipboard(chord.key, chord.mods); @@ -423,14 +424,36 @@ trait KeyboardLayoutIsLatin { impl KeyboardLayoutIsLatin for InputState {} -fn clipboard_event_follows(events: &[Event], layout: &Layout, platform: Platform) -> Vec)>> { +/// The suite's egui fork has an inherent `InputState::latin_layout_char`, the character a key +/// pressed this frame types on the Latin layout shortcuts follow; crates.io egui reports none. +/// Reached as [`KeyboardLayoutIsLatin`] is. +#[allow(dead_code, reason = "unused when egui has the inherent method")] +trait LatinLayoutChar { + fn latin_layout_char(&self, _physical_key: EguiKey) -> Option { + None + } +} + +impl LatinLayoutChar for InputState {} + +/// A press as [`KeyInput::from_egui`] reads it, with the Latin layout's character `input` reports +/// for its key. +fn key_input(event: &Event, platform: Platform, input: &InputState) -> KeyInput { + let mut key = KeyInput::from_egui(event, platform).unwrap_or_default(); + if let Event::Key { physical_key: Some(physical), .. } = event { + key.latin = input.latin_layout_char(*physical); + } + key +} + +fn clipboard_event_follows(events: &[Event], layout: &Layout, platform: Platform, input: &InputState) -> Vec)>> { let mut out = vec![None; events.len()]; let mut ahead: [Option; 3] = [None; 3]; for (i, event) in events.iter().enumerate().rev() { if let Some(seen) = event_clip(event).and_then(|c| ahead.get_mut(c as usize)) { *seen = Some(i); } else if let Event::Key { pressed: true, .. } = event { - let chord = KeyInput::from_egui(event, platform).and_then(|input| layout.resolve(&input)); + let chord = layout.resolve(&key_input(event, platform, input)); let clip = chord.and_then(|c| clipboard_kind(c.key, c.mods)); if let Some(j) = clip.and_then(|c| ahead.get_mut(c as usize)).and_then(Option::take) { let text = if let Some(Event::Paste(text)) = events.get(j) { Some(text.clone()) } else { None }; diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs index 329191e0..391ee5f4 100644 --- a/crates/keymap/src/layout.rs +++ b/crates/keymap/src/layout.rs @@ -3,11 +3,12 @@ //! //! One rule decides matching ([`Layout::candidates`]), recording ([`Layout::resolve`]) and labels //! ([`Chord::display_for`]): -//! 1. **Letters A–Z:** the layout's Latin letter for the key, [`KeyInput::latin`] when the -//! platform reports it. Without it, on a layout that is not Latin, the letter the toolkit -//! reports as typed, else the key's position (the Russian `с` key is C, the Hebrew `/` key is -//! Q). The suite's egui-winit reports the Latin layout's letter there, so on Dvorak + Russian -//! the `с` key is Dvorak's J. On a Latin layout (Dvorak, AZERTY), the letter typed. +//! 1. **Letters A–Z:** the layout's Latin letter for the key, from [`KeyInput::latin`] when the +//! platform reports the Latin layout's character: on Dvorak + Russian the `с` key is Dvorak's +//! J, and the `й` key, `'` on Dvorak, runs no letter binding. Without a report, on a layout +//! that is not Latin, the letter the toolkit reports as typed, else the key's position (the +//! Russian `с` key is C, the Hebrew `/` key is Q); on a Latin layout (Dvorak, AZERTY), the +//! letter typed. //! 2. **Digits:** the digit typed, else the key's position (AZERTY's `&é"` row types no digit //! without Shift). A digit-row press that types punctuation also matches its position's digit, //! after the punctuation: AZERTY's `-` key runs Ctrl+- and, when that has no binding, Ctrl+6. @@ -46,10 +47,12 @@ pub struct KeyInput { /// The key at the same position on a US QWERTY keyboard (winit's physical key, the web's /// `code`). pub physical: Option, - /// The layout's Latin letter for this key, when the platform reports one: the virtual-key - /// code on Windows, the layout's ⌘ table on macOS, the first Latin layout group on Linux, the - /// key code on the web when the layout is not Latin. Keys other than letters are ignored. - pub latin: Option, + /// The character this key types on the layout the platform takes shortcut letters from, when + /// it reports one: the layout's ⌘ table on macOS, the first Latin layout group on Linux. A + /// letter makes the press that letter; any other character means the key has no Latin letter, + /// so the press runs no letter binding (Dvorak's `'` on the key Russian types `й` on is no + /// Ctrl+Q). + pub latin: Option, /// Whether the layout needs Shift to type `typed`, when the platform reports it. Otherwise /// Shift counts as needed when `typed` differs from `physical`, and without `physical` when /// `typed` is a shifted symbol on a US layout (`:`, `?`, `+`). Only punctuation drops Shift. @@ -71,8 +74,8 @@ impl KeyInput { self } - pub const fn with_latin(mut self, letter: Key) -> KeyInput { - self.latin = Some(letter); + pub const fn with_latin(mut self, c: char) -> KeyInput { + self.latin = Some(c); self } @@ -196,12 +199,14 @@ impl Layout { /// `None` when the press names no key. pub fn resolve(&self, input: &KeyInput) -> Option { let held = input.mods; - if let Some(letter) = input.latin.filter(|k| k.is_letter()) { + let latin = input.latin.map(|c| Key::from_char(c).filter(|k| k.is_letter())); + if let Some(Some(letter)) = latin { return Some(Chord::new(held, letter)); } // A letter typed on a layout that is not Latin is the toolkit's letter for shortcuts, or // egui standing in the position for a letter it has no key for. - if !self.is_latin() + if latin.is_none() + && !self.is_latin() && let Some(letter) = input.typed.filter(|k| k.is_letter()).or(input.physical).filter(|k| k.is_letter()) { return Some(Chord::new(held, letter)); @@ -213,7 +218,12 @@ impl Layout { { return None; } - let key = input.typed.or(input.physical)?; + let mut key = input.typed.or(input.physical)?; + // The Latin layout has no letter here, so a letter egui reports is its position: the + // press is the Latin layout's key, or nothing. + if let (Some(c), true) = (input.latin, key.is_letter()) { + key = Key::from_char(c)?; + } let drop_shift = held.shift && key.is_symbol() && input.needs_shift(key); Some(Chord::new(if drop_shift { without_shift(held) } else { held }, key)) } diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index a495d7c7..e5364518 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -537,3 +537,37 @@ fn letters_follow_the_latin_layout_from_egui_winit_to_the_dispatcher() { assert_eq!(ran, [expected], "{name}, reported"); } } + +/// `input` with the Latin layout characters the suite's egui-winit reports per physical key, set +/// through serde because crates.io egui has no such field; `None` there. +fn with_latin_layout_chars(mut input: InputState, chars: &[(EguiKey, char)]) -> Option { + let mut raw = serde_json::to_value(&input.raw).unwrap(); + raw.get("latin_layout_chars")?; + raw["latin_layout_chars"] = serde_json::to_value(chars).unwrap(); + input.raw = serde_json::from_value(raw).unwrap(); + Some(input) +} + +#[test] +fn a_key_without_a_latin_letter_runs_no_letter_binding() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new(vec![app("quit", "Cmd+Q"), app("quote", "Cmd+'"), app("j", "Cmd+J")], Scopes::new(), Platform::Linux); + let at = |key, physical| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: CTRL }; + + // Dvorak + Russian. The QWERTY Q key types й, and Dvorak ' there: egui-winit reports the key + // as ', and egui without a key for the character as its position, Q. The QWERTY C key types + // с, and Dvorak j. + let latin = [(EguiKey::Q, '\''), (EguiKey::C, 'j')]; + let cases = [(at(EguiKey::Quote, EguiKey::Q), "quote"), (at(EguiKey::Q, EguiKey::Q), "quote"), (at(EguiKey::J, EguiKey::C), "j")]; + for (press, expected) in cases { + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let Some(input) = with_latin_layout_chars(frame(1.0, CTRL, vec![press.clone()]), &latin) else { + // crates.io egui reports no Latin layout, so the position decides: Ctrl+Q. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(EguiKey::Q, EguiKey::Q)])); + assert_eq!(ran, ["quit"]); + return; + }; + let (ran, _) = run(&mut d, &km, &Active::NONE, input); + assert_eq!(ran, [expected], "{press:?}"); + } +} diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs index 9d4f440c..a0376476 100644 --- a/crates/keymap/tests/layout.rs +++ b/crates/keymap/tests/layout.rs @@ -35,6 +35,14 @@ fn each_layout_resolves_presses_by_the_rule() { // Greek: Q types ;. ("Greek ⌃Q", &other, at("Cmd+;", Key::Q), "Cmd+Q"), ("Greek ⌃Z", &other, at("Cmd+Z", Key::Z), "Cmd+Z"), + // Dvorak + Russian, as the suite's egui-winit reports them: the Latin layout's character + // for each key. Dvorak types ' on the Q position, which egui reports as Q or as '. + ("Dvorak + Russian ⌃С", &other, at("Cmd+J", Key::C).with_latin('j'), "Cmd+J"), + ("Dvorak + Russian ⌃Й", &other, at("Cmd+Q", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian ⌃Й as '", &other, at("Cmd+'", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian / key types .", &other, at("Cmd+.", Key::Slash).with_latin('z'), "Cmd+Z"), + // Dvorak + Hebrew: the Q position types / and has no Dvorak letter. + ("Dvorak + Hebrew ⌃Q", &other, at("Cmd+/", Key::Q).with_latin('\''), "Cmd+/"), // Thai Kedmanee: 2 types /, - types ข, D types ก. ("Thai 2 key types /", &other, at("Cmd+/", Key::Num2), "Cmd+/"), ("Thai - key types ข", &other, at("Cmd+-", Key::Minus), "Cmd+-"), @@ -64,7 +72,7 @@ fn each_layout_resolves_presses_by_the_rule() { ("US ⇧- types _, which has no key", &latin, at("Shift+-", Key::Minus), "Shift+-"), // macOS "Dvorak – QWERTY ⌘": with ⌘ held the layout's ⌘ table is QWERTY, while the // logical key egui reports is Dvorak's K. - ("Dvorak – QWERTY ⌘ ⌘V", &latin, at("Cmd+K", Key::V).with_latin(Key::V), "Cmd+V"), + ("Dvorak – QWERTY ⌘ ⌘V", &latin, at("Cmd+K", Key::V).with_latin('v'), "Cmd+V"), // A Shift level the platform reports wins over the inference. ("reported Shift level", &latin, at("Cmd+Shift+/", Key::Num7).with_shift_needed(false), "Cmd+Shift+/"), ]; @@ -81,7 +89,10 @@ fn reported_cases_match_only_the_chord_the_press_means() { assert!(arabic.matches(&chord("Ctrl+Shift+D"), &ctrl_shift_d, p)); assert!(!arabic.matches(&chord("Ctrl+Shift+]"), &ctrl_shift_d, p), "Arabic Ctrl+Shift+D must not run Ctrl+Shift+]"); - let qwerty_cmd = at("Cmd+K", Key::V).with_latin(Key::V); + let dvorak_russian_q = at("Cmd+Q", Key::Q).with_latin('\''); + assert!(!Layout::new().with_latin(false).matches(&chord("Ctrl+Q"), &dvorak_russian_q, p), "Dvorak + Russian й must not quit"); + + let qwerty_cmd = at("Cmd+K", Key::V).with_latin('v'); assert!(Layout::new().matches(&chord("Cmd+V"), &qwerty_cmd, Platform::Mac)); assert!(!Layout::new().matches(&chord("Cmd+K"), &qwerty_cmd, Platform::Mac), "⌘V must not open the command palette");