Skip to main content

repose_core/
shortcuts.rs

1use crate::Vec2;
2use crate::effects::{Dispose, on_unmount};
3use crate::input::{Key, Modifiers, PointerKind};
4use std::cell::RefCell;
5use std::rc::Rc;
6
7#[derive(Clone, Debug, PartialEq)]
8pub enum Gesture {
9    SwipeLeft,
10    SwipeRight,
11    Pinch {
12        delta_scale: f32,
13    },
14    PinchWithCenter {
15        delta_scale: f32,
16        center: Vec2,
17    },
18    /// 2/3-finger pan (centroid translation). `delta` is in physical px,
19    /// positive = content moves right/down (natural scrolling).
20    Pan {
21        delta: Vec2,
22    },
23}
24
25/// Low-level drag-and-drop actions dispatched by the platform.
26/// The framework handles gesture detection (mouse drag vs touch long press)
27/// and the DnD state machine internally.
28#[derive(Clone, Debug, PartialEq)]
29pub enum DragAction {
30    /// Pointer button pressed (mouse down / touch start).
31    Press {
32        position: Vec2,
33        capture_id: u64,
34        kind: PointerKind,
35        modifiers: Modifiers,
36    },
37    /// Pointer moved while button is pressed or touch is active.
38    Move {
39        position: Vec2,
40        modifiers: Modifiers,
41    },
42    /// Pointer released.
43    Release {
44        position: Vec2,
45        modifiers: Modifiers,
46    },
47    /// Drag cancelled (e.g. Escape key).
48    Cancel,
49}
50
51#[derive(Clone, Debug, PartialEq)]
52pub enum Action {
53    Copy,
54    Cut,
55    Paste,
56    SelectAll,
57    Undo,
58    Redo,
59
60    Back,
61    Find,
62    Save,
63
64    FocusNext,
65    FocusPrevious,
66    FocusLeft,
67    FocusRight,
68    FocusUp,
69    FocusDown,
70
71    Gesture(Gesture),
72    Drag(DragAction),
73    Custom(Rc<str>),
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct KeyChord {
78    pub key: Key,
79    pub modifiers: Modifiers,
80}
81
82impl KeyChord {
83    pub fn new(key: Key, modifiers: Modifiers) -> Self {
84        Self { key, modifiers }
85    }
86}
87
88#[derive(Clone, Debug)]
89pub struct ShortcutBinding {
90    pub chord: KeyChord,
91    pub action: Action,
92}
93
94#[derive(Clone, Debug, Default)]
95pub struct ShortcutMap {
96    pub bindings: Vec<ShortcutBinding>,
97}
98
99impl ShortcutMap {
100    pub fn new() -> Self {
101        Self {
102            bindings: Vec::new(),
103        }
104    }
105
106    pub fn bind(mut self, key: Key, modifiers: Modifiers, action: Action) -> Self {
107        self.bindings.push(ShortcutBinding {
108            chord: KeyChord::new(key, modifiers),
109            action,
110        });
111        self
112    }
113
114    pub fn bind_action(mut self, action: Action) -> Self {
115        if let Some(chord) = default_chord_for(&action) {
116            self.bindings.push(ShortcutBinding { chord, action });
117        }
118        self
119    }
120
121    pub fn merge(mut self, other: ShortcutMap) -> Self {
122        self.bindings.extend(other.bindings);
123        self
124    }
125
126    pub fn insert(&mut self, key: Key, modifiers: Modifiers, action: Action) {
127        self.bindings.push(ShortcutBinding {
128            chord: KeyChord::new(key, modifiers),
129            action,
130        });
131    }
132
133    pub fn action_for(&self, chord: &KeyChord) -> Option<Action> {
134        self.bindings
135            .iter()
136            .rev()
137            .find(|binding| &binding.chord == chord)
138            .map(|binding| binding.action.clone())
139    }
140}
141
142pub type Handler = Rc<dyn Fn(Action) -> bool>;
143
144thread_local! {
145    static HANDLER: RefCell<Option<Handler>> = RefCell::new(None);
146    static DEFAULT_MAP: RefCell<ShortcutMap> = RefCell::new(default_map());
147    static SCOPES: RefCell<Vec<ShortcutMap>> = const { RefCell::new(Vec::new()) };
148}
149
150/// Set/clear the global handler (prefer InstallShortcutHandler + scoped_effect).
151pub fn set(handler: Option<Handler>) {
152    HANDLER.with(|h| *h.borrow_mut() = handler);
153}
154
155/// Dispatch an action to the global handler. Returns true if consumed.
156pub fn handle(action: Action) -> bool {
157    HANDLER.with(|h| h.borrow().as_ref().map(|f| f(action)).unwrap_or(false))
158}
159
160/// Resolve a key chord to an action using scoped + default maps.
161pub fn resolve_action(chord: KeyChord) -> Option<Action> {
162    if chord.key == Key::Unknown {
163        return None;
164    }
165
166    if let Some(action) = SCOPES.with(|scopes| {
167        scopes
168            .borrow()
169            .iter()
170            .rev()
171            .find_map(|scope| scope.action_for(&chord))
172    }) {
173        return Some(action);
174    }
175
176    DEFAULT_MAP.with(|m| m.borrow().action_for(&chord))
177}
178
179/// Replace the default shortcut map used by resolve_action.
180pub fn set_default_map(map: ShortcutMap) {
181    DEFAULT_MAP.with(|m| *m.borrow_mut() = map);
182}
183
184/// Push a shortcut map for the current scope, popped on unmount.
185#[allow(non_snake_case)]
186pub fn InstallShortcutMap(map: ShortcutMap) -> Dispose {
187    SCOPES.with(|scopes| scopes.borrow_mut().push(map));
188    on_unmount(|| {
189        let _ = SCOPES.try_with(|scopes| {
190            scopes.borrow_mut().pop();
191        });
192    })
193}
194
195/// Install/uninstall a global shortcut handler for the current scope.
196/// Restores the previous handler on unmount (supports nesting).
197#[allow(non_snake_case)]
198pub fn InstallShortcutHandler(handler: Handler) -> Dispose {
199    let prev = HANDLER.with(|h| h.borrow_mut().replace(handler));
200    on_unmount(move || {
201        let _ = HANDLER.try_with(|h| *h.borrow_mut() = prev);
202    })
203}
204
205pub fn default_chord_for(action: &Action) -> Option<KeyChord> {
206    // On non-macOS, sets ctrl true
207    let cmd = Modifiers {
208        command: true,
209        ctrl: !cfg!(target_os = "macos"),
210        ..Modifiers::default()
211    };
212    match action {
213        Action::Copy => Some(KeyChord::new(Key::Character('c'), cmd)),
214        Action::Cut => Some(KeyChord::new(Key::Character('x'), cmd)),
215        Action::Paste => Some(KeyChord::new(Key::Character('v'), cmd)),
216        Action::SelectAll => Some(KeyChord::new(Key::Character('a'), cmd)),
217        Action::Undo => Some(KeyChord::new(Key::Character('z'), cmd)),
218        Action::Redo => Some(KeyChord::new(
219            Key::Character('z'),
220            Modifiers {
221                command: true,
222                shift: true,
223                ctrl: !cfg!(target_os = "macos"),
224                ..Modifiers::default()
225            },
226        )),
227        Action::Find => Some(KeyChord::new(Key::Character('f'), cmd)),
228        Action::Save => Some(KeyChord::new(Key::Character('s'), cmd)),
229        Action::FocusNext => Some(KeyChord::new(Key::Tab, Modifiers::default())),
230        Action::FocusPrevious => Some(KeyChord::new(
231            Key::Tab,
232            Modifiers {
233                shift: true,
234                ..Modifiers::default()
235            },
236        )),
237        Action::FocusLeft => Some(KeyChord::new(Key::ArrowLeft, Modifiers::default())),
238        Action::FocusRight => Some(KeyChord::new(Key::ArrowRight, Modifiers::default())),
239        Action::FocusUp => Some(KeyChord::new(Key::ArrowUp, Modifiers::default())),
240        Action::FocusDown => Some(KeyChord::new(Key::ArrowDown, Modifiers::default())),
241        _ => None,
242    }
243}
244
245pub fn default_map() -> ShortcutMap {
246    let mut map = ShortcutMap::new();
247    let actions = vec![
248        Action::Copy,
249        Action::Cut,
250        Action::Paste,
251        Action::SelectAll,
252        Action::Undo,
253        Action::Redo,
254        Action::Find,
255        Action::Save,
256        Action::FocusNext,
257        Action::FocusPrevious,
258        Action::FocusLeft,
259        Action::FocusRight,
260        Action::FocusUp,
261        Action::FocusDown,
262    ];
263    for action in actions {
264        if let Some(chord) = default_chord_for(&action) {
265            map.insert(chord.key, chord.modifiers, action);
266        }
267    }
268    map
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn resolve_action_prefers_scopes() {
277        let mut map = ShortcutMap::new();
278        map.insert(
279            Key::Character('k'),
280            Modifiers::default(),
281            Action::Custom("one".into()),
282        );
283        set_default_map(map);
284
285        let mut scope = ShortcutMap::new();
286        scope.insert(
287            Key::Character('k'),
288            Modifiers::default(),
289            Action::Custom("two".into()),
290        );
291
292        SCOPES.with(|scopes| scopes.borrow_mut().push(scope));
293
294        let chord = KeyChord::new(Key::Character('k'), Modifiers::default());
295        assert_eq!(
296            resolve_action(chord.clone()),
297            Some(Action::Custom("two".into()))
298        );
299
300        SCOPES.with(|scopes| scopes.borrow_mut().pop());
301        assert_eq!(resolve_action(chord), Some(Action::Custom("one".into())));
302    }
303}