Skip to main content

dais_core/
keybindings.rs

1use std::collections::HashMap;
2
3use crate::config::Config;
4
5/// Named actions that can be bound to keys.
6///
7/// These names are the public API for keybinding configuration — they appear in
8/// the TOML config file and in documentation. Renaming one is a breaking change.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Action {
11    /// Advance to the next logical slide or overlay-aware build step.
12    NextSlide,
13    /// Move to the previous logical slide or overlay-aware build step.
14    PreviousSlide,
15    /// Advance by one raw PDF page.
16    NextOverlay,
17    /// Move back by one raw PDF page.
18    PreviousOverlay,
19    /// Jump to the first logical slide.
20    FirstSlide,
21    /// Jump to the last logical slide.
22    LastSlide,
23    /// Enter a numeric slide jump.
24    GoToSlide,
25    /// Freeze or unfreeze the audience display.
26    ToggleFreeze,
27    /// Black out or restore the audience display.
28    ToggleBlackout,
29    /// Show or hide the whiteboard canvas.
30    ToggleWhiteboard,
31    /// Enable or disable the laser pointer.
32    ToggleLaser,
33    /// Rotate through configured laser pointer styles.
34    CycleLaserStyle,
35    /// Enable or disable freehand ink mode.
36    ToggleInk,
37    /// Clear ink from the active slide or whiteboard.
38    ClearInk,
39    /// Rotate through configured ink colors.
40    CycleInkColor,
41    /// Rotate through configured ink widths.
42    CycleInkWidth,
43    /// Enable or disable the spotlight overlay.
44    ToggleSpotlight,
45    /// Enable or disable the zoom overlay.
46    ToggleZoom,
47    /// Show or hide the slide overview.
48    ToggleOverview,
49    /// Show or hide slide notes.
50    ToggleNotes,
51    /// Toggle markdown editing for slide notes.
52    ToggleNotesEdit,
53    /// Start, pause, or resume the main timer.
54    StartPauseTimer,
55    /// Reset the main timer.
56    ResetTimer,
57    /// Increase the notes font size.
58    IncrementNotesFont,
59    /// Decrease the notes font size.
60    DecrementNotesFont,
61    /// Toggle screen-share window mode.
62    ToggleScreenShare,
63    /// Toggle the single-monitor presentation surface.
64    TogglePresentationMode,
65    /// Swap presenter and audience monitors for this session.
66    SwapDisplays,
67    /// Enable or disable text box placement mode.
68    ToggleTextBoxMode,
69    /// Request application shutdown.
70    Quit,
71    /// Save the active sidecar file.
72    SaveSidecar,
73}
74
75impl Action {
76    /// The config-file name for this action (e.g., `next_slide`).
77    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    /// Parse a config-file action name into an action.
114    pub fn from_config_name(name: &str) -> Option<Self> {
115        Self::all().iter().copied().find(|action| action.config_name() == name)
116    }
117
118    /// Human-readable description for the help overlay.
119    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    /// Grouping label for the help overlay.
156    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    /// All known actions.
196    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/// A key combination (key name + modifiers).
234#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub struct KeyCombo {
236    /// The key name (e.g., "Right", "Space", "a", "F1").
237    pub key: String,
238    /// Whether Shift is held.
239    pub shift: bool,
240    /// Whether Ctrl (or Cmd on macOS) is held.
241    pub ctrl: bool,
242    /// Whether Alt is held.
243    pub alt: bool,
244}
245
246impl KeyCombo {
247    /// Parse a key combo string like "Shift+Right" or "Ctrl+s".
248    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    /// Human-readable display string (e.g., "Ctrl+Shift+S").
269    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        // Capitalise single-character keys for display.
281        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
292/// Maps key combinations to actions.
293pub struct KeybindingMap {
294    bindings: HashMap<KeyCombo, Action>,
295}
296
297impl KeybindingMap {
298    /// Build a keybinding map from user config overlaid on defaults.
299    pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
300        let mut bindings = HashMap::new();
301
302        // Start with defaults
303        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        // Overlay user config — if a user defines any binding for an action,
312        // it replaces all defaults for that action.
313        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                // Remove all existing bindings for this action
319                bindings.retain(|_, v| *v != action);
320                // Add user-defined bindings
321                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    /// Build a keybinding map from config plus the active clicker profile.
335    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    /// Look up the action bound to a key combination.
342    pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
343        self.bindings.get(combo).copied()
344    }
345
346    /// Return the active bindings grouped by action with human-readable key names.
347    ///
348    /// Each action appears in `Action::all()` order. The associated `Vec` contains
349    /// the display strings for every key combo currently mapped to that action.
350    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
385/// Default keybindings (pdfpc-compatible where applicable).
386fn 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        // "x" should now be next_slide
480        let x = KeyCombo::parse("x").unwrap();
481        assert_eq!(map.lookup(&x), Some(Action::NextSlide));
482
483        // Default "Right" should no longer be bound to next_slide
484        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}