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