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",
114 Self::PreviousSlide => "Previous slide",
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 => "Navigation",
156
157 Self::ToggleFreeze
158 | Self::ToggleBlackout
159 | Self::ToggleWhiteboard
160 | Self::ToggleScreenShare
161 | Self::TogglePresentationMode => "Display",
162
163 Self::ToggleLaser
164 | Self::CycleLaserStyle
165 | Self::ToggleInk
166 | Self::ClearInk
167 | Self::CycleInkColor
168 | Self::CycleInkWidth
169 | Self::ToggleSpotlight
170 | Self::ToggleZoom
171 | Self::ToggleTextBoxMode => "Presenter Tools",
172
173 Self::StartPauseTimer | Self::ResetTimer => "Timer",
174
175 Self::ToggleOverview
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::ToggleFreeze,
196 Self::ToggleBlackout,
197 Self::ToggleWhiteboard,
198 Self::ToggleLaser,
199 Self::CycleLaserStyle,
200 Self::ToggleInk,
201 Self::ClearInk,
202 Self::CycleInkColor,
203 Self::CycleInkWidth,
204 Self::ToggleSpotlight,
205 Self::ToggleZoom,
206 Self::ToggleOverview,
207 Self::ToggleNotes,
208 Self::ToggleNotesEdit,
209 Self::StartPauseTimer,
210 Self::ResetTimer,
211 Self::IncrementNotesFont,
212 Self::DecrementNotesFont,
213 Self::ToggleScreenShare,
214 Self::TogglePresentationMode,
215 Self::ToggleTextBoxMode,
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, 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
277pub struct KeybindingMap {
279 bindings: HashMap<KeyCombo, Action>,
280}
281
282impl KeybindingMap {
283 pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
285 let mut bindings = HashMap::new();
286
287 for (action, keys) in default_keybindings() {
289 for key_str in keys {
290 if let Some(combo) = KeyCombo::parse(&key_str) {
291 bindings.insert(combo, action);
292 }
293 }
294 }
295
296 let action_lookup: HashMap<&str, Action> =
299 Action::all().iter().map(|a| (a.config_name(), *a)).collect();
300
301 for (action_name, keys) in user_bindings {
302 if let Some(&action) = action_lookup.get(action_name.as_str()) {
303 bindings.retain(|_, v| *v != action);
305 for key_str in keys {
307 if let Some(combo) = KeyCombo::parse(key_str) {
308 bindings.insert(combo, action);
309 }
310 }
311 } else {
312 tracing::warn!("Unknown action in keybinding config: {action_name}");
313 }
314 }
315
316 Self { bindings }
317 }
318
319 pub fn from_full_config(config: &Config) -> Self {
321 let mut map = Self::from_config(&config.keybindings);
322 map.apply_clicker_profile(&config.active_clicker_profile());
323 map
324 }
325
326 pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
328 self.bindings.get(combo).copied()
329 }
330
331 pub fn action_bindings(&self) -> Vec<(Action, Vec<String>)> {
336 Action::all()
337 .iter()
338 .map(|&action| {
339 let mut keys: Vec<String> = self
340 .bindings
341 .iter()
342 .filter(|&(_, &a)| a == action)
343 .map(|(combo, _)| combo.display_name())
344 .collect();
345 keys.sort();
346 (action, keys)
347 })
348 .collect()
349 }
350
351 fn apply_clicker_profile(&mut self, clicker_bindings: &HashMap<String, String>) {
352 let action_lookup: HashMap<&str, Action> =
353 Action::all().iter().map(|a| (a.config_name(), *a)).collect();
354
355 for (key_name, action_name) in clicker_bindings {
356 let Some(&action) = action_lookup.get(action_name.as_str()) else {
357 tracing::warn!("Unknown action in clicker profile: {action_name}");
358 continue;
359 };
360
361 if let Some(combo) = KeyCombo::parse(key_name) {
362 self.bindings.insert(combo, action);
363 } else {
364 tracing::warn!("Invalid key in clicker profile: {key_name}");
365 }
366 }
367 }
368}
369
370fn default_keybindings() -> Vec<(Action, Vec<String>)> {
372 vec![
373 (Action::NextSlide, vec!["Right".into(), "Space".into(), "Down".into(), "PageDown".into()]),
374 (Action::PreviousSlide, vec!["Left".into(), "Up".into(), "PageUp".into()]),
375 (Action::NextOverlay, vec!["Shift+Right".into(), "Shift+Down".into()]),
376 (Action::PreviousOverlay, vec!["Shift+Left".into(), "Shift+Up".into()]),
377 (Action::FirstSlide, vec!["Home".into()]),
378 (Action::LastSlide, vec!["End".into()]),
379 (Action::GoToSlide, vec!["g".into()]),
380 (Action::ToggleFreeze, vec!["f".into()]),
381 (Action::ToggleBlackout, vec!["b".into(), ".".into()]),
382 (Action::ToggleWhiteboard, vec!["w".into()]),
383 (Action::ToggleLaser, vec!["l".into()]),
384 (Action::CycleLaserStyle, vec!["Ctrl+l".into()]),
385 (Action::ToggleInk, vec!["d".into()]),
386 (Action::ClearInk, vec!["c".into()]),
387 (Action::CycleInkColor, vec!["Ctrl+d".into()]),
388 (Action::CycleInkWidth, vec!["Shift+d".into()]),
389 (Action::ToggleSpotlight, vec!["s".into()]),
390 (Action::ToggleZoom, vec!["z".into()]),
391 (Action::ToggleOverview, vec!["o".into()]),
392 (Action::ToggleNotes, vec!["n".into()]),
393 (Action::ToggleNotesEdit, vec!["Ctrl+n".into()]),
394 (Action::StartPauseTimer, vec!["t".into()]),
395 (Action::ResetTimer, vec!["Shift+t".into()]),
396 (Action::IncrementNotesFont, vec!["+".into(), "Shift+=".into()]),
397 (Action::DecrementNotesFont, vec!["-".into()]),
398 (Action::ToggleScreenShare, vec!["Shift+s".into()]),
399 (Action::TogglePresentationMode, vec!["F5".into()]),
400 (Action::ToggleTextBoxMode, vec!["x".into()]),
401 (Action::Quit, vec!["q".into(), "Escape".into()]),
402 (Action::SaveSidecar, vec!["Ctrl+s".into()]),
403 ]
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn parse_simple_key() {
412 let combo = KeyCombo::parse("Right").unwrap();
413 assert_eq!(combo.key, "Right");
414 assert!(!combo.shift);
415 assert!(!combo.ctrl);
416 }
417
418 #[test]
419 fn parse_shift_combo() {
420 let combo = KeyCombo::parse("Shift+Right").unwrap();
421 assert_eq!(combo.key, "Right");
422 assert!(combo.shift);
423 assert!(!combo.ctrl);
424 }
425
426 #[test]
427 fn parse_ctrl_combo() {
428 let combo = KeyCombo::parse("Ctrl+s").unwrap();
429 assert_eq!(combo.key, "s");
430 assert!(!combo.shift);
431 assert!(combo.ctrl);
432 }
433
434 #[test]
435 fn default_bindings_load() {
436 let map = KeybindingMap::from_config(&HashMap::new());
437 let right = KeyCombo::parse("Right").unwrap();
438 assert_eq!(map.lookup(&right), Some(Action::NextSlide));
439 }
440
441 #[test]
442 fn user_override_replaces_defaults() {
443 let mut user = HashMap::new();
444 user.insert("next_slide".to_string(), vec!["x".to_string()]);
445 let map = KeybindingMap::from_config(&user);
446
447 let x = KeyCombo::parse("x").unwrap();
449 assert_eq!(map.lookup(&x), Some(Action::NextSlide));
450
451 let right = KeyCombo::parse("Right").unwrap();
453 assert_ne!(map.lookup(&right), Some(Action::NextSlide));
454 }
455
456 #[test]
457 fn clicker_profile_overlays_bindings() {
458 let mut config = Config::default();
459 config.clicker.profile = "custom".to_string();
460 config.clicker.profiles.insert(
461 "custom".to_string(),
462 HashMap::from([("Escape".to_string(), "toggle_blackout".to_string())]),
463 );
464
465 let map = KeybindingMap::from_full_config(&config);
466 let escape = KeyCombo::parse("Escape").unwrap();
467 assert_eq!(map.lookup(&escape), Some(Action::ToggleBlackout));
468 }
469
470 #[test]
471 fn display_name_simple_key() {
472 let combo = KeyCombo::parse("Right").unwrap();
473 assert_eq!(combo.display_name(), "Right");
474 }
475
476 #[test]
477 fn display_name_modifier_combo() {
478 let combo = KeyCombo::parse("Ctrl+Shift+s").unwrap();
479 assert_eq!(combo.display_name(), "Ctrl+Shift+S");
480 }
481
482 #[test]
483 fn display_name_single_char_uppercase() {
484 let combo = KeyCombo::parse("g").unwrap();
485 assert_eq!(combo.display_name(), "G");
486 }
487
488 #[test]
489 fn action_bindings_returns_all_actions() {
490 let map = KeybindingMap::from_config(&HashMap::new());
491 let bindings = map.action_bindings();
492 assert_eq!(bindings.len(), Action::all().len());
493 }
494
495 #[test]
496 fn action_bindings_reflects_overrides() {
497 let mut user = HashMap::new();
498 user.insert("quit".to_string(), vec!["x".to_string()]);
499 let map = KeybindingMap::from_config(&user);
500 let bindings = map.action_bindings();
501
502 let quit_keys: Vec<String> =
503 bindings.iter().find(|(a, _)| *a == Action::Quit).unwrap().1.clone();
504 assert_eq!(quit_keys, vec!["X"]);
505 }
506
507 #[test]
508 fn every_action_has_description_and_group() {
509 for action in Action::all() {
510 assert!(!action.description().is_empty(), "{action:?} missing description");
511 assert!(!action.group().is_empty(), "{action:?} missing group");
512 }
513 }
514}