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