Skip to main content

santui_core/
config.rs

1use std::path::PathBuf;
2use std::time::{Duration, SystemTime};
3
4/// Top-level Santui configuration, deserialized from `config.toml`.
5#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
6pub struct Config {
7    /// Default theme name (must match a built-in theme or a custom theme name).
8    pub theme: Option<String>,
9    /// Custom theme color overrides.
10    pub custom_theme: Option<CustomThemeColors>,
11    /// Key-binding overrides (reserved — schema defined for future use).
12    #[serde(default)]
13    pub keybindings: Option<KeyBindings>,
14    /// Plugin-specific settings (reserved — schema defined for future use).
15    #[serde(default)]
16    pub plugins: Option<PluginConfig>,
17}
18
19/// Per-color-field overrides for a custom theme.
20///
21/// Each field is an optional hex colour string like `"#ff8800"` or `"ff8800"`.
22#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
23pub struct CustomThemeColors {
24    pub name: Option<String>,
25    pub accent: Option<String>,
26    pub highlight: Option<String>,
27    pub logo: Option<String>,
28    pub text: Option<String>,
29    pub text_muted: Option<String>,
30    pub background: Option<String>,
31    pub background_panel: Option<String>,
32    pub background_overlay: Option<String>,
33    pub border: Option<String>,
34    pub success: Option<String>,
35    pub error: Option<String>,
36    pub inverted_text: Option<String>,
37}
38
39/// Key-binding overrides (reserved for future use).
40#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
41pub struct KeyBindings {}
42
43/// Plugin-specific configuration (reserved for future use).
44#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct PluginConfig {}
46
47impl Config {
48    /// Load `config.toml` from `dir` or return a default config if the file
49    /// doesn't exist.
50    pub fn load_from(dir: &std::path::Path) -> Self {
51        Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
52    }
53
54    /// Like `load_from`, but returns an error message instead of silently
55    /// falling back to defaults.
56    pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
57        let path = dir.join("config.toml");
58        if !path.exists() {
59            return Err("config.toml not found".into());
60        }
61        let content = std::fs::read_to_string(&path)
62            .map_err(|e| format!("Failed to read config.toml: {e}"))?;
63        toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
64    }
65
66    /// Write the config to `dir/config.toml`.
67    pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
68        let path = dir.join("config.toml");
69        let content = toml::to_string_pretty(self)?;
70        std::fs::write(&path, content)?;
71        Ok(())
72    }
73}
74
75/// Watches `config.toml` for changes via periodic timestamp polling.
76///
77/// Call [`ConfigManager::poll`] once per frame in the main loop.  When the
78/// file has been modified externally `dirty` is set to `true` and the new
79/// config is available via [`ConfigManager::config`].
80#[derive(Debug)]
81pub struct ConfigManager {
82    dir: PathBuf,
83    config: Config,
84    last_modified: Option<SystemTime>,
85    /// Set to `true` by [`poll`](ConfigManager::poll) when the file changed.
86    pub dirty: bool,
87    /// Error message from the last load/parse attempt, cleared on ack.
88    error: Option<String>,
89    /// Main loop tick rate (how often the UI refreshes and polls for input).
90    tick_rate: Duration,
91}
92
93impl ConfigManager {
94    /// Create a new manager, immediately loading the config from `dir`.
95    pub fn new(dir: PathBuf) -> Self {
96        let last_modified = dir
97            .join("config.toml")
98            .metadata()
99            .ok()
100            .and_then(|m| m.modified().ok());
101        let (config, error) = match Config::try_load_from(&dir) {
102            Ok(cfg) => (cfg, None),
103            Err(e) => (Config::default(), Some(e)),
104        };
105        ConfigManager {
106            dir,
107            config,
108            last_modified,
109            dirty: false,
110            error,
111            tick_rate: Duration::from_millis(100),
112        }
113    }
114
115    /// Re-read config from disk.  Call this once per frame.
116    pub fn poll(&mut self) {
117        let path = self.dir.join("config.toml");
118        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
119            Some(t) => t,
120            None => return,
121        };
122        let changed = match self.last_modified {
123            Some(last) => modified != last,
124            None => true,
125        };
126        if !changed {
127            return;
128        }
129        self.last_modified = Some(modified);
130        match Config::try_load_from(&self.dir) {
131            Ok(cfg) => {
132                self.config = cfg;
133                self.error = None;
134            }
135            Err(e) => {
136                self.error = Some(e);
137            }
138        }
139        self.dirty = true;
140    }
141
142    /// Acknowledge the dirty flag (call after applying changes).
143    pub fn ack(&mut self) {
144        self.dirty = false;
145    }
146
147    pub fn config(&self) -> &Config {
148        &self.config
149    }
150
151    /// Error message from the last failed config load/parse, if any.
152    pub fn error(&self) -> Option<&str> {
153        self.error.as_deref()
154    }
155
156    /// Update the `theme` field and immediately persist.
157    /// When selecting a built-in theme, custom overrides are cleared so they
158    /// don't leak into the newly chosen theme.
159    pub fn save_theme(&mut self, theme_name: &str) {
160        self.config.theme = Some(theme_name.to_string());
161        self.config.custom_theme = None;
162        self.persist();
163    }
164
165    /// Set custom theme colour overrides in config and persist.
166    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
167        self.config.custom_theme = Some(colors);
168        self.persist();
169    }
170
171    pub fn tick_rate(&self) -> Duration {
172        self.tick_rate
173    }
174
175    pub fn set_tick_rate(&mut self, duration: Duration) {
176        self.tick_rate = duration;
177    }
178
179    /// Remove custom theme colour overrides from config and persist.
180    pub fn clear_custom_theme(&mut self) {
181        if self.config.custom_theme.is_some() {
182            self.config.custom_theme = None;
183            self.persist();
184        }
185    }
186
187    /// Write the in-memory config to disk and sync the modification timestamp
188    /// so the next `poll()` doesn't re-detect our own write.
189    fn persist(&mut self) {
190        if let Err(e) = self.config.save_to(&self.dir) {
191            log::error!("[santui] Failed to save config: {e}");
192            return;
193        }
194        self.last_modified = self
195            .dir
196            .join("config.toml")
197            .metadata()
198            .ok()
199            .and_then(|m| m.modified().ok());
200    }
201}