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