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",
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    /// 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 => "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    /// 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::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/// 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, 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
277/// Maps key combinations to actions.
278pub struct KeybindingMap {
279    bindings: HashMap<KeyCombo, Action>,
280}
281
282impl KeybindingMap {
283    /// Build a keybinding map from user config overlaid on defaults.
284    pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
285        let mut bindings = HashMap::new();
286
287        // Start with defaults
288        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        // Overlay user config — if a user defines any binding for an action,
297        // it replaces all defaults for that action.
298        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                // Remove all existing bindings for this action
304                bindings.retain(|_, v| *v != action);
305                // Add user-defined bindings
306                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    /// Build a keybinding map from config plus the active clicker profile.
320    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    /// Look up the action bound to a key combination.
327    pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
328        self.bindings.get(combo).copied()
329    }
330
331    /// Return the active bindings grouped by action with human-readable key names.
332    ///
333    /// Each action appears in `Action::all()` order. The associated `Vec` contains
334    /// the display strings for every key combo currently mapped to that action.
335    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
370/// Default keybindings (pdfpc-compatible where applicable).
371fn 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        // "x" should now be next_slide
448        let x = KeyCombo::parse("x").unwrap();
449        assert_eq!(map.lookup(&x), Some(Action::NextSlide));
450
451        // Default "Right" should no longer be bound to next_slide
452        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}