1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::state::TimerMode;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct Config {
12 pub display: DisplayConfig,
14 pub timer: TimerConfig,
16 pub laser: LaserConfig,
18 pub spotlight: SpotlightConfig,
20 pub ink: InkConfig,
22 pub text_boxes: TextBoxConfig,
24 pub notes: NotesConfig,
26 pub keybindings: HashMap<String, Vec<String>>,
28 pub clicker: ClickerConfig,
30 pub remote: RemoteConfig,
32 pub export: ExportConfig,
34 pub sidecar_format: String,
36 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#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(default)]
61pub struct DisplayConfig {
62 pub mode: String,
64 pub single_monitor_view: String,
66 pub audience_monitor: String,
68 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#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct TimerConfig {
85 pub mode: TimerMode,
87 pub duration_minutes: Option<u32>,
89 pub warning_minutes: Option<u32>,
91 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#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(default)]
123pub struct LaserConfig {
124 pub color: String,
126 pub size: f32,
128 pub style: String,
130 pub dot: PointerStyleConfig,
132 pub minimal: PointerStyleConfig,
134 pub crosshair: PointerStyleConfig,
136 pub arrow: PointerStyleConfig,
138 pub ring: PointerStyleConfig,
140 pub bullseye: PointerStyleConfig,
142 pub highlight: PointerStyleConfig,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(default)]
149pub struct PointerStyleConfig {
150 pub color: String,
152 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#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(default)]
181pub struct SpotlightConfig {
182 pub radius: f32,
184 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#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(default)]
198pub struct InkConfig {
199 pub colors: Vec<String>,
202 pub width: f32,
204 pub highlighter_colors: Vec<String>,
207 pub highlighter_width: f32,
209}
210
211#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(default)]
213struct PartialInkConfig {
214 colors: Option<Vec<String>>,
216 color: Option<String>,
218 width: Option<f32>,
219 highlighter_colors: Option<Vec<String>>,
220 highlighter_width: Option<f32>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(default)]
226pub struct TextBoxConfig {
227 pub color: String,
229 pub background: String,
231 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#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default)]
246pub struct ClickerConfig {
247 pub profile: String,
249 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#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(default)]
263pub struct RemoteConfig {
264 pub enabled: bool,
266 pub host: String,
268 pub port: u16,
270 pub token: String,
272 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#[derive(Debug, Clone, Serialize, Deserialize)]
288#[serde(default)]
289pub struct NotesConfig {
290 pub font_size: f32,
292 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#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(default)]
306pub struct ExportConfig {
307 pub format: String,
309 pub layers: String,
311 pub handout: bool,
313 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
437pub 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 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 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
476pub fn config_path() -> Option<PathBuf> {
478 directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
479}
480
481pub 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#[derive(Debug, Clone, Copy, Default)]
499pub struct ConfigLoadOptions {
500 pub portable: bool,
502}
503
504pub 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
518pub 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}