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 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#[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 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
174pub fn default_bindings() -> Vec<Binding> {
177 [
178 "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 "r=release_monitor",
192 "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
203pub 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 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 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 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}