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 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#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct DisplayConfig {
53 pub mode: String,
55 pub single_monitor_view: String,
57 pub audience_monitor: String,
59 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#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct TimerConfig {
76 pub mode: TimerMode,
78 pub duration_minutes: Option<u32>,
80 pub warning_minutes: Option<u32>,
82 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#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct LaserConfig {
115 pub color: String,
117 pub size: f32,
119 pub style: String,
121 pub dot: PointerStyleConfig,
123 pub minimal: PointerStyleConfig,
125 pub crosshair: PointerStyleConfig,
127 pub arrow: PointerStyleConfig,
129 pub ring: PointerStyleConfig,
131 pub bullseye: PointerStyleConfig,
133 pub highlight: PointerStyleConfig,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(default)]
140pub struct PointerStyleConfig {
141 pub color: String,
143 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#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(default)]
172pub struct SpotlightConfig {
173 pub radius: f32,
175 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#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(default)]
189pub struct InkConfig {
190 pub colors: Vec<String>,
193 pub width: f32,
195}
196
197#[derive(Debug, Clone, Default, Deserialize)]
198#[serde(default)]
199struct PartialInkConfig {
200 colors: Option<Vec<String>>,
202 color: Option<String>,
204 width: Option<f32>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct TextBoxConfig {
211 pub color: String,
213 pub background: String,
215 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#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(default)]
230pub struct ClickerConfig {
231 pub profile: String,
233 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#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(default)]
247pub struct NotesConfig {
248 pub font_size: f32,
250 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
352pub 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 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 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
391pub fn config_path() -> Option<PathBuf> {
393 directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
394}
395
396pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
398 pdf_path.parent().map(|dir| dir.join("dais.toml"))
399}
400
401pub 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}