Skip to main content

teksilo_core/
shortcut.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! User-facing rebindable keyboard shortcuts.
5//!
6//! The shortcut system has three layers:
7//!
8//! - [`KeyStroke`] — a single keyboard chord (key + modifiers).
9//! - [`Shortcut`] — a first-class, rebindable record with a stable
10//!   string id, localizable metadata, one or two keystrokes, a scope,
11//!   and an `on_activate` closure that produces an
12//!   [`Intent`] at activation time.
13//! - [`ShortcutRegistry`] — a two-layer store: widget-declared
14//!   defaults (refreshed every build) plus persisted user overrides.
15//!   The effective view merges them.
16//!
17//! Dispatch (wired in a later step): a keystroke is looked up in the
18//! registry, the matching shortcut's `on_activate` produces an intent,
19//! and the framework walks **source-widget → root** invoking
20//! [`Action`](crate::action::Action) handlers along the way.
21
22use crate::event::{Key, Modifiers};
23use crate::intent::Intent;
24use crate::signal::{Prop, Signal};
25use crate::widget::EventContext;
26use crate::widget_id::WidgetId;
27use std::collections::HashMap;
28use std::fmt;
29
30// ---------------------------------------------------------------------------
31// KeyStroke
32// ---------------------------------------------------------------------------
33
34/// A single keyboard chord: a key plus its modifiers.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36pub struct KeyStroke {
37    pub key: Key,
38    pub modifiers: Modifiers,
39}
40
41impl KeyStroke {
42    pub fn new(key: Key, modifiers: Modifiers) -> Self {
43        Self { key, modifiers }
44    }
45
46    /// A chord on `Ctrl`.
47    ///
48    /// Read as a *declared* shortcut default this carries the cross-platform
49    /// primary-accelerator convention: see
50    /// [`with_command_convention`](Self::with_command_convention) for what the
51    /// registry does with it on macOS. Use [`command`](Self::command) when you
52    /// want that intent stated outright, and
53    /// [`new`](Self::new) with [`Modifiers::CTRL`] plus
54    /// [`ShortcutBuilder::literal_modifiers`] when you mean physical Control on
55    /// every platform.
56    pub fn ctrl(key: Key) -> Self {
57        Self::new(key, Modifiers::CTRL)
58    }
59
60    pub fn ctrl_shift(key: Key) -> Self {
61        Self::new(key, Modifiers::CTRL | Modifiers::SHIFT)
62    }
63
64    /// A chord on the platform's **primary accelerator**: ⌘S on macOS, Ctrl+S
65    /// on Windows and Linux. See [`Modifiers::COMMAND`].
66    ///
67    /// Equivalent to [`ctrl`](Self::ctrl) once a declared shortcut has been
68    /// resolved, but it says so at the call site — which matters for the chords
69    /// built outside the registry, like the copy/paste labels a context menu
70    /// renders for itself.
71    pub fn command(key: Key) -> Self {
72        Self::new(key, Modifiers::COMMAND)
73    }
74
75    /// A chord on the platform's primary accelerator plus `Shift`: ⇧⌘Z on
76    /// macOS, Ctrl+Shift+Z on Windows and Linux.
77    pub fn command_shift(key: Key) -> Self {
78        Self::new(key, Modifiers::COMMAND | Modifiers::SHIFT)
79    }
80
81    pub fn alt(key: Key) -> Self {
82        Self::new(key, Modifiers::ALT)
83    }
84
85    /// This chord with a declared `Ctrl` reinterpreted as the platform's
86    /// primary accelerator ([`Modifiers::COMMAND`]) — ⌘ on macOS, unchanged
87    /// everywhere else.
88    ///
89    /// This is the convention Qt spells `Qt::CTRL` and the one Teksilo's native
90    /// menu bar has always applied when turning a declared chord into an
91    /// `NSMenuItem` key equivalent. [`ShortcutRegistry`] applies it to every
92    /// **declared** default, so an app that writes `KeyStroke::ctrl(Key::F)`
93    /// once gets Ctrl+F on Windows and Linux and ⌘F on macOS — the same chord
94    /// the menu row already advertised.
95    ///
96    /// It is deliberately *not* applied to a **user override**: a chord the
97    /// user captured in a settings UI is a literal statement of intent, and
98    /// rewriting it would make physical ⌃F unbindable on macOS.
99    ///
100    /// Idempotent, and a no-op for a chord that already names `Super`, so
101    /// `Ctrl+Super` survives as the genuine ⌃⌘ two-modifier chord.
102    pub fn with_command_convention(self) -> Self {
103        Self::new(self.key, self.modifiers.with_command_convention())
104    }
105}
106
107impl fmt::Display for KeyStroke {
108    // Plain "Ctrl+S" form ("Cmd+S" for a Super chord on macOS, where the key
109    // is named Command). Widgets that display shortcuts to users should
110    // use `teksilo_widgets::keystroke_format::format_keystroke()` instead,
111    // which handles platform-specific symbols (⌘ on macOS) and locale-
112    // aware modifier names ("Strg" in German) via teksilo-i18n.
113    // See architecture §11.2.
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(f, "{}{}", self.modifiers, self.key)
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Scope
121// ---------------------------------------------------------------------------
122
123/// Whether a shortcut fires regardless of focus position or only when
124/// focus is inside a specific widget's subtree.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum ShortcutScope {
127    /// Reachable regardless of focus. Used by app-level shortcuts.
128    Global,
129    /// Reachable only when focus is inside this widget (or one of its
130    /// descendants). Used by widget-declared shortcuts — the widget
131    /// that calls `register_shortcut` scopes to itself by default but
132    /// may scope to any [`WidgetId`] it knows (child, sibling, etc.).
133    Scoped(WidgetId),
134}
135
136/// Whether two shortcut scopes can be active at the same time — the
137/// basis for [`ShortcutRegistry::find_conflict`]. A `Global` shortcut is
138/// active everywhere, so it can collide with anything; two `Scoped`
139/// shortcuts collide only when scoped to the same widget. (Distinct
140/// `Scoped` ids whose subtrees happen to nest are treated as disjoint —
141/// the registry has no tree to prove containment.)
142fn scopes_can_collide(a: ShortcutScope, b: ShortcutScope) -> bool {
143    match (a, b) {
144        (ShortcutScope::Scoped(x), ShortcutScope::Scoped(y)) => x == y,
145        _ => true,
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Shortcut
151// ---------------------------------------------------------------------------
152
153/// Closure signature for a shortcut's activation handler.
154///
155/// Receives the matched [`KeyStroke`] (so the closure can branch on
156/// which chord fired, e.g. Ctrl+1 vs Ctrl+2) and a mutable
157/// [`EventContext`]. Returns the [`Intent`] to dispatch.
158pub type ShortcutOnActivate = Box<dyn FnMut(KeyStroke, &mut EventContext) -> Intent>;
159
160/// Closure signature for a key-capture callback.
161///
162/// Runs when the next `KeyDown` event bypasses shortcut resolution.
163/// Receives the captured keystroke, mutable access to the registry
164/// (for rebinds), and a mutable [`EventContext`] (so the handler can
165/// emit commands, send intents, dismiss overlays, etc.).
166pub type KeyCaptureCallback = Box<dyn FnOnce(KeyStroke, &mut ShortcutRegistry, &mut EventContext)>;
167
168/// Shared cell behind [`CaptureHandle`] and the tree's active capture
169/// slot. `None` once the capture has fired or been cancelled.
170pub(crate) type KeyCaptureSlot = std::rc::Rc<std::cell::RefCell<Option<KeyCaptureCallback>>>;
171
172/// RAII handle for an armed key-capture session.
173///
174/// Dropping the handle cancels the capture if it has not already
175/// fired — the pattern matches
176/// [`ObserverHandle`](crate::signal::ObserverHandle) elsewhere in the
177/// framework. Widgets that arm a capture should hold onto the handle
178/// (typically in their own state) so destruction of the widget tears
179/// the capture down cleanly.
180///
181/// The handle refers to **its own** slot, not whatever capture is
182/// currently armed; calling `begin_key_capture` twice creates two
183/// independent slots. Dropping the old handle cancels only the old
184/// slot (already orphaned by the new call) — it cannot race-cancel a
185/// newer capture.
186#[must_use = "key capture is cancelled when the CaptureHandle is dropped"]
187pub struct CaptureHandle {
188    slot: KeyCaptureSlot,
189}
190
191impl std::fmt::Debug for CaptureHandle {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("CaptureHandle")
194            .field("armed", &self.slot.borrow().is_some())
195            .finish()
196    }
197}
198
199impl CaptureHandle {
200    pub(crate) fn new(slot: KeyCaptureSlot) -> Self {
201        Self { slot }
202    }
203
204    /// Whether this handle's capture is still armed (`true`) or has
205    /// already fired / been cancelled (`false`).
206    pub fn is_armed(&self) -> bool {
207        self.slot.borrow().is_some()
208    }
209
210    /// Cancel this handle's capture explicitly. Equivalent to dropping
211    /// the handle; exposed for callers that want the cancellation to
212    /// happen at a precise point rather than at scope end.
213    pub fn cancel(self) {
214        // Drop runs on scope exit; no explicit body needed.
215        drop(self);
216    }
217}
218
219impl Drop for CaptureHandle {
220    fn drop(&mut self) {
221        // Clear only *this* slot's callback — other capture sessions
222        // (created by later calls to `begin_key_capture`) live in
223        // their own `Rc<RefCell<...>>` instances.
224        self.slot.borrow_mut().take();
225    }
226}
227
228/// A user-facing, rebindable keyboard shortcut.
229///
230/// Shortcuts are declared by widgets at `build()` time and held in a
231/// [`ShortcutRegistry`]. User rebindings (from a settings UI) stored
232/// as `overrides` in the registry always win over the `primary`/
233/// `secondary` declared here; those fields represent the **default**.
234///
235/// `name` and `description` are [`Prop<String>`] so they can track
236/// locale changes through a `Signal<String>` without teksilo-core
237/// depending on teksilo-i18n. Apps convert their `LocalizedString`
238/// values to a `Signal<String>` at registration time.
239pub struct Shortcut {
240    /// Stable id used for persistence, registry lookup and menu/tooltip
241    /// references. Must be unique within a [`ShortcutRegistry`].
242    /// Hierarchical dot-style is the convention: `"editor.format.bold"`.
243    pub id: &'static str,
244    /// User-visible label shown in menus and the settings UI.
245    pub name: Prop<String>,
246    /// Optional settings-UI category, e.g. `"editor.format"`. When
247    /// `None`, UIs should derive it from `id` (segment before last `.`).
248    pub category: Option<&'static str>,
249    /// Tooltip / detail text for the settings UI.
250    pub description: Option<Prop<String>>,
251    /// Default primary keystroke. Overridden at lookup time by the
252    /// registry's override layer when the user has rebound this id.
253    pub primary: Option<KeyStroke>,
254    /// Default secondary (alternate) keystroke. Many apps let a single
255    /// logical shortcut have two bindings (e.g. Ctrl+S and F12).
256    pub secondary: Option<KeyStroke>,
257    /// Optional explicit intent name emitted on activation. When
258    /// `None`, the produced intent's name equals `id`.
259    pub intent: Option<&'static str>,
260    /// Produces the [`Intent`] to dispatch when this shortcut fires.
261    /// When `None`, the registry synthesizes a no-parameter intent
262    /// using [`Shortcut::intent_name`].
263    pub on_activate: Option<ShortcutOnActivate>,
264    /// Whether this shortcut is global or scoped to a widget subtree.
265    /// Widget-declared shortcuts default to `Scoped(self_id)`;
266    /// app-level declarations use `Global`.
267    pub scope: ShortcutScope,
268    /// Whether a matching, *disabled* [`Action`](crate::action::Action)
269    /// on a widget should let the intent keep walking up the focus
270    /// chain. `true` (the default) lets outer ancestors serve as
271    /// fallbacks; `false` treats disabled as "owned, just dormant."
272    pub propagate_when_disabled: bool,
273    /// Reactive "is this shortcut currently live?" predicate.
274    /// `None` means always enabled. When the signal resolves to
275    /// `false`, the shortcut is treated **as if not registered** —
276    /// the keystroke falls through to the focused widget's normal
277    /// `on_key` dispatch and `on_activate` is **not** invoked.
278    pub enabled_when: Option<Prop<bool>>,
279    /// Take the declared chords literally instead of applying the
280    /// primary-accelerator convention — see
281    /// [`ShortcutBuilder::literal_modifiers`].
282    pub literal_modifiers: bool,
283}
284
285impl fmt::Debug for Shortcut {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        f.debug_struct("Shortcut")
288            .field("id", &self.id)
289            .field("name", &self.name)
290            .field("category", &self.category)
291            .field("description", &self.description)
292            .field("primary", &self.primary)
293            .field("secondary", &self.secondary)
294            .field("intent", &self.intent)
295            .field(
296                "on_activate",
297                &self.on_activate.as_ref().map(|_| "<closure>"),
298            )
299            .field("scope", &self.scope)
300            .field("propagate_when_disabled", &self.propagate_when_disabled)
301            .field("enabled_when", &self.enabled_when.is_some())
302            .field("literal_modifiers", &self.literal_modifiers)
303            .finish()
304    }
305}
306
307impl Shortcut {
308    /// Start building a shortcut with a stable id.
309    #[allow(clippy::new_ret_no_self)]
310    pub fn new(id: &'static str) -> ShortcutBuilder {
311        ShortcutBuilder {
312            inner: Shortcut {
313                id,
314                name: Prop::Static(String::new()),
315                category: None,
316                description: None,
317                primary: None,
318                secondary: None,
319                intent: None,
320                on_activate: None,
321                scope: ShortcutScope::Global,
322                propagate_when_disabled: true,
323                enabled_when: None,
324                literal_modifiers: false,
325            },
326        }
327    }
328
329    /// Resolve the current enabled state. `true` when no predicate is
330    /// set; otherwise reads the signal.
331    pub fn is_enabled(&self) -> bool {
332        self.enabled_when.as_ref().map(|s| s.get()).unwrap_or(true)
333    }
334
335    /// Intent name this shortcut produces. Falls back to `id` when no
336    /// explicit intent is set.
337    pub fn intent_name(&self) -> &'static str {
338        self.intent.unwrap_or(self.id)
339    }
340
341    /// Whether `keystroke` matches this shortcut's primary or
342    /// secondary default. The registry uses the **effective**
343    /// keystrokes (defaults merged with overrides) during live
344    /// lookups instead of this.
345    ///
346    /// Compares against the *resolved* defaults, so on macOS a chord
347    /// declared `Ctrl+S` matches a pressed ⌘S — see
348    /// [`declared_keystrokes`](Self::declared_keystrokes).
349    pub fn matches_default(&self, keystroke: KeyStroke) -> bool {
350        let (primary, secondary) = self.declared_keystrokes();
351        primary == Some(keystroke) || secondary == Some(keystroke)
352    }
353
354    /// This shortcut's declared chords as the platform actually reads
355    /// them: unchanged when [`literal_modifiers`](Self::literal_modifiers)
356    /// is set, otherwise passed through
357    /// [`KeyStroke::with_command_convention`] so a declared `Ctrl` means ⌘
358    /// on macOS.
359    ///
360    /// The registry layers user overrides on top of these; an override is
361    /// never rewritten.
362    pub fn declared_keystrokes(&self) -> (Option<KeyStroke>, Option<KeyStroke>) {
363        if self.literal_modifiers {
364            (self.primary, self.secondary)
365        } else {
366            (
367                self.primary.map(KeyStroke::with_command_convention),
368                self.secondary.map(KeyStroke::with_command_convention),
369            )
370        }
371    }
372}
373
374/// Fluent builder for [`Shortcut`]. Default scope is `Global`; use
375/// [`ShortcutBuilder::scope`] or [`ShortcutBuilder::scope_to`] for a
376/// scoped shortcut (the typical widget-declared case).
377pub struct ShortcutBuilder {
378    inner: Shortcut,
379}
380
381impl ShortcutBuilder {
382    /// User-visible label. Accepts a static `String`/`&str` or a reactive
383    /// `Signal<String>` / `Prop<String>` (for localized names driven by
384    /// teksilo-i18n's `LocalizedString`).
385    pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
386        self.inner.name = name.into();
387        self
388    }
389
390    pub fn category(mut self, category: &'static str) -> Self {
391        self.inner.category = Some(category);
392        self
393    }
394
395    /// Take the declared chords **literally** — no primary-accelerator
396    /// convention, on any platform.
397    ///
398    /// By default a declared `Ctrl` chord is read as "the platform's
399    /// accelerator" and becomes ⌘ on macOS (see
400    /// [`KeyStroke::with_command_convention`]), which is what almost every
401    /// command wants. A few chords are genuinely Control on macOS too, and for
402    /// those the rewrite would be wrong or fatal:
403    ///
404    /// - **Ctrl+Tab** cycles tabs on macOS as well; ⌘Tab belongs to the
405    ///   application switcher and never reaches an app at all.
406    /// - Chords whose ⌘ form the system takes first — ⌘Space (Spotlight),
407    ///   ⌘⇥, ⌘H, ⌘Q — where the rewritten chord would simply never arrive.
408    ///
409    /// Declaring those with `literal_modifiers` keeps them on Control
410    /// everywhere, including macOS.
411    pub fn literal_modifiers(mut self) -> Self {
412        self.inner.literal_modifiers = true;
413        self
414    }
415
416    /// Description. Accepts a static `String`/`&str` or a reactive
417    /// `Signal<String>` / `Prop<String>`.
418    pub fn description(mut self, description: impl Into<Prop<String>>) -> Self {
419        self.inner.description = Some(description.into());
420        self
421    }
422
423    pub fn primary(mut self, keystroke: KeyStroke) -> Self {
424        self.inner.primary = Some(keystroke);
425        self
426    }
427
428    pub fn secondary(mut self, keystroke: KeyStroke) -> Self {
429        self.inner.secondary = Some(keystroke);
430        self
431    }
432
433    /// Override the intent name; defaults to the shortcut's `id`.
434    pub fn intent(mut self, intent: &'static str) -> Self {
435        self.inner.intent = Some(intent);
436        self
437    }
438
439    /// Provide a closure that produces the [`Intent`] at activation
440    /// time. Use when the intent's parameters depend on the matched
441    /// keystroke or runtime state.
442    ///
443    /// The closure may return any `Into<Intent>` — typically an
444    /// [`IntentKind`](crate::intent::IntentKind) enum variant, which
445    /// converts via the blanket `impl<K: IntentKind> From<K> for Intent`.
446    pub fn on_activate<R>(
447        mut self,
448        mut f: impl FnMut(KeyStroke, &mut EventContext) -> R + 'static,
449    ) -> Self
450    where
451        R: Into<Intent>,
452    {
453        self.inner.on_activate = Some(Box::new(move |ks, ctx| f(ks, ctx).into()));
454        self
455    }
456
457    /// Scope the shortcut to a specific widget's subtree. Equivalent
458    /// to `.scope(ShortcutScope::Scoped(id))`.
459    pub fn scope_to(mut self, id: WidgetId) -> Self {
460        self.inner.scope = ShortcutScope::Scoped(id);
461        self
462    }
463
464    pub fn scope(mut self, scope: ShortcutScope) -> Self {
465        self.inner.scope = scope;
466        self
467    }
468
469    /// Explicit global scope (the builder's default).
470    pub fn global(mut self) -> Self {
471        self.inner.scope = ShortcutScope::Global;
472        self
473    }
474
475    /// Control how a disabled matching [`Action`](crate::action::Action)
476    /// behaves during dispatch. `true` (default): propagate to
477    /// ancestors. `false`: consume the intent at that level.
478    pub fn propagate_when_disabled(mut self, propagate: bool) -> Self {
479        self.inner.propagate_when_disabled = propagate;
480        self
481    }
482
483    /// Reactive predicate that controls whether the shortcut is
484    /// *live*. When the signal holds `false`, the shortcut is
485    /// treated as if it were not registered — the keystroke falls
486    /// through to the focused widget's normal `on_key` dispatch.
487    ///
488    /// Typical use: a rich-text editor registering
489    /// `editor.format.bold` with `enabled_when(has_selection)` so
490    /// Ctrl+B only fires when there is something to embolden.
491    pub fn enabled_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
492        self.inner.enabled_when = Some(signal.into());
493        self
494    }
495
496    pub fn build(self) -> Shortcut {
497        self.inner
498    }
499}
500
501// ---------------------------------------------------------------------------
502// Registry
503// ---------------------------------------------------------------------------
504
505/// Per-slot user override state.
506///
507/// Each slot (primary / secondary) independently records whether the
508/// user has touched it. `Default` means "fall back to the widget's
509/// declared default at effective-lookup time" (so a later
510/// re-registration with a different default flows through
511/// automatically). `Bound(ks)` locks the slot to a specific chord,
512/// and `Unbound` locks the slot to *no* chord.
513#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
514pub enum SlotOverride {
515    /// User hasn't touched this slot; use the shortcut's current
516    /// declared default.
517    #[default]
518    Default,
519    /// User explicitly bound the slot to this chord.
520    Bound(KeyStroke),
521    /// User explicitly unbound the slot.
522    Unbound,
523}
524
525impl SlotOverride {
526    /// Resolve this override against a fallback default from the
527    /// shortcut's declaration site.
528    pub fn resolve(self, fallback: Option<KeyStroke>) -> Option<KeyStroke> {
529        match self {
530            SlotOverride::Default => fallback,
531            SlotOverride::Bound(ks) => Some(ks),
532            SlotOverride::Unbound => None,
533        }
534    }
535
536    /// Whether the user has touched this slot.
537    pub fn is_touched(self) -> bool {
538        !matches!(self, SlotOverride::Default)
539    }
540}
541
542/// Per-shortcut user override (populated by the settings UI and
543/// persisted to disk). Per-slot semantics: each field records either
544/// a user edit or a delegation to the shortcut's declared default.
545#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
546pub struct KeyStrokeOverride {
547    pub primary: SlotOverride,
548    pub secondary: SlotOverride,
549}
550
551impl KeyStrokeOverride {
552    /// Whether the override has any user-touched slot. Entries with
553    /// all-Default slots are effectively empty and removed by
554    /// `clear_override`-like paths.
555    pub fn is_empty(self) -> bool {
556        !self.primary.is_touched() && !self.secondary.is_touched()
557    }
558}
559
560/// The merged, read-only view of a shortcut with user overrides applied.
561///
562/// Menus, tooltips and dispatch consume this shape. Fields borrow from
563/// the registry so the consumer pays no clone cost.
564///
565/// `enabled` is the snapshot of the shortcut's `enabled_when` signal
566/// at construction time — convenient for settings UIs that render
567/// greyed-out rows for currently-inapplicable shortcuts. Dispatch
568/// does not need to inspect it because
569/// [`ShortcutRegistry::find_by_keystroke`] already filters disabled
570/// shortcuts out.
571#[derive(Debug, Clone, Copy)]
572pub struct EffectiveShortcut<'a> {
573    pub shortcut: &'a Shortcut,
574    pub primary: Option<KeyStroke>,
575    pub secondary: Option<KeyStroke>,
576    pub enabled: bool,
577}
578
579impl EffectiveShortcut<'_> {
580    pub fn matches(&self, keystroke: KeyStroke) -> bool {
581        self.primary == Some(keystroke) || self.secondary == Some(keystroke)
582    }
583}
584
585/// Two-layer registry of [`Shortcut`]s.
586///
587/// - `defaults` holds the records registered by widgets during their
588///   `build()`. Re-registering the same id **upserts**: code-owned
589///   fields are updated, the user override (if any) is preserved.
590/// - `overrides` holds user-supplied keystroke rebindings keyed by
591///   shortcut id. Overrides persist across widget rebuilds and even
592///   when the corresponding default is temporarily unregistered —
593///   graveyard semantics, so a widget that disappears and reappears
594///   keeps its user-customised bindings.
595///
596/// Every mutation bumps [`ShortcutRegistry::version`] so consumers
597/// (menu labels, settings UIs) can observe that signal and re-read
598/// through [`ShortcutRegistry::effective`].
599pub struct ShortcutRegistry {
600    defaults: HashMap<&'static str, Shortcut>,
601    overrides: HashMap<String, KeyStrokeOverride>,
602    /// Reverse map from owner widget to the ids it registered. Used
603    /// by [`ShortcutRegistry::unregister_all_for_owner`] when a widget
604    /// is destroyed. Owner is distinct from scope: a widget can own a
605    /// globally-scoped shortcut and still have it cleaned up when the
606    /// widget goes away.
607    by_owner: HashMap<WidgetId, Vec<&'static str>>,
608    /// Reverse map from id to its owner, for cheap symmetric cleanup
609    /// when a shortcut is re-registered by a different owner (rare,
610    /// but the indices must stay consistent).
611    owner_by_id: HashMap<&'static str, WidgetId>,
612    version: Signal<u64>,
613    /// Per-id reactive resolved *primary* keystroke, created lazily the
614    /// first time a widget asks to observe an id (see
615    /// [`ShortcutRegistry::effective_primary_signal`]). Each mutation
616    /// refreshes only the ids it actually touched (with an equality
617    /// guard), so registering or rebinding one shortcut never notifies
618    /// the observers of an unrelated id. This is what lets a menu item
619    /// bind its accelerator as a *leaf* value that repaints in place,
620    /// instead of hard-rebuilding on the coarse global [`Self::version`]
621    /// signal (which would tear down the item and drop clicks).
622    resolved: HashMap<&'static str, Signal<Option<KeyStroke>>>,
623}
624
625impl Default for ShortcutRegistry {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631impl fmt::Debug for ShortcutRegistry {
632    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
633        f.debug_struct("ShortcutRegistry")
634            .field("defaults", &self.defaults)
635            .field("overrides", &self.overrides)
636            .field("by_owner", &self.by_owner)
637            .field("version", &self.version.get())
638            .finish()
639    }
640}
641
642impl ShortcutRegistry {
643    pub fn new() -> Self {
644        Self {
645            defaults: HashMap::new(),
646            overrides: HashMap::new(),
647            by_owner: HashMap::new(),
648            owner_by_id: HashMap::new(),
649            version: Signal::new(0),
650            resolved: HashMap::new(),
651        }
652    }
653
654    /// A reactive handle that ticks on every mutation (register,
655    /// unregister, rebind, put_override). Menus and settings widgets
656    /// observe it to refresh derived state.
657    pub fn version(&self) -> &Signal<u64> {
658        &self.version
659    }
660
661    /// A reactive handle to the **effective primary keystroke** for a
662    /// single shortcut `id`, created lazily and seeded with the current
663    /// value. It ticks only when *that* id's resolved primary actually
664    /// changes — registering, unregistering or rebinding any *other*
665    /// shortcut leaves it untouched.
666    ///
667    /// This is the granular counterpart to [`Self::version`]: a widget
668    /// that displays one shortcut's accelerator (a menu item, a
669    /// tooltip) should bind this and update its label as a leaf value,
670    /// rather than observing the coarse global version and rebuilding.
671    pub fn effective_primary_signal(&mut self, id: &'static str) -> Signal<Option<KeyStroke>> {
672        if let Some(sig) = self.resolved.get(id) {
673            return sig.clone();
674        }
675        let current = self
676            .defaults
677            .get(id)
678            .and_then(|s| self.resolved_keystrokes(id, s).0);
679        let sig = Signal::new(current);
680        self.resolved.insert(id, sig.clone());
681        sig
682    }
683
684    /// Upsert a shortcut default without an owner. If `id` already
685    /// exists this **replaces** the code-owned fields but
686    /// **preserves any existing user override**.
687    ///
688    /// Use [`ShortcutRegistry::register_owned`] to tie the lifetime
689    /// of a registration to a widget (so arena destroy can clean up
690    /// automatically).
691    pub fn register(&mut self, shortcut: Shortcut) -> Option<Shortcut> {
692        let id = shortcut.id;
693        let previous = self.defaults.insert(id, shortcut);
694        // If a previous registration had an owner, the new anonymous
695        // registration reassigns ownership away. Drop the old owner
696        // index entry so `unregister_all_for_owner` stays accurate.
697        self.detach_owner_index(id);
698        self.bump_version();
699        self.refresh_resolved(id);
700        previous
701    }
702
703    /// Upsert a shortcut default owned by `owner`. When `owner` is
704    /// destroyed, the framework calls
705    /// [`ShortcutRegistry::unregister_all_for_owner`] to remove this
706    /// registration. Preserves user overrides identically to
707    /// [`ShortcutRegistry::register`].
708    pub fn register_owned(&mut self, shortcut: Shortcut, owner: WidgetId) -> Option<Shortcut> {
709        let id = shortcut.id;
710        let previous = self.defaults.insert(id, shortcut);
711        self.detach_owner_index(id);
712        self.by_owner.entry(owner).or_default().push(id);
713        self.owner_by_id.insert(id, owner);
714        self.bump_version();
715        self.refresh_resolved(id);
716        previous
717    }
718
719    /// Remove a default by id. The user override for that id (if any)
720    /// stays in the graveyard so it can be re-applied if the shortcut
721    /// is later re-registered.
722    pub fn unregister(&mut self, id: &str) -> Option<Shortcut> {
723        let removed = self.defaults.remove(id);
724        if removed.is_some() {
725            self.detach_owner_index(id);
726            self.bump_version();
727            self.refresh_resolved(id);
728        }
729        removed
730    }
731
732    /// Remove every shortcut registered by `owner`. Called by the
733    /// widget tree when a widget is destroyed — keeps the registry
734    /// from leaking entries whose `on_activate` closures may capture
735    /// state owned by the destroyed widget.
736    pub fn unregister_all_for_owner(&mut self, owner: WidgetId) {
737        let Some(ids) = self.by_owner.remove(&owner) else {
738            return;
739        };
740        let mut any = false;
741        for id in ids {
742            if self.defaults.remove(id).is_some() {
743                any = true;
744            }
745            self.owner_by_id.remove(id);
746            // Its effective primary is now gone — push that to any observer.
747            self.refresh_resolved(id);
748        }
749        if any {
750            self.bump_version();
751        }
752    }
753
754    /// The widget id that currently owns `id`, if any.
755    pub fn owner_of(&self, id: &str) -> Option<WidgetId> {
756        self.owner_by_id.get(id).copied()
757    }
758
759    pub fn len(&self) -> usize {
760        self.defaults.len()
761    }
762
763    pub fn is_empty(&self) -> bool {
764        self.defaults.is_empty()
765    }
766
767    /// Iterate the raw defaults (no overrides merged). Most UI code
768    /// wants [`ShortcutRegistry::iter_effective`] instead.
769    pub fn iter_defaults(&self) -> impl Iterator<Item = &Shortcut> {
770        self.defaults.values()
771    }
772
773    /// Borrow the raw default record.
774    pub fn get_default(&self, id: &str) -> Option<&Shortcut> {
775        self.defaults.get(id)
776    }
777
778    /// Invoke the registered `on_activate` closure for the shortcut
779    /// with the given `id`. Returns the produced [`Intent`] (or a
780    /// synthesized no-parameter intent when the shortcut has no
781    /// custom `on_activate`), or `None` if `id` is not registered.
782    ///
783    /// Deliberately split from `find_by_keystroke` so the dispatcher
784    /// can check scope and `enabled_when` **before** the closure
785    /// runs — otherwise a scope mismatch or disabled predicate would
786    /// silently drop any side effects the closure put in `ctx`.
787    pub(crate) fn invoke_on_activate(
788        &mut self,
789        id: &str,
790        keystroke: KeyStroke,
791        ctx: &mut EventContext,
792    ) -> Option<Intent> {
793        let shortcut = self.defaults.get_mut(id)?;
794        let intent_name = shortcut.intent_name();
795        let intent = match &mut shortcut.on_activate {
796            Some(handler) => handler(keystroke, ctx),
797            None => Intent::new(intent_name),
798        };
799        Some(intent)
800    }
801
802    /// Current override for `id`, if any.
803    pub fn override_for(&self, id: &str) -> Option<KeyStrokeOverride> {
804        self.overrides.get(id).copied()
805    }
806
807    /// Set the full override for `id`. Intended for loading persisted
808    /// user preferences at app startup.
809    pub fn put_override(&mut self, id: impl Into<String>, override_: KeyStrokeOverride) {
810        let id = id.into();
811        self.overrides.insert(id.clone(), override_);
812        self.bump_version();
813        self.refresh_resolved(&id);
814    }
815
816    /// Set the primary slot of the override for `id`. The secondary
817    /// slot is left untouched — with per-slot [`SlotOverride`]
818    /// semantics the untouched slot continues to delegate to
819    /// whatever default the shortcut currently declares.
820    pub fn rebind_primary(&mut self, id: impl Into<String>, keystroke: Option<KeyStroke>) {
821        let id = id.into();
822        let entry = self.overrides.entry(id.clone()).or_default();
823        entry.primary = match keystroke {
824            Some(ks) => SlotOverride::Bound(ks),
825            None => SlotOverride::Unbound,
826        };
827        self.bump_version();
828        self.refresh_resolved(&id);
829    }
830
831    /// Set the secondary slot of the override for `id`. The primary
832    /// slot stays in whatever state it was (`Default` or user-set).
833    pub fn rebind_secondary(&mut self, id: impl Into<String>, keystroke: Option<KeyStroke>) {
834        let id = id.into();
835        let entry = self.overrides.entry(id.clone()).or_default();
836        entry.secondary = match keystroke {
837            Some(ks) => SlotOverride::Bound(ks),
838            None => SlotOverride::Unbound,
839        };
840        self.bump_version();
841        // Secondary-only change: the primary signal is guarded, so this
842        // is a no-op for primary observers — kept for uniformity.
843        self.refresh_resolved(&id);
844    }
845
846    /// Drop the user override for `id`, restoring the declared defaults.
847    pub fn clear_override(&mut self, id: &str) {
848        if self.overrides.remove(id).is_some() {
849            self.bump_version();
850            self.refresh_resolved(id);
851        }
852    }
853
854    /// Clear every override. Restores the declared defaults for all
855    /// registered shortcuts. Graveyard entries are dropped too.
856    pub fn clear_all_overrides(&mut self) {
857        if !self.overrides.is_empty() {
858            self.overrides.clear();
859            self.bump_version();
860            self.refresh_all_resolved();
861        }
862    }
863
864    /// Snapshot of the full override map, suitable for persisting to
865    /// disk. Cloned intentionally so callers can serialize without
866    /// holding a borrow on the registry.
867    pub fn export_overrides(&self) -> HashMap<String, KeyStrokeOverride> {
868        self.overrides.clone()
869    }
870
871    /// Replace the entire override map from a persisted snapshot.
872    /// Typically called once at app startup after loading user
873    /// preferences from disk. Overrides for ids that are not yet
874    /// registered are kept in the graveyard so they apply whenever
875    /// the widget that declares the corresponding default shows up.
876    pub fn import_overrides(&mut self, overrides: HashMap<String, KeyStrokeOverride>) {
877        self.overrides = overrides;
878        self.bump_version();
879        self.refresh_all_resolved();
880    }
881
882    /// Effective (defaults + overrides) view of `id`. `None` when the
883    /// id has no registered default — overrides alone don't manifest
884    /// as effective records, but they're kept in the graveyard.
885    pub fn effective(&self, id: &str) -> Option<EffectiveShortcut<'_>> {
886        let shortcut = self.defaults.get(id)?;
887        let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
888        Some(EffectiveShortcut {
889            shortcut,
890            primary,
891            secondary,
892            enabled: shortcut.is_enabled(),
893        })
894    }
895
896    /// Iterate all effective shortcuts, including currently-disabled
897    /// ones. The per-item `enabled` flag lets settings UIs render
898    /// disabled rows greyed out.
899    ///
900    /// Order is deterministic: sorted by `(category, id)` so repeated
901    /// calls produce identical sequences regardless of the internal
902    /// `HashMap` insertion order.
903    pub fn iter_effective(&self) -> impl Iterator<Item = EffectiveShortcut<'_>> {
904        let mut items: Vec<EffectiveShortcut<'_>> = self
905            .defaults
906            .iter()
907            .map(|(id, shortcut)| {
908                let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
909                EffectiveShortcut {
910                    shortcut,
911                    primary,
912                    secondary,
913                    enabled: shortcut.is_enabled(),
914                }
915            })
916            .collect();
917        items.sort_by(|a, b| {
918            a.shortcut
919                .category
920                .cmp(&b.shortcut.category)
921                .then(a.shortcut.id.cmp(b.shortcut.id))
922        });
923        items.into_iter()
924    }
925
926    /// Find the first shortcut id that **genuinely** conflicts with
927    /// `keystroke`, excluding `excluding_id` if given. Used by settings
928    /// UIs to auto-unbind conflicts when the user rebinds a chord.
929    /// Includes disabled shortcuts — a chord is "taken" regardless of
930    /// whether its current binding is live.
931    ///
932    /// **Scope-aware.** Two shortcuts only conflict when they could be
933    /// simultaneously active: either one is [`ShortcutScope::Global`]
934    /// (active everywhere), or both are [`ShortcutScope::Scoped`] to the
935    /// **same** widget. Two shortcuts scoped to *different* widgets —
936    /// e.g. a `Delete` binding in two separate panels — are **not** a
937    /// conflict, because the dispatcher resolves them by focus. (The
938    /// registry can't see the tree, so two different `Scoped` ids are
939    /// assumed disjoint; the rare genuinely-nested overlap is left
940    /// unflagged, erring toward allowing the binding.)
941    ///
942    /// When `excluding_id` is `None` (or names an unregistered id) the
943    /// "self" scope is unknown, so every same-chord shortcut is flagged
944    /// — the safe, conservative fallback.
945    pub fn find_conflict(
946        &self,
947        keystroke: KeyStroke,
948        excluding_id: Option<&str>,
949    ) -> Option<&'static str> {
950        let self_scope = excluding_id
951            .and_then(|eid| self.defaults.get(eid))
952            .map(|s| s.scope);
953        self.defaults.iter().find_map(|(&id, shortcut)| {
954            if excluding_id == Some(id) {
955                return None;
956            }
957            let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
958            if primary != Some(keystroke) && secondary != Some(keystroke) {
959                return None;
960            }
961            // Chord matches — apply the scope rule. With no known self
962            // scope, flag unconditionally (conservative fallback).
963            match self_scope {
964                Some(self_scope) if !scopes_can_collide(self_scope, shortcut.scope) => None,
965                _ => Some(id),
966            }
967        })
968    }
969
970    /// All effective shortcuts whose primary or secondary keystroke
971    /// matches **and** are currently enabled, in the deterministic
972    /// `(category, id)` order of [`iter_effective`](Self::iter_effective).
973    ///
974    /// The dispatcher needs *every* same-chord candidate, not just the
975    /// first: the first by id-order may be a `Scoped` binding whose
976    /// subtree doesn't contain the current focus (and so must yield to
977    /// an applicable `Global` one), or a `Global` binding that should
978    /// itself yield to an in-focus `Scoped` one (most-specific-scope
979    /// wins). Resolving that needs the widget tree (descendant checks),
980    /// which the registry can't see — so it hands back all candidates
981    /// and the dispatcher selects with focus in hand.
982    pub fn matches_by_keystroke(
983        &self,
984        keystroke: KeyStroke,
985    ) -> impl Iterator<Item = EffectiveShortcut<'_>> {
986        self.iter_effective()
987            .filter(move |s| s.enabled && s.matches(keystroke))
988    }
989
990    /// First effective shortcut whose primary or secondary keystroke
991    /// matches **and** is currently enabled. Disabled shortcuts are
992    /// invisible to the dispatcher — the keystroke falls through to
993    /// the focused widget's normal `on_key` handling, matching the
994    /// "treated as if not registered" semantic advertised by
995    /// [`Shortcut::enabled_when`].
996    ///
997    /// Note: this ignores scope applicability — for focus-aware
998    /// resolution the dispatcher uses
999    /// [`matches_by_keystroke`](Self::matches_by_keystroke) instead.
1000    pub fn find_by_keystroke(&self, keystroke: KeyStroke) -> Option<EffectiveShortcut<'_>> {
1001        self.matches_by_keystroke(keystroke).next()
1002    }
1003
1004    fn resolved_keystrokes(
1005        &self,
1006        id: &str,
1007        shortcut: &Shortcut,
1008    ) -> (Option<KeyStroke>, Option<KeyStroke>) {
1009        let ov = self.overrides.get(id).copied().unwrap_or_default();
1010        // Declared defaults go through the primary-accelerator convention
1011        // (`Ctrl` → ⌘ on macOS); user overrides do not. An app author writes
1012        // one chord for three platforms and means "the accelerator"; a user
1013        // who captured a chord in a settings UI pressed the keys they meant,
1014        // and rewriting those would put physical ⌃F out of reach on macOS.
1015        let (primary, secondary) = shortcut.declared_keystrokes();
1016        (ov.primary.resolve(primary), ov.secondary.resolve(secondary))
1017    }
1018
1019    fn bump_version(&self) {
1020        self.version.set(self.version.get().wrapping_add(1));
1021    }
1022
1023    /// Push the current effective primary keystroke for `id` into its
1024    /// per-id signal, if one is being observed. Guarded by equality so
1025    /// a no-op re-registration (e.g. a widget re-declaring the same
1026    /// shortcut on rebuild) doesn't notify observers. A `&str` is
1027    /// accepted so the override-keyed (`String`) mutators can call it.
1028    fn refresh_resolved(&self, id: &str) {
1029        if let Some(sig) = self.resolved.get(id) {
1030            let current = self
1031                .defaults
1032                .get(id)
1033                .and_then(|s| self.resolved_keystrokes(id, s).0);
1034            if sig.get() != current {
1035                sig.set(current);
1036            }
1037        }
1038    }
1039
1040    /// Refresh every observed id — for the "reset everything" mutators
1041    /// (`clear_all_overrides`, `import_overrides`) that can change many
1042    /// resolutions at once. The per-id equality guard keeps unchanged
1043    /// ids from notifying.
1044    fn refresh_all_resolved(&self) {
1045        let ids: Vec<&'static str> = self.resolved.keys().copied().collect();
1046        for id in ids {
1047            self.refresh_resolved(id);
1048        }
1049    }
1050
1051    /// Drop the owner index entries for `id`. Idempotent — safe to call
1052    /// even when the id has no known owner.
1053    fn detach_owner_index(&mut self, id: &str) {
1054        let Some(owner) = self.owner_by_id.remove(id) else {
1055            return;
1056        };
1057        if let Some(vec) = self.by_owner.get_mut(&owner) {
1058            vec.retain(|entry| *entry != id);
1059            if vec.is_empty() {
1060                self.by_owner.remove(&owner);
1061            }
1062        }
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069
1070    // --- Shortcut builder & defaults ------------------------------------
1071
1072    #[test]
1073    fn builder_defaults_are_sane() {
1074        let s = Shortcut::new("editor.format.bold").name("Bold").build();
1075        assert_eq!(s.id, "editor.format.bold");
1076        assert_eq!(s.intent_name(), "editor.format.bold");
1077        assert_eq!(s.name.get(), "Bold");
1078        assert_eq!(s.scope, ShortcutScope::Global);
1079        assert!(s.propagate_when_disabled);
1080        assert!(s.primary.is_none());
1081        assert!(s.secondary.is_none());
1082    }
1083
1084    #[test]
1085    fn builder_intent_overrides_id() {
1086        let s = Shortcut::new("app.save_as").intent("app.save").build();
1087        assert_eq!(s.intent_name(), "app.save");
1088    }
1089
1090    #[test]
1091    fn builder_scope_variants() {
1092        use slotmap::KeyData;
1093        let id: WidgetId = KeyData::from_ffi(7).into();
1094
1095        let g = Shortcut::new("foo").build();
1096        assert_eq!(g.scope, ShortcutScope::Global);
1097
1098        let s = Shortcut::new("bar").scope_to(id).build();
1099        assert_eq!(s.scope, ShortcutScope::Scoped(id));
1100
1101        let e = Shortcut::new("baz")
1102            .scope(ShortcutScope::Scoped(id))
1103            .build();
1104        assert_eq!(e.scope, ShortcutScope::Scoped(id));
1105
1106        let back = Shortcut::new("qux").scope_to(id).global().build();
1107        assert_eq!(back.scope, ShortcutScope::Global);
1108    }
1109
1110    #[test]
1111    fn shortcut_matches_default_primary_and_secondary() {
1112        let s = Shortcut::new("edit.undo")
1113            .primary(KeyStroke::command(Key::Z))
1114            .secondary(KeyStroke::alt(Key::Backspace))
1115            .build();
1116        assert!(s.matches_default(KeyStroke::command(Key::Z)));
1117        assert!(s.matches_default(KeyStroke::alt(Key::Backspace)));
1118        assert!(!s.matches_default(KeyStroke::command(Key::Y)));
1119    }
1120
1121    // --- Registry: upsert preserves user overrides ---------------------
1122
1123    #[test]
1124    fn register_upserts_and_preserves_override() {
1125        let mut reg = ShortcutRegistry::new();
1126        reg.register(
1127            Shortcut::new("app.save")
1128                .name("Save")
1129                .primary(KeyStroke::command(Key::S))
1130                .build(),
1131        );
1132
1133        // User rebinds Ctrl+S → Ctrl+Shift+S.
1134        reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
1135        assert_eq!(
1136            reg.effective("app.save").unwrap().primary,
1137            Some(KeyStroke::command_shift(Key::S))
1138        );
1139
1140        // Widget rebuilds, re-registers with the same defaults. The
1141        // user override must survive.
1142        reg.register(
1143            Shortcut::new("app.save")
1144                .name("Save")
1145                .primary(KeyStroke::command(Key::S))
1146                .build(),
1147        );
1148        assert_eq!(
1149            reg.effective("app.save").unwrap().primary,
1150            Some(KeyStroke::command_shift(Key::S))
1151        );
1152
1153        // Defaults change (rename + new default keystroke) but the
1154        // effective primary is still the user's rebinding.
1155        reg.register(
1156            Shortcut::new("app.save")
1157                .name("Save (renamed)")
1158                .primary(KeyStroke::alt(Key::S))
1159                .build(),
1160        );
1161        let eff = reg.effective("app.save").unwrap();
1162        assert_eq!(eff.primary, Some(KeyStroke::command_shift(Key::S)));
1163        assert_eq!(eff.shortcut.name.get(), "Save (renamed)");
1164    }
1165
1166    #[test]
1167    fn clear_override_restores_default() {
1168        let mut reg = ShortcutRegistry::new();
1169        reg.register(
1170            Shortcut::new("app.save")
1171                .primary(KeyStroke::command(Key::S))
1172                .build(),
1173        );
1174        reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
1175        reg.clear_override("app.save");
1176        assert_eq!(
1177            reg.effective("app.save").unwrap().primary,
1178            Some(KeyStroke::command(Key::S))
1179        );
1180    }
1181
1182    // --- Registry: graveyard -------------------------------------------
1183
1184    #[test]
1185    fn override_survives_unregister_and_reregister() {
1186        let mut reg = ShortcutRegistry::new();
1187        reg.register(
1188            Shortcut::new("editor.format.bold")
1189                .primary(KeyStroke::command(Key::B))
1190                .build(),
1191        );
1192        reg.rebind_primary("editor.format.bold", Some(KeyStroke::command_shift(Key::B)));
1193
1194        reg.unregister("editor.format.bold");
1195        assert!(reg.effective("editor.format.bold").is_none());
1196        assert_eq!(
1197            reg.override_for("editor.format.bold").unwrap().primary,
1198            SlotOverride::Bound(KeyStroke::command_shift(Key::B))
1199        );
1200
1201        reg.register(
1202            Shortcut::new("editor.format.bold")
1203                .primary(KeyStroke::command(Key::B))
1204                .build(),
1205        );
1206        assert_eq!(
1207            reg.effective("editor.format.bold").unwrap().primary,
1208            Some(KeyStroke::command_shift(Key::B))
1209        );
1210    }
1211
1212    // --- Registry: version signal --------------------------------------
1213
1214    #[test]
1215    fn version_bumps_on_every_mutation() {
1216        let mut reg = ShortcutRegistry::new();
1217        let v0 = reg.version().get();
1218
1219        reg.register(Shortcut::new("a").build());
1220        let v1 = reg.version().get();
1221        assert!(v1 > v0);
1222
1223        reg.rebind_primary("a", Some(KeyStroke::command(Key::A)));
1224        let v2 = reg.version().get();
1225        assert!(v2 > v1);
1226
1227        reg.rebind_secondary("a", Some(KeyStroke::alt(Key::A)));
1228        let v3 = reg.version().get();
1229        assert!(v3 > v2);
1230
1231        reg.clear_override("a");
1232        let v4 = reg.version().get();
1233        assert!(v4 > v3);
1234
1235        reg.unregister("a");
1236        let v5 = reg.version().get();
1237        assert!(v5 > v4);
1238    }
1239
1240    // --- Registry: per-id resolved signal (granular reactivity) --------
1241
1242    #[test]
1243    fn per_id_signal_seeds_isolates_and_tracks() {
1244        use std::cell::Cell;
1245        use std::rc::Rc;
1246
1247        let mut reg = ShortcutRegistry::new();
1248        reg.register(
1249            Shortcut::new("work.new")
1250                .primary(KeyStroke::command(Key::N))
1251                .build(),
1252        );
1253
1254        // Seeded with the current effective primary.
1255        let sig = reg.effective_primary_signal("work.new");
1256        assert_eq!(sig.get(), Some(KeyStroke::command(Key::N)));
1257
1258        // Count notifications to prove *isolation* from unrelated churn.
1259        let hits = Rc::new(Cell::new(0usize));
1260        let _h = {
1261            let hits = hits.clone();
1262            sig.observe(move |_| hits.set(hits.get() + 1))
1263        };
1264
1265        // Registering / unregistering an UNRELATED id must not notify us —
1266        // this is the whole point: a scoped shortcut registered in some
1267        // other widget's build() no longer disturbs this menu item.
1268        reg.register(
1269            Shortcut::new("outline.open_to_side")
1270                .primary(KeyStroke::command(Key::Enter))
1271                .build(),
1272        );
1273        reg.unregister("outline.open_to_side");
1274        assert_eq!(
1275            hits.get(),
1276            0,
1277            "unrelated shortcut churn must not notify a per-id observer"
1278        );
1279        assert_eq!(sig.get(), Some(KeyStroke::command(Key::N)));
1280
1281        // Rebinding OUR id updates the signal (and notifies exactly once).
1282        reg.rebind_primary("work.new", Some(KeyStroke::command_shift(Key::N)));
1283        assert_eq!(sig.get(), Some(KeyStroke::command_shift(Key::N)));
1284        assert_eq!(hits.get(), 1);
1285
1286        // Unregistering OUR id resolves to None.
1287        reg.unregister("work.new");
1288        assert_eq!(sig.get(), None);
1289        assert_eq!(hits.get(), 2);
1290    }
1291
1292    #[test]
1293    fn per_id_signal_observed_before_registration_goes_live_on_register() {
1294        // The menu-open scenario: a widget observes an id whose default is
1295        // not registered yet; when it later registers, the signal updates —
1296        // so the accelerator is current whenever the menu next appears,
1297        // even though the item is never rebuilt for shortcut changes.
1298        let mut reg = ShortcutRegistry::new();
1299        let sig = reg.effective_primary_signal("late.cmd");
1300        assert_eq!(sig.get(), None);
1301
1302        reg.register(
1303            Shortcut::new("late.cmd")
1304                .primary(KeyStroke::command(Key::S))
1305                .build(),
1306        );
1307        assert_eq!(sig.get(), Some(KeyStroke::command(Key::S)));
1308
1309        // A user override on top is reflected too.
1310        reg.rebind_primary("late.cmd", Some(KeyStroke::command_shift(Key::S)));
1311        assert_eq!(sig.get(), Some(KeyStroke::command_shift(Key::S)));
1312    }
1313
1314    // --- Registry: find_by_keystroke honors overrides -----------------
1315
1316    #[test]
1317    fn find_by_keystroke_uses_effective() {
1318        let mut reg = ShortcutRegistry::new();
1319        reg.register(
1320            Shortcut::new("app.save")
1321                .primary(KeyStroke::command(Key::S))
1322                .build(),
1323        );
1324        assert_eq!(
1325            reg.find_by_keystroke(KeyStroke::command(Key::S))
1326                .map(|s| s.shortcut.id),
1327            Some("app.save")
1328        );
1329
1330        reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
1331
1332        assert!(reg.find_by_keystroke(KeyStroke::command(Key::S)).is_none());
1333        assert_eq!(
1334            reg.find_by_keystroke(KeyStroke::command_shift(Key::S))
1335                .map(|s| s.shortcut.id),
1336            Some("app.save")
1337        );
1338    }
1339
1340    // --- Registry: owner indexing & cleanup ----------------------------
1341
1342    #[test]
1343    fn unregister_all_for_owner_removes_only_owner_entries() {
1344        use slotmap::KeyData;
1345        let editor: WidgetId = KeyData::from_ffi(1).into();
1346        let other: WidgetId = KeyData::from_ffi(2).into();
1347
1348        let mut reg = ShortcutRegistry::new();
1349        reg.register_owned(
1350            Shortcut::new("editor.format.bold")
1351                .primary(KeyStroke::command(Key::B))
1352                .build(),
1353            editor,
1354        );
1355        reg.register_owned(
1356            Shortcut::new("editor.format.italic")
1357                .primary(KeyStroke::command(Key::I))
1358                .build(),
1359            editor,
1360        );
1361        reg.register_owned(
1362            Shortcut::new("app.save")
1363                .primary(KeyStroke::command(Key::S))
1364                .build(),
1365            other,
1366        );
1367        assert_eq!(reg.len(), 3);
1368
1369        reg.unregister_all_for_owner(editor);
1370        assert_eq!(reg.len(), 1);
1371        assert!(reg.get_default("editor.format.bold").is_none());
1372        assert!(reg.get_default("editor.format.italic").is_none());
1373        assert!(reg.get_default("app.save").is_some());
1374        assert_eq!(reg.owner_of("app.save"), Some(other));
1375    }
1376
1377    #[test]
1378    fn anonymous_register_drops_prior_owner_index() {
1379        use slotmap::KeyData;
1380        let editor: WidgetId = KeyData::from_ffi(42).into();
1381
1382        let mut reg = ShortcutRegistry::new();
1383        reg.register_owned(Shortcut::new("foo").build(), editor);
1384        assert_eq!(reg.owner_of("foo"), Some(editor));
1385
1386        // Anonymous re-registration (e.g., app-level) supersedes the
1387        // owner link; cleanup for `editor` must no longer drop "foo".
1388        reg.register(Shortcut::new("foo").build());
1389        assert_eq!(reg.owner_of("foo"), None);
1390
1391        reg.unregister_all_for_owner(editor);
1392        assert!(reg.get_default("foo").is_some());
1393    }
1394
1395    #[test]
1396    fn reregister_with_new_owner_reassigns() {
1397        use slotmap::KeyData;
1398        let a: WidgetId = KeyData::from_ffi(10).into();
1399        let b: WidgetId = KeyData::from_ffi(11).into();
1400
1401        let mut reg = ShortcutRegistry::new();
1402        reg.register_owned(Shortcut::new("bar").build(), a);
1403        reg.register_owned(Shortcut::new("bar").build(), b);
1404        assert_eq!(reg.owner_of("bar"), Some(b));
1405
1406        // Cleanup for the original owner should be a no-op now.
1407        reg.unregister_all_for_owner(a);
1408        assert!(reg.get_default("bar").is_some());
1409        reg.unregister_all_for_owner(b);
1410        assert!(reg.get_default("bar").is_none());
1411    }
1412
1413    // --- enabled_when --------------------------------------------------
1414
1415    #[test]
1416    fn shortcut_is_enabled_defaults_true() {
1417        let s = Shortcut::new("foo").build();
1418        assert!(s.is_enabled());
1419    }
1420
1421    #[test]
1422    fn shortcut_is_enabled_follows_signal() {
1423        let enabled = Signal::new(false);
1424        let s = Shortcut::new("foo").enabled_when(enabled.clone()).build();
1425        assert!(!s.is_enabled());
1426        enabled.set(true);
1427        assert!(s.is_enabled());
1428    }
1429
1430    #[test]
1431    fn find_by_keystroke_skips_disabled() {
1432        let enabled = Signal::new(false);
1433        let mut reg = ShortcutRegistry::new();
1434        reg.register(
1435            Shortcut::new("app.save")
1436                .primary(KeyStroke::command(Key::S))
1437                .enabled_when(enabled.clone())
1438                .build(),
1439        );
1440        // Disabled → invisible to dispatch.
1441        assert!(reg.find_by_keystroke(KeyStroke::command(Key::S)).is_none());
1442
1443        // Enable → dispatch sees it.
1444        enabled.set(true);
1445        assert_eq!(
1446            reg.find_by_keystroke(KeyStroke::command(Key::S))
1447                .map(|s| s.shortcut.id),
1448            Some("app.save")
1449        );
1450    }
1451
1452    #[test]
1453    fn overrides_round_trip_through_export_import() {
1454        let mut reg = ShortcutRegistry::new();
1455        reg.register(
1456            Shortcut::new("app.save")
1457                .primary(KeyStroke::command(Key::S))
1458                .build(),
1459        );
1460        reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
1461
1462        let snapshot = reg.export_overrides();
1463        assert_eq!(snapshot.len(), 1);
1464
1465        // Fresh registry, same defaults but no overrides yet.
1466        let mut reg2 = ShortcutRegistry::new();
1467        reg2.register(
1468            Shortcut::new("app.save")
1469                .primary(KeyStroke::command(Key::S))
1470                .build(),
1471        );
1472        assert_eq!(
1473            reg2.effective("app.save").unwrap().primary,
1474            Some(KeyStroke::command(Key::S))
1475        );
1476
1477        reg2.import_overrides(snapshot);
1478        assert_eq!(
1479            reg2.effective("app.save").unwrap().primary,
1480            Some(KeyStroke::command_shift(Key::S))
1481        );
1482    }
1483
1484    #[test]
1485    fn clear_all_overrides_restores_every_default() {
1486        let mut reg = ShortcutRegistry::new();
1487        reg.register(
1488            Shortcut::new("a")
1489                .primary(KeyStroke::command(Key::A))
1490                .build(),
1491        );
1492        reg.register(
1493            Shortcut::new("b")
1494                .primary(KeyStroke::command(Key::B))
1495                .build(),
1496        );
1497        reg.rebind_primary("a", Some(KeyStroke::alt(Key::A)));
1498        reg.rebind_primary("b", Some(KeyStroke::alt(Key::B)));
1499
1500        reg.clear_all_overrides();
1501        assert_eq!(
1502            reg.effective("a").unwrap().primary,
1503            Some(KeyStroke::command(Key::A))
1504        );
1505        assert_eq!(
1506            reg.effective("b").unwrap().primary,
1507            Some(KeyStroke::command(Key::B))
1508        );
1509    }
1510
1511    #[test]
1512    fn untouched_slot_tracks_live_default_after_reregistration() {
1513        // Per-slot SlotOverride semantics: a rebind on the primary
1514        // slot MUST leave the secondary slot delegating to the
1515        // shortcut's current declaration. When the widget later
1516        // re-registers with a different default secondary, the
1517        // untouched secondary slot flows through automatically.
1518        let mut reg = ShortcutRegistry::new();
1519        reg.register(
1520            Shortcut::new("foo")
1521                .primary(KeyStroke::command(Key::S))
1522                .build(),
1523        );
1524
1525        reg.rebind_primary("foo", Some(KeyStroke::command_shift(Key::S)));
1526
1527        // Widget re-registers with a NEW default secondary. The
1528        // untouched secondary slot must pick this up — that is the
1529        // whole point of per-slot Default delegation.
1530        reg.register(
1531            Shortcut::new("foo")
1532                .primary(KeyStroke::command(Key::S))
1533                .secondary(KeyStroke::alt(Key::S))
1534                .build(),
1535        );
1536
1537        let eff = reg.effective("foo").unwrap();
1538        assert_eq!(eff.primary, Some(KeyStroke::command_shift(Key::S)));
1539        assert_eq!(
1540            eff.secondary,
1541            Some(KeyStroke::alt(Key::S)),
1542            "untouched secondary slot must reflect the new default"
1543        );
1544    }
1545
1546    #[test]
1547    fn rebind_primary_none_is_explicit_unbind_not_delegate() {
1548        // Passing `None` to rebind_primary must set the slot to
1549        // Unbound (user explicitly said "no binding here"), not
1550        // Default (which would still fall back to the declared
1551        // default).
1552        let mut reg = ShortcutRegistry::new();
1553        reg.register(
1554            Shortcut::new("foo")
1555                .primary(KeyStroke::command(Key::S))
1556                .build(),
1557        );
1558        reg.rebind_primary("foo", None);
1559        assert_eq!(reg.effective("foo").unwrap().primary, None);
1560
1561        // `clear_override` goes back to default.
1562        reg.clear_override("foo");
1563        assert_eq!(
1564            reg.effective("foo").unwrap().primary,
1565            Some(KeyStroke::command(Key::S))
1566        );
1567    }
1568
1569    #[test]
1570    fn find_conflict_skips_excluded_id_and_respects_overrides() {
1571        let mut reg = ShortcutRegistry::new();
1572        reg.register(
1573            Shortcut::new("a")
1574                .primary(KeyStroke::command(Key::X))
1575                .build(),
1576        );
1577        reg.register(
1578            Shortcut::new("b")
1579                .primary(KeyStroke::command(Key::Y))
1580                .build(),
1581        );
1582
1583        // Ctrl+X is bound to "a"; looking for it while excluding "a"
1584        // returns None, including it returns Some("a").
1585        assert_eq!(
1586            reg.find_conflict(KeyStroke::command(Key::X), Some("a")),
1587            None
1588        );
1589        assert_eq!(
1590            reg.find_conflict(KeyStroke::command(Key::X), None),
1591            Some("a")
1592        );
1593        // Ctrl+Z is bound to nothing.
1594        assert_eq!(reg.find_conflict(KeyStroke::command(Key::Z), None), None);
1595
1596        // User rebinds "b" to Ctrl+X — that becomes the new conflict
1597        // for Ctrl+X (overrides outrank defaults).
1598        reg.rebind_primary("b", Some(KeyStroke::command(Key::X)));
1599        assert_eq!(
1600            reg.find_conflict(KeyStroke::command(Key::X), Some("b")),
1601            Some("a")
1602        );
1603        assert_eq!(
1604            reg.find_conflict(KeyStroke::command(Key::X), Some("a")),
1605            Some("b")
1606        );
1607    }
1608
1609    #[test]
1610    fn find_conflict_is_scope_aware() {
1611        use slotmap::KeyData;
1612        let panel_a: WidgetId = KeyData::from_ffi(11).into();
1613        let panel_b: WidgetId = KeyData::from_ffi(22).into();
1614
1615        let mut reg = ShortcutRegistry::new();
1616        // Same chord (Delete) scoped to two different panels — legitimate,
1617        // resolved by focus at runtime, NOT a conflict.
1618        reg.register(
1619            Shortcut::new("a.delete")
1620                .scope_to(panel_a)
1621                .primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
1622                .build(),
1623        );
1624        reg.register(
1625            Shortcut::new("b.delete")
1626                .scope_to(panel_b)
1627                .primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
1628                .build(),
1629        );
1630        assert_eq!(
1631            reg.find_conflict(
1632                KeyStroke::new(Key::Delete, Modifiers::NONE),
1633                Some("a.delete")
1634            ),
1635            None,
1636            "Delete in a different panel scope is not a conflict"
1637        );
1638
1639        // A second shortcut scoped to the SAME panel IS a conflict.
1640        reg.register(
1641            Shortcut::new("a.delete2")
1642                .scope_to(panel_a)
1643                .primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
1644                .build(),
1645        );
1646        assert_eq!(
1647            reg.find_conflict(
1648                KeyStroke::new(Key::Delete, Modifiers::NONE),
1649                Some("a.delete")
1650            ),
1651            Some("a.delete2"),
1652            "same-scope same-chord is a real conflict"
1653        );
1654
1655        // A Global shortcut on the same chord collides with everything.
1656        reg.register(
1657            Shortcut::new("g.delete")
1658                .global()
1659                .primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
1660                .build(),
1661        );
1662        // The global excludes itself and collides with all three scoped
1663        // bindings; HashMap order makes the exact id arbitrary, so just
1664        // require it found one of them.
1665        let hit = reg.find_conflict(
1666            KeyStroke::new(Key::Delete, Modifiers::NONE),
1667            Some("g.delete"),
1668        );
1669        assert!(
1670            matches!(hit, Some("a.delete" | "a.delete2" | "b.delete")),
1671            "a global chord conflicts with any scoped binding, got {hit:?}"
1672        );
1673        assert_eq!(
1674            reg.find_conflict(
1675                KeyStroke::new(Key::Delete, Modifiers::NONE),
1676                Some("b.delete")
1677            ),
1678            Some("g.delete"),
1679            "a scoped binding conflicts with a global on the same chord"
1680        );
1681    }
1682
1683    #[test]
1684    fn iter_effective_order_is_deterministic_by_category_then_id() {
1685        let mut reg = ShortcutRegistry::new();
1686        reg.register(Shortcut::new("z.last").category("edit").build());
1687        reg.register(Shortcut::new("a.first").category("edit").build());
1688        reg.register(Shortcut::new("m.file").category("app").build());
1689
1690        let ids: Vec<&str> = reg.iter_effective().map(|e| e.shortcut.id).collect();
1691        assert_eq!(ids, vec!["m.file", "a.first", "z.last"]);
1692    }
1693
1694    #[test]
1695    fn iter_effective_still_includes_disabled_with_flag() {
1696        let enabled = Signal::new(false);
1697        let mut reg = ShortcutRegistry::new();
1698        reg.register(
1699            Shortcut::new("app.save")
1700                .primary(KeyStroke::command(Key::S))
1701                .enabled_when(enabled.clone())
1702                .build(),
1703        );
1704        let all: Vec<_> = reg.iter_effective().collect();
1705        assert_eq!(all.len(), 1);
1706        assert!(!all[0].enabled, "settings UI must see disabled state");
1707
1708        enabled.set(true);
1709        let all: Vec<_> = reg.iter_effective().collect();
1710        assert!(all[0].enabled);
1711    }
1712
1713    #[test]
1714    fn secondary_keystroke_matches_via_effective() {
1715        let mut reg = ShortcutRegistry::new();
1716        reg.register(
1717            Shortcut::new("edit.undo")
1718                .primary(KeyStroke::command(Key::Z))
1719                .secondary(KeyStroke::alt(Key::Backspace))
1720                .build(),
1721        );
1722        assert_eq!(
1723            reg.find_by_keystroke(KeyStroke::alt(Key::Backspace))
1724                .map(|s| s.shortcut.id),
1725            Some("edit.undo")
1726        );
1727    }
1728
1729    // --- The primary-accelerator convention -----------------------------
1730    //
1731    // `Modifiers::COMMAND` resolves at compile time, so these assert the rule
1732    // in terms of `KeyStroke::command`, which resolves the same way — true on
1733    // every host, and on macOS the statement that ⌘F fires a `Ctrl+F`
1734    // declaration. The platform-parameterised half of the rule (the actual
1735    // Ctrl→⌘ rewrite, testable from a Linux CI) lives in `event::modifier_tests`.
1736
1737    #[test]
1738    fn a_declared_ctrl_chord_resolves_to_the_platform_accelerator() {
1739        let mut reg = ShortcutRegistry::new();
1740        reg.register(
1741            Shortcut::new("editor.find")
1742                .primary(KeyStroke::ctrl(Key::F))
1743                .secondary(KeyStroke::ctrl_shift(Key::F))
1744                .build(),
1745        );
1746        let eff = reg.effective("editor.find").unwrap();
1747        assert_eq!(eff.primary, Some(KeyStroke::command(Key::F)));
1748        assert_eq!(eff.secondary, Some(KeyStroke::command_shift(Key::F)));
1749
1750        // The whole point: the accelerator chord dispatches.
1751        assert!(
1752            reg.find_by_keystroke(KeyStroke::command(Key::F)).is_some(),
1753            "the platform's accelerator must fire a Ctrl-declared shortcut"
1754        );
1755    }
1756
1757    #[test]
1758    fn literal_modifiers_pins_a_declaration_to_physical_control() {
1759        let mut reg = ShortcutRegistry::new();
1760        reg.register(
1761            Shortcut::new("view.next_tab")
1762                .literal_modifiers()
1763                .primary(KeyStroke::ctrl(Key::Tab))
1764                .build(),
1765        );
1766        assert_eq!(
1767            reg.effective("view.next_tab").unwrap().primary,
1768            Some(KeyStroke::new(Key::Tab, Modifiers::CTRL)),
1769            "Ctrl+Tab must stay Ctrl+Tab — ⌘⇥ is the macOS application switcher"
1770        );
1771    }
1772
1773    #[test]
1774    fn a_declared_super_chord_is_left_alone() {
1775        let mut reg = ShortcutRegistry::new();
1776        reg.register(
1777            Shortcut::new("a")
1778                .primary(KeyStroke::new(Key::S, Modifiers::SUPER))
1779                .build(),
1780        );
1781        reg.register(
1782            Shortcut::new("b")
1783                .primary(KeyStroke::new(Key::B, Modifiers::CTRL | Modifiers::SUPER))
1784                .build(),
1785        );
1786        assert_eq!(
1787            reg.effective("a").unwrap().primary,
1788            Some(KeyStroke::new(Key::S, Modifiers::SUPER))
1789        );
1790        assert_eq!(
1791            reg.effective("b").unwrap().primary,
1792            Some(KeyStroke::new(Key::B, Modifiers::CTRL | Modifiers::SUPER)),
1793            "Ctrl+Super is a genuine two-modifier chord, not a Ctrl to rewrite"
1794        );
1795    }
1796
1797    #[test]
1798    fn a_user_override_is_taken_literally() {
1799        // A chord captured in a settings UI is a statement of intent. Rewriting
1800        // it would make physical Control unbindable on macOS — and would mean
1801        // the row the user is looking at fires a chord they did not press.
1802        let mut reg = ShortcutRegistry::new();
1803        reg.register(
1804            Shortcut::new("editor.find")
1805                .primary(KeyStroke::ctrl(Key::F))
1806                .build(),
1807        );
1808        let literal_control = KeyStroke::new(Key::G, Modifiers::CTRL);
1809        reg.rebind_primary("editor.find", Some(literal_control));
1810        assert_eq!(
1811            reg.effective("editor.find").unwrap().primary,
1812            Some(literal_control)
1813        );
1814
1815        // Clearing it hands the slot back to the declared default, convention
1816        // and all.
1817        reg.clear_override("editor.find");
1818        assert_eq!(
1819            reg.effective("editor.find").unwrap().primary,
1820            Some(KeyStroke::command(Key::F))
1821        );
1822    }
1823
1824    #[test]
1825    fn find_conflict_sees_the_resolved_chord() {
1826        // The settings UI hands `find_conflict` the chord the user just
1827        // pressed. It must recognise that an accelerator chord collides with a
1828        // Ctrl-declared default — otherwise a second shortcut can be bound to
1829        // it silently and both would fire.
1830        let mut reg = ShortcutRegistry::new();
1831        reg.register(
1832            Shortcut::new("editor.find")
1833                .primary(KeyStroke::ctrl(Key::F))
1834                .build(),
1835        );
1836        assert_eq!(
1837            reg.find_conflict(KeyStroke::command(Key::F), None),
1838            Some("editor.find")
1839        );
1840    }
1841
1842    #[test]
1843    fn matches_default_follows_the_convention_and_its_opt_out() {
1844        let converted = Shortcut::new("a").primary(KeyStroke::ctrl(Key::F)).build();
1845        assert!(converted.matches_default(KeyStroke::command(Key::F)));
1846
1847        let literal = Shortcut::new("b")
1848            .literal_modifiers()
1849            .primary(KeyStroke::ctrl(Key::Tab))
1850            .build();
1851        assert!(literal.matches_default(KeyStroke::new(Key::Tab, Modifiers::CTRL)));
1852    }
1853}