1use std::collections::HashMap;
2
3use crate::config::Config;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Action {
11 NextSlide,
13 PreviousSlide,
15 NextOverlay,
17 PreviousOverlay,
19 FirstSlide,
21 LastSlide,
23 GoToSlide,
25 ToggleFreeze,
27 ToggleBlackout,
29 ToggleWhiteboard,
31 ToggleLaser,
33 CycleLaserStyle,
35 ToggleInk,
37 ClearInk,
39 CycleInkColor,
41 CycleInkWidth,
43 ToggleSpotlight,
45 ToggleZoom,
47 ToggleOverview,
49 ToggleNotes,
51 ToggleNotesEdit,
53 StartPauseTimer,
55 ResetTimer,
57 IncrementNotesFont,
59 DecrementNotesFont,
61 ToggleScreenShare,
63 TogglePresentationMode,
65 SwapDisplays,
67 ToggleTextBoxMode,
69 Quit,
71 SaveSidecar,
73}
74
75impl Action {
76 pub fn config_name(self) -> &'static str {
78 match self {
79 Self::NextSlide => "next_slide",
80 Self::PreviousSlide => "previous_slide",
81 Self::NextOverlay => "next_overlay",
82 Self::PreviousOverlay => "previous_overlay",
83 Self::FirstSlide => "first_slide",
84 Self::LastSlide => "last_slide",
85 Self::GoToSlide => "go_to_slide",
86 Self::ToggleFreeze => "toggle_freeze",
87 Self::ToggleBlackout => "toggle_blackout",
88 Self::ToggleWhiteboard => "toggle_whiteboard",
89 Self::ToggleLaser => "toggle_laser",
90 Self::CycleLaserStyle => "cycle_laser_style",
91 Self::ToggleInk => "toggle_ink",
92 Self::ClearInk => "clear_ink",
93 Self::CycleInkColor => "cycle_ink_color",
94 Self::CycleInkWidth => "cycle_ink_width",
95 Self::ToggleSpotlight => "toggle_spotlight",
96 Self::ToggleZoom => "toggle_zoom",
97 Self::ToggleOverview => "toggle_overview",
98 Self::ToggleNotes => "toggle_notes",
99 Self::ToggleNotesEdit => "toggle_notes_edit",
100 Self::StartPauseTimer => "start_pause_timer",
101 Self::ResetTimer => "reset_timer",
102 Self::IncrementNotesFont => "increment_notes_font",
103 Self::DecrementNotesFont => "decrement_notes_font",
104 Self::ToggleScreenShare => "toggle_screen_share",
105 Self::TogglePresentationMode => "toggle_presentation_mode",
106 Self::SwapDisplays => "swap_displays",
107 Self::ToggleTextBoxMode => "toggle_text_box_mode",
108 Self::Quit => "quit",
109 Self::SaveSidecar => "save_sidecar",
110 }
111 }
112
113 pub fn from_config_name(name: &str) -> Option<Self> {
115 Self::all().iter().copied().find(|action| action.config_name() == name)
116 }
117
118 pub fn description(self) -> &'static str {
120 match self {
121 Self::NextSlide => "Next slide/build step",
122 Self::PreviousSlide => "Previous slide/build step",
123 Self::NextOverlay => "Next overlay step",
124 Self::PreviousOverlay => "Previous overlay step",
125 Self::FirstSlide => "First slide",
126 Self::LastSlide => "Last slide",
127 Self::GoToSlide => "Go to slide…",
128 Self::ToggleFreeze => "Freeze audience",
129 Self::ToggleBlackout => "Black out audience",
130 Self::ToggleWhiteboard => "Whiteboard",
131 Self::ToggleLaser => "Laser pointer",
132 Self::CycleLaserStyle => "Cycle laser style",
133 Self::ToggleInk => "Drawing mode",
134 Self::ClearInk => "Clear ink",
135 Self::CycleInkColor => "Cycle pen color",
136 Self::CycleInkWidth => "Cycle pen width",
137 Self::ToggleSpotlight => "Spotlight",
138 Self::ToggleZoom => "Zoom mode",
139 Self::ToggleOverview => "Slide overview",
140 Self::ToggleNotes => "Toggle notes",
141 Self::ToggleNotesEdit => "Edit notes",
142 Self::StartPauseTimer => "Start / pause timer",
143 Self::ResetTimer => "Reset timer",
144 Self::IncrementNotesFont => "Increase notes font",
145 Self::DecrementNotesFont => "Decrease notes font",
146 Self::ToggleScreenShare => "Screen-share mode",
147 Self::TogglePresentationMode => "Presentation mode",
148 Self::SwapDisplays => "Swap displays",
149 Self::ToggleTextBoxMode => "Text box mode",
150 Self::Quit => "Quit",
151 Self::SaveSidecar => "Save sidecar",
152 }
153 }
154
155 pub fn group(self) -> &'static str {
157 match self {
158 Self::NextSlide
159 | Self::PreviousSlide
160 | Self::NextOverlay
161 | Self::PreviousOverlay
162 | Self::FirstSlide
163 | Self::LastSlide
164 | Self::GoToSlide
165 | Self::ToggleOverview => "Navigation",
166
167 Self::ToggleFreeze
168 | Self::ToggleBlackout
169 | Self::ToggleWhiteboard
170 | Self::ToggleScreenShare
171 | Self::TogglePresentationMode
172 | Self::SwapDisplays => "Display",
173
174 Self::ToggleLaser
175 | Self::CycleLaserStyle
176 | Self::ToggleInk
177 | Self::ClearInk
178 | Self::CycleInkColor
179 | Self::CycleInkWidth
180 | Self::ToggleSpotlight
181 | Self::ToggleZoom
182 | Self::ToggleTextBoxMode => "Presenter Tools",
183
184 Self::StartPauseTimer | Self::ResetTimer => "Timer",
185
186 Self::ToggleNotes
187 | Self::ToggleNotesEdit
188 | Self::IncrementNotesFont
189 | Self::DecrementNotesFont => "Notes & Panels",
190
191 Self::Quit | Self::SaveSidecar => "System",
192 }
193 }
194
195 pub fn all() -> &'static [Action] {
197 &[
198 Self::NextSlide,
199 Self::PreviousSlide,
200 Self::NextOverlay,
201 Self::PreviousOverlay,
202 Self::FirstSlide,
203 Self::LastSlide,
204 Self::GoToSlide,
205 Self::ToggleOverview,
206 Self::ToggleFreeze,
207 Self::ToggleBlackout,
208 Self::ToggleWhiteboard,
209 Self::ToggleScreenShare,
210 Self::TogglePresentationMode,
211 Self::SwapDisplays,
212 Self::ToggleLaser,
213 Self::CycleLaserStyle,
214 Self::ToggleInk,
215 Self::ClearInk,
216 Self::CycleInkColor,
217 Self::CycleInkWidth,
218 Self::ToggleSpotlight,
219 Self::ToggleZoom,
220 Self::ToggleTextBoxMode,
221 Self::ToggleNotes,
222 Self::ToggleNotesEdit,
223 Self::IncrementNotesFont,
224 Self::DecrementNotesFont,
225 Self::StartPauseTimer,
226 Self::ResetTimer,
227 Self::Quit,
228 Self::SaveSidecar,
229 ]
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub struct KeyCombo {
236 pub key: String,
238 pub shift: bool,
240 pub ctrl: bool,
242 pub alt: bool,
244}
245
246impl KeyCombo {
247 pub fn parse(s: &str) -> Option<Self> {
249 let parts: Vec<&str> = s.split('+').collect();
250 let mut shift = false;
251 let mut ctrl = false;
252 let mut alt = false;
253 let mut key = None;
254
255 for part in &parts {
256 let normalized = part.trim();
257 match normalized.to_lowercase().as_str() {
258 "shift" => shift = true,
259 "ctrl" | "control" | "cmd" | "command" => ctrl = true,
260 "alt" | "option" => alt = true,
261 _ => key = Some(normalized.to_string()),
262 }
263 }
264
265 key.map(|key| Self { key: normalize_key_name(&key), shift, ctrl, alt })
266 }
267
268 pub fn display_name(&self) -> String {
270 let mut parts = Vec::new();
271 if self.ctrl {
272 parts.push("Ctrl");
273 }
274 if self.alt {
275 parts.push("Alt");
276 }
277 if self.shift {
278 parts.push("Shift");
279 }
280 let key_display: String =
282 if self.key.len() == 1 { self.key.to_uppercase() } else { self.key.clone() };
283 parts.push(&key_display);
284 parts.join("+")
285 }
286}
287
288fn normalize_key_name(key: &str) -> String {
289 if key.chars().count() == 1 { key.to_lowercase() } else { key.to_string() }
290}
291
292pub struct KeybindingMap {
294 bindings: HashMap<KeyCombo, Action>,
295}
296
297impl KeybindingMap {
298 pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
300 let mut bindings = HashMap::new();
301
302 for (action, keys) in default_keybindings() {
304 for key_str in keys {
305 if let Some(combo) = KeyCombo::parse(&key_str) {
306 bindings.insert(combo, action);
307 }
308 }
309 }
310
311 let action_lookup: HashMap<&str, Action> =
314 Action::all().iter().map(|a| (a.config_name(), *a)).collect();
315
316 for (action_name, keys) in user_bindings {
317 if let Some(&action) = action_lookup.get(action_name.as_str()) {
318 bindings.retain(|_, v| *v != action);
320 for key_str in keys {
322 if let Some(combo) = KeyCombo::parse(key_str) {
323 bindings.insert(combo, action);
324 }
325 }
326 } else {
327 tracing::warn!("Unknown action in keybinding config: {action_name}");
328 }
329 }
330
331 Self { bindings }
332 }
333
334 pub fn from_full_config(config: &Config) -> Self {
336 let mut map = Self::from_config(&config.keybindings);
337 map.apply_clicker_profile(&config.active_clicker_profile());
338 map
339 }
340
341 pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
343 self.bindings.get(combo).copied()
344 }
345
346 pub fn action_bindings(&self) -> Vec<(Action, Vec<String>)> {
351 Action::all()
352 .iter()
353 .map(|&action| {
354 let mut keys: Vec<String> = self
355 .bindings
356 .iter()
357 .filter(|&(_, &a)| a == action)
358 .map(|(combo, _)| combo.display_name())
359 .collect();
360 keys.sort();
361 (action, keys)
362 })
363 .collect()
364 }
365
366 fn apply_clicker_profile(&mut self, clicker_bindings: &HashMap<String, String>) {
367 let action_lookup: HashMap<&str, Action> =
368 Action::all().iter().map(|a| (a.config_name(), *a)).collect();
369
370 for (key_name, action_name) in clicker_bindings {
371 let Some(&action) = action_lookup.get(action_name.as_str()) else {
372 tracing::warn!("Unknown action in clicker profile: {action_name}");
373 continue;
374 };
375
376 if let Some(combo) = KeyCombo::parse(key_name) {
377 self.bindings.insert(combo, action);
378 } else {
379 tracing::warn!("Invalid key in clicker profile: {key_name}");
380 }
381 }
382 }
383}
384
385fn default_keybindings() -> Vec<(Action, Vec<String>)> {
387 vec![
388 (Action::NextSlide, vec!["Right".into(), "Space".into(), "Down".into(), "PageDown".into()]),
389 (Action::PreviousSlide, vec!["Left".into(), "Up".into(), "PageUp".into()]),
390 (Action::NextOverlay, vec!["Shift+Right".into(), "Shift+Down".into()]),
391 (Action::PreviousOverlay, vec!["Shift+Left".into(), "Shift+Up".into()]),
392 (Action::FirstSlide, vec!["Home".into()]),
393 (Action::LastSlide, vec!["End".into()]),
394 (Action::GoToSlide, vec!["g".into()]),
395 (Action::ToggleFreeze, vec!["f".into()]),
396 (Action::ToggleBlackout, vec!["b".into(), ".".into()]),
397 (Action::ToggleWhiteboard, vec!["w".into()]),
398 (Action::ToggleScreenShare, vec!["Shift+s".into()]),
399 (Action::TogglePresentationMode, vec!["F5".into()]),
400 (Action::SwapDisplays, vec!["F6".into()]),
401 (Action::ToggleLaser, vec!["l".into()]),
402 (Action::CycleLaserStyle, vec!["Ctrl+l".into()]),
403 (Action::ToggleInk, vec!["d".into()]),
404 (Action::ClearInk, vec!["c".into()]),
405 (Action::CycleInkColor, vec!["Ctrl+d".into()]),
406 (Action::CycleInkWidth, vec!["Shift+d".into()]),
407 (Action::ToggleSpotlight, vec!["s".into()]),
408 (Action::ToggleZoom, vec!["z".into()]),
409 (Action::ToggleTextBoxMode, vec!["x".into()]),
410 (Action::ToggleOverview, vec!["o".into()]),
411 (Action::ToggleNotes, vec!["n".into()]),
412 (Action::ToggleNotesEdit, vec!["Ctrl+n".into()]),
413 (Action::IncrementNotesFont, vec!["+".into(), "Shift+=".into()]),
414 (Action::DecrementNotesFont, vec!["-".into()]),
415 (Action::StartPauseTimer, vec!["t".into()]),
416 (Action::ResetTimer, vec!["Shift+t".into()]),
417 (Action::Quit, vec!["q".into(), "Escape".into()]),
418 (Action::SaveSidecar, vec!["Ctrl+s".into()]),
419 ]
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn parse_simple_key() {
428 let combo = KeyCombo::parse("Right").unwrap();
429 assert_eq!(combo.key, "Right");
430 assert!(!combo.shift);
431 assert!(!combo.ctrl);
432 }
433
434 #[test]
435 fn parse_shift_combo() {
436 let combo = KeyCombo::parse("Shift+Right").unwrap();
437 assert_eq!(combo.key, "Right");
438 assert!(combo.shift);
439 assert!(!combo.ctrl);
440 }
441
442 #[test]
443 fn parse_ctrl_combo() {
444 let combo = KeyCombo::parse("Ctrl+s").unwrap();
445 assert_eq!(combo.key, "s");
446 assert!(!combo.shift);
447 assert!(combo.ctrl);
448 }
449
450 #[test]
451 fn parse_single_character_keys_case_insensitively() {
452 let lower = KeyCombo::parse("Ctrl+l").unwrap();
453 let upper = KeyCombo::parse("Ctrl+L").unwrap();
454
455 assert_eq!(lower, upper);
456 assert_eq!(upper.key, "l");
457 }
458
459 #[test]
460 fn default_bindings_load() {
461 let map = KeybindingMap::from_config(&HashMap::new());
462 let right = KeyCombo::parse("Right").unwrap();
463 assert_eq!(map.lookup(&right), Some(Action::NextSlide));
464 }
465
466 #[test]
467 fn default_swap_display_binding_loads() {
468 let map = KeybindingMap::from_config(&HashMap::new());
469 let f6 = KeyCombo::parse("F6").unwrap();
470 assert_eq!(map.lookup(&f6), Some(Action::SwapDisplays));
471 }
472
473 #[test]
474 fn user_override_replaces_defaults() {
475 let mut user = HashMap::new();
476 user.insert("next_slide".to_string(), vec!["x".to_string()]);
477 let map = KeybindingMap::from_config(&user);
478
479 let x = KeyCombo::parse("x").unwrap();
481 assert_eq!(map.lookup(&x), Some(Action::NextSlide));
482
483 let right = KeyCombo::parse("Right").unwrap();
485 assert_ne!(map.lookup(&right), Some(Action::NextSlide));
486 }
487
488 #[test]
489 fn clicker_profile_overlays_bindings() {
490 let mut config = Config::default();
491 config.clicker.profile = "custom".to_string();
492 config.clicker.profiles.insert(
493 "custom".to_string(),
494 HashMap::from([("Escape".to_string(), "toggle_blackout".to_string())]),
495 );
496
497 let map = KeybindingMap::from_full_config(&config);
498 let escape = KeyCombo::parse("Escape").unwrap();
499 assert_eq!(map.lookup(&escape), Some(Action::ToggleBlackout));
500 }
501
502 #[test]
503 fn display_name_simple_key() {
504 let combo = KeyCombo::parse("Right").unwrap();
505 assert_eq!(combo.display_name(), "Right");
506 }
507
508 #[test]
509 fn display_name_modifier_combo() {
510 let combo = KeyCombo::parse("Ctrl+Shift+s").unwrap();
511 assert_eq!(combo.display_name(), "Ctrl+Shift+S");
512 }
513
514 #[test]
515 fn display_name_single_char_uppercase() {
516 let combo = KeyCombo::parse("g").unwrap();
517 assert_eq!(combo.display_name(), "G");
518 }
519
520 #[test]
521 fn action_bindings_returns_all_actions() {
522 let map = KeybindingMap::from_config(&HashMap::new());
523 let bindings = map.action_bindings();
524 assert_eq!(bindings.len(), Action::all().len());
525 }
526
527 #[test]
528 fn cycle_laser_style_accepts_uppercase_config_binding() {
529 let mut user = HashMap::new();
530 user.insert("cycle_laser_style".to_string(), vec!["Ctrl+L".to_string()]);
531 let map = KeybindingMap::from_config(&user);
532
533 let live_key = KeyCombo::parse("Ctrl+l").unwrap();
534 assert_eq!(map.lookup(&live_key), Some(Action::CycleLaserStyle));
535 }
536
537 #[test]
538 fn action_bindings_reflects_overrides() {
539 let mut user = HashMap::new();
540 user.insert("quit".to_string(), vec!["x".to_string()]);
541 let map = KeybindingMap::from_config(&user);
542 let bindings = map.action_bindings();
543
544 let quit_keys: Vec<String> =
545 bindings.iter().find(|(a, _)| *a == Action::Quit).unwrap().1.clone();
546 assert_eq!(quit_keys, vec!["X"]);
547 }
548
549 #[test]
550 fn action_order_keeps_groups_contiguous() {
551 let mut seen_groups = Vec::new();
552 let mut previous_group = "";
553
554 for action in Action::all() {
555 let group = action.group();
556 if group != previous_group {
557 assert!(!seen_groups.contains(&group), "{group} appears in multiple help sections");
558 seen_groups.push(group);
559 previous_group = group;
560 }
561 }
562 }
563
564 #[test]
565 fn every_action_has_description_and_group() {
566 for action in Action::all() {
567 assert!(!action.description().is_empty(), "{action:?} missing description");
568 assert!(!action.group().is_empty(), "{action:?} missing group");
569 }
570 }
571}