Skip to main content

santui_core/
config.rs

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