Skip to main content

repose_core/
shortcuts.rs

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