Skip to main content

dais_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::state::TimerMode;
7
8/// Top-level application configuration, loaded from TOML.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct Config {
12    /// Window layout and monitor selection.
13    pub display: DisplayConfig,
14    /// Main presentation timer behavior.
15    pub timer: TimerConfig,
16    /// Laser pointer defaults.
17    pub laser: LaserConfig,
18    /// Spotlight overlay defaults.
19    pub spotlight: SpotlightConfig,
20    /// Freehand ink defaults.
21    pub ink: InkConfig,
22    /// Text box defaults.
23    pub text_boxes: TextBoxConfig,
24    /// Notes panel defaults.
25    pub notes: NotesConfig,
26    /// User keybindings, keyed by [`Action`](crate::keybindings::Action) config names.
27    pub keybindings: HashMap<String, Vec<String>>,
28    /// Clicker/remote profile configuration.
29    pub clicker: ClickerConfig,
30    /// Local HTTP remote-control API configuration.
31    pub remote: RemoteConfig,
32    /// Annotated export defaults.
33    pub export: ExportConfig,
34    /// Sidecar save format: `"dais"` or `"pdfpc"`.
35    pub sidecar_format: String,
36    /// Whether to persist per-slide timing data when saving sidecars.
37    pub save_slide_timings: bool,
38}
39
40#[derive(Debug, Clone, Default, Deserialize)]
41#[serde(default)]
42struct PartialConfig {
43    display: Option<PartialDisplayConfig>,
44    timer: Option<PartialTimerConfig>,
45    laser: Option<PartialLaserConfig>,
46    spotlight: Option<PartialSpotlightConfig>,
47    ink: Option<PartialInkConfig>,
48    text_boxes: Option<PartialTextBoxConfig>,
49    notes: Option<PartialNotesConfig>,
50    keybindings: Option<HashMap<String, Vec<String>>>,
51    clicker: Option<PartialClickerConfig>,
52    remote: Option<PartialRemoteConfig>,
53    export: Option<PartialExportConfig>,
54    sidecar_format: Option<String>,
55    save_slide_timings: Option<bool>,
56}
57
58/// Display mode and monitor assignment.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(default)]
61pub struct DisplayConfig {
62    /// Display mode: "dual", "single", or "screen-share".
63    pub mode: String,
64    /// Single-monitor presentation surface: "hud" or "split".
65    pub single_monitor_view: String,
66    /// Audience monitor identifier or "auto".
67    pub audience_monitor: String,
68    /// Presenter monitor identifier or "auto".
69    pub presenter_monitor: String,
70}
71
72#[derive(Debug, Clone, Default, Deserialize)]
73#[serde(default)]
74struct PartialDisplayConfig {
75    mode: Option<String>,
76    single_monitor_view: Option<String>,
77    audience_monitor: Option<String>,
78    presenter_monitor: Option<String>,
79}
80
81/// Timer configuration.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct TimerConfig {
85    /// "countdown" or "elapsed".
86    pub mode: TimerMode,
87    /// Timer duration in minutes. If omitted in elapsed mode, no limit is shown.
88    pub duration_minutes: Option<u32>,
89    /// Minutes remaining when warning color activates.
90    pub warning_minutes: Option<u32>,
91    /// Whether to show red when past duration.
92    pub overrun_color: bool,
93}
94
95#[derive(Debug, Clone, Default, Deserialize)]
96#[serde(default)]
97struct PartialTimerConfig {
98    mode: Option<TimerMode>,
99    duration_minutes: Option<OptionalU32Value>,
100    warning_minutes: Option<OptionalU32Value>,
101    overrun_color: Option<bool>,
102}
103
104#[derive(Debug, Clone, Copy, Deserialize)]
105#[serde(untagged)]
106enum OptionalU32Value {
107    Value(u32),
108    Null(()),
109}
110
111impl OptionalU32Value {
112    fn into_option(self) -> Option<u32> {
113        match self {
114            Self::Value(value) => Some(value),
115            Self::Null(()) => None,
116        }
117    }
118}
119
120/// Laser pointer configuration.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(default)]
123pub struct LaserConfig {
124    /// Default hex color string (e.g., "#FF0000") applied to all pointer styles unless overridden.
125    pub color: String,
126    /// Default size in logical pixels at 1x scale applied to all pointer styles unless overridden.
127    pub size: f32,
128    /// Style: "dot", "minimal", "crosshair", "arrow", "ring", "bullseye", or "highlight".
129    pub style: String,
130    /// Dot pointer appearance.
131    pub dot: PointerStyleConfig,
132    /// Minimal dot pointer appearance.
133    pub minimal: PointerStyleConfig,
134    /// Crosshair pointer appearance.
135    pub crosshair: PointerStyleConfig,
136    /// Arrow pointer appearance.
137    pub arrow: PointerStyleConfig,
138    /// Ring pointer appearance.
139    pub ring: PointerStyleConfig,
140    /// Bullseye pointer appearance.
141    pub bullseye: PointerStyleConfig,
142    /// Highlight pointer appearance.
143    pub highlight: PointerStyleConfig,
144}
145
146/// Appearance configuration for one laser pointer style.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(default)]
149pub struct PointerStyleConfig {
150    /// Hex color string (e.g., "#FF0000" or "#FF000080").
151    pub color: String,
152    /// Size in logical pixels at 1x scale.
153    pub size: f32,
154}
155
156#[derive(Debug, Clone, Default, Deserialize)]
157#[serde(default)]
158struct PartialLaserConfig {
159    color: Option<String>,
160    size: Option<f32>,
161    style: Option<String>,
162    dot: Option<PartialPointerStyleConfig>,
163    minimal: Option<PartialPointerStyleConfig>,
164    crosshair: Option<PartialPointerStyleConfig>,
165    arrow: Option<PartialPointerStyleConfig>,
166    ring: Option<PartialPointerStyleConfig>,
167    bullseye: Option<PartialPointerStyleConfig>,
168    highlight: Option<PartialPointerStyleConfig>,
169}
170
171#[derive(Debug, Clone, Default, Deserialize)]
172#[serde(default)]
173struct PartialPointerStyleConfig {
174    color: Option<String>,
175    size: Option<f32>,
176}
177
178/// Spotlight configuration.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(default)]
181pub struct SpotlightConfig {
182    /// Radius in logical pixels at 1x scale.
183    pub radius: f32,
184    /// Opacity of the dimmed area (0.0–1.0).
185    pub dim_opacity: f32,
186}
187
188#[derive(Debug, Clone, Default, Deserialize)]
189#[serde(default)]
190struct PartialSpotlightConfig {
191    radius: Option<f32>,
192    dim_opacity: Option<f32>,
193}
194
195/// Ink drawing configuration.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(default)]
198pub struct InkConfig {
199    /// Pen color presets as hex strings (RGB or RGBA). `CycleInkColor` steps through these.
200    /// Accepts a single string (`color = "#FF0000"`) or an array (`colors = ["#FF0000", "#0000FF"]`).
201    pub colors: Vec<String>,
202    /// Default pen stroke width in logical pixels.
203    pub width: f32,
204    /// Highlighter color presets as RGBA hex strings (alpha controls opacity).
205    /// Defaults to semi-transparent yellow, green, cyan, and pink.
206    pub highlighter_colors: Vec<String>,
207    /// Default highlighter stroke width in logical pixels.
208    pub highlighter_width: f32,
209}
210
211#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(default)]
213struct PartialInkConfig {
214    /// New array form: `colors = ["#FF0000", "#0000FF"]`.
215    colors: Option<Vec<String>>,
216    /// Legacy single-color form: `color = "#FF0000"`. Ignored if `colors` is also set.
217    color: Option<String>,
218    width: Option<f32>,
219    highlighter_colors: Option<Vec<String>>,
220    highlighter_width: Option<f32>,
221}
222
223/// Default style for newly created text boxes.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(default)]
226pub struct TextBoxConfig {
227    /// Text color as a hex string (RGB or RGBA).
228    pub color: String,
229    /// Background fill as a hex string (RGB or RGBA), or `"transparent"`.
230    pub background: String,
231    /// Typst setup inserted after Dais defaults and before newly created text box content.
232    pub typst_prelude: String,
233}
234
235#[derive(Debug, Clone, Default, Deserialize)]
236#[serde(default)]
237struct PartialTextBoxConfig {
238    color: Option<String>,
239    background: Option<String>,
240    typst_prelude: Option<String>,
241}
242
243/// Clicker/remote hardware configuration.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default)]
246pub struct ClickerConfig {
247    /// Name of the active clicker profile (e.g., "default", "logitech-spotlight").
248    pub profile: String,
249    /// Custom profile definitions mapping key names to action names.
250    pub profiles: HashMap<String, HashMap<String, String>>,
251}
252
253#[derive(Debug, Clone, Default, Deserialize)]
254#[serde(default)]
255struct PartialClickerConfig {
256    profile: Option<String>,
257    profiles: Option<HashMap<String, HashMap<String, String>>>,
258}
259
260/// Local HTTP remote-control API configuration.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(default)]
263pub struct RemoteConfig {
264    /// Whether to start the remote API server with a presentation.
265    pub enabled: bool,
266    /// Bind host for the remote API. Defaults to loopback.
267    pub host: String,
268    /// Bind port for the remote API. `0` asks the OS to choose a free port.
269    pub port: u16,
270    /// Bearer token for remote API requests. Empty means generate one per launch.
271    pub token: String,
272    /// Allow unauthenticated requests from loopback clients when bound to loopback.
273    pub allow_unauthenticated_loopback: bool,
274}
275
276#[derive(Debug, Clone, Default, Deserialize)]
277#[serde(default)]
278struct PartialRemoteConfig {
279    enabled: Option<bool>,
280    host: Option<String>,
281    port: Option<u16>,
282    token: Option<String>,
283    allow_unauthenticated_loopback: Option<bool>,
284}
285
286/// Notes panel configuration.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288#[serde(default)]
289pub struct NotesConfig {
290    /// Font size in points.
291    pub font_size: f32,
292    /// Step size for font size increment/decrement.
293    pub font_size_step: f32,
294}
295
296#[derive(Debug, Clone, Default, Deserialize)]
297#[serde(default)]
298struct PartialNotesConfig {
299    font_size: Option<f32>,
300    font_size_step: Option<f32>,
301}
302
303/// Annotated export defaults.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(default)]
306pub struct ExportConfig {
307    /// Output format: "pdf", "svg", or "png".
308    pub format: String,
309    /// Layers to include: "all", "background", "ink", "text", or "overlays".
310    pub layers: String,
311    /// Export one page per logical slide using the final build page of each group.
312    pub handout: bool,
313    /// Whiteboard export behavior: "none", "append", or "only".
314    pub whiteboard: String,
315}
316
317#[derive(Debug, Clone, Default, Deserialize)]
318#[serde(default)]
319struct PartialExportConfig {
320    format: Option<String>,
321    layers: Option<String>,
322    handout: Option<bool>,
323    whiteboard: Option<String>,
324}
325
326impl Default for Config {
327    fn default() -> Self {
328        Self {
329            display: DisplayConfig::default(),
330            timer: TimerConfig::default(),
331            laser: LaserConfig::default(),
332            spotlight: SpotlightConfig::default(),
333            ink: InkConfig::default(),
334            text_boxes: TextBoxConfig::default(),
335            notes: NotesConfig::default(),
336            keybindings: HashMap::new(),
337            clicker: ClickerConfig::default(),
338            remote: RemoteConfig::default(),
339            export: ExportConfig::default(),
340            sidecar_format: "dais".to_string(),
341            save_slide_timings: true,
342        }
343    }
344}
345
346impl Default for DisplayConfig {
347    fn default() -> Self {
348        Self {
349            mode: "dual".to_string(),
350            single_monitor_view: "hud".to_string(),
351            audience_monitor: "auto".to_string(),
352            presenter_monitor: "auto".to_string(),
353        }
354    }
355}
356
357impl Default for TimerConfig {
358    fn default() -> Self {
359        Self {
360            mode: TimerMode::Elapsed,
361            duration_minutes: None,
362            warning_minutes: None,
363            overrun_color: true,
364        }
365    }
366}
367
368impl Default for LaserConfig {
369    fn default() -> Self {
370        let pointer = PointerStyleConfig::default();
371        Self {
372            color: pointer.color.clone(),
373            size: pointer.size,
374            style: "dot".to_string(),
375            dot: pointer.clone(),
376            minimal: pointer.clone(),
377            crosshair: pointer.clone(),
378            arrow: pointer.clone(),
379            ring: pointer.clone(),
380            bullseye: pointer.clone(),
381            highlight: pointer,
382        }
383    }
384}
385
386impl Default for PointerStyleConfig {
387    fn default() -> Self {
388        Self { color: "#FF0000".to_string(), size: 12.0 }
389    }
390}
391
392impl Default for SpotlightConfig {
393    fn default() -> Self {
394        Self { radius: 80.0, dim_opacity: 0.6 }
395    }
396}
397
398impl Default for InkConfig {
399    fn default() -> Self {
400        Self {
401            colors: vec!["#FF0000".to_string()],
402            width: 3.0,
403            highlighter_colors: Vec::new(),
404            highlighter_width: 10.0,
405        }
406    }
407}
408
409impl Default for TextBoxConfig {
410    fn default() -> Self {
411        Self {
412            color: "#000000".to_string(),
413            background: "transparent".to_string(),
414            typst_prelude: String::new(),
415        }
416    }
417}
418
419impl Default for ClickerConfig {
420    fn default() -> Self {
421        Self { profile: "default".to_string(), profiles: HashMap::new() }
422    }
423}
424
425impl Default for RemoteConfig {
426    fn default() -> Self {
427        Self {
428            enabled: false,
429            host: "127.0.0.1".to_string(),
430            port: 4317,
431            token: String::new(),
432            allow_unauthenticated_loopback: true,
433        }
434    }
435}
436
437/// Return the built-in default clicker profile mapping common USB presenter keys to actions.
438pub fn default_clicker_profile() -> HashMap<String, String> {
439    HashMap::from([
440        ("PageDown".to_string(), "next_slide".to_string()),
441        ("PageUp".to_string(), "previous_slide".to_string()),
442        ("F5".to_string(), "toggle_presentation_mode".to_string()),
443        ("b".to_string(), "toggle_blackout".to_string()),
444        (".".to_string(), "toggle_blackout".to_string()),
445    ])
446}
447
448impl Config {
449    /// Resolve the active clicker profile into a key -> action map.
450    pub fn active_clicker_profile(&self) -> HashMap<String, String> {
451        if self.clicker.profile == "default" {
452            return default_clicker_profile();
453        }
454
455        self.clicker.profiles.get(&self.clicker.profile).cloned().unwrap_or_else(|| {
456            tracing::warn!(
457                "Configured clicker profile '{}' not found; using default profile",
458                self.clicker.profile
459            );
460            default_clicker_profile()
461        })
462    }
463
464    /// Normalize the configured sidecar save format to a supported value.
465    pub fn normalized_sidecar_format(&self) -> &str {
466        if self.sidecar_format.eq_ignore_ascii_case("dais") { "dais" } else { "pdfpc" }
467    }
468}
469
470impl Default for NotesConfig {
471    fn default() -> Self {
472        Self { font_size: 16.0, font_size_step: 2.0 }
473    }
474}
475
476/// Resolve the platform-appropriate config file path.
477pub fn config_path() -> Option<PathBuf> {
478    directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
479}
480
481/// Resolve a project-local config path for a PDF.
482pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
483    pdf_path.parent().map(|dir| dir.join("dais.toml"))
484}
485
486impl Default for ExportConfig {
487    fn default() -> Self {
488        Self {
489            format: "pdf".to_string(),
490            layers: "all".to_string(),
491            handout: false,
492            whiteboard: "none".to_string(),
493        }
494    }
495}
496
497/// Options controlling how layered configuration is loaded.
498#[derive(Debug, Clone, Copy, Default)]
499pub struct ConfigLoadOptions {
500    /// Skip the platform user config directory for USB-portable runs.
501    pub portable: bool,
502}
503
504/// Load layered config for a document.
505///
506/// Precedence:
507/// 1. Built-in defaults
508/// 2. Machine-wide config (`config.toml` in the standard OS config dir)
509/// 3. Project-local config (`dais.toml` next to the PDF)
510/// 4. Explicit `--config` path, if provided
511///
512/// Missing or invalid config files are logged and ignored; this function always
513/// returns a usable configuration by falling back to defaults.
514pub fn load_config_for(pdf_path: &Path, explicit_config: Option<&Path>) -> Config {
515    load_config_for_with_options(pdf_path, explicit_config, ConfigLoadOptions::default())
516}
517
518/// Load layered config for a document with additional mode options.
519///
520/// In portable mode, Dais skips the machine-wide config layer so a copied binary
521/// and project folder behave consistently on machines that may already have
522/// user-level Dais settings.
523pub fn load_config_for_with_options(
524    pdf_path: &Path,
525    explicit_config: Option<&Path>,
526    options: ConfigLoadOptions,
527) -> Config {
528    load_config_from_paths(pdf_path, config_path().as_deref(), explicit_config, options)
529}
530
531fn load_config_from_paths(
532    pdf_path: &Path,
533    machine_config: Option<&Path>,
534    explicit_config: Option<&Path>,
535    options: ConfigLoadOptions,
536) -> Config {
537    let mut config = Config::default();
538
539    if options.portable {
540        tracing::debug!("Portable mode enabled; skipping machine-wide config");
541    } else if let Some(path) = machine_config {
542        merge_config_file(&mut config, path);
543    } else {
544        tracing::warn!("Could not determine config directory, using defaults");
545    }
546
547    if let Some(path) = project_config_path(pdf_path) {
548        merge_config_file(&mut config, &path);
549    }
550
551    if let Some(path) = explicit_config {
552        merge_config_file(&mut config, path);
553    }
554
555    config
556}
557
558fn merge_config_file(config: &mut Config, path: &Path) {
559    let Ok(contents) = std::fs::read_to_string(path) else {
560        tracing::debug!("No config file at {}", path.display());
561        return;
562    };
563
564    match toml::from_str::<PartialConfig>(&contents) {
565        Ok(partial) => {
566            tracing::info!("Loaded config layer from {}", path.display());
567            apply_partial_config(config, partial);
568        }
569        Err(e) => {
570            tracing::warn!("Failed to parse config at {}: {e}", path.display());
571        }
572    }
573}
574
575fn apply_partial_config(config: &mut Config, partial: PartialConfig) {
576    if let Some(display) = partial.display {
577        if let Some(mode) = display.mode {
578            config.display.mode = mode;
579        }
580        if let Some(v) = display.single_monitor_view {
581            config.display.single_monitor_view = v;
582        }
583        if let Some(audience_monitor) = display.audience_monitor {
584            config.display.audience_monitor = audience_monitor;
585        }
586        if let Some(presenter_monitor) = display.presenter_monitor {
587            config.display.presenter_monitor = presenter_monitor;
588        }
589    }
590
591    if let Some(timer) = partial.timer {
592        if let Some(mode) = timer.mode {
593            config.timer.mode = mode;
594        }
595        if let Some(duration_minutes) = timer.duration_minutes {
596            config.timer.duration_minutes = duration_minutes.into_option();
597        }
598        if let Some(warning_minutes) = timer.warning_minutes {
599            config.timer.warning_minutes = warning_minutes.into_option();
600        }
601        if let Some(overrun_color) = timer.overrun_color {
602            config.timer.overrun_color = overrun_color;
603        }
604    }
605
606    if let Some(laser) = partial.laser {
607        apply_laser_config(&mut config.laser, laser);
608    }
609
610    if let Some(spotlight) = partial.spotlight {
611        if let Some(radius) = spotlight.radius {
612            config.spotlight.radius = radius;
613        }
614        if let Some(dim_opacity) = spotlight.dim_opacity {
615            config.spotlight.dim_opacity = dim_opacity;
616        }
617    }
618
619    if let Some(ink) = partial.ink {
620        if let Some(colors) = ink.colors {
621            config.ink.colors = colors;
622        } else if let Some(color) = ink.color {
623            config.ink.colors = vec![color];
624        }
625        if let Some(width) = ink.width {
626            config.ink.width = width;
627        }
628        if let Some(highlighter_colors) = ink.highlighter_colors {
629            config.ink.highlighter_colors = highlighter_colors;
630        }
631        if let Some(highlighter_width) = ink.highlighter_width {
632            config.ink.highlighter_width = highlighter_width;
633        }
634    }
635
636    if let Some(text_boxes) = partial.text_boxes {
637        if let Some(color) = text_boxes.color {
638            config.text_boxes.color = color;
639        }
640        if let Some(background) = text_boxes.background {
641            config.text_boxes.background = background;
642        }
643        if let Some(typst_prelude) = text_boxes.typst_prelude {
644            config.text_boxes.typst_prelude = typst_prelude;
645        }
646    }
647
648    if let Some(notes) = partial.notes {
649        if let Some(font_size) = notes.font_size {
650            config.notes.font_size = font_size;
651        }
652        if let Some(font_size_step) = notes.font_size_step {
653            config.notes.font_size_step = font_size_step;
654        }
655    }
656
657    if let Some(keybindings) = partial.keybindings {
658        config.keybindings.extend(keybindings);
659    }
660
661    if let Some(clicker) = partial.clicker {
662        if let Some(profile) = clicker.profile {
663            config.clicker.profile = profile;
664        }
665        if let Some(profiles) = clicker.profiles {
666            config.clicker.profiles.extend(profiles);
667        }
668    }
669
670    if let Some(remote) = partial.remote {
671        apply_remote_config(&mut config.remote, remote);
672    }
673
674    if let Some(export) = partial.export {
675        apply_export_config(&mut config.export, export);
676    }
677
678    if let Some(sidecar_format) = partial.sidecar_format {
679        config.sidecar_format = sidecar_format;
680    }
681    if let Some(save_slide_timings) = partial.save_slide_timings {
682        config.save_slide_timings = save_slide_timings;
683    }
684}
685
686fn apply_export_config(config: &mut ExportConfig, partial: PartialExportConfig) {
687    if let Some(format) = partial.format {
688        config.format = format;
689    }
690    if let Some(layers) = partial.layers {
691        config.layers = layers;
692    }
693    if let Some(handout) = partial.handout {
694        config.handout = handout;
695    }
696    if let Some(whiteboard) = partial.whiteboard {
697        config.whiteboard = whiteboard;
698    }
699}
700
701fn apply_remote_config(config: &mut RemoteConfig, partial: PartialRemoteConfig) {
702    if let Some(enabled) = partial.enabled {
703        config.enabled = enabled;
704    }
705    if let Some(host) = partial.host {
706        config.host = host;
707    }
708    if let Some(port) = partial.port {
709        config.port = port;
710    }
711    if let Some(token) = partial.token {
712        config.token = token;
713    }
714    if let Some(allow) = partial.allow_unauthenticated_loopback {
715        config.allow_unauthenticated_loopback = allow;
716    }
717}
718
719fn apply_laser_config(config: &mut LaserConfig, partial: PartialLaserConfig) {
720    if let Some(color) = partial.color {
721        config.color = color.clone();
722        config.dot.color = color.clone();
723        config.minimal.color = color.clone();
724        config.crosshair.color = color.clone();
725        config.arrow.color = color.clone();
726        config.ring.color = color.clone();
727        config.bullseye.color = color.clone();
728        config.highlight.color = color;
729    }
730    if let Some(size) = partial.size {
731        config.size = size;
732        config.dot.size = size;
733        config.minimal.size = size;
734        config.crosshair.size = size;
735        config.arrow.size = size;
736        config.ring.size = size;
737        config.bullseye.size = size;
738        config.highlight.size = size;
739    }
740    if let Some(style) = partial.style {
741        config.style = style;
742    }
743    if let Some(dot) = partial.dot {
744        apply_pointer_style_config(&mut config.dot, dot);
745    }
746    if let Some(minimal) = partial.minimal {
747        apply_pointer_style_config(&mut config.minimal, minimal);
748    }
749    if let Some(crosshair) = partial.crosshair {
750        apply_pointer_style_config(&mut config.crosshair, crosshair);
751    }
752    if let Some(arrow) = partial.arrow {
753        apply_pointer_style_config(&mut config.arrow, arrow);
754    }
755    if let Some(ring) = partial.ring {
756        apply_pointer_style_config(&mut config.ring, ring);
757    }
758    if let Some(bullseye) = partial.bullseye {
759        apply_pointer_style_config(&mut config.bullseye, bullseye);
760    }
761    if let Some(highlight) = partial.highlight {
762        apply_pointer_style_config(&mut config.highlight, highlight);
763    }
764}
765
766fn apply_pointer_style_config(config: &mut PointerStyleConfig, partial: PartialPointerStyleConfig) {
767    if let Some(color) = partial.color {
768        config.color = color;
769    }
770    if let Some(size) = partial.size {
771        config.size = size;
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::time::{SystemTime, UNIX_EPOCH};
779
780    fn temp_config_dir(name: &str) -> PathBuf {
781        let suffix = SystemTime::now()
782            .duration_since(UNIX_EPOCH)
783            .expect("system time should be after unix epoch")
784            .as_nanos();
785        let dir = std::env::temp_dir().join(format!("dais_config_test_{name}_{suffix}"));
786        std::fs::create_dir_all(&dir).expect("should create temp config test dir");
787        dir
788    }
789
790    #[test]
791    fn partial_config_overrides_selected_fields() {
792        let mut config = Config::default();
793        let partial = PartialConfig {
794            display: Some(PartialDisplayConfig {
795                mode: Some("screen-share".to_string()),
796                single_monitor_view: Some("split".to_string()),
797                audience_monitor: Some("Projector".to_string()),
798                presenter_monitor: None,
799            }),
800            timer: Some(PartialTimerConfig {
801                mode: Some(TimerMode::Countdown),
802                duration_minutes: Some(OptionalU32Value::Value(45)),
803                warning_minutes: Some(OptionalU32Value::Value(10)),
804                overrun_color: Some(false),
805            }),
806            save_slide_timings: Some(false),
807            ..Default::default()
808        };
809
810        apply_partial_config(&mut config, partial);
811
812        assert_eq!(config.display.mode, "screen-share");
813        assert_eq!(config.display.single_monitor_view, "split");
814        assert_eq!(config.display.audience_monitor, "Projector");
815        assert_eq!(config.timer.mode, TimerMode::Countdown);
816        assert_eq!(config.timer.duration_minutes, Some(45));
817        assert_eq!(config.timer.warning_minutes, Some(10));
818        assert!(!config.timer.overrun_color);
819        assert!(!config.save_slide_timings);
820    }
821
822    #[test]
823    fn partial_config_overrides_text_box_defaults() {
824        let mut config = Config::default();
825        let partial = PartialConfig {
826            text_boxes: Some(PartialTextBoxConfig {
827                color: Some("#112233".to_string()),
828                background: Some("#445566AA".to_string()),
829                typst_prelude: Some("#set align(horizon)".to_string()),
830            }),
831            ..Default::default()
832        };
833
834        apply_partial_config(&mut config, partial);
835
836        assert_eq!(config.text_boxes.color, "#112233");
837        assert_eq!(config.text_boxes.background, "#445566AA");
838        assert_eq!(config.text_boxes.typst_prelude, "#set align(horizon)");
839    }
840
841    #[test]
842    fn partial_config_overrides_export_defaults() {
843        let mut config = Config::default();
844        let partial = PartialConfig {
845            export: Some(PartialExportConfig {
846                format: Some("svg".to_string()),
847                layers: Some("ink".to_string()),
848                handout: Some(true),
849                whiteboard: Some("append".to_string()),
850            }),
851            ..Default::default()
852        };
853
854        apply_partial_config(&mut config, partial);
855
856        assert_eq!(config.export.format, "svg");
857        assert_eq!(config.export.layers, "ink");
858        assert!(config.export.handout);
859        assert_eq!(config.export.whiteboard, "append");
860    }
861
862    #[test]
863    fn partial_laser_defaults_apply_to_all_pointer_styles() {
864        let mut config = Config::default();
865        let partial = PartialConfig {
866            laser: Some(PartialLaserConfig {
867                color: Some("#FFFFFF".to_string()),
868                size: Some(20.0),
869                ..Default::default()
870            }),
871            ..Default::default()
872        };
873
874        apply_partial_config(&mut config, partial);
875
876        assert_eq!(config.laser.dot.color, "#FFFFFF");
877        assert_eq!(config.laser.minimal.color, "#FFFFFF");
878        assert_eq!(config.laser.crosshair.color, "#FFFFFF");
879        assert_eq!(config.laser.arrow.color, "#FFFFFF");
880        assert_eq!(config.laser.ring.color, "#FFFFFF");
881        assert_eq!(config.laser.bullseye.color, "#FFFFFF");
882        assert_eq!(config.laser.highlight.color, "#FFFFFF");
883        assert!((config.laser.dot.size - 20.0).abs() < f32::EPSILON);
884        assert!((config.laser.minimal.size - 20.0).abs() < f32::EPSILON);
885        assert!((config.laser.crosshair.size - 20.0).abs() < f32::EPSILON);
886        assert!((config.laser.arrow.size - 20.0).abs() < f32::EPSILON);
887        assert!((config.laser.ring.size - 20.0).abs() < f32::EPSILON);
888        assert!((config.laser.bullseye.size - 20.0).abs() < f32::EPSILON);
889        assert!((config.laser.highlight.size - 20.0).abs() < f32::EPSILON);
890    }
891
892    #[test]
893    fn partial_laser_pointer_style_overrides_defaults() {
894        let partial: PartialConfig = toml::from_str(
895            r##"
896            [laser]
897            color = "#FFFFFF"
898            size = 14.0
899            style = "crosshair"
900
901            [laser.crosshair]
902            color = "#00FF00"
903            size = 30.0
904
905            [laser.minimal]
906            size = 8.0
907
908            [laser.highlight]
909            color = "#FFFF0080"
910            "##,
911        )
912        .unwrap();
913        let mut config = Config::default();
914
915        apply_partial_config(&mut config, partial);
916
917        assert_eq!(config.laser.style, "crosshair");
918        assert_eq!(config.laser.dot.color, "#FFFFFF");
919        assert!((config.laser.dot.size - 14.0).abs() < f32::EPSILON);
920        assert_eq!(config.laser.minimal.color, "#FFFFFF");
921        assert!((config.laser.minimal.size - 8.0).abs() < f32::EPSILON);
922        assert_eq!(config.laser.crosshair.color, "#00FF00");
923        assert!((config.laser.crosshair.size - 30.0).abs() < f32::EPSILON);
924        assert_eq!(config.laser.arrow.color, "#FFFFFF");
925        assert!((config.laser.arrow.size - 14.0).abs() < f32::EPSILON);
926        assert_eq!(config.laser.ring.color, "#FFFFFF");
927        assert!((config.laser.ring.size - 14.0).abs() < f32::EPSILON);
928        assert_eq!(config.laser.bullseye.color, "#FFFFFF");
929        assert!((config.laser.bullseye.size - 14.0).abs() < f32::EPSILON);
930        assert_eq!(config.laser.highlight.color, "#FFFF0080");
931        assert!((config.laser.highlight.size - 14.0).abs() < f32::EPSILON);
932    }
933
934    #[test]
935    fn partial_config_can_clear_optional_timer_values() {
936        let mut config = Config::default();
937        config.timer.duration_minutes = Some(20);
938        config.timer.warning_minutes = Some(5);
939
940        let partial = PartialConfig {
941            timer: Some(PartialTimerConfig {
942                duration_minutes: Some(OptionalU32Value::Null(())),
943                warning_minutes: Some(OptionalU32Value::Null(())),
944                ..Default::default()
945            }),
946            ..Default::default()
947        };
948
949        apply_partial_config(&mut config, partial);
950
951        assert_eq!(config.timer.duration_minutes, None);
952        assert_eq!(config.timer.warning_minutes, None);
953    }
954
955    #[test]
956    fn portable_config_skips_machine_config() {
957        let dir = temp_config_dir("portable_skips_machine");
958        let machine_config = dir.join("machine.toml");
959        let pdf_path = dir.join("slides.pdf");
960        std::fs::write(&machine_config, "[display]\nmode = \"screen-share\"\n")
961            .expect("should write machine config");
962
963        let regular = load_config_from_paths(
964            &pdf_path,
965            Some(&machine_config),
966            None,
967            ConfigLoadOptions { portable: false },
968        );
969        let portable = load_config_from_paths(
970            &pdf_path,
971            Some(&machine_config),
972            None,
973            ConfigLoadOptions { portable: true },
974        );
975
976        assert_eq!(regular.display.mode, "screen-share");
977        assert_eq!(portable.display.mode, "dual");
978
979        let _ = std::fs::remove_dir_all(dir);
980    }
981
982    #[test]
983    fn portable_config_still_loads_project_and_explicit_config() {
984        let dir = temp_config_dir("portable_keeps_project_explicit");
985        let explicit_config = dir.join("explicit.toml");
986        let pdf_dir = dir.join("talk");
987        std::fs::create_dir_all(&pdf_dir).expect("should create pdf dir");
988        let pdf_path = pdf_dir.join("slides.pdf");
989        std::fs::write(pdf_dir.join("dais.toml"), "[display]\nmode = \"single\"\n")
990            .expect("should write project config");
991        std::fs::write(&explicit_config, "[display]\nmode = \"screen-share\"\n")
992            .expect("should write explicit config");
993
994        let config = load_config_from_paths(
995            &pdf_path,
996            None,
997            Some(&explicit_config),
998            ConfigLoadOptions { portable: true },
999        );
1000
1001        assert_eq!(config.display.mode, "screen-share");
1002
1003        let _ = std::fs::remove_dir_all(dir);
1004    }
1005}