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    /// Enable or disable text box placement mode.
66    ToggleTextBoxMode,
67    /// Request application shutdown.
68    Quit,
69    /// Save the active sidecar file.
70    SaveSidecar,
71}
72
73impl Action {
74    /// The config-file name for this action (e.g., `next_slide`).
75    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    /// Human-readable description for the help overlay.
111    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    /// Grouping label for the help overlay.
147    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    /// All known actions.
186    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/// A key combination (key name + modifiers).
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224pub struct KeyCombo {
225    /// The key name (e.g., "Right", "Space", "a", "F1").
226    pub key: String,
227    /// Whether Shift is held.
228    pub shift: bool,
229    /// Whether Ctrl (or Cmd on macOS) is held.
230    pub ctrl: bool,
231    /// Whether Alt is held.
232    pub alt: bool,
233}
234
235impl KeyCombo {
236    /// Parse a key combo string like "Shift+Right" or "Ctrl+s".
237    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    /// Human-readable display string (e.g., "Ctrl+Shift+S").
258    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        // Capitalise single-character keys for display.
270        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
281/// Maps key combinations to actions.
282pub struct KeybindingMap {
283    bindings: HashMap<KeyCombo, Action>,
284}
285
286impl KeybindingMap {
287    /// Build a keybinding map from user config overlaid on defaults.
288    pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
289        let mut bindings = HashMap::new();
290
291        // Start with defaults
292        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        // Overlay user config — if a user defines any binding for an action,
301        // it replaces all defaults for that action.
302        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                // Remove all existing bindings for this action
308                bindings.retain(|_, v| *v != action);
309                // Add user-defined bindings
310                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    /// Build a keybinding map from config plus the active clicker profile.
324    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    /// Look up the action bound to a key combination.
331    pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
332        self.bindings.get(combo).copied()
333    }
334
335    /// Return the active bindings grouped by action with human-readable key names.
336    ///
337    /// Each action appears in `Action::all()` order. The associated `Vec` contains
338    /// the display strings for every key combo currently mapped to that action.
339    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
374/// Default keybindings (pdfpc-compatible where applicable).
375fn 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        // "x" should now be next_slide
461        let x = KeyCombo::parse("x").unwrap();
462        assert_eq!(map.lookup(&x), Some(Action::NextSlide));
463
464        // Default "Right" should no longer be bound to next_slide
465        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}