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