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    /// Throttle: only poll the filesystem every N frames.
92    poll_skip: u32,
93}
94
95impl ConfigManager {
96    /// Create a new manager, immediately loading the config from `dir`.
97    pub fn new(dir: PathBuf) -> Self {
98        let last_modified = dir
99            .join("config.toml")
100            .metadata()
101            .ok()
102            .and_then(|m| m.modified().ok());
103        let (config, error) = match Config::try_load_from(&dir) {
104            Ok(cfg) => (cfg, None),
105            Err(e) => (Config::default(), Some(e)),
106        };
107        ConfigManager {
108            dir,
109            config,
110            last_modified,
111            dirty: false,
112            error,
113            tick_rate: Duration::from_millis(100),
114            poll_skip: 0,
115        }
116    }
117
118    /// Re-read config from disk.  Call this once per frame.
119    pub fn poll(&mut self) {
120        self.poll_skip = self.poll_skip.saturating_sub(1);
121        if self.poll_skip > 0 {
122            return;
123        }
124        self.poll_skip = 30;
125        let path = self.dir.join("config.toml");
126        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
127            Some(t) => t,
128            None => return,
129        };
130        let changed = match self.last_modified {
131            Some(last) => modified != last,
132            None => true,
133        };
134        if !changed {
135            return;
136        }
137        self.last_modified = Some(modified);
138        match Config::try_load_from(&self.dir) {
139            Ok(cfg) => {
140                self.config = cfg;
141                self.error = None;
142                self.dirty = true;
143            }
144            Err(e) => {
145                self.error = Some(e);
146            }
147        }
148    }
149
150    /// Acknowledge the dirty flag (call after applying changes).
151    pub fn ack(&mut self) {
152        self.dirty = false;
153    }
154
155    pub fn config(&self) -> &Config {
156        &self.config
157    }
158
159    /// Error message from the last failed config load/parse, if any.
160    pub fn error(&self) -> Option<&str> {
161        self.error.as_deref()
162    }
163
164    /// Update the `theme` field and immediately persist.
165    /// When selecting a built-in theme, custom overrides are cleared so they
166    /// don't leak into the newly chosen theme.
167    pub fn save_theme(&mut self, theme_name: &str) {
168        self.config.theme = Some(theme_name.to_string());
169        self.config.custom_theme = None;
170        self.persist();
171    }
172
173    /// Set custom theme colour overrides in config and persist.
174    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
175        self.config.custom_theme = Some(colors);
176        self.persist();
177    }
178
179    pub fn tick_rate(&self) -> Duration {
180        self.tick_rate
181    }
182
183    pub fn set_tick_rate(&mut self, duration: Duration) {
184        self.tick_rate = duration;
185    }
186
187    /// Remove custom theme colour overrides from config and persist.
188    pub fn clear_custom_theme(&mut self) {
189        if self.config.custom_theme.is_some() {
190            self.config.custom_theme = None;
191            self.persist();
192        }
193    }
194
195    /// Write the in-memory config to disk and sync the modification timestamp
196    /// so the next `poll()` doesn't re-detect our own write.
197    fn persist(&mut self) {
198        if let Err(e) = self.config.save_to(&self.dir) {
199            log::error!("[santui] Failed to save config: {e}");
200            return;
201        }
202        self.last_modified = self
203            .dir
204            .join("config.toml")
205            .metadata()
206            .ok()
207            .and_then(|m| m.modified().ok());
208    }
209}