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