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