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 ReleaseMonitor,
58 ToggleSnap,
61 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#[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 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
178pub fn default_bindings() -> Vec<Binding> {
181 [
182 "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 "r=release_monitor",
197 "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
208pub 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 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 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 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}