use crate::event::{Key, Modifiers};
use crate::intent::Intent;
use crate::signal::{Prop, Signal};
use crate::widget::EventContext;
use crate::widget_id::WidgetId;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct KeyStroke {
pub key: Key,
pub modifiers: Modifiers,
}
impl KeyStroke {
pub fn new(key: Key, modifiers: Modifiers) -> Self {
Self { key, modifiers }
}
pub fn ctrl(key: Key) -> Self {
Self::new(key, Modifiers::CTRL)
}
pub fn ctrl_shift(key: Key) -> Self {
Self::new(key, Modifiers::CTRL | Modifiers::SHIFT)
}
pub fn command(key: Key) -> Self {
Self::new(key, Modifiers::COMMAND)
}
pub fn command_shift(key: Key) -> Self {
Self::new(key, Modifiers::COMMAND | Modifiers::SHIFT)
}
pub fn alt(key: Key) -> Self {
Self::new(key, Modifiers::ALT)
}
pub fn with_command_convention(self) -> Self {
Self::new(self.key, self.modifiers.with_command_convention())
}
}
impl fmt::Display for KeyStroke {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.modifiers, self.key)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutScope {
Global,
Scoped(WidgetId),
}
fn scopes_can_collide(a: ShortcutScope, b: ShortcutScope) -> bool {
match (a, b) {
(ShortcutScope::Scoped(x), ShortcutScope::Scoped(y)) => x == y,
_ => true,
}
}
pub type ShortcutOnActivate = Box<dyn FnMut(KeyStroke, &mut EventContext) -> Intent>;
pub type KeyCaptureCallback = Box<dyn FnOnce(KeyStroke, &mut ShortcutRegistry, &mut EventContext)>;
pub(crate) type KeyCaptureSlot = std::rc::Rc<std::cell::RefCell<Option<KeyCaptureCallback>>>;
#[must_use = "key capture is cancelled when the CaptureHandle is dropped"]
pub struct CaptureHandle {
slot: KeyCaptureSlot,
}
impl std::fmt::Debug for CaptureHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CaptureHandle")
.field("armed", &self.slot.borrow().is_some())
.finish()
}
}
impl CaptureHandle {
pub(crate) fn new(slot: KeyCaptureSlot) -> Self {
Self { slot }
}
pub fn is_armed(&self) -> bool {
self.slot.borrow().is_some()
}
pub fn cancel(self) {
drop(self);
}
}
impl Drop for CaptureHandle {
fn drop(&mut self) {
self.slot.borrow_mut().take();
}
}
pub struct Shortcut {
pub id: &'static str,
pub name: Prop<String>,
pub category: Option<&'static str>,
pub description: Option<Prop<String>>,
pub primary: Option<KeyStroke>,
pub secondary: Option<KeyStroke>,
pub intent: Option<&'static str>,
pub on_activate: Option<ShortcutOnActivate>,
pub scope: ShortcutScope,
pub propagate_when_disabled: bool,
pub enabled_when: Option<Prop<bool>>,
pub literal_modifiers: bool,
}
impl fmt::Debug for Shortcut {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Shortcut")
.field("id", &self.id)
.field("name", &self.name)
.field("category", &self.category)
.field("description", &self.description)
.field("primary", &self.primary)
.field("secondary", &self.secondary)
.field("intent", &self.intent)
.field(
"on_activate",
&self.on_activate.as_ref().map(|_| "<closure>"),
)
.field("scope", &self.scope)
.field("propagate_when_disabled", &self.propagate_when_disabled)
.field("enabled_when", &self.enabled_when.is_some())
.field("literal_modifiers", &self.literal_modifiers)
.finish()
}
}
impl Shortcut {
#[allow(clippy::new_ret_no_self)]
pub fn new(id: &'static str) -> ShortcutBuilder {
ShortcutBuilder {
inner: Shortcut {
id,
name: Prop::Static(String::new()),
category: None,
description: None,
primary: None,
secondary: None,
intent: None,
on_activate: None,
scope: ShortcutScope::Global,
propagate_when_disabled: true,
enabled_when: None,
literal_modifiers: false,
},
}
}
pub fn is_enabled(&self) -> bool {
self.enabled_when.as_ref().map(|s| s.get()).unwrap_or(true)
}
pub fn intent_name(&self) -> &'static str {
self.intent.unwrap_or(self.id)
}
pub fn matches_default(&self, keystroke: KeyStroke) -> bool {
let (primary, secondary) = self.declared_keystrokes();
primary == Some(keystroke) || secondary == Some(keystroke)
}
pub fn declared_keystrokes(&self) -> (Option<KeyStroke>, Option<KeyStroke>) {
if self.literal_modifiers {
(self.primary, self.secondary)
} else {
(
self.primary.map(KeyStroke::with_command_convention),
self.secondary.map(KeyStroke::with_command_convention),
)
}
}
}
pub struct ShortcutBuilder {
inner: Shortcut,
}
impl ShortcutBuilder {
pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
self.inner.name = name.into();
self
}
pub fn category(mut self, category: &'static str) -> Self {
self.inner.category = Some(category);
self
}
pub fn literal_modifiers(mut self) -> Self {
self.inner.literal_modifiers = true;
self
}
pub fn description(mut self, description: impl Into<Prop<String>>) -> Self {
self.inner.description = Some(description.into());
self
}
pub fn primary(mut self, keystroke: KeyStroke) -> Self {
self.inner.primary = Some(keystroke);
self
}
pub fn secondary(mut self, keystroke: KeyStroke) -> Self {
self.inner.secondary = Some(keystroke);
self
}
pub fn intent(mut self, intent: &'static str) -> Self {
self.inner.intent = Some(intent);
self
}
pub fn on_activate<R>(
mut self,
mut f: impl FnMut(KeyStroke, &mut EventContext) -> R + 'static,
) -> Self
where
R: Into<Intent>,
{
self.inner.on_activate = Some(Box::new(move |ks, ctx| f(ks, ctx).into()));
self
}
pub fn scope_to(mut self, id: WidgetId) -> Self {
self.inner.scope = ShortcutScope::Scoped(id);
self
}
pub fn scope(mut self, scope: ShortcutScope) -> Self {
self.inner.scope = scope;
self
}
pub fn global(mut self) -> Self {
self.inner.scope = ShortcutScope::Global;
self
}
pub fn propagate_when_disabled(mut self, propagate: bool) -> Self {
self.inner.propagate_when_disabled = propagate;
self
}
pub fn enabled_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
self.inner.enabled_when = Some(signal.into());
self
}
pub fn build(self) -> Shortcut {
self.inner
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SlotOverride {
#[default]
Default,
Bound(KeyStroke),
Unbound,
}
impl SlotOverride {
pub fn resolve(self, fallback: Option<KeyStroke>) -> Option<KeyStroke> {
match self {
SlotOverride::Default => fallback,
SlotOverride::Bound(ks) => Some(ks),
SlotOverride::Unbound => None,
}
}
pub fn is_touched(self) -> bool {
!matches!(self, SlotOverride::Default)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct KeyStrokeOverride {
pub primary: SlotOverride,
pub secondary: SlotOverride,
}
impl KeyStrokeOverride {
pub fn is_empty(self) -> bool {
!self.primary.is_touched() && !self.secondary.is_touched()
}
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveShortcut<'a> {
pub shortcut: &'a Shortcut,
pub primary: Option<KeyStroke>,
pub secondary: Option<KeyStroke>,
pub enabled: bool,
}
impl EffectiveShortcut<'_> {
pub fn matches(&self, keystroke: KeyStroke) -> bool {
self.primary == Some(keystroke) || self.secondary == Some(keystroke)
}
}
pub struct ShortcutRegistry {
defaults: HashMap<&'static str, Shortcut>,
overrides: HashMap<String, KeyStrokeOverride>,
by_owner: HashMap<WidgetId, Vec<&'static str>>,
owner_by_id: HashMap<&'static str, WidgetId>,
version: Signal<u64>,
resolved: HashMap<&'static str, Signal<Option<KeyStroke>>>,
}
impl Default for ShortcutRegistry {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for ShortcutRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ShortcutRegistry")
.field("defaults", &self.defaults)
.field("overrides", &self.overrides)
.field("by_owner", &self.by_owner)
.field("version", &self.version.get())
.finish()
}
}
impl ShortcutRegistry {
pub fn new() -> Self {
Self {
defaults: HashMap::new(),
overrides: HashMap::new(),
by_owner: HashMap::new(),
owner_by_id: HashMap::new(),
version: Signal::new(0),
resolved: HashMap::new(),
}
}
pub fn version(&self) -> &Signal<u64> {
&self.version
}
pub fn effective_primary_signal(&mut self, id: &'static str) -> Signal<Option<KeyStroke>> {
if let Some(sig) = self.resolved.get(id) {
return sig.clone();
}
let current = self
.defaults
.get(id)
.and_then(|s| self.resolved_keystrokes(id, s).0);
let sig = Signal::new(current);
self.resolved.insert(id, sig.clone());
sig
}
pub fn register(&mut self, shortcut: Shortcut) -> Option<Shortcut> {
let id = shortcut.id;
let previous = self.defaults.insert(id, shortcut);
self.detach_owner_index(id);
self.bump_version();
self.refresh_resolved(id);
previous
}
pub fn register_owned(&mut self, shortcut: Shortcut, owner: WidgetId) -> Option<Shortcut> {
let id = shortcut.id;
let previous = self.defaults.insert(id, shortcut);
self.detach_owner_index(id);
self.by_owner.entry(owner).or_default().push(id);
self.owner_by_id.insert(id, owner);
self.bump_version();
self.refresh_resolved(id);
previous
}
pub fn unregister(&mut self, id: &str) -> Option<Shortcut> {
let removed = self.defaults.remove(id);
if removed.is_some() {
self.detach_owner_index(id);
self.bump_version();
self.refresh_resolved(id);
}
removed
}
pub fn unregister_all_for_owner(&mut self, owner: WidgetId) {
let Some(ids) = self.by_owner.remove(&owner) else {
return;
};
let mut any = false;
for id in ids {
if self.defaults.remove(id).is_some() {
any = true;
}
self.owner_by_id.remove(id);
self.refresh_resolved(id);
}
if any {
self.bump_version();
}
}
pub fn owner_of(&self, id: &str) -> Option<WidgetId> {
self.owner_by_id.get(id).copied()
}
pub fn len(&self) -> usize {
self.defaults.len()
}
pub fn is_empty(&self) -> bool {
self.defaults.is_empty()
}
pub fn iter_defaults(&self) -> impl Iterator<Item = &Shortcut> {
self.defaults.values()
}
pub fn get_default(&self, id: &str) -> Option<&Shortcut> {
self.defaults.get(id)
}
pub(crate) fn invoke_on_activate(
&mut self,
id: &str,
keystroke: KeyStroke,
ctx: &mut EventContext,
) -> Option<Intent> {
let shortcut = self.defaults.get_mut(id)?;
let intent_name = shortcut.intent_name();
let intent = match &mut shortcut.on_activate {
Some(handler) => handler(keystroke, ctx),
None => Intent::new(intent_name),
};
Some(intent)
}
pub fn override_for(&self, id: &str) -> Option<KeyStrokeOverride> {
self.overrides.get(id).copied()
}
pub fn put_override(&mut self, id: impl Into<String>, override_: KeyStrokeOverride) {
let id = id.into();
self.overrides.insert(id.clone(), override_);
self.bump_version();
self.refresh_resolved(&id);
}
pub fn rebind_primary(&mut self, id: impl Into<String>, keystroke: Option<KeyStroke>) {
let id = id.into();
let entry = self.overrides.entry(id.clone()).or_default();
entry.primary = match keystroke {
Some(ks) => SlotOverride::Bound(ks),
None => SlotOverride::Unbound,
};
self.bump_version();
self.refresh_resolved(&id);
}
pub fn rebind_secondary(&mut self, id: impl Into<String>, keystroke: Option<KeyStroke>) {
let id = id.into();
let entry = self.overrides.entry(id.clone()).or_default();
entry.secondary = match keystroke {
Some(ks) => SlotOverride::Bound(ks),
None => SlotOverride::Unbound,
};
self.bump_version();
self.refresh_resolved(&id);
}
pub fn clear_override(&mut self, id: &str) {
if self.overrides.remove(id).is_some() {
self.bump_version();
self.refresh_resolved(id);
}
}
pub fn clear_all_overrides(&mut self) {
if !self.overrides.is_empty() {
self.overrides.clear();
self.bump_version();
self.refresh_all_resolved();
}
}
pub fn export_overrides(&self) -> HashMap<String, KeyStrokeOverride> {
self.overrides.clone()
}
pub fn import_overrides(&mut self, overrides: HashMap<String, KeyStrokeOverride>) {
self.overrides = overrides;
self.bump_version();
self.refresh_all_resolved();
}
pub fn effective(&self, id: &str) -> Option<EffectiveShortcut<'_>> {
let shortcut = self.defaults.get(id)?;
let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
Some(EffectiveShortcut {
shortcut,
primary,
secondary,
enabled: shortcut.is_enabled(),
})
}
pub fn iter_effective(&self) -> impl Iterator<Item = EffectiveShortcut<'_>> {
let mut items: Vec<EffectiveShortcut<'_>> = self
.defaults
.iter()
.map(|(id, shortcut)| {
let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
EffectiveShortcut {
shortcut,
primary,
secondary,
enabled: shortcut.is_enabled(),
}
})
.collect();
items.sort_by(|a, b| {
a.shortcut
.category
.cmp(&b.shortcut.category)
.then(a.shortcut.id.cmp(b.shortcut.id))
});
items.into_iter()
}
pub fn find_conflict(
&self,
keystroke: KeyStroke,
excluding_id: Option<&str>,
) -> Option<&'static str> {
let self_scope = excluding_id
.and_then(|eid| self.defaults.get(eid))
.map(|s| s.scope);
self.defaults.iter().find_map(|(&id, shortcut)| {
if excluding_id == Some(id) {
return None;
}
let (primary, secondary) = self.resolved_keystrokes(id, shortcut);
if primary != Some(keystroke) && secondary != Some(keystroke) {
return None;
}
match self_scope {
Some(self_scope) if !scopes_can_collide(self_scope, shortcut.scope) => None,
_ => Some(id),
}
})
}
pub fn matches_by_keystroke(
&self,
keystroke: KeyStroke,
) -> impl Iterator<Item = EffectiveShortcut<'_>> {
self.iter_effective()
.filter(move |s| s.enabled && s.matches(keystroke))
}
pub fn find_by_keystroke(&self, keystroke: KeyStroke) -> Option<EffectiveShortcut<'_>> {
self.matches_by_keystroke(keystroke).next()
}
fn resolved_keystrokes(
&self,
id: &str,
shortcut: &Shortcut,
) -> (Option<KeyStroke>, Option<KeyStroke>) {
let ov = self.overrides.get(id).copied().unwrap_or_default();
let (primary, secondary) = shortcut.declared_keystrokes();
(ov.primary.resolve(primary), ov.secondary.resolve(secondary))
}
fn bump_version(&self) {
self.version.set(self.version.get().wrapping_add(1));
}
fn refresh_resolved(&self, id: &str) {
if let Some(sig) = self.resolved.get(id) {
let current = self
.defaults
.get(id)
.and_then(|s| self.resolved_keystrokes(id, s).0);
if sig.get() != current {
sig.set(current);
}
}
}
fn refresh_all_resolved(&self) {
let ids: Vec<&'static str> = self.resolved.keys().copied().collect();
for id in ids {
self.refresh_resolved(id);
}
}
fn detach_owner_index(&mut self, id: &str) {
let Some(owner) = self.owner_by_id.remove(id) else {
return;
};
if let Some(vec) = self.by_owner.get_mut(&owner) {
vec.retain(|entry| *entry != id);
if vec.is_empty() {
self.by_owner.remove(&owner);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults_are_sane() {
let s = Shortcut::new("editor.format.bold").name("Bold").build();
assert_eq!(s.id, "editor.format.bold");
assert_eq!(s.intent_name(), "editor.format.bold");
assert_eq!(s.name.get(), "Bold");
assert_eq!(s.scope, ShortcutScope::Global);
assert!(s.propagate_when_disabled);
assert!(s.primary.is_none());
assert!(s.secondary.is_none());
}
#[test]
fn builder_intent_overrides_id() {
let s = Shortcut::new("app.save_as").intent("app.save").build();
assert_eq!(s.intent_name(), "app.save");
}
#[test]
fn builder_scope_variants() {
use slotmap::KeyData;
let id: WidgetId = KeyData::from_ffi(7).into();
let g = Shortcut::new("foo").build();
assert_eq!(g.scope, ShortcutScope::Global);
let s = Shortcut::new("bar").scope_to(id).build();
assert_eq!(s.scope, ShortcutScope::Scoped(id));
let e = Shortcut::new("baz")
.scope(ShortcutScope::Scoped(id))
.build();
assert_eq!(e.scope, ShortcutScope::Scoped(id));
let back = Shortcut::new("qux").scope_to(id).global().build();
assert_eq!(back.scope, ShortcutScope::Global);
}
#[test]
fn shortcut_matches_default_primary_and_secondary() {
let s = Shortcut::new("edit.undo")
.primary(KeyStroke::command(Key::Z))
.secondary(KeyStroke::alt(Key::Backspace))
.build();
assert!(s.matches_default(KeyStroke::command(Key::Z)));
assert!(s.matches_default(KeyStroke::alt(Key::Backspace)));
assert!(!s.matches_default(KeyStroke::command(Key::Y)));
}
#[test]
fn register_upserts_and_preserves_override() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.name("Save")
.primary(KeyStroke::command(Key::S))
.build(),
);
reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
assert_eq!(
reg.effective("app.save").unwrap().primary,
Some(KeyStroke::command_shift(Key::S))
);
reg.register(
Shortcut::new("app.save")
.name("Save")
.primary(KeyStroke::command(Key::S))
.build(),
);
assert_eq!(
reg.effective("app.save").unwrap().primary,
Some(KeyStroke::command_shift(Key::S))
);
reg.register(
Shortcut::new("app.save")
.name("Save (renamed)")
.primary(KeyStroke::alt(Key::S))
.build(),
);
let eff = reg.effective("app.save").unwrap();
assert_eq!(eff.primary, Some(KeyStroke::command_shift(Key::S)));
assert_eq!(eff.shortcut.name.get(), "Save (renamed)");
}
#[test]
fn clear_override_restores_default() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.build(),
);
reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
reg.clear_override("app.save");
assert_eq!(
reg.effective("app.save").unwrap().primary,
Some(KeyStroke::command(Key::S))
);
}
#[test]
fn override_survives_unregister_and_reregister() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("editor.format.bold")
.primary(KeyStroke::command(Key::B))
.build(),
);
reg.rebind_primary("editor.format.bold", Some(KeyStroke::command_shift(Key::B)));
reg.unregister("editor.format.bold");
assert!(reg.effective("editor.format.bold").is_none());
assert_eq!(
reg.override_for("editor.format.bold").unwrap().primary,
SlotOverride::Bound(KeyStroke::command_shift(Key::B))
);
reg.register(
Shortcut::new("editor.format.bold")
.primary(KeyStroke::command(Key::B))
.build(),
);
assert_eq!(
reg.effective("editor.format.bold").unwrap().primary,
Some(KeyStroke::command_shift(Key::B))
);
}
#[test]
fn version_bumps_on_every_mutation() {
let mut reg = ShortcutRegistry::new();
let v0 = reg.version().get();
reg.register(Shortcut::new("a").build());
let v1 = reg.version().get();
assert!(v1 > v0);
reg.rebind_primary("a", Some(KeyStroke::command(Key::A)));
let v2 = reg.version().get();
assert!(v2 > v1);
reg.rebind_secondary("a", Some(KeyStroke::alt(Key::A)));
let v3 = reg.version().get();
assert!(v3 > v2);
reg.clear_override("a");
let v4 = reg.version().get();
assert!(v4 > v3);
reg.unregister("a");
let v5 = reg.version().get();
assert!(v5 > v4);
}
#[test]
fn per_id_signal_seeds_isolates_and_tracks() {
use std::cell::Cell;
use std::rc::Rc;
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("work.new")
.primary(KeyStroke::command(Key::N))
.build(),
);
let sig = reg.effective_primary_signal("work.new");
assert_eq!(sig.get(), Some(KeyStroke::command(Key::N)));
let hits = Rc::new(Cell::new(0usize));
let _h = {
let hits = hits.clone();
sig.observe(move |_| hits.set(hits.get() + 1))
};
reg.register(
Shortcut::new("outline.open_to_side")
.primary(KeyStroke::command(Key::Enter))
.build(),
);
reg.unregister("outline.open_to_side");
assert_eq!(
hits.get(),
0,
"unrelated shortcut churn must not notify a per-id observer"
);
assert_eq!(sig.get(), Some(KeyStroke::command(Key::N)));
reg.rebind_primary("work.new", Some(KeyStroke::command_shift(Key::N)));
assert_eq!(sig.get(), Some(KeyStroke::command_shift(Key::N)));
assert_eq!(hits.get(), 1);
reg.unregister("work.new");
assert_eq!(sig.get(), None);
assert_eq!(hits.get(), 2);
}
#[test]
fn per_id_signal_observed_before_registration_goes_live_on_register() {
let mut reg = ShortcutRegistry::new();
let sig = reg.effective_primary_signal("late.cmd");
assert_eq!(sig.get(), None);
reg.register(
Shortcut::new("late.cmd")
.primary(KeyStroke::command(Key::S))
.build(),
);
assert_eq!(sig.get(), Some(KeyStroke::command(Key::S)));
reg.rebind_primary("late.cmd", Some(KeyStroke::command_shift(Key::S)));
assert_eq!(sig.get(), Some(KeyStroke::command_shift(Key::S)));
}
#[test]
fn find_by_keystroke_uses_effective() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.build(),
);
assert_eq!(
reg.find_by_keystroke(KeyStroke::command(Key::S))
.map(|s| s.shortcut.id),
Some("app.save")
);
reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
assert!(reg.find_by_keystroke(KeyStroke::command(Key::S)).is_none());
assert_eq!(
reg.find_by_keystroke(KeyStroke::command_shift(Key::S))
.map(|s| s.shortcut.id),
Some("app.save")
);
}
#[test]
fn unregister_all_for_owner_removes_only_owner_entries() {
use slotmap::KeyData;
let editor: WidgetId = KeyData::from_ffi(1).into();
let other: WidgetId = KeyData::from_ffi(2).into();
let mut reg = ShortcutRegistry::new();
reg.register_owned(
Shortcut::new("editor.format.bold")
.primary(KeyStroke::command(Key::B))
.build(),
editor,
);
reg.register_owned(
Shortcut::new("editor.format.italic")
.primary(KeyStroke::command(Key::I))
.build(),
editor,
);
reg.register_owned(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.build(),
other,
);
assert_eq!(reg.len(), 3);
reg.unregister_all_for_owner(editor);
assert_eq!(reg.len(), 1);
assert!(reg.get_default("editor.format.bold").is_none());
assert!(reg.get_default("editor.format.italic").is_none());
assert!(reg.get_default("app.save").is_some());
assert_eq!(reg.owner_of("app.save"), Some(other));
}
#[test]
fn anonymous_register_drops_prior_owner_index() {
use slotmap::KeyData;
let editor: WidgetId = KeyData::from_ffi(42).into();
let mut reg = ShortcutRegistry::new();
reg.register_owned(Shortcut::new("foo").build(), editor);
assert_eq!(reg.owner_of("foo"), Some(editor));
reg.register(Shortcut::new("foo").build());
assert_eq!(reg.owner_of("foo"), None);
reg.unregister_all_for_owner(editor);
assert!(reg.get_default("foo").is_some());
}
#[test]
fn reregister_with_new_owner_reassigns() {
use slotmap::KeyData;
let a: WidgetId = KeyData::from_ffi(10).into();
let b: WidgetId = KeyData::from_ffi(11).into();
let mut reg = ShortcutRegistry::new();
reg.register_owned(Shortcut::new("bar").build(), a);
reg.register_owned(Shortcut::new("bar").build(), b);
assert_eq!(reg.owner_of("bar"), Some(b));
reg.unregister_all_for_owner(a);
assert!(reg.get_default("bar").is_some());
reg.unregister_all_for_owner(b);
assert!(reg.get_default("bar").is_none());
}
#[test]
fn shortcut_is_enabled_defaults_true() {
let s = Shortcut::new("foo").build();
assert!(s.is_enabled());
}
#[test]
fn shortcut_is_enabled_follows_signal() {
let enabled = Signal::new(false);
let s = Shortcut::new("foo").enabled_when(enabled.clone()).build();
assert!(!s.is_enabled());
enabled.set(true);
assert!(s.is_enabled());
}
#[test]
fn find_by_keystroke_skips_disabled() {
let enabled = Signal::new(false);
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.enabled_when(enabled.clone())
.build(),
);
assert!(reg.find_by_keystroke(KeyStroke::command(Key::S)).is_none());
enabled.set(true);
assert_eq!(
reg.find_by_keystroke(KeyStroke::command(Key::S))
.map(|s| s.shortcut.id),
Some("app.save")
);
}
#[test]
fn overrides_round_trip_through_export_import() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.build(),
);
reg.rebind_primary("app.save", Some(KeyStroke::command_shift(Key::S)));
let snapshot = reg.export_overrides();
assert_eq!(snapshot.len(), 1);
let mut reg2 = ShortcutRegistry::new();
reg2.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.build(),
);
assert_eq!(
reg2.effective("app.save").unwrap().primary,
Some(KeyStroke::command(Key::S))
);
reg2.import_overrides(snapshot);
assert_eq!(
reg2.effective("app.save").unwrap().primary,
Some(KeyStroke::command_shift(Key::S))
);
}
#[test]
fn clear_all_overrides_restores_every_default() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("a")
.primary(KeyStroke::command(Key::A))
.build(),
);
reg.register(
Shortcut::new("b")
.primary(KeyStroke::command(Key::B))
.build(),
);
reg.rebind_primary("a", Some(KeyStroke::alt(Key::A)));
reg.rebind_primary("b", Some(KeyStroke::alt(Key::B)));
reg.clear_all_overrides();
assert_eq!(
reg.effective("a").unwrap().primary,
Some(KeyStroke::command(Key::A))
);
assert_eq!(
reg.effective("b").unwrap().primary,
Some(KeyStroke::command(Key::B))
);
}
#[test]
fn untouched_slot_tracks_live_default_after_reregistration() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("foo")
.primary(KeyStroke::command(Key::S))
.build(),
);
reg.rebind_primary("foo", Some(KeyStroke::command_shift(Key::S)));
reg.register(
Shortcut::new("foo")
.primary(KeyStroke::command(Key::S))
.secondary(KeyStroke::alt(Key::S))
.build(),
);
let eff = reg.effective("foo").unwrap();
assert_eq!(eff.primary, Some(KeyStroke::command_shift(Key::S)));
assert_eq!(
eff.secondary,
Some(KeyStroke::alt(Key::S)),
"untouched secondary slot must reflect the new default"
);
}
#[test]
fn rebind_primary_none_is_explicit_unbind_not_delegate() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("foo")
.primary(KeyStroke::command(Key::S))
.build(),
);
reg.rebind_primary("foo", None);
assert_eq!(reg.effective("foo").unwrap().primary, None);
reg.clear_override("foo");
assert_eq!(
reg.effective("foo").unwrap().primary,
Some(KeyStroke::command(Key::S))
);
}
#[test]
fn find_conflict_skips_excluded_id_and_respects_overrides() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("a")
.primary(KeyStroke::command(Key::X))
.build(),
);
reg.register(
Shortcut::new("b")
.primary(KeyStroke::command(Key::Y))
.build(),
);
assert_eq!(
reg.find_conflict(KeyStroke::command(Key::X), Some("a")),
None
);
assert_eq!(
reg.find_conflict(KeyStroke::command(Key::X), None),
Some("a")
);
assert_eq!(reg.find_conflict(KeyStroke::command(Key::Z), None), None);
reg.rebind_primary("b", Some(KeyStroke::command(Key::X)));
assert_eq!(
reg.find_conflict(KeyStroke::command(Key::X), Some("b")),
Some("a")
);
assert_eq!(
reg.find_conflict(KeyStroke::command(Key::X), Some("a")),
Some("b")
);
}
#[test]
fn find_conflict_is_scope_aware() {
use slotmap::KeyData;
let panel_a: WidgetId = KeyData::from_ffi(11).into();
let panel_b: WidgetId = KeyData::from_ffi(22).into();
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("a.delete")
.scope_to(panel_a)
.primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
.build(),
);
reg.register(
Shortcut::new("b.delete")
.scope_to(panel_b)
.primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
.build(),
);
assert_eq!(
reg.find_conflict(
KeyStroke::new(Key::Delete, Modifiers::NONE),
Some("a.delete")
),
None,
"Delete in a different panel scope is not a conflict"
);
reg.register(
Shortcut::new("a.delete2")
.scope_to(panel_a)
.primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
.build(),
);
assert_eq!(
reg.find_conflict(
KeyStroke::new(Key::Delete, Modifiers::NONE),
Some("a.delete")
),
Some("a.delete2"),
"same-scope same-chord is a real conflict"
);
reg.register(
Shortcut::new("g.delete")
.global()
.primary(KeyStroke::new(Key::Delete, Modifiers::NONE))
.build(),
);
let hit = reg.find_conflict(
KeyStroke::new(Key::Delete, Modifiers::NONE),
Some("g.delete"),
);
assert!(
matches!(hit, Some("a.delete" | "a.delete2" | "b.delete")),
"a global chord conflicts with any scoped binding, got {hit:?}"
);
assert_eq!(
reg.find_conflict(
KeyStroke::new(Key::Delete, Modifiers::NONE),
Some("b.delete")
),
Some("g.delete"),
"a scoped binding conflicts with a global on the same chord"
);
}
#[test]
fn iter_effective_order_is_deterministic_by_category_then_id() {
let mut reg = ShortcutRegistry::new();
reg.register(Shortcut::new("z.last").category("edit").build());
reg.register(Shortcut::new("a.first").category("edit").build());
reg.register(Shortcut::new("m.file").category("app").build());
let ids: Vec<&str> = reg.iter_effective().map(|e| e.shortcut.id).collect();
assert_eq!(ids, vec!["m.file", "a.first", "z.last"]);
}
#[test]
fn iter_effective_still_includes_disabled_with_flag() {
let enabled = Signal::new(false);
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("app.save")
.primary(KeyStroke::command(Key::S))
.enabled_when(enabled.clone())
.build(),
);
let all: Vec<_> = reg.iter_effective().collect();
assert_eq!(all.len(), 1);
assert!(!all[0].enabled, "settings UI must see disabled state");
enabled.set(true);
let all: Vec<_> = reg.iter_effective().collect();
assert!(all[0].enabled);
}
#[test]
fn secondary_keystroke_matches_via_effective() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("edit.undo")
.primary(KeyStroke::command(Key::Z))
.secondary(KeyStroke::alt(Key::Backspace))
.build(),
);
assert_eq!(
reg.find_by_keystroke(KeyStroke::alt(Key::Backspace))
.map(|s| s.shortcut.id),
Some("edit.undo")
);
}
#[test]
fn a_declared_ctrl_chord_resolves_to_the_platform_accelerator() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("editor.find")
.primary(KeyStroke::ctrl(Key::F))
.secondary(KeyStroke::ctrl_shift(Key::F))
.build(),
);
let eff = reg.effective("editor.find").unwrap();
assert_eq!(eff.primary, Some(KeyStroke::command(Key::F)));
assert_eq!(eff.secondary, Some(KeyStroke::command_shift(Key::F)));
assert!(
reg.find_by_keystroke(KeyStroke::command(Key::F)).is_some(),
"the platform's accelerator must fire a Ctrl-declared shortcut"
);
}
#[test]
fn literal_modifiers_pins_a_declaration_to_physical_control() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("view.next_tab")
.literal_modifiers()
.primary(KeyStroke::ctrl(Key::Tab))
.build(),
);
assert_eq!(
reg.effective("view.next_tab").unwrap().primary,
Some(KeyStroke::new(Key::Tab, Modifiers::CTRL)),
"Ctrl+Tab must stay Ctrl+Tab — ⌘⇥ is the macOS application switcher"
);
}
#[test]
fn a_declared_super_chord_is_left_alone() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("a")
.primary(KeyStroke::new(Key::S, Modifiers::SUPER))
.build(),
);
reg.register(
Shortcut::new("b")
.primary(KeyStroke::new(Key::B, Modifiers::CTRL | Modifiers::SUPER))
.build(),
);
assert_eq!(
reg.effective("a").unwrap().primary,
Some(KeyStroke::new(Key::S, Modifiers::SUPER))
);
assert_eq!(
reg.effective("b").unwrap().primary,
Some(KeyStroke::new(Key::B, Modifiers::CTRL | Modifiers::SUPER)),
"Ctrl+Super is a genuine two-modifier chord, not a Ctrl to rewrite"
);
}
#[test]
fn a_user_override_is_taken_literally() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("editor.find")
.primary(KeyStroke::ctrl(Key::F))
.build(),
);
let literal_control = KeyStroke::new(Key::G, Modifiers::CTRL);
reg.rebind_primary("editor.find", Some(literal_control));
assert_eq!(
reg.effective("editor.find").unwrap().primary,
Some(literal_control)
);
reg.clear_override("editor.find");
assert_eq!(
reg.effective("editor.find").unwrap().primary,
Some(KeyStroke::command(Key::F))
);
}
#[test]
fn find_conflict_sees_the_resolved_chord() {
let mut reg = ShortcutRegistry::new();
reg.register(
Shortcut::new("editor.find")
.primary(KeyStroke::ctrl(Key::F))
.build(),
);
assert_eq!(
reg.find_conflict(KeyStroke::command(Key::F), None),
Some("editor.find")
);
}
#[test]
fn matches_default_follows_the_convention_and_its_opt_out() {
let converted = Shortcut::new("a").primary(KeyStroke::ctrl(Key::F)).build();
assert!(converted.matches_default(KeyStroke::command(Key::F)));
let literal = Shortcut::new("b")
.literal_modifiers()
.primary(KeyStroke::ctrl(Key::Tab))
.build();
assert!(literal.matches_default(KeyStroke::new(Key::Tab, Modifiers::CTRL)));
}
}