Skip to main content

frust_core/
shortcuts.rs

1//! Keyboard shortcuts: the [`Chord`] spec and the per-paint declaration table behind
2//! [`PaintCtx::register_shortcut`](crate::widget::PaintCtx::register_shortcut).
3//!
4//! A widget declares a [`ShortcutDecl`] from its own `paint`, exactly like an overlay entry: the
5//! root clears the table every paint, so a declarer that stops declaring (or unmounts) stops
6//! matching after the next paint, with nothing to unregister. A declaration made while the root
7//! paints a floated pod is stamped with that pod's [`OverlayKey`].
8//!
9//! A `Key` the focus path left unhandled is matched against the last paint's table in tier
10//! order — declarations stamped with the topmost interactive overlay, then main-tree
11//! [`ShortcutScope::Local`], then [`ShortcutScope::AppGlobal`] — newest declaration first within
12//! a tier. While a modal pod is registered only that pod's tier is tried. Each candidate gets an [`InputEvent::Shortcut`](crate::event::InputEvent::Shortcut)
13//! broadcast (wrapped in an overlay event for a stamped one); the declarer answers `Handled`, and
14//! an unanswered delivery falls through to the next candidate.
15//!
16//! The thread-locals below are single-threaded RAII brackets, each scoped to one root's pass at a
17//! time and safe to re-enter: `SHORTCUT_TABLE` collects a paint pass's declarations and
18//! `SHORTCUT_PASS_OPEN` guards it; `CURRENT_OVERLAY` stamps those made while a pod paints;
19//! `SHORTCUT_HANDLED` and `KEY_FORWARD`/`KEY_FORWARD_OPEN` bracket one key dispatch.
20
21use std::cell::Cell;
22use std::fmt;
23use std::rc::Rc;
24
25use crate::event::{Key, KeyEvent, Modifiers, NamedKey, PassBracket};
26use crate::overlay::OverlayKey;
27
28thread_local! {
29    /// The next [`ShortcutId::next`] value; starts at `1` so `0` is never handed out.
30    static NEXT_SHORTCUT_ID: Cell<u64> = const { Cell::new(1) };
31
32    /// Declarations made so far in this thread's open paint pass, in paint order.
33    static SHORTCUT_TABLE: Cell<Vec<ShortcutEntry>> = const { Cell::new(Vec::new()) };
34
35    static SHORTCUT_PASS_OPEN: Cell<bool> = const { Cell::new(false) };
36
37    /// The floated pod the root is painting, stamped onto every declaration it makes.
38    static CURRENT_OVERLAY: Cell<Option<OverlayKey>> = const { Cell::new(None) };
39
40    /// Raised when a widget answers a shortcut delivery `Handled` (containers drop the result).
41    static SHORTCUT_HANDLED: Cell<bool> = const { Cell::new(false) };
42
43    /// The pod this event pass asked declined keys be forwarded to.
44    static KEY_FORWARD: Cell<Option<OverlayKey>> = const { Cell::new(None) };
45
46    static KEY_FORWARD_OPEN: Cell<bool> = const { Cell::new(false) };
47}
48
49/// Whether `Mod` means Command (Apple targets) or Ctrl (everything else, `wasm32` included).
50const MOD_IS_META: bool = cfg!(any(target_os = "macos", target_os = "ios"));
51
52/// A shortcut's identity, owned by its declarer and quoted back by its delivery.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub struct ShortcutId(u64);
55
56impl ShortcutId {
57    /// Allocate an id unique on this thread. Call once when the declarer is built.
58    pub fn next() -> Self {
59        ShortcutId(NEXT_SHORTCUT_ID.with(|next| {
60            let id = next.get();
61            next.set(id.wrapping_add(1));
62            id
63        }))
64    }
65
66    /// The raw integer, for map keys; allocation order is not a contract.
67    pub fn value(self) -> u64 {
68        self.0
69    }
70}
71
72/// Which tier a main-tree declaration joins; an overlay-stamped one is always tried first.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub enum ShortcutScope {
75    /// Tried before every app-global declaration.
76    Local,
77    /// The last tier: whatever no overlay or local declaration answered.
78    AppGlobal,
79}
80
81/// A key plus the exact modifier set it must be pressed with.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct Chord {
84    /// A character key is stored lowercased and matched case-insensitively.
85    pub key: Key,
86    pub mods: Modifiers,
87}
88
89/// Why [`Chord::parse`] refused a spec.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub enum ChordParseError {
92    /// The spec was empty or whitespace.
93    Empty,
94    /// A token that is neither a modifier nor a key name.
95    UnknownToken(String),
96    /// A key the input vocabulary cannot deliver. Reserved: every key name the parser knows,
97    /// function keys `F1`..`F24` included, is deliverable, so nothing produces it today.
98    UnsupportedKey(String),
99    /// Only modifiers, or an empty token between two `+`.
100    MissingKey,
101    /// More than one non-modifier token.
102    MultipleKeys,
103}
104
105impl fmt::Display for ChordParseError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            ChordParseError::Empty => f.write_str("empty chord"),
109            ChordParseError::UnknownToken(token) => write!(f, "unknown chord token `{token}`"),
110            ChordParseError::UnsupportedKey(token) => {
111                write!(f, "key `{token}` is not in the input vocabulary")
112            }
113            ChordParseError::MissingKey => f.write_str("chord names no key"),
114            ChordParseError::MultipleKeys => f.write_str("chord names more than one key"),
115        }
116    }
117}
118
119impl std::error::Error for ChordParseError {}
120
121impl Chord {
122    /// A chord from its parts; a character key is lowercased so it matches either case, and a
123    /// Shift-insensitive symbol key (see [`Chord::matches`]) drops `mods.shift`.
124    pub fn new(key: Key, mods: Modifiers) -> Self {
125        let key = match key {
126            Key::Character(text) => Key::Character(text.to_lowercase()),
127            named => named,
128        };
129        let mut mods = mods;
130        if shift_insensitive(&key) {
131            mods.shift = false;
132        }
133        Chord { key, mods }
134    }
135
136    /// Parse `"Mod+K"`, `"Shift+Tab"`, `"Ctrl+Shift+P"`, `"1"`, `"F2"`: `+`-separated, case-insensitive
137    /// modifiers then one key. `Mod` is Command on macOS/iOS and Ctrl elsewhere, chosen at compile
138    /// time, so a web build running on a Mac maps it to Ctrl. A symbol key (`+`, `?`, `!`) ignores
139    /// Shift, so `"Shift+?"` parses as `"?"`; spell `Shift+1` as `!`.
140    pub fn parse(spec: &str) -> Result<Chord, ChordParseError> {
141        let spec = spec.trim();
142        if spec.is_empty() {
143            return Err(ChordParseError::Empty);
144        }
145        // A trailing `++` names the plus key itself.
146        let (head, plus) = match spec.strip_suffix("++") {
147            Some(head) => (head, true),
148            None if spec == "+" => ("", true),
149            None => (spec, false),
150        };
151        let mut mods = Modifiers::default();
152        let mut key = plus.then(|| Key::Character("+".to_string()));
153        let tokens = (!head.is_empty()).then(|| head.split('+'));
154        for token in tokens.into_iter().flatten() {
155            let token = token.trim();
156            if token.is_empty() {
157                return Err(ChordParseError::MissingKey);
158            }
159            if apply_modifier(&mut mods, token) {
160                continue;
161            }
162            let parsed = parse_key(token)?;
163            if key.replace(parsed).is_some() {
164                return Err(ChordParseError::MultipleKeys);
165            }
166        }
167        let key = key.ok_or(ChordParseError::MissingKey)?;
168        Ok(Chord::new(key, mods))
169    }
170
171    /// Whether `event` presses this chord: the same key (characters case-insensitively) and
172    /// exactly the same modifiers. A symbol key (a character that is neither alphanumeric nor
173    /// whitespace) ignores `event.modifiers.shift`, because shells report Shift for the keys
174    /// that need it; ctrl, alt and meta still compare. An auto-repeat matches like a fresh press.
175    pub fn matches(&self, event: &KeyEvent) -> bool {
176        let mut held = event.modifiers;
177        if shift_insensitive(&self.key) {
178            held.shift = false;
179        }
180        if held != self.mods {
181            return false;
182        }
183        match (&self.key, &event.key) {
184            (Key::Named(want), Key::Named(got)) => want == got,
185            (Key::Character(want), Key::Character(got)) => *want == got.to_lowercase(),
186            _ => false,
187        }
188    }
189
190    /// The chord as a hint surface shows it, by `Mod`'s compile-time rule: `"⌘K"`, `"⇧⌘P"` on
191    /// macOS/iOS (symbols in ⌃⌥⇧⌘ order, unseparated), `"Ctrl+K"`, `"Ctrl+Shift+P"` elsewhere.
192    pub fn display(&self) -> String {
193        self.display_for(MOD_IS_META)
194    }
195
196    fn display_for(&self, apple: bool) -> String {
197        let m = self.mods;
198        let key = key_label(&self.key, apple);
199        let held = if apple {
200            [(m.ctrl, "⌃"), (m.alt, "⌥"), (m.shift, "⇧"), (m.meta, "⌘")]
201        } else {
202            [
203                (m.ctrl, "Ctrl"),
204                (m.alt, "Alt"),
205                (m.shift, "Shift"),
206                (m.meta, "Super"),
207            ]
208        };
209        let mut parts: Vec<&str> = held.iter().filter(|(on, _)| *on).map(|(_, n)| *n).collect();
210        parts.push(&key);
211        parts.join(if apple { "" } else { "+" })
212    }
213}
214
215/// Whether `key` is a symbol character whose Shift state a chord ignores.
216fn shift_insensitive(key: &Key) -> bool {
217    match key {
218        Key::Character(text) => {
219            !text.is_empty()
220                && text
221                    .chars()
222                    .all(|c| !c.is_alphanumeric() && !c.is_whitespace())
223        }
224        Key::Named(_) => false,
225    }
226}
227
228/// How [`Chord::display`] names `key`: characters uppercased, Apple's key symbols when `apple`.
229fn key_label(key: &Key, apple: bool) -> String {
230    let named = match key {
231        Key::Character(text) if text == " " => "Space",
232        Key::Character(text) => return text.to_uppercase(),
233        Key::Named(named) => match (named, apple) {
234            (NamedKey::Enter, true) => "↩",
235            (NamedKey::Enter, false) => "Enter",
236            (NamedKey::Backspace, true) => "⌫",
237            (NamedKey::Backspace, false) => "Backspace",
238            (NamedKey::Delete, true) => "⌦",
239            (NamedKey::Delete, false) => "Delete",
240            (NamedKey::Escape, true) => "⎋",
241            (NamedKey::Escape, false) => "Esc",
242            (NamedKey::Tab, true) => "⇥",
243            (NamedKey::Tab, false) => "Tab",
244            (NamedKey::ArrowLeft, _) => "←",
245            (NamedKey::ArrowRight, _) => "→",
246            (NamedKey::ArrowUp, _) => "↑",
247            (NamedKey::ArrowDown, _) => "↓",
248            (NamedKey::Home, _) => "Home",
249            (NamedKey::End, _) => "End",
250            (NamedKey::Insert, _) => "Insert",
251            (NamedKey::Copy, _) => "Copy",
252            (NamedKey::Cut, _) => "Cut",
253            (NamedKey::Paste, _) => "Paste",
254            (
255                NamedKey::F1
256                | NamedKey::F2
257                | NamedKey::F3
258                | NamedKey::F4
259                | NamedKey::F5
260                | NamedKey::F6
261                | NamedKey::F7
262                | NamedKey::F8
263                | NamedKey::F9
264                | NamedKey::F10
265                | NamedKey::F11
266                | NamedKey::F12
267                | NamedKey::F13
268                | NamedKey::F14
269                | NamedKey::F15
270                | NamedKey::F16
271                | NamedKey::F17
272                | NamedKey::F18
273                | NamedKey::F19
274                | NamedKey::F20
275                | NamedKey::F21
276                | NamedKey::F22
277                | NamedKey::F23
278                | NamedKey::F24,
279                _,
280            ) => return format!("F{}", named.function_number().unwrap_or(0)),
281        },
282    };
283    named.to_string()
284}
285
286/// Set the modifier `token` names and return `true`, or `false` when it names none.
287fn apply_modifier(mods: &mut Modifiers, token: &str) -> bool {
288    match token.to_ascii_lowercase().as_str() {
289        "mod" if MOD_IS_META => mods.meta = true,
290        "mod" => mods.ctrl = true,
291        "ctrl" | "control" => mods.ctrl = true,
292        "shift" => mods.shift = true,
293        "alt" | "option" | "opt" => mods.alt = true,
294        "meta" | "cmd" | "command" | "super" | "win" => mods.meta = true,
295        _ => return false,
296    }
297    true
298}
299
300/// The key one non-modifier token names.
301fn parse_key(token: &str) -> Result<Key, ChordParseError> {
302    if token.chars().count() == 1 {
303        return Ok(Key::Character(token.to_lowercase()));
304    }
305    let lower = token.to_ascii_lowercase();
306    let named = match lower.as_str() {
307        "enter" | "return" => NamedKey::Enter,
308        "backspace" => NamedKey::Backspace,
309        "delete" | "del" => NamedKey::Delete,
310        "left" | "arrowleft" => NamedKey::ArrowLeft,
311        "right" | "arrowright" => NamedKey::ArrowRight,
312        "up" | "arrowup" => NamedKey::ArrowUp,
313        "down" | "arrowdown" => NamedKey::ArrowDown,
314        "home" => NamedKey::Home,
315        "end" => NamedKey::End,
316        "escape" | "esc" => NamedKey::Escape,
317        "tab" => NamedKey::Tab,
318        "insert" | "ins" => NamedKey::Insert,
319        "copy" => NamedKey::Copy,
320        "cut" => NamedKey::Cut,
321        "paste" => NamedKey::Paste,
322        "space" => return Ok(Key::Character(" ".to_string())),
323        _ => match function_key(&lower) {
324            Some(named) => named,
325            None => return Err(ChordParseError::UnknownToken(token.to_string())),
326        },
327    };
328    Ok(Key::Named(named))
329}
330
331/// `f1`..`f24` (already lowercased) as a function key.
332fn function_key(lower: &str) -> Option<NamedKey> {
333    let digits = lower.strip_prefix('f')?;
334    if !digits.bytes().all(|b| b.is_ascii_digit()) {
335        return None;
336    }
337    NamedKey::function(digits.parse::<u8>().ok()?)
338}
339
340/// One shortcut a widget declares for the paint pass in progress.
341#[derive(Clone, Debug, PartialEq)]
342pub struct ShortcutDecl {
343    pub id: ShortcutId,
344    pub chord: Chord,
345    /// What a hint surface shows for it.
346    pub label: String,
347    pub scope: ShortcutScope,
348}
349
350/// One declaration as a palette or help overlay lists it
351/// ([`RenderRoot::shortcut_hints`](crate::app::RenderRoot::shortcut_hints)).
352#[derive(Clone, Debug, PartialEq)]
353pub struct ShortcutHint {
354    pub chord: Chord,
355    pub label: String,
356    pub scope: ShortcutScope,
357}
358
359/// A declaration as the root keeps it: stamped with the floated pod that declared it, if any.
360#[derive(Clone, Debug, PartialEq)]
361pub(crate) struct ShortcutEntry {
362    pub(crate) decl: ShortcutDecl,
363    pub(crate) overlay: Option<OverlayKey>,
364}
365
366/// The [`PassBracket`] around one declaration pass: enter clears, `take` drains.
367pub(crate) struct ShortcutPaintPass(PassBracket<Vec<ShortcutEntry>>);
368
369impl ShortcutPaintPass {
370    pub(crate) fn enter() -> Self {
371        ShortcutPaintPass(PassBracket::enter(&SHORTCUT_TABLE, &SHORTCUT_PASS_OPEN))
372    }
373
374    pub(crate) fn take(&self) -> Vec<ShortcutEntry> {
375        self.0.take()
376    }
377}
378
379/// Record `decl` in this thread's open pass, stamped with the pod being painted.
380pub(crate) fn register(decl: ShortcutDecl) {
381    let overlay = CURRENT_OVERLAY.with(Cell::get);
382    SHORTCUT_TABLE.with(|table| {
383        let mut entries = table.take();
384        entries.push(ShortcutEntry { decl, overlay });
385        table.set(entries);
386    });
387}
388
389/// Stamps declarations with `key` while held; restores the enclosing stamp on drop.
390pub(crate) struct OverlayStamp(Option<OverlayKey>);
391
392impl OverlayStamp {
393    pub(crate) fn enter(key: OverlayKey) -> Self {
394        OverlayStamp(CURRENT_OVERLAY.with(|slot| slot.replace(Some(key))))
395    }
396}
397
398impl Drop for OverlayStamp {
399    fn drop(&mut self) {
400        CURRENT_OVERLAY.with(|slot| slot.set(self.0));
401    }
402}
403
404/// Note that a widget answered a shortcut delivery `Handled` (behind `ChildPod`'s dispatch).
405pub(crate) fn note_handled() {
406    SHORTCUT_HANDLED.with(|flag| flag.set(true));
407}
408
409/// Brackets one delivery's handled flag: enter lowers it, drop restores the enclosing value.
410pub(crate) struct DeliveryProbe(bool);
411
412impl DeliveryProbe {
413    pub(crate) fn enter() -> Self {
414        DeliveryProbe(SHORTCUT_HANDLED.with(|flag| flag.replace(false)))
415    }
416
417    /// Whether a widget answered this delivery.
418    pub(crate) fn handled(&self) -> bool {
419        SHORTCUT_HANDLED.with(Cell::get)
420    }
421}
422
423impl Drop for DeliveryProbe {
424    fn drop(&mut self) {
425        SHORTCUT_HANDLED.with(|flag| flag.set(self.0));
426    }
427}
428
429/// Record a forward request (behind `EventCtx::forward_declined_keys_to`); last writer wins.
430pub(crate) fn request_key_forward(key: OverlayKey) {
431    KEY_FORWARD.with(|slot| slot.set(Some(key)));
432}
433
434/// The [`PassBracket`] around one event pass's forward request: enter clears, `take` drains.
435pub(crate) struct KeyForwardPass(PassBracket<Option<OverlayKey>>);
436
437impl KeyForwardPass {
438    pub(crate) fn enter() -> Self {
439        KeyForwardPass(PassBracket::enter(&KEY_FORWARD, &KEY_FORWARD_OPEN))
440    }
441
442    pub(crate) fn take(&self) -> Option<OverlayKey> {
443        self.0.take()
444    }
445}
446
447/// The last paint's declarations, in paint order: the snapshot `RenderRoot` matches keys against.
448#[derive(Clone, Debug, Default)]
449pub(crate) struct ShortcutTable {
450    entries: Vec<ShortcutEntry>,
451}
452
453impl ShortcutTable {
454    pub(crate) fn new(entries: Vec<ShortcutEntry>) -> Self {
455        Self { entries }
456    }
457
458    #[cfg(test)]
459    pub(crate) fn entries(&self) -> &[ShortcutEntry] {
460        &self.entries
461    }
462
463    /// Every declaration `filter` keeps, reachable or not, as hints in paint order.
464    pub(crate) fn hints(&self, mut filter: impl FnMut(&ShortcutHint) -> bool) -> Vec<ShortcutHint> {
465        let hints = self.entries.iter().map(|e| ShortcutHint {
466            chord: e.decl.chord.clone(),
467            label: e.decl.label.clone(),
468            scope: e.decl.scope,
469        });
470        hints.filter(|hint| filter(hint)).collect()
471    }
472
473    /// Every declaration as a shared snapshot, which the owning root hands to its contexts.
474    pub(crate) fn publish(&self) -> Rc<[ShortcutHint]> {
475        self.hints(|_| true).into()
476    }
477
478    /// The declarations `event` presses, as `(id, overlay stamp)` in delivery order: the
479    /// `top_overlay` pod's, then (unless `masked`, a modal pod's mask) main-tree `Local`, then
480    /// `AppGlobal`, newest first in each. Any other floated pod's declarations are left out.
481    pub(crate) fn candidates(
482        &self,
483        event: &KeyEvent,
484        top_overlay: Option<OverlayKey>,
485        masked: bool,
486    ) -> Vec<(ShortcutId, Option<OverlayKey>)> {
487        let tiers: [&dyn Fn(&ShortcutEntry) -> bool; 3] = [
488            &|e| e.overlay.is_some() && e.overlay == top_overlay,
489            &|e| e.overlay.is_none() && e.decl.scope == ShortcutScope::Local,
490            &|e| e.overlay.is_none() && e.decl.scope == ShortcutScope::AppGlobal,
491        ];
492        let mut out = Vec::new();
493        for in_tier in &tiers[..if masked { 1 } else { 3 }] {
494            let tier: Vec<_> = self
495                .entries
496                .iter()
497                .rev()
498                .filter(|e| in_tier(e) && e.decl.chord.matches(event))
499                .map(|e| (e.decl.id, e.overlay))
500                .collect();
501            #[cfg(debug_assertions)]
502            if tier.len() > 1 {
503                log::warn!(
504                    "frust-core: {} shortcuts in one tier match {:?}; the last declared wins",
505                    tier.len(),
506                    event.key
507                );
508            }
509            out.extend(tier);
510        }
511        out
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::widget::PaintCtx;
519    use kurbo::{Point, Size};
520
521    fn mods(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Modifiers {
522        Modifiers {
523            shift,
524            ctrl,
525            alt,
526            meta,
527        }
528    }
529
530    fn press(key: Key, modifiers: Modifiers) -> KeyEvent {
531        KeyEvent {
532            key,
533            modifiers,
534            repeat: false,
535        }
536    }
537
538    fn character(text: &str) -> Key {
539        Key::Character(text.to_string())
540    }
541
542    fn decl(chord: &str, scope: ShortcutScope) -> ShortcutDecl {
543        ShortcutDecl {
544            id: ShortcutId::next(),
545            chord: Chord::parse(chord).expect("valid chord"),
546            label: chord.to_string(),
547            scope,
548        }
549    }
550
551    #[test]
552    fn mod_maps_to_meta_on_apple_targets_and_ctrl_elsewhere() {
553        let chord = Chord::parse("Mod+K").unwrap();
554        let apple = cfg!(any(target_os = "macos", target_os = "ios"));
555        assert_eq!(chord.mods, mods(false, !apple, false, apple));
556        assert_eq!(chord.key, character("k"));
557    }
558
559    #[test]
560    fn parses_named_keys_modifier_stacks_and_bare_characters() {
561        assert_eq!(
562            Chord::parse("Shift+Tab").unwrap(),
563            Chord::new(Key::Named(NamedKey::Tab), mods(true, false, false, false))
564        );
565        assert_eq!(
566            Chord::parse("Ctrl+Shift+P").unwrap(),
567            Chord::new(character("p"), mods(true, true, false, false))
568        );
569        assert_eq!(
570            Chord::parse("1").unwrap(),
571            Chord::new(character("1"), Modifiers::default())
572        );
573        assert_eq!(
574            Chord::parse(" cmd + alt + Esc ").unwrap(),
575            Chord::new(Key::Named(NamedKey::Escape), mods(false, false, true, true))
576        );
577        assert_eq!(
578            Chord::parse("Ctrl++").unwrap(),
579            Chord::new(character("+"), mods(false, true, false, false))
580        );
581        assert_eq!(Chord::parse("Space").unwrap().key, character(" "));
582    }
583
584    #[test]
585    fn function_keys_parse_case_blind_and_display_without_apple_symbols() {
586        let f2 = Chord::new(Key::Named(NamedKey::F2), Modifiers::default());
587        assert_eq!(Chord::parse("F2"), Ok(f2));
588        assert_eq!(Chord::parse("f24").unwrap().key, Key::Named(NamedKey::F24));
589        assert_eq!(
590            Chord::parse("shift+f5").unwrap(),
591            Chord::new(Key::Named(NamedKey::F5), mods(true, false, false, false))
592        );
593        let ctrl = Chord::parse("Ctrl+F12").unwrap();
594        assert_eq!(ctrl.key, Key::Named(NamedKey::F12));
595        assert!(ctrl.mods.ctrl);
596        let key = Key::Named(NamedKey::F2);
597        let cmd_f2 = Chord::new(key.clone(), mods(false, false, false, true));
598        assert_eq!(cmd_f2.display_for(true), "⌘F2");
599        let ctrl_f2 = Chord::new(key, mods(false, true, false, false));
600        assert_eq!(ctrl_f2.display_for(false), "Ctrl+F2");
601    }
602
603    #[test]
604    fn unknown_tokens_and_malformed_specs_are_typed_errors() {
605        assert_eq!(
606            Chord::parse("Hyper+K"),
607            Err(ChordParseError::UnknownToken("Hyper".to_string()))
608        );
609        assert_eq!(
610            Chord::parse("Ctrl+Banana"),
611            Err(ChordParseError::UnknownToken("Banana".to_string()))
612        );
613        assert_eq!(
614            Chord::parse("F0"),
615            Err(ChordParseError::UnknownToken("F0".to_string()))
616        );
617        assert_eq!(
618            Chord::parse("F25"),
619            Err(ChordParseError::UnknownToken("F25".to_string()))
620        );
621        assert_eq!(Chord::parse("F+1"), Err(ChordParseError::MultipleKeys));
622        assert_eq!(Chord::parse(""), Err(ChordParseError::Empty));
623        assert_eq!(Chord::parse("Shift"), Err(ChordParseError::MissingKey));
624        assert_eq!(Chord::parse("Ctrl+"), Err(ChordParseError::MissingKey));
625        assert_eq!(Chord::parse("K+J"), Err(ChordParseError::MultipleKeys));
626        let message = Chord::parse("Hyper+K").unwrap_err().to_string();
627        assert!(message.contains("Hyper"), "{message}");
628    }
629
630    #[test]
631    fn matching_is_exact_on_modifiers_case_blind_on_characters_and_ignores_repeat() {
632        let chord = Chord::parse("Ctrl+K").unwrap();
633        let ctrl = mods(false, true, false, false);
634        assert!(chord.matches(&press(character("k"), ctrl)));
635        assert!(chord.matches(&press(character("K"), ctrl)));
636        let mut held = press(character("k"), ctrl);
637        held.repeat = true;
638        assert!(chord.matches(&held), "an auto-repeat matches too");
639        assert!(!chord.matches(&press(character("k"), Modifiers::default())));
640        assert!(!chord.matches(&press(character("k"), mods(true, true, false, false))));
641        assert!(!chord.matches(&press(character("j"), ctrl)));
642        assert!(!chord.matches(&press(Key::Named(NamedKey::Tab), ctrl)));
643    }
644
645    #[test]
646    fn display_follows_the_platform_rule() {
647        let chord = |spec: &str| Chord::parse(spec).unwrap();
648        assert_eq!(chord("Ctrl+K").display_for(false), "Ctrl+K");
649        assert_eq!(chord("Ctrl+K").display_for(true), "⌃K");
650        assert_eq!(chord("Meta+Shift+P").display_for(true), "⇧⌘P");
651        assert_eq!(chord("Meta+Shift+P").display_for(false), "Shift+Super+P");
652        assert_eq!(chord("Alt+Ctrl+Space").display_for(false), "Ctrl+Alt+Space");
653        assert_eq!(chord("Shift+Tab").display_for(false), "Shift+Tab");
654        assert_eq!(chord("Shift+Tab").display_for(true), "⇧⇥");
655        assert_eq!(chord("Esc").display_for(false), "Esc");
656        assert_eq!(chord("Cmd+Enter").display_for(true), "⌘↩");
657        assert_eq!(chord("Cmd+Enter").display_for(false), "Super+Enter");
658        assert_eq!(chord("1").display(), "1");
659        let apple = cfg!(any(target_os = "macos", target_os = "ios"));
660        let expected = if apple { "⌘K" } else { "Ctrl+K" };
661        assert_eq!(chord("Mod+K").display(), expected);
662    }
663
664    #[test]
665    fn publish_snapshots_the_table_as_one_shared_rc() {
666        let table = ShortcutTable::new(vec![
667            ShortcutEntry {
668                decl: decl("Mod+K", ShortcutScope::AppGlobal),
669                overlay: None,
670            },
671            ShortcutEntry {
672                decl: decl("Esc", ShortcutScope::Local),
673                overlay: Some(OverlayKey::next()),
674            },
675        ]);
676        let first = table.publish();
677        let labels: Vec<_> = first.iter().map(|h| h.label.as_str()).collect();
678        assert_eq!(labels, ["Mod+K", "Esc"]);
679        assert!(ShortcutTable::default().publish().is_empty());
680        assert_eq!(first.len(), 2, "a held snapshot is unaffected");
681    }
682
683    #[test]
684    fn symbol_chords_ignore_shift_and_the_rest_stay_exact() {
685        let (ctrl, shift) = (
686            mods(false, true, false, false),
687            mods(true, false, false, false),
688        );
689        let both = mods(true, true, false, false);
690        let plus = Chord::parse("Ctrl++").unwrap();
691        assert!(plus.matches(&press(character("+"), both)));
692        assert!(plus.matches(&press(character("+"), ctrl)));
693        assert!(
694            !plus.matches(&press(character("+"), shift)),
695            "ctrl still counts"
696        );
697        let query = Chord::parse("?").unwrap();
698        assert!(query.matches(&press(character("?"), shift)));
699        assert!(query.matches(&press(character("?"), Modifiers::default())));
700        assert_eq!(Chord::parse("Shift+?").unwrap(), query);
701        assert_eq!(query.display_for(false), "?");
702        let space = Chord::parse("Shift+Space").unwrap();
703        assert!(!space.matches(&press(character(" "), Modifiers::default())));
704        assert!(space.matches(&press(character(" "), shift)));
705        let digit = Chord::parse("1").unwrap();
706        assert!(!digit.matches(&press(character("1"), shift)));
707        let palette = Chord::parse("Ctrl+Shift+P").unwrap();
708        assert!(!palette.matches(&press(character("p"), ctrl)));
709        assert!(palette.matches(&press(character("P"), both)));
710    }
711
712    #[test]
713    fn hints_list_every_declaration_in_paint_order_through_the_filter() {
714        let (global, local, floated) = (
715            decl("Mod+K", ShortcutScope::AppGlobal),
716            decl("Mod+B", ShortcutScope::Local),
717            decl("Esc", ShortcutScope::Local),
718        );
719        let table = ShortcutTable::new(vec![
720            ShortcutEntry {
721                decl: global.clone(),
722                overlay: None,
723            },
724            ShortcutEntry {
725                decl: local.clone(),
726                overlay: None,
727            },
728            ShortcutEntry {
729                decl: floated.clone(),
730                overlay: Some(OverlayKey::next()),
731            },
732        ]);
733        let all = table.hints(|_| true);
734        let labels: Vec<_> = all.iter().map(|h| h.label.as_str()).collect();
735        assert_eq!(labels, ["Mod+K", "Mod+B", "Esc"]);
736        assert_eq!(
737            all[0],
738            ShortcutHint {
739                chord: global.chord,
740                label: global.label,
741                scope: ShortcutScope::AppGlobal,
742            }
743        );
744        let local_only = table.hints(|h| h.scope == ShortcutScope::Local);
745        assert_eq!(local_only.len(), 2);
746        assert!(ShortcutTable::default().hints(|_| true).is_empty());
747    }
748
749    #[test]
750    fn candidates_follow_tier_order_newest_first() {
751        let (top, other) = (OverlayKey::next(), OverlayKey::next());
752        let global = decl("Ctrl+K", ShortcutScope::AppGlobal);
753        let local_old = decl("Ctrl+K", ShortcutScope::Local);
754        let local_new = decl("Ctrl+K", ShortcutScope::Local);
755        let in_top = decl("Ctrl+K", ShortcutScope::AppGlobal);
756        let in_other = decl("Ctrl+K", ShortcutScope::Local);
757        let unrelated = decl("Ctrl+J", ShortcutScope::Local);
758        let stamped = |d: &ShortcutDecl, overlay| ShortcutEntry {
759            decl: d.clone(),
760            overlay,
761        };
762        let table = ShortcutTable::new(vec![
763            stamped(&global, None),
764            stamped(&local_old, None),
765            stamped(&in_top, Some(top)),
766            stamped(&local_new, None),
767            stamped(&in_other, Some(other)),
768            stamped(&unrelated, None),
769        ]);
770        let event = press(character("k"), mods(false, true, false, false));
771        assert_eq!(
772            table.candidates(&event, Some(top), false),
773            vec![
774                (in_top.id, Some(top)),
775                (local_new.id, None),
776                (local_old.id, None),
777                (global.id, None),
778            ],
779            "overlay tier, then Local (last declared first), then AppGlobal"
780        );
781        assert_eq!(
782            table.candidates(&event, None, false).len(),
783            3,
784            "with no interactive pod on top, no stamped declaration is reachable"
785        );
786        assert_eq!(
787            table.candidates(&event, Some(top), true),
788            vec![(in_top.id, Some(top))],
789            "a modal pod masks both main-tree tiers"
790        );
791    }
792
793    #[test]
794    fn the_table_is_pass_scoped_and_stamps_the_pod_being_painted() {
795        let pass = ShortcutPaintPass::enter();
796        let (plain, floated) = (
797            decl("1", ShortcutScope::Local),
798            decl("2", ShortcutScope::Local),
799        );
800        let key = OverlayKey::next();
801        register(plain.clone());
802        {
803            let _stamp = OverlayStamp::enter(key);
804            register(floated.clone());
805        }
806        let drained = pass.take();
807        assert_eq!(
808            drained.iter().map(|e| e.overlay).collect::<Vec<_>>(),
809            vec![None, Some(key)]
810        );
811        assert!(pass.take().is_empty(), "the drain empties the table");
812        drop(pass);
813        register(plain);
814        let next = ShortcutPaintPass::enter();
815        assert!(next.take().is_empty(), "a stray declaration never leaks in");
816    }
817
818    #[test]
819    fn declaring_counts_as_a_paint_publication() {
820        let _pass = ShortcutPaintPass::enter();
821        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(10.0, 10.0));
822        assert_eq!(ctx.paint_publications(), 0);
823        ctx.register_shortcut(decl("Mod+S", ShortcutScope::AppGlobal));
824        assert_eq!(
825            ctx.paint_publications(),
826            1,
827            "a retained boundary must refuse to replay a declaring body"
828        );
829    }
830
831    #[test]
832    fn a_forward_request_is_pass_scoped_and_nests() {
833        let (outer_key, inner_key) = (OverlayKey::next(), OverlayKey::next());
834        let outer = KeyForwardPass::enter();
835        request_key_forward(outer_key);
836        {
837            let inner = KeyForwardPass::enter();
838            assert_eq!(inner.take(), None, "a nested pass starts empty");
839            request_key_forward(inner_key);
840            assert_eq!(inner.take(), Some(inner_key));
841        }
842        assert_eq!(
843            outer.take(),
844            Some(outer_key),
845            "and hands the enclosing request back"
846        );
847        drop(outer);
848        request_key_forward(inner_key);
849        assert_eq!(
850            KeyForwardPass::enter().take(),
851            None,
852            "a stray request never leaks in"
853        );
854    }
855
856    #[test]
857    fn a_delivery_probe_brackets_the_handled_flag() {
858        let outer = DeliveryProbe::enter();
859        note_handled();
860        {
861            let inner = DeliveryProbe::enter();
862            assert!(!inner.handled(), "a nested delivery starts unanswered");
863        }
864        assert!(outer.handled(), "and hands the enclosing answer back");
865        drop(outer);
866        assert!(!DeliveryProbe::enter().handled());
867    }
868}