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