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    /// Sidecar save format: `"dais"` or `"pdfpc"`.
31    pub sidecar_format: String,
32}
33
34#[derive(Debug, Clone, Default, Deserialize)]
35#[serde(default)]
36struct PartialConfig {
37    display: Option<PartialDisplayConfig>,
38    timer: Option<PartialTimerConfig>,
39    laser: Option<PartialLaserConfig>,
40    spotlight: Option<PartialSpotlightConfig>,
41    ink: Option<PartialInkConfig>,
42    text_boxes: Option<PartialTextBoxConfig>,
43    notes: Option<PartialNotesConfig>,
44    keybindings: Option<HashMap<String, Vec<String>>>,
45    clicker: Option<PartialClickerConfig>,
46    sidecar_format: Option<String>,
47}
48
49/// Display mode and monitor assignment.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct DisplayConfig {
53    /// Display mode: "dual", "single", or "screen-share".
54    pub mode: String,
55    /// Single-monitor presentation surface: "hud" or "split".
56    pub single_monitor_view: String,
57    /// Audience monitor identifier or "auto".
58    pub audience_monitor: String,
59    /// Presenter monitor identifier or "auto".
60    pub presenter_monitor: String,
61}
62
63#[derive(Debug, Clone, Default, Deserialize)]
64#[serde(default)]
65struct PartialDisplayConfig {
66    mode: Option<String>,
67    single_monitor_view: Option<String>,
68    audience_monitor: Option<String>,
69    presenter_monitor: Option<String>,
70}
71
72/// Timer configuration.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct TimerConfig {
76    /// "countdown" or "elapsed".
77    pub mode: TimerMode,
78    /// Timer duration in minutes. If omitted in elapsed mode, no limit is shown.
79    pub duration_minutes: Option<u32>,
80    /// Minutes remaining when warning color activates.
81    pub warning_minutes: Option<u32>,
82    /// Whether to show red when past duration.
83    pub overrun_color: bool,
84}
85
86#[derive(Debug, Clone, Default, Deserialize)]
87#[serde(default)]
88struct PartialTimerConfig {
89    mode: Option<TimerMode>,
90    duration_minutes: Option<OptionalU32Value>,
91    warning_minutes: Option<OptionalU32Value>,
92    overrun_color: Option<bool>,
93}
94
95#[derive(Debug, Clone, Copy, Deserialize)]
96#[serde(untagged)]
97enum OptionalU32Value {
98    Value(u32),
99    Null(()),
100}
101
102impl OptionalU32Value {
103    fn into_option(self) -> Option<u32> {
104        match self {
105            Self::Value(value) => Some(value),
106            Self::Null(()) => None,
107        }
108    }
109}
110
111/// Laser pointer configuration.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct LaserConfig {
115    /// Default hex color string (e.g., "#FF0000") applied to all pointer styles unless overridden.
116    pub color: String,
117    /// Default size in logical pixels at 1x scale applied to all pointer styles unless overridden.
118    pub size: f32,
119    /// Style: "dot", "minimal", "crosshair", "arrow", "ring", "bullseye", or "highlight".
120    pub style: String,
121    /// Dot pointer appearance.
122    pub dot: PointerStyleConfig,
123    /// Minimal dot pointer appearance.
124    pub minimal: PointerStyleConfig,
125    /// Crosshair pointer appearance.
126    pub crosshair: PointerStyleConfig,
127    /// Arrow pointer appearance.
128    pub arrow: PointerStyleConfig,
129    /// Ring pointer appearance.
130    pub ring: PointerStyleConfig,
131    /// Bullseye pointer appearance.
132    pub bullseye: PointerStyleConfig,
133    /// Highlight pointer appearance.
134    pub highlight: PointerStyleConfig,
135}
136
137/// Appearance configuration for one laser pointer style.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(default)]
140pub struct PointerStyleConfig {
141    /// Hex color string (e.g., "#FF0000" or "#FF000080").
142    pub color: String,
143    /// Size in logical pixels at 1x scale.
144    pub size: f32,
145}
146
147#[derive(Debug, Clone, Default, Deserialize)]
148#[serde(default)]
149struct PartialLaserConfig {
150    color: Option<String>,
151    size: Option<f32>,
152    style: Option<String>,
153    dot: Option<PartialPointerStyleConfig>,
154    minimal: Option<PartialPointerStyleConfig>,
155    crosshair: Option<PartialPointerStyleConfig>,
156    arrow: Option<PartialPointerStyleConfig>,
157    ring: Option<PartialPointerStyleConfig>,
158    bullseye: Option<PartialPointerStyleConfig>,
159    highlight: Option<PartialPointerStyleConfig>,
160}
161
162#[derive(Debug, Clone, Default, Deserialize)]
163#[serde(default)]
164struct PartialPointerStyleConfig {
165    color: Option<String>,
166    size: Option<f32>,
167}
168
169/// Spotlight configuration.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(default)]
172pub struct SpotlightConfig {
173    /// Radius in logical pixels at 1x scale.
174    pub radius: f32,
175    /// Opacity of the dimmed area (0.0–1.0).
176    pub dim_opacity: f32,
177}
178
179#[derive(Debug, Clone, Default, Deserialize)]
180#[serde(default)]
181struct PartialSpotlightConfig {
182    radius: Option<f32>,
183    dim_opacity: Option<f32>,
184}
185
186/// Ink drawing configuration.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(default)]
189pub struct InkConfig {
190    /// Pen color presets as hex strings (RGBA or RGB). `CycleInkColor` steps through these.
191    /// Accepts a single string (`color = "#FF0000"`) or an array (`colors = ["#FF0000", "#0000FF"]`).
192    pub colors: Vec<String>,
193    /// Stroke width in logical pixels.
194    pub width: f32,
195}
196
197#[derive(Debug, Clone, Default, Deserialize)]
198#[serde(default)]
199struct PartialInkConfig {
200    /// New array form: `colors = ["#FF0000", "#0000FF"]`.
201    colors: Option<Vec<String>>,
202    /// Legacy single-color form: `color = "#FF0000"`. Ignored if `colors` is also set.
203    color: Option<String>,
204    width: Option<f32>,
205}
206
207/// Default style for newly created text boxes.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct TextBoxConfig {
211    /// Text color as a hex string (RGB or RGBA).
212    pub color: String,
213    /// Background fill as a hex string (RGB or RGBA), or `"transparent"`.
214    pub background: String,
215    /// Typst setup inserted after Dais defaults and before newly created text box content.
216    pub typst_prelude: String,
217}
218
219#[derive(Debug, Clone, Default, Deserialize)]
220#[serde(default)]
221struct PartialTextBoxConfig {
222    color: Option<String>,
223    background: Option<String>,
224    typst_prelude: Option<String>,
225}
226
227/// Clicker/remote hardware configuration.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(default)]
230pub struct ClickerConfig {
231    /// Name of the active clicker profile (e.g., "default", "logitech-spotlight").
232    pub profile: String,
233    /// Custom profile definitions mapping key names to action names.
234    pub profiles: HashMap<String, HashMap<String, String>>,
235}
236
237#[derive(Debug, Clone, Default, Deserialize)]
238#[serde(default)]
239struct PartialClickerConfig {
240    profile: Option<String>,
241    profiles: Option<HashMap<String, HashMap<String, String>>>,
242}
243
244/// Notes panel configuration.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(default)]
247pub struct NotesConfig {
248    /// Font size in points.
249    pub font_size: f32,
250    /// Step size for font size increment/decrement.
251    pub font_size_step: f32,
252}
253
254#[derive(Debug, Clone, Default, Deserialize)]
255#[serde(default)]
256struct PartialNotesConfig {
257    font_size: Option<f32>,
258    font_size_step: Option<f32>,
259}
260
261impl Default for Config {
262    fn default() -> Self {
263        Self {
264            display: DisplayConfig::default(),
265            timer: TimerConfig::default(),
266            laser: LaserConfig::default(),
267            spotlight: SpotlightConfig::default(),
268            ink: InkConfig::default(),
269            text_boxes: TextBoxConfig::default(),
270            notes: NotesConfig::default(),
271            keybindings: HashMap::new(),
272            clicker: ClickerConfig::default(),
273            sidecar_format: "dais".to_string(),
274        }
275    }
276}
277
278impl Default for DisplayConfig {
279    fn default() -> Self {
280        Self {
281            mode: "dual".to_string(),
282            single_monitor_view: "hud".to_string(),
283            audience_monitor: "auto".to_string(),
284            presenter_monitor: "auto".to_string(),
285        }
286    }
287}
288
289impl Default for TimerConfig {
290    fn default() -> Self {
291        Self {
292            mode: TimerMode::Elapsed,
293            duration_minutes: None,
294            warning_minutes: None,
295            overrun_color: true,
296        }
297    }
298}
299
300impl Default for LaserConfig {
301    fn default() -> Self {
302        let pointer = PointerStyleConfig::default();
303        Self {
304            color: pointer.color.clone(),
305            size: pointer.size,
306            style: "dot".to_string(),
307            dot: pointer.clone(),
308            minimal: pointer.clone(),
309            crosshair: pointer.clone(),
310            arrow: pointer.clone(),
311            ring: pointer.clone(),
312            bullseye: pointer.clone(),
313            highlight: pointer,
314        }
315    }
316}
317
318impl Default for PointerStyleConfig {
319    fn default() -> Self {
320        Self { color: "#FF0000".to_string(), size: 12.0 }
321    }
322}
323
324impl Default for SpotlightConfig {
325    fn default() -> Self {
326        Self { radius: 80.0, dim_opacity: 0.6 }
327    }
328}
329
330impl Default for InkConfig {
331    fn default() -> Self {
332        Self { colors: vec!["#FF0000".to_string()], width: 3.0 }
333    }
334}
335
336impl Default for TextBoxConfig {
337    fn default() -> Self {
338        Self {
339            color: "#000000".to_string(),
340            background: "transparent".to_string(),
341            typst_prelude: String::new(),
342        }
343    }
344}
345
346impl Default for ClickerConfig {
347    fn default() -> Self {
348        Self { profile: "default".to_string(), profiles: HashMap::new() }
349    }
350}
351
352/// Return the built-in default clicker profile mapping common USB presenter keys to actions.
353pub fn default_clicker_profile() -> HashMap<String, String> {
354    HashMap::from([
355        ("PageDown".to_string(), "next_slide".to_string()),
356        ("PageUp".to_string(), "previous_slide".to_string()),
357        ("F5".to_string(), "toggle_presentation_mode".to_string()),
358        ("b".to_string(), "toggle_blackout".to_string()),
359        (".".to_string(), "toggle_blackout".to_string()),
360    ])
361}
362
363impl Config {
364    /// Resolve the active clicker profile into a key -> action map.
365    pub fn active_clicker_profile(&self) -> HashMap<String, String> {
366        if self.clicker.profile == "default" {
367            return default_clicker_profile();
368        }
369
370        self.clicker.profiles.get(&self.clicker.profile).cloned().unwrap_or_else(|| {
371            tracing::warn!(
372                "Configured clicker profile '{}' not found; using default profile",
373                self.clicker.profile
374            );
375            default_clicker_profile()
376        })
377    }
378
379    /// Normalize the configured sidecar save format to a supported value.
380    pub fn normalized_sidecar_format(&self) -> &str {
381        if self.sidecar_format.eq_ignore_ascii_case("dais") { "dais" } else { "pdfpc" }
382    }
383}
384
385impl Default for NotesConfig {
386    fn default() -> Self {
387        Self { font_size: 16.0, font_size_step: 2.0 }
388    }
389}
390
391/// Resolve the platform-appropriate config file path.
392pub fn config_path() -> Option<PathBuf> {
393    directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
394}
395
396/// Resolve a project-local config path for a PDF.
397pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
398    pdf_path.parent().map(|dir| dir.join("dais.toml"))
399}
400
401/// Load layered config for a document.
402///
403/// Precedence:
404/// 1. Built-in defaults
405/// 2. Machine-wide config (`config.toml` in the standard OS config dir)
406/// 3. Project-local config (`dais.toml` next to the PDF)
407/// 4. Explicit `--config` path, if provided
408///
409/// Missing or invalid config files are logged and ignored; this function always
410/// returns a usable configuration by falling back to defaults.
411pub fn load_config_for(pdf_path: &Path, explicit_config: Option<&Path>) -> Config {
412    let mut config = Config::default();
413
414    if let Some(path) = config_path() {
415        merge_config_file(&mut config, &path);
416    } else {
417        tracing::warn!("Could not determine config directory, using defaults");
418    }
419
420    if let Some(path) = project_config_path(pdf_path) {
421        merge_config_file(&mut config, &path);
422    }
423
424    if let Some(path) = explicit_config {
425        merge_config_file(&mut config, path);
426    }
427
428    config
429}
430
431fn merge_config_file(config: &mut Config, path: &Path) {
432    let Ok(contents) = std::fs::read_to_string(path) else {
433        tracing::debug!("No config file at {}", path.display());
434        return;
435    };
436
437    match toml::from_str::<PartialConfig>(&contents) {
438        Ok(partial) => {
439            tracing::info!("Loaded config layer from {}", path.display());
440            apply_partial_config(config, partial);
441        }
442        Err(e) => {
443            tracing::warn!("Failed to parse config at {}: {e}", path.display());
444        }
445    }
446}
447
448fn apply_partial_config(config: &mut Config, partial: PartialConfig) {
449    if let Some(display) = partial.display {
450        if let Some(mode) = display.mode {
451            config.display.mode = mode;
452        }
453        if let Some(v) = display.single_monitor_view {
454            config.display.single_monitor_view = v;
455        }
456        if let Some(audience_monitor) = display.audience_monitor {
457            config.display.audience_monitor = audience_monitor;
458        }
459        if let Some(presenter_monitor) = display.presenter_monitor {
460            config.display.presenter_monitor = presenter_monitor;
461        }
462    }
463
464    if let Some(timer) = partial.timer {
465        if let Some(mode) = timer.mode {
466            config.timer.mode = mode;
467        }
468        if let Some(duration_minutes) = timer.duration_minutes {
469            config.timer.duration_minutes = duration_minutes.into_option();
470        }
471        if let Some(warning_minutes) = timer.warning_minutes {
472            config.timer.warning_minutes = warning_minutes.into_option();
473        }
474        if let Some(overrun_color) = timer.overrun_color {
475            config.timer.overrun_color = overrun_color;
476        }
477    }
478
479    if let Some(laser) = partial.laser {
480        apply_laser_config(&mut config.laser, laser);
481    }
482
483    if let Some(spotlight) = partial.spotlight {
484        if let Some(radius) = spotlight.radius {
485            config.spotlight.radius = radius;
486        }
487        if let Some(dim_opacity) = spotlight.dim_opacity {
488            config.spotlight.dim_opacity = dim_opacity;
489        }
490    }
491
492    if let Some(ink) = partial.ink {
493        if let Some(colors) = ink.colors {
494            config.ink.colors = colors;
495        } else if let Some(color) = ink.color {
496            config.ink.colors = vec![color];
497        }
498        if let Some(width) = ink.width {
499            config.ink.width = width;
500        }
501    }
502
503    if let Some(text_boxes) = partial.text_boxes {
504        if let Some(color) = text_boxes.color {
505            config.text_boxes.color = color;
506        }
507        if let Some(background) = text_boxes.background {
508            config.text_boxes.background = background;
509        }
510        if let Some(typst_prelude) = text_boxes.typst_prelude {
511            config.text_boxes.typst_prelude = typst_prelude;
512        }
513    }
514
515    if let Some(notes) = partial.notes {
516        if let Some(font_size) = notes.font_size {
517            config.notes.font_size = font_size;
518        }
519        if let Some(font_size_step) = notes.font_size_step {
520            config.notes.font_size_step = font_size_step;
521        }
522    }
523
524    if let Some(keybindings) = partial.keybindings {
525        config.keybindings.extend(keybindings);
526    }
527
528    if let Some(clicker) = partial.clicker {
529        if let Some(profile) = clicker.profile {
530            config.clicker.profile = profile;
531        }
532        if let Some(profiles) = clicker.profiles {
533            config.clicker.profiles.extend(profiles);
534        }
535    }
536
537    if let Some(sidecar_format) = partial.sidecar_format {
538        config.sidecar_format = sidecar_format;
539    }
540}
541
542fn apply_laser_config(config: &mut LaserConfig, partial: PartialLaserConfig) {
543    if let Some(color) = partial.color {
544        config.color = color.clone();
545        config.dot.color = color.clone();
546        config.minimal.color = color.clone();
547        config.crosshair.color = color.clone();
548        config.arrow.color = color.clone();
549        config.ring.color = color.clone();
550        config.bullseye.color = color.clone();
551        config.highlight.color = color;
552    }
553    if let Some(size) = partial.size {
554        config.size = size;
555        config.dot.size = size;
556        config.minimal.size = size;
557        config.crosshair.size = size;
558        config.arrow.size = size;
559        config.ring.size = size;
560        config.bullseye.size = size;
561        config.highlight.size = size;
562    }
563    if let Some(style) = partial.style {
564        config.style = style;
565    }
566    if let Some(dot) = partial.dot {
567        apply_pointer_style_config(&mut config.dot, dot);
568    }
569    if let Some(minimal) = partial.minimal {
570        apply_pointer_style_config(&mut config.minimal, minimal);
571    }
572    if let Some(crosshair) = partial.crosshair {
573        apply_pointer_style_config(&mut config.crosshair, crosshair);
574    }
575    if let Some(arrow) = partial.arrow {
576        apply_pointer_style_config(&mut config.arrow, arrow);
577    }
578    if let Some(ring) = partial.ring {
579        apply_pointer_style_config(&mut config.ring, ring);
580    }
581    if let Some(bullseye) = partial.bullseye {
582        apply_pointer_style_config(&mut config.bullseye, bullseye);
583    }
584    if let Some(highlight) = partial.highlight {
585        apply_pointer_style_config(&mut config.highlight, highlight);
586    }
587}
588
589fn apply_pointer_style_config(config: &mut PointerStyleConfig, partial: PartialPointerStyleConfig) {
590    if let Some(color) = partial.color {
591        config.color = color;
592    }
593    if let Some(size) = partial.size {
594        config.size = size;
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn partial_config_overrides_selected_fields() {
604        let mut config = Config::default();
605        let partial = PartialConfig {
606            display: Some(PartialDisplayConfig {
607                mode: Some("screen-share".to_string()),
608                single_monitor_view: Some("split".to_string()),
609                audience_monitor: Some("Projector".to_string()),
610                presenter_monitor: None,
611            }),
612            timer: Some(PartialTimerConfig {
613                mode: Some(TimerMode::Countdown),
614                duration_minutes: Some(OptionalU32Value::Value(45)),
615                warning_minutes: Some(OptionalU32Value::Value(10)),
616                overrun_color: Some(false),
617            }),
618            ..Default::default()
619        };
620
621        apply_partial_config(&mut config, partial);
622
623        assert_eq!(config.display.mode, "screen-share");
624        assert_eq!(config.display.single_monitor_view, "split");
625        assert_eq!(config.display.audience_monitor, "Projector");
626        assert_eq!(config.timer.mode, TimerMode::Countdown);
627        assert_eq!(config.timer.duration_minutes, Some(45));
628        assert_eq!(config.timer.warning_minutes, Some(10));
629        assert!(!config.timer.overrun_color);
630    }
631
632    #[test]
633    fn partial_config_overrides_text_box_defaults() {
634        let mut config = Config::default();
635        let partial = PartialConfig {
636            text_boxes: Some(PartialTextBoxConfig {
637                color: Some("#112233".to_string()),
638                background: Some("#445566AA".to_string()),
639                typst_prelude: Some("#set align(horizon)".to_string()),
640            }),
641            ..Default::default()
642        };
643
644        apply_partial_config(&mut config, partial);
645
646        assert_eq!(config.text_boxes.color, "#112233");
647        assert_eq!(config.text_boxes.background, "#445566AA");
648        assert_eq!(config.text_boxes.typst_prelude, "#set align(horizon)");
649    }
650
651    #[test]
652    fn partial_laser_defaults_apply_to_all_pointer_styles() {
653        let mut config = Config::default();
654        let partial = PartialConfig {
655            laser: Some(PartialLaserConfig {
656                color: Some("#FFFFFF".to_string()),
657                size: Some(20.0),
658                ..Default::default()
659            }),
660            ..Default::default()
661        };
662
663        apply_partial_config(&mut config, partial);
664
665        assert_eq!(config.laser.dot.color, "#FFFFFF");
666        assert_eq!(config.laser.minimal.color, "#FFFFFF");
667        assert_eq!(config.laser.crosshair.color, "#FFFFFF");
668        assert_eq!(config.laser.arrow.color, "#FFFFFF");
669        assert_eq!(config.laser.ring.color, "#FFFFFF");
670        assert_eq!(config.laser.bullseye.color, "#FFFFFF");
671        assert_eq!(config.laser.highlight.color, "#FFFFFF");
672        assert!((config.laser.dot.size - 20.0).abs() < f32::EPSILON);
673        assert!((config.laser.minimal.size - 20.0).abs() < f32::EPSILON);
674        assert!((config.laser.crosshair.size - 20.0).abs() < f32::EPSILON);
675        assert!((config.laser.arrow.size - 20.0).abs() < f32::EPSILON);
676        assert!((config.laser.ring.size - 20.0).abs() < f32::EPSILON);
677        assert!((config.laser.bullseye.size - 20.0).abs() < f32::EPSILON);
678        assert!((config.laser.highlight.size - 20.0).abs() < f32::EPSILON);
679    }
680
681    #[test]
682    fn partial_laser_pointer_style_overrides_defaults() {
683        let partial: PartialConfig = toml::from_str(
684            r##"
685            [laser]
686            color = "#FFFFFF"
687            size = 14.0
688            style = "crosshair"
689
690            [laser.crosshair]
691            color = "#00FF00"
692            size = 30.0
693
694            [laser.minimal]
695            size = 8.0
696
697            [laser.highlight]
698            color = "#FFFF0080"
699            "##,
700        )
701        .unwrap();
702        let mut config = Config::default();
703
704        apply_partial_config(&mut config, partial);
705
706        assert_eq!(config.laser.style, "crosshair");
707        assert_eq!(config.laser.dot.color, "#FFFFFF");
708        assert!((config.laser.dot.size - 14.0).abs() < f32::EPSILON);
709        assert_eq!(config.laser.minimal.color, "#FFFFFF");
710        assert!((config.laser.minimal.size - 8.0).abs() < f32::EPSILON);
711        assert_eq!(config.laser.crosshair.color, "#00FF00");
712        assert!((config.laser.crosshair.size - 30.0).abs() < f32::EPSILON);
713        assert_eq!(config.laser.arrow.color, "#FFFFFF");
714        assert!((config.laser.arrow.size - 14.0).abs() < f32::EPSILON);
715        assert_eq!(config.laser.ring.color, "#FFFFFF");
716        assert!((config.laser.ring.size - 14.0).abs() < f32::EPSILON);
717        assert_eq!(config.laser.bullseye.color, "#FFFFFF");
718        assert!((config.laser.bullseye.size - 14.0).abs() < f32::EPSILON);
719        assert_eq!(config.laser.highlight.color, "#FFFF0080");
720        assert!((config.laser.highlight.size - 14.0).abs() < f32::EPSILON);
721    }
722
723    #[test]
724    fn partial_config_can_clear_optional_timer_values() {
725        let mut config = Config::default();
726        config.timer.duration_minutes = Some(20);
727        config.timer.warning_minutes = Some(5);
728
729        let partial = PartialConfig {
730            timer: Some(PartialTimerConfig {
731                duration_minutes: Some(OptionalU32Value::Null(())),
732                warning_minutes: Some(OptionalU32Value::Null(())),
733                ..Default::default()
734            }),
735            ..Default::default()
736        };
737
738        apply_partial_config(&mut config, partial);
739
740        assert_eq!(config.timer.duration_minutes, None);
741        assert_eq!(config.timer.warning_minutes, None);
742    }
743}