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