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