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 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
52pub struct InputHandler {
54 sender: CommandSender,
55 keybindings: KeybindingMap,
56 mode: InputMode,
57 jump_buffer: String,
58 jump_start: Option<Instant>,
59 stroke_in_progress: bool,
62 erase_in_progress: bool,
64}
65
66const JUMP_TIMEOUT_SECS: f64 = 3.0;
68const DEFAULT_ZOOM_FACTOR: f32 = 1.5;
70const 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 pub fn handle_input(&mut self, ctx: &egui::Context, modes: UiModes) {
90 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 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 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 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 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 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 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 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 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 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
393pub 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
400fn 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
411fn egui_key_name(key: egui::Key) -> String {
413 egui_key_name_public(key)
414}
415
416pub 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 let debug = format!("{other:?}");
452 debug.to_lowercase()
453 }
454 }
455}
456
457fn 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
486fn 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}