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