Skip to main content

pixelcoords_core/
hotkeys.rs

1//! Hotkey binding grammar: `KEY=ACTION[,EDGE][,WHEN]`.
2//!
3//! Ported from the predecessor's config grammar, minus Win32 virtual-key
4//! codes: keys are platform-neutral names the binary maps from its window
5//! system's key events. Parsing is strict — unknown actions, edges, or
6//! conditions are errors, not silently dropped.
7
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum KeyName {
12    /// A single printable character, stored uppercase.
13    Character(char),
14    Tab,
15    CapsLock,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum Edge {
20    #[default]
21    Press,
22    Release,
23    Repeat,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum When {
28    HasSelection,
29    CursorInShape,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Action {
34    Quit,
35    Save,
36    NextTool,
37    DeleteAtCursor,
38    LabelEditAtCursor,
39    Undo,
40    /// Re-apply the most recently undone edit.
41    Redo,
42    /// Send the topmost shape under the cursor to the bottom of the
43    /// stack, so overlapped shapes become reachable.
44    CycleOverlap,
45    /// Show or hide the control panel.
46    TogglePanel,
47    /// Open the session-name editor.
48    NameSession,
49    /// Rotate the shape under the cursor counterclockwise.
50    RotateCcw,
51    /// Rotate the shape under the cursor clockwise.
52    RotateCw,
53    /// Unfreeze the monitor under the cursor and close its overlay window,
54    /// leaving the others frozen. The only way to reach this: the overlay
55    /// windows are borderless and undecorated, so no close button exists
56    /// and `CloseRequested` never fires from a user action.
57    ReleaseMonitor,
58    /// Turn edge snapping on or off for the rest of the run. The config
59    /// owns the launch default; this does not persist.
60    ToggleSnap,
61    /// Accepted by the grammar for forward compatibility; snapshot mode has
62    /// no themes, so the binary treats it as a no-op.
63    NextTheme,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct Binding {
68    pub key: KeyName,
69    pub action: Action,
70    pub edge: Edge,
71    pub when: Option<When>,
72}
73
74/// Everything a binding condition can observe about the app.
75#[derive(Debug, Clone, Copy, Default)]
76pub struct OverlayState {
77    pub has_selection: bool,
78    pub cursor_in_shape: bool,
79}
80
81#[derive(Debug, Error, PartialEq, Eq)]
82pub enum HotkeyError {
83    #[error("binding '{0}' is not KEY=ACTION[,EDGE][,WHEN]")]
84    Malformed(String),
85    #[error("unknown key '{0}' (single character, 'tab', or 'capslock')")]
86    UnknownKey(String),
87    #[error("unknown action '{0}'")]
88    UnknownAction(String),
89    #[error("unknown edge '{0}' (press, release, or repeat)")]
90    UnknownEdge(String),
91    #[error("unknown condition '{0}' (has_selection or cursor_in)")]
92    UnknownWhen(String),
93}
94
95pub fn parse_key(s: &str) -> Result<KeyName, HotkeyError> {
96    let t = s.trim();
97    match t.to_ascii_lowercase().as_str() {
98        "tab" => Ok(KeyName::Tab),
99        "capslock" | "caps_lock" | "caps" => Ok(KeyName::CapsLock),
100        _ => {
101            let mut chars = t.chars();
102            match (chars.next(), chars.next()) {
103                (Some(c), None) if !c.is_whitespace() => {
104                    Ok(KeyName::Character(c.to_ascii_uppercase()))
105                }
106                _ => Err(HotkeyError::UnknownKey(t.to_string())),
107            }
108        }
109    }
110}
111
112pub fn parse_action(s: &str) -> Result<Action, HotkeyError> {
113    match s.trim().to_ascii_lowercase().as_str() {
114        "quit" => Ok(Action::Quit),
115        "save" => Ok(Action::Save),
116        "next_tool" => Ok(Action::NextTool),
117        "delete_at_cursor" | "delete_selection_at_cursor" => Ok(Action::DeleteAtCursor),
118        "label_edit_at_cursor" => Ok(Action::LabelEditAtCursor),
119        "undo" => Ok(Action::Undo),
120        "redo" => Ok(Action::Redo),
121        "cycle_overlap" => Ok(Action::CycleOverlap),
122        "toggle_panel" => Ok(Action::TogglePanel),
123        "name_session" => Ok(Action::NameSession),
124        "rotate_ccw" => Ok(Action::RotateCcw),
125        "rotate_cw" => Ok(Action::RotateCw),
126        "release_monitor" => Ok(Action::ReleaseMonitor),
127        "toggle_snap" => Ok(Action::ToggleSnap),
128        "next_theme" => Ok(Action::NextTheme),
129        other => Err(HotkeyError::UnknownAction(other.to_string())),
130    }
131}
132
133impl Binding {
134    /// Parse one `KEY=ACTION[,EDGE][,WHEN]` spec. EDGE and WHEN may appear
135    /// in either order, matching the predecessor's CLI.
136    pub fn parse(spec: &str) -> Result<Self, HotkeyError> {
137        let (key_part, rest) = spec
138            .split_once('=')
139            .ok_or_else(|| HotkeyError::Malformed(spec.to_string()))?;
140        let mut parts = rest.split(',');
141        let action_part = parts.next().unwrap_or_default();
142        if action_part.trim().is_empty() {
143            return Err(HotkeyError::Malformed(spec.to_string()));
144        }
145        let key = parse_key(key_part)?;
146        let action = parse_action(action_part)?;
147        let mut edge = Edge::default();
148        let mut when = None;
149        for part in parts {
150            let t = part.trim().to_ascii_lowercase();
151            match t.as_str() {
152                "press" => edge = Edge::Press,
153                "release" => edge = Edge::Release,
154                "repeat" => edge = Edge::Repeat,
155                "has_selection" => when = Some(When::HasSelection),
156                "cursor_in" => when = Some(When::CursorInShape),
157                "hold" | "down" | "up" => return Err(HotkeyError::UnknownEdge(t)),
158                _ => return Err(HotkeyError::UnknownWhen(t)),
159            }
160        }
161        Ok(Self {
162            key,
163            action,
164            edge,
165            when,
166        })
167    }
168
169    const fn condition_met(self, state: OverlayState) -> bool {
170        match self.when {
171            None => true,
172            Some(When::HasSelection) => state.has_selection,
173            Some(When::CursorInShape) => state.cursor_in_shape,
174        }
175    }
176}
177
178/// Default bindings; user config and CLI `--bind` entries are appended after
179/// these, and the *last* matching binding wins, so later sources override.
180pub fn default_bindings() -> Vec<Binding> {
181    [
182        // The left hand covers everything, game-cluster style: QE turn,
183        // WASD does the rest, Z undoes. Quit lives on Esc in the app, so
184        // no letter is spent on it.
185        "w=next_tool",
186        "tab=next_tool",
187        "a=label_edit_at_cursor,release,cursor_in",
188        "s=save,has_selection",
189        "d=delete_at_cursor,press,cursor_in",
190        "z=undo",
191        "c=cycle_overlap,press,cursor_in",
192        "h=toggle_panel",
193        "n=name_session",
194        "x=toggle_snap",
195        // The only trigger for releasing one display; see Action::ReleaseMonitor.
196        "r=release_monitor",
197        // Rotation binds press AND repeat so holding the key keeps turning.
198        "q=rotate_ccw,press,cursor_in",
199        "q=rotate_ccw,repeat,cursor_in",
200        "e=rotate_cw,press,cursor_in",
201        "e=rotate_cw,repeat,cursor_in",
202    ]
203    .into_iter()
204    .map(|s| Binding::parse(s).expect("default bindings are valid"))
205    .collect()
206}
207
208/// Resolve a key event against the binding list. Later bindings shadow
209/// earlier ones for the same key + edge; a shadowing binding whose condition
210/// fails suppresses the shadowed one rather than falling through.
211pub fn match_event(
212    bindings: &[Binding],
213    key: KeyName,
214    edge: Edge,
215    state: OverlayState,
216) -> Option<Action> {
217    bindings
218        .iter()
219        .rev()
220        .find(|b| b.key == key && b.edge == edge)
221        .filter(|b| b.condition_met(state))
222        .map(|b| b.action)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parses_full_form() {
231        let b = Binding::parse("E=label_edit_at_cursor,release,cursor_in").unwrap();
232        assert_eq!(b.key, KeyName::Character('E'));
233        assert_eq!(b.action, Action::LabelEditAtCursor);
234        assert_eq!(b.edge, Edge::Release);
235        assert_eq!(b.when, Some(When::CursorInShape));
236    }
237
238    #[test]
239    fn edge_defaults_to_press() {
240        let b = Binding::parse("q=quit").unwrap();
241        assert_eq!(b.edge, Edge::Press);
242        assert_eq!(b.when, None);
243    }
244
245    #[test]
246    fn edge_and_when_order_is_flexible() {
247        let a = Binding::parse("w=save,has_selection,release").unwrap();
248        let b = Binding::parse("w=save,release,has_selection").unwrap();
249        assert_eq!(a, b);
250    }
251
252    #[test]
253    fn key_is_case_insensitive_and_uppercased() {
254        assert_eq!(parse_key("q").unwrap(), KeyName::Character('Q'));
255        assert_eq!(parse_key("Q").unwrap(), KeyName::Character('Q'));
256        assert_eq!(parse_key(" TAB ").unwrap(), KeyName::Tab);
257        assert_eq!(parse_key("caps_lock").unwrap(), KeyName::CapsLock);
258    }
259
260    #[test]
261    fn rejects_unknown_pieces() {
262        assert_eq!(
263            Binding::parse("qq=quit").unwrap_err(),
264            HotkeyError::UnknownKey("qq".into())
265        );
266        assert_eq!(
267            Binding::parse("q=fly").unwrap_err(),
268            HotkeyError::UnknownAction("fly".into())
269        );
270        assert_eq!(
271            Binding::parse("q=quit,hold").unwrap_err(),
272            HotkeyError::UnknownEdge("hold".into())
273        );
274        assert_eq!(
275            Binding::parse("q=quit,when_happy").unwrap_err(),
276            HotkeyError::UnknownWhen("when_happy".into())
277        );
278        assert_eq!(
279            Binding::parse("just_a_key").unwrap_err(),
280            HotkeyError::Malformed("just_a_key".into())
281        );
282        assert_eq!(
283            Binding::parse("q=").unwrap_err(),
284            HotkeyError::Malformed("q=".into())
285        );
286    }
287
288    #[test]
289    fn legacy_action_alias_accepted() {
290        assert_eq!(
291            parse_action("delete_selection_at_cursor").unwrap(),
292            Action::DeleteAtCursor
293        );
294    }
295
296    #[test]
297    fn match_requires_edge() {
298        let bindings = default_bindings();
299        let state = OverlayState::default();
300        assert_eq!(
301            match_event(&bindings, KeyName::Character('Z'), Edge::Press, state),
302            Some(Action::Undo)
303        );
304        assert_eq!(
305            match_event(&bindings, KeyName::Character('Z'), Edge::Release, state),
306            None
307        );
308    }
309
310    #[test]
311    fn match_gates_on_conditions() {
312        let bindings = default_bindings();
313        let none = OverlayState::default();
314        assert_eq!(
315            match_event(&bindings, KeyName::Character('S'), Edge::Press, none),
316            None
317        );
318        assert_eq!(
319            match_event(
320                &bindings,
321                KeyName::Character('S'),
322                Edge::Press,
323                OverlayState {
324                    has_selection: true,
325                    ..none
326                }
327            ),
328            Some(Action::Save)
329        );
330        assert_eq!(
331            match_event(&bindings, KeyName::Character('D'), Edge::Press, none),
332            None
333        );
334        assert_eq!(
335            match_event(
336                &bindings,
337                KeyName::Character('D'),
338                Edge::Press,
339                OverlayState {
340                    cursor_in_shape: true,
341                    ..none
342                }
343            ),
344            Some(Action::DeleteAtCursor)
345        );
346    }
347
348    #[test]
349    fn later_binding_shadows_earlier() {
350        let mut bindings = default_bindings();
351        bindings.push(Binding::parse("q=undo").unwrap());
352        assert_eq!(
353            match_event(
354                &bindings,
355                KeyName::Character('Q'),
356                Edge::Press,
357                OverlayState::default()
358            ),
359            Some(Action::Undo)
360        );
361    }
362
363    #[test]
364    fn shadowing_binding_with_failed_condition_suppresses() {
365        let mut bindings = default_bindings();
366        bindings.push(Binding::parse("q=save,has_selection").unwrap());
367        // The rebind of Q is conditional and the condition fails: Q does
368        // nothing rather than falling back to quit.
369        assert_eq!(
370            match_event(
371                &bindings,
372                KeyName::Character('Q'),
373                Edge::Press,
374                OverlayState::default()
375            ),
376            None
377        );
378    }
379
380    #[test]
381    fn rotation_defaults_fire_on_press_and_repeat() {
382        let bindings = default_bindings();
383        let state = OverlayState {
384            cursor_in_shape: true,
385            ..OverlayState::default()
386        };
387        for edge in [Edge::Press, Edge::Repeat] {
388            assert_eq!(
389                match_event(&bindings, KeyName::Character('Q'), edge, state),
390                Some(Action::RotateCcw)
391            );
392            assert_eq!(
393                match_event(&bindings, KeyName::Character('E'), edge, state),
394                Some(Action::RotateCw)
395            );
396        }
397        // Not over a shape: no rotation.
398        assert_eq!(
399            match_event(
400                &bindings,
401                KeyName::Character('Q'),
402                Edge::Press,
403                OverlayState::default()
404            ),
405            None
406        );
407    }
408
409    #[test]
410    fn defaults_cover_expected_keys() {
411        let bindings = default_bindings();
412        assert_eq!(bindings.len(), 15);
413        assert_eq!(
414            match_event(
415                &bindings,
416                KeyName::Character('R'),
417                Edge::Press,
418                OverlayState::default()
419            ),
420            Some(Action::ReleaseMonitor),
421            "R releases a monitor — the only trigger, since undecorated \
422             overlay windows have no close button"
423        );
424        // W and Tab both cycle the tool; Z undoes; quit is not in the
425        // table at all — it lives on Esc in the app.
426        for key in [KeyName::Character('W'), KeyName::Tab] {
427            assert_eq!(
428                match_event(&bindings, key, Edge::Press, OverlayState::default()),
429                Some(Action::NextTool)
430            );
431        }
432        assert_eq!(
433            match_event(
434                &bindings,
435                KeyName::Character('Z'),
436                Edge::Press,
437                OverlayState::default()
438            ),
439            Some(Action::Undo)
440        );
441        assert!(
442            !bindings.iter().any(|b| b.action == Action::Quit),
443            "quit is Esc's job, not a letter's"
444        );
445        assert_eq!(
446            match_event(
447                &bindings,
448                KeyName::Character('X'),
449                Edge::Press,
450                OverlayState::default()
451            ),
452            Some(Action::ToggleSnap),
453        );
454    }
455}