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