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