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, modifiers, .. } = event {
207                self.handle_key(*key, *modifiers);
208            }
209        }
210    }
211
212    fn handle_key(&mut self, key: egui::Key, modifiers: egui::Modifiers) {
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            self.dispatch_action(action);
241        }
242    }
243
244    fn dispatch_action(&mut self, action: Action) {
245        match action {
246            Action::GoToSlide => {
247                self.mode = InputMode::JumpToSlide;
248                self.jump_buffer.clear();
249                self.jump_start = Some(Instant::now());
250            }
251            Action::StartPauseTimer => {
252                let _ = self.sender.send(Command::ToggleTimer);
253            }
254            _ => {
255                if let Some(cmd) = action_to_command(action) {
256                    let _ = self.sender.send(cmd);
257                }
258            }
259        }
260    }
261
262    fn cancel_jump(&mut self) {
263        self.mode = InputMode::Normal;
264        self.jump_buffer.clear();
265        self.jump_start = None;
266    }
267
268    /// Handle mouse interaction on the current slide image area.
269    ///
270    /// Call this with the egui `Response` and image `Rect` from the current
271    /// slide widget.
272    pub fn handle_slide_mouse(
273        &mut self,
274        response: &egui::Response,
275        image_rect: egui::Rect,
276        aids: ActiveAids,
277        current_zoom_factor: Option<f32>,
278    ) {
279        if let Some(pos) = response.hover_pos() {
280            let norm = normalize_to_rect(pos, image_rect);
281            if (0.0..=1.0).contains(&norm.0) && (0.0..=1.0).contains(&norm.1) {
282                if aids.laser || aids.spotlight {
283                    let _ = self.sender.send(Command::SetPointerPosition(norm.0, norm.1));
284                    if aids.spotlight {
285                        let _ = self.sender.send(Command::SetSpotlightPosition(norm.0, norm.1));
286                    }
287                }
288
289                if aids.zoom {
290                    let scroll_delta = response.ctx.input(|i| i.raw_scroll_delta.y);
291                    let current_factor = current_zoom_factor.unwrap_or(DEFAULT_ZOOM_FACTOR);
292                    let factor = if response.hovered() && scroll_delta.abs() > f32::EPSILON {
293                        step_zoom_factor(current_factor, scroll_delta)
294                    } else {
295                        current_factor
296                    };
297                    let _ = self
298                        .sender
299                        .send(Command::SetZoomRegion { center: (norm.0, norm.1), factor });
300                }
301            }
302        }
303
304        let pointer_down = response.ctx.input(|i| i.pointer.primary_down());
305        if aids.ink
306            && pointer_down
307            && response.contains_pointer()
308            && let Some(pos) = response
309                .interact_pointer_pos()
310                .or_else(|| response.ctx.input(|i| i.pointer.latest_pos()))
311        {
312            let norm = normalize_to_rect(pos, image_rect);
313            if (0.0..=1.0).contains(&norm.0) && (0.0..=1.0).contains(&norm.1) {
314                let _ = self.sender.send(Command::AddInkPoint(norm.0, norm.1));
315                self.stroke_in_progress = true;
316            }
317        }
318
319        // Finish the stroke when the pointer is released. We use our own
320        // tracking flag rather than drag_stopped() because egui's drag
321        // detection requires a minimum movement threshold — a tap or tiny
322        // movement never fires drag_stopped(), leaving the stroke open and
323        // causing the next press to connect back to the old position.
324        if aids.ink && self.stroke_in_progress && !pointer_down {
325            let _ = self.sender.send(Command::FinishInkStroke);
326            self.stroke_in_progress = false;
327        }
328    }
329
330    pub fn mode(&self) -> InputMode {
331        self.mode
332    }
333
334    pub fn jump_buffer(&self) -> &str {
335        &self.jump_buffer
336    }
337
338    /// Access the active keybinding map.
339    pub fn keybindings(&self) -> &KeybindingMap {
340        &self.keybindings
341    }
342}
343
344fn step_zoom_factor(current_factor: f32, scroll_delta: f32) -> f32 {
345    let current_index = ZOOM_STEPS
346        .iter()
347        .position(|step| (*step - current_factor).abs() < f32::EPSILON)
348        .unwrap_or_else(|| nearest_zoom_step_index(current_factor));
349
350    let next_index = if scroll_delta > 0.0 {
351        current_index.saturating_add(1).min(ZOOM_STEPS.len() - 1)
352    } else {
353        current_index.saturating_sub(1)
354    };
355
356    ZOOM_STEPS[next_index]
357}
358
359fn nearest_zoom_step_index(current_factor: f32) -> usize {
360    ZOOM_STEPS
361        .iter()
362        .enumerate()
363        .min_by(|(_, left), (_, right)| {
364            (current_factor - **left)
365                .abs()
366                .partial_cmp(&(current_factor - **right).abs())
367                .unwrap_or(std::cmp::Ordering::Equal)
368        })
369        .map_or(0, |(index, _)| index)
370}
371
372/// Convert a screen-space position to normalized (0..1) coordinates within a rect.
373pub fn normalize_to_rect(pos: egui::Pos2, rect: egui::Rect) -> (f32, f32) {
374    let x = (pos.x - rect.min.x) / rect.width();
375    let y = (pos.y - rect.min.y) / rect.height();
376    (x, y)
377}
378
379/// Convert an egui key + modifiers to our `KeyCombo` string format for lookup.
380fn egui_to_key_combo(key: egui::Key, modifiers: egui::Modifiers) -> KeyCombo {
381    let key_name = egui_key_name(key);
382    KeyCombo {
383        key: key_name,
384        shift: modifiers.shift,
385        ctrl: modifiers.ctrl || modifiers.command,
386        alt: modifiers.alt,
387    }
388}
389
390/// Map an egui key to the string name used in our keybinding config.
391fn egui_key_name(key: egui::Key) -> String {
392    egui_key_name_public(key)
393}
394
395/// Map an egui key to the string name used in our keybinding config (public for test-input mode).
396pub fn egui_key_name_public(key: egui::Key) -> String {
397    match key {
398        egui::Key::ArrowRight => "Right".into(),
399        egui::Key::ArrowLeft => "Left".into(),
400        egui::Key::ArrowUp => "Up".into(),
401        egui::Key::ArrowDown => "Down".into(),
402        egui::Key::Space => "Space".into(),
403        egui::Key::Enter => "Enter".into(),
404        egui::Key::Escape => "Escape".into(),
405        egui::Key::Home => "Home".into(),
406        egui::Key::End => "End".into(),
407        egui::Key::PageUp => "PageUp".into(),
408        egui::Key::PageDown => "PageDown".into(),
409        egui::Key::Tab => "Tab".into(),
410        egui::Key::Backspace => "Backspace".into(),
411        egui::Key::Delete => "Delete".into(),
412        egui::Key::F1 => "F1".into(),
413        egui::Key::F2 => "F2".into(),
414        egui::Key::F3 => "F3".into(),
415        egui::Key::F4 => "F4".into(),
416        egui::Key::F5 => "F5".into(),
417        egui::Key::F6 => "F6".into(),
418        egui::Key::F7 => "F7".into(),
419        egui::Key::F8 => "F8".into(),
420        egui::Key::F9 => "F9".into(),
421        egui::Key::F10 => "F10".into(),
422        egui::Key::F11 => "F11".into(),
423        egui::Key::F12 => "F12".into(),
424        egui::Key::Minus => "-".into(),
425        egui::Key::Plus => "+".into(),
426        egui::Key::Equals => "=".into(),
427        egui::Key::Period => ".".into(),
428        other => {
429            // For letter keys (A-Z) and digit keys, egui::Key debug names work
430            let debug = format!("{other:?}");
431            debug.to_lowercase()
432        }
433    }
434}
435
436/// Try to extract a digit character from a key press.
437fn key_to_digit(key: egui::Key) -> Option<char> {
438    match key {
439        egui::Key::Num0 => Some('0'),
440        egui::Key::Num1 => Some('1'),
441        egui::Key::Num2 => Some('2'),
442        egui::Key::Num3 => Some('3'),
443        egui::Key::Num4 => Some('4'),
444        egui::Key::Num5 => Some('5'),
445        egui::Key::Num6 => Some('6'),
446        egui::Key::Num7 => Some('7'),
447        egui::Key::Num8 => Some('8'),
448        egui::Key::Num9 => Some('9'),
449        _ => None,
450    }
451}
452
453/// Map an `Action` to a `Command` for simple 1:1 mappings.
454/// Returns `None` for actions that need special handling (`GoToSlide`, `StartPauseTimer`).
455fn action_to_command(action: Action) -> Option<Command> {
456    match action {
457        Action::NextSlide => Some(Command::NextSlide),
458        Action::PreviousSlide => Some(Command::PreviousSlide),
459        Action::NextOverlay => Some(Command::NextOverlay),
460        Action::PreviousOverlay => Some(Command::PreviousOverlay),
461        Action::FirstSlide => Some(Command::FirstSlide),
462        Action::LastSlide => Some(Command::LastSlide),
463        Action::ToggleFreeze => Some(Command::ToggleFreeze),
464        Action::ToggleBlackout => Some(Command::ToggleBlackout),
465        Action::ToggleWhiteboard => Some(Command::ToggleWhiteboard),
466        Action::ToggleLaser => Some(Command::ToggleLaser),
467        Action::CycleLaserStyle => Some(Command::CycleLaserStyle),
468        Action::ToggleInk => Some(Command::ToggleInk),
469        Action::ClearInk => Some(Command::ClearInk),
470        Action::CycleInkColor => Some(Command::CycleInkColor),
471        Action::CycleInkWidth => Some(Command::CycleInkWidth),
472        Action::ToggleSpotlight => Some(Command::ToggleSpotlight),
473        Action::ToggleZoom => Some(Command::ToggleZoom),
474        Action::ToggleOverview => Some(Command::ToggleSlideOverview),
475        Action::ToggleNotes => Some(Command::ToggleNotesPanel),
476        Action::ToggleNotesEdit => Some(Command::ToggleNotesEdit),
477        Action::ResetTimer => Some(Command::ResetTimer),
478        Action::IncrementNotesFont => Some(Command::IncrementNotesFontSize),
479        Action::DecrementNotesFont => Some(Command::DecrementNotesFontSize),
480        Action::ToggleScreenShare => Some(Command::ToggleScreenShareMode),
481        Action::TogglePresentationMode => Some(Command::TogglePresentationMode),
482        Action::ToggleTextBoxMode => Some(Command::ToggleTextBoxMode),
483        Action::Quit => Some(Command::Quit),
484        Action::SaveSidecar => Some(Command::SaveSidecar),
485        Action::GoToSlide | Action::StartPauseTimer => None,
486    }
487}