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