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 crosshair: PointerStyleConfig,
125 pub arrow: PointerStyleConfig,
127 pub ring: PointerStyleConfig,
129 pub bullseye: PointerStyleConfig,
131 pub highlight: PointerStyleConfig,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(default)]
138pub struct PointerStyleConfig {
139 pub color: String,
141 pub size: f32,
143}
144
145#[derive(Debug, Clone, Default, Deserialize)]
146#[serde(default)]
147struct PartialLaserConfig {
148 color: Option<String>,
149 size: Option<f32>,
150 style: Option<String>,
151 dot: Option<PartialPointerStyleConfig>,
152 crosshair: Option<PartialPointerStyleConfig>,
153 arrow: Option<PartialPointerStyleConfig>,
154 ring: Option<PartialPointerStyleConfig>,
155 bullseye: Option<PartialPointerStyleConfig>,
156 highlight: Option<PartialPointerStyleConfig>,
157}
158
159#[derive(Debug, Clone, Default, Deserialize)]
160#[serde(default)]
161struct PartialPointerStyleConfig {
162 color: Option<String>,
163 size: Option<f32>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default)]
169pub struct SpotlightConfig {
170 pub radius: f32,
172 pub dim_opacity: f32,
174}
175
176#[derive(Debug, Clone, Default, Deserialize)]
177#[serde(default)]
178struct PartialSpotlightConfig {
179 radius: Option<f32>,
180 dim_opacity: Option<f32>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(default)]
186pub struct InkConfig {
187 pub colors: Vec<String>,
190 pub width: f32,
192}
193
194#[derive(Debug, Clone, Default, Deserialize)]
195#[serde(default)]
196struct PartialInkConfig {
197 colors: Option<Vec<String>>,
199 color: Option<String>,
201 width: Option<f32>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(default)]
207pub struct TextBoxConfig {
208 pub color: String,
210 pub background: String,
212}
213
214#[derive(Debug, Clone, Default, Deserialize)]
215#[serde(default)]
216struct PartialTextBoxConfig {
217 color: Option<String>,
218 background: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224pub struct ClickerConfig {
225 pub profile: String,
227 pub profiles: HashMap<String, HashMap<String, String>>,
229}
230
231#[derive(Debug, Clone, Default, Deserialize)]
232#[serde(default)]
233struct PartialClickerConfig {
234 profile: Option<String>,
235 profiles: Option<HashMap<String, HashMap<String, String>>>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct NotesConfig {
242 pub font_size: f32,
244 pub font_size_step: f32,
246}
247
248#[derive(Debug, Clone, Default, Deserialize)]
249#[serde(default)]
250struct PartialNotesConfig {
251 font_size: Option<f32>,
252 font_size_step: Option<f32>,
253}
254
255impl Default for Config {
256 fn default() -> Self {
257 Self {
258 display: DisplayConfig::default(),
259 timer: TimerConfig::default(),
260 laser: LaserConfig::default(),
261 spotlight: SpotlightConfig::default(),
262 ink: InkConfig::default(),
263 text_boxes: TextBoxConfig::default(),
264 notes: NotesConfig::default(),
265 keybindings: HashMap::new(),
266 clicker: ClickerConfig::default(),
267 sidecar_format: "dais".to_string(),
268 }
269 }
270}
271
272impl Default for DisplayConfig {
273 fn default() -> Self {
274 Self {
275 mode: "dual".to_string(),
276 single_monitor_view: "hud".to_string(),
277 audience_monitor: "auto".to_string(),
278 presenter_monitor: "auto".to_string(),
279 }
280 }
281}
282
283impl Default for TimerConfig {
284 fn default() -> Self {
285 Self {
286 mode: TimerMode::Elapsed,
287 duration_minutes: None,
288 warning_minutes: None,
289 overrun_color: true,
290 }
291 }
292}
293
294impl Default for LaserConfig {
295 fn default() -> Self {
296 let pointer = PointerStyleConfig::default();
297 Self {
298 color: pointer.color.clone(),
299 size: pointer.size,
300 style: "dot".to_string(),
301 dot: pointer.clone(),
302 crosshair: pointer.clone(),
303 arrow: pointer.clone(),
304 ring: pointer.clone(),
305 bullseye: pointer.clone(),
306 highlight: pointer,
307 }
308 }
309}
310
311impl Default for PointerStyleConfig {
312 fn default() -> Self {
313 Self { color: "#FF0000".to_string(), size: 12.0 }
314 }
315}
316
317impl Default for SpotlightConfig {
318 fn default() -> Self {
319 Self { radius: 80.0, dim_opacity: 0.6 }
320 }
321}
322
323impl Default for InkConfig {
324 fn default() -> Self {
325 Self { colors: vec!["#FF0000".to_string()], width: 3.0 }
326 }
327}
328
329impl Default for TextBoxConfig {
330 fn default() -> Self {
331 Self { color: "#000000".to_string(), background: "transparent".to_string() }
332 }
333}
334
335impl Default for ClickerConfig {
336 fn default() -> Self {
337 Self { profile: "default".to_string(), profiles: HashMap::new() }
338 }
339}
340
341pub fn default_clicker_profile() -> HashMap<String, String> {
343 HashMap::from([
344 ("PageDown".to_string(), "next_slide".to_string()),
345 ("PageUp".to_string(), "previous_slide".to_string()),
346 ("F5".to_string(), "toggle_presentation_mode".to_string()),
347 ("b".to_string(), "toggle_blackout".to_string()),
348 (".".to_string(), "toggle_blackout".to_string()),
349 ])
350}
351
352impl Config {
353 pub fn active_clicker_profile(&self) -> HashMap<String, String> {
355 if self.clicker.profile == "default" {
356 return default_clicker_profile();
357 }
358
359 self.clicker.profiles.get(&self.clicker.profile).cloned().unwrap_or_else(|| {
360 tracing::warn!(
361 "Configured clicker profile '{}' not found; using default profile",
362 self.clicker.profile
363 );
364 default_clicker_profile()
365 })
366 }
367
368 pub fn normalized_sidecar_format(&self) -> &str {
370 if self.sidecar_format.eq_ignore_ascii_case("dais") { "dais" } else { "pdfpc" }
371 }
372}
373
374impl Default for NotesConfig {
375 fn default() -> Self {
376 Self { font_size: 16.0, font_size_step: 2.0 }
377 }
378}
379
380pub fn config_path() -> Option<PathBuf> {
382 directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
383}
384
385pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
387 pdf_path.parent().map(|dir| dir.join("dais.toml"))
388}
389
390pub fn load_config_for(pdf_path: &Path, explicit_config: Option<&Path>) -> Config {
401 let mut config = Config::default();
402
403 if let Some(path) = config_path() {
404 merge_config_file(&mut config, &path);
405 } else {
406 tracing::warn!("Could not determine config directory, using defaults");
407 }
408
409 if let Some(path) = project_config_path(pdf_path) {
410 merge_config_file(&mut config, &path);
411 }
412
413 if let Some(path) = explicit_config {
414 merge_config_file(&mut config, path);
415 }
416
417 config
418}
419
420fn merge_config_file(config: &mut Config, path: &Path) {
421 let Ok(contents) = std::fs::read_to_string(path) else {
422 tracing::debug!("No config file at {}", path.display());
423 return;
424 };
425
426 match toml::from_str::<PartialConfig>(&contents) {
427 Ok(partial) => {
428 tracing::info!("Loaded config layer from {}", path.display());
429 apply_partial_config(config, partial);
430 }
431 Err(e) => {
432 tracing::warn!("Failed to parse config at {}: {e}", path.display());
433 }
434 }
435}
436
437fn apply_partial_config(config: &mut Config, partial: PartialConfig) {
438 if let Some(display) = partial.display {
439 if let Some(mode) = display.mode {
440 config.display.mode = mode;
441 }
442 if let Some(v) = display.single_monitor_view {
443 config.display.single_monitor_view = v;
444 }
445 if let Some(audience_monitor) = display.audience_monitor {
446 config.display.audience_monitor = audience_monitor;
447 }
448 if let Some(presenter_monitor) = display.presenter_monitor {
449 config.display.presenter_monitor = presenter_monitor;
450 }
451 }
452
453 if let Some(timer) = partial.timer {
454 if let Some(mode) = timer.mode {
455 config.timer.mode = mode;
456 }
457 if let Some(duration_minutes) = timer.duration_minutes {
458 config.timer.duration_minutes = duration_minutes.into_option();
459 }
460 if let Some(warning_minutes) = timer.warning_minutes {
461 config.timer.warning_minutes = warning_minutes.into_option();
462 }
463 if let Some(overrun_color) = timer.overrun_color {
464 config.timer.overrun_color = overrun_color;
465 }
466 }
467
468 if let Some(laser) = partial.laser {
469 apply_laser_config(&mut config.laser, laser);
470 }
471
472 if let Some(spotlight) = partial.spotlight {
473 if let Some(radius) = spotlight.radius {
474 config.spotlight.radius = radius;
475 }
476 if let Some(dim_opacity) = spotlight.dim_opacity {
477 config.spotlight.dim_opacity = dim_opacity;
478 }
479 }
480
481 if let Some(ink) = partial.ink {
482 if let Some(colors) = ink.colors {
483 config.ink.colors = colors;
484 } else if let Some(color) = ink.color {
485 config.ink.colors = vec![color];
486 }
487 if let Some(width) = ink.width {
488 config.ink.width = width;
489 }
490 }
491
492 if let Some(text_boxes) = partial.text_boxes {
493 if let Some(color) = text_boxes.color {
494 config.text_boxes.color = color;
495 }
496 if let Some(background) = text_boxes.background {
497 config.text_boxes.background = background;
498 }
499 }
500
501 if let Some(notes) = partial.notes {
502 if let Some(font_size) = notes.font_size {
503 config.notes.font_size = font_size;
504 }
505 if let Some(font_size_step) = notes.font_size_step {
506 config.notes.font_size_step = font_size_step;
507 }
508 }
509
510 if let Some(keybindings) = partial.keybindings {
511 config.keybindings.extend(keybindings);
512 }
513
514 if let Some(clicker) = partial.clicker {
515 if let Some(profile) = clicker.profile {
516 config.clicker.profile = profile;
517 }
518 if let Some(profiles) = clicker.profiles {
519 config.clicker.profiles.extend(profiles);
520 }
521 }
522
523 if let Some(sidecar_format) = partial.sidecar_format {
524 config.sidecar_format = sidecar_format;
525 }
526}
527
528fn apply_laser_config(config: &mut LaserConfig, partial: PartialLaserConfig) {
529 if let Some(color) = partial.color {
530 config.color = color.clone();
531 config.dot.color = color.clone();
532 config.crosshair.color = color.clone();
533 config.arrow.color = color.clone();
534 config.ring.color = color.clone();
535 config.bullseye.color = color.clone();
536 config.highlight.color = color;
537 }
538 if let Some(size) = partial.size {
539 config.size = size;
540 config.dot.size = size;
541 config.crosshair.size = size;
542 config.arrow.size = size;
543 config.ring.size = size;
544 config.bullseye.size = size;
545 config.highlight.size = size;
546 }
547 if let Some(style) = partial.style {
548 config.style = style;
549 }
550 if let Some(dot) = partial.dot {
551 apply_pointer_style_config(&mut config.dot, dot);
552 }
553 if let Some(crosshair) = partial.crosshair {
554 apply_pointer_style_config(&mut config.crosshair, crosshair);
555 }
556 if let Some(arrow) = partial.arrow {
557 apply_pointer_style_config(&mut config.arrow, arrow);
558 }
559 if let Some(ring) = partial.ring {
560 apply_pointer_style_config(&mut config.ring, ring);
561 }
562 if let Some(bullseye) = partial.bullseye {
563 apply_pointer_style_config(&mut config.bullseye, bullseye);
564 }
565 if let Some(highlight) = partial.highlight {
566 apply_pointer_style_config(&mut config.highlight, highlight);
567 }
568}
569
570fn apply_pointer_style_config(config: &mut PointerStyleConfig, partial: PartialPointerStyleConfig) {
571 if let Some(color) = partial.color {
572 config.color = color;
573 }
574 if let Some(size) = partial.size {
575 config.size = size;
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn partial_config_overrides_selected_fields() {
585 let mut config = Config::default();
586 let partial = PartialConfig {
587 display: Some(PartialDisplayConfig {
588 mode: Some("screen-share".to_string()),
589 single_monitor_view: Some("split".to_string()),
590 audience_monitor: Some("Projector".to_string()),
591 presenter_monitor: None,
592 }),
593 timer: Some(PartialTimerConfig {
594 mode: Some(TimerMode::Countdown),
595 duration_minutes: Some(OptionalU32Value::Value(45)),
596 warning_minutes: Some(OptionalU32Value::Value(10)),
597 overrun_color: Some(false),
598 }),
599 ..Default::default()
600 };
601
602 apply_partial_config(&mut config, partial);
603
604 assert_eq!(config.display.mode, "screen-share");
605 assert_eq!(config.display.single_monitor_view, "split");
606 assert_eq!(config.display.audience_monitor, "Projector");
607 assert_eq!(config.timer.mode, TimerMode::Countdown);
608 assert_eq!(config.timer.duration_minutes, Some(45));
609 assert_eq!(config.timer.warning_minutes, Some(10));
610 assert!(!config.timer.overrun_color);
611 }
612
613 #[test]
614 fn partial_config_overrides_text_box_defaults() {
615 let mut config = Config::default();
616 let partial = PartialConfig {
617 text_boxes: Some(PartialTextBoxConfig {
618 color: Some("#112233".to_string()),
619 background: Some("#445566AA".to_string()),
620 }),
621 ..Default::default()
622 };
623
624 apply_partial_config(&mut config, partial);
625
626 assert_eq!(config.text_boxes.color, "#112233");
627 assert_eq!(config.text_boxes.background, "#445566AA");
628 }
629
630 #[test]
631 fn partial_laser_defaults_apply_to_all_pointer_styles() {
632 let mut config = Config::default();
633 let partial = PartialConfig {
634 laser: Some(PartialLaserConfig {
635 color: Some("#FFFFFF".to_string()),
636 size: Some(20.0),
637 ..Default::default()
638 }),
639 ..Default::default()
640 };
641
642 apply_partial_config(&mut config, partial);
643
644 assert_eq!(config.laser.dot.color, "#FFFFFF");
645 assert_eq!(config.laser.crosshair.color, "#FFFFFF");
646 assert_eq!(config.laser.arrow.color, "#FFFFFF");
647 assert_eq!(config.laser.ring.color, "#FFFFFF");
648 assert_eq!(config.laser.bullseye.color, "#FFFFFF");
649 assert_eq!(config.laser.highlight.color, "#FFFFFF");
650 assert!((config.laser.dot.size - 20.0).abs() < f32::EPSILON);
651 assert!((config.laser.crosshair.size - 20.0).abs() < f32::EPSILON);
652 assert!((config.laser.arrow.size - 20.0).abs() < f32::EPSILON);
653 assert!((config.laser.ring.size - 20.0).abs() < f32::EPSILON);
654 assert!((config.laser.bullseye.size - 20.0).abs() < f32::EPSILON);
655 assert!((config.laser.highlight.size - 20.0).abs() < f32::EPSILON);
656 }
657
658 #[test]
659 fn partial_laser_pointer_style_overrides_defaults() {
660 let partial: PartialConfig = toml::from_str(
661 r##"
662 [laser]
663 color = "#FFFFFF"
664 size = 14.0
665 style = "crosshair"
666
667 [laser.crosshair]
668 color = "#00FF00"
669 size = 30.0
670
671 [laser.highlight]
672 color = "#FFFF0080"
673 "##,
674 )
675 .unwrap();
676 let mut config = Config::default();
677
678 apply_partial_config(&mut config, partial);
679
680 assert_eq!(config.laser.style, "crosshair");
681 assert_eq!(config.laser.dot.color, "#FFFFFF");
682 assert!((config.laser.dot.size - 14.0).abs() < f32::EPSILON);
683 assert_eq!(config.laser.crosshair.color, "#00FF00");
684 assert!((config.laser.crosshair.size - 30.0).abs() < f32::EPSILON);
685 assert_eq!(config.laser.arrow.color, "#FFFFFF");
686 assert!((config.laser.arrow.size - 14.0).abs() < f32::EPSILON);
687 assert_eq!(config.laser.ring.color, "#FFFFFF");
688 assert!((config.laser.ring.size - 14.0).abs() < f32::EPSILON);
689 assert_eq!(config.laser.bullseye.color, "#FFFFFF");
690 assert!((config.laser.bullseye.size - 14.0).abs() < f32::EPSILON);
691 assert_eq!(config.laser.highlight.color, "#FFFF0080");
692 assert!((config.laser.highlight.size - 14.0).abs() < f32::EPSILON);
693 }
694
695 #[test]
696 fn partial_config_can_clear_optional_timer_values() {
697 let mut config = Config::default();
698 config.timer.duration_minutes = Some(20);
699 config.timer.warning_minutes = Some(5);
700
701 let partial = PartialConfig {
702 timer: Some(PartialTimerConfig {
703 duration_minutes: Some(OptionalU32Value::Null(())),
704 warning_minutes: Some(OptionalU32Value::Null(())),
705 ..Default::default()
706 }),
707 ..Default::default()
708 };
709
710 apply_partial_config(&mut config, partial);
711
712 assert_eq!(config.timer.duration_minutes, None);
713 assert_eq!(config.timer.warning_minutes, None);
714 }
715}