Skip to main content

incular_gestures/
keyboard.rs

1use std::any::Any;
2use std::cell::{Cell, RefCell};
3
4use std::collections::HashMap;
5
6use std::rc::{Rc, Weak};
7
8use incular_core::{Code, KeyboardEvent, KeyboardKey, Modifiers};
9
10use crate::details::GestureCallbacks;
11
12impl GestureCallbacks {
13    /// Whether this callback set carries retained keyboard/focus behavior.
14    #[must_use]
15    pub fn has_keyboard_listener(&self) -> bool {
16        self.on_key.is_some()
17            || self.on_key_down.is_some()
18            || self.on_key_repeat.is_some()
19            || self.on_key_up.is_some()
20            || self.on_shortcut.is_some()
21            || self.focus_node.is_some()
22    }
23
24    /// Whether this listener services keyboard-derived semantic
25    /// increment/decrement. Explicit semantic callbacks execute
26    /// independently of this flag; it governs only the keyboard
27    /// fallback, and never keyboard dispatch itself. Advertisement
28    /// and execution share this predicate so they cannot disagree.
29    #[must_use]
30    pub fn services_semantic_keys(&self) -> bool {
31        self.has_keyboard_listener() && self.include_semantics
32    }
33
34    /// Dispatches one keyboard event through the listener callbacks. The
35    /// shortcut dispatcher is nearest-scope behavior and gets first refusal;
36    /// the generic callback then gets the next opportunity before the
37    /// phase-specific callbacks run.
38    #[must_use]
39    pub fn handle_keyboard(&self, event: KeyboardEvent) -> bool {
40        if self
41            .on_shortcut
42            .as_ref()
43            .is_some_and(|callback| callback(event.clone()))
44        {
45            return true;
46        }
47        if self
48            .on_key
49            .as_ref()
50            .is_some_and(|callback| callback(event.clone()))
51        {
52            return true;
53        }
54        if event.state.is_down() {
55            let mut handled = false;
56            if let Some(callback) = &self.on_key_down {
57                callback(event.clone());
58                handled = true;
59            }
60            if event.repeat
61                && let Some(callback) = &self.on_key_repeat
62            {
63                callback(event);
64                handled = true;
65            }
66            handled
67        } else if let Some(callback) = &self.on_key_up {
68            callback(event);
69            true
70        } else {
71            false
72        }
73    }
74}
75
76/// A keyboard shortcut key.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
78pub struct ShortcutKey {
79    pub code: Code,
80    pub modifiers: Modifiers,
81}
82
83/// A command identifier that can be bound independently from the shortcut
84/// that invokes it.
85///
86/// `Command` is the convenient string-backed default. Applications with a
87/// closed command vocabulary should use the generic `Actions<C>` and
88/// `Shortcuts<C>` forms with their own `C` enum instead of converting every
89/// command to a string.
90#[derive(Clone, Debug, PartialEq, Eq, Hash)]
91pub struct Command(String);
92impl Command {
93    #[must_use]
94    pub fn new(name: impl Into<String>) -> Self {
95        Self(name.into())
96    }
97    #[must_use]
98    pub fn name(&self) -> &str {
99        &self.0
100    }
101}
102impl From<&str> for Command {
103    fn from(value: &str) -> Self {
104        Self::new(value)
105    }
106}
107
108/// Bounds required by typed command registries.
109///
110/// This is intentionally a value bound, not an inheritance hook. A command
111/// can therefore be a small enum, an interned identifier, or the compatibility
112/// [`Command`] string value.
113pub trait CommandId: Clone + Eq + std::hash::Hash + 'static {}
114
115impl<T> CommandId for T where T: Clone + Eq + std::hash::Hash + 'static {}
116
117/// A command intent passed through an action registry.
118#[derive(Clone, Debug, PartialEq, Eq, Hash)]
119pub struct Intent<C: CommandId = Command> {
120    command: C,
121}
122
123impl<C: CommandId> Intent<C> {
124    /// Creates an intent for a command identifier.
125    #[must_use]
126    pub fn new(command: impl Into<C>) -> Self {
127        Self {
128            command: command.into(),
129        }
130    }
131
132    /// Returns the command identifier carried by this intent.
133    #[must_use]
134    pub fn command(&self) -> &C {
135        &self.command
136    }
137}
138
139impl Intent<Command> {
140    /// Returns the stable command name.
141    #[must_use]
142    pub fn name(&self) -> &str {
143        self.command.name()
144    }
145}
146
147impl<C: CommandId> From<C> for Intent<C> {
148    fn from(value: C) -> Self {
149        Self::new(value)
150    }
151}
152
153impl From<&str> for Intent {
154    fn from(value: &str) -> Self {
155        Self::new(value)
156    }
157}
158
159/// A type-erased command intent used only while a retained keyboard event
160/// walks its element ancestors. The command remains strongly typed inside
161/// each [`Actions`] registry.
162#[derive(Clone)]
163pub struct ErasedIntent {
164    command: Rc<dyn Any>,
165}
166
167impl ErasedIntent {
168    #[must_use]
169    pub fn from_intent<C: CommandId>(intent: &Intent<C>) -> Self {
170        Self {
171            command: Rc::new(intent.command().clone()),
172        }
173    }
174
175    #[must_use]
176    pub fn downcast_command<C: CommandId>(&self) -> Option<&C> {
177        self.command.as_ref().downcast_ref::<C>()
178    }
179}
180
181/// Result of invoking an action.
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
183pub enum ActionResult {
184    /// The action consumed the intent.
185    Handled,
186    /// The action declined it, allowing an outer scope to try.
187    Ignored,
188}
189
190impl From<bool> for ActionResult {
191    fn from(handled: bool) -> Self {
192        if handled {
193            Self::Handled
194        } else {
195            Self::Ignored
196        }
197    }
198}
199
200struct ActionListenerState {
201    listeners: RefCell<Vec<Weak<ActionListenerEntry>>>,
202}
203
204struct ActionListenerEntry {
205    active: Cell<bool>,
206    callback: Rc<dyn Fn()>,
207}
208
209/// Owns one registration made with [`Action::add_action_listener`]. Dropping
210/// it removes the listener from future notifications.
211pub struct ActionListenerSubscription {
212    state: Weak<ActionListenerState>,
213    entry: Rc<ActionListenerEntry>,
214}
215
216/// Phase emitted around an action invocation.
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
218pub enum ActionInvocationPhase {
219    Start,
220    End,
221}
222
223struct ActionInvocationListenerState {
224    listeners: RefCell<Vec<Weak<ActionInvocationListenerEntry>>>,
225}
226
227struct ActionInvocationListenerEntry {
228    active: Cell<bool>,
229    callback: Rc<dyn Fn(ActionInvocationPhase)>,
230}
231
232/// Owns one registration made with [`Action::add_invocation_listener`].
233pub struct ActionInvocationSubscription {
234    state: Weak<ActionInvocationListenerState>,
235    entry: Rc<ActionInvocationListenerEntry>,
236}
237
238impl ActionInvocationSubscription {
239    fn remove(&self) -> bool {
240        let was_active = self.entry.active.replace(false);
241        if let Some(state) = self.state.upgrade() {
242            state.listeners.borrow_mut().retain(|listener| {
243                listener
244                    .upgrade()
245                    .is_some_and(|listener| !Rc::ptr_eq(&listener, &self.entry))
246            });
247        }
248        was_active
249    }
250}
251
252impl Drop for ActionInvocationSubscription {
253    fn drop(&mut self) {
254        let _ = self.remove();
255    }
256}
257
258impl ActionListenerSubscription {
259    fn remove(&self) -> bool {
260        let was_active = self.entry.active.replace(false);
261        if let Some(state) = self.state.upgrade() {
262            state.listeners.borrow_mut().retain(|listener| {
263                listener
264                    .upgrade()
265                    .is_some_and(|listener| !Rc::ptr_eq(&listener, &self.entry))
266            });
267        }
268        was_active
269    }
270}
271
272impl Drop for ActionListenerSubscription {
273    fn drop(&mut self) {
274        let _ = self.remove();
275    }
276}
277
278/// A Rust-native command action.
279///
280/// Actions are values with an enabled state and a callback. They do not form
281/// a generic inheritance tree like Flutter's `Action<T>` classes; a command
282/// registry composes them by scope instead.
283type ActionCallback<C> = Rc<dyn Fn(&Intent<C>) -> ActionResult>;
284
285#[derive(Clone)]
286pub struct Action<C: CommandId = Command> {
287    intent: Intent<C>,
288    enabled: Rc<Cell<bool>>,
289    callback: ActionCallback<C>,
290    listeners: Rc<ActionListenerState>,
291    invocation_listeners: Rc<ActionInvocationListenerState>,
292}
293
294impl<C: CommandId + std::fmt::Debug> std::fmt::Debug for Action<C> {
295    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        formatter
297            .debug_struct("Action")
298            .field("intent", &self.intent)
299            .field("enabled", &self.is_enabled())
300            .finish_non_exhaustive()
301    }
302}
303
304impl<C: CommandId> Action<C> {
305    /// Creates an enabled action whose callback consumes the intent.
306    #[must_use]
307    pub fn new(command: impl Into<Intent<C>>, callback: impl Fn() + 'static) -> Self {
308        Self::with_handler(command, move |_| {
309            callback();
310            ActionResult::Handled
311        })
312    }
313
314    /// Creates an action with an explicit handled/ignored result.
315    #[must_use]
316    pub fn with_handler<R>(
317        command: impl Into<Intent<C>>,
318        callback: impl Fn(&Intent<C>) -> R + 'static,
319    ) -> Self
320    where
321        R: Into<ActionResult> + 'static,
322    {
323        Self {
324            intent: command.into(),
325            enabled: Rc::new(Cell::new(true)),
326            callback: Rc::new(move |intent| callback(intent).into()),
327            listeners: Rc::new(ActionListenerState {
328                listeners: RefCell::new(Vec::new()),
329            }),
330            invocation_listeners: Rc::new(ActionInvocationListenerState {
331                listeners: RefCell::new(Vec::new()),
332            }),
333        }
334    }
335
336    /// Returns the intent this action handles.
337    #[must_use]
338    pub fn intent(&self) -> &Intent<C> {
339        &self.intent
340    }
341
342    /// Returns the stable command identifier.
343    #[must_use]
344    pub fn command(&self) -> &C {
345        self.intent.command()
346    }
347
348    /// Enables or disables this action without removing it from its scope.
349    pub fn set_enabled(&self, enabled: bool) {
350        if self.enabled.replace(enabled) != enabled {
351            self.notify_action_listeners();
352        }
353    }
354
355    /// Returns whether the action can currently run.
356    #[must_use]
357    pub fn is_enabled(&self) -> bool {
358        self.enabled.get()
359    }
360
361    /// Returns whether this action is enabled for the supplied intent.
362    #[must_use]
363    pub fn is_action_enabled(&self, intent: &Intent<C>) -> bool {
364        self.is_enabled() && self.intent.command() == intent.command()
365    }
366
367    /// Registers a callback that observes state changes published by this
368    /// action. The returned subscription is safe to hold in a retained widget.
369    #[must_use]
370    pub fn add_action_listener(&self, listener: impl Fn() + 'static) -> ActionListenerSubscription {
371        let entry = Rc::new(ActionListenerEntry {
372            active: Cell::new(true),
373            callback: Rc::new(listener),
374        });
375        self.listeners
376            .listeners
377            .borrow_mut()
378            .push(Rc::downgrade(&entry));
379        ActionListenerSubscription {
380            state: Rc::downgrade(&self.listeners),
381            entry,
382        }
383    }
384
385    /// Short alias for [`Self::add_action_listener`].
386    #[must_use]
387    pub fn add_listener(&self, listener: impl Fn() + 'static) -> ActionListenerSubscription {
388        self.add_action_listener(listener)
389    }
390
391    /// Removes a registration returned by [`Self::add_action_listener`].
392    pub fn remove_action_listener(&self, subscription: &ActionListenerSubscription) -> bool {
393        subscription.remove()
394    }
395
396    /// Notifies listeners that action state or another action-owned property
397    /// changed. Listeners added during delivery wait for the next notification;
398    /// removed listeners are skipped immediately.
399    pub fn notify_action_listeners(&self) {
400        let listeners = {
401            let mut listeners = self.listeners.listeners.borrow_mut();
402            listeners.retain(|listener| listener.strong_count() != 0);
403            listeners
404                .iter()
405                .filter_map(Weak::upgrade)
406                .collect::<Vec<_>>()
407        };
408        for listener in listeners {
409            if listener.active.get() {
410                (listener.callback)();
411            }
412        }
413    }
414
415    /// Registers a listener that observes the start and end of every
416    /// successful invocation of this action.
417    #[must_use]
418    pub fn add_invocation_listener(
419        &self,
420        listener: impl Fn(ActionInvocationPhase) + 'static,
421    ) -> ActionInvocationSubscription {
422        let entry = Rc::new(ActionInvocationListenerEntry {
423            active: Cell::new(true),
424            callback: Rc::new(listener),
425        });
426        self.invocation_listeners
427            .listeners
428            .borrow_mut()
429            .push(Rc::downgrade(&entry));
430        ActionInvocationSubscription {
431            state: Rc::downgrade(&self.invocation_listeners),
432            entry,
433        }
434    }
435
436    fn notify_invocation(&self, phase: ActionInvocationPhase) {
437        let listeners = {
438            let mut listeners = self.invocation_listeners.listeners.borrow_mut();
439            listeners.retain(|listener| listener.strong_count() != 0);
440            listeners
441                .iter()
442                .filter_map(Weak::upgrade)
443                .collect::<Vec<_>>()
444        };
445        for listener in listeners {
446            if listener.active.get() {
447                (listener.callback)(phase);
448            }
449        }
450    }
451
452    #[must_use]
453    pub fn listener_count(&self) -> usize {
454        let mut listeners = self.listeners.listeners.borrow_mut();
455        listeners.retain(|listener| listener.strong_count() != 0);
456        listeners
457            .iter()
458            .filter_map(Weak::upgrade)
459            .filter(|listener| listener.active.get())
460            .count()
461    }
462
463    /// Invokes this action for an intent.
464    #[must_use]
465    pub fn invoke(&self, intent: &Intent<C>) -> ActionResult {
466        if !self.is_action_enabled(intent) {
467            return ActionResult::Ignored;
468        }
469        self.notify_invocation(ActionInvocationPhase::Start);
470        let result = (self.callback)(intent);
471        self.notify_invocation(ActionInvocationPhase::End);
472        result
473    }
474}
475
476/// Result of looking up one local shortcut binding during ancestor dispatch.
477#[derive(Clone)]
478pub enum ShortcutMatch {
479    Callback(Rc<dyn Fn()>),
480    Intent(ErasedIntent),
481}
482
483/// Type-erased retained shortcut scope used by the widget tree to resolve
484/// local shortcut precedence before walking to an ancestor scope.
485pub struct ErasedShortcutScope {
486    finder: Rc<dyn Fn(KeyboardEvent) -> Option<ShortcutMatch>>,
487}
488
489impl ErasedShortcutScope {
490    #[must_use]
491    pub fn from_shortcuts<C: CommandId>(shortcuts: Rc<Shortcuts<C>>) -> Rc<Self> {
492        Rc::new(Self {
493            finder: Rc::new(move |event| shortcuts.find_match(&event)),
494        })
495    }
496
497    #[must_use]
498    pub fn find(&self, event: KeyboardEvent) -> Option<ShortcutMatch> {
499        (self.finder)(event)
500    }
501}
502
503/// Type-erased retained action scope used by the widget tree to resolve an
504/// intent against the closest ancestor action map.
505type ErasedActionInvoker = dyn Fn(&ErasedIntent) -> Option<ActionResult>;
506
507pub struct ErasedActionScope {
508    invoker: Rc<ErasedActionInvoker>,
509}
510
511impl ErasedActionScope {
512    #[must_use]
513    pub fn from_actions<C: CommandId>(actions: Rc<Actions<C>>) -> Rc<Self> {
514        Rc::new(Self {
515            invoker: Rc::new(move |intent| {
516                let command = intent.downcast_command::<C>()?;
517                let intent = Intent::new(command.clone());
518                actions.invoke_intent_first(&intent)
519            }),
520        })
521    }
522
523    #[must_use]
524    pub fn invoke(&self, intent: &ErasedIntent) -> Option<ActionResult> {
525        (self.invoker)(intent)
526    }
527}
528
529struct ActionScope<C: CommandId> {
530    actions: HashMap<C, Action<C>>,
531}
532
533/// Command-to-action registry with nearest-scope dispatch.
534///
535/// The last pushed scope is searched first. A missing, disabled, or
536/// `Ignored` action falls through to the next outer scope, which makes nested
537/// command palettes and temporary modal bindings predictable.
538pub struct Actions<C: CommandId = Command> {
539    scopes: Vec<ActionScope<C>>,
540}
541
542impl<C: CommandId> Default for Actions<C> {
543    fn default() -> Self {
544        Self {
545            scopes: vec![ActionScope::<C> {
546                actions: HashMap::new(),
547            }],
548        }
549    }
550}
551
552impl<C: CommandId> Actions<C> {
553    /// Creates an empty typed registry. Use `Actions::new()` for the
554    /// string-backed default registry when no generic type is needed.
555    #[must_use]
556    pub fn typed() -> Self {
557        Self::default()
558    }
559
560    /// Registers a simple callback in the current (nearest) scope.
561    pub fn register(&mut self, command: impl Into<C>, action: impl Fn() + 'static) {
562        let command = command.into();
563        self.register_action(Action::new(Intent::new(command), action));
564    }
565
566    /// Registers an explicit action in the current scope.
567    pub fn register_action(&mut self, action: Action<C>) {
568        self.current_scope_mut()
569            .actions
570            .insert(action.command().clone(), action);
571    }
572
573    /// Registers a callback that can explicitly allow fallthrough.
574    pub fn register_handler<R>(
575        &mut self,
576        command: impl Into<Intent<C>>,
577        action: impl Fn(&Intent<C>) -> R + 'static,
578    ) where
579        R: Into<ActionResult> + 'static,
580    {
581        self.register_action(Action::with_handler(command, action));
582    }
583
584    /// Pushes a nested action scope. Call [`Self::pop_scope`] when the modal
585    /// or temporary scope is no longer active.
586    pub fn push_scope(&mut self) {
587        self.scopes.push(ActionScope::<C> {
588            actions: HashMap::new(),
589        });
590    }
591
592    /// Pops the nearest scope, preserving the root scope.
593    #[must_use]
594    pub fn pop_scope(&mut self) -> bool {
595        (self.scopes.len() > 1).then(|| self.scopes.pop()).is_some()
596    }
597
598    /// Runs a closure in a temporary nested scope and restores the previous
599    /// scope after the closure returns, including when the closure unwinds.
600    pub fn with_scope<R>(&mut self, callback: impl FnOnce(&mut Self) -> R) -> R {
601        self.push_scope();
602        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self)));
603        let _ = self.pop_scope();
604        match result {
605            Ok(result) => result,
606            Err(panic) => std::panic::resume_unwind(panic),
607        }
608    }
609
610    /// Returns the number of active action scopes.
611    #[must_use]
612    pub fn scope_depth(&self) -> usize {
613        self.scopes.len()
614    }
615
616    /// Returns whether no action is registered in any retained scope.
617    #[must_use]
618    pub fn is_empty(&self) -> bool {
619        self.scopes.iter().all(|scope| scope.actions.is_empty())
620    }
621
622    /// Removes a command from the nearest scope.
623    pub fn unregister(&mut self, command: &C) -> bool {
624        self.current_scope_mut().actions.remove(command).is_some()
625    }
626
627    #[must_use]
628    pub fn invoke(&self, command: &C) -> bool {
629        self.invoke_intent(&Intent::new(command.clone()))
630    }
631
632    /// Dispatches an intent from the nearest scope outward.
633    #[must_use]
634    pub fn invoke_intent(&self, intent: &Intent<C>) -> bool {
635        self.scopes.iter().rev().any(|scope| {
636            scope
637                .actions
638                .get(intent.command())
639                .is_some_and(|action| action.invoke(intent) == ActionResult::Handled)
640        })
641    }
642
643    /// Returns the result from the nearest scope that contains a mapping,
644    /// including `Ignored` for a disabled or declining action. This preserves
645    /// Flutter's ancestor action lookup rule: a found local mapping shadows
646    /// an outer mapping even when it cannot currently handle the intent.
647    #[must_use]
648    pub fn invoke_intent_first(&self, intent: &Intent<C>) -> Option<ActionResult> {
649        self.scopes.iter().rev().find_map(|scope| {
650            scope
651                .actions
652                .get(intent.command())
653                .map(|action| action.invoke(intent))
654        })
655    }
656
657    /// Returns the nearest registered action, if any.
658    #[must_use]
659    pub fn action(&self, command: &C) -> Option<Action<C>> {
660        self.scopes
661            .iter()
662            .rev()
663            .find_map(|scope| scope.actions.get(command).cloned())
664    }
665
666    fn current_scope_mut(&mut self) -> &mut ActionScope<C> {
667        if self.scopes.is_empty() {
668            self.scopes.push(ActionScope::<C> {
669                actions: HashMap::new(),
670            });
671        }
672        self.scopes
673            .last_mut()
674            .expect("action registry has a root scope")
675    }
676}
677
678impl Actions<Command> {
679    /// Creates an empty string-backed command registry.
680    #[must_use]
681    pub fn new() -> Self {
682        Self::default()
683    }
684}
685
686impl ShortcutKey {
687    #[must_use]
688    pub const fn new(code: Code, modifiers: Modifiers) -> Self {
689        Self { code, modifiers }
690    }
691
692    /// Builds a logical-key activator for layouts where physical codes are
693    /// not stable enough (for example character shortcuts).
694    #[must_use]
695    pub fn logical(key: KeyboardKey, modifiers: Modifiers) -> LogicalShortcutKey {
696        LogicalShortcutKey { key, modifiers }
697    }
698}
699
700/// A logical single-key shortcut with the modifier state required by the
701/// shortcut. This is the retained equivalent of Flutter's
702/// `SingleActivator`: the four shortcut modifiers are matched independently,
703/// while lock-state and unrelated non-modifier keys do not affect a match.
704#[derive(Clone, Debug, PartialEq, Eq, Hash)]
705pub struct SingleActivator {
706    pub trigger: KeyboardKey,
707    pub modifiers: Modifiers,
708    pub include_repeats: bool,
709}
710
711impl SingleActivator {
712    #[must_use]
713    pub fn new(trigger: KeyboardKey, modifiers: Modifiers) -> Self {
714        Self {
715            trigger,
716            modifiers: shortcut_modifiers(modifiers),
717            include_repeats: true,
718        }
719    }
720
721    #[must_use]
722    pub fn with_repeats(mut self, include_repeats: bool) -> Self {
723        self.include_repeats = include_repeats;
724        self
725    }
726
727    #[must_use]
728    pub const fn trigger(&self) -> &KeyboardKey {
729        &self.trigger
730    }
731
732    #[must_use]
733    pub const fn modifiers(&self) -> Modifiers {
734        self.modifiers
735    }
736
737    #[must_use]
738    pub const fn includes_repeats(&self) -> bool {
739        self.include_repeats
740    }
741
742    #[must_use]
743    pub fn accepts(&self, event: &KeyboardEvent) -> bool {
744        event.state.is_down()
745            && (self.include_repeats || !event.repeat)
746            && event.key == self.trigger
747            && shortcut_modifiers(event.modifiers) == self.modifiers
748    }
749}
750
751/// A key activator accepted by callback shortcut maps.
752///
753/// Physical and logical activators are kept distinct, and a `Single` uses the
754/// logical key with Flutter-style modifier matching. The enum is intentionally
755/// value-based so equivalent registrations replace one another while distinct
756/// activators may both fire for one event.
757#[derive(Clone, Debug, PartialEq, Eq, Hash)]
758pub enum ShortcutActivator {
759    Physical {
760        key: ShortcutKey,
761        trigger: ShortcutTrigger,
762    },
763    Logical {
764        key: LogicalShortcutKey,
765        trigger: ShortcutTrigger,
766    },
767    Single(SingleActivator),
768}
769
770impl ShortcutActivator {
771    #[must_use]
772    pub const fn physical(key: ShortcutKey) -> Self {
773        Self::Physical {
774            key,
775            trigger: ShortcutTrigger::Down,
776        }
777    }
778
779    #[must_use]
780    pub const fn physical_on(key: ShortcutKey, trigger: ShortcutTrigger) -> Self {
781        Self::Physical { key, trigger }
782    }
783
784    #[must_use]
785    pub fn logical(key: LogicalShortcutKey) -> Self {
786        Self::Logical {
787            key,
788            trigger: ShortcutTrigger::Down,
789        }
790    }
791
792    #[must_use]
793    pub fn logical_on(key: LogicalShortcutKey, trigger: ShortcutTrigger) -> Self {
794        Self::Logical { key, trigger }
795    }
796
797    #[must_use]
798    pub fn single(trigger: KeyboardKey, modifiers: Modifiers) -> Self {
799        Self::Single(SingleActivator::new(trigger, modifiers))
800    }
801
802    #[must_use]
803    pub fn accepts(&self, event: &KeyboardEvent) -> bool {
804        match self {
805            Self::Physical { key, trigger } => {
806                key.code == event.code && key.modifiers == event.modifiers && trigger.matches(event)
807            }
808            Self::Logical { key, trigger } => {
809                key.key == event.key && key.modifiers == event.modifiers && trigger.matches(event)
810            }
811            Self::Single(activator) => activator.accepts(event),
812        }
813    }
814}
815
816impl From<ShortcutKey> for ShortcutActivator {
817    fn from(value: ShortcutKey) -> Self {
818        Self::physical(value)
819    }
820}
821
822impl From<LogicalShortcutKey> for ShortcutActivator {
823    fn from(value: LogicalShortcutKey) -> Self {
824        Self::logical(value)
825    }
826}
827
828impl From<SingleActivator> for ShortcutActivator {
829    fn from(value: SingleActivator) -> Self {
830        Self::Single(value)
831    }
832}
833
834fn shortcut_modifiers(modifiers: Modifiers) -> Modifiers {
835    modifiers & (Modifiers::ALT | Modifiers::CONTROL | Modifiers::META | Modifiers::SHIFT)
836}
837
838/// A keyboard shortcut matched against the event's logical key value.
839#[derive(Clone, Debug, PartialEq, Eq, Hash)]
840pub struct LogicalShortcutKey {
841    pub key: KeyboardKey,
842    pub modifiers: Modifiers,
843}
844
845impl LogicalShortcutKey {
846    #[must_use]
847    pub fn new(key: KeyboardKey, modifiers: Modifiers) -> Self {
848        Self { key, modifiers }
849    }
850}
851
852/// Controls when a shortcut binding is eligible for a keyboard event.
853#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
854pub enum ShortcutTrigger {
855    /// A key-down event, including operating-system repeat events.
856    #[default]
857    Down,
858    /// The initial key-down event, excluding repeats.
859    Press,
860    /// A repeated key-down event.
861    Repeat,
862    /// A key-up event.
863    Up,
864    /// Either key-down or key-up.
865    Any,
866}
867
868impl ShortcutTrigger {
869    #[must_use]
870    pub const fn matches(self, event: &KeyboardEvent) -> bool {
871        match self {
872            Self::Down => event.state.is_down(),
873            Self::Press => event.state.is_down() && !event.repeat,
874            Self::Repeat => event.state.is_down() && event.repeat,
875            Self::Up => event.state.is_up(),
876            Self::Any => true,
877        }
878    }
879}
880
881enum ShortcutBinding<C: CommandId> {
882    Callback(Rc<dyn Fn()>),
883    Intent(Intent<C>),
884}
885
886struct ShortcutRegistration<C: CommandId> {
887    trigger: ShortcutTrigger,
888    binding: ShortcutBinding<C>,
889}
890
891struct ShortcutScope<C: CommandId> {
892    physical: HashMap<ShortcutKey, Vec<ShortcutRegistration<C>>>,
893    logical: HashMap<LogicalShortcutKey, Vec<ShortcutRegistration<C>>>,
894}
895
896/// A small typed shortcut map that centralizes keyboard dispatch.
897pub struct Shortcuts<C: CommandId = Command> {
898    scopes: Vec<ShortcutScope<C>>,
899}
900
901impl<C: CommandId> Default for Shortcuts<C> {
902    fn default() -> Self {
903        Self {
904            scopes: vec![ShortcutScope {
905                physical: HashMap::new(),
906                logical: HashMap::new(),
907            }],
908        }
909    }
910}
911
912impl<C: CommandId> Shortcuts<C> {
913    /// Creates an empty typed shortcut map. Use `Shortcuts::new()` for the
914    /// string-backed default map when no generic type is needed.
915    #[must_use]
916    pub fn typed() -> Self {
917        Self::default()
918    }
919
920    pub fn register(&mut self, key: ShortcutKey, action: impl Fn() + 'static) {
921        self.register_on(key, ShortcutTrigger::Down, action);
922    }
923
924    /// Registers a callback for a specific key transition.
925    pub fn register_on(
926        &mut self,
927        key: ShortcutKey,
928        trigger: ShortcutTrigger,
929        action: impl Fn() + 'static,
930    ) {
931        Self::replace_binding(
932            &mut self.current_scope_mut().physical,
933            key,
934            trigger,
935            ShortcutBinding::Callback(Rc::new(action)),
936        );
937    }
938
939    /// Registers a callback against a logical key value.
940    pub fn register_logical(&mut self, key: LogicalShortcutKey, action: impl Fn() + 'static) {
941        self.register_logical_on(key, ShortcutTrigger::Down, action);
942    }
943
944    /// Registers a logical-key callback for a specific key transition.
945    pub fn register_logical_on(
946        &mut self,
947        key: LogicalShortcutKey,
948        trigger: ShortcutTrigger,
949        action: impl Fn() + 'static,
950    ) {
951        Self::replace_binding(
952            &mut self.current_scope_mut().logical,
953            key,
954            trigger,
955            ShortcutBinding::Callback(Rc::new(action)),
956        );
957    }
958
959    /// Binds a shortcut to a command registered in [`Actions`].
960    pub fn bind(&mut self, key: ShortcutKey, command: impl Into<C>) {
961        self.bind_on(key, ShortcutTrigger::Down, Intent::new(command.into()));
962    }
963
964    /// Binds a shortcut to an intent for a specific key transition.
965    pub fn bind_on(
966        &mut self,
967        key: ShortcutKey,
968        trigger: ShortcutTrigger,
969        command: impl Into<Intent<C>>,
970    ) {
971        Self::replace_binding(
972            &mut self.current_scope_mut().physical,
973            key,
974            trigger,
975            ShortcutBinding::Intent(command.into()),
976        );
977    }
978
979    /// Binds a logical key to a typed command.
980    pub fn bind_logical(&mut self, key: LogicalShortcutKey, command: impl Into<C>) {
981        self.bind_logical_on(key, ShortcutTrigger::Down, Intent::new(command.into()));
982    }
983
984    /// Binds a logical key to an intent for a specific transition.
985    pub fn bind_logical_on(
986        &mut self,
987        key: LogicalShortcutKey,
988        trigger: ShortcutTrigger,
989        command: impl Into<Intent<C>>,
990    ) {
991        Self::replace_binding(
992            &mut self.current_scope_mut().logical,
993            key,
994            trigger,
995            ShortcutBinding::Intent(command.into()),
996        );
997    }
998
999    /// Removes all bindings for a shortcut key.
1000    pub fn clear(&mut self, key: &ShortcutKey) -> bool {
1001        self.current_scope_mut().physical.remove(key).is_some()
1002    }
1003
1004    /// Removes all logical-key bindings from the nearest scope.
1005    pub fn clear_logical(&mut self, key: &LogicalShortcutKey) -> bool {
1006        self.current_scope_mut().logical.remove(key).is_some()
1007    }
1008
1009    /// Pushes a nested shortcut scope.
1010    pub fn push_scope(&mut self) {
1011        self.scopes.push(ShortcutScope {
1012            physical: HashMap::new(),
1013            logical: HashMap::new(),
1014        });
1015    }
1016
1017    /// Pops the nearest shortcut scope, preserving the root scope.
1018    #[must_use]
1019    pub fn pop_scope(&mut self) -> bool {
1020        (self.scopes.len() > 1).then(|| self.scopes.pop()).is_some()
1021    }
1022
1023    /// Runs a closure in a temporary shortcut scope.
1024    pub fn with_scope<R>(&mut self, callback: impl FnOnce(&mut Self) -> R) -> R {
1025        self.push_scope();
1026        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self)));
1027        let _ = self.pop_scope();
1028        match result {
1029            Ok(result) => result,
1030            Err(panic) => std::panic::resume_unwind(panic),
1031        }
1032    }
1033
1034    /// Returns the number of active shortcut scopes.
1035    #[must_use]
1036    pub fn scope_depth(&self) -> usize {
1037        self.scopes.len()
1038    }
1039
1040    /// Returns whether no shortcut is registered in any retained scope.
1041    #[must_use]
1042    pub fn is_empty(&self) -> bool {
1043        self.scopes.iter().all(|scope| {
1044            scope.physical.values().all(Vec::is_empty) && scope.logical.values().all(Vec::is_empty)
1045        })
1046    }
1047
1048    pub fn handle(&self, event: KeyboardEvent) -> bool {
1049        self.dispatch(event, None)
1050    }
1051
1052    /// Dispatches a command binding. Direct callback registrations continue to
1053    /// use [`Self::handle`] for source compatibility.
1054    #[must_use]
1055    pub fn handle_actions(&self, event: KeyboardEvent, actions: &Actions<C>) -> bool {
1056        self.dispatch(event, Some(actions))
1057    }
1058
1059    /// Alias emphasizing that this is the complete keyboard-event path.
1060    #[must_use]
1061    pub fn handle_event(&self, event: KeyboardEvent, actions: &Actions<C>) -> bool {
1062        self.handle_actions(event, actions)
1063    }
1064
1065    /// Creates a type-erased scope for the retained focus-chain dispatcher.
1066    #[must_use]
1067    pub fn erased_scope(self: &Rc<Self>) -> Rc<ErasedShortcutScope> {
1068        ErasedShortcutScope::from_shortcuts(self.clone())
1069    }
1070
1071    fn replace_binding<K>(
1072        bindings: &mut HashMap<K, Vec<ShortcutRegistration<C>>>,
1073        key: K,
1074        trigger: ShortcutTrigger,
1075        binding: ShortcutBinding<C>,
1076    ) where
1077        K: Eq + std::hash::Hash,
1078    {
1079        let registrations = bindings.entry(key).or_default();
1080        registrations.retain(|registration| registration.trigger != trigger);
1081        registrations.push(ShortcutRegistration { trigger, binding });
1082    }
1083
1084    fn dispatch(&self, event: KeyboardEvent, actions: Option<&Actions<C>>) -> bool {
1085        let physical = ShortcutKey::new(event.code, event.modifiers);
1086        let logical = LogicalShortcutKey::new(event.key.clone(), event.modifiers);
1087        for scope in self.scopes.iter().rev() {
1088            if scope.physical.get(&physical).is_some_and(|registrations| {
1089                Self::dispatch_registrations(registrations, &event, actions)
1090            }) || scope.logical.get(&logical).is_some_and(|registrations| {
1091                Self::dispatch_registrations(registrations, &event, actions)
1092            }) {
1093                return true;
1094            }
1095        }
1096        false
1097    }
1098
1099    fn find_match(&self, event: &KeyboardEvent) -> Option<ShortcutMatch> {
1100        let physical = ShortcutKey::new(event.code, event.modifiers);
1101        let logical = LogicalShortcutKey::new(event.key.clone(), event.modifiers);
1102        for scope in self.scopes.iter().rev() {
1103            if let Some(registration) = scope.physical.get(&physical).and_then(|registrations| {
1104                registrations
1105                    .iter()
1106                    .find(|registration| registration.trigger.matches(event))
1107            }) {
1108                return Some(Self::erase_binding(&registration.binding));
1109            }
1110            if let Some(registration) = scope.logical.get(&logical).and_then(|registrations| {
1111                registrations
1112                    .iter()
1113                    .find(|registration| registration.trigger.matches(event))
1114            }) {
1115                return Some(Self::erase_binding(&registration.binding));
1116            }
1117        }
1118        None
1119    }
1120
1121    fn erase_binding(binding: &ShortcutBinding<C>) -> ShortcutMatch {
1122        match binding {
1123            ShortcutBinding::Callback(callback) => ShortcutMatch::Callback(callback.clone()),
1124            ShortcutBinding::Intent(intent) => {
1125                ShortcutMatch::Intent(ErasedIntent::from_intent(intent))
1126            }
1127        }
1128    }
1129
1130    fn dispatch_registrations(
1131        registrations: &[ShortcutRegistration<C>],
1132        event: &KeyboardEvent,
1133        actions: Option<&Actions<C>>,
1134    ) -> bool {
1135        registrations
1136            .iter()
1137            .filter(|registration| registration.trigger.matches(event))
1138            .any(|registration| match &registration.binding {
1139                ShortcutBinding::Callback(callback) => {
1140                    callback();
1141                    true
1142                }
1143                ShortcutBinding::Intent(intent) => {
1144                    actions.is_some_and(|actions| actions.invoke_intent(intent))
1145                }
1146            })
1147    }
1148
1149    fn current_scope_mut(&mut self) -> &mut ShortcutScope<C> {
1150        if self.scopes.is_empty() {
1151            self.scopes.push(ShortcutScope {
1152                physical: HashMap::new(),
1153                logical: HashMap::new(),
1154            });
1155        }
1156        self.scopes
1157            .last_mut()
1158            .expect("shortcut registry has a root scope")
1159    }
1160}
1161
1162impl Shortcuts<Command> {
1163    /// Creates an empty string-backed shortcut map.
1164    #[must_use]
1165    pub fn new() -> Self {
1166        Self::default()
1167    }
1168}