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", "crosshair", "arrow", "ring", "bullseye", or "highlight".
120    pub style: String,
121    /// Dot pointer appearance.
122    pub dot: PointerStyleConfig,
123    /// Crosshair pointer appearance.
124    pub crosshair: PointerStyleConfig,
125    /// Arrow pointer appearance.
126    pub arrow: PointerStyleConfig,
127    /// Ring pointer appearance.
128    pub ring: PointerStyleConfig,
129    /// Bullseye pointer appearance.
130    pub bullseye: PointerStyleConfig,
131    /// Highlight pointer appearance.
132    pub highlight: PointerStyleConfig,
133}
134
135/// Appearance configuration for one laser pointer style.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(default)]
138pub struct PointerStyleConfig {
139    /// Hex color string (e.g., "#FF0000" or "#FF000080").
140    pub color: String,
141    /// Size in logical pixels at 1x scale.
142    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/// Spotlight configuration.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default)]
169pub struct SpotlightConfig {
170    /// Radius in logical pixels at 1x scale.
171    pub radius: f32,
172    /// Opacity of the dimmed area (0.0–1.0).
173    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/// Ink drawing configuration.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(default)]
186pub struct InkConfig {
187    /// Pen color presets as hex strings (RGBA or RGB). `CycleInkColor` steps through these.
188    /// Accepts a single string (`color = "#FF0000"`) or an array (`colors = ["#FF0000", "#0000FF"]`).
189    pub colors: Vec<String>,
190    /// Stroke width in logical pixels.
191    pub width: f32,
192}
193
194#[derive(Debug, Clone, Default, Deserialize)]
195#[serde(default)]
196struct PartialInkConfig {
197    /// New array form: `colors = ["#FF0000", "#0000FF"]`.
198    colors: Option<Vec<String>>,
199    /// Legacy single-color form: `color = "#FF0000"`. Ignored if `colors` is also set.
200    color: Option<String>,
201    width: Option<f32>,
202}
203
204/// Default style for newly created text boxes.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(default)]
207pub struct TextBoxConfig {
208    /// Text color as a hex string (RGB or RGBA).
209    pub color: String,
210    /// Background fill as a hex string (RGB or RGBA), or `"transparent"`.
211    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/// Clicker/remote hardware configuration.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224pub struct ClickerConfig {
225    /// Name of the active clicker profile (e.g., "default", "logitech-spotlight").
226    pub profile: String,
227    /// Custom profile definitions mapping key names to action names.
228    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/// Notes panel configuration.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct NotesConfig {
242    /// Font size in points.
243    pub font_size: f32,
244    /// Step size for font size increment/decrement.
245    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
341/// Return the built-in default clicker profile mapping common USB presenter keys to actions.
342pub 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    /// Resolve the active clicker profile into a key -> action map.
354    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    /// Normalize the configured sidecar save format to a supported value.
369    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
380/// Resolve the platform-appropriate config file path.
381pub fn config_path() -> Option<PathBuf> {
382    directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
383}
384
385/// Resolve a project-local config path for a PDF.
386pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
387    pdf_path.parent().map(|dir| dir.join("dais.toml"))
388}
389
390/// Load layered config for a document.
391///
392/// Precedence:
393/// 1. Built-in defaults
394/// 2. Machine-wide config (`config.toml` in the standard OS config dir)
395/// 3. Project-local config (`dais.toml` next to the PDF)
396/// 4. Explicit `--config` path, if provided
397///
398/// Missing or invalid config files are logged and ignored; this function always
399/// returns a usable configuration by falling back to defaults.
400pub 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}