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