diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 15e812db..f4719461 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -79,6 +79,30 @@ macOS it folds into `cmd` (`Chord::effective`). - `chord::foreign::read_foreign` reads key text written by other applications (Windows or Mac modifier names, numeric keypad keys). +## Keyboard layouts + +[`layout`](src/layout.rs) decides which chord a key press means, the same way for matching +(`Layout::candidates`), the shortcut recorder (`Layout::resolve`) and labels +(`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 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 + `/` is ⇧7, so ⇧⌘7 runs ⌘/. +4. Whether a layout is Latin is decided for the layout, not per key. +5. Labels show letters and digits by name, and a punctuation key as the character the layout + types at its position when that character has no key of its own (`Ctrl+Ü` for Ctrl+[ on + German). + +A `KeyInput` holds what the toolkit reports for one press: modifiers, the key typed, the +physical key, and, when the platform reports them, the layout's Latin letter (Windows +virtual-key code, macOS ⌘ table, Linux Latin layout group) and whether Shift was needed. A +`Layout` holds whether the layout is Latin, as the platform reports it or as plain typing shows +it (`Layout::observe`), and the characters it types at punctuation positions. + ## Storing overrides `Overrides` is a field of the app's settings type. It is stored as a map of one text per command @@ -209,9 +233,10 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File ## Features -- `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the - shifted key egui reports for a Shift binding (`Shift+;` matches `:`); and `egui::Dispatcher`, - which runs a frame's key presses against the keymap: +- `egui`: conversions to and from egui keys and modifiers; `KeyInput::from_egui`; + `egui::matches`, which accepts the shifted key egui reports for a Shift binding (`Shift+;` + matches `:`); and `egui::Dispatcher`, which runs a frame's key presses against the keymap, + reading them by the layout it learns from typing or is given (`with_layout`, `layout()`): ```rust,ignore let hits = ctx.input_mut(|i| dispatcher.dispatch(i, &keymap, &active)); diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 23b1c41c..66651de3 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -7,6 +7,8 @@ use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use crate::layout::Layout; + pub mod foreign; /// The longest chord or trigger text [`Chord::parse`] and [`Trigger::parse`] accept, in bytes. @@ -138,6 +140,77 @@ impl Key { .or_else(|| KEY_ALIASES.iter().find(|(alias, _)| alias.eq_ignore_ascii_case(name)).map(|&(_, k)| k)) } + /// `A` to `Z`. + pub const fn is_letter(self) -> bool { + matches!( + self, + Key::A + | Key::B + | Key::C + | Key::D + | Key::E + | Key::F + | Key::G + | Key::H + | Key::I + | Key::J + | Key::K + | Key::L + | Key::M + | Key::N + | Key::O + | Key::P + | Key::Q + | Key::R + | Key::S + | Key::T + | Key::U + | Key::V + | Key::W + | Key::X + | Key::Y + | Key::Z + ) + } + + /// `0` to `9`. + pub const fn is_digit(self) -> bool { + matches!(self, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9) + } + + /// Punctuation and symbol keys: `-`, `=`, `+`, brackets, `\`, `;`, `'`, `` ` ``, `,`, `.`, + /// `/` and the shifted symbols `:`, `|`, `?`, `!`, `{`, `}`. + pub const fn is_symbol(self) -> bool { + matches!( + self, + Key::Minus + | Key::Equals + | Key::Plus + | Key::OpenBracket + | Key::CloseBracket + | Key::Backslash + | Key::Semicolon + | Key::Quote + | Key::Backtick + | Key::Comma + | Key::Period + | Key::Slash + | Key::Colon + | Key::Pipe + | Key::Questionmark + | Key::Exclamationmark + | Key::OpenCurlyBracket + | Key::CloseCurlyBracket + ) + } + + /// The letter, digit or symbol key for a typed character, ignoring case. + pub fn from_char(c: char) -> Option { + let mut buf = [0; 4]; + let text: &str = c.encode_utf8(&mut buf); + Key::ALL.iter().copied().find(|k| (k.is_letter() || k.is_digit() || k.is_symbol()) && k.name().eq_ignore_ascii_case(text)) + } + /// `F1` to `F35`. pub const fn is_function_key(self) -> bool { matches!( @@ -430,6 +503,17 @@ impl Chord { /// Text for menus and settings: `⌃⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere. pub fn display(&self, platform: Platform) -> String { + self.display_key(platform, None) + } + + /// [`display`](Self::display) for the keys of `layout`: a punctuation key shows the character + /// the layout types at its position when that character has no key of its own (`Ctrl+Ü` for + /// Ctrl+[ on German). Letters and digits show as in `display`. + pub fn display_for(&self, platform: Platform, layout: &Layout) -> String { + self.display_key(platform, layout.key_label(self.key).as_deref()) + } + + fn display_key(&self, platform: Platform, key_text: Option<&str>) -> String { let m = self.mods.effective(platform); let mut s = String::new(); if platform.is_mac() { @@ -438,14 +522,14 @@ impl Chord { s.push_str(glyph); } } - s.push_str(self.key.mac_symbol()); + s.push_str(key_text.unwrap_or(self.key.mac_symbol())); } else { for (on, name) in [(m.cmd, "Ctrl+"), (m.alt, "Alt+"), (m.shift, "Shift+")] { if on { s.push_str(name); } } - s.push_str(self.key.text_name()); + s.push_str(key_text.unwrap_or(self.key.text_name())); } s } @@ -554,6 +638,12 @@ impl Trigger { let one = self.chord.display(platform); if self.double { format!("{one} {one}") } else { one } } + + /// [`Chord::display_for`], twice with a space between for a double press. + pub fn display_for(&self, platform: Platform, layout: &Layout) -> String { + let one = self.chord.display_for(platform, layout); + if self.double { format!("{one} {one}") } else { one } + } } impl From for Trigger { diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index c40bcea8..b85b60e0 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -8,6 +8,7 @@ use ::egui::{Context, Event, InputState, Key as EguiKey, KeyboardShortcut, Modif use crate::chord::{Chord, Key, Mods, Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Keymap, Lookup}; +use crate::layout::{KeyInput, Layout}; use crate::scope::Active; /// The platform to build a keymap for in this context. Unlike [`Platform::current`], a web build @@ -89,22 +90,22 @@ impl Chord { } } -/// Base keys and the key egui reports for them with Shift held on a US layout. -const SHIFTED: &[(Key, Key)] = &[ - (Key::Semicolon, Key::Colon), - (Key::Slash, Key::Questionmark), - (Key::Backslash, Key::Pipe), - (Key::OpenBracket, Key::OpenCurlyBracket), - (Key::CloseBracket, Key::CloseCurlyBracket), - (Key::Equals, Key::Plus), - (Key::Num1, Key::Exclamationmark), -]; - -fn without_shift(m: Mods) -> Mods { - Mods { shift: false, ..m } +impl KeyInput { + /// What an egui key press reports: the logical key as typed and the physical key. egui + /// reports no Latin letter and no Shift level, so [`Layout`] infers them; a toolkit that + /// reports them fills them in here. `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)); + input.typed = Key::from_egui(*key); + input.physical = physical_key.and_then(Key::from_egui); + Some(input) + } } -/// Whether a key press egui reported triggers `binding` on `platform`. +/// Whether a key press egui reported triggers `binding` on `platform`, on a layout about which +/// nothing is known, for a press whose physical key is unknown. [`Layout::matches`] takes the +/// whole press and the layout. /// /// Besides an exact match: /// - Shift with a base key matches the shifted key egui reports (`Shift+;` matches `:`). @@ -113,47 +114,7 @@ fn without_shift(m: Mods) -> Mods { /// `Shift+=`, so zoom keys work with and without Shift. pub fn matches(binding: &Chord, key: EguiKey, mods: &Modifiers, platform: Platform) -> bool { let Some(pressed) = Key::from_egui(key) else { return false }; - matches_press(binding, pressed, Mods::from_egui(mods, platform), platform) -} - -fn matches_press(binding: &Chord, pressed: Key, held: Mods, platform: Platform) -> bool { - let want = binding.effective(platform); - if pressed == want.key && held == want.mods { - return true; - } - let same_but_shift = without_shift(held) == without_shift(want.mods); - if !same_but_shift { - return false; - } - let shifted_of = |base: Key| SHIFTED.iter().find(|(b, _)| *b == base).map(|&(_, s)| s); - let is_shifted_symbol = SHIFTED.iter().any(|&(_, s)| s == want.key); - match (want.key, pressed) { - (Key::Equals, Key::Plus) => !want.mods.shift || held.shift, - (Key::Plus, Key::Equals) => held.shift && !want.mods.shift, - (w, p) if w == p => is_shifted_symbol && !want.mods.shift && held.shift, - (w, p) => want.mods.shift && held.shift && shifted_of(w) == Some(p), - } -} - -/// The chords a press can stand for, most specific first: the press itself, then those that keep -/// Shift (`Shift+;` for `:`), then those without it (`=` for `+`). Each one [`matches`] the press. -fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { - let base = SHIFTED.iter().find(|&&(_, s)| s == pressed).map(|&(b, _)| b); - let swap = match pressed { - Key::Plus => Some(Key::Equals), - Key::Equals => Some(Key::Plus), - _ => None, - }; - let mut out = Vec::with_capacity(6); - for mods in [held, without_shift(held)] { - for key in [Some(pressed), base, swap].into_iter().flatten() { - let chord = Chord::new(mods, key); - if !out.contains(&chord) && matches_press(&chord, pressed, held, platform) { - out.push(chord); - } - } - } - out + 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. @@ -170,9 +131,10 @@ pub struct Hit { pub cycle: Vec, /// The chord whose binding matched, with `double` set when the double binding ran. pub trigger: Trigger, - /// Whether `trigger` is the press itself. `false` when the press matched through a stand-in: - /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or - /// `+` and `=` standing for each other. + /// Whether `trigger` is the chord the press means ([`Layout::resolve`]), the one the shortcut + /// recorder records. `false` when the press matched through a stand-in: a Shift binding on the + /// US base key (`Shift+;` for `:`), `+` and `=` standing for each other, or a digit-row + /// position's digit. pub exact: bool, /// The text of the Paste event that ran the command, or on the web of the Paste event that /// came with the press that ran it. @@ -236,6 +198,7 @@ pub struct Dispatcher { /// Whether a press's Text event comes before it when no text field has focus, as eframe's /// web build sends them. text_first: bool, + layout: Layout, } /// eframe's web build sends a press's Text event first when no text field has focus. @@ -243,7 +206,7 @@ const TEXT_FIRST: bool = cfg!(target_arch = "wasm32"); impl Default for Dispatcher { fn default() -> Dispatcher { - Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST } + Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST, layout: Layout::new() } } } @@ -272,6 +235,24 @@ impl Dispatcher { self } + /// Starts from what the platform reports about the keyboard layout. The dispatcher also + /// learns from what plain presses type ([`Layout::observe`]). + pub fn with_layout(mut self, layout: Layout) -> Dispatcher { + self.layout = layout; + self + } + + /// The keyboard layout as known so far, for the shortcut recorder and labels + /// ([`Chord::display_for`]). + pub fn layout(&self) -> &Layout { + &self.layout + } + + /// For reporting a layout switch ([`Layout::set_latin`]). + pub fn layout_mut(&mut self) -> &mut Layout { + &mut self.layout + } + /// The commands this frame's key presses run with `active` focus, in press order. /// /// A chord pressed again within [`DOUBLE_PRESS`] in the same panel runs its double binding @@ -292,17 +273,19 @@ impl Dispatcher { let mut kept = Vec::with_capacity(input.events.len()); let events = std::mem::take(&mut input.events); let text_of = text_of_press(&events, self.text_first && !active.typing); + self.learn(&events, &text_of, platform); 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, platform); + let causes = clipboard_event_follows(&events, &self.layout, platform); let mut caused_taken: Vec> = vec![None; events.len()]; for (i, event) in events.into_iter().enumerate() { match &event { - Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { - let held = Mods::from_egui(modifiers, platform); - if let Some(key) = Key::from_egui(*key) { - let plain_clip = is_plain_clipboard(key, held); + Event::Key { pressed: true, repeat, .. } if !*repeat || self.repeat => { + let input = KeyInput::from_egui(&event, platform).unwrap_or_default(); + let held = input.mods; + if let Some(chord) = self.layout.resolve(&input) { + let plain_clip = is_plain_clipboard(chord.key, chord.mods); let keep = if active.typing { text_of[i].is_some() || plain_clip } else { !self.clipboard && plain_clip && causes[i].is_some() }; if keep { @@ -313,7 +296,7 @@ impl Dispatcher { continue; } let caused = causes[i].clone(); - let hit = self.press(key, held, *repeat, now, keymap, active).map(|mut hit| { + let hit = self.press(&input, *repeat, now, keymap, active).map(|mut hit| { hit.pasted = caused.as_ref().and_then(|(_, text)| text.clone()); hit }); @@ -344,7 +327,7 @@ impl Dispatcher { 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(key, held, false, now, keymap, active) } else { None }; + let hit = if look { self.press(&KeyInput::new(held).with_typed(key), false, now, keymap, active) } else { None }; if let Some(mut hit) = hit { if let Event::Paste(text) = &event { hit.pasted = Some(text.clone()); @@ -364,12 +347,21 @@ impl Dispatcher { hits } - fn press(&mut self, key: Key, held: Mods, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { - let chords = candidates(key, held, keymap.platform()); + /// Teaches the layout what this frame's presses typed. + fn learn(&mut self, events: &[Event], text_of: &[Option], platform: Platform) { + for (event, text) in events.iter().zip(text_of) { + let Some(Event::Text(text)) = text.and_then(|j| events.get(j)) else { continue }; + if let Some(KeyInput { mods, physical: Some(physical), .. }) = KeyInput::from_egui(event, platform) { + self.layout.observe(physical, mods, text); + } + } + } + + fn press(&mut self, input: &KeyInput, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { + let chords = self.layout.candidates(input); let first = *chords.first()?; - let pressed = Chord::new(held, key); let find = |double: bool| { - chords.iter().find_map(|&chord| Hit::from_lookup(keymap.lookup(&chord, double, active), Trigger { chord, double }, chord == pressed)) + chords.iter().find_map(|&chord| Hit::from_lookup(keymap.lookup(&chord, double, active), Trigger { chord, double }, chord == first)) }; if repeat { return find(false); @@ -394,14 +386,15 @@ enum Clip { /// For each clipboard key press whose clipboard event comes later in the frame, as the web build /// sends them: the index of that event, and the text of a Paste event. -fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec)>> { +fn clipboard_event_follows(events: &[Event], layout: &Layout, platform: Platform) -> 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 { key, pressed: true, modifiers, .. } = event { - let clip = Key::from_egui(*key).and_then(|k| clipboard_kind(k, Mods::from_egui(modifiers, platform))); + } else if let Event::Key { pressed: true, .. } = event { + let chord = KeyInput::from_egui(event, platform).and_then(|input| layout.resolve(&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 }; out[i] = Some((j, text)); diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs new file mode 100644 index 00000000..3beacce3 --- /dev/null +++ b/crates/keymap/src/layout.rs @@ -0,0 +1,255 @@ +//! Which chord a key press means on the active keyboard layout, and the layout's own characters +//! for labels. +//! +//! 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 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. +//! 3. **Punctuation:** the character typed, without Shift when the layout needs Shift for it +//! (German `/` is ⇧7, so ⇧⌘7 is ⌘/; US `:` is ⇧;, so ⇧; is `:`). A Shift binding on a US +//! base key also matches the same press (`Shift+;` matches `:`), and `=` and `+` stand for +//! each other so zoom keys work with and without Shift. +//! 4. **Whether a layout is Latin** is decided for the whole layout ([`Layout::is_latin`]), never +//! per key: Dvorak's `'` key on the Q position stays `'`. +//! 5. **Labels** show letters and digits by name, and a punctuation key as the character the +//! layout types at its position when that character has no key of its own: Ctrl+[ is `Ctrl+Ü` +//! on German and `Ctrl+Х` on Russian. +//! +//! [`KeyInput`] carries what the toolkit reports for one press; toolkits that learn more from the +//! platform fill more of it. [`Layout`] holds what is known about the layout itself, reported by +//! the platform or learned from what plain typing produces ([`Layout::observe`]). + +use std::collections::BTreeMap; + +use crate::chord::{Chord, Key, Mods, Platform}; + +/// What the toolkit reports about one key press. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct KeyInput { + /// The modifiers held, read for the platform as [`Mods`] describes. + pub mods: Mods, + /// The key for the character the layout typed with `mods` held, when there is one (egui's + /// logical key). + pub typed: Option, + /// 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, + /// 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. + pub shift_needed: Option, +} + +impl KeyInput { + pub const fn new(mods: Mods) -> KeyInput { + KeyInput { mods, typed: None, physical: None, latin: None, shift_needed: None } + } + + pub const fn with_typed(mut self, key: Key) -> KeyInput { + self.typed = Some(key); + self + } + + pub const fn with_physical(mut self, key: Key) -> KeyInput { + self.physical = Some(key); + self + } + + pub const fn with_latin(mut self, letter: Key) -> KeyInput { + self.latin = Some(letter); + self + } + + pub const fn with_shift_needed(mut self, needed: bool) -> KeyInput { + self.shift_needed = Some(needed); + self + } + + fn needs_shift(&self, typed: Key) -> bool { + // A shifted symbol is no key position (egui's numeric keypad Plus is the one physical + // key among them), so it says nothing about the Shift level. + self.shift_needed.unwrap_or(match self.physical { + Some(physical) if us_base(physical).is_none() => physical != typed, + _ => us_base(typed).is_some(), + }) + } +} + +/// The active keyboard layout, as far as the platform reports it or typing has shown it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Layout { + reported_latin: Option, + seen_latin: Option, + /// The character typed without modifiers at each punctuation position. + chars: BTreeMap, +} + +/// US base keys and the key egui reports for them with Shift held. +const SHIFTED: &[(Key, Key)] = &[ + (Key::Semicolon, Key::Colon), + (Key::Slash, Key::Questionmark), + (Key::Backslash, Key::Pipe), + (Key::OpenBracket, Key::OpenCurlyBracket), + (Key::CloseBracket, Key::CloseCurlyBracket), + (Key::Equals, Key::Plus), + (Key::Num1, Key::Exclamationmark), +]; + +fn us_base(shifted: Key) -> Option { + SHIFTED.iter().find(|&&(_, s)| s == shifted).map(|&(b, _)| b) +} + +const fn without_shift(m: Mods) -> Mods { + Mods { shift: false, ..m } +} + +/// Letters of the Latin script: ASCII, Latin-1, Latin Extended-A and -B, Latin Extended +/// Additional. +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && matches!(c, 'A'..='Z' | 'a'..='z' | '\u{00C0}'..='\u{024F}' | '\u{1E00}'..='\u{1EFF}') +} + +impl Layout { + /// Nothing known yet: the layout counts as Latin until the platform or typing says otherwise. + pub fn new() -> Layout { + Layout::default() + } + + /// Whether the layout is Latin, as the platform reports it. + pub fn with_latin(mut self, latin: bool) -> Layout { + self.reported_latin = Some(latin); + self + } + + /// Whether the layout is Latin, as the platform reports it; `None` when it cannot tell, so + /// that typing decides. Call again when the user switches layouts. + pub fn set_latin(&mut self, latin: Option) { + self.reported_latin = latin; + } + + /// Whether letters are read as typed (rule 1): what the platform reported, else what a letter + /// key typed last, else true. + pub fn is_latin(&self) -> bool { + self.reported_latin.or(self.seen_latin).unwrap_or(true) + } + + /// The character the layout types without modifiers at the punctuation position `physical`. + pub fn with_char(mut self, physical: Key, typed: char) -> Layout { + self.set_char(physical, typed); + self + } + + /// See [`with_char`](Self::with_char). Positions other than punctuation are ignored. + pub fn set_char(&mut self, physical: Key, typed: char) { + if physical.is_symbol() { + self.chars.insert(physical, typed); + } + } + + /// The character known to be typed without modifiers at the punctuation position `physical`. + pub fn char_at(&self, physical: Key) -> Option { + self.chars.get(&physical).copied() + } + + /// Learns from a press at `physical` with `mods` held that typed `text`: a letter typed at a + /// letter position tells whether the layout is Latin, and a character typed without + /// modifiers at a punctuation position is that position's label. Presses with Cmd, Ctrl or + /// Alt, and text longer than one character, teach nothing. + pub fn observe(&mut self, physical: Key, mods: Mods, text: &str) { + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return }; + if mods.cmd || mods.ctrl || mods.alt { + return; + } + if physical.is_letter() { + if c.is_alphabetic() { + self.seen_latin = Some(is_latin_letter(c)); + } + } else if !mods.shift && !c.is_control() { + self.set_char(physical, c); + } + } + + /// The chord a press means, as the shortcut recorder records it and lookups try first. + /// `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()) { + return Some(Chord::new(held, letter)); + } + if !self.is_latin() + && let Some(position) = input.physical.filter(|k| k.is_letter()) + { + return Some(Chord::new(held, position)); + } + let key = input.typed.or(input.physical)?; + let drop_shift = held.shift && key.is_symbol() && input.needs_shift(key); + Some(Chord::new(if drop_shift { without_shift(held) } else { held }, key)) + } + + /// The chords a press can run, most specific first: [`resolve`](Self::resolve)'s chord, the + /// same press written as Shift with a US base key (`Shift+;` for `:`), `=` for `+` and `+` + /// for Shift+`=`, and on the digit row the position's digit. + pub fn candidates(&self, input: &KeyInput) -> Vec { + let Some(first) = self.resolve(input) else { return Vec::new() }; + let mut out = Vec::with_capacity(4); + let mut push = |chord: Chord| { + if !out.contains(&chord) { + out.push(chord); + } + }; + push(first); + if input.mods.shift + && !first.mods.shift + && let Some(base) = us_base(first.key) + { + push(Chord::new(input.mods, base)); + } + match first.key { + Key::Plus => push(Chord::new(first.mods, Key::Equals)), + Key::Equals if first.mods.shift => push(Chord::new(without_shift(first.mods), Key::Plus)), + _ => {} + } + if let Some(digit) = input.physical.filter(|k| k.is_digit()) + && first.key.is_symbol() + { + push(Chord::new(input.mods, digit)); + } + out + } + + /// Whether a press runs `binding` on `platform`: one of its [`candidates`](Self::candidates) + /// is the binding. + pub fn matches(&self, binding: &Chord, input: &KeyInput, platform: Platform) -> bool { + let want = binding.effective(platform); + self.candidates(input).iter().any(|c| c.effective(platform) == want) + } + + /// Rule 5: the layout's character for the punctuation key `key`, when pressing that position + /// means `key` and the layout types `key`'s own character nowhere else that is known. + pub(crate) fn key_label(&self, key: Key) -> Option { + let typed = self.char_at(key)?; + if Key::from_char(typed).is_some() { + return None; + } + let own = key.name().chars().next()?; + if self.chars.values().any(|&c| c == own) { + return None; + } + let mut upper = typed.to_uppercase(); + Some(match (upper.next(), upper.next()) { + (Some(u), None) => u.to_string(), + _ => typed.to_string(), + }) + } +} diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 9816ecda..7493a998 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -9,7 +9,8 @@ //! 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 +//! 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 //! converts to and from egui keys, matches key presses against bindings and dispatches a frame's //! key events. //! @@ -136,6 +137,7 @@ pub mod command; pub mod egui; pub mod import; pub mod keymap; +pub mod layout; pub mod reserved; pub mod scope; @@ -147,4 +149,5 @@ 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 scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index c1452e47..c7332b91 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -1,7 +1,7 @@ #![cfg(feature = "egui")] use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; -use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; use egui::{Event, InputState, Key as EguiKey, Modifiers}; fn chord(text: &str) -> Chord { @@ -453,3 +453,37 @@ fn key_repeats_run_only_when_turned_on() { assert_eq!(ran, ["nudge.right"]); assert_eq!(left, [held(true), held(true)]); } + +#[test] +fn the_dispatcher_reads_presses_by_the_layout() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new( + vec![app("edit.duplicate", "Cmd+Shift+D"), app("tab.next", "Cmd+Shift+]"), app("edit.comment", "Cmd+/")], + Scopes::new(), + Platform::Windows, + ); + let at = |key, physical, modifiers| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + + // Arabic 101: typing ي on the D key shows the layout is not Latin, so ⌃⇧D (which types ]) is + // Ctrl+Shift+D. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::D, EguiKey::D, Modifiers::NONE), Event::Text("ي".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert!(!d.layout().is_latin()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); + + // German: ⇧7 types /, so Ctrl+Shift+7 runs Ctrl+/; the ü key's label is learned from typing. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::OpenBracket, EguiKey::OpenBracket, Modifiers::NONE), Event::Text("ü".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert_eq!(chord("Cmd+[").display_for(Platform::Windows, d.layout()), "Ctrl+Ü"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)])); + assert_eq!(ran, ["edit.comment"]); + + // A layout the platform reports applies from the first press. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); +} diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs new file mode 100644 index 00000000..31f344f3 --- /dev/null +++ b/crates/keymap/tests/layout.rs @@ -0,0 +1,157 @@ +use craft_keymap::{Chord, Key, KeyInput, Layout, Mods, Platform}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +/// A press as egui reports it at the US position `physical`: the modifiers held and the logical +/// key, which is the physical key when the character typed has no key (`ф`, `é`, `ข`). +fn at(reported: &str, physical: Key) -> KeyInput { + let c = chord(reported); + KeyInput::new(c.mods).with_typed(c.key).with_physical(physical) +} + +fn texts(chords: &[Chord]) -> Vec { + chords.iter().map(Chord::to_string).collect() +} + +#[test] +fn each_layout_resolves_presses_by_the_rule() { + let latin = Layout::new(); + let other = Layout::new().with_latin(false); + let rows: &[(&str, &Layout, KeyInput, &str)] = &[ + // Russian: letters type Cyrillic, which egui reports as the physical key. + ("Russian ⌘С", &other, at("Cmd+C", Key::C), "Cmd+C"), + ("Russian ⌘Х", &other, at("Cmd+[", Key::OpenBracket), "Cmd+["), + ("Russian / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + ("Russian ⇧7 types ?", &other, at("Shift+?", Key::Num7), "?"), + // Arabic 101: ⇧D types ]. + ("Arabic ⌃⇧D", &other, at("Cmd+Shift+]", Key::D), "Cmd+Shift+D"), + ("Arabic ⌃V", &other, at("Cmd+V", Key::V), "Cmd+V"), + // Hebrew: Q types /, W types ', the / position types .. + ("Hebrew ⌃Q", &other, at("Cmd+/", Key::Q), "Cmd+Q"), + ("Hebrew ⌃W", &other, at("Cmd+'", Key::W), "Cmd+W"), + ("Hebrew / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + // Greek: Q types ;. + ("Greek ⌃Q", &other, at("Cmd+;", Key::Q), "Cmd+Q"), + ("Greek ⌃Z", &other, at("Cmd+Z", Key::Z), "Cmd+Z"), + // 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+-"), + ("Thai ⌃D", &other, at("Cmd+D", Key::D), "Cmd+D"), + // AZERTY. + ("AZERTY A", &latin, at("Cmd+A", Key::Q), "Cmd+A"), + ("AZERTY M", &latin, at("Cmd+M", Key::Semicolon), "Cmd+M"), + ("AZERTY M key types ,", &latin, at("Cmd+,", Key::M), "Cmd+,"), + ("AZERTY & key", &latin, at("Cmd+1", Key::Num1), "Cmd+1"), + ("AZERTY ⇧& types 1", &latin, at("Cmd+Shift+1", Key::Num1), "Cmd+Shift+1"), + ("AZERTY 6 key types -", &latin, at("Cmd+-", Key::Num6), "Cmd+-"), + ("AZERTY ⇧; types .", &latin, at("Shift+.", Key::Comma), "."), + // Dvorak. + ("Dvorak O", &latin, at("Cmd+O", Key::S), "Cmd+O"), + ("Dvorak ⇧O keeps Shift", &latin, at("Cmd+Shift+O", Key::S), "Cmd+Shift+O"), + ("Dvorak Q key types '", &latin, at("Cmd+'", Key::Q), "Cmd+'"), + ("Dvorak W key types ,", &latin, at("Cmd+,", Key::W), "Cmd+,"), + // QWERTZ (German). + ("QWERTZ ⇧⌘7 types /", &latin, at("Cmd+Shift+/", Key::Num7), "Cmd+/"), + ("QWERTZ Z", &latin, at("Cmd+Z", Key::Y), "Cmd+Z"), + ("QWERTZ ⇧ß types ?", &latin, at("Cmd+Shift+?", Key::Minus), "Cmd+?"), + ("QWERTZ + key", &latin, at("Cmd++", Key::CloseBracket), "Cmd++"), + ("QWERTZ ⇧, types ;", &latin, at("Cmd+Shift+;", Key::Comma), "Cmd+;"), + // US. + ("US ⇧;", &latin, at("Shift+:", Key::Semicolon), ":"), + ("US ⇧⌘=", &latin, at("Cmd+Shift++", Key::Equals), "Cmd++"), + ("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"), + // 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+/"), + ]; + for (name, layout, input, want) in rows { + assert_eq!(layout.resolve(input).map(|c| c.to_string()).as_deref(), Some(*want), "{name}"); + } +} + +#[test] +fn reported_cases_match_only_the_chord_the_press_means() { + let p = Platform::Windows; + let arabic = Layout::new().with_latin(false); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + 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); + 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"); + + let german = Layout::new(); + let slash = at("Cmd+Shift+/", Key::Num7); + assert_eq!(texts(&german.candidates(&slash)), ["Cmd+/", "Cmd+Shift+7"]); + assert!(german.matches(&chord("⌘/"), &slash, Platform::Mac)); + // A US-written ⌘? binding is reached with German's ⇧ß, not with ⇧7. + assert!(!german.matches(&chord("Cmd+Shift+/"), &slash, p)); + assert_eq!(texts(&german.candidates(&at("Cmd+Shift+?", Key::Minus))), ["Cmd+?", "Cmd+Shift+/"]); + // German Ctrl+; is Ctrl+Shift+, and must not run a Ctrl+Shift+, (font size) binding. + let semicolon = at("Cmd+Shift+;", Key::Comma); + assert!(german.matches(&chord("Ctrl+;"), &semicolon, p)); + assert!(!german.matches(&chord("Ctrl+Shift+,"), &semicolon, p)); + + // AZERTY's 6 key types -: Ctrl+- first, then Ctrl+6. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+-", Key::Num6))), ["Cmd+-", "Cmd+6"]); + // Zoom keys: + also runs =, and Shift+= runs +. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+Shift++", Key::Equals))), ["Cmd++", "Cmd+Shift+=", "Cmd+="]); + assert_eq!(texts(&Layout::new().candidates(&KeyInput::new(Mods::SHIFT).with_typed(Key::Equals))), ["Shift+=", "+"]); +} + +#[test] +fn typing_decides_whether_the_layout_is_latin() { + let mut layout = Layout::new(); + assert!(layout.is_latin(), "nothing known counts as Latin"); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + // Read as punctuation until the layout is known: ] needs Shift on the D key. + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+]"))); + + layout.observe(Key::D, Mods::NONE, "ي"); + assert!(!layout.is_latin()); + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+Shift+D"))); + + // Punctuation on a letter key says nothing: Hebrew Q and Dvorak Q both type a symbol. + layout.observe(Key::Q, Mods::NONE, "/"); + assert!(!layout.is_latin()); + layout.observe(Key::A, Mods::SHIFT, "A"); + assert!(layout.is_latin()); + layout.observe(Key::W, Mods::NONE, "ς"); + assert!(!layout.is_latin()); + layout.observe(Key::S, Mods::CMD, "s"); + assert!(!layout.is_latin(), "a command chord teaches nothing"); + layout.observe(Key::S, Mods::NONE, "š"); + assert!(layout.is_latin(), "Latin letters beyond ASCII count as Latin"); + + // What the platform reports wins. + let mut reported = Layout::new().with_latin(false); + reported.observe(Key::A, Mods::NONE, "a"); + assert!(!reported.is_latin()); + reported.set_latin(None); + assert!(reported.is_latin()); +} + +#[test] +fn labels_show_the_layouts_own_punctuation() { + let mut german = Layout::new().with_char(Key::OpenBracket, 'ü').with_char(Key::Slash, '-').with_char(Key::CloseBracket, '+'); + german.observe(Key::Minus, Mods::NONE, "ß"); + let label = |layout: &Layout, text: &str, p| chord(text).display_for(p, layout); + assert_eq!(label(&german, "Cmd+[", Platform::Windows), "Ctrl+Ü"); + assert_eq!(label(&german, "Cmd+[", Platform::Mac), "⌘Ü"); + // German types - at the / position, so Ctrl+- shows -; its + key types a key of its own. + assert_eq!(label(&german, "Cmd+-", Platform::Windows), "Ctrl+-"); + assert_eq!(label(&german, "Cmd+]", Platform::Windows), "Ctrl+]"); + assert_eq!(label(&german, "Cmd+Z", Platform::Windows), "Ctrl+Z"); + assert_eq!(label(&german, "Cmd+1", Platform::Windows), "Ctrl+1"); + + let russian = Layout::new().with_latin(false).with_char(Key::OpenBracket, 'х'); + assert_eq!(label(&russian, "Cmd+[", Platform::Linux), "Ctrl+Х"); + assert_eq!(label(&russian, "Cmd+C", Platform::Linux), "Ctrl+C"); + assert_eq!(craft_keymap::Trigger::double(chord("[")).display_for(Platform::Linux, &russian), "Х Х"); + assert_eq!(label(&Layout::new(), "Cmd+[", Platform::Linux), "Ctrl+["); +}