keymap: read presses by the keyboard layout

Matching, the recorder and labels share one rule (craft-libs#198, track 4):
letters by the layout's Latin letter, or by position on layouts that are not
Latin; digits as typed, else by position; punctuation as typed, without the
Shift the layout needs for it. Layout holds whether the layout is Latin and
its characters at punctuation positions, reported by the platform or learned
from typing. KeyInput holds what the toolkit reports for one press, so a
toolkit that knows the virtual-key code, the macOS command table or the
Shift level can pass it on.
This commit is contained in:
aiongg committed 2026-10-21 04:57:06 +00:00
1 parent 9aaf9dfd38
commit 86248331bb
7 files changed
+637 -80

No files matched your search

+28 -3
View File
@@ -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));
+92 -2
View File
@@ -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<Key> {
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<Chord> for Trigger {
+66 -73
View File
@@ -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<KeyInput> {
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<Chord> {
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<CommandRef>,
/// 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<Option<bool>> = 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<Hit> {
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<usize>], 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<Hit> {
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<Option<(usize, Option<String>)>> {
fn clipboard_event_follows(events: &[Event], layout: &Layout, platform: Platform) -> Vec<Option<(usize, Option<String>)>> {
let mut out = vec![None; events.len()];
let mut ahead: [Option<usize>; 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));
+255
View File
@@ -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<Key>,
/// The key at the same position on a US QWERTY keyboard (winit's physical key, the web's
/// `code`).
pub physical: Option<Key>,
/// 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<Key>,
/// 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<bool>,
}
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<bool>,
seen_latin: Option<bool>,
/// The character typed without modifiers at each punctuation position.
chars: BTreeMap<Key, char>,
}
/// 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<Key> {
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<bool>) {
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<char> {
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<Chord> {
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<Chord> {
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<String> {
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(),
})
}
}
+4 -1
View File
@@ -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};
+35 -1
View File
@@ -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"]);
}
+157
View File
@@ -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<String> {
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+[");
}