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