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