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, InkStroke, PointerAppearance, PointerAppearances, PointerStyle, PresentationState,
10 TextBox, TimerState, ZoomRegion,
11};
12use dais_sidecar::types::{InkStrokeMeta, PresentationMetadata, TextBoxMeta};
13
14const INK_COLOR_FALLBACKS: &[[u8; 4]] = &[
16 [220, 30, 30, 255], [30, 100, 220, 255], [30, 180, 30, 255], [220, 200, 0, 255], ];
21
22const INK_WIDTH_PRESETS: &[f32] = &[1.0, 2.0, 4.0, 8.0, 16.0];
24
25#[derive(Debug, Clone, Copy)]
26enum SidecarKind {
27 Dais,
28 Pdfpc,
29}
30
31pub struct PresentationEngine {
36 receiver: CommandReceiver,
37 state: PresentationState,
38 shared_state: Arc<RwLock<PresentationState>>,
39 timer_start: Option<Instant>,
40 slide_start: Instant,
41 ink_color_presets: Vec<[u8; 4]>,
43 text_box_default_color: [u8; 4],
45 text_box_default_background: Option<[u8; 4]>,
47 text_box_default_typst_prelude: String,
49 pdf_path: std::path::PathBuf,
51 sidecar_kind: SidecarKind,
53 metadata: PresentationMetadata,
55}
56
57impl PresentationEngine {
58 pub fn new(
62 total_pages: usize,
63 metadata: &PresentationMetadata,
64 config: &Config,
65 receiver: CommandReceiver,
66 pdf_path: std::path::PathBuf,
67 ) -> (Self, Arc<RwLock<PresentationState>>) {
68 let slide_groups = build_slide_groups(total_pages, metadata);
69 let mut state = PresentationState::new(total_pages, slide_groups);
70
71 let duration = match (config.timer.mode, config.timer.duration_minutes) {
73 (dais_core::state::TimerMode::Elapsed, None) => None,
74 (_, Some(minutes)) => Some(std::time::Duration::from_secs(u64::from(minutes) * 60)),
75 (dais_core::state::TimerMode::Countdown, None) => {
76 Some(std::time::Duration::from_mins(20))
77 }
78 };
79 let warning_threshold = match (duration, config.timer.warning_minutes) {
80 (Some(_), Some(minutes)) => {
81 Some(std::time::Duration::from_secs(u64::from(minutes) * 60))
82 }
83 _ => None,
84 };
85 state.timer = TimerState {
86 mode: config.timer.mode,
87 duration,
88 warning_threshold,
89 ..TimerState::default()
90 };
91 state.notes_font_size = config.notes.font_size;
92 state.notes_font_size_step = config.notes.font_size_step;
93 state.pointer_style = parse_pointer_style(&config.laser.style);
94 state.pointer_appearances = pointer_appearances_from_config(config);
95 if config.display.mode == "single" {
96 state.laser_active = false;
97 }
98 state.spotlight_radius = config.spotlight.radius.clamp(16.0, 2048.0);
99 state.spotlight_dim_opacity = config.spotlight.dim_opacity.clamp(0.0, 1.0);
100
101 let mut ink_color_presets: Vec<[u8; 4]> =
103 config.ink.colors.iter().filter_map(|s| parse_hex_color(s)).collect();
104 for &fallback in INK_COLOR_FALLBACKS {
105 if ink_color_presets.len() >= 4 {
106 break;
107 }
108 ink_color_presets.push(fallback);
109 }
110
111 state.active_pen = ActivePen {
112 color: ink_color_presets.first().copied().unwrap_or([255, 0, 0, 255]),
113 width: config.ink.width,
114 };
115 let text_box_default_color =
116 parse_hex_color(&config.text_boxes.color).unwrap_or([0, 0, 0, 255]);
117 let text_box_default_background = parse_optional_hex_color(&config.text_boxes.background);
118 let text_box_default_typst_prelude = config.text_boxes.typst_prelude.clone();
119
120 load_annotations_into_state(&mut state, metadata);
123
124 let shared_state = Arc::new(RwLock::new(state.clone()));
125
126 (
127 Self {
128 receiver,
129 state,
130 shared_state: Arc::clone(&shared_state),
131 timer_start: None,
132 slide_start: Instant::now(),
133 ink_color_presets,
134 text_box_default_color,
135 text_box_default_background,
136 text_box_default_typst_prelude,
137 pdf_path,
138 sidecar_kind: if config.normalized_sidecar_format() == "dais" {
139 SidecarKind::Dais
140 } else {
141 SidecarKind::Pdfpc
142 },
143 metadata: metadata.clone(),
144 },
145 shared_state,
146 )
147 }
148
149 pub fn tick(&mut self) -> bool {
153 let timers_ticking = self.update_timers();
155
156 let commands = self.receiver.drain();
158 let mut should_quit = false;
159 let content_changed = !commands.is_empty();
160
161 for cmd in &commands {
162 if matches!(cmd, Command::Quit) {
163 if self.state.presentation_mode {
164 self.state.presentation_mode = false;
165 } else if self.state.overview_visible {
166 self.state.overview_visible = false;
167 } else if self.state.quit_requested {
168 self.save_sidecar();
169 should_quit = true;
170 } else {
171 self.state.quit_requested = true;
172 }
173 } else {
174 self.state.quit_requested = false;
175 }
176 self.process_command(cmd);
177 }
178
179 if content_changed {
184 if let Ok(mut shared) = self.shared_state.write() {
185 *shared = self.state.clone();
186 }
187 } else if timers_ticking && let Ok(mut shared) = self.shared_state.write() {
188 shared.timer.elapsed = self.state.timer.elapsed;
189 shared.slide_elapsed = self.state.slide_elapsed;
190 }
191
192 should_quit
193 }
194
195 pub fn state(&self) -> &PresentationState {
197 &self.state
198 }
199
200 fn update_timers(&mut self) -> bool {
203 let current = self.state.current_logical_slide;
204 let elapsed = self.slide_start.elapsed();
205 if let Some(total) = self.state.slide_elapsed_by_logical.get_mut(current) {
206 *total = elapsed;
207 self.state.slide_elapsed = *total;
208 } else {
209 self.state.slide_elapsed = elapsed;
210 }
211
212 if self.state.timer.running
213 && let Some(start) = self.timer_start
214 {
215 self.state.timer.elapsed = start.elapsed();
216 }
217
218 self.state.timer.running || self.state.slide_elapsed > std::time::Duration::ZERO
219 }
220
221 fn process_command(&mut self, cmd: &Command) {
222 match cmd {
223 Command::NextSlide
224 | Command::PreviousSlide
225 | Command::NextOverlay
226 | Command::PreviousOverlay
227 | Command::FirstSlide
228 | Command::LastSlide
229 | Command::GoToSlide(_) => self.handle_navigation(cmd),
230
231 Command::ToggleFreeze
232 | Command::ToggleBlackout
233 | Command::ToggleWhiteboard
234 | Command::ToggleScreenShareMode
235 | Command::TogglePresentationMode => {
236 self.handle_display_mode(cmd);
237 }
238
239 Command::ToggleLaser
240 | Command::CycleLaserStyle
241 | Command::SetPointerPosition(..)
242 | Command::ToggleInk
243 | Command::AddInkPoint(..)
244 | Command::FinishInkStroke
245 | Command::ClearInk
246 | Command::SetInkColor(_)
247 | Command::SetInkWidth(_)
248 | Command::CycleInkColor
249 | Command::CycleInkWidth
250 | Command::ToggleSpotlight
251 | Command::SetSpotlightPosition(..)
252 | Command::ToggleZoom
253 | Command::SetZoomRegion { .. } => self.handle_aid(cmd),
254
255 Command::ToggleTextBoxMode
256 | Command::PlaceTextBox { .. }
257 | Command::EditTextBoxContent { .. }
258 | Command::MoveTextBox { .. }
259 | Command::ResizeTextBox { .. }
260 | Command::DeleteTextBox { .. }
261 | Command::SelectTextBox(_)
262 | Command::DeselectTextBox
263 | Command::BeginTextBoxEdit { .. }
264 | Command::SetTextBoxFontSize { .. }
265 | Command::SetTextBoxColor { .. }
266 | Command::SetTextBoxBackground { .. } => self.handle_text_box(cmd),
267
268 Command::StartTimer
269 | Command::PauseTimer
270 | Command::ToggleTimer
271 | Command::ResetTimer => {
272 self.handle_timer(cmd);
273 }
274
275 Command::ToggleSlideOverview
276 | Command::ToggleNotesPanel
277 | Command::ToggleNotesEdit
278 | Command::SetCurrentSlideNotes(_)
279 | Command::IncrementNotesFontSize
280 | Command::DecrementNotesFontSize => self.handle_ui_panel(cmd),
281
282 Command::Quit => {} Command::SaveSidecar => {
284 self.save_sidecar();
285 }
286 }
287 }
288
289 fn handle_navigation(&mut self, cmd: &Command) {
290 match *cmd {
291 Command::NextSlide => self.next_slide(),
292 Command::PreviousSlide => self.previous_slide(),
293 Command::NextOverlay => self.next_overlay(),
294 Command::PreviousOverlay => self.previous_overlay(),
295 Command::FirstSlide => self.go_to_group(0),
296 Command::LastSlide => {
297 let last = self.state.total_logical_slides.saturating_sub(1);
298 self.go_to_group(last);
299 }
300 Command::GoToSlide(index) => self.go_to_group(index),
301 _ => {}
302 }
303 }
304
305 fn handle_display_mode(&mut self, cmd: &Command) {
306 match *cmd {
307 Command::ToggleFreeze => {
308 if self.state.frozen {
309 self.state.frozen = false;
310 self.state.frozen_page = None;
311 } else {
312 self.state.frozen = true;
313 self.state.frozen_page = Some(self.state.current_page);
314 }
315 }
316 Command::ToggleBlackout => {
317 self.state.blacked_out = !self.state.blacked_out;
318 if self.state.blacked_out {
319 self.state.whiteboard_active = false;
320 }
321 }
322 Command::ToggleWhiteboard => {
323 self.state.whiteboard_active = !self.state.whiteboard_active;
324 if self.state.whiteboard_active {
325 self.state.blacked_out = false;
326 self.state.ink_active = true;
328 self.state.laser_active = false;
329 self.state.text_box_mode = false;
330 self.state.pointer_position = None;
331 self.reset_text_box_selection();
332 }
333 }
334 Command::ToggleScreenShareMode => {
335 self.state.screen_share_mode = !self.state.screen_share_mode;
336 }
337 Command::TogglePresentationMode => {
338 self.state.presentation_mode = !self.state.presentation_mode;
339 }
340 _ => {}
341 }
342 }
343
344 fn handle_aid(&mut self, cmd: &Command) {
345 match *cmd {
346 Command::ToggleLaser => {
347 self.state.laser_active = !self.state.laser_active;
348 if !self.state.laser_active {
349 self.state.pointer_position = None;
350 }
351 if self.state.laser_active {
353 self.state.ink_active = false;
354 }
355 }
356 Command::CycleLaserStyle => {
357 self.state.pointer_style = match self.state.pointer_style {
358 PointerStyle::Dot => PointerStyle::Minimal,
359 PointerStyle::Minimal => PointerStyle::Crosshair,
360 PointerStyle::Crosshair => PointerStyle::Arrow,
361 PointerStyle::Arrow => PointerStyle::Ring,
362 PointerStyle::Ring => PointerStyle::Bullseye,
363 PointerStyle::Bullseye => PointerStyle::Highlight,
364 PointerStyle::Highlight => PointerStyle::Dot,
365 };
366 }
367 Command::SetPointerPosition(x, y) => {
368 let clamped = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
369 if self.state.laser_active || self.state.spotlight_active {
370 self.state.pointer_position = Some(clamped);
371 }
372 if self.state.spotlight_active {
373 self.state.spotlight_position = Some(clamped);
374 }
375 }
376 Command::ToggleInk
377 | Command::AddInkPoint(..)
378 | Command::FinishInkStroke
379 | Command::ClearInk
380 | Command::SetInkColor(_)
381 | Command::SetInkWidth(_)
382 | Command::CycleInkColor
383 | Command::CycleInkWidth => self.handle_ink(cmd),
384 Command::ToggleSpotlight => {
385 self.state.spotlight_active = !self.state.spotlight_active;
386 if !self.state.spotlight_active {
387 self.state.spotlight_position = None;
388 }
389 }
390 Command::SetSpotlightPosition(x, y) if self.state.spotlight_active => {
391 self.state.spotlight_position = Some((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
392 }
393 Command::SetSpotlightPosition(..) => {}
394 Command::ToggleZoom => {
395 self.state.zoom_active = !self.state.zoom_active;
396 if !self.state.zoom_active {
397 self.state.zoom_region = None;
398 }
399 }
400 Command::SetZoomRegion { center, factor } if self.state.zoom_active => {
401 self.state.zoom_region = Some(ZoomRegion {
402 center: (center.0.clamp(0.0, 1.0), center.1.clamp(0.0, 1.0)),
403 factor: factor.clamp(1.0, 10.0),
404 });
405 }
406 Command::SetZoomRegion { .. } => {}
407 _ => {}
408 }
409 }
410
411 fn handle_ink(&mut self, cmd: &Command) {
412 match *cmd {
413 Command::ToggleInk => {
414 self.state.ink_active = !self.state.ink_active;
415 if self.state.ink_active {
416 self.state.laser_active = false;
417 self.state.pointer_position = None;
418 }
419 }
420 Command::AddInkPoint(x, y) if self.state.ink_active => {
421 let point = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
422 let active_pen = self.state.active_pen;
423 let strokes = if self.state.whiteboard_active {
424 &mut self.state.whiteboard_strokes
425 } else {
426 self.state.slide_ink_by_page.entry(self.state.current_page).or_default()
427 };
428 if let Some(stroke) = strokes.last_mut()
429 && !stroke.finished
430 {
431 stroke.points.push(point);
432 return;
433 }
434 strokes.push(InkStroke {
436 points: vec![point],
437 color: active_pen.color,
438 width: active_pen.width,
439 finished: false,
440 });
441 }
442 Command::AddInkPoint(..) => {}
443 Command::FinishInkStroke => {
444 let strokes = if self.state.whiteboard_active {
445 &mut self.state.whiteboard_strokes
446 } else {
447 self.state.slide_ink_by_page.entry(self.state.current_page).or_default()
448 };
449 if let Some(stroke) = strokes.last_mut() {
450 stroke.finished = true;
451 }
452 }
453 Command::ClearInk => {
454 if self.state.whiteboard_active {
455 self.state.whiteboard_strokes.clear();
456 } else {
457 self.state.slide_ink_by_page.remove(&self.state.current_page);
458 }
459 }
460 Command::SetInkColor(color) => {
461 self.state.active_pen.color = color;
462 }
463 Command::SetInkWidth(width) => {
464 self.state.active_pen.width = width.max(0.5);
465 }
466 Command::CycleInkColor if !self.ink_color_presets.is_empty() => {
467 let current = self.state.active_pen.color;
468 let idx = self.ink_color_presets.iter().position(|c| *c == current).unwrap_or(0);
469 self.state.active_pen.color =
470 self.ink_color_presets[(idx + 1) % self.ink_color_presets.len()];
471 }
472 Command::CycleInkColor => {}
473 Command::CycleInkWidth => {
474 let current = self.state.active_pen.width;
475 self.state.active_pen.width = INK_WIDTH_PRESETS
479 .iter()
480 .find(|&&w| w > current)
481 .copied()
482 .unwrap_or(INK_WIDTH_PRESETS[0]);
483 }
484 _ => {}
485 }
486 }
487
488 fn reset_text_box_selection(&mut self) {
489 self.state.selected_text_box = None;
490 self.state.text_box_editing = false;
491 }
492
493 fn update_current_text_box_mut(&mut self, id: u64, f: impl FnOnce(&mut TextBox)) {
494 let page = self.state.current_page;
495 if let Some(boxes) = self.state.slide_text_boxes_by_page.get_mut(&page)
496 && let Some(tb) = boxes.iter_mut().find(|b| b.id == id)
497 {
498 f(tb);
499 }
500 }
501
502 fn create_text_box(&mut self, x: f32, y: f32, w: f32, h: f32) {
503 let id = self.state.next_text_box_id;
504 self.state.next_text_box_id += 1;
505 let x = x.clamp(0.0, 1.0);
506 let y = y.clamp(0.0, 1.0);
507 let w = w.clamp(0.01, 1.0 - x);
508 let h = h.clamp(0.01, 1.0 - y);
509 let text_box = TextBox {
510 id,
511 rect: (x, y, w, h),
512 content: String::new(),
513 font_size: 30.0,
514 color: self.text_box_default_color,
515 background: self.text_box_default_background,
516 typst_prelude: self.text_box_default_typst_prelude.clone(),
517 };
518 self.state
519 .slide_text_boxes_by_page
520 .entry(self.state.current_page)
521 .or_default()
522 .push(text_box);
523 self.state.selected_text_box = Some(id);
524 self.state.text_box_editing = true;
525 }
526
527 fn handle_text_box(&mut self, cmd: &Command) {
528 match cmd {
529 Command::ToggleTextBoxMode => {
530 self.state.text_box_mode = !self.state.text_box_mode;
531 if self.state.text_box_mode {
532 self.state.ink_active = false;
534 self.state.laser_active = false;
535 self.state.pointer_position = None;
536 } else {
537 self.reset_text_box_selection();
538 }
539 }
540 Command::PlaceTextBox { x, y, w, h } => self.create_text_box(*x, *y, *w, *h),
541 Command::EditTextBoxContent { id, content } => {
542 self.update_current_text_box_mut(*id, |tb| tb.content.clone_from(content));
543 }
544 Command::MoveTextBox { id, x, y } => {
545 self.update_current_text_box_mut(*id, |tb| {
546 let (_, _, w, h) = tb.rect;
547 tb.rect = (x.clamp(0.0, 1.0 - w), y.clamp(0.0, 1.0 - h), w, h);
548 });
549 }
550 Command::ResizeTextBox { id, w, h } => {
551 self.update_current_text_box_mut(*id, |tb| {
552 let (x, y, _, _) = tb.rect;
553 let w = w.clamp(0.02, 1.0 - x);
554 let h = h.clamp(0.02, 1.0 - y);
555 tb.rect = (x, y, w, h);
556 });
557 }
558 Command::DeleteTextBox { id } => {
559 let page = self.state.current_page;
560 if let Some(boxes) = self.state.slide_text_boxes_by_page.get_mut(&page) {
561 boxes.retain(|b| b.id != *id);
562 }
563 if self.state.selected_text_box == Some(*id) {
564 self.reset_text_box_selection();
565 }
566 }
567 Command::SelectTextBox(id) => {
568 self.state.selected_text_box = Some(*id);
569 self.state.text_box_editing = false;
570 }
571 Command::DeselectTextBox => self.reset_text_box_selection(),
572 Command::BeginTextBoxEdit { id } => {
573 self.state.selected_text_box = Some(*id);
574 self.state.text_box_editing = true;
575 }
576 Command::SetTextBoxFontSize { id, size } => {
577 self.update_current_text_box_mut(*id, |tb| tb.font_size = size.clamp(6.0, 144.0));
578 }
579 Command::SetTextBoxColor { id, color } => {
580 self.update_current_text_box_mut(*id, |tb| tb.color = *color);
581 }
582 Command::SetTextBoxBackground { id, color } => {
583 self.update_current_text_box_mut(*id, |tb| tb.background = *color);
584 }
585 _ => {}
586 }
587 }
588
589 fn handle_timer(&mut self, cmd: &Command) {
590 match *cmd {
591 Command::ToggleTimer => {
592 if self.state.timer.running {
593 self.state.timer.running = false;
594 } else {
595 self.state.timer.running = true;
596 self.timer_start = Some(
597 Instant::now()
598 .checked_sub(self.state.timer.elapsed)
599 .unwrap_or_else(Instant::now),
600 );
601 }
602 }
603 Command::StartTimer if !self.state.timer.running => {
604 self.state.timer.running = true;
605 self.timer_start = Some(
606 Instant::now()
607 .checked_sub(self.state.timer.elapsed)
608 .unwrap_or_else(Instant::now),
609 );
610 }
611 Command::StartTimer => {}
612 Command::PauseTimer => self.state.timer.running = false,
613 Command::ResetTimer => {
614 self.state.timer.running = false;
615 self.state.timer.elapsed = std::time::Duration::ZERO;
616 self.timer_start = None;
617 }
618 _ => {}
619 }
620 }
621
622 fn handle_ui_panel(&mut self, cmd: &Command) {
623 match *cmd {
624 Command::ToggleSlideOverview => {
625 self.state.overview_visible = !self.state.overview_visible;
626 }
627 Command::ToggleNotesPanel => {
628 self.state.notes_visible = !self.state.notes_visible;
629 if !self.state.notes_visible {
630 self.state.notes_editing = false;
631 }
632 }
633 Command::ToggleNotesEdit => {
634 self.state.notes_editing = !self.state.notes_editing;
635 if self.state.notes_editing {
636 self.state.notes_visible = true;
637 }
638 }
639 Command::SetCurrentSlideNotes(ref text) => {
640 let notes = if text.trim().is_empty() { None } else { Some(text.clone()) };
641 if let Some(group) =
642 self.state.slide_groups.get_mut(self.state.current_logical_slide)
643 {
644 group.notes.clone_from(¬es);
645 }
646 self.state.current_notes = notes;
647 }
648 Command::IncrementNotesFontSize => {
649 self.state.notes_font_size =
650 (self.state.notes_font_size + self.state.notes_font_size_step).min(72.0);
651 }
652 Command::DecrementNotesFontSize => {
653 self.state.notes_font_size =
654 (self.state.notes_font_size - self.state.notes_font_size_step).max(8.0);
655 }
656 _ => {}
657 }
658 }
659
660 fn save_sidecar(&self) {
663 use dais_sidecar::format::SidecarFormat;
664 use dais_sidecar::types::SlideGroupMeta;
665
666 let mut notes = std::collections::HashMap::new();
669 let mut groups = Vec::new();
670 for group in &self.state.slide_groups {
671 if let (Some(&start), Some(&end)) = (group.pages.first(), group.pages.last()) {
672 groups.push(SlideGroupMeta { start_page: start, end_page: end });
673 }
674 if let Some(ref text) = group.notes
675 && let Some(&page) = group.pages.first()
676 {
677 notes.insert(page, text.clone());
678 }
679 }
680
681 let mut metadata = self.metadata.clone();
682 metadata.groups = groups;
683 metadata.notes = notes;
684
685 let mut slide_timings = std::collections::HashMap::new();
687 for (i, dur) in self.state.slide_elapsed_by_logical.iter().enumerate() {
688 let secs = dur.as_secs_f64();
689 if secs > 0.0 {
690 slide_timings.insert(i, secs);
691 }
692 }
693 metadata.slide_timings = slide_timings;
694
695 metadata.slide_annotations = self
697 .state
698 .slide_ink_by_page
699 .iter()
700 .filter(|(_, strokes)| !strokes.is_empty())
701 .map(|(&page, strokes)| {
702 (
703 page,
704 strokes
705 .iter()
706 .filter(|s| s.finished)
707 .map(|s| InkStrokeMeta {
708 points: s.points.clone(),
709 color: s.color,
710 width: s.width,
711 })
712 .collect(),
713 )
714 })
715 .collect();
716 metadata.whiteboard_annotations = self
717 .state
718 .whiteboard_strokes
719 .iter()
720 .filter(|s| s.finished)
721 .map(|s| InkStrokeMeta { points: s.points.clone(), color: s.color, width: s.width })
722 .collect();
723
724 metadata.slide_text_boxes = self
726 .state
727 .slide_text_boxes_by_page
728 .iter()
729 .filter(|(_, boxes)| !boxes.is_empty())
730 .map(|(&page, boxes)| {
731 (
732 page,
733 boxes
734 .iter()
735 .map(|tb| TextBoxMeta {
736 id: tb.id,
737 rect: tb.rect,
738 content: tb.content.clone(),
739 font_size: tb.font_size,
740 color: tb.color,
741 background: tb.background,
742 typst_prelude: tb.typst_prelude.clone(),
743 })
744 .collect(),
745 )
746 })
747 .collect();
748
749 let (format, sidecar_path): (Box<dyn SidecarFormat>, _) = match self.sidecar_kind {
750 SidecarKind::Dais => (
751 Box::new(dais_sidecar::dais_format::DaisFormat),
752 self.pdf_path.with_extension("dais"),
753 ),
754 SidecarKind::Pdfpc => {
755 (Box::new(dais_sidecar::pdfpc::PdfpcFormat), self.pdf_path.with_extension("pdfpc"))
756 }
757 };
758
759 match format.write(&sidecar_path, &metadata) {
760 Ok(()) => tracing::info!("Saved sidecar to {}", sidecar_path.display()),
761 Err(e) => tracing::error!("Failed to save sidecar: {e}"),
762 }
763 }
764
765 fn next_slide(&mut self) {
768 self.advance_step();
769 }
770
771 fn previous_slide(&mut self) {
772 self.rewind_step();
773 }
774
775 fn next_overlay(&mut self) {
776 self.advance_step();
777 }
778
779 fn previous_overlay(&mut self) {
780 self.rewind_step();
781 }
782
783 fn advance_step(&mut self) {
784 if self.state.blacked_out {
785 return;
786 }
787 if self.state.slide_groups.is_empty() {
788 return;
789 }
790 let group = &self.state.slide_groups[self.state.current_logical_slide];
791 let overlay = self.state.current_overlay_within_group;
792 if overlay + 1 < group.pages.len() {
793 self.state.current_overlay_within_group = overlay + 1;
794 self.state.current_page = group.pages[overlay + 1];
795 } else {
796 let current = self.state.current_logical_slide;
797 if current + 1 < self.state.total_logical_slides {
798 self.state.blacked_out = false;
799 self.go_to_group(current + 1);
800 } else {
801 self.state.blacked_out = true;
802 }
803 }
804 }
805
806 fn rewind_step(&mut self) {
807 if self.state.blacked_out {
808 self.state.blacked_out = false;
809 return;
810 }
811 if self.state.slide_groups.is_empty() {
812 return;
813 }
814 let overlay = self.state.current_overlay_within_group;
815 if overlay > 0 {
816 let group = &self.state.slide_groups[self.state.current_logical_slide];
817 self.state.current_overlay_within_group = overlay - 1;
818 self.state.current_page = group.pages[overlay - 1];
819 } else {
820 let current = self.state.current_logical_slide;
821 if current > 0 {
822 let last_overlay = self.state.slide_groups[current - 1].pages.len() - 1;
823 self.go_to_group(current - 1);
824 self.state.current_overlay_within_group = last_overlay;
825 self.state.current_page = self.state.slide_groups[current - 1].pages[last_overlay];
826 }
827 }
828 }
829
830 fn go_to_group(&mut self, group_index: usize) {
831 if self.state.slide_groups.is_empty() || self.state.total_logical_slides == 0 {
832 return;
833 }
834 let clamped = group_index.min(self.state.total_logical_slides - 1);
836 self.state.blacked_out = false;
837 self.state.current_logical_slide = clamped;
838 self.state.current_overlay_within_group = 0;
839 self.state.current_page = self.state.slide_groups[clamped].pages[0];
840 let accumulated = self
841 .state
842 .slide_elapsed_by_logical
843 .get(clamped)
844 .copied()
845 .unwrap_or(std::time::Duration::ZERO);
846 self.slide_start = Instant::now().checked_sub(accumulated).unwrap_or_else(Instant::now);
847 self.state.slide_elapsed = accumulated;
848 self.update_notes();
849 }
850
851 fn update_notes(&mut self) {
852 self.state.current_notes = self
853 .state
854 .slide_groups
855 .get(self.state.current_logical_slide)
856 .and_then(|g| g.notes.clone());
857 }
858}
859
860fn build_slide_groups(total_pages: usize, metadata: &PresentationMetadata) -> Vec<SlideGroup> {
865 if metadata.groups.is_empty() {
866 let mut groups = dais_core::slide_group::default_grouping(total_pages);
868 for group in &mut groups {
869 if let Some(page) = group.pages.first() {
870 group.notes = metadata.notes.get(page).cloned();
871 }
872 }
873 return groups;
874 }
875
876 let mut group_by_start: std::collections::HashMap<usize, &dais_sidecar::types::SlideGroupMeta> =
880 metadata
881 .groups
882 .iter()
883 .filter(|gm| gm.start_page < total_pages)
884 .map(|gm| (gm.start_page, gm))
885 .collect();
886
887 let mut groups: Vec<SlideGroup> = Vec::new();
888 let mut page = 0usize;
889 while page < total_pages {
890 let logical_index = groups.len();
891 if let Some(gm) = group_by_start.remove(&page) {
892 let end = gm.end_page.min(total_pages - 1);
893 let pages: Vec<usize> = (page..=end).collect();
894 let notes = metadata.notes.get(&page).cloned();
895 groups.push(SlideGroup { logical_index, pages, notes });
896 page = end + 1;
897 } else {
898 let notes = metadata.notes.get(&page).cloned();
899 groups.push(SlideGroup { logical_index, pages: vec![page], notes });
900 page += 1;
901 }
902 }
903
904 groups
905}
906
907fn parse_hex_color(color_str: &str) -> Option<[u8; 4]> {
909 let hex = color_str.strip_prefix('#').unwrap_or(color_str);
910 if hex.len() == 6 {
911 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
912 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
913 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
914 Some([red, green, blue, 255])
915 } else if hex.len() == 8 {
916 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
917 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
918 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
919 let alpha = u8::from_str_radix(&hex[6..8], 16).ok()?;
920 Some([red, green, blue, alpha])
921 } else {
922 None
923 }
924}
925
926fn parse_optional_hex_color(color_str: &str) -> Option<[u8; 4]> {
927 if color_str.trim().eq_ignore_ascii_case("transparent") {
928 None
929 } else {
930 parse_hex_color(color_str)
931 }
932}
933
934fn parse_pointer_style(style: &str) -> PointerStyle {
935 match style.trim().to_ascii_lowercase().as_str() {
936 "minimal" => PointerStyle::Minimal,
937 "crosshair" => PointerStyle::Crosshair,
938 "arrow" => PointerStyle::Arrow,
939 "ring" => PointerStyle::Ring,
940 "bullseye" => PointerStyle::Bullseye,
941 "highlight" => PointerStyle::Highlight,
942 _ => PointerStyle::Dot,
943 }
944}
945
946fn pointer_appearances_from_config(config: &Config) -> PointerAppearances {
947 PointerAppearances {
948 dot: pointer_appearance_from_config(&config.laser.dot),
949 minimal: pointer_appearance_from_config(&config.laser.minimal),
950 crosshair: pointer_appearance_from_config(&config.laser.crosshair),
951 arrow: pointer_appearance_from_config(&config.laser.arrow),
952 ring: pointer_appearance_from_config(&config.laser.ring),
953 bullseye: pointer_appearance_from_config(&config.laser.bullseye),
954 highlight: pointer_appearance_from_config(&config.laser.highlight),
955 }
956}
957
958fn pointer_appearance_from_config(
959 config: &dais_core::config::PointerStyleConfig,
960) -> PointerAppearance {
961 PointerAppearance {
962 color: parse_hex_color(&config.color).unwrap_or([255, 0, 0, 255]),
963 size: config.size.clamp(2.0, 96.0),
964 }
965}
966
967fn load_annotations_into_state(state: &mut PresentationState, metadata: &PresentationMetadata) {
969 for (page, strokes) in &metadata.slide_annotations {
970 let runtime_strokes: Vec<InkStroke> = strokes
971 .iter()
972 .map(|s| InkStroke {
973 points: s.points.clone(),
974 color: s.color,
975 width: s.width,
976 finished: true,
977 })
978 .collect();
979 if !runtime_strokes.is_empty() {
980 state.slide_ink_by_page.insert(*page, runtime_strokes);
981 }
982 }
983 state.whiteboard_strokes = metadata
984 .whiteboard_annotations
985 .iter()
986 .map(|s| InkStroke {
987 points: s.points.clone(),
988 color: s.color,
989 width: s.width,
990 finished: true,
991 })
992 .collect();
993
994 let mut max_id: u64 = 0;
996 for (page, boxes) in &metadata.slide_text_boxes {
997 let runtime_boxes: Vec<TextBox> = boxes
998 .iter()
999 .map(|tb| {
1000 if tb.id > max_id {
1001 max_id = tb.id;
1002 }
1003 TextBox {
1004 id: tb.id,
1005 rect: tb.rect,
1006 content: tb.content.clone(),
1007 font_size: tb.font_size,
1008 color: tb.color,
1009 background: tb.background,
1010 typst_prelude: tb.typst_prelude.clone(),
1011 }
1012 })
1013 .collect();
1014 if !runtime_boxes.is_empty() {
1015 state.slide_text_boxes_by_page.insert(*page, runtime_boxes);
1016 }
1017 }
1018 state.next_text_box_id = max_id + 1;
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use dais_core::bus::CommandBus;
1026 use dais_sidecar::types::SlideGroupMeta;
1027 use std::collections::HashMap;
1028
1029 fn make_engine(
1030 total_pages: usize,
1031 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1032 make_engine_with_metadata(total_pages, &PresentationMetadata::default())
1033 }
1034
1035 fn make_engine_with_metadata(
1036 total_pages: usize,
1037 metadata: &PresentationMetadata,
1038 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1039 make_engine_with_config(total_pages, metadata, &Config::default())
1040 }
1041
1042 fn make_engine_with_config(
1043 total_pages: usize,
1044 metadata: &PresentationMetadata,
1045 config: &Config,
1046 ) -> (PresentationEngine, Arc<RwLock<PresentationState>>, dais_core::bus::CommandSender) {
1047 let bus = CommandBus::new();
1048 let sender = bus.sender();
1049 let receiver = bus.into_receiver();
1050 let (engine, shared) = PresentationEngine::new(
1051 total_pages,
1052 metadata,
1053 config,
1054 receiver,
1055 std::path::PathBuf::from("test.pdf"),
1056 );
1057 (engine, shared, sender)
1058 }
1059
1060 #[test]
1063 fn parse_hex_color_6_digit() {
1064 assert_eq!(parse_hex_color("#FF0000"), Some([255, 0, 0, 255]));
1065 assert_eq!(parse_hex_color("00FF00"), Some([0, 255, 0, 255]));
1066 assert_eq!(parse_hex_color("#0000ff"), Some([0, 0, 255, 255]));
1067 }
1068
1069 #[test]
1070 fn parse_hex_color_8_digit() {
1071 assert_eq!(parse_hex_color("#FF000080"), Some([255, 0, 0, 128]));
1072 }
1073
1074 #[test]
1075 fn parse_hex_color_invalid() {
1076 assert_eq!(parse_hex_color(""), None);
1077 assert_eq!(parse_hex_color("#FFF"), None);
1078 assert_eq!(parse_hex_color("ZZZZZZ"), None);
1079 }
1080
1081 #[test]
1082 fn parse_optional_hex_color_transparent() {
1083 assert_eq!(parse_optional_hex_color("transparent"), None);
1084 assert_eq!(parse_optional_hex_color(" TRANSPARENT "), None);
1085 assert_eq!(parse_optional_hex_color("#01020380"), Some([1, 2, 3, 128]));
1086 }
1087
1088 #[test]
1089 fn parse_pointer_style_variants() {
1090 assert_eq!(parse_pointer_style("dot"), PointerStyle::Dot);
1091 assert_eq!(parse_pointer_style("minimal"), PointerStyle::Minimal);
1092 assert_eq!(parse_pointer_style("crosshair"), PointerStyle::Crosshair);
1093 assert_eq!(parse_pointer_style("arrow"), PointerStyle::Arrow);
1094 assert_eq!(parse_pointer_style("ring"), PointerStyle::Ring);
1095 assert_eq!(parse_pointer_style("bullseye"), PointerStyle::Bullseye);
1096 assert_eq!(parse_pointer_style("highlight"), PointerStyle::Highlight);
1097 assert_eq!(parse_pointer_style("unknown"), PointerStyle::Dot);
1098 }
1099
1100 #[test]
1101 fn presentation_aid_config_populates_state() {
1102 let mut config = Config::default();
1103 config.laser.style = "crosshair".to_string();
1104 config.laser.crosshair.color = "#33CC66AA".to_string();
1105 config.laser.crosshair.size = 24.0;
1106 config.spotlight.radius = 220.0;
1107 config.spotlight.dim_opacity = 0.35;
1108
1109 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
1110
1111 let pointer = engine.state().current_pointer_appearance();
1112 assert_eq!(pointer.color, [0x33, 0xCC, 0x66, 0xAA]);
1113 assert!((pointer.size - 24.0).abs() < f32::EPSILON);
1114 assert_eq!(engine.state().pointer_style, PointerStyle::Crosshair);
1115 assert!((engine.state().spotlight_radius - 220.0).abs() < f32::EPSILON);
1116 assert!((engine.state().spotlight_dim_opacity - 0.35).abs() < f32::EPSILON);
1117 }
1118
1119 #[test]
1120 fn per_style_pointer_config_populates_state() {
1121 let mut config = Config::default();
1122 config.laser.style = "crosshair".to_string();
1123 config.laser.dot.color = "#FFFFFF".to_string();
1124 config.laser.dot.size = 10.0;
1125 config.laser.minimal.color = "#FF00AA".to_string();
1126 config.laser.minimal.size = 8.0;
1127 config.laser.crosshair.color = "#00FF00".to_string();
1128 config.laser.crosshair.size = 28.0;
1129 config.laser.arrow.color = "#3355FF80".to_string();
1130 config.laser.arrow.size = 18.0;
1131 config.laser.ring.color = "#FFAA00".to_string();
1132 config.laser.ring.size = 30.0;
1133 config.laser.bullseye.color = "#AA00FF".to_string();
1134 config.laser.bullseye.size = 26.0;
1135 config.laser.highlight.color = "#FFFF0080".to_string();
1136 config.laser.highlight.size = 36.0;
1137
1138 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
1139
1140 assert_eq!(engine.state().pointer_appearances.dot.color, [255, 255, 255, 255]);
1141 assert!((engine.state().pointer_appearances.dot.size - 10.0).abs() < f32::EPSILON);
1142 assert_eq!(engine.state().pointer_appearances.minimal.color, [0xFF, 0, 0xAA, 255]);
1143 assert!((engine.state().pointer_appearances.minimal.size - 8.0).abs() < f32::EPSILON);
1144 assert_eq!(engine.state().pointer_appearances.crosshair.color, [0, 255, 0, 255]);
1145 assert!((engine.state().pointer_appearances.crosshair.size - 28.0).abs() < f32::EPSILON);
1146 assert_eq!(engine.state().pointer_appearances.arrow.color, [0x33, 0x55, 0xFF, 0x80]);
1147 assert!((engine.state().pointer_appearances.arrow.size - 18.0).abs() < f32::EPSILON);
1148 assert_eq!(engine.state().pointer_appearances.ring.color, [0xFF, 0xAA, 0, 255]);
1149 assert!((engine.state().pointer_appearances.ring.size - 30.0).abs() < f32::EPSILON);
1150 assert_eq!(engine.state().pointer_appearances.bullseye.color, [0xAA, 0, 0xFF, 255]);
1151 assert!((engine.state().pointer_appearances.bullseye.size - 26.0).abs() < f32::EPSILON);
1152 assert_eq!(engine.state().pointer_appearances.highlight.color, [0xFF, 0xFF, 0, 0x80]);
1153 assert!((engine.state().pointer_appearances.highlight.size - 36.0).abs() < f32::EPSILON);
1154 assert_eq!(engine.state().current_pointer_appearance().color, [0, 255, 0, 255]);
1155 }
1156
1157 #[test]
1160 fn build_groups_no_metadata_gives_one_to_one() {
1161 let groups = build_slide_groups(5, &PresentationMetadata::default());
1162 assert_eq!(groups.len(), 5);
1163 for (i, group) in groups.iter().enumerate() {
1164 assert_eq!(group.logical_index, i);
1165 assert_eq!(group.pages, vec![i]);
1166 }
1167 }
1168
1169 #[test]
1170 fn build_groups_from_metadata() {
1171 let meta = PresentationMetadata {
1172 groups: vec![
1173 SlideGroupMeta { start_page: 0, end_page: 2 },
1174 SlideGroupMeta { start_page: 3, end_page: 4 },
1175 ],
1176 ..Default::default()
1177 };
1178 let groups = build_slide_groups(5, &meta);
1179 assert_eq!(groups.len(), 2);
1180 assert_eq!(groups[0].pages, vec![0, 1, 2]);
1181 assert_eq!(groups[1].pages, vec![3, 4]);
1182 }
1183
1184 #[test]
1185 fn build_groups_with_notes() {
1186 let mut notes = HashMap::new();
1187 notes.insert(0, "Slide one notes".to_string());
1188 notes.insert(3, "Slide two notes".to_string());
1189 let meta = PresentationMetadata {
1190 groups: vec![
1191 SlideGroupMeta { start_page: 0, end_page: 2 },
1192 SlideGroupMeta { start_page: 3, end_page: 4 },
1193 ],
1194 notes,
1195 ..Default::default()
1196 };
1197 let groups = build_slide_groups(5, &meta);
1198 assert_eq!(groups[0].notes.as_deref(), Some("Slide one notes"));
1199 assert_eq!(groups[1].notes.as_deref(), Some("Slide two notes"));
1200 }
1201
1202 #[test]
1203 fn build_groups_uncovered_pages_become_individual() {
1204 let meta = PresentationMetadata {
1205 groups: vec![SlideGroupMeta { start_page: 0, end_page: 1 }],
1206 ..Default::default()
1207 };
1208 let groups = build_slide_groups(5, &meta);
1209 assert_eq!(groups.len(), 4); assert_eq!(groups[0].pages, vec![0, 1]);
1211 assert_eq!(groups[1].pages, vec![2]);
1212 assert_eq!(groups[2].pages, vec![3]);
1213 assert_eq!(groups[3].pages, vec![4]);
1214 }
1215
1216 #[test]
1219 fn initial_state_at_first_slide() {
1220 let (engine, _, _) = make_engine(10);
1221 let state = engine.state();
1222 assert_eq!(state.current_page, 0);
1223 assert_eq!(state.current_logical_slide, 0);
1224 assert_eq!(state.current_overlay_within_group, 0);
1225 assert_eq!(state.total_pages, 10);
1226 assert_eq!(state.total_logical_slides, 10);
1227 }
1228
1229 #[test]
1230 fn next_slide_advances() {
1231 let (mut engine, _, sender) = make_engine(5);
1232 sender.send(Command::NextSlide).unwrap();
1233 engine.tick();
1234 assert_eq!(engine.state().current_logical_slide, 1);
1235 assert_eq!(engine.state().current_page, 1);
1236 }
1237
1238 #[test]
1239 fn next_slide_advances_within_group_before_next_group() {
1240 let meta = PresentationMetadata {
1241 groups: vec![
1242 SlideGroupMeta { start_page: 0, end_page: 2 },
1243 SlideGroupMeta { start_page: 3, end_page: 4 },
1244 ],
1245 ..Default::default()
1246 };
1247 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1248
1249 sender.send(Command::NextSlide).unwrap();
1250 engine.tick();
1251 assert_eq!(engine.state().current_logical_slide, 0);
1252 assert_eq!(engine.state().current_overlay_within_group, 1);
1253 assert_eq!(engine.state().current_page, 1);
1254
1255 sender.send(Command::NextSlide).unwrap();
1256 engine.tick();
1257 assert_eq!(engine.state().current_logical_slide, 0);
1258 assert_eq!(engine.state().current_overlay_within_group, 2);
1259 assert_eq!(engine.state().current_page, 2);
1260
1261 sender.send(Command::NextSlide).unwrap();
1262 engine.tick();
1263 assert_eq!(engine.state().current_logical_slide, 1);
1264 assert_eq!(engine.state().current_overlay_within_group, 0);
1265 assert_eq!(engine.state().current_page, 3);
1266 }
1267
1268 #[test]
1269 fn next_slide_stops_at_end() {
1270 let (mut engine, _, sender) = make_engine(3);
1271 for _ in 0..10 {
1272 sender.send(Command::NextSlide).unwrap();
1273 }
1274 engine.tick();
1275 assert_eq!(engine.state().current_logical_slide, 2);
1276 assert!(engine.state().blacked_out);
1277 }
1278
1279 #[test]
1280 fn previous_slide_stops_at_start() {
1281 let (mut engine, _, sender) = make_engine(5);
1282 sender.send(Command::PreviousSlide).unwrap();
1283 engine.tick();
1284 assert_eq!(engine.state().current_logical_slide, 0);
1285 }
1286
1287 #[test]
1288 fn previous_slide_rewinds_within_group_before_previous_group() {
1289 let meta = PresentationMetadata {
1290 groups: vec![
1291 SlideGroupMeta { start_page: 0, end_page: 2 },
1292 SlideGroupMeta { start_page: 3, end_page: 4 },
1293 ],
1294 ..Default::default()
1295 };
1296 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1297
1298 sender.send(Command::LastSlide).unwrap();
1299 engine.tick();
1300 assert_eq!(engine.state().current_page, 3);
1301
1302 sender.send(Command::PreviousSlide).unwrap();
1303 engine.tick();
1304 assert_eq!(engine.state().current_logical_slide, 0);
1305 assert_eq!(engine.state().current_overlay_within_group, 2);
1306 assert_eq!(engine.state().current_page, 2);
1307
1308 sender.send(Command::PreviousSlide).unwrap();
1309 engine.tick();
1310 assert_eq!(engine.state().current_logical_slide, 0);
1311 assert_eq!(engine.state().current_overlay_within_group, 1);
1312 assert_eq!(engine.state().current_page, 1);
1313 }
1314
1315 #[test]
1316 fn previous_slide_clears_end_blackout() {
1317 let (mut engine, _, sender) = make_engine(1);
1318 sender.send(Command::NextSlide).unwrap();
1319 engine.tick();
1320 assert!(engine.state().blacked_out);
1321
1322 sender.send(Command::PreviousSlide).unwrap();
1323 engine.tick();
1324 assert!(!engine.state().blacked_out);
1325 assert_eq!(engine.state().current_logical_slide, 0);
1326 }
1327
1328 #[test]
1329 fn first_and_last_slide() {
1330 let (mut engine, _, sender) = make_engine(10);
1331 sender.send(Command::LastSlide).unwrap();
1332 engine.tick();
1333 assert_eq!(engine.state().current_logical_slide, 9);
1334
1335 sender.send(Command::FirstSlide).unwrap();
1336 engine.tick();
1337 assert_eq!(engine.state().current_logical_slide, 0);
1338 }
1339
1340 #[test]
1341 fn go_to_slide() {
1342 let (mut engine, _, sender) = make_engine(10);
1343 sender.send(Command::GoToSlide(5)).unwrap();
1344 engine.tick();
1345 assert_eq!(engine.state().current_logical_slide, 5);
1346 }
1347
1348 #[test]
1349 fn go_to_slide_out_of_range_ignored() {
1350 let (mut engine, _, sender) = make_engine(5);
1351 sender.send(Command::GoToSlide(100)).unwrap();
1352 engine.tick();
1353 assert_eq!(engine.state().current_logical_slide, 4);
1355 }
1356
1357 #[test]
1360 fn overlay_navigation_within_group() {
1361 let meta = PresentationMetadata {
1362 groups: vec![
1363 SlideGroupMeta { start_page: 0, end_page: 2 },
1364 SlideGroupMeta { start_page: 3, end_page: 4 },
1365 ],
1366 ..Default::default()
1367 };
1368 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1369 assert_eq!(engine.state().current_logical_slide, 0);
1370
1371 sender.send(Command::NextOverlay).unwrap();
1372 engine.tick();
1373 assert_eq!(engine.state().current_logical_slide, 0);
1374 assert_eq!(engine.state().current_overlay_within_group, 1);
1375 assert_eq!(engine.state().current_page, 1);
1376
1377 sender.send(Command::NextOverlay).unwrap();
1378 engine.tick();
1379 assert_eq!(engine.state().current_overlay_within_group, 2);
1380 assert_eq!(engine.state().current_page, 2);
1381
1382 sender.send(Command::NextOverlay).unwrap();
1384 engine.tick();
1385 assert_eq!(engine.state().current_logical_slide, 1);
1386 assert_eq!(engine.state().current_overlay_within_group, 0);
1387 assert_eq!(engine.state().current_page, 3);
1388 }
1389
1390 #[test]
1391 fn previous_overlay_goes_to_last_overlay_of_prev_group() {
1392 let meta = PresentationMetadata {
1393 groups: vec![
1394 SlideGroupMeta { start_page: 0, end_page: 2 },
1395 SlideGroupMeta { start_page: 3, end_page: 4 },
1396 ],
1397 ..Default::default()
1398 };
1399 let (mut engine, _, sender) = make_engine_with_metadata(5, &meta);
1400 sender.send(Command::LastSlide).unwrap();
1401 engine.tick();
1402 assert_eq!(engine.state().current_logical_slide, 1);
1403
1404 sender.send(Command::PreviousOverlay).unwrap();
1405 engine.tick();
1406 assert_eq!(engine.state().current_logical_slide, 0);
1407 assert_eq!(engine.state().current_overlay_within_group, 2);
1408 assert_eq!(engine.state().current_page, 2);
1409 }
1410
1411 #[test]
1414 fn toggle_freeze_captures_page() {
1415 let (mut engine, _, sender) = make_engine(5);
1416 sender.send(Command::NextSlide).unwrap();
1417 sender.send(Command::NextSlide).unwrap();
1418 engine.tick();
1419 assert_eq!(engine.state().current_page, 2);
1420
1421 sender.send(Command::ToggleFreeze).unwrap();
1422 engine.tick();
1423 assert!(engine.state().frozen);
1424 assert_eq!(engine.state().frozen_page, Some(2));
1425
1426 sender.send(Command::ToggleFreeze).unwrap();
1427 engine.tick();
1428 assert!(!engine.state().frozen);
1429 assert_eq!(engine.state().frozen_page, None);
1430 }
1431
1432 #[test]
1433 fn toggle_blackout() {
1434 let (mut engine, _, sender) = make_engine(5);
1435 assert!(!engine.state().blacked_out);
1436 sender.send(Command::ToggleBlackout).unwrap();
1437 engine.tick();
1438 assert!(engine.state().blacked_out);
1439 sender.send(Command::ToggleBlackout).unwrap();
1440 engine.tick();
1441 assert!(!engine.state().blacked_out);
1442 }
1443
1444 #[test]
1445 fn toggle_screen_share() {
1446 let (mut engine, _, sender) = make_engine(5);
1447 assert!(!engine.state().screen_share_mode);
1448 sender.send(Command::ToggleScreenShareMode).unwrap();
1449 engine.tick();
1450 assert!(engine.state().screen_share_mode);
1451 }
1452
1453 #[test]
1454 fn toggle_presentation_mode() {
1455 let (mut engine, _, sender) = make_engine(5);
1456 assert!(!engine.state().presentation_mode);
1457 sender.send(Command::TogglePresentationMode).unwrap();
1458 engine.tick();
1459 assert!(engine.state().presentation_mode);
1460 sender.send(Command::TogglePresentationMode).unwrap();
1461 engine.tick();
1462 assert!(!engine.state().presentation_mode);
1463 }
1464
1465 #[test]
1466 fn quit_exits_presentation_mode_first() {
1467 let (mut engine, _, sender) = make_engine(5);
1468 sender.send(Command::TogglePresentationMode).unwrap();
1469 engine.tick();
1470 assert!(engine.state().presentation_mode);
1471
1472 sender.send(Command::Quit).unwrap();
1474 let should_quit = engine.tick();
1475 assert!(!should_quit);
1476 assert!(!engine.state().presentation_mode);
1477
1478 sender.send(Command::Quit).unwrap();
1480 let should_quit = engine.tick();
1481 assert!(!should_quit);
1482 assert!(engine.state().quit_requested);
1483
1484 sender.send(Command::Quit).unwrap();
1486 let should_quit = engine.tick();
1487 assert!(should_quit);
1488 }
1489
1490 #[test]
1493 fn laser_and_ink_mutually_exclusive() {
1494 let (mut engine, _, sender) = make_engine(5);
1495 assert!(engine.state().laser_active);
1496
1497 sender.send(Command::ToggleLaser).unwrap();
1498 engine.tick();
1499 assert!(!engine.state().laser_active);
1500
1501 sender.send(Command::ToggleInk).unwrap();
1502 engine.tick();
1503 assert!(engine.state().ink_active);
1504 assert!(!engine.state().laser_active);
1505
1506 sender.send(Command::ToggleLaser).unwrap();
1507 engine.tick();
1508 assert!(engine.state().laser_active);
1509 assert!(!engine.state().ink_active);
1510 }
1511
1512 #[test]
1513 fn cycle_laser_style_rotates_styles() {
1514 let (mut engine, _, sender) = make_engine(5);
1515 assert_eq!(engine.state().pointer_style, PointerStyle::Dot);
1516
1517 sender.send(Command::CycleLaserStyle).unwrap();
1518 engine.tick();
1519 assert_eq!(engine.state().pointer_style, PointerStyle::Minimal);
1520
1521 sender.send(Command::CycleLaserStyle).unwrap();
1522 engine.tick();
1523 assert_eq!(engine.state().pointer_style, PointerStyle::Crosshair);
1524
1525 sender.send(Command::CycleLaserStyle).unwrap();
1526 engine.tick();
1527 assert_eq!(engine.state().pointer_style, PointerStyle::Arrow);
1528
1529 sender.send(Command::CycleLaserStyle).unwrap();
1530 engine.tick();
1531 assert_eq!(engine.state().pointer_style, PointerStyle::Ring);
1532
1533 sender.send(Command::CycleLaserStyle).unwrap();
1534 engine.tick();
1535 assert_eq!(engine.state().pointer_style, PointerStyle::Bullseye);
1536
1537 sender.send(Command::CycleLaserStyle).unwrap();
1538 engine.tick();
1539 assert_eq!(engine.state().pointer_style, PointerStyle::Highlight);
1540
1541 sender.send(Command::CycleLaserStyle).unwrap();
1542 engine.tick();
1543 assert_eq!(engine.state().pointer_style, PointerStyle::Dot);
1544 }
1545
1546 #[test]
1547 fn pointer_position_when_laser_active() {
1548 let (mut engine, _, sender) = make_engine(5);
1549 sender.send(Command::SetPointerPosition(0.5, 0.5)).unwrap();
1550 engine.tick();
1551 assert_eq!(engine.state().pointer_position, Some((0.5, 0.5)));
1552 }
1553
1554 #[test]
1555 fn ink_stroke_lifecycle() {
1556 let (mut engine, _, sender) = make_engine(5);
1557 sender.send(Command::ToggleInk).unwrap();
1558 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1559 sender.send(Command::AddInkPoint(0.3, 0.4)).unwrap();
1560 sender.send(Command::FinishInkStroke).unwrap();
1561 engine.tick();
1562
1563 let strokes = engine.state().current_page_ink();
1564 assert_eq!(strokes.len(), 1);
1565 assert_eq!(strokes[0].points.len(), 2);
1566 assert!(strokes[0].finished);
1567
1568 sender.send(Command::AddInkPoint(0.5, 0.6)).unwrap();
1569 engine.tick();
1570 assert_eq!(engine.state().current_page_ink().len(), 2);
1571 }
1572
1573 #[test]
1574 fn ink_points_ignored_when_ink_inactive() {
1575 let (mut engine, _, sender) = make_engine(5);
1576 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1577 engine.tick();
1578 assert!(engine.state().current_page_ink().is_empty());
1579 }
1580
1581 #[test]
1582 fn clear_ink() {
1583 let (mut engine, _, sender) = make_engine(5);
1584 sender.send(Command::ToggleInk).unwrap();
1585 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1586 sender.send(Command::FinishInkStroke).unwrap();
1587 engine.tick();
1588 assert_eq!(engine.state().current_page_ink().len(), 1);
1589
1590 sender.send(Command::ClearInk).unwrap();
1591 engine.tick();
1592 assert!(engine.state().current_page_ink().is_empty());
1593 }
1594
1595 #[test]
1596 fn ink_persists_across_navigation() {
1597 let (mut engine, _, sender) = make_engine(5);
1598
1599 sender.send(Command::ToggleInk).unwrap();
1601 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1602 sender.send(Command::FinishInkStroke).unwrap();
1603 engine.tick();
1604 assert_eq!(engine.state().current_page_ink().len(), 1);
1605
1606 sender.send(Command::NextSlide).unwrap();
1608 engine.tick();
1609 assert!(engine.state().current_page_ink().is_empty());
1610 assert_eq!(engine.state().current_page, 1);
1611
1612 sender.send(Command::PreviousSlide).unwrap();
1614 engine.tick();
1615 assert_eq!(engine.state().current_page_ink().len(), 1);
1616 assert_eq!(engine.state().current_page_ink()[0].points.len(), 1);
1617 }
1618
1619 #[test]
1620 fn clear_ink_only_affects_current_page() {
1621 let (mut engine, _, sender) = make_engine(5);
1622
1623 sender.send(Command::ToggleInk).unwrap();
1625 sender.send(Command::AddInkPoint(0.1, 0.2)).unwrap();
1626 sender.send(Command::FinishInkStroke).unwrap();
1627 engine.tick();
1628
1629 sender.send(Command::NextSlide).unwrap();
1631 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
1632 sender.send(Command::FinishInkStroke).unwrap();
1633 engine.tick();
1634 assert_eq!(engine.state().current_page_ink().len(), 1);
1635
1636 sender.send(Command::ClearInk).unwrap();
1638 engine.tick();
1639 assert!(engine.state().current_page_ink().is_empty());
1640
1641 sender.send(Command::PreviousSlide).unwrap();
1643 engine.tick();
1644 assert_eq!(engine.state().current_page_ink().len(), 1);
1645 }
1646
1647 #[test]
1648 fn whiteboard_strokes_survive_navigation() {
1649 let (mut engine, _, sender) = make_engine(5);
1650
1651 sender.send(Command::ToggleWhiteboard).unwrap();
1653 sender.send(Command::AddInkPoint(0.3, 0.3)).unwrap();
1654 sender.send(Command::FinishInkStroke).unwrap();
1655 engine.tick();
1656 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
1657
1658 sender.send(Command::ToggleWhiteboard).unwrap();
1660 sender.send(Command::NextSlide).unwrap();
1661 sender.send(Command::ToggleWhiteboard).unwrap();
1662 engine.tick();
1663 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
1664 }
1665
1666 #[test]
1667 fn annotations_loaded_from_metadata() {
1668 use dais_sidecar::types::InkStrokeMeta;
1669
1670 let mut meta = PresentationMetadata::default();
1671 meta.slide_annotations.insert(
1672 0,
1673 vec![InkStrokeMeta {
1674 points: vec![(0.1, 0.2), (0.3, 0.4)],
1675 color: [255, 0, 0, 255],
1676 width: 3.0,
1677 }],
1678 );
1679 meta.whiteboard_annotations =
1680 vec![InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 0, 255, 255], width: 2.0 }];
1681
1682 let (engine, _, _) = make_engine_with_metadata(5, &meta);
1683
1684 assert_eq!(engine.state().current_page_ink().len(), 1);
1686 assert_eq!(engine.state().current_page_ink()[0].points.len(), 2);
1687 assert!(engine.state().current_page_ink()[0].finished);
1688
1689 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
1691 assert!(engine.state().whiteboard_strokes[0].finished);
1692 }
1693
1694 #[test]
1695 fn spotlight_toggle_and_position() {
1696 let (mut engine, _, sender) = make_engine(5);
1697 sender.send(Command::ToggleSpotlight).unwrap();
1698 sender.send(Command::SetSpotlightPosition(0.3, 0.7)).unwrap();
1699 engine.tick();
1700 assert!(engine.state().spotlight_active);
1701 assert_eq!(engine.state().spotlight_position, Some((0.3, 0.7)));
1702
1703 sender.send(Command::ToggleSpotlight).unwrap();
1704 engine.tick();
1705 assert!(!engine.state().spotlight_active);
1706 assert_eq!(engine.state().spotlight_position, None);
1707 }
1708
1709 #[test]
1710 fn spotlight_position_ignored_when_inactive() {
1711 let (mut engine, _, sender) = make_engine(5);
1712 sender.send(Command::SetSpotlightPosition(0.5, 0.5)).unwrap();
1713 engine.tick();
1714 assert_eq!(engine.state().spotlight_position, None);
1715 }
1716
1717 #[test]
1718 fn zoom_toggle_and_region() {
1719 let (mut engine, _, sender) = make_engine(5);
1720 sender.send(Command::ToggleZoom).unwrap();
1721 sender.send(Command::SetZoomRegion { center: (0.5, 0.5), factor: 2.0 }).unwrap();
1722 engine.tick();
1723 assert!(engine.state().zoom_active);
1724 let region = engine.state().zoom_region.as_ref().unwrap();
1725 assert_eq!(region.center, (0.5, 0.5));
1726 assert!((region.factor - 2.0).abs() < f32::EPSILON);
1727
1728 sender.send(Command::ToggleZoom).unwrap();
1729 engine.tick();
1730 assert!(!engine.state().zoom_active);
1731 assert!(engine.state().zoom_region.is_none());
1732 }
1733
1734 #[test]
1735 fn position_clamping() {
1736 let (mut engine, _, sender) = make_engine(5);
1737 sender.send(Command::SetPointerPosition(-1.0, 2.0)).unwrap();
1738 engine.tick();
1739 assert_eq!(engine.state().pointer_position, Some((0.0, 1.0)));
1740 }
1741
1742 #[test]
1745 fn timer_start_pause_reset() {
1746 let (mut engine, _, sender) = make_engine(5);
1747 assert!(!engine.state().timer.running);
1748
1749 sender.send(Command::StartTimer).unwrap();
1750 engine.tick();
1751 assert!(engine.state().timer.running);
1752
1753 sender.send(Command::PauseTimer).unwrap();
1754 engine.tick();
1755 assert!(!engine.state().timer.running);
1756
1757 sender.send(Command::ResetTimer).unwrap();
1758 engine.tick();
1759 assert!(!engine.state().timer.running);
1760 assert_eq!(engine.state().timer.elapsed, std::time::Duration::ZERO);
1761 }
1762
1763 #[test]
1764 fn toggle_timer_starts_and_pauses() {
1765 let (mut engine, _, sender) = make_engine(5);
1766 assert!(!engine.state().timer.running);
1767
1768 sender.send(Command::ToggleTimer).unwrap();
1770 engine.tick();
1771 assert!(engine.state().timer.running);
1772
1773 sender.send(Command::ToggleTimer).unwrap();
1775 engine.tick();
1776 assert!(!engine.state().timer.running);
1777
1778 sender.send(Command::ToggleTimer).unwrap();
1780 engine.tick();
1781 assert!(engine.state().timer.running);
1782 }
1783
1784 #[test]
1785 fn toggle_timer_does_not_cancel_itself_in_single_tick() {
1786 let (mut engine, _, sender) = make_engine(5);
1787
1788 sender.send(Command::ToggleTimer).unwrap();
1790 sender.send(Command::ToggleTimer).unwrap();
1791 engine.tick();
1792 assert!(!engine.state().timer.running, "two toggles should cancel out");
1793 }
1794
1795 #[test]
1796 fn slide_timer_accumulates_when_returning_to_slide() {
1797 let (mut engine, _, sender) = make_engine(5);
1798
1799 std::thread::sleep(std::time::Duration::from_millis(15));
1800 engine.tick();
1801 let slide_0_elapsed = engine.state().slide_elapsed;
1802 assert!(slide_0_elapsed > std::time::Duration::ZERO);
1803
1804 sender.send(Command::NextSlide).unwrap();
1805 engine.tick();
1806 assert_eq!(engine.state().current_logical_slide, 1);
1807 assert!(
1808 engine.state().slide_elapsed <= std::time::Duration::from_millis(5),
1809 "new slide should start near zero"
1810 );
1811
1812 sender.send(Command::PreviousSlide).unwrap();
1813 engine.tick();
1814 assert_eq!(engine.state().current_logical_slide, 0);
1815 assert!(
1816 engine.state().slide_elapsed >= slide_0_elapsed,
1817 "returning to a slide should restore accumulated time"
1818 );
1819 }
1820
1821 #[test]
1824 fn toggle_overview() {
1825 let (mut engine, _, sender) = make_engine(5);
1826 assert!(!engine.state().overview_visible);
1827 sender.send(Command::ToggleSlideOverview).unwrap();
1828 engine.tick();
1829 assert!(engine.state().overview_visible);
1830 sender.send(Command::ToggleSlideOverview).unwrap();
1831 engine.tick();
1832 assert!(!engine.state().overview_visible);
1833 }
1834
1835 #[test]
1836 fn notes_font_size_bounds() {
1837 let (mut engine, _, sender) = make_engine(5);
1838 let initial = engine.state().notes_font_size;
1839
1840 sender.send(Command::IncrementNotesFontSize).unwrap();
1841 engine.tick();
1842 assert!(engine.state().notes_font_size > initial);
1843
1844 for _ in 0..100 {
1845 sender.send(Command::DecrementNotesFontSize).unwrap();
1846 }
1847 engine.tick();
1848 assert!((engine.state().notes_font_size - 8.0).abs() < f32::EPSILON);
1849 }
1850
1851 #[test]
1852 fn notes_font_size_upper_bound() {
1853 let (mut engine, _, sender) = make_engine(5);
1854 for _ in 0..100 {
1855 sender.send(Command::IncrementNotesFontSize).unwrap();
1856 }
1857 engine.tick();
1858 assert!((engine.state().notes_font_size - 72.0).abs() < f32::EPSILON);
1859 }
1860
1861 #[test]
1864 fn quit_command_requires_confirmation() {
1865 let (mut engine, _, sender) = make_engine(5);
1866 sender.send(Command::Quit).unwrap();
1868 assert!(!engine.tick());
1869 assert!(engine.state().quit_requested);
1870
1871 sender.send(Command::Quit).unwrap();
1873 assert!(engine.tick());
1874 }
1875
1876 #[test]
1877 fn quit_cancelled_by_other_command() {
1878 let (mut engine, _, sender) = make_engine(5);
1879 sender.send(Command::Quit).unwrap();
1881 engine.tick();
1882 assert!(engine.state().quit_requested);
1883
1884 sender.send(Command::NextSlide).unwrap();
1886 assert!(!engine.tick());
1887 assert!(!engine.state().quit_requested);
1888 }
1889
1890 #[test]
1891 fn no_quit_returns_false() {
1892 let (mut engine, _, sender) = make_engine(5);
1893 sender.send(Command::NextSlide).unwrap();
1894 assert!(!engine.tick());
1895 }
1896
1897 #[test]
1900 fn state_broadcast_to_shared() {
1901 let (mut engine, shared, sender) = make_engine(5);
1902 sender.send(Command::NextSlide).unwrap();
1903 engine.tick();
1904
1905 let shared_state = shared.read().unwrap();
1906 assert_eq!(shared_state.current_logical_slide, 1);
1907 }
1908
1909 #[test]
1912 fn notes_update_on_navigation() {
1913 let mut notes = HashMap::new();
1914 notes.insert(0, "Notes for slide 0".to_string());
1915 notes.insert(1, "Notes for slide 1".to_string());
1916 let meta = PresentationMetadata { notes, ..Default::default() };
1917 let (mut engine, _, sender) = make_engine_with_metadata(3, &meta);
1918
1919 assert_eq!(engine.state().current_notes.as_deref(), Some("Notes for slide 0"));
1920
1921 sender.send(Command::NextSlide).unwrap();
1922 engine.tick();
1923 assert_eq!(engine.state().current_notes.as_deref(), Some("Notes for slide 1"));
1924
1925 sender.send(Command::NextSlide).unwrap();
1926 engine.tick();
1927 assert_eq!(engine.state().current_notes, None);
1928 }
1929
1930 #[test]
1931 fn notes_can_be_edited_inline() {
1932 let (mut engine, _, sender) = make_engine(3);
1933
1934 sender.send(Command::ToggleNotesEdit).unwrap();
1935 sender.send(Command::SetCurrentSlideNotes("Hello **markdown**".to_string())).unwrap();
1936 engine.tick();
1937
1938 assert!(engine.state().notes_editing);
1939 assert_eq!(engine.state().current_notes.as_deref(), Some("Hello **markdown**"));
1940 assert_eq!(engine.state().slide_groups[0].notes.as_deref(), Some("Hello **markdown**"));
1941
1942 sender.send(Command::SetCurrentSlideNotes(String::new())).unwrap();
1943 engine.tick();
1944 assert_eq!(engine.state().current_notes, None);
1945 assert_eq!(engine.state().slide_groups[0].notes, None);
1946 }
1947
1948 #[test]
1949 fn new_text_boxes_use_configured_typst_prelude() {
1950 let mut config = Config::default();
1951 config.text_boxes.typst_prelude = "#set align(horizon)".to_string();
1952 let (mut engine, _, sender) =
1953 make_engine_with_config(1, &PresentationMetadata::default(), &config);
1954
1955 sender.send(Command::PlaceTextBox { x: 0.1, y: 0.1, w: 0.3, h: 0.2 }).unwrap();
1956 engine.tick();
1957
1958 let boxes = engine.state().current_page_text_boxes();
1959 assert_eq!(boxes.len(), 1);
1960 assert_eq!(boxes[0].typst_prelude, "#set align(horizon)");
1961 }
1962
1963 #[test]
1966 fn toggle_whiteboard() {
1967 let (mut engine, _, sender) = make_engine(5);
1968 assert!(!engine.state().whiteboard_active);
1969
1970 sender.send(Command::ToggleWhiteboard).unwrap();
1971 engine.tick();
1972 assert!(engine.state().whiteboard_active);
1973 assert!(engine.state().ink_active); assert!(!engine.state().laser_active);
1975
1976 sender.send(Command::ToggleWhiteboard).unwrap();
1977 engine.tick();
1978 assert!(!engine.state().whiteboard_active);
1979 }
1980
1981 #[test]
1982 fn whiteboard_exits_text_box_context() {
1983 let (mut engine, _, sender) = make_engine(1);
1984
1985 sender.send(Command::ToggleTextBoxMode).unwrap();
1986 sender.send(Command::PlaceTextBox { x: 0.1, y: 0.1, w: 0.2, h: 0.2 }).unwrap();
1987 engine.tick();
1988 assert!(engine.state().text_box_mode);
1989 assert!(engine.state().selected_text_box.is_some());
1990
1991 sender.send(Command::ToggleWhiteboard).unwrap();
1992 engine.tick();
1993
1994 assert!(engine.state().whiteboard_active);
1995 assert!(!engine.state().text_box_mode);
1996 assert!(engine.state().selected_text_box.is_none());
1997 assert!(!engine.state().text_box_editing);
1998 }
1999
2000 #[test]
2001 fn whiteboard_and_blackout_mutually_exclusive() {
2002 let (mut engine, _, sender) = make_engine(5);
2003
2004 sender.send(Command::ToggleWhiteboard).unwrap();
2005 engine.tick();
2006 assert!(engine.state().whiteboard_active);
2007
2008 sender.send(Command::ToggleBlackout).unwrap();
2009 engine.tick();
2010 assert!(engine.state().blacked_out);
2011 assert!(!engine.state().whiteboard_active);
2012
2013 sender.send(Command::ToggleWhiteboard).unwrap();
2014 engine.tick();
2015 assert!(engine.state().whiteboard_active);
2016 assert!(!engine.state().blacked_out);
2017 }
2018
2019 #[test]
2020 fn whiteboard_ink_strokes_separate_from_slide() {
2021 let (mut engine, _, sender) = make_engine(5);
2022
2023 sender.send(Command::ToggleInk).unwrap();
2025 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2026 sender.send(Command::FinishInkStroke).unwrap();
2027 engine.tick();
2028 assert_eq!(engine.state().current_page_ink().len(), 1);
2029 assert!(engine.state().whiteboard_strokes.is_empty());
2030
2031 sender.send(Command::ToggleWhiteboard).unwrap();
2033 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2034 sender.send(Command::FinishInkStroke).unwrap();
2035 engine.tick();
2036 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2037 assert_eq!(engine.state().current_page_ink().len(), 1);
2039 }
2040
2041 #[test]
2042 fn clear_ink_targets_whiteboard_when_active() {
2043 let (mut engine, _, sender) = make_engine(5);
2044
2045 sender.send(Command::ToggleWhiteboard).unwrap();
2047 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2048 sender.send(Command::FinishInkStroke).unwrap();
2049 engine.tick();
2050 assert_eq!(engine.state().whiteboard_strokes.len(), 1);
2051
2052 sender.send(Command::ClearInk).unwrap();
2053 engine.tick();
2054 assert!(engine.state().whiteboard_strokes.is_empty());
2055 }
2056
2057 #[test]
2060 fn active_pen_initializes_from_config() {
2061 let mut config = Config::default();
2062 config.ink.colors = vec!["#FF000080".to_string()]; config.ink.width = 7.5;
2064 let (engine, _, _) = make_engine_with_config(3, &PresentationMetadata::default(), &config);
2065 assert_eq!(engine.state().active_pen.color, [255, 0, 0, 128]);
2066 assert!((engine.state().active_pen.width - 7.5).abs() < f32::EPSILON);
2067 }
2068
2069 #[test]
2070 fn set_ink_color_updates_active_pen_only() {
2071 let (mut engine, _, sender) = make_engine(3);
2072 sender.send(Command::ToggleInk).unwrap();
2073 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2074 sender.send(Command::FinishInkStroke).unwrap();
2075 engine.tick();
2076
2077 sender.send(Command::SetInkColor([0, 0, 255, 200])).unwrap();
2079 engine.tick();
2080
2081 let strokes = engine.state().current_page_ink();
2082 assert_eq!(strokes.len(), 1);
2083 assert_eq!(engine.state().active_pen.color, [0, 0, 255, 200]);
2084 assert_ne!(strokes[0].color, [0, 0, 255, 200]);
2086 }
2087
2088 #[test]
2089 fn set_ink_width_updates_active_pen_only() {
2090 let (mut engine, _, sender) = make_engine(3);
2091 sender.send(Command::ToggleInk).unwrap();
2092 sender.send(Command::AddInkPoint(0.2, 0.2)).unwrap();
2093 sender.send(Command::FinishInkStroke).unwrap();
2094 engine.tick();
2095
2096 let original_width = engine.state().current_page_ink()[0].width;
2097
2098 sender.send(Command::SetInkWidth(12.0)).unwrap();
2099 engine.tick();
2100
2101 assert!((engine.state().active_pen.width - 12.0).abs() < f32::EPSILON);
2102 assert!((engine.state().current_page_ink()[0].width - original_width).abs() < f32::EPSILON);
2104 }
2105
2106 #[test]
2107 fn pen_change_does_not_mutate_prior_strokes() {
2108 let (mut engine, _, sender) = make_engine(3);
2109 sender.send(Command::ToggleInk).unwrap();
2110
2111 sender.send(Command::SetInkColor([255, 0, 0, 255])).unwrap();
2113 sender.send(Command::SetInkWidth(3.0)).unwrap();
2114 sender.send(Command::AddInkPoint(0.1, 0.1)).unwrap();
2115 sender.send(Command::FinishInkStroke).unwrap();
2116 engine.tick();
2117
2118 sender.send(Command::SetInkColor([0, 0, 255, 128])).unwrap();
2120 sender.send(Command::SetInkWidth(10.0)).unwrap();
2121 sender.send(Command::AddInkPoint(0.5, 0.5)).unwrap();
2122 sender.send(Command::FinishInkStroke).unwrap();
2123 engine.tick();
2124
2125 let strokes = engine.state().current_page_ink();
2126 assert_eq!(strokes.len(), 2);
2127 assert_eq!(strokes[0].color, [255, 0, 0, 255]);
2129 assert!((strokes[0].width - 3.0).abs() < f32::EPSILON);
2130 assert_eq!(strokes[1].color, [0, 0, 255, 128]);
2132 assert!((strokes[1].width - 10.0).abs() < f32::EPSILON);
2133 }
2134
2135 #[test]
2136 fn new_stroke_snapshots_active_pen_at_creation() {
2137 let mut config = Config::default();
2138 config.ink.colors = vec!["#00FF0080".to_string()]; config.ink.width = 5.0;
2140 let (mut engine, _, sender) =
2141 make_engine_with_config(3, &PresentationMetadata::default(), &config);
2142
2143 sender.send(Command::ToggleInk).unwrap();
2144 sender.send(Command::AddInkPoint(0.3, 0.3)).unwrap();
2145 sender.send(Command::FinishInkStroke).unwrap();
2146 engine.tick();
2147
2148 let strokes = engine.state().current_page_ink();
2149 assert_eq!(strokes[0].color, [0, 255, 0, 128]);
2150 assert!((strokes[0].width - 5.0).abs() < f32::EPSILON);
2151 }
2152}