1use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum KeyName {
12 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 Redo,
42 CycleOverlap,
45 TogglePanel,
47 NameSession,
49 RotateCcw,
51 RotateCw,
53 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#[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 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
168pub fn default_bindings() -> Vec<Binding> {
171 [
172 "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 "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
195pub 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 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 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 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}