1use std::sync::{Arc, RwLock};
2use std::time::Instant;
3
4use dais_core::bus::CommandReceiver;
5use dais_core::commands::Command;
6use dais_core::config::Config;
7use dais_core::slide_group::SlideGroup;
8use dais_core::state::{
9 ActivePen, DrawTool, InkStroke, PointerAppearance, PointerAppearances, PointerStyle,
10 PresentationState, TextBox, TimerState, ZoomRegion,
11};
12use dais_sidecar::markdown_notes::MarkdownNotesDocument;
13use dais_sidecar::types::{InkStrokeMeta, PresentationMetadata, TextBoxMeta};
14
15const INK_COLOR_FALLBACKS: &[[u8; 4]] = &[
17 [220, 30, 30, 255], [30, 100, 220, 255], [30, 180, 30, 255], [220, 200, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255], ];
24
25const MIN_INK_COLOR_PRESETS: usize = 6;
26
27const INK_WIDTH_PRESETS: &[f32] = &[1.0, 2.0, 4.0, 8.0, 16.0];
29
30const HIGHLIGHTER_COLOR_PRESETS: &[[u8; 4]] = &[
32 [255, 220, 0, 100], [60, 220, 60, 100], [0, 200, 255, 100], [255, 80, 180, 100], ];
37
38#[derive(Debug, Clone, Copy)]
39enum SidecarKind {
40 Dais,
41 Pdfpc,
42}
43
44pub struct PresentationEngine {
49 receiver: CommandReceiver,
50 state: PresentationState,
51 shared_state: Arc<RwLock<PresentationState>>,
52 timer_start: Option<Instant>,
53 slide_start: Instant,
54 ink_color_presets: Vec<[u8; 4]>,
56 text_box_default_color: [u8; 4],
58 text_box_default_background: Option<[u8; 4]>,
60 text_box_default_typst_prelude: String,
62 pdf_path: std::path::PathBuf,
64 sidecar_kind: SidecarKind,
66 metadata: PresentationMetadata,
68 save_slide_timings: bool,
70 markdown_notes: Option<MarkdownNotesDocument>,
72}
73
74impl PresentationEngine {
75 pub fn new(
79 total_pages: usize,
80 metadata: &PresentationMetadata,
81 config: &Config,
82 receiver: CommandReceiver,
83 pdf_path: std::path::PathBuf,
84 ) -> (Self, Arc<RwLock<PresentationState>>) {
85 Self::new_impl(total_pages, metadata, config, receiver, pdf_path, None)
86 }
87
88 pub fn new_with_notes(
90 total_pages: usize,
91 metadata: &PresentationMetadata,
92 config: &Config,
93 receiver: CommandReceiver,
94 pdf_path: std::path::PathBuf,
95 notes_path: &std::path::Path,
96 ) -> Result<(Self, Arc<RwLock<PresentationState>>), dais_sidecar::format::SidecarError> {
97 let slide_groups = build_slide_groups(total_pages, metadata);
98 let markdown_notes = MarkdownNotesDocument::read(notes_path, slide_groups.len())?;
99 Ok(Self::new_impl(total_pages, metadata, config, receiver, pdf_path, Some(markdown_notes)))
100 }
101
102 fn new_impl(
103 total_pages: usize,
104 metadata: &PresentationMetadata,
105 config: &Config,
106 receiver: CommandReceiver,
107 pdf_path: std::path::PathBuf,
108 markdown_notes: Option<MarkdownNotesDocument>,
109 ) -> (Self, Arc<RwLock<PresentationState>>) {
110 let slide_groups = build_slide_groups(total_pages, metadata);
111 let mut state = PresentationState::new(total_pages, slide_groups);
112 if let Some(notes) = &markdown_notes {
113 apply_markdown_notes(&mut state, notes);
114 }
115
116 let duration = match (config.timer.mode, config.timer.duration_minutes) {
118 (dais_core::state::TimerMode::Elapsed, None) => None,
119 (_, Some(minutes)) => Some(std::time::Duration::from_secs(u64::from(minutes) * 60)),
120 (dais_core::state::TimerMode::Countdown, None) => {
121 Some(std::time::Duration::from_mins(20))
122 }
123 };
124 let warning_threshold = match (duration, config.timer.warning_minutes) {
125 (Some(_), Some(minutes)) => {
126 Some(std::time::Duration::from_secs(u64::from(minutes) * 60))
127 }
128 _ => None,
129 };
130 state.timer = TimerState {
131 mode: config.timer.mode,
132 duration,
133 warning_threshold,
134 ..TimerState::default()
135 };
136 state.slide_target_durations = metadata
137 .slide_target_durations
138 .iter()
139 .filter_map(|(&slide, &seconds)| {
140 if slide < state.total_logical_slides && seconds.is_finite() && seconds > 0.0 {
141 Some((slide, std::time::Duration::from_secs_f64(seconds)))
142 } else {
143 None
144 }
145 })
146 .fold(vec![None; state.total_logical_slides], |mut targets, (slide, duration)| {
147 targets[slide] = Some(duration);
148 targets
149 });
150 state.notes_font_size = config.notes.font_size;
151 state.notes_font_size_step = config.notes.font_size_step;
152 state.pointer_style = parse_pointer_style(&config.laser.style);
153 state.pointer_appearances = pointer_appearances_from_config(config);
154 if config.display.mode == "single" {
155 state.laser_active = false;
156 }
157 state.spotlight_radius = config.spotlight.radius.clamp(16.0, 2048.0);
158 state.spotlight_dim_opacity = config.spotlight.dim_opacity.clamp(0.0, 1.0);
159
160 let ink_color_presets = ink_color_presets_from_config(&config.ink.colors);
162
163 state.active_pen = ActivePen {
164 color: ink_color_presets.first().copied().unwrap_or([255, 0, 0, 255]),
165 width: config.ink.width,
166 };
167 state.ink_color_presets.clone_from(&ink_color_presets);
168 let highlighter_color_presets: Vec<[u8; 4]> = if config.ink.highlighter_colors.is_empty() {
169 HIGHLIGHTER_COLOR_PRESETS.to_vec()
170 } else {
171 config.ink.highlighter_colors.iter().filter_map(|s| parse_hex_color(s)).collect()
172 };
173 state.active_highlighter = ActivePen {
174 color: highlighter_color_presets.first().copied().unwrap_or([255, 220, 0, 100]),
175 width: config.ink.highlighter_width,
176 };
177 state.highlighter_color_presets = highlighter_color_presets;
178 let text_box_default_color =
179 parse_hex_color(&config.text_boxes.color).unwrap_or([0, 0, 0, 255]);
180 let text_box_default_background = parse_optional_hex_color(&config.text_boxes.background);
181 let text_box_default_typst_prelude = config.text_boxes.typst_prelude.clone();
182
183 load_annotations_into_state(&mut state, metadata);
186
187 let shared_state = Arc::new(RwLock::new(state.clone()));
188
189 (
190 Self {
191 receiver,
192 state,
193 shared_state: Arc::clone(&shared_state),
194 timer_start: None,
195 slide_start: Instant::now(),
196 ink_color_presets,
197 text_box_default_color,
198 text_box_default_background,
199 text_box_default_typst_prelude,
200 pdf_path,
201 sidecar_kind: if config.normalized_sidecar_format() == "dais" {
202 SidecarKind::Dais
203 } else {
204 SidecarKind::Pdfpc
205 },
206 metadata: metadata.clone(),
207 save_slide_timings: config.save_slide_timings,
208 markdown_notes,
209 },
210 shared_state,
211 )
212 }
213
214 pub fn tick(&mut self) -> bool {
218 let timers_ticking = self.update_timers();
220
221 let commands = self.receiver.drain();
223 let mut should_quit = false;
224 let content_changed = !commands.is_empty();
225
226 for cmd in &commands {
227 if matches!(cmd, Command::Quit) {
228 if self.state.presentation_mode {
229 self.state.presentation_mode = false;
230 } else if self.state.overview_visible {
231 self.state.overview_visible = false;
232 } else if self.state.quit_requested {
233 self.save_sidecar();
234 should_quit = true;
235 } else {
236 self.state.quit_requested = true;
237 }
238 } else {
239 self.state.quit_requested = false;
240 }
241 self.process_command(cmd);
242 }
243
244 if content_changed {
249 if let Ok(mut shared) = self.shared_state.write() {
250 *shared = self.state.clone();
251 }
252 } else if timers_ticking && let Ok(mut shared) = self.shared_state.write() {
253 shared.timer.elapsed = self.state.timer.elapsed;
254 shared.slide_elapsed = self.state.slide_elapsed;
255 }
256
257 should_quit
258 }
259
260 pub fn state(&self) -> &PresentationState {
262 &self.state
263 }
264
265 fn update_timers(&mut self) -> bool {
268 let current = self.state.current_logical_slide;
269 let elapsed = self.slide_start.elapsed();
270 if let Some(total) = self.state.slide_elapsed_by_logical.get_mut(current) {
271 *total = elapsed;
272 self.state.slide_elapsed = *total;
273 } else {
274 self.state.slide_elapsed = elapsed;
275 }
276
277 if self.state.timer.running
278 && let Some(start) = self.timer_start
279 {
280 self.state.timer.elapsed = start.elapsed();
281 }
282
283 self.state.timer.running || self.state.slide_elapsed > std::time::Duration::ZERO
284 }
285
286 fn process_command(&mut self, cmd: &Command) {
287 match cmd {
288 Command::NextSlide
289 | Command::PreviousSlide
290 | Command::NextOverlay
291 | Command::PreviousOverlay
292 | Command::FirstSlide
293 | Command::LastSlide
294 | Command::GoToSlide(_) => self.handle_navigation(cmd),
295
296 Command::ToggleFreeze
297 | Command::ToggleBlackout
298 | Command::ToggleWhiteboard
299 | Command::ToggleScreenShareMode
300 | Command::TogglePresentationMode
301 | Command::SwapDisplays => {
302 self.handle_display_mode(cmd);
303 }
304
305 Command::ToggleLaser
306 | Command::CycleLaserStyle
307 | Command::SetPointerPosition(..)
308 | Command::ToggleInk
309 | Command::AddInkPoint(..)
310 | Command::FinishInkStroke
311 | Command::ClearInk
312 | Command::SetInkColor(_)
313 | Command::SetInkWidth(_)
314 | Command::CycleInkColor
315 | Command::CycleInkWidth
316 | Command::EraseInkNear { .. }
317 | Command::SetDrawTool(_)
318 | Command::ToggleSpotlight
319 | Command::SetSpotlightPosition(..)
320 | Command::ToggleZoom
321 | Command::SetZoomRegion { .. } => self.handle_aid(cmd),
322
323 Command::ToggleTextBoxMode
324 | Command::PlaceTextBox { .. }
325 | Command::EditTextBoxContent { .. }
326 | Command::MoveTextBox { .. }
327 | Command::ResizeTextBox { .. }
328 | Command::DeleteTextBox { .. }
329 | Command::SelectTextBox(_)
330 | Command::DeselectTextBox
331 | Command::BeginTextBoxEdit { .. }
332 | Command::SetTextBoxFontSize { .. }
333 | Command::SetTextBoxColor { .. }
334 | Command::SetTextBoxBackground { .. } => self.handle_text_box(cmd),
335
336 Command::StartTimer
337 | Command::PauseTimer
338 | Command::ToggleTimer
339 | Command::ResetTimer => {
340 self.handle_timer(cmd);
341 }
342
343 Command::ToggleSlideOverview
344 | Command::ToggleNotesPanel
345 | Command::ToggleNotesEdit
346 | Command::SetCurrentSlideNotes(_)
347 | Command::IncrementNotesFontSize
348 | Command::DecrementNotesFontSize => self.handle_ui_panel(cmd),
349
350 Command::Quit => {} Command::SaveSidecar => {
352 self.save_sidecar();
353 }
354 }
355 }
356
357 fn handle_navigation(&mut self, cmd: &Command) {
358 match *cmd {
359 Command::NextSlide => self.next_slide(),
360 Command::PreviousSlide => self.previous_slide(),
361 Command::NextOverlay => self.next_overlay(),
362 Command::PreviousOverlay => self.previous_overlay(),
363 Command::FirstSlide => self.go_to_group(0),
364 Command::LastSlide => {
365 let last = self.state.total_logical_slides.saturating_sub(1);
366 self.go_to_group(last);
367 }
368 Command::GoToSlide(index) => self.go_to_group(index),
369 _ => {}
370 }
371 }
372
373 fn handle_display_mode(&mut self, cmd: &Command) {
374 match *cmd {
375 Command::ToggleFreeze => {
376 if self.state.frozen {
377 self.state.frozen = false;
378 self.state.frozen_page = None;
379 } else {
380 self.state.frozen = true;
381 self.state.frozen_page = Some(self.state.current_page);
382 }
383 }
384 Command::ToggleBlackout => {
385 self.state.blacked_out = !self.state.blacked_out;
386 if self.state.blacked_out {
387 self.state.whiteboard_active = false;
388 }
389 }
390 Command::ToggleWhiteboard => {
391 self.state.whiteboard_active = !self.state.whiteboard_active;
392 if self.state.whiteboard_active {
393 self.state.blacked_out = false;
394 self.state.ink_active = true;
396 self.state.laser_active = false;
397 self.state.text_box_mode = false;
398 self.state.pointer_position = None;
399 self.reset_text_box_selection();
400 }
401 }
402 Command::ToggleScreenShareMode => {
403 self.state.screen_share_mode = !self.state.screen_share_mode;
404 }
405 Command::TogglePresentationMode => {
406 self.state.presentation_mode = !self.state.presentation_mode;
407 }
408 Command::SwapDisplays => {
409 self.state.displays_swapped = !self.state.displays_swapped;
410 }
411 _ => {}
412 }
413 }
414
415 fn handle_aid(&mut self, cmd: &Command) {
416 match *cmd {
417 Command::ToggleLaser => {
418 self.state.laser_active = !self.state.laser_active;
419 if !self.state.laser_active {
420 self.state.pointer_position = None;
421 }
422 if self.state.laser_active {
424 self.state.ink_active = false;
425 }
426 }
427 Command::CycleLaserStyle => {
428 self.state.pointer_style = match self.state.pointer_style {
429 PointerStyle::Dot => PointerStyle::Minimal,
430 PointerStyle::Minimal => PointerStyle::Crosshair,
431 PointerStyle::Crosshair => PointerStyle::Arrow,
432 PointerStyle::Arrow => PointerStyle::Ring,
433 PointerStyle::Ring => PointerStyle::Bullseye,
434 PointerStyle::Bullseye => PointerStyle::Highlight,
435 PointerStyle::Highlight => PointerStyle::Dot,
436 };
437 }
438 Command::SetPointerPosition(x, y) => {
439 let clamped = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
440 if self.state.laser_active || self.state.spotlight_active {
441 self.state.pointer_position = Some(clamped);
442 }
443 if self.state.spotlight_active {
444 self.state.spotlight_position = Some(clamped);
445 }
446 }
447 Command::ToggleInk
448 | Command::AddInkPoint(..)
449 | Command::FinishInkStroke
450 | Command::ClearInk
451 | Command::EraseInkNear { .. }
452 | Command::SetDrawTool(_)
453 | Command::SetInkColor(_)
454 | Command::SetInkWidth(_)
455 | Command::CycleInkColor
456 | Command::CycleInkWidth => self.handle_ink(cmd),
457 Command::ToggleSpotlight => {
458 self.state.spotlight_active = !self.state.spotlight_active;
459 if !self.state.spotlight_active {
460 self.state.spotlight_position = None;
461 }
462 }
463 Command::SetSpotlightPosition(x, y) if self.state.spotlight_active => {
464 self.state.spotlight_position = Some((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
465 }
466 Command::SetSpotlightPosition(..) => {}
467 Command::ToggleZoom => {
468 self.state.zoom_active = !self.state.zoom_active;
469 if !self.state.zoom_active {
470 self.state.zoom_region = None;
471 }
472 }
473 Command::SetZoomRegion { center, factor } if self.state.zoom_active => {
474 self.state.zoom_region = Some(ZoomRegion {
475 center: (center.0.clamp(0.0, 1.0), center.1.clamp(0.0, 1.0)),
476 factor: factor.clamp(1.0, 10.0),
477 });
478 }
479 Command::SetZoomRegion { .. } => {}
480 _ => {}
481 }
482 }
483
484 fn handle_ink(&mut self, cmd: &Command) {
485 match *cmd {
486 Command::ToggleInk => {
487 self.state.ink_active = !self.state.ink_active;
488 if self.state.ink_active {
489 self.state.laser_active = false;
490 self.state.pointer_position = None;
491 }
492 }
493 Command::AddInkPoint(x, y) if self.state.ink_active => {
494 let point = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
495 let active_pen = match self.state.draw_tool {
496 DrawTool::Highlighter => self.state.active_highlighter,
497 _ => self.state.active_pen,
498 };
499 let strokes = if self.state.whiteboard_active {
500 &mut self.state.whiteboard_strokes
501 } else {
502 self.state.slide_ink_by_page.entry(self.state.current_page).or_default()
503 };
504 if let Some(stroke) = strokes.last_mut()
505 && !stroke.finished
506 {
507 stroke.points.push(point);
508 return;
509 }
510 strokes.push(InkStroke {
512 points: vec![point],
513 color: active_pen.color,
514 width: active_pen.width,
515 finished: false,
516 });
517 }
518 Command::AddInkPoint(..) => {}
519 Command::FinishInkStroke => {
520 let strokes = if self.state.whiteboard_active {
521 &mut self.state.whiteboard_strokes
522 } else {
523 self.state.slide_ink_by_page.entry(self.state.current_page).or_default()
524 };
525 if let Some(stroke) = strokes.last_mut() {
526 stroke.finished = true;
527 }
528 }
529 Command::ClearInk => {
530 if self.state.whiteboard_active {
531 self.state.whiteboard_strokes.clear();
532 } else {
533 self.state.slide_ink_by_page.remove(&self.state.current_page);
534 }
535 }
536 Command::SetInkColor(color) => match self.state.draw_tool {
537 DrawTool::Highlighter => self.state.active_highlighter.color = color,
538 _ => self.state.active_pen.color = color,
539 },
540 Command::SetInkWidth(width) => match self.state.draw_tool {
541 DrawTool::Highlighter => self.state.active_highlighter.width = width.max(0.5),
542 DrawTool::Eraser => self.state.eraser_radius = width.clamp(0.005, 0.5),
543 DrawTool::Pen => self.state.active_pen.width = width.max(0.5),
544 },
545 Command::CycleInkColor if !self.ink_color_presets.is_empty() => {
546 let current = self.state.active_pen.color;
547 let idx = self.ink_color_presets.iter().position(|c| *c == current).unwrap_or(0);
548 self.state.active_pen.color =
549 self.ink_color_presets[(idx + 1) % self.ink_color_presets.len()];
550 }
551 Command::CycleInkColor => {}
552 Command::CycleInkWidth => {
553 let current = self.state.active_pen.width;
554 self.state.active_pen.width = INK_WIDTH_PRESETS
558 .iter()
559 .find(|&&w| w > current)
560 .copied()
561 .unwrap_or(INK_WIDTH_PRESETS[0]);
562 }
563 Command::SetDrawTool(tool) => {
564 self.state.draw_tool = tool;
565 }
566 Command::EraseInkNear { x, y, radius } => {
567 if self.state.whiteboard_active {
568 self.state.whiteboard_strokes =
569 erase_strokes_near(&self.state.whiteboard_strokes, x, y, radius);
570 } else {
571 let page = self.state.current_page;
572 let strokes =
573 self.state.slide_ink_by_page.get(&page).cloned().unwrap_or_default();
574 let new_strokes = erase_strokes_near(&strokes, x, y, radius);
575 if new_strokes.is_empty() {
576 self.state.slide_ink_by_page.remove(&page);
577 } else {
578 self.state.slide_ink_by_page.insert(page, new_strokes);
579 }
580 }
581 }
582 _ => {}
583 }
584 }
585
586 fn reset_text_box_selection(&mut self) {
587 self.state.selected_text_box = None;
588 self.state.text_box_editing = false;
589 }
590
591 fn update_current_text_box_mut(&mut self, id: u64, f: impl FnOnce(&mut TextBox)) {
592 let page = self.state.current_page;
593 if let Some(boxes) = self.state.slide_text_boxes_by_page.get_mut(&page)
594 && let Some(tb) = boxes.iter_mut().find(|b| b.id == id)
595 {
596 f(tb);
597 }
598 }
599
600 fn create_text_box(&mut self, x: f32, y: f32, w: f32, h: f32) {
601 let id = self.state.next_text_box_id;
602 self.state.next_text_box_id += 1;
603 let x = x.clamp(0.0, 1.0);
604 let y = y.clamp(0.0, 1.0);
605 let w = w.clamp(0.01, 1.0 - x);
606 let h = h.clamp(0.01, 1.0 - y);
607 let text_box = TextBox {
608 id,
609 rect: (x, y, w, h),
610 content: String::new(),
611 font_size: 30.0,
612 color: self.text_box_default_color,
613 background: self.text_box_default_background,
614 typst_prelude: self.text_box_default_typst_prelude.clone(),
615 };
616 self.state
617 .slide_text_boxes_by_page
618 .entry(self.state.current_page)
619 .or_default()
620 .push(text_box);
621 self.state.selected_text_box = Some(id);
622 self.state.text_box_editing = true;
623 }
624
625 fn handle_text_box(&mut self, cmd: &Command) {
626 match cmd {
627 Command::ToggleTextBoxMode => {
628 self.state.text_box_mode = !self.state.text_box_mode;
629 if self.state.text_box_mode {
630 self.state.ink_active = false;
632 self.state.laser_active = false;
633 self.state.pointer_position = None;
634 } else {
635 self.reset_text_box_selection();
636 }
637 }
638 Command::PlaceTextBox { x, y, w, h } => self.create_text_box(*x, *y, *w, *h),
639 Command::EditTextBoxContent { id, content } => {
640 self.update_current_text_box_mut(*id, |tb| tb.content.clone_from(content));
641 }
642 Command::MoveTextBox { id, x, y } => {
643 self.update_current_text_box_mut(*id, |tb| {
644 let (_, _, w, h) = tb.rect;
645 tb.rect = (x.clamp(0.0, 1.0 - w), y.clamp(0.0, 1.0 - h), w, h);
646 });
647 }
648 Command::ResizeTextBox { id, w, h } => {
649 self.update_current_text_box_mut(*id, |tb| {
650 let (x, y, _, _) = tb.rect;
651 let w = w.clamp(0.02, 1.0 - x);
652 let h = h.clamp(0.02, 1.0 - y);
653 tb.rect = (x, y, w, h);
654 });
655 }
656 Command::DeleteTextBox { id } => {
657 let page = self.state.current_page;
658 if let Some(boxes) = self.state.slide_text_boxes_by_page.get_mut(&page) {
659 boxes.retain(|b| b.id != *id);
660 }
661 if self.state.selected_text_box == Some(*id) {
662 self.reset_text_box_selection();
663 }
664 }
665 Command::SelectTextBox(id) => {
666 self.state.selected_text_box = Some(*id);
667 self.state.text_box_editing = false;
668 }
669 Command::DeselectTextBox => self.reset_text_box_selection(),
670 Command::BeginTextBoxEdit { id } => {
671 self.state.selected_text_box = Some(*id);
672 self.state.text_box_editing = true;
673 }
674 Command::SetTextBoxFontSize { id, size } => {
675 self.update_current_text_box_mut(*id, |tb| tb.font_size = size.clamp(6.0, 144.0));
676 }
677 Command::SetTextBoxColor { id, color } => {
678 self.update_current_text_box_mut(*id, |tb| tb.color = *color);
679 }
680 Command::SetTextBoxBackground { id, color } => {
681 self.update_current_text_box_mut(*id, |tb| tb.background = *color);
682 }
683 _ => {}
684 }
685 }
686
687 fn handle_timer(&mut self, cmd: &Command) {
688 match *cmd {
689 Command::ToggleTimer => {
690 if self.state.timer.running {
691 self.state.timer.running = false;
692 } else {
693 self.state.timer.running = true;
694 self.timer_start = Some(
695 Instant::now()
696 .checked_sub(self.state.timer.elapsed)
697 .unwrap_or_else(Instant::now),
698 );
699 }
700 }
701 Command::StartTimer if !self.state.timer.running => {
702 self.state.timer.running = true;
703 self.timer_start = Some(
704 Instant::now()
705 .checked_sub(self.state.timer.elapsed)
706 .unwrap_or_else(Instant::now),
707 );
708 }
709 Command::StartTimer => {}
710 Command::PauseTimer => self.state.timer.running = false,
711 Command::ResetTimer => {
712 self.state.timer.running = false;
713 self.state.timer.elapsed = std::time::Duration::ZERO;
714 self.timer_start = None;
715 }
716 _ => {}
717 }
718 }
719
720 fn handle_ui_panel(&mut self, cmd: &Command) {
721 match *cmd {
722 Command::ToggleSlideOverview => {
723 self.state.overview_visible = !self.state.overview_visible;
724 }
725 Command::ToggleNotesPanel => {
726 self.state.notes_visible = !self.state.notes_visible;
727 if !self.state.notes_visible {
728 self.state.notes_editing = false;
729 }
730 }
731 Command::ToggleNotesEdit => {
732 self.state.notes_editing = !self.state.notes_editing;
733 if self.state.notes_editing {
734 self.state.notes_visible = true;
735 }
736 }
737 Command::SetCurrentSlideNotes(ref text) => {
738 let notes = if text.trim().is_empty() { None } else { Some(text.clone()) };
739 if let Some(group) =
740 self.state.slide_groups.get_mut(self.state.current_logical_slide)
741 {
742 group.notes.clone_from(¬es);
743 }
744 self.state.current_notes = notes;
745 }
746 Command::IncrementNotesFontSize => {
747 self.state.notes_font_size =
748 (self.state.notes_font_size + self.state.notes_font_size_step).min(72.0);
749 }
750 Command::DecrementNotesFontSize => {
751 self.state.notes_font_size =
752 (self.state.notes_font_size - self.state.notes_font_size_step).max(8.0);
753 }
754 _ => {}
755 }
756 }
757
758 fn save_sidecar(&self) {
761 use dais_sidecar::format::SidecarFormat;
762 use dais_sidecar::types::SlideGroupMeta;
763
764 let mut notes = std::collections::HashMap::new();
767 let mut groups = Vec::new();
768 for group in &self.state.slide_groups {
769 if let (Some(&start), Some(&end)) = (group.pages.first(), group.pages.last()) {
770 groups.push(SlideGroupMeta { start_page: start, end_page: end });
771 }
772 if let Some(ref text) = group.notes
773 && let Some(&page) = group.pages.first()
774 {
775 notes.insert(page, text.clone());
776 }
777 }
778
779 let mut metadata = self.metadata.clone();
780 metadata.groups = groups;
781 metadata.notes = if self.markdown_notes.is_some() {
782 std::collections::HashMap::new()
783 } else {
784 notes.clone()
785 };
786
787 if self.save_slide_timings {
788 let mut slide_timings = std::collections::HashMap::new();
790 for (i, dur) in self.state.slide_elapsed_by_logical.iter().enumerate() {
791 let secs = dur.as_secs_f64();
792 if secs > 0.0 {
793 slide_timings.insert(i, secs);
794 }
795 }
796 metadata.slide_timings = slide_timings;
797 }
798
799 metadata.slide_annotations = self
801 .state
802 .slide_ink_by_page
803 .iter()
804 .filter(|(_, strokes)| !strokes.is_empty())
805 .map(|(&page, strokes)| {
806 (
807 page,
808 strokes
809 .iter()
810 .filter(|s| s.finished)
811 .map(|s| InkStrokeMeta {
812 points: s.points.clone(),
813 color: s.color,
814 width: s.width,
815 })
816 .collect(),
817 )
818 })
819 .collect();
820 metadata.whiteboard_annotations = self
821 .state
822 .whiteboard_strokes
823 .iter()
824 .filter(|s| s.finished)
825 .map(|s| InkStrokeMeta { points: s.points.clone(), color: s.color, width: s.width })
826 .collect();
827
828 metadata.slide_text_boxes = self
830 .state
831 .slide_text_boxes_by_page
832 .iter()
833 .filter(|(_, boxes)| !boxes.is_empty())
834 .map(|(&page, boxes)| {
835 (
836 page,
837 boxes
838 .iter()
839 .map(|tb| TextBoxMeta {
840 id: tb.id,
841 rect: tb.rect,
842 content: tb.content.clone(),
843 font_size: tb.font_size,
844 color: tb.color,
845 background: tb.background,
846 typst_prelude: tb.typst_prelude.clone(),
847 })
848 .collect(),
849 )
850 })
851 .collect();
852
853 let (format, sidecar_path): (Box<dyn SidecarFormat>, _) = match self.sidecar_kind {
854 SidecarKind::Dais => (
855 Box::new(dais_sidecar::dais_format::DaisFormat),
856 self.pdf_path.with_extension("dais"),
857 ),
858 SidecarKind::Pdfpc => {
859 (Box::new(dais_sidecar::pdfpc::PdfpcFormat), self.pdf_path.with_extension("pdfpc"))
860 }
861 };
862
863 match format.write(&sidecar_path, &metadata) {
864 Ok(()) => tracing::info!("Saved sidecar to {}", sidecar_path.display()),
865 Err(e) => tracing::error!("Failed to save sidecar: {e}"),
866 }
867
868 if let Some(markdown_notes) = &self.markdown_notes {
869 match markdown_notes.write_notes(¬es_by_logical_slide(&self.state)) {
870 Ok(()) => tracing::info!("Saved Markdown notes"),
871 Err(e) => tracing::error!("Failed to save Markdown notes: {e}"),
872 }
873 }
874 }
875
876 fn next_slide(&mut self) {
879 self.advance_step();
880 }
881
882 fn previous_slide(&mut self) {
883 self.rewind_step();
884 }
885
886 fn next_overlay(&mut self) {
887 self.advance_step();
888 }
889
890 fn previous_overlay(&mut self) {
891 self.rewind_step();
892 }
893
894 fn advance_step(&mut self) {
895 if self.state.blacked_out {
896 return;
897 }
898 if self.state.slide_groups.is_empty() {
899 return;
900 }
901 let group = &self.state.slide_groups[self.state.current_logical_slide];
902 let overlay = self.state.current_overlay_within_group;
903 if overlay + 1 < group.pages.len() {
904 self.state.current_overlay_within_group = overlay + 1;
905 self.state.current_page = group.pages[overlay + 1];
906 } else {
907 let current = self.state.current_logical_slide;
908 if current + 1 < self.state.total_logical_slides {
909 self.state.blacked_out = false;
910 self.go_to_group(current + 1);
911 } else {
912 self.state.blacked_out = true;
913 }
914 }
915 }
916
917 fn rewind_step(&mut self) {
918 if self.state.blacked_out {
919 self.state.blacked_out = false;
920 return;
921 }
922 if self.state.slide_groups.is_empty() {
923 return;
924 }
925 let overlay = self.state.current_overlay_within_group;
926 if overlay > 0 {
927 let group = &self.state.slide_groups[self.state.current_logical_slide];
928 self.state.current_overlay_within_group = overlay - 1;
929 self.state.current_page = group.pages[overlay - 1];
930 } else {
931 let current = self.state.current_logical_slide;
932 if current > 0 {
933 let last_overlay = self.state.slide_groups[current - 1].pages.len() - 1;
934 self.go_to_group(current - 1);
935 self.state.current_overlay_within_group = last_overlay;
936 self.state.current_page = self.state.slide_groups[current - 1].pages[last_overlay];
937 }
938 }
939 }
940
941 fn go_to_group(&mut self, group_index: usize) {
942 if self.state.slide_groups.is_empty() || self.state.total_logical_slides == 0 {
943 return;
944 }
945 let clamped = group_index.min(self.state.total_logical_slides - 1);
947 self.state.blacked_out = false;
948 self.state.current_logical_slide = clamped;
949 self.state.current_overlay_within_group = 0;
950 self.state.current_page = self.state.slide_groups[clamped].pages[0];
951 let accumulated = self
952 .state
953 .slide_elapsed_by_logical
954 .get(clamped)
955 .copied()
956 .unwrap_or(std::time::Duration::ZERO);
957 self.slide_start = Instant::now().checked_sub(accumulated).unwrap_or_else(Instant::now);
958 self.state.slide_elapsed = accumulated;
959 self.update_notes();
960 }
961
962 fn update_notes(&mut self) {
963 self.state.current_notes = self
964 .state
965 .slide_groups
966 .get(self.state.current_logical_slide)
967 .and_then(|g| g.notes.clone());
968 }
969}
970
971fn build_slide_groups(total_pages: usize, metadata: &PresentationMetadata) -> Vec<SlideGroup> {
976 if metadata.groups.is_empty() {
977 let mut groups = dais_core::slide_group::default_grouping(total_pages);
979 for group in &mut groups {
980 if let Some(page) = group.pages.first() {
981 group.notes = metadata.notes.get(page).cloned();
982 }
983 }
984 return groups;
985 }
986
987 let mut group_by_start: std::collections::HashMap<usize, &dais_sidecar::types::SlideGroupMeta> =
991 metadata
992 .groups
993 .iter()
994 .filter(|gm| gm.start_page < total_pages)
995 .map(|gm| (gm.start_page, gm))
996 .collect();
997
998 let mut groups: Vec<SlideGroup> = Vec::new();
999 let mut page = 0usize;
1000 while page < total_pages {
1001 let logical_index = groups.len();
1002 if let Some(gm) = group_by_start.remove(&page) {
1003 let end = gm.end_page.min(total_pages - 1);
1004 let pages: Vec<usize> = (page..=end).collect();
1005 let notes = metadata.notes.get(&page).cloned();
1006 groups.push(SlideGroup { logical_index, pages, notes });
1007 page = end + 1;
1008 } else {
1009 let notes = metadata.notes.get(&page).cloned();
1010 groups.push(SlideGroup { logical_index, pages: vec![page], notes });
1011 page += 1;
1012 }
1013 }
1014
1015 groups
1016}
1017
1018fn apply_markdown_notes(state: &mut PresentationState, notes: &MarkdownNotesDocument) {
1019 let notes_by_slide = notes.notes_by_slide();
1020 for group in &mut state.slide_groups {
1021 group.notes = notes_by_slide.get(&group.logical_index).cloned();
1022 }
1023 state.current_notes =
1024 state.slide_groups.get(state.current_logical_slide).and_then(|group| group.notes.clone());
1025}
1026
1027fn notes_by_logical_slide(state: &PresentationState) -> std::collections::HashMap<usize, String> {
1028 state
1029 .slide_groups
1030 .iter()
1031 .filter_map(|group| {
1032 group.notes.as_ref().and_then(|notes| {
1033 (!notes.trim().is_empty()).then_some((group.logical_index, notes.clone()))
1034 })
1035 })
1036 .collect()
1037}
1038
1039fn parse_hex_color(color_str: &str) -> Option<[u8; 4]> {
1041 let hex = color_str.strip_prefix('#').unwrap_or(color_str);
1042 if hex.len() == 6 {
1043 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
1044 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
1045 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
1046 Some([red, green, blue, 255])
1047 } else if hex.len() == 8 {
1048 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
1049 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
1050 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
1051 let alpha = u8::from_str_radix(&hex[6..8], 16).ok()?;
1052 Some([red, green, blue, alpha])
1053 } else {
1054 None
1055 }
1056}
1057
1058fn ink_color_presets_from_config(colors: &[String]) -> Vec<[u8; 4]> {
1059 let mut presets: Vec<[u8; 4]> = colors.iter().filter_map(|s| parse_hex_color(s)).collect();
1060 let fallback_start = usize::from(!presets.is_empty());
1061 for &fallback in &INK_COLOR_FALLBACKS[fallback_start..] {
1062 if presets.len() >= MIN_INK_COLOR_PRESETS {
1063 break;
1064 }
1065 presets.push(fallback);
1066 }
1067 presets
1068}
1069
1070fn parse_optional_hex_color(color_str: &str) -> Option<[u8; 4]> {
1071 if color_str.trim().eq_ignore_ascii_case("transparent") {
1072 None
1073 } else {
1074 parse_hex_color(color_str)
1075 }
1076}
1077
1078fn parse_pointer_style(style: &str) -> PointerStyle {
1079 match style.trim().to_ascii_lowercase().as_str() {
1080 "minimal" => PointerStyle::Minimal,
1081 "crosshair" => PointerStyle::Crosshair,
1082 "arrow" => PointerStyle::Arrow,
1083 "ring" => PointerStyle::Ring,
1084 "bullseye" => PointerStyle::Bullseye,
1085 "highlight" => PointerStyle::Highlight,
1086 _ => PointerStyle::Dot,
1087 }
1088}
1089
1090fn pointer_appearances_from_config(config: &Config) -> PointerAppearances {
1091 PointerAppearances {
1092 dot: pointer_appearance_from_config(&config.laser.dot),
1093 minimal: pointer_appearance_from_config(&config.laser.minimal),
1094 crosshair: pointer_appearance_from_config(&config.laser.crosshair),
1095 arrow: pointer_appearance_from_config(&config.laser.arrow),
1096 ring: pointer_appearance_from_config(&config.laser.ring),
1097 bullseye: pointer_appearance_from_config(&config.laser.bullseye),
1098 highlight: pointer_appearance_from_config(&config.laser.highlight),
1099 }
1100}
1101
1102fn pointer_appearance_from_config(
1103 config: &dais_core::config::PointerStyleConfig,
1104) -> PointerAppearance {
1105 PointerAppearance {
1106 color: parse_hex_color(&config.color).unwrap_or([255, 0, 0, 255]),
1107 size: config.size.clamp(2.0, 96.0),
1108 }
1109}
1110
1111fn load_annotations_into_state(state: &mut PresentationState, metadata: &PresentationMetadata) {
1113 for (page, strokes) in &metadata.slide_annotations {
1114 let runtime_strokes: Vec<InkStroke> = strokes
1115 .iter()
1116 .map(|s| InkStroke {
1117 points: s.points.clone(),
1118 color: s.color,
1119 width: s.width,
1120 finished: true,
1121 })
1122 .collect();
1123 if !runtime_strokes.is_empty() {
1124 state.slide_ink_by_page.insert(*page, runtime_strokes);
1125 }
1126 }
1127 state.whiteboard_strokes = metadata
1128 .whiteboard_annotations
1129 .iter()
1130 .map(|s| InkStroke {
1131 points: s.points.clone(),
1132 color: s.color,
1133 width: s.width,
1134 finished: true,
1135 })
1136 .collect();
1137
1138 let mut max_id: u64 = 0;
1140 for (page, boxes) in &metadata.slide_text_boxes {
1141 let runtime_boxes: Vec<TextBox> = boxes
1142 .iter()
1143 .map(|tb| {
1144 if tb.id > max_id {
1145 max_id = tb.id;
1146 }
1147 TextBox {
1148 id: tb.id,
1149 rect: tb.rect,
1150 content: tb.content.clone(),
1151 font_size: tb.font_size,
1152 color: tb.color,
1153 background: tb.background,
1154 typst_prelude: tb.typst_prelude.clone(),
1155 }
1156 })
1157 .collect();
1158 if !runtime_boxes.is_empty() {
1159 state.slide_text_boxes_by_page.insert(*page, runtime_boxes);
1160 }
1161 }
1162 state.next_text_box_id = max_id + 1;
1164}
1165
1166fn erase_strokes_near(strokes: &[InkStroke], cx: f32, cy: f32, radius: f32) -> Vec<InkStroke> {
1171 let mut result = Vec::new();
1172 for stroke in strokes {
1173 clip_stroke_against_circle(stroke, cx, cy, radius, &mut result);
1174 }
1175 result
1176}
1177
1178fn clip_stroke_against_circle(
1179 stroke: &InkStroke,
1180 cx: f32,
1181 cy: f32,
1182 radius: f32,
1183 out: &mut Vec<InkStroke>,
1184) {
1185 let points = &stroke.points;
1186 if points.is_empty() {
1187 return;
1188 }
1189
1190 if points.len() == 1 {
1192 let (x, y) = points[0];
1193 let dx = x - cx;
1194 let dy = y - cy;
1195 if dx * dx + dy * dy > radius * radius {
1196 out.push(stroke.clone());
1197 }
1198 return;
1199 }
1200
1201 let mut current: Vec<(f32, f32)> = Vec::new();
1202
1203 for window in points.windows(2) {
1204 let p1 = window[0];
1205 let p2 = window[1];
1206
1207 match clip_segment_outside_circle(p1, p2, cx, cy, radius) {
1208 SegmentClip::Full => {
1209 if current.is_empty() {
1210 current.push(p1);
1211 }
1212 current.push(p2);
1213 }
1214 SegmentClip::None => {
1215 flush_sub_stroke(¤t, stroke, out);
1216 current.clear();
1217 }
1218 SegmentClip::Start(ep) => {
1219 if current.is_empty() {
1220 current.push(p1);
1221 }
1222 current.push(ep);
1223 flush_sub_stroke(¤t, stroke, out);
1224 current.clear();
1225 }
1226 SegmentClip::End(sp) => {
1227 flush_sub_stroke(¤t, stroke, out);
1228 current.clear();
1229 current.push(sp);
1230 current.push(p2);
1231 }
1232 SegmentClip::Both(ep, sp) => {
1233 if current.is_empty() {
1234 current.push(p1);
1235 }
1236 current.push(ep);
1237 flush_sub_stroke(¤t, stroke, out);
1238 current.clear();
1239 current.push(sp);
1240 current.push(p2);
1241 }
1242 }
1243 }
1244
1245 flush_sub_stroke(¤t, stroke, out);
1246}
1247
1248fn flush_sub_stroke(current: &[(f32, f32)], template: &InkStroke, out: &mut Vec<InkStroke>) {
1249 if current.len() >= 2 {
1250 out.push(InkStroke {
1251 points: current.to_owned(),
1252 color: template.color,
1253 width: template.width,
1254 finished: true,
1255 });
1256 }
1257}
1258
1259enum SegmentClip {
1261 Full,
1263 None,
1265 Start((f32, f32)),
1267 End((f32, f32)),
1269 Both((f32, f32), (f32, f32)),
1271}
1272
1273fn clip_segment_outside_circle(
1275 p1: (f32, f32),
1276 p2: (f32, f32),
1277 cx: f32,
1278 cy: f32,
1279 radius: f32,
1280) -> SegmentClip {
1281 let (x1, y1) = p1;
1282 let (x2, y2) = p2;
1283
1284 let fx = x1 - cx;
1285 let fy = y1 - cy;
1286 let ex = x2 - cx;
1287 let ey = y2 - cy;
1288
1289 let r2 = radius * radius;
1290 let p1_inside = fx * fx + fy * fy <= r2;
1291 let p2_inside = ex * ex + ey * ey <= r2;
1292
1293 if p1_inside && p2_inside {
1294 return SegmentClip::None;
1295 }
1296
1297 let dx = x2 - x1;
1298 let dy = y2 - y1;
1299 let a = dx * dx + dy * dy;
1300
1301 if a < 1e-12 {
1302 return SegmentClip::Full;
1304 }
1305
1306 let b = 2.0 * (fx * dx + fy * dy);
1307 let c = fx * fx + fy * fy - r2;
1308 let discriminant = b * b - 4.0 * a * c;
1309
1310 if discriminant <= 0.0 {
1311 return SegmentClip::Full;
1313 }
1314
1315 let sqrt_disc = discriminant.sqrt();
1316 let t1 = (-b - sqrt_disc) / (2.0 * a);
1318 let t2 = (-b + sqrt_disc) / (2.0 * a);
1319
1320 let t1_in = (0.0..=1.0).contains(&t1);
1321 let t2_in = (0.0..=1.0).contains(&t2);
1322
1323 match (p1_inside, p2_inside) {
1324 (false, false) => {
1325 if t1_in && t2_in {
1326 SegmentClip::Both(lerp(p1, p2, t1), lerp(p1, p2, t2))
1328 } else {
1329 SegmentClip::Full
1331 }
1332 }
1333 (false, true) => {
1334 let entry = if t1_in { lerp(p1, p2, t1) } else { p1 };
1336 SegmentClip::Start(entry)
1337 }
1338 (true, false) => {
1339 let exit_pt = if t2_in { lerp(p1, p2, t2) } else { p2 };
1341 SegmentClip::End(exit_pt)
1342 }
1343 (true, true) => unreachable!("both-inside handled above"),
1344 }
1345}
1346
1347fn lerp(p1: (f32, f32), p2: (f32, f32), t: f32) -> (f32, f32) {
1348 (p1.0 + t * (p2.0 - p1.0), p1.1 + t * (p2.1 - p1.1))
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use super::*;
1354 use dais_core::bus::CommandBus;
1355 use dais_sidecar::types::SlideGroupMeta;
1356 use std::collections::HashMap;
1357 use std::path::PathBuf;
1358 use std::time::{SystemTime, UNIX_EPOCH};
1359
1360 fn make_engine(
1361 total_pages: usize,
1362 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1363 make_engine_with_metadata(total_pages, &test_metadata())
1364 }
1365
1366 fn test_metadata() -> PresentationMetadata {
1367 PresentationMetadata {
1368 slide_timings: HashMap::from([(0, 9.7e-6)]),
1369 ..PresentationMetadata::default()
1370 }
1371 }
1372
1373 fn make_engine_with_metadata(
1374 total_pages: usize,
1375 metadata: &PresentationMetadata,
1376 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1377 make_engine_with_config(total_pages, metadata, &Config::default())
1378 }
1379
1380 fn make_engine_with_config(
1381 total_pages: usize,
1382 metadata: &PresentationMetadata,
1383 config: &Config,
1384 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1385 let mut config = config.clone();
1386 config.save_slide_timings = false;
1387 let bus = CommandBus::new();
1388 let sender = bus.sender();
1389 let receiver = bus.into_receiver();
1390 let (engine, shared) = PresentationEngine::new(
1391 total_pages,
1392 metadata,
1393 &config,
1394 receiver,
1395 std::path::PathBuf::from("test.pdf"),
1396 );
1397 (engine, shared, sender)
1398 }
1399
1400 fn temp_test_dir(name: &str) -> PathBuf {
1401 let suffix = SystemTime::now()
1402 .duration_since(UNIX_EPOCH)
1403 .expect("system time should be after unix epoch")
1404 .as_nanos();
1405 let dir = std::env::temp_dir().join(format!("dais_engine_{name}_{suffix}"));
1406 std::fs::create_dir_all(&dir).expect("should create temp test dir");
1407 dir
1408 }
1409
1410 fn make_engine_with_markdown_notes(
1411 total_pages: usize,
1412 metadata: &PresentationMetadata,
1413 pdf_path: PathBuf,
1414 notes_path: &std::path::Path,
1415 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1416 let config = Config { save_slide_timings: false, ..Config::default() };
1417 let bus = CommandBus::new();
1418 let sender = bus.sender();
1419 let receiver = bus.into_receiver();
1420 let (engine, shared) = PresentationEngine::new_with_notes(
1421 total_pages,
1422 metadata,
1423 &config,
1424 receiver,
1425 pdf_path,
1426 notes_path,
1427 )
1428 .expect("markdown notes should load");
1429 (engine, shared, sender)
1430 }
1431
1432 #[test]
1435 fn parse_hex_color_6_digit() {
1436 assert_eq!(parse_hex_color("#FF0000"), Some([255, 0, 0, 255]));
1437 assert_eq!(parse_hex_color("00FF00"), Some([0, 255, 0, 255]));
1438 assert_eq!(parse_hex_color("#0000ff"), Some([0, 0, 255, 255]));
1439 }
1440
1441 #[test]
1442 fn parse_hex_color_8_digit() {
1443 assert_eq!(parse_hex_color("#FF000080"), Some([255, 0, 0, 128]));
1444 }
1445
1446 #[test]
1447 fn parse_hex_color_invalid() {
1448 assert_eq!(parse_hex_color(""), None);
1449 assert_eq!(parse_hex_color("#FFF"), None);
1450 assert_eq!(parse_hex_color("ZZZZZZ"), None);
1451 }
1452
1453 #[test]
1454 fn parse_optional_hex_color_transparent() {
1455 assert_eq!(parse_optional_hex_color("transparent"), None);
1456 assert_eq!(parse_optional_hex_color(" TRANSPARENT "), None);
1457 assert_eq!(parse_optional_hex_color("#01020380"), Some([1, 2, 3, 128]));
1458 }
1459
1460 #[test]
1461 fn parse_pointer_style_variants() {
1462 assert_eq!(parse_pointer_style("dot"), PointerStyle::Dot);
1463 assert_eq!(parse_pointer_style("minimal"), PointerStyle::Minimal);
1464 assert_eq!(parse_pointer_style("crosshair"), PointerStyle::Crosshair);
1465 assert_eq!(parse_pointer_style("arrow"), PointerStyle::Arrow);
1466 assert_eq!(parse_pointer_style("ring"), PointerStyle::Ring);
1467 assert_eq!(parse_pointer_style("bullseye"), PointerStyle::Bullseye);
1468 assert_eq!(parse_pointer_style("highlight"), PointerStyle::Highlight);
1469 assert_eq!(parse_pointer_style("unknown"), PointerStyle::Dot);
1470 }
1471
1472 #[test]
1473 fn presentation_aid_config_populates_state() {
1474 let mut config = Config::default();
1475 config.laser.style = "crosshair".to_string();
1476 config.laser.crosshair.color = "#33CC66AA".to_string();
1477 config.laser.crosshair.size = 24.0;
1478 config.spotlight.radius = 220.0;
1479 config.spotlight.dim_opacity = 0.35;
1480
1481 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
1482
1483 let pointer = engine.state().current_pointer_appearance();
1484 assert_eq!(pointer.color, [0x33, 0xCC, 0x66, 0xAA]);
1485 assert!((pointer.size - 24.0).abs() < f32::EPSILON);
1486 assert_eq!(engine.state().pointer_style, PointerStyle::Crosshair);
1487 assert!((engine.state().spotlight_radius - 220.0).abs() < f32::EPSILON);
1488 assert!((engine.state().spotlight_dim_opacity - 0.35).abs() < f32::EPSILON);
1489 }
1490
1491 #[test]
1492 fn per_style_pointer_config_populates_state() {
1493 let mut config = Config::default();
1494 config.laser.style = "crosshair".to_string();
1495 config.laser.dot.color = "#FFFFFF".to_string();
1496 config.laser.dot.size = 10.0;
1497 config.laser.minimal.color = "#FF00AA".to_string();
1498 config.laser.minimal.size = 8.0;
1499 config.laser.crosshair.color = "#00FF00".to_string();
1500 config.laser.crosshair.size = 28.0;
1501 config.laser.arrow.color = "#3355FF80".to_string();
1502 config.laser.arrow.size = 18.0;
1503 config.laser.ring.color = "#FFAA00".to_string();
1504 config.laser.ring.size = 30.0;
1505 config.laser.bullseye.color = "#AA00FF".to_string();
1506 config.laser.bullseye.size = 26.0;
1507 config.laser.highlight.color = "#FFFF0080".to_string();
1508 config.laser.highlight.size = 36.0;
1509
1510 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
1511
1512 assert_eq!(engine.state().pointer_appearances.dot.color, [255, 255, 255, 255]);
1513 assert!((engine.state().pointer_appearances.dot.size - 10.0).abs() < f32::EPSILON);
1514 assert_eq!(engine.state().pointer_appearances.minimal.color, [0xFF, 0, 0xAA, 255]);
1515 assert!((engine.state().pointer_appearances.minimal.size - 8.0).abs() < f32::EPSILON);
1516 assert_eq!(engine.state().pointer_appearances.crosshair.color, [0, 255, 0, 255]);
1517 assert!((engine.state().pointer_appearances.crosshair.size - 28.0).abs() < f32::EPSILON);
1518 assert_eq!(engine.state().pointer_appearances.arrow.color, [0x33, 0x55, 0xFF, 0x80]);
1519 assert!((engine.state().pointer_appearances.arrow.size - 18.0).abs() < f32::EPSILON);
1520 assert_eq!(engine.state().pointer_appearances.ring.color, [0xFF, 0xAA, 0, 255]);
1521 assert!((engine.state().pointer_appearances.ring.size - 30.0).abs() < f32::EPSILON);
1522 assert_eq!(engine.state().pointer_appearances.bullseye.color, [0xAA, 0, 0xFF, 255]);
1523 assert!((engine.state().pointer_appearances.bullseye.size - 26.0).abs() < f32::EPSILON);
1524 assert_eq!(engine.state().pointer_appearances.highlight.color, [0xFF, 0xFF, 0, 0x80]);
1525 assert!((engine.state().pointer_appearances.highlight.size - 36.0).abs() < f32::EPSILON);
1526 assert_eq!(engine.state().current_pointer_appearance().color, [0, 255, 0, 255]);
1527 }
1528
1529 #[test]
1532 fn build_groups_no_metadata_gives_one_to_one() {
1533 let groups = build_slide_groups(5, &PresentationMetadata::default());
1534 assert_eq!(groups.len(), 5);
1535 for (i, group) in groups.iter().enumerate() {
1536 assert_eq!(group.logical_index, i);
1537 assert_eq!(group.pages, vec![i]);
1538 }
1539 }
1540
1541 #[test]
1542 fn build_groups_from_metadata() {
1543 let meta = PresentationMetadata {
1544 groups: vec![
1545 SlideGroupMeta { start_page: 0, end_page: 2 },
1546 SlideGroupMeta { start_page: 3, end_page: 4 },
1547 ],
1548 ..Default::default()
1549 };
1550 let groups = build_slide_groups(5, &meta);
1551 assert_eq!(groups.len(), 2);
1552 assert_eq!(groups[0].pages, vec![0, 1, 2]);
1553 assert_eq!(groups[1].pages, vec![3, 4]);
1554 }
1555
1556 #[test]
1557 fn slide_target_durations_follow_logical_slide_groups() {
1558 let meta = PresentationMetadata {
1559 groups: vec![
1560 SlideGroupMeta { start_page: 0, end_page: 2 },
1561 SlideGroupMeta { start_page: 3, end_page: 4 },
1562 ],
1563 slide_target_durations: HashMap::from([(0, 60.0), (1, 90.0), (2, 120.0)]),
1564 ..Default::default()
1565 };
1566 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1567
1568 assert_eq!(engine.state().total_logical_slides, 2);
1569 assert_eq!(
1570 engine.state().slide_target_durations,
1571 vec![Some(std::time::Duration::from_mins(1)), Some(std::time::Duration::from_secs(90))]
1572 );
1573
1574 sender.send(Command::NextSlide).unwrap();
1575 engine.tick();
1576 assert_eq!(engine.state().current_logical_slide, 0);
1577 assert_eq!(engine.state().current_page, 1);
1578 assert_eq!(
1579 engine.state().slide_target_durations[engine.state().current_logical_slide],
1580 Some(std::time::Duration::from_mins(1))
1581 );
1582 }
1583
1584 #[test]
1585 fn build_groups_with_notes() {
1586 let mut notes = HashMap::new();
1587 notes.insert(0, "Slide one notes".to_string());
1588 notes.insert(3, "Slide two notes".to_string());
1589 let meta = PresentationMetadata {
1590 groups: vec![
1591 SlideGroupMeta { start_page: 0, end_page: 2 },
1592 SlideGroupMeta { start_page: 3, end_page: 4 },
1593 ],
1594 notes,
1595 ..Default::default()
1596 };
1597 let groups = build_slide_groups(5, &meta);
1598 assert_eq!(groups[0].notes.as_deref(), Some("Slide one notes"));
1599 assert_eq!(groups[1].notes.as_deref(), Some("Slide two notes"));
1600 }
1601
1602 #[test]
1603 fn build_groups_uncovered_pages_become_individual() {
1604 let meta = PresentationMetadata {
1605 groups: vec![SlideGroupMeta { start_page: 0, end_page: 1 }],
1606 ..Default::default()
1607 };
1608 let groups = build_slide_groups(5, &meta);
1609 assert_eq!(groups.len(), 4); assert_eq!(groups[0].pages, vec![0, 1]);
1611 assert_eq!(groups[1].pages, vec![2]);
1612 assert_eq!(groups[2].pages, vec![3]);
1613 assert_eq!(groups[3].pages, vec![4]);
1614 }
1615
1616 #[test]
1619 fn initial_state_at_first_slide() {
1620 let (engine, _, _) = make_engine(10);
1621 let state = engine.state();
1622 assert_eq!(state.current_page, 0);
1623 assert_eq!(state.current_logical_slide, 0);
1624 assert_eq!(state.current_overlay_within_group, 0);
1625 assert_eq!(state.total_pages, 10);
1626 assert_eq!(state.total_logical_slides, 10);
1627 }
1628
1629 #[test]
1630 fn next_slide_advances() {
1631 let (mut engine, _, sender) = make_engine(5);
1632 sender.send(Command::NextSlide).unwrap();
1633 engine.tick();
1634 assert_eq!(engine.state().current_logical_slide, 1);
1635 assert_eq!(engine.state().current_page, 1);
1636 }
1637
1638 #[test]
1639 fn next_slide_advances_within_group_before_next_group() {
1640 let meta = PresentationMetadata {
1641 groups: vec![
1642 SlideGroupMeta { start_page: 0, end_page: 2 },
1643 SlideGroupMeta { start_page: 3, end_page: 4 },
1644 ],
1645 ..Default::default()
1646 };
1647 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1648
1649 sender.send(Command::NextSlide).unwrap();
1650 engine.tick();
1651 assert_eq!(engine.state().current_logical_slide, 0);
1652 assert_eq!(engine.state().current_overlay_within_group, 1);
1653 assert_eq!(engine.state().current_page, 1);
1654
1655 sender.send(Command::NextSlide).unwrap();
1656 engine.tick();
1657 assert_eq!(engine.state().current_logical_slide, 0);
1658 assert_eq!(engine.state().current_overlay_within_group, 2);
1659 assert_eq!(engine.state().current_page, 2);
1660
1661 sender.send(Command::NextSlide).unwrap();
1662 engine.tick();
1663 assert_eq!(engine.state().current_logical_slide, 1);
1664 assert_eq!(engine.state().current_overlay_within_group, 0);
1665 assert_eq!(engine.state().current_page, 3);
1666 }
1667
1668 #[test]
1669 fn next_slide_stops_at_end() {
1670 let (mut engine, _, sender) = make_engine(3);
1671 for _ in 0..10 {
1672 sender.send(Command::NextSlide).unwrap();
1673 }
1674 engine.tick();
1675 assert_eq!(engine.state().current_logical_slide, 2);
1676 assert!(engine.state().blacked_out);
1677 }
1678
1679 #[test]
1680 fn previous_slide_stops_at_start() {
1681 let (mut engine, _, sender) = make_engine(5);
1682 sender.send(Command::PreviousSlide).unwrap();
1683 engine.tick();
1684 assert_eq!(engine.state().current_logical_slide, 0);
1685 }
1686
1687 #[test]
1688 fn previous_slide_rewinds_within_group_before_previous_group() {
1689 let meta = PresentationMetadata {
1690 groups: vec![
1691 SlideGroupMeta { start_page: 0, end_page: 2 },
1692 SlideGroupMeta { start_page: 3, end_page: 4 },
1693 ],
1694 ..Default::default()
1695 };
1696 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1697
1698 sender.send(Command::LastSlide).unwrap();
1699 engine.tick();
1700 assert_eq!(engine.state().current_page, 3);
1701
1702 sender.send(Command::PreviousSlide).unwrap();
1703 engine.tick();
1704 assert_eq!(engine.state().current_logical_slide, 0);
1705 assert_eq!(engine.state().current_overlay_within_group, 2);
1706 assert_eq!(engine.state().current_page, 2);
1707
1708 sender.send(Command::PreviousSlide).unwrap();
1709 engine.tick();
1710 assert_eq!(engine.state().current_logical_slide, 0);
1711 assert_eq!(engine.state().current_overlay_within_group, 1);
1712 assert_eq!(engine.state().current_page, 1);
1713 }
1714
1715 #[test]
1716 fn previous_slide_clears_end_blackout() {
1717 let (mut engine, _, sender) = make_engine(1);
1718 sender.send(Command::NextSlide).unwrap();
1719 engine.tick();
1720 assert!(engine.state().blacked_out);
1721
1722 sender.send(Command::PreviousSlide).unwrap();
1723 engine.tick();
1724 assert!(!engine.state().blacked_out);
1725 assert_eq!(engine.state().current_logical_slide, 0);
1726 }
1727
1728 #[test]
1729 fn first_and_last_slide() {
1730 let (mut engine, _, sender) = make_engine(10);
1731 sender.send(Command::LastSlide).unwrap();
1732 engine.tick();
1733 assert_eq!(engine.state().current_logical_slide, 9);
1734
1735 sender.send(Command::FirstSlide).unwrap();
1736 engine.tick();
1737 assert_eq!(engine.state().current_logical_slide, 0);
1738 }
1739
1740 #[test]
1741 fn go_to_slide() {
1742 let (mut engine, _, sender) = make_engine(10);
1743 sender.send(Command::GoToSlide(5)).unwrap();
1744 engine.tick();
1745 assert_eq!(engine.state().current_logical_slide, 5);
1746 }
1747
1748 #[test]
1749 fn go_to_slide_out_of_range_ignored() {
1750 let (mut engine, _, sender) = make_engine(5);
1751 sender.send(Command::GoToSlide(100)).unwrap();
1752 engine.tick();
1753 assert_eq!(engine.state().current_logical_slide, 4);
1755 }
1756
1757 #[test]
1760 fn overlay_navigation_within_group() {
1761 let meta = PresentationMetadata {
1762 groups: vec![
1763 SlideGroupMeta { start_page: 0, end_page: 2 },
1764 SlideGroupMeta { start_page: 3, end_page: 4 },
1765 ],
1766 ..Default::default()
1767 };
1768 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1769 assert_eq!(engine.state().current_logical_slide, 0);
1770
1771 sender.send(Command::NextOverlay).unwrap();
1772 engine.tick();
1773 assert_eq!(engine.state().current_logical_slide, 0);
1774 assert_eq!(engine.state().current_overlay_within_group, 1);
1775 assert_eq!(engine.state().current_page, 1);
1776
1777 sender.send(Command::NextOverlay).unwrap();
1778 engine.tick();
1779 assert_eq!(engine.state().current_overlay_within_group, 2);
1780 assert_eq!(engine.state().current_page, 2);
1781
1782 sender.send(Command::NextOverlay).unwrap();
1784 engine.tick();
1785 assert_eq!(engine.state().current_logical_slide, 1);
1786 assert_eq!(engine.state().current_overlay_within_group, 0);
1787 assert_eq!(engine.state().current_page, 3);
1788 }
1789
1790 #[test]
1791 fn previous_overlay_goes_to_last_overlay_of_prev_group() {
1792 let meta = PresentationMetadata {
1793 groups: vec![
1794 SlideGroupMeta { start_page: 0, end_page: 2 },
1795 SlideGroupMeta { start_page: 3, end_page: 4 },
1796 ],
1797 ..Default::default()
1798 };
1799 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1800 sender.send(Command::LastSlide).unwrap();
1801 engine.tick();
1802 assert_eq!(engine.state().current_logical_slide, 1);
1803
1804 sender.send(Command::PreviousOverlay).unwrap();
1805 engine.tick();
1806 assert_eq!(engine.state().current_logical_slide, 0);
1807 assert_eq!(engine.state().current_overlay_within_group, 2);
1808 assert_eq!(engine.state().current_page, 2);
1809 }
1810
1811 #[test]
1814 fn toggle_freeze_captures_page() {
1815 let (mut engine, _, sender) = make_engine(5);
1816 sender.send(Command::NextSlide).unwrap();
1817 sender.send(Command::NextSlide).unwrap();
1818 engine.tick();
1819 assert_eq!(engine.state().current_page, 2);
1820
1821 sender.send(Command::ToggleFreeze).unwrap();
1822 engine.tick();
1823 assert!(engine.state().frozen);
1824 assert_eq!(engine.state().frozen_page, Some(2));
1825
1826 sender.send(Command::ToggleFreeze).unwrap();
1827 engine.tick();
1828 assert!(!engine.state().frozen);
1829 assert_eq!(engine.state().frozen_page, None);
1830 }
1831
1832 #[test]
1833 fn toggle_blackout() {
1834 let (mut engine, _, sender) = make_engine(5);
1835 assert!(!engine.state().blacked_out);
1836 sender.send(Command::ToggleBlackout).unwrap();
1837 engine.tick();
1838 assert!(engine.state().blacked_out);
1839 sender.send(Command::ToggleBlackout).unwrap();
1840 engine.tick();
1841 assert!(!engine.state().blacked_out);
1842 }
1843
1844 #[test]
1845 fn toggle_screen_share() {
1846 let (mut engine, _, sender) = make_engine(5);
1847 assert!(!engine.state().screen_share_mode);
1848 sender.send(Command::ToggleScreenShareMode).unwrap();
1849 engine.tick();
1850 assert!(engine.state().screen_share_mode);
1851 }
1852
1853 #[test]
1854 fn toggle_presentation_mode() {
1855 let (mut engine, _, sender) = make_engine(5);
1856 assert!(!engine.state().presentation_mode);
1857 sender.send(Command::TogglePresentationMode).unwrap();
1858 engine.tick();
1859 assert!(engine.state().presentation_mode);
1860 sender.send(Command::TogglePresentationMode).unwrap();
1861 engine.tick();
1862 assert!(!engine.state().presentation_mode);
1863 }
1864
1865 #[test]
1866 fn swap_displays_toggles_runtime_flag() {
1867 let (mut engine, _, sender) = make_engine(5);
1868 assert!(!engine.state().displays_swapped);
1869
1870 sender.send(Command::SwapDisplays).unwrap();
1871 engine.tick();
1872 assert!(engine.state().displays_swapped);
1873
1874 sender.send(Command::SwapDisplays).unwrap();
1875 engine.tick();
1876 assert!(!engine.state().displays_swapped);
1877 }
1878
1879 #[test]
1880 fn quit_exits_presentation_mode_first() {
1881 let (mut engine, _, sender) = make_engine(5);
1882 sender.send(Command::TogglePresentationMode).unwrap();
1883 engine.tick();
1884 assert!(engine.state().presentation_mode);
1885
1886 sender.send(Command::Quit).unwrap();
1888 let should_quit = engine.tick();
1889 assert!(!should_quit);
1890 assert!(!engine.state().presentation_mode);
1891
1892 sender.send(Command::Quit).unwrap();
1894 let should_quit = engine.tick();
1895 assert!(!should_quit);
1896 assert!(engine.state().quit_requested);
1897
1898 sender.send(Command::Quit).unwrap();
1900 let should_quit = engine.tick();
1901 assert!(should_quit);
1902 }
1903
1904 #[test]
1907 fn laser_and_ink_mutually_exclusive() {
1908 let (mut engine, _, sender) = make_engine(5);
1909 assert!(engine.state().laser_active);
1910
1911 sender.send(Command::ToggleLaser).unwrap();
1912 engine.tick();
1913 assert!(!engine.state().laser_active);
1914
1915 sender.send(Command::ToggleInk).unwrap();
1916 engine.tick();
1917 assert!(engine.state().ink_active);
1918 assert!(!engine.state().laser_active);
1919
1920 sender.send(Command::ToggleLaser).unwrap();
1921 engine.tick();
1922 assert!(engine.state().laser_active);
1923 assert!(!engine.state().ink_active);
1924 }
1925
1926 #[test]
1927 fn cycle_laser_style_rotates_styles() {
1928 let (mut engine, _, sender) = make_engine(5);
1929 assert_eq!(engine.state().pointer_style, PointerStyle::Dot);
1930
1931 sender.send(Command::CycleLaserStyle).unwrap();
1932 engine.tick();
1933 assert_eq!(engine.state().pointer_style, PointerStyle::Minimal);
1934
1935 sender.send(Command::CycleLaserStyle).unwrap();
1936 engine.tick();
1937 assert_eq!(engine.state().pointer_style, PointerStyle::Crosshair);
1938
1939 sender.send(Command::CycleLaserStyle).unwrap();
1940 engine.tick();
1941 assert_eq!(engine.state().pointer_style, PointerStyle::Arrow);
1942
1943 sender.send(Command::CycleLaserStyle).unwrap();
1944 engine.tick();
1945 assert_eq!(engine.state().pointer_style, PointerStyle::Ring);
1946
1947 sender.send(Command::CycleLaserStyle).unwrap();
1948 engine.tick();
1949 assert_eq!(engine.state().pointer_style, PointerStyle::Bullseye);
1950
1951 sender.send(Command::CycleLaserStyle).unwrap();
1952 engine.tick();
1953 assert_eq!(engine.state().pointer_style, PointerStyle::Highlight);
1954
1955 sender.send(Command::CycleLaserStyle).unwrap();
1956 engine.tick();
1957 assert_eq!(engine.state().pointer_style, PointerStyle::Dot);
1958 }
1959
1960 #[test]
1961 fn pointer_position_when_laser_active() {
1962 let (mut engine, _, sender) = make_engine(5);
1963 sender.send(Command::SetPointerPosition(0.5, 0.5)).unwrap();
1964 engine.tick();
1965 assert_eq!(engine.state().pointer_position, Some((0.5, 0.5)));
1966 }
1967
1968 #[test]
1969 fn ink_stroke_lifecycle() {
1970 let (mut engine, _, sender) = make_engine(5);
1971 sender.send(Command::ToggleInk).unwrap();
1972 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1973 sender.send(Command::AddInkPoint(0.3, 0.4)).unwrap();
1974 sender.send(Command::FinishInkStroke).unwrap();
1975 engine.tick();
1976
1977 let strokes = engine.state().current_page_ink();
1978 assert_eq!(strokes.len(), 1);
1979 assert_eq!(strokes[0].points.len(), 2);
1980 assert!(strokes[0].finished);
1981
1982 sender.send(Command::AddInkPoint(0.5, 0.6)).unwrap();
1983 engine.tick();
1984 assert_eq!(engine.state().current_page_ink().len(), 2);
1985 }
1986
1987 #[test]
1988 fn ink_points_ignored_when_ink_inactive() {
1989 let (mut engine, _, sender) = make_engine(5);
1990 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1991 engine.tick();
1992 assert!(engine.state().current_page_ink().is_empty());
1993 }
1994
1995 #[test]
1996 fn clear_ink() {
1997 let (mut engine, _, sender) = make_engine(5);
1998 sender.send(Command::ToggleInk).unwrap();
1999 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
2000 sender.send(Command::FinishInkStroke).unwrap();
2001 engine.tick();
2002 assert_eq!(engine.state().current_page_ink().len(), 1);
2003
2004 sender.send(Command::ClearInk).unwrap();
2005 engine.tick();
2006 assert!(engine.state().current_page_ink().is_empty());
2007 }
2008
2009 #[test]
2010 fn ink_persists_across_navigation() {
2011 let (mut engine, _, sender) = make_engine(5);
2012
2013 sender.send(Command::ToggleInk).unwrap();
2015 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
2016 sender.send(Command::FinishInkStroke).unwrap();
2017 engine.tick();
2018 assert_eq!(engine.state().current_page_ink().len(), 1);
2019
2020 sender.send(Command::NextSlide).unwrap();
2022 engine.tick();
2023 assert!(engine.state().current_page_ink().is_empty());
2024 assert_eq!(engine.state().current_page, 1);
2025
2026 sender.send(Command::PreviousSlide).unwrap();
2028 engine.tick();
2029 assert_eq!(engine.state().current_page_ink().len(), 1);
2030 assert_eq!(engine.state().current_page_ink()[0].points.len(), 1);
2031 }
2032
2033 #[test]
2034 fn clear_ink_only_affects_current_page() {
2035 let (mut engine, _, sender) = make_engine(5);
2036
2037 sender.send(Command::ToggleInk).unwrap();
2039 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
2040 sender.send(Command::FinishInkStroke).unwrap();
2041 engine.tick();
2042
2043 sender.send(Command::NextSlide).unwrap();
2045 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2046 sender.send(Command::FinishInkStroke).unwrap();
2047 engine.tick();
2048 assert_eq!(engine.state().current_page_ink().len(), 1);
2049
2050 sender.send(Command::ClearInk).unwrap();
2052 engine.tick();
2053 assert!(engine.state().current_page_ink().is_empty());
2054
2055 sender.send(Command::PreviousSlide).unwrap();
2057 engine.tick();
2058 assert_eq!(engine.state().current_page_ink().len(), 1);
2059 }
2060
2061 #[test]
2062 fn whiteboard_strokes_survive_navigation() {
2063 let (mut engine, _, sender) = make_engine(5);
2064
2065 sender.send(Command::ToggleWhiteboard).unwrap();
2067 sender.send(Command::AddInkPoint(0.3, 0.3)).unwrap();
2068 sender.send(Command::FinishInkStroke).unwrap();
2069 engine.tick();
2070 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2071
2072 sender.send(Command::ToggleWhiteboard).unwrap();
2074 sender.send(Command::NextSlide).unwrap();
2075 sender.send(Command::ToggleWhiteboard).unwrap();
2076 engine.tick();
2077 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2078 }
2079
2080 #[test]
2081 fn annotations_loaded_from_metadata() {
2082 use dais_sidecar::types::InkStrokeMeta;
2083
2084 let mut meta = PresentationMetadata::default();
2085 meta.slide_annotations.insert(
2086 0,
2087 vec![InkStrokeMeta {
2088 points: vec![(0.1, 0.2), (0.3, 0.4)],
2089 color: [255, 0, 0, 255],
2090 width: 3.0,
2091 }],
2092 );
2093 meta.whiteboard_annotations =
2094 vec![InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 0, 255, 255], width: 2.0 }];
2095
2096 let (engine, _, _) = make_engine_with_metadata(5, &meta);
2097
2098 assert_eq!(engine.state().current_page_ink().len(), 1);
2100 assert_eq!(engine.state().current_page_ink()[0].points.len(), 2);
2101 assert!(engine.state().current_page_ink()[0].finished);
2102
2103 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2105 assert!(engine.state().whiteboard_strokes[0].finished);
2106 }
2107
2108 #[test]
2109 fn spotlight_toggle_and_position() {
2110 let (mut engine, _, sender) = make_engine(5);
2111 sender.send(Command::ToggleSpotlight).unwrap();
2112 sender.send(Command::SetSpotlightPosition(0.3, 0.7)).unwrap();
2113 engine.tick();
2114 assert!(engine.state().spotlight_active);
2115 assert_eq!(engine.state().spotlight_position, Some((0.3, 0.7)));
2116
2117 sender.send(Command::ToggleSpotlight).unwrap();
2118 engine.tick();
2119 assert!(!engine.state().spotlight_active);
2120 assert_eq!(engine.state().spotlight_position, None);
2121 }
2122
2123 #[test]
2124 fn spotlight_position_ignored_when_inactive() {
2125 let (mut engine, _, sender) = make_engine(5);
2126 sender.send(Command::SetSpotlightPosition(0.5, 0.5)).unwrap();
2127 engine.tick();
2128 assert_eq!(engine.state().spotlight_position, None);
2129 }
2130
2131 #[test]
2132 fn zoom_toggle_and_region() {
2133 let (mut engine, _, sender) = make_engine(5);
2134 sender.send(Command::ToggleZoom).unwrap();
2135 sender.send(Command::SetZoomRegion { center: (0.5, 0.5), factor: 2.0 }).unwrap();
2136 engine.tick();
2137 assert!(engine.state().zoom_active);
2138 let region = engine.state().zoom_region.as_ref().unwrap();
2139 assert_eq!(region.center, (0.5, 0.5));
2140 assert!((region.factor - 2.0).abs() < f32::EPSILON);
2141
2142 sender.send(Command::ToggleZoom).unwrap();
2143 engine.tick();
2144 assert!(!engine.state().zoom_active);
2145 assert!(engine.state().zoom_region.is_none());
2146 }
2147
2148 #[test]
2149 fn position_clamping() {
2150 let (mut engine, _, sender) = make_engine(5);
2151 sender.send(Command::SetPointerPosition(-1.0, 2.0)).unwrap();
2152 engine.tick();
2153 assert_eq!(engine.state().pointer_position, Some((0.0, 1.0)));
2154 }
2155
2156 #[test]
2159 fn timer_start_pause_reset() {
2160 let (mut engine, _, sender) = make_engine(5);
2161 assert!(!engine.state().timer.running);
2162
2163 sender.send(Command::StartTimer).unwrap();
2164 engine.tick();
2165 assert!(engine.state().timer.running);
2166
2167 sender.send(Command::PauseTimer).unwrap();
2168 engine.tick();
2169 assert!(!engine.state().timer.running);
2170
2171 sender.send(Command::ResetTimer).unwrap();
2172 engine.tick();
2173 assert!(!engine.state().timer.running);
2174 assert_eq!(engine.state().timer.elapsed, std::time::Duration::ZERO);
2175 }
2176
2177 #[test]
2178 fn toggle_timer_starts_and_pauses() {
2179 let (mut engine, _, sender) = make_engine(5);
2180 assert!(!engine.state().timer.running);
2181
2182 sender.send(Command::ToggleTimer).unwrap();
2184 engine.tick();
2185 assert!(engine.state().timer.running);
2186
2187 sender.send(Command::ToggleTimer).unwrap();
2189 engine.tick();
2190 assert!(!engine.state().timer.running);
2191
2192 sender.send(Command::ToggleTimer).unwrap();
2194 engine.tick();
2195 assert!(engine.state().timer.running);
2196 }
2197
2198 #[test]
2199 fn toggle_timer_does_not_cancel_itself_in_single_tick() {
2200 let (mut engine, _, sender) = make_engine(5);
2201
2202 sender.send(Command::ToggleTimer).unwrap();
2204 sender.send(Command::ToggleTimer).unwrap();
2205 engine.tick();
2206 assert!(!engine.state().timer.running, "two toggles should cancel out");
2207 }
2208
2209 #[test]
2210 fn slide_timer_accumulates_when_returning_to_slide() {
2211 let (mut engine, _, sender) = make_engine(5);
2212
2213 std::thread::sleep(std::time::Duration::from_millis(15));
2214 engine.tick();
2215 let slide_0_elapsed = engine.state().slide_elapsed;
2216 assert!(slide_0_elapsed > std::time::Duration::ZERO);
2217
2218 sender.send(Command::NextSlide).unwrap();
2219 engine.tick();
2220 assert_eq!(engine.state().current_logical_slide, 1);
2221 assert!(
2222 engine.state().slide_elapsed <= std::time::Duration::from_millis(5),
2223 "new slide should start near zero"
2224 );
2225
2226 sender.send(Command::PreviousSlide).unwrap();
2227 engine.tick();
2228 assert_eq!(engine.state().current_logical_slide, 0);
2229 assert!(
2230 engine.state().slide_elapsed >= slide_0_elapsed,
2231 "returning to a slide should restore accumulated time"
2232 );
2233 }
2234
2235 #[test]
2238 fn toggle_overview() {
2239 let (mut engine, _, sender) = make_engine(5);
2240 assert!(!engine.state().overview_visible);
2241 sender.send(Command::ToggleSlideOverview).unwrap();
2242 engine.tick();
2243 assert!(engine.state().overview_visible);
2244 sender.send(Command::ToggleSlideOverview).unwrap();
2245 engine.tick();
2246 assert!(!engine.state().overview_visible);
2247 }
2248
2249 #[test]
2250 fn notes_font_size_bounds() {
2251 let (mut engine, _, sender) = make_engine(5);
2252 let initial = engine.state().notes_font_size;
2253
2254 sender.send(Command::IncrementNotesFontSize).unwrap();
2255 engine.tick();
2256 assert!(engine.state().notes_font_size > initial);
2257
2258 for _ in 0..100 {
2259 sender.send(Command::DecrementNotesFontSize).unwrap();
2260 }
2261 engine.tick();
2262 assert!((engine.state().notes_font_size - 8.0).abs() < f32::EPSILON);
2263 }
2264
2265 #[test]
2266 fn notes_font_size_upper_bound() {
2267 let (mut engine, _, sender) = make_engine(5);
2268 for _ in 0..100 {
2269 sender.send(Command::IncrementNotesFontSize).unwrap();
2270 }
2271 engine.tick();
2272 assert!((engine.state().notes_font_size - 72.0).abs() < f32::EPSILON);
2273 }
2274
2275 #[test]
2278 fn quit_command_requires_confirmation() {
2279 let (mut engine, _, sender) = make_engine(5);
2280 sender.send(Command::Quit).unwrap();
2282 assert!(!engine.tick());
2283 assert!(engine.state().quit_requested);
2284
2285 sender.send(Command::Quit).unwrap();
2287 assert!(engine.tick());
2288 }
2289
2290 #[test]
2291 fn quit_cancelled_by_other_command() {
2292 let (mut engine, _, sender) = make_engine(5);
2293 sender.send(Command::Quit).unwrap();
2295 engine.tick();
2296 assert!(engine.state().quit_requested);
2297
2298 sender.send(Command::NextSlide).unwrap();
2300 assert!(!engine.tick());
2301 assert!(!engine.state().quit_requested);
2302 }
2303
2304 #[test]
2305 fn no_quit_returns_false() {
2306 let (mut engine, _, sender) = make_engine(5);
2307 sender.send(Command::NextSlide).unwrap();
2308 assert!(!engine.tick());
2309 }
2310
2311 #[test]
2314 fn state_broadcast_to_shared() {
2315 let (mut engine, shared, sender) = make_engine(5);
2316 sender.send(Command::NextSlide).unwrap();
2317 engine.tick();
2318
2319 let shared_state = shared.read().unwrap();
2320 assert_eq!(shared_state.current_logical_slide, 1);
2321 }
2322
2323 #[test]
2326 fn notes_update_on_navigation() {
2327 let mut notes = HashMap::new();
2328 notes.insert(0, "Notes for slide 0".to_string());
2329 notes.insert(1, "Notes for slide 1".to_string());
2330 let meta = PresentationMetadata { notes, ..Default::default() };
2331 let (mut engine, _, sender) = make_engine_with_metadata(3, &meta);
2332
2333 assert_eq!(engine.state().current_notes.as_deref(), Some("Notes for slide 0"));
2334
2335 sender.send(Command::NextSlide).unwrap();
2336 engine.tick();
2337 assert_eq!(engine.state().current_notes.as_deref(), Some("Notes for slide 1"));
2338
2339 sender.send(Command::NextSlide).unwrap();
2340 engine.tick();
2341 assert_eq!(engine.state().current_notes, None);
2342 }
2343
2344 #[test]
2345 fn notes_can_be_edited_inline() {
2346 let (mut engine, _, sender) = make_engine(3);
2347
2348 sender.send(Command::ToggleNotesEdit).unwrap();
2349 sender.send(Command::SetCurrentSlideNotes("Hello **markdown**".to_string())).unwrap();
2350 engine.tick();
2351
2352 assert!(engine.state().notes_editing);
2353 assert_eq!(engine.state().current_notes.as_deref(), Some("Hello **markdown**"));
2354 assert_eq!(engine.state().slide_groups[0].notes.as_deref(), Some("Hello **markdown**"));
2355
2356 sender.send(Command::SetCurrentSlideNotes(String::new())).unwrap();
2357 engine.tick();
2358 assert_eq!(engine.state().current_notes, None);
2359 assert_eq!(engine.state().slide_groups[0].notes, None);
2360 }
2361
2362 #[test]
2363 fn markdown_notes_override_sidecar_notes() {
2364 let dir = temp_test_dir("markdown_notes_override");
2365 let notes_path = dir.join("talk_notes.md");
2366 std::fs::write(¬es_path, "# From Markdown\n\nMarkdown note.\n")
2367 .expect("should write notes");
2368 let mut sidecar_notes = HashMap::new();
2369 sidecar_notes.insert(0, "Sidecar note.".to_string());
2370 let meta = PresentationMetadata { notes: sidecar_notes, ..Default::default() };
2371
2372 let (engine, _, _) =
2373 make_engine_with_markdown_notes(1, &meta, dir.join("talk.pdf"), ¬es_path);
2374
2375 assert_eq!(engine.state().current_notes.as_deref(), Some("Markdown note."));
2376
2377 let _ = std::fs::remove_dir_all(dir);
2378 }
2379
2380 #[test]
2381 fn markdown_notes_save_back_to_markdown_and_not_sidecar_notes() {
2382 use dais_sidecar::dais_format::DaisFormat;
2383 use dais_sidecar::format::SidecarFormat;
2384
2385 let dir = temp_test_dir("markdown_notes_save");
2386 let pdf_path = dir.join("talk.pdf");
2387 let notes_path = dir.join("talk_notes.md");
2388 std::fs::write(¬es_path, "# First\n\nOld first.\n\n# Second\n\nOld second.\n")
2389 .expect("should write notes");
2390
2391 let (mut engine, _, sender) = make_engine_with_markdown_notes(
2392 2,
2393 &PresentationMetadata::default(),
2394 pdf_path.clone(),
2395 ¬es_path,
2396 );
2397
2398 sender.send(Command::SetCurrentSlideNotes("Edited first.".to_string())).unwrap();
2399 sender.send(Command::SaveSidecar).unwrap();
2400 engine.tick();
2401
2402 let markdown = std::fs::read_to_string(¬es_path).expect("should read saved markdown");
2403 assert!(markdown.contains("# First\n\nEdited first.\n"));
2404 assert!(markdown.contains("# Second\n\nOld second.\n"));
2405
2406 let sidecar = DaisFormat.read(&pdf_path.with_extension("dais")).unwrap();
2407 assert!(sidecar.notes.is_empty());
2408
2409 let _ = std::fs::remove_dir_all(dir);
2410 }
2411
2412 #[test]
2413 fn new_text_boxes_use_configured_typst_prelude() {
2414 let mut config = Config::default();
2415 config.text_boxes.typst_prelude = "#set align(horizon)".to_string();
2416 let (mut engine, _, sender) =
2417 make_engine_with_config(1, &PresentationMetadata::default(), &config);
2418
2419 sender.send(Command::PlaceTextBox { x: 0.1, y: 0.1, w: 0.3, h: 0.2 }).unwrap();
2420 engine.tick();
2421
2422 let boxes = engine.state().current_page_text_boxes();
2423 assert_eq!(boxes.len(), 1);
2424 assert_eq!(boxes[0].typst_prelude, "#set align(horizon)");
2425 }
2426
2427 #[test]
2430 fn toggle_whiteboard() {
2431 let (mut engine, _, sender) = make_engine(5);
2432 assert!(!engine.state().whiteboard_active);
2433
2434 sender.send(Command::ToggleWhiteboard).unwrap();
2435 engine.tick();
2436 assert!(engine.state().whiteboard_active);
2437 assert!(engine.state().ink_active); assert!(!engine.state().laser_active);
2439
2440 sender.send(Command::ToggleWhiteboard).unwrap();
2441 engine.tick();
2442 assert!(!engine.state().whiteboard_active);
2443 }
2444
2445 #[test]
2446 fn whiteboard_exits_text_box_context() {
2447 let (mut engine, _, sender) = make_engine(1);
2448
2449 sender.send(Command::ToggleTextBoxMode).unwrap();
2450 sender.send(Command::PlaceTextBox { x: 0.1, y: 0.1, w: 0.2, h: 0.2 }).unwrap();
2451 engine.tick();
2452 assert!(engine.state().text_box_mode);
2453 assert!(engine.state().selected_text_box.is_some());
2454
2455 sender.send(Command::ToggleWhiteboard).unwrap();
2456 engine.tick();
2457
2458 assert!(engine.state().whiteboard_active);
2459 assert!(!engine.state().text_box_mode);
2460 assert!(engine.state().selected_text_box.is_none());
2461 assert!(!engine.state().text_box_editing);
2462 }
2463
2464 #[test]
2465 fn whiteboard_and_blackout_mutually_exclusive() {
2466 let (mut engine, _, sender) = make_engine(5);
2467
2468 sender.send(Command::ToggleWhiteboard).unwrap();
2469 engine.tick();
2470 assert!(engine.state().whiteboard_active);
2471
2472 sender.send(Command::ToggleBlackout).unwrap();
2473 engine.tick();
2474 assert!(engine.state().blacked_out);
2475 assert!(!engine.state().whiteboard_active);
2476
2477 sender.send(Command::ToggleWhiteboard).unwrap();
2478 engine.tick();
2479 assert!(engine.state().whiteboard_active);
2480 assert!(!engine.state().blacked_out);
2481 }
2482
2483 #[test]
2484 fn whiteboard_ink_strokes_separate_from_slide() {
2485 let (mut engine, _, sender) = make_engine(5);
2486
2487 sender.send(Command::ToggleInk).unwrap();
2489 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2490 sender.send(Command::FinishInkStroke).unwrap();
2491 engine.tick();
2492 assert_eq!(engine.state().current_page_ink().len(), 1);
2493 assert!(engine.state().whiteboard_strokes.is_empty());
2494
2495 sender.send(Command::ToggleWhiteboard).unwrap();
2497 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2498 sender.send(Command::FinishInkStroke).unwrap();
2499 engine.tick();
2500 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2501 assert_eq!(engine.state().current_page_ink().len(), 1);
2503 }
2504
2505 #[test]
2506 fn clear_ink_targets_whiteboard_when_active() {
2507 let (mut engine, _, sender) = make_engine(5);
2508
2509 sender.send(Command::ToggleWhiteboard).unwrap();
2511 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2512 sender.send(Command::FinishInkStroke).unwrap();
2513 engine.tick();
2514 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2515
2516 sender.send(Command::ClearInk).unwrap();
2517 engine.tick();
2518 assert!(engine.state().whiteboard_strokes.is_empty());
2519 }
2520
2521 #[test]
2524 fn active_pen_initializes_from_config() {
2525 let mut config = Config::default();
2526 config.ink.colors = vec!["#FF000080".to_string()]; config.ink.width = 7.5;
2528 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
2529 assert_eq!(engine.state().active_pen.color, [255, 0, 0, 128]);
2530 assert!((engine.state().active_pen.width - 7.5).abs() < f32::EPSILON);
2531 assert_eq!(engine.state().ink_color_presets.len(), 6);
2532 assert!(engine.state().ink_color_presets.contains(&[255, 255, 255, 255]));
2533 assert!(engine.state().ink_color_presets.contains(&[0, 0, 0, 255]));
2534 }
2535
2536 #[test]
2537 fn set_ink_color_updates_active_pen_only() {
2538 let (mut engine, _, sender) = make_engine(3);
2539 sender.send(Command::ToggleInk).unwrap();
2540 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2541 sender.send(Command::FinishInkStroke).unwrap();
2542 engine.tick();
2543
2544 sender.send(Command::SetInkColor([0, 0, 255, 200])).unwrap();
2546 engine.tick();
2547
2548 let strokes = engine.state().current_page_ink();
2549 assert_eq!(strokes.len(), 1);
2550 assert_eq!(engine.state().active_pen.color, [0, 0, 255, 200]);
2551 assert_ne!(strokes[0].color, [0, 0, 255, 200]);
2553 }
2554
2555 #[test]
2556 fn set_ink_width_updates_active_pen_only() {
2557 let (mut engine, _, sender) = make_engine(3);
2558 sender.send(Command::ToggleInk).unwrap();
2559 sender.send(Command::AddInkPoint(0.2, 0.2)).unwrap();
2560 sender.send(Command::FinishInkStroke).unwrap();
2561 engine.tick();
2562
2563 let original_width = engine.state().current_page_ink()[0].width;
2564
2565 sender.send(Command::SetInkWidth(12.0)).unwrap();
2566 engine.tick();
2567
2568 assert!((engine.state().active_pen.width - 12.0).abs() < f32::EPSILON);
2569 assert!((engine.state().current_page_ink()[0].width - original_width).abs() < f32::EPSILON);
2571 }
2572
2573 #[test]
2574 fn pen_change_does_not_mutate_prior_strokes() {
2575 let (mut engine, _, sender) = make_engine(3);
2576 sender.send(Command::ToggleInk).unwrap();
2577
2578 sender.send(Command::SetInkColor([255, 0, 0, 255])).unwrap();
2580 sender.send(Command::SetInkWidth(3.0)).unwrap();
2581 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2582 sender.send(Command::FinishInkStroke).unwrap();
2583 engine.tick();
2584
2585 sender.send(Command::SetInkColor([0, 0, 255, 128])).unwrap();
2587 sender.send(Command::SetInkWidth(10.0)).unwrap();
2588 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2589 sender.send(Command::FinishInkStroke).unwrap();
2590 engine.tick();
2591
2592 let strokes = engine.state().current_page_ink();
2593 assert_eq!(strokes.len(), 2);
2594 assert_eq!(strokes[0].color, [255, 0, 0, 255]);
2596 assert!((strokes[0].width - 3.0).abs() < f32::EPSILON);
2597 assert_eq!(strokes[1].color, [0, 0, 255, 128]);
2599 assert!((strokes[1].width - 10.0).abs() < f32::EPSILON);
2600 }
2601
2602 #[test]
2603 fn new_stroke_snapshots_active_pen_at_creation() {
2604 let mut config = Config::default();
2605 config.ink.colors = vec!["#00FF0080".to_string()]; config.ink.width = 5.0;
2607 let (mut engine, _, sender) =
2608 make_engine_with_config(3, &PresentationMetadata::default(), &config);
2609
2610 sender.send(Command::ToggleInk).unwrap();
2611 sender.send(Command::AddInkPoint(0.3, 0.3)).unwrap();
2612 sender.send(Command::FinishInkStroke).unwrap();
2613 engine.tick();
2614
2615 let strokes = engine.state().current_page_ink();
2616 assert_eq!(strokes[0].color, [0, 255, 0, 128]);
2617 assert!((strokes[0].width - 5.0).abs() < f32::EPSILON);
2618 }
2619}