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