Skip to main content

dais_engine/
engine.rs

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