Skip to main content

dais_ui/
input.rs

1//! Input handling — mode-aware key/mouse → Command pipeline.
2//!
3//! Converts egui key/mouse events into [`Command`]s dispatched via a [`CommandSender`].
4
5use std::time::Instant;
6
7use dais_core::bus::CommandSender;
8use dais_core::commands::Command;
9use dais_core::keybindings::{Action, KeyCombo, KeybindingMap};
10
11/// Which input mode the presenter console is in.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InputMode {
14    /// Default keyboard-driven navigation.
15    Normal,
16    /// Slide overview grid is showing; Enter selects, Escape closes.
17    Overview,
18    /// Freehand ink drawing; mouse drives strokes.
19    Ink,
20    /// Laser pointer active; mouse drives position.
21    Laser,
22    /// Inline markdown notes editing.
23    NotesEdit,
24    /// Digit accumulation for jump-to-slide (G → digits → Enter).
25    JumpToSlide,
26    /// Text box placement and editing mode.
27    TextBox,
28}
29
30/// Which presentation aids are currently active, used to drive mouse handling.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct ActiveAids {
33    pub ink: bool,
34    pub laser: bool,
35    pub spotlight: bool,
36    pub zoom: bool,
37}
38
39#[derive(Debug, Clone, Copy, Default)]
40pub struct UiModes {
41    pub overview_visible: bool,
42    pub ink_active: bool,
43    pub laser_active: bool,
44    pub notes_editing: bool,
45    pub text_box_mode: bool,
46    pub text_box_editing: bool,
47    pub selected_text_box: Option<u64>,
48}
49
50/// Processes egui events and dispatches [`Command`]s.
51pub struct InputHandler {
52    sender: CommandSender,
53    keybindings: KeybindingMap,
54    mode: InputMode,
55    jump_buffer: String,
56    jump_start: Option<Instant>,
57    /// True while an ink stroke is being built (pointer held down). Used to
58    /// finish the stroke reliably even when egui's drag threshold isn't met.
59    stroke_in_progress: bool,
60}
61
62/// Timeout for jump-to-slide digit accumulation.
63const JUMP_TIMEOUT_SECS: f64 = 3.0;
64/// Default zoom factor when zoom is first activated.
65const DEFAULT_ZOOM_FACTOR: f32 = 1.5;
66/// Supported zoom steps for mouse-wheel control.
67const ZOOM_STEPS: &[f32] = &[1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0];
68
69impl InputHandler {
70    pub fn new(sender: CommandSender, keybindings: KeybindingMap) -> Self {
71        Self {
72            sender,
73            keybindings,
74            mode: InputMode::Normal,
75            jump_buffer: String::new(),
76            jump_start: None,
77            stroke_in_progress: false,
78        }
79    }
80
81    /// Call once per frame from the presenter console.
82    ///
83    /// UI state drives mode transitions.
84    pub fn handle_input(&mut self, ctx: &egui::Context, modes: UiModes) {
85        // Sync mode from external state changes
86        if self.mode != InputMode::JumpToSlide {
87            if modes.overview_visible {
88                self.mode = InputMode::Overview;
89            } else if modes.notes_editing {
90                self.mode = InputMode::NotesEdit;
91            } else if modes.ink_active {
92                self.mode = InputMode::Ink;
93            } else if modes.laser_active {
94                self.mode = InputMode::Laser;
95            } else if modes.text_box_mode {
96                self.mode = InputMode::TextBox;
97            } else {
98                self.mode = InputMode::Normal;
99            }
100        }
101
102        // Check jump-to-slide timeout
103        if self.mode == InputMode::JumpToSlide
104            && let Some(start) = self.jump_start
105            && start.elapsed().as_secs_f64() > JUMP_TIMEOUT_SECS
106        {
107            self.cancel_jump();
108        }
109
110        if self.mode == InputMode::NotesEdit {
111            self.process_notes_editor_keys(ctx);
112            return;
113        }
114
115        if self.mode == InputMode::TextBox && modes.text_box_editing {
116            // While inline editor is active, only handle commit shortcuts
117            self.process_text_box_editor_keys(ctx, modes.selected_text_box);
118            return;
119        }
120
121        if self.mode == InputMode::TextBox {
122            self.process_text_box_mode_keys(ctx, modes.selected_text_box);
123            return;
124        }
125
126        self.process_keys(ctx);
127    }
128
129    fn process_notes_editor_keys(&mut self, ctx: &egui::Context) {
130        let events: Vec<egui::Event> = ctx.input(|i| i.events.clone());
131
132        for event in &events {
133            if let egui::Event::Key { key, pressed: true, modifiers, .. } = event {
134                if *key == egui::Key::Escape {
135                    let _ = self.sender.send(Command::ToggleNotesEdit);
136                    continue;
137                }
138
139                let combo = egui_to_key_combo(*key, *modifiers);
140                if let Some(action) = self.keybindings.lookup(&combo) {
141                    match action {
142                        Action::SaveSidecar | Action::ToggleNotesEdit => {
143                            self.dispatch_action(action);
144                        }
145                        _ => {}
146                    }
147                }
148            }
149        }
150    }
151
152    fn process_text_box_editor_keys(&mut self, ctx: &egui::Context, _selected: Option<u64>) {
153        // When the TextEdit overlay is focused, Escape exits edit mode
154        let events: Vec<egui::Event> = ctx.input(|i| i.events.clone());
155        for event in &events {
156            if let egui::Event::Key { key: egui::Key::Escape, pressed: true, .. } = event {
157                let _ = self.sender.send(Command::DeselectTextBox);
158            }
159        }
160    }
161
162    fn process_text_box_mode_keys(&mut self, ctx: &egui::Context, selected: Option<u64>) {
163        let events: Vec<egui::Event> = ctx.input(|i| i.events.clone());
164        for event in &events {
165            if let egui::Event::Key { key, pressed: true, modifiers, .. } = event {
166                match key {
167                    egui::Key::Escape => {
168                        if selected.is_some() {
169                            let _ = self.sender.send(Command::DeselectTextBox);
170                        } else {
171                            let _ = self.sender.send(Command::ToggleTextBoxMode);
172                        }
173                    }
174                    egui::Key::Delete | egui::Key::Backspace => {
175                        if let Some(id) = selected {
176                            let _ = self.sender.send(Command::DeleteTextBox { id });
177                        }
178                    }
179                    egui::Key::Enter => {
180                        if let Some(id) = selected {
181                            let _ = self.sender.send(Command::BeginTextBoxEdit { id });
182                        }
183                    }
184                    _ => {
185                        let combo = egui_to_key_combo(*key, *modifiers);
186                        if let Some(action) = self.keybindings.lookup(&combo) {
187                            // Allow global shortcuts to work even in text box mode
188                            match action {
189                                Action::SaveSidecar | Action::ToggleTextBoxMode | Action::Quit => {
190                                    self.dispatch_action(action);
191                                }
192                                _ => {}
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200
201    fn process_keys(&mut self, ctx: &egui::Context) {
202        // Collect key events this frame
203        let events: Vec<egui::Event> = ctx.input(|i| i.events.clone());
204
205        for event in &events {
206            if let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event {
207                self.handle_key(*key, *modifiers, *repeat);
208            }
209        }
210    }
211
212    fn handle_key(&mut self, key: egui::Key, modifiers: egui::Modifiers, repeat: bool) {
213        // In jump-to-slide mode, handle digits/Enter/Escape specially
214        if self.mode == InputMode::JumpToSlide {
215            if let Some(digit) = key_to_digit(key) {
216                self.jump_buffer.push(digit);
217                return;
218            }
219            match key {
220                egui::Key::Enter => {
221                    if let Ok(page_num) = self.jump_buffer.parse::<usize>() {
222                        let index = page_num.saturating_sub(1);
223                        let _ = self.sender.send(Command::GoToSlide(index));
224                    }
225                    self.cancel_jump();
226                    return;
227                }
228                egui::Key::Escape => {
229                    self.cancel_jump();
230                    return;
231                }
232                _ => {
233                    self.cancel_jump();
234                }
235            }
236        }
237
238        let combo = egui_to_key_combo(key, modifiers);
239        if let Some(action) = self.keybindings.lookup(&combo) {
240            if repeat && !action_allows_key_repeat(action) {
241                return;
242            }
243            self.dispatch_action(action);
244        }
245    }
246
247    fn dispatch_action(&mut self, action: Action) {
248        match action {
249            Action::GoToSlide => {
250                self.mode = InputMode::JumpToSlide;
251                self.jump_buffer.clear();
252                self.jump_start = Some(Instant::now());
253            }
254            Action::StartPauseTimer => {
255                let _ = self.sender.send(Command::ToggleTimer);
256            }
257            _ => {
258                if let Some(cmd) = action_to_command(action) {
259                    let _ = self.sender.send(cmd);
260                }
261            }
262        }
263    }
264
265    fn cancel_jump(&mut self) {
266        self.mode = InputMode::Normal;
267        self.jump_buffer.clear();
268        self.jump_start = None;
269    }
270
271    /// Handle mouse interaction on the current slide image area.
272    ///
273    /// Call this with the egui `Response` and image `Rect` from the current
274    /// slide widget.
275    pub fn handle_slide_mouse(
276        &mut self,
277        response: &egui::Response,
278        image_rect: egui::Rect,
279        aids: ActiveAids,
280        current_zoom_factor: Option<f32>,
281    ) {
282        if let Some(pos) = response.hover_pos() {
283            let norm = normalize_to_rect(pos, image_rect);
284            if (0.0..=1.0).contains(&norm.0) && (0.0..=1.0).contains(&norm.1) {
285                if aids.laser || aids.spotlight {
286                    let _ = self.sender.send(Command::SetPointerPosition(norm.0, norm.1));
287                    if aids.spotlight {
288                        let _ = self.sender.send(Command::SetSpotlightPosition(norm.0, norm.1));
289                    }
290                }
291
292                if aids.zoom {
293                    let scroll_delta = response.ctx.input(|i| i.raw_scroll_delta.y);
294                    let current_factor = current_zoom_factor.unwrap_or(DEFAULT_ZOOM_FACTOR);
295                    let factor = if response.hovered() && scroll_delta.abs() > f32::EPSILON {
296                        step_zoom_factor(current_factor, scroll_delta)
297                    } else {
298                        current_factor
299                    };
300                    let _ = self
301                        .sender
302                        .send(Command::SetZoomRegion { center: (norm.0, norm.1), factor });
303                }
304            }
305        }
306
307        let pointer_down = response.ctx.input(|i| i.pointer.primary_down());
308        if aids.ink
309            && pointer_down
310            && response.contains_pointer()
311            && let Some(pos) = response
312                .interact_pointer_pos()
313                .or_else(|| response.ctx.input(|i| i.pointer.latest_pos()))
314        {
315            let norm = normalize_to_rect(pos, image_rect);
316            if (0.0..=1.0).contains(&norm.0) && (0.0..=1.0).contains(&norm.1) {
317                let _ = self.sender.send(Command::AddInkPoint(norm.0, norm.1));
318                self.stroke_in_progress = true;
319            }
320        }
321
322        // Finish the stroke when the pointer is released. We use our own
323        // tracking flag rather than drag_stopped() because egui's drag
324        // detection requires a minimum movement threshold — a tap or tiny
325        // movement never fires drag_stopped(), leaving the stroke open and
326        // causing the next press to connect back to the old position.
327        if aids.ink && self.stroke_in_progress && !pointer_down {
328            let _ = self.sender.send(Command::FinishInkStroke);
329            self.stroke_in_progress = false;
330        }
331    }
332
333    pub fn mode(&self) -> InputMode {
334        self.mode
335    }
336
337    pub fn jump_buffer(&self) -> &str {
338        &self.jump_buffer
339    }
340
341    /// Access the active keybinding map.
342    pub fn keybindings(&self) -> &KeybindingMap {
343        &self.keybindings
344    }
345}
346
347fn step_zoom_factor(current_factor: f32, scroll_delta: f32) -> f32 {
348    let current_index = ZOOM_STEPS
349        .iter()
350        .position(|step| (*step - current_factor).abs() < f32::EPSILON)
351        .unwrap_or_else(|| nearest_zoom_step_index(current_factor));
352
353    let next_index = if scroll_delta > 0.0 {
354        current_index.saturating_add(1).min(ZOOM_STEPS.len() - 1)
355    } else {
356        current_index.saturating_sub(1)
357    };
358
359    ZOOM_STEPS[next_index]
360}
361
362fn nearest_zoom_step_index(current_factor: f32) -> usize {
363    ZOOM_STEPS
364        .iter()
365        .enumerate()
366        .min_by(|(_, left), (_, right)| {
367            (current_factor - **left)
368                .abs()
369                .partial_cmp(&(current_factor - **right).abs())
370                .unwrap_or(std::cmp::Ordering::Equal)
371        })
372        .map_or(0, |(index, _)| index)
373}
374
375/// Convert a screen-space position to normalized (0..1) coordinates within a rect.
376pub fn normalize_to_rect(pos: egui::Pos2, rect: egui::Rect) -> (f32, f32) {
377    let x = (pos.x - rect.min.x) / rect.width();
378    let y = (pos.y - rect.min.y) / rect.height();
379    (x, y)
380}
381
382/// Convert an egui key + modifiers to our `KeyCombo` string format for lookup.
383fn egui_to_key_combo(key: egui::Key, modifiers: egui::Modifiers) -> KeyCombo {
384    let key_name = egui_key_name(key);
385    KeyCombo {
386        key: key_name,
387        shift: modifiers.shift,
388        ctrl: modifiers.ctrl || modifiers.command,
389        alt: modifiers.alt,
390    }
391}
392
393/// Map an egui key to the string name used in our keybinding config.
394fn egui_key_name(key: egui::Key) -> String {
395    egui_key_name_public(key)
396}
397
398/// Map an egui key to the string name used in our keybinding config (public for test-input mode).
399pub fn egui_key_name_public(key: egui::Key) -> String {
400    match key {
401        egui::Key::ArrowRight => "Right".into(),
402        egui::Key::ArrowLeft => "Left".into(),
403        egui::Key::ArrowUp => "Up".into(),
404        egui::Key::ArrowDown => "Down".into(),
405        egui::Key::Space => "Space".into(),
406        egui::Key::Enter => "Enter".into(),
407        egui::Key::Escape => "Escape".into(),
408        egui::Key::Home => "Home".into(),
409        egui::Key::End => "End".into(),
410        egui::Key::PageUp => "PageUp".into(),
411        egui::Key::PageDown => "PageDown".into(),
412        egui::Key::Tab => "Tab".into(),
413        egui::Key::Backspace => "Backspace".into(),
414        egui::Key::Delete => "Delete".into(),
415        egui::Key::F1 => "F1".into(),
416        egui::Key::F2 => "F2".into(),
417        egui::Key::F3 => "F3".into(),
418        egui::Key::F4 => "F4".into(),
419        egui::Key::F5 => "F5".into(),
420        egui::Key::F6 => "F6".into(),
421        egui::Key::F7 => "F7".into(),
422        egui::Key::F8 => "F8".into(),
423        egui::Key::F9 => "F9".into(),
424        egui::Key::F10 => "F10".into(),
425        egui::Key::F11 => "F11".into(),
426        egui::Key::F12 => "F12".into(),
427        egui::Key::Minus => "-".into(),
428        egui::Key::Plus => "+".into(),
429        egui::Key::Equals => "=".into(),
430        egui::Key::Period => ".".into(),
431        other => {
432            // For letter keys (A-Z) and digit keys, egui::Key debug names work
433            let debug = format!("{other:?}");
434            debug.to_lowercase()
435        }
436    }
437}
438
439/// Try to extract a digit character from a key press.
440fn key_to_digit(key: egui::Key) -> Option<char> {
441    match key {
442        egui::Key::Num0 => Some('0'),
443        egui::Key::Num1 => Some('1'),
444        egui::Key::Num2 => Some('2'),
445        egui::Key::Num3 => Some('3'),
446        egui::Key::Num4 => Some('4'),
447        egui::Key::Num5 => Some('5'),
448        egui::Key::Num6 => Some('6'),
449        egui::Key::Num7 => Some('7'),
450        egui::Key::Num8 => Some('8'),
451        egui::Key::Num9 => Some('9'),
452        _ => None,
453    }
454}
455
456fn action_allows_key_repeat(action: Action) -> bool {
457    matches!(
458        action,
459        Action::NextSlide
460            | Action::PreviousSlide
461            | Action::NextOverlay
462            | Action::PreviousOverlay
463            | Action::IncrementNotesFont
464            | Action::DecrementNotesFont
465    )
466}
467
468/// Map an `Action` to a `Command` for simple 1:1 mappings.
469/// Returns `None` for actions that need special handling (`GoToSlide`, `StartPauseTimer`).
470fn action_to_command(action: Action) -> Option<Command> {
471    match action {
472        Action::NextSlide => Some(Command::NextSlide),
473        Action::PreviousSlide => Some(Command::PreviousSlide),
474        Action::NextOverlay => Some(Command::NextOverlay),
475        Action::PreviousOverlay => Some(Command::PreviousOverlay),
476        Action::FirstSlide => Some(Command::FirstSlide),
477        Action::LastSlide => Some(Command::LastSlide),
478        Action::ToggleFreeze => Some(Command::ToggleFreeze),
479        Action::ToggleBlackout => Some(Command::ToggleBlackout),
480        Action::ToggleWhiteboard => Some(Command::ToggleWhiteboard),
481        Action::ToggleLaser => Some(Command::ToggleLaser),
482        Action::CycleLaserStyle => Some(Command::CycleLaserStyle),
483        Action::ToggleInk => Some(Command::ToggleInk),
484        Action::ClearInk => Some(Command::ClearInk),
485        Action::CycleInkColor => Some(Command::CycleInkColor),
486        Action::CycleInkWidth => Some(Command::CycleInkWidth),
487        Action::ToggleSpotlight => Some(Command::ToggleSpotlight),
488        Action::ToggleZoom => Some(Command::ToggleZoom),
489        Action::ToggleOverview => Some(Command::ToggleSlideOverview),
490        Action::ToggleNotes => Some(Command::ToggleNotesPanel),
491        Action::ToggleNotesEdit => Some(Command::ToggleNotesEdit),
492        Action::ResetTimer => Some(Command::ResetTimer),
493        Action::IncrementNotesFont => Some(Command::IncrementNotesFontSize),
494        Action::DecrementNotesFont => Some(Command::DecrementNotesFontSize),
495        Action::ToggleScreenShare => Some(Command::ToggleScreenShareMode),
496        Action::TogglePresentationMode => Some(Command::TogglePresentationMode),
497        Action::ToggleTextBoxMode => Some(Command::ToggleTextBoxMode),
498        Action::Quit => Some(Command::Quit),
499        Action::SaveSidecar => Some(Command::SaveSidecar),
500        Action::GoToSlide | Action::StartPauseTimer => None,
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use dais_core::bus::CommandBus;
507
508    use super::*;
509
510    fn make_handler() -> (InputHandler, dais_core::bus::CommandReceiver) {
511        let bus = CommandBus::new();
512        let sender = bus.sender();
513        let receiver = bus.into_receiver();
514        let keybindings = KeybindingMap::from_config(&std::collections::HashMap::new());
515        (InputHandler::new(sender, keybindings), receiver)
516    }
517
518    #[test]
519    fn ctrl_l_dispatches_cycle_laser_style() {
520        let (mut handler, receiver) = make_handler();
521
522        handler.handle_key(egui::Key::L, egui::Modifiers::CTRL, false);
523
524        assert_eq!(receiver.try_recv(), Some(Command::CycleLaserStyle));
525    }
526
527    #[test]
528    fn repeated_ctrl_l_does_not_skip_pointer_styles() {
529        let (mut handler, receiver) = make_handler();
530
531        handler.handle_key(egui::Key::L, egui::Modifiers::CTRL, true);
532
533        assert_eq!(receiver.try_recv(), None);
534    }
535}