Skip to main content

repose_core/
shortcuts.rs

1use crate::Vec2;
2use crate::effects::Dispose;
3use crate::input::{Key, Modifiers, PointerKind};
4use crate::remember_with_key;
5use std::cell::{Cell, RefCell};
6use std::rc::{Rc, Weak};
7
8#[derive(Clone, Debug, PartialEq)]
9pub enum Gesture {
10    SwipeLeft,
11    SwipeRight,
12    /// Center-less pinch kept for back-compat with producers that do not
13    /// track a centroid (none in-tree emit this, handlers must treat it as
14    /// unroutable and return false unless they own the whole surface).
15    Pinch {
16        delta_scale: f32,
17    },
18    PinchWithCenter {
19        delta_scale: f32,
20        center: Vec2,
21    },
22    /// Two-finger rotation (twist). `delta_rotation` is in radians
23    /// (positive = clockwise in screen space, y-down), `center` is the
24    /// gesture centroid in physical px.
25    Rotate {
26        delta_rotation: f32,
27        center: Vec2,
28    },
29    /// 2/3-finger pan (centroid translation). `delta` is in physical px,
30    /// positive = content moves right/down (natural scrolling). `center` is
31    /// the gesture centroid in physical px (Compose `calculateCentroid`).
32    Pan {
33        delta: Vec2,
34        center: Vec2,
35    },
36}
37
38/// Low-level drag-and-drop actions dispatched by the platform.
39/// The framework handles gesture detection (mouse drag vs touch long press)
40/// and the DnD state machine internally.
41#[derive(Clone, Debug, PartialEq)]
42pub enum DragAction {
43    /// Pointer button pressed (mouse down / touch start).
44    Press {
45        position: Vec2,
46        capture_id: u64,
47        kind: PointerKind,
48        modifiers: Modifiers,
49    },
50    /// Pointer moved while button is pressed or touch is active.
51    Move {
52        position: Vec2,
53        modifiers: Modifiers,
54    },
55    /// Pointer released.
56    Release {
57        position: Vec2,
58        modifiers: Modifiers,
59    },
60    /// Drag cancelled (e.g. Escape key).
61    Cancel,
62}
63
64#[derive(Clone, Debug, PartialEq)]
65pub enum Action {
66    Copy,
67    Cut,
68    Paste,
69    SelectAll,
70    Undo,
71    Redo,
72
73    Back,
74    Find,
75    Save,
76
77    FocusNext,
78    FocusPrevious,
79    FocusLeft,
80    FocusRight,
81    FocusUp,
82    FocusDown,
83
84    Gesture(Gesture),
85    Drag(DragAction),
86    Custom(Rc<str>),
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Hash)]
90pub struct KeyChord {
91    pub key: Key,
92    pub modifiers: Modifiers,
93}
94
95impl KeyChord {
96    pub fn new(key: Key, modifiers: Modifiers) -> Self {
97        Self { key, modifiers }
98    }
99}
100
101#[derive(Clone, Debug)]
102pub struct ShortcutBinding {
103    pub chord: KeyChord,
104    pub action: Action,
105}
106
107#[derive(Clone, Debug, Default)]
108pub struct ShortcutMap {
109    pub bindings: Vec<ShortcutBinding>,
110}
111
112impl ShortcutMap {
113    pub fn new() -> Self {
114        Self {
115            bindings: Vec::new(),
116        }
117    }
118
119    pub fn bind(mut self, key: Key, modifiers: Modifiers, action: Action) -> Self {
120        self.bindings.push(ShortcutBinding {
121            chord: KeyChord::new(key, modifiers),
122            action,
123        });
124        self
125    }
126
127    pub fn bind_action(mut self, action: Action) -> Self {
128        if let Some(chord) = default_chord_for(&action) {
129            self.bindings.push(ShortcutBinding { chord, action });
130        }
131        self
132    }
133
134    pub fn merge(mut self, other: ShortcutMap) -> Self {
135        self.bindings.extend(other.bindings);
136        self
137    }
138
139    pub fn insert(&mut self, key: Key, modifiers: Modifiers, action: Action) {
140        self.bindings.push(ShortcutBinding {
141            chord: KeyChord::new(key, modifiers),
142            action,
143        });
144    }
145
146    pub fn action_for(&self, chord: &KeyChord) -> Option<Action> {
147        self.bindings
148            .iter()
149            .rev()
150            .find(|binding| &binding.chord == chord)
151            .map(|binding| binding.action.clone())
152    }
153}
154
155pub type Handler = Rc<dyn Fn(Action) -> bool>;
156
157#[derive(Clone)]
158pub struct ShortcutState {
159    pub handler: Option<Handler>,
160    pub default_map: ShortcutMap,
161    pub scopes: Vec<ShortcutMap>,
162    runtime_installs: Rc<RefCell<RuntimeShortcutInstalls>>,
163    use_global_fallback: bool,
164    identity: u64,
165}
166
167struct RuntimeShortcutMapEntry {
168    key: String,
169    token: u64,
170    map: ShortcutMap,
171    cleanup: Dispose,
172}
173
174struct RuntimeShortcutHandlerEntry {
175    key: String,
176    token: u64,
177    handler: Handler,
178    cleanup: Dispose,
179}
180
181#[derive(Default)]
182struct RuntimeShortcutInstalls {
183    maps: Vec<RuntimeShortcutMapEntry>,
184    handlers: Vec<RuntimeShortcutHandlerEntry>,
185    next_token: u64,
186}
187
188impl ShortcutState {
189    pub fn new() -> Self {
190        Self {
191            handler: None,
192            default_map: default_map(),
193            scopes: Vec::new(),
194            runtime_installs: Rc::new(RefCell::new(RuntimeShortcutInstalls {
195                next_token: 1,
196                ..RuntimeShortcutInstalls::default()
197            })),
198            use_global_fallback: true,
199            identity: next_shortcut_state_id(),
200        }
201    }
202
203    pub fn without_global_fallback(mut self) -> Self {
204        self.use_global_fallback = false;
205        self
206    }
207
208    fn resolve_local_action(&self, chord: &KeyChord) -> Option<Action> {
209        if chord.key == Key::Unknown {
210            return None;
211        }
212        if let Some(action) = self
213            .scopes
214            .iter()
215            .rev()
216            .find_map(|scope| scope.action_for(chord))
217        {
218            return Some(action);
219        }
220        if let Some(action) = self
221            .runtime_installs
222            .borrow()
223            .maps
224            .iter()
225            .rev()
226            .find_map(|entry| entry.map.action_for(chord))
227        {
228            return Some(action);
229        }
230        self.default_map.action_for(chord)
231    }
232
233    pub fn resolve_action(&self, chord: &KeyChord) -> Option<Action> {
234        self.resolve_local_action(chord).or_else(|| {
235            self.use_global_fallback
236                .then(|| resolve_global_action(chord.clone()))
237                .flatten()
238        })
239    }
240
241    fn handle_local(&self, action: Action) -> bool {
242        let handler = self
243            .runtime_installs
244            .borrow()
245            .handlers
246            .iter()
247            .rev()
248            .map(|entry| entry.handler.clone())
249            .next()
250            .or_else(|| self.handler.clone());
251        handler.is_some_and(|handler| handler(action))
252    }
253
254    pub fn handle(&self, action: Action) -> bool {
255        if self.handle_local(action.clone()) {
256            return true;
257        }
258        self.use_global_fallback && handle_global(action)
259    }
260}
261
262impl Default for ShortcutState {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268struct ScopeEntry {
269    token: Option<u64>,
270    map: ShortcutMap,
271}
272
273struct ShortcutScopeStack(Vec<ScopeEntry>);
274
275impl ShortcutScopeStack {
276    #[cfg(test)]
277    fn push(&mut self, map: ShortcutMap) {
278        self.0.push(ScopeEntry { token: None, map });
279    }
280
281    fn push_installed(&mut self, token: u64, map: ShortcutMap) {
282        self.0.push(ScopeEntry {
283            token: Some(token),
284            map,
285        });
286    }
287
288    #[cfg(test)]
289    fn pop(&mut self) -> Option<ShortcutMap> {
290        self.0.pop().map(|entry| entry.map)
291    }
292
293    fn remove(&mut self, token: u64) -> Option<ShortcutMap> {
294        let index = self.0.iter().position(|entry| entry.token == Some(token))?;
295        Some(self.0.remove(index).map)
296    }
297
298    fn update(&mut self, token: u64, map: ShortcutMap) -> Option<ShortcutMap> {
299        let entry = self.0.iter_mut().find(|entry| entry.token == Some(token))?;
300        Some(std::mem::replace(&mut entry.map, map))
301    }
302
303    fn iter_rev(&self) -> impl Iterator<Item = &ShortcutMap> {
304        self.0.iter().rev().map(|entry| &entry.map)
305    }
306}
307
308struct MapInstallState {
309    token: u64,
310    active: bool,
311    cleanup: Option<Dispose>,
312}
313
314struct HandlerInstallState {
315    token: u64,
316    active: bool,
317    cleanup: Option<Dispose>,
318}
319
320thread_local! {
321    static HANDLER: RefCell<Option<Handler>> = RefCell::new(None);
322    static BASE_HANDLER: RefCell<Option<Handler>> = RefCell::new(None);
323    static HANDLER_ENTRIES: RefCell<Vec<(u64, Handler)>> = const { RefCell::new(Vec::new()) };
324    static DEFAULT_MAP: RefCell<ShortcutMap> = RefCell::new(default_map());
325    static SCOPES: RefCell<ShortcutScopeStack> =
326        const { RefCell::new(ShortcutScopeStack(Vec::new())) };
327    static NEXT_INSTALLER_ID: Cell<u64> = const { Cell::new(1) };
328    static NEXT_SHORTCUT_STATE_ID: Cell<u64> = const { Cell::new(1) };
329    static ACTIVE_SHORTCUT_STATE: RefCell<Option<ShortcutState>> = const { RefCell::new(None) };
330}
331
332fn next_installer_id() -> u64 {
333    NEXT_INSTALLER_ID.with(|next| {
334        let id = next.get();
335        next.set(id.wrapping_add(1));
336        id
337    })
338}
339
340fn next_shortcut_state_id() -> u64 {
341    NEXT_SHORTCUT_STATE_ID.with(|next| {
342        let id = next.get();
343        next.set(id.wrapping_add(1));
344        id
345    })
346}
347
348pub fn with_runtime_state<R>(state: &ShortcutState, f: impl FnOnce() -> R) -> R {
349    struct Restore(Option<ShortcutState>);
350    impl Drop for Restore {
351        fn drop(&mut self) {
352            ACTIVE_SHORTCUT_STATE.with(|slot| {
353                *slot.borrow_mut() = self.0.take();
354            });
355        }
356    }
357    let previous = ACTIVE_SHORTCUT_STATE.with(|slot| slot.borrow_mut().replace(state.clone()));
358    let _restore = Restore(previous);
359    f()
360}
361
362fn active_runtime_state() -> Option<ShortcutState> {
363    ACTIVE_SHORTCUT_STATE.with(|slot| slot.borrow().clone())
364}
365
366pub(crate) fn active_runtime_identity() -> Option<u64> {
367    active_runtime_state().map(|state| state.identity)
368}
369
370fn sync_handler() {
371    let handler = HANDLER_ENTRIES
372        .try_with(|entries| {
373            entries
374                .try_borrow()
375                .ok()
376                .and_then(|entries| entries.last().map(|(_, handler)| handler.clone()))
377        })
378        .ok()
379        .flatten()
380        .or_else(|| BASE_HANDLER.with(|base| base.try_borrow().ok().and_then(|base| base.clone())));
381    let old = HANDLER.try_with(|current| {
382        current
383            .try_borrow_mut()
384            .ok()
385            .map(|mut current| std::mem::replace(&mut *current, handler))
386    });
387    if let Ok(Some(old)) = old {
388        drop(old);
389    }
390}
391
392pub fn set(handler: Option<Handler>) {
393    let old = BASE_HANDLER.try_with(|base| {
394        base.try_borrow_mut()
395            .ok()
396            .map(|mut base| std::mem::replace(&mut *base, handler))
397    });
398    if let Ok(Some(old)) = old {
399        drop(old);
400    }
401    sync_handler();
402}
403
404fn handle_global(action: Action) -> bool {
405    let handler = HANDLER
406        .try_with(|current| {
407            current
408                .try_borrow()
409                .ok()
410                .and_then(|current| current.clone())
411        })
412        .ok()
413        .flatten();
414    handler.map(|handler| handler(action)).unwrap_or(false)
415}
416
417pub fn handle(action: Action) -> bool {
418    if let Some(state) = active_runtime_state() {
419        return state.handle(action);
420    }
421    handle_global(action)
422}
423
424fn resolve_global_action(chord: KeyChord) -> Option<Action> {
425    if chord.key == Key::Unknown {
426        return None;
427    }
428    let scoped = SCOPES
429        .try_with(|scopes| {
430            scopes
431                .try_borrow()
432                .ok()
433                .and_then(|scopes| scopes.iter_rev().find_map(|scope| scope.action_for(&chord)))
434        })
435        .ok()
436        .flatten();
437    scoped.or_else(|| {
438        DEFAULT_MAP
439            .try_with(|map| map.try_borrow().ok().and_then(|map| map.action_for(&chord)))
440            .ok()
441            .flatten()
442    })
443}
444
445pub fn resolve_action(chord: KeyChord) -> Option<Action> {
446    if let Some(state) = active_runtime_state() {
447        return state.resolve_action(&chord);
448    }
449    resolve_global_action(chord)
450}
451
452pub fn set_default_map(map: ShortcutMap) {
453    let old = DEFAULT_MAP.try_with(|current| {
454        current
455            .try_borrow_mut()
456            .ok()
457            .map(|mut current| std::mem::replace(&mut *current, map))
458    });
459    if let Ok(Some(old)) = old {
460        drop(old);
461    }
462    crate::request_frame();
463}
464
465fn map_cleanup_key(key: &str) -> String {
466    format!("shortcut-map-cleanup:{key}")
467}
468
469fn handler_cleanup_key(key: &str) -> String {
470    format!("shortcut-handler-cleanup:{key}")
471}
472
473fn owner_disposer(key: String, cleanup: Dispose, owner: String) -> Dispose {
474    Dispose::new(move || {
475        crate::runtime::remove_keyed_disposer_for_owner(&key, &cleanup, &owner);
476    })
477}
478
479fn runtime_owner_disposer(key: String, cleanup: Dispose, owner: String) -> Dispose {
480    Dispose::new(move || {
481        cleanup.run();
482        crate::runtime::remove_keyed_disposer_for_owner(&key, &cleanup, &owner);
483    })
484}
485
486/// `slot` separates the map and handler registries. They used to share one key,
487/// so installing the handler displaced the map's disposer and ran its cleanup on
488/// the spot, from inside the caller's `runtime_installs` borrow, so the cleanup
489/// re-borrowed a `RefCell` that was already mutably borrowed. The resulting
490/// panic was swallowed by `run_keyed_disposer`, leaving a stale map entry and no
491/// handler. Distinct keys make registration non-destructive.
492fn register_runtime_cleanup(
493    state: &ShortcutState,
494    slot: &str,
495    key: &str,
496    cleanup: Dispose,
497) -> Dispose {
498    let cleanup_key = format!(
499        "runtime-shortcut-{slot}-cleanup:{}:{key}",
500        Rc::as_ptr(&state.runtime_installs) as usize
501    );
502    let owner =
503        crate::runtime::scope_owner_token(crate::scope_cache::current_scope_key().as_deref());
504    if !crate::runtime::keyed_disposer_has_owner(&cleanup_key, &cleanup, &owner) {
505        crate::runtime::register_keyed_disposer_for_owner(
506            cleanup_key.clone(),
507            cleanup.clone(),
508            owner.clone(),
509        );
510        if let Some(scope) = crate::scope::current_scope() {
511            let registered = cleanup.clone();
512            let scope_key = cleanup_key.clone();
513            let scope_owner = owner.clone();
514            scope.add_disposer(move || {
515                crate::runtime::remove_keyed_disposer_for_owner(
516                    &scope_key,
517                    &registered,
518                    &scope_owner,
519                );
520            });
521        }
522    }
523    runtime_owner_disposer(cleanup_key, cleanup, owner)
524}
525
526fn install_runtime_map(state: &ShortcutState, key: String, map: ShortcutMap) -> Dispose {
527    let token = {
528        let mut installs = state.runtime_installs.borrow_mut();
529        if let Some(entry) = installs.maps.iter_mut().find(|entry| entry.key == key) {
530            entry.map = map;
531            return entry.cleanup.clone();
532        }
533        let token = installs.next_token;
534        installs.next_token = installs.next_token.wrapping_add(1);
535        token
536    };
537    // Registered outside the `installs` borrow: registration can run a cleanup
538    // for this key, and that cleanup re-borrows `installs`.
539    let weak: Weak<RefCell<RuntimeShortcutInstalls>> = Rc::downgrade(&state.runtime_installs);
540    let cleanup_key = key.clone();
541    let cleanup = Dispose::new(move || {
542        let Some(installs) = weak.upgrade() else {
543            return;
544        };
545        let mut installs = installs.borrow_mut();
546        if let Some(index) = installs
547            .maps
548            .iter()
549            .position(|entry| entry.key == cleanup_key && entry.token == token)
550        {
551            installs.maps.remove(index);
552        }
553    });
554    let disposer = register_runtime_cleanup(state, "map", &key, cleanup);
555    state
556        .runtime_installs
557        .borrow_mut()
558        .maps
559        .push(RuntimeShortcutMapEntry {
560            key,
561            token,
562            map,
563            cleanup: disposer.clone(),
564        });
565    disposer
566}
567
568fn install_runtime_handler(state: &ShortcutState, key: String, handler: Handler) -> Dispose {
569    let token = {
570        let mut installs = state.runtime_installs.borrow_mut();
571        if let Some(entry) = installs.handlers.iter_mut().find(|entry| entry.key == key) {
572            entry.handler = handler;
573            return entry.cleanup.clone();
574        }
575        let token = installs.next_token;
576        installs.next_token = installs.next_token.wrapping_add(1);
577        token
578    };
579    let weak: Weak<RefCell<RuntimeShortcutInstalls>> = Rc::downgrade(&state.runtime_installs);
580    let cleanup_key = key.clone();
581    let cleanup = Dispose::new(move || {
582        let Some(installs) = weak.upgrade() else {
583            return;
584        };
585        let mut installs = installs.borrow_mut();
586        if let Some(index) = installs
587            .handlers
588            .iter()
589            .position(|entry| entry.key == cleanup_key && entry.token == token)
590        {
591            installs.handlers.remove(index);
592        }
593    });
594    let disposer = register_runtime_cleanup(state, "handler", &key, cleanup);
595    state
596        .runtime_installs
597        .borrow_mut()
598        .handlers
599        .push(RuntimeShortcutHandlerEntry {
600            key,
601            token,
602            handler,
603            cleanup: disposer.clone(),
604        });
605    disposer
606}
607
608fn install_shortcut_map_state(key: impl Into<String>, map: ShortcutMap) -> Dispose {
609    let key = key.into();
610    if let Some(state) = active_runtime_state() {
611        return install_runtime_map(&state, key, map);
612    }
613    let state_key = format!("shortcut-map-state:{key}");
614    let state: Rc<RefCell<MapInstallState>> = remember_with_key(state_key, || {
615        RefCell::new(MapInstallState {
616            token: 0,
617            active: false,
618            cleanup: None,
619        })
620    });
621    let (token, cleanup, installed) = {
622        let weak: Weak<RefCell<MapInstallState>> = Rc::downgrade(&state);
623        let mut state = state.borrow_mut();
624        if state.active {
625            (
626                state.token,
627                state.cleanup.clone().expect("active map state"),
628                false,
629            )
630        } else {
631            let token = next_installer_id();
632            let cleanup = Dispose::new(move || {
633                let removed = SCOPES
634                    .try_with(|scopes| {
635                        scopes
636                            .try_borrow_mut()
637                            .ok()
638                            .and_then(|mut scopes| scopes.remove(token))
639                    })
640                    .ok()
641                    .flatten();
642                drop(removed);
643                if let Some(state) = weak.upgrade() {
644                    let old = {
645                        let mut state = state.borrow_mut();
646                        if state.token == token {
647                            state.active = false;
648                            state.cleanup.take()
649                        } else {
650                            None
651                        }
652                    };
653                    drop(old);
654                }
655            });
656            state.token = token;
657            state.active = true;
658            state.cleanup = Some(cleanup.clone());
659            (token, cleanup, true)
660        }
661    };
662    if installed {
663        SCOPES.with(|scopes| scopes.borrow_mut().push_installed(token, map));
664    } else {
665        let old = SCOPES
666            .try_with(|scopes| {
667                scopes
668                    .try_borrow_mut()
669                    .ok()
670                    .and_then(|mut scopes| scopes.update(token, map))
671            })
672            .ok()
673            .flatten();
674        drop(old);
675    }
676    let cleanup_key = map_cleanup_key(&key);
677    let owner =
678        crate::runtime::scope_owner_token(crate::scope_cache::current_scope_key().as_deref());
679    if !crate::runtime::keyed_disposer_has_owner(&cleanup_key, &cleanup, &owner) {
680        crate::runtime::register_keyed_disposer_for_owner(
681            cleanup_key.clone(),
682            cleanup.clone(),
683            owner.clone(),
684        );
685        if let Some(scope) = crate::scope::current_scope() {
686            let registered = cleanup.clone();
687            let scope_key = cleanup_key.clone();
688            let scope_owner = owner.clone();
689            scope.add_disposer(move || {
690                crate::runtime::remove_keyed_disposer_for_owner(
691                    &scope_key,
692                    &registered,
693                    &scope_owner,
694                );
695            });
696        }
697    }
698    owner_disposer(cleanup_key, cleanup, owner)
699}
700
701pub fn install_shortcut_map_with_key(key: impl Into<String>, map: ShortcutMap) -> Dispose {
702    install_shortcut_map_state(key, map)
703}
704
705#[allow(non_snake_case)]
706pub fn InstallShortcutMapWithKey(key: impl Into<String>, map: ShortcutMap) -> Dispose {
707    install_shortcut_map_state(key, map)
708}
709
710#[track_caller]
711#[allow(non_snake_case)]
712pub fn InstallShortcutMap(map: ShortcutMap) -> Dispose {
713    let location = std::panic::Location::caller();
714    install_shortcut_map_state(
715        format!(
716            "{}:{}:{}",
717            location.file(),
718            location.line(),
719            location.column()
720        ),
721        map,
722    )
723}
724
725pub fn install_shortcut_handler_state(key: impl Into<String>, handler: Handler) -> Dispose {
726    let key = key.into();
727    if let Some(state) = active_runtime_state() {
728        return install_runtime_handler(&state, key, handler);
729    }
730    let state_key = format!("shortcut-handler-state:{key}");
731    let state: Rc<RefCell<HandlerInstallState>> = remember_with_key(state_key, || {
732        RefCell::new(HandlerInstallState {
733            token: 0,
734            active: false,
735            cleanup: None,
736        })
737    });
738    let (token, cleanup, installed) = {
739        let weak: Weak<RefCell<HandlerInstallState>> = Rc::downgrade(&state);
740        let mut state = state.borrow_mut();
741        if state.active {
742            (
743                state.token,
744                state.cleanup.clone().expect("active handler state"),
745                false,
746            )
747        } else {
748            let token = next_installer_id();
749            let cleanup = Dispose::new(move || {
750                let removed = HANDLER_ENTRIES
751                    .try_with(|entries| {
752                        entries.try_borrow_mut().ok().and_then(|mut entries| {
753                            let index = entries.iter().position(|(id, _)| *id == token)?;
754                            Some(entries.remove(index).1)
755                        })
756                    })
757                    .ok()
758                    .flatten();
759                drop(removed);
760                if let Some(state) = weak.upgrade() {
761                    let old = {
762                        let mut state = state.borrow_mut();
763                        if state.token == token {
764                            state.active = false;
765                            state.cleanup.take()
766                        } else {
767                            None
768                        }
769                    };
770                    drop(old);
771                }
772                sync_handler();
773            });
774            state.token = token;
775            state.active = true;
776            state.cleanup = Some(cleanup.clone());
777            (token, cleanup, true)
778        }
779    };
780    if installed {
781        let old = HANDLER_ENTRIES
782            .try_with(|entries| {
783                entries.try_borrow_mut().ok().map(|mut entries| {
784                    entries.push((token, handler));
785                    None::<Handler>
786                })
787            })
788            .ok()
789            .flatten();
790        drop(old);
791        sync_handler();
792    } else {
793        let old = HANDLER_ENTRIES
794            .try_with(|entries| {
795                entries.try_borrow_mut().ok().and_then(|mut entries| {
796                    let entry = entries.iter_mut().find(|(id, _)| *id == token)?;
797                    let old = entry.1.clone();
798                    entry.1 = handler;
799                    Some(old)
800                })
801            })
802            .ok()
803            .flatten();
804        drop(old);
805        sync_handler();
806    }
807    let cleanup_key = handler_cleanup_key(&key);
808    let owner =
809        crate::runtime::scope_owner_token(crate::scope_cache::current_scope_key().as_deref());
810    if !crate::runtime::keyed_disposer_has_owner(&cleanup_key, &cleanup, &owner) {
811        crate::runtime::register_keyed_disposer_for_owner(
812            cleanup_key.clone(),
813            cleanup.clone(),
814            owner.clone(),
815        );
816        if let Some(scope) = crate::scope::current_scope() {
817            let registered = cleanup.clone();
818            let scope_key = cleanup_key.clone();
819            let scope_owner = owner.clone();
820            scope.add_disposer(move || {
821                crate::runtime::remove_keyed_disposer_for_owner(
822                    &scope_key,
823                    &registered,
824                    &scope_owner,
825                );
826            });
827        }
828    }
829    owner_disposer(cleanup_key, cleanup, owner)
830}
831
832pub fn install_shortcut_handler_with_key(key: impl Into<String>, handler: Handler) -> Dispose {
833    install_shortcut_handler_state(key, handler)
834}
835
836#[allow(non_snake_case)]
837pub fn InstallShortcutHandlerWithKey(key: impl Into<String>, handler: Handler) -> Dispose {
838    install_shortcut_handler_state(key, handler)
839}
840
841#[track_caller]
842#[allow(non_snake_case)]
843pub fn InstallShortcutHandler(handler: Handler) -> Dispose {
844    let location = std::panic::Location::caller();
845    install_shortcut_handler_state(
846        format!(
847            "{}:{}:{}",
848            location.file(),
849            location.line(),
850            location.column()
851        ),
852        handler,
853    )
854}
855
856pub fn default_chord_for(action: &Action) -> Option<KeyChord> {
857    // On non-macOS, sets ctrl true
858    let cmd = Modifiers {
859        command: true,
860        ctrl: !cfg!(target_os = "macos"),
861        ..Modifiers::default()
862    };
863    match action {
864        Action::Copy => Some(KeyChord::new(Key::Character('c'), cmd)),
865        Action::Cut => Some(KeyChord::new(Key::Character('x'), cmd)),
866        Action::Paste => Some(KeyChord::new(Key::Character('v'), cmd)),
867        Action::SelectAll => Some(KeyChord::new(Key::Character('a'), cmd)),
868        Action::Undo => Some(KeyChord::new(Key::Character('z'), cmd)),
869        Action::Redo => Some(KeyChord::new(
870            Key::Character('z'),
871            Modifiers {
872                command: true,
873                shift: true,
874                ctrl: !cfg!(target_os = "macos"),
875                ..Modifiers::default()
876            },
877        )),
878        Action::Find => Some(KeyChord::new(Key::Character('f'), cmd)),
879        Action::Save => Some(KeyChord::new(Key::Character('s'), cmd)),
880        Action::FocusNext => Some(KeyChord::new(Key::Tab, Modifiers::default())),
881        Action::FocusPrevious => Some(KeyChord::new(
882            Key::Tab,
883            Modifiers {
884                shift: true,
885                ..Modifiers::default()
886            },
887        )),
888        Action::FocusLeft => Some(KeyChord::new(Key::ArrowLeft, Modifiers::default())),
889        Action::FocusRight => Some(KeyChord::new(Key::ArrowRight, Modifiers::default())),
890        Action::FocusUp => Some(KeyChord::new(Key::ArrowUp, Modifiers::default())),
891        Action::FocusDown => Some(KeyChord::new(Key::ArrowDown, Modifiers::default())),
892        _ => None,
893    }
894}
895
896pub fn default_map() -> ShortcutMap {
897    let mut map = ShortcutMap::new();
898    let actions = vec![
899        Action::Copy,
900        Action::Cut,
901        Action::Paste,
902        Action::SelectAll,
903        Action::Undo,
904        Action::Redo,
905        Action::Find,
906        Action::Save,
907        Action::FocusNext,
908        Action::FocusPrevious,
909        Action::FocusLeft,
910        Action::FocusRight,
911        Action::FocusUp,
912        Action::FocusDown,
913    ];
914    for action in actions {
915        if let Some(chord) = default_chord_for(&action) {
916            map.insert(chord.key, chord.modifiers, action);
917        }
918    }
919    map
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    #[test]
927    fn resolve_action_prefers_scopes() {
928        let mut map = ShortcutMap::new();
929        map.insert(
930            Key::Character('k'),
931            Modifiers::default(),
932            Action::Custom("one".into()),
933        );
934        set_default_map(map);
935
936        let mut scope = ShortcutMap::new();
937        scope.insert(
938            Key::Character('k'),
939            Modifiers::default(),
940            Action::Custom("two".into()),
941        );
942
943        SCOPES.with(|scopes| scopes.borrow_mut().push(scope));
944
945        let chord = KeyChord::new(Key::Character('k'), Modifiers::default());
946        assert_eq!(
947            resolve_action(chord.clone()),
948            Some(Action::Custom("two".into()))
949        );
950
951        SCOPES.with(|scopes| scopes.borrow_mut().pop());
952        assert_eq!(resolve_action(chord), Some(Action::Custom("one".into())));
953    }
954
955    #[test]
956    fn runtime_installers_are_isolated() {
957        let a = ShortcutState::new();
958        let b = ShortcutState::new();
959        let chord = KeyChord::new(Key::Character('j'), Modifiers::default());
960        let mut map_a = ShortcutMap::new();
961        map_a.insert(
962            chord.key.clone(),
963            chord.modifiers,
964            Action::Custom("a".into()),
965        );
966        let mut map_b = ShortcutMap::new();
967        map_b.insert(
968            chord.key.clone(),
969            chord.modifiers,
970            Action::Custom("b".into()),
971        );
972        let dispose_a = with_runtime_state(&a, || InstallShortcutMapWithKey("same-install", map_a));
973        let dispose_b = with_runtime_state(&b, || InstallShortcutMapWithKey("same-install", map_b));
974        assert_eq!(a.resolve_action(&chord), Some(Action::Custom("a".into())));
975        assert_eq!(b.resolve_action(&chord), Some(Action::Custom("b".into())));
976        dispose_a.run();
977        assert!(a.resolve_action(&chord).is_none());
978        assert_eq!(b.resolve_action(&chord), Some(Action::Custom("b".into())));
979        dispose_b.run();
980    }
981
982    #[test]
983    fn scoped_shortcut_effects_are_isolated_per_runtime() {
984        use crate::{effects::Dispose, runtime::ComposeGuard, scope::Scope};
985        use std::cell::Cell;
986
987        fn compose(state: &ShortcutState, action: &'static str, fired: &Rc<Cell<usize>>) -> Scope {
988            let scope = Scope::new();
989            let state = state.clone();
990            let fired = fired.clone();
991            scope.run(|| {
992                with_runtime_state(&state, || {
993                    crate::scope::scoped_effect_once(move || {
994                        let mut map = ShortcutMap::new();
995                        map.insert(
996                            Key::Character('j'),
997                            Modifiers::default(),
998                            Action::Custom(action.into()),
999                        );
1000                        let map_disposer = InstallShortcutMapWithKey("shared-scoped", map);
1001                        let handler_disposer = InstallShortcutHandlerWithKey(
1002                            "shared-scoped",
1003                            Rc::new(move |_action| {
1004                                fired.set(fired.get() + 1);
1005                                true
1006                            }),
1007                        );
1008                        Dispose::new(move || {
1009                            map_disposer.run();
1010                            handler_disposer.run();
1011                        })
1012                    });
1013                });
1014            });
1015            scope
1016        }
1017
1018        let a = ShortcutState::new();
1019        let b = ShortcutState::new();
1020        let fired_a = Rc::new(Cell::new(0));
1021        let fired_b = Rc::new(Cell::new(0));
1022        // One compose pass, as a real frame: a keyed slot that no longer gets
1023        // read is collected at the end of the pass, so composing the two
1024        // runtimes in separate passes would dispose the first one's effect.
1025        let (scope_a, scope_b) = {
1026            let _guard = ComposeGuard::begin();
1027            (compose(&a, "a", &fired_a), compose(&b, "b", &fired_b))
1028        };
1029        let chord = KeyChord::new(Key::Character('j'), Modifiers::default());
1030        let action_a = a.resolve_action(&chord).unwrap();
1031        let action_b = b.resolve_action(&chord).unwrap();
1032        assert!(a.handle(action_a));
1033        assert!(b.handle(action_b));
1034        assert_eq!(fired_a.get(), 1);
1035        assert_eq!(fired_b.get(), 1);
1036        scope_a.dispose();
1037        scope_b.dispose();
1038    }
1039
1040    #[test]
1041    fn keyed_map_updates_and_cleans_up() {
1042        set_default_map(ShortcutMap::new());
1043        let chord = KeyChord::new(Key::Character('m'), Modifiers::default());
1044        let mut first = ShortcutMap::new();
1045        first.insert(
1046            chord.key.clone(),
1047            chord.modifiers,
1048            Action::Custom("first".into()),
1049        );
1050        let mut second = ShortcutMap::new();
1051        second.insert(
1052            chord.key.clone(),
1053            chord.modifiers,
1054            Action::Custom("second".into()),
1055        );
1056        let key = "shortcut-test-map-update";
1057        let disposer = install_shortcut_map_with_key(key, first);
1058        assert_eq!(
1059            resolve_action(chord.clone()),
1060            Some(Action::Custom("first".into()))
1061        );
1062        let _ = install_shortcut_map_with_key(key, second);
1063        assert_eq!(
1064            resolve_action(chord.clone()),
1065            Some(Action::Custom("second".into()))
1066        );
1067        disposer.run();
1068        assert_eq!(resolve_action(chord), None);
1069    }
1070
1071    #[test]
1072    fn handler_restores_base_after_keyed_cleanup() {
1073        let base = Rc::new(|_action: Action| false);
1074        let installed = Rc::new(|_action: Action| true);
1075        set(Some(base));
1076        let key = "shortcut-test-handler-restore";
1077        let disposer = install_shortcut_handler_with_key(key, installed);
1078        assert!(handle(Action::Copy));
1079        disposer.run();
1080        assert!(!handle(Action::Copy));
1081        set(None);
1082    }
1083}