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