dais_core/state.rs
1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::slide_group::SlideGroup;
5
6/// A positioned text overlay on a slide.
7#[derive(Debug, Clone)]
8pub struct TextBox {
9 /// Unique identifier for this text box.
10 pub id: u64,
11 /// Normalized position and size: (x, y, w, h) in 0..1 coordinates.
12 pub rect: (f32, f32, f32, f32),
13 /// Typst markup content.
14 pub content: String,
15 /// Font size in points.
16 pub font_size: f32,
17 /// Text color as RGBA.
18 pub color: [u8; 4],
19 /// Optional background fill as RGBA.
20 pub background: Option<[u8; 4]>,
21 /// Additional Typst setup inserted after Dais defaults and before content.
22 pub typst_prelude: String,
23}
24
25/// The single authoritative state of the presentation.
26///
27/// The engine owns and mutates this. The UI reads it (via watch channel) and renders it.
28/// The UI holds no authoritative state of its own — all mutations go through
29/// the [`CommandBus`](crate::bus::CommandBus).
30#[derive(Debug, Clone)]
31pub struct PresentationState {
32 // -- Document info --
33 /// Total number of raw PDF pages.
34 pub total_pages: usize,
35 /// Logical slide groups (may be 1:1 with pages if no grouping).
36 pub slide_groups: Vec<SlideGroup>,
37 /// Total number of logical slides.
38 pub total_logical_slides: usize,
39
40 // -- Navigation --
41 /// Current raw PDF page index (0-based).
42 pub current_page: usize,
43 /// Current logical slide index (0-based).
44 pub current_logical_slide: usize,
45 /// Current overlay step within the current group (0-based).
46 pub current_overlay_within_group: usize,
47
48 // -- Display modes --
49 /// Whether the audience display is frozen.
50 pub frozen: bool,
51 /// The page shown on the audience display when frozen (None = not frozen).
52 pub frozen_page: Option<usize>,
53 /// Whether the audience display is blacked out.
54 pub blacked_out: bool,
55 /// Whether the whiteboard is active (blank white canvas on audience).
56 pub whiteboard_active: bool,
57 /// Ink strokes drawn on the whiteboard (persist across navigation).
58 pub whiteboard_strokes: Vec<InkStroke>,
59 /// Whether screen-share mode is active.
60 pub screen_share_mode: bool,
61 /// Whether presentation mode is active (single-monitor fullscreen HUD).
62 pub presentation_mode: bool,
63
64 // -- Presentation aids --
65 /// Whether the laser pointer is active.
66 pub laser_active: bool,
67 /// Current pointer position (normalized 0..1), None if pointer is off-slide.
68 pub pointer_position: Option<(f32, f32)>,
69 /// Pointer visual style.
70 pub pointer_style: PointerStyle,
71 /// Pointer appearance settings for each visual style.
72 pub pointer_appearances: PointerAppearances,
73 /// Whether ink drawing mode is active.
74 pub ink_active: bool,
75 /// Active pen settings — used to initialise the next stroke.
76 pub active_pen: ActivePen,
77 /// Per-page slide ink annotations (`page_index` → strokes).
78 pub slide_ink_by_page: HashMap<usize, Vec<InkStroke>>,
79 /// Per-page text box overlays (`page_index` → boxes).
80 pub slide_text_boxes_by_page: HashMap<usize, Vec<TextBox>>,
81 /// Whether text box placement mode is active.
82 pub text_box_mode: bool,
83 /// The currently selected text box id, if any.
84 pub selected_text_box: Option<u64>,
85 /// Whether the selected text box is in inline edit mode.
86 pub text_box_editing: bool,
87 /// Counter for assigning unique text box IDs.
88 pub next_text_box_id: u64,
89 /// Whether the spotlight overlay is active.
90 pub spotlight_active: bool,
91 /// Spotlight center position (normalized 0..1).
92 pub spotlight_position: Option<(f32, f32)>,
93 /// Spotlight radius in logical pixels.
94 pub spotlight_radius: f32,
95 /// Spotlight dim opacity from 0.0 to 1.0.
96 pub spotlight_dim_opacity: f32,
97 /// Whether zoom is active on the audience display.
98 pub zoom_active: bool,
99 /// Current zoom region, if zoom is active.
100 pub zoom_region: Option<ZoomRegion>,
101
102 // -- Timer --
103 /// Timer state.
104 pub timer: TimerState,
105 /// Total time spent on the current logical slide during this session.
106 pub slide_elapsed: Duration,
107 /// Accumulated time spent on each logical slide during this session.
108 pub slide_elapsed_by_logical: Vec<Duration>,
109
110 // -- UI --
111 /// Whether the slide overview grid is visible.
112 pub overview_visible: bool,
113 /// Whether the notes panel is visible.
114 pub notes_visible: bool,
115 /// Whether the notes panel is in markdown edit mode.
116 pub notes_editing: bool,
117 /// Current notes font size in points.
118 pub notes_font_size: f32,
119 /// Step size for font increment/decrement.
120 pub notes_font_size_step: f32,
121 /// Whether a quit confirmation dialog is showing.
122 pub quit_requested: bool,
123
124 // -- Content --
125 /// Markdown notes for the current logical slide, if any.
126 pub current_notes: Option<String>,
127}
128
129impl PresentationState {
130 /// Create a new state for a presentation with the given slide groups.
131 pub fn new(total_pages: usize, slide_groups: Vec<SlideGroup>) -> Self {
132 let total_logical_slides = slide_groups.len();
133 let current_notes = slide_groups.first().and_then(|g| g.notes.clone());
134 Self {
135 total_pages,
136 slide_groups,
137 total_logical_slides,
138 current_page: 0,
139 current_logical_slide: 0,
140 current_overlay_within_group: 0,
141 frozen: false,
142 frozen_page: None,
143 blacked_out: false,
144 whiteboard_active: false,
145 whiteboard_strokes: Vec::new(),
146 screen_share_mode: false,
147 presentation_mode: false,
148 laser_active: true,
149 pointer_position: None,
150 pointer_style: PointerStyle::Dot,
151 pointer_appearances: PointerAppearances::default(),
152 ink_active: false,
153 active_pen: ActivePen::default(),
154 slide_ink_by_page: HashMap::new(),
155 slide_text_boxes_by_page: HashMap::new(),
156 text_box_mode: false,
157 selected_text_box: None,
158 text_box_editing: false,
159 next_text_box_id: 1,
160 spotlight_active: false,
161 spotlight_position: None,
162 spotlight_radius: 80.0,
163 spotlight_dim_opacity: 0.6,
164 zoom_active: false,
165 zoom_region: None,
166 timer: TimerState::default(),
167 slide_elapsed: Duration::ZERO,
168 slide_elapsed_by_logical: vec![Duration::ZERO; total_logical_slides],
169 overview_visible: false,
170 notes_visible: true,
171 notes_editing: false,
172 notes_font_size: 16.0,
173 notes_font_size_step: 2.0,
174 quit_requested: false,
175 current_notes,
176 }
177 }
178
179 /// The page the audience should see (respects freeze).
180 pub fn audience_page(&self) -> usize {
181 self.frozen_page.unwrap_or(self.current_page)
182 }
183
184 /// Ink strokes for the current presenter page (read-only convenience for UI).
185 pub fn current_page_ink(&self) -> &[InkStroke] {
186 self.slide_ink_by_page.get(&self.current_page).map_or(&[], |v| v.as_slice())
187 }
188
189 /// Text boxes for the current presenter page (read-only convenience for UI).
190 pub fn current_page_text_boxes(&self) -> &[TextBox] {
191 self.slide_text_boxes_by_page.get(&self.current_page).map_or(&[], |v| v.as_slice())
192 }
193
194 /// Appearance for the currently selected pointer style.
195 pub fn current_pointer_appearance(&self) -> PointerAppearance {
196 self.pointer_appearances.for_style(self.pointer_style)
197 }
198}
199
200/// Visual style for the audience laser pointer.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum PointerStyle {
203 /// Circular laser dot with glow.
204 #[default]
205 Dot,
206 /// Minimal dot without glow.
207 Minimal,
208 /// Crosshair with a small center dot.
209 Crosshair,
210 /// Arrow-style marker.
211 Arrow,
212 /// Hollow circle pointer.
213 Ring,
214 /// Ring with a center dot.
215 Bullseye,
216 /// Translucent filled highlight circle.
217 Highlight,
218}
219
220/// Color and size for one pointer visual style.
221#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct PointerAppearance {
223 /// Pointer color as RGBA.
224 pub color: [u8; 4],
225 /// Pointer size in logical pixels.
226 pub size: f32,
227}
228
229impl Default for PointerAppearance {
230 fn default() -> Self {
231 Self { color: [255, 0, 0, 255], size: 12.0 }
232 }
233}
234
235/// Per-style pointer appearance settings.
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct PointerAppearances {
238 /// Appearance for the dot pointer.
239 pub dot: PointerAppearance,
240 /// Appearance for the minimal dot pointer.
241 pub minimal: PointerAppearance,
242 /// Appearance for the crosshair pointer.
243 pub crosshair: PointerAppearance,
244 /// Appearance for the arrow pointer.
245 pub arrow: PointerAppearance,
246 /// Appearance for the ring pointer.
247 pub ring: PointerAppearance,
248 /// Appearance for the bullseye pointer.
249 pub bullseye: PointerAppearance,
250 /// Appearance for the highlight pointer.
251 pub highlight: PointerAppearance,
252}
253
254impl PointerAppearances {
255 /// Return the appearance for a pointer style.
256 pub fn for_style(&self, style: PointerStyle) -> PointerAppearance {
257 match style {
258 PointerStyle::Dot => self.dot,
259 PointerStyle::Minimal => self.minimal,
260 PointerStyle::Crosshair => self.crosshair,
261 PointerStyle::Arrow => self.arrow,
262 PointerStyle::Ring => self.ring,
263 PointerStyle::Bullseye => self.bullseye,
264 PointerStyle::Highlight => self.highlight,
265 }
266 }
267}
268
269impl Default for PointerAppearances {
270 fn default() -> Self {
271 let default = PointerAppearance::default();
272 Self {
273 dot: default,
274 minimal: default,
275 crosshair: default,
276 arrow: default,
277 ring: default,
278 bullseye: default,
279 highlight: default,
280 }
281 }
282}
283
284/// A single ink stroke drawn on a slide.
285#[derive(Debug, Clone)]
286pub struct InkStroke {
287 /// Points along the stroke (normalized 0..1 coordinates).
288 pub points: Vec<(f32, f32)>,
289 /// Stroke color as RGBA — snapshotted from `ActivePen` at stroke creation.
290 pub color: [u8; 4],
291 /// Stroke width in logical pixels — snapshotted from `ActivePen` at stroke creation.
292 pub width: f32,
293 /// Whether this stroke is complete (pen lifted).
294 pub finished: bool,
295}
296
297/// The currently selected pen settings used to initialise the next stroke.
298///
299/// These are runtime-only; they are not persisted. Changing them never mutates
300/// already-existing strokes — each stroke snapshots `ActivePen` at creation time.
301#[derive(Debug, Clone, Copy)]
302pub struct ActivePen {
303 /// Pen color as RGBA (alpha included for highlighter / semitransparent pens).
304 pub color: [u8; 4],
305 /// Stroke width in logical pixels.
306 pub width: f32,
307}
308
309impl Default for ActivePen {
310 fn default() -> Self {
311 Self { color: [255, 0, 0, 255], width: 3.0 }
312 }
313}
314
315/// Defines a zoom region on the slide.
316#[derive(Debug, Clone, Copy)]
317pub struct ZoomRegion {
318 /// Center of the zoom region (normalized 0..1).
319 pub center: (f32, f32),
320 /// Magnification factor (e.g., 2.0 = 2x zoom).
321 pub factor: f32,
322}
323
324/// Timer state for the presentation.
325#[derive(Debug, Clone)]
326pub struct TimerState {
327 /// Timer mode.
328 pub mode: TimerMode,
329 /// Configured total duration, if any.
330 pub duration: Option<Duration>,
331 /// Time elapsed since the timer started.
332 pub elapsed: Duration,
333 /// Whether the timer is currently running.
334 pub running: bool,
335 /// Threshold for the warning phase.
336 pub warning_threshold: Option<Duration>,
337}
338
339/// Timer counting mode.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
341#[serde(rename_all = "lowercase")]
342pub enum TimerMode {
343 /// Count up from zero.
344 Elapsed,
345 /// Count down from the configured duration.
346 Countdown,
347}
348
349/// Visual phase of the timer, derived from state each frame.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum TimerPhase {
352 /// Normal — plenty of time remaining.
353 Normal,
354 /// Warning — less than the warning threshold remaining.
355 Warning,
356 /// Overrun — past the configured duration.
357 Overrun,
358}
359
360impl TimerState {
361 /// Compute the current timer phase.
362 pub fn phase(&self) -> TimerPhase {
363 let Some(duration) = self.duration else {
364 return TimerPhase::Normal;
365 };
366
367 if self.elapsed >= duration {
368 TimerPhase::Overrun
369 } else if self.warning_threshold.is_some_and(|warning| self.elapsed + warning >= duration) {
370 TimerPhase::Warning
371 } else {
372 TimerPhase::Normal
373 }
374 }
375
376 /// The display time for the timer.
377 /// For countdown mode, returns time remaining (clamped to 0).
378 /// For elapsed mode, returns time elapsed.
379 pub fn display_time(&self) -> Duration {
380 match self.mode {
381 TimerMode::Countdown => {
382 self.duration.map_or(self.elapsed, |duration| duration.saturating_sub(self.elapsed))
383 }
384 TimerMode::Elapsed => self.elapsed,
385 }
386 }
387}
388
389impl Default for TimerState {
390 fn default() -> Self {
391 Self {
392 mode: TimerMode::Elapsed,
393 duration: None,
394 elapsed: Duration::ZERO,
395 running: false,
396 warning_threshold: None,
397 }
398 }
399}