1use std::time::Instant;
6
7use dais_core::bus::CommandSender;
8use dais_core::commands::Command;
9use dais_core::keybindings::{Action, KeyCombo, KeybindingMap};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InputMode {
14 Normal,
16 Overview,
18 Ink,
20 Laser,
22 NotesEdit,
24 JumpToSlide,
26 TextBox,
28}
29
30#[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
50pub struct InputHandler {
52 sender: CommandSender,
53 keybindings: KeybindingMap,
54 mode: InputMode,
55 jump_buffer: String,
56 jump_start: Option<Instant>,
57 stroke_in_progress: bool,
60}
61
62const JUMP_TIMEOUT_SECS: f64 = 3.0;
64const DEFAULT_ZOOM_FACTOR: f32 = 1.5;
66const 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 pub fn handle_input(&mut self, ctx: &egui::Context, modes: UiModes) {
85 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 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 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 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 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 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 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 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 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 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
375pub 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
382fn 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
393fn egui_key_name(key: egui::Key) -> String {
395 egui_key_name_public(key)
396}
397
398pub 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 let debug = format!("{other:?}");
434 debug.to_lowercase()
435 }
436 }
437}
438
439fn 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
468fn 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}