1use 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 static NEXT_SHORTCUT_ID: Cell<u64> = const { Cell::new(1) };
31
32 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 static CURRENT_OVERLAY: Cell<Option<OverlayKey>> = const { Cell::new(None) };
39
40 static SHORTCUT_HANDLED: Cell<bool> = const { Cell::new(false) };
42
43 static KEY_FORWARD: Cell<Option<OverlayKey>> = const { Cell::new(None) };
45
46 static KEY_FORWARD_OPEN: Cell<bool> = const { Cell::new(false) };
47}
48
49const MOD_IS_META: bool = cfg!(any(target_os = "macos", target_os = "ios"));
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub struct ShortcutId(u64);
55
56impl ShortcutId {
57 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 pub fn value(self) -> u64 {
68 self.0
69 }
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub enum ShortcutScope {
75 Local,
77 AppGlobal,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct Chord {
84 pub key: Key,
86 pub mods: Modifiers,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub enum ChordParseError {
92 Empty,
94 UnknownToken(String),
96 UnsupportedKey(String),
99 MissingKey,
101 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 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 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 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 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 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
215fn 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
228fn 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
286fn 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
300fn 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
331fn 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#[derive(Clone, Debug, PartialEq)]
342pub struct ShortcutDecl {
343 pub id: ShortcutId,
344 pub chord: Chord,
345 pub label: String,
347 pub scope: ShortcutScope,
348}
349
350#[derive(Clone, Debug, PartialEq)]
353pub struct ShortcutHint {
354 pub chord: Chord,
355 pub label: String,
356 pub scope: ShortcutScope,
357}
358
359#[derive(Clone, Debug, PartialEq)]
361pub(crate) struct ShortcutEntry {
362 pub(crate) decl: ShortcutDecl,
363 pub(crate) overlay: Option<OverlayKey>,
364}
365
366pub(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
379pub(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
389pub(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
404pub(crate) fn note_handled() {
406 SHORTCUT_HANDLED.with(|flag| flag.set(true));
407}
408
409pub(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 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
429pub(crate) fn request_key_forward(key: OverlayKey) {
431 KEY_FORWARD.with(|slot| slot.set(Some(key)));
432}
433
434pub(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#[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 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 pub(crate) fn publish(&self) -> Rc<[ShortcutHint]> {
475 self.hints(|_| true).into()
476 }
477
478 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}