Skip to main content

slt/style/
theme_io.rs

1//! External TOML theme files and (optionally) a filesystem hot-reload watcher.
2//!
3//! Gated behind the `serde` feature; the [`ThemeWatcher`] additionally requires
4//! the `theme-watch` feature (which pulls in `notify`). Neither `toml` nor
5//! `notify` is compiled into the default or `wasm32` builds.
6//!
7//! The format is a single TOML document with a `[theme]` table and an optional
8//! `[widgets]` table:
9//!
10//! ```toml
11//! [theme]
12//! primary = "#ff6b6b"
13//! accent  = "cyan"
14//! bg      = "#1e1e2e"
15//! text    = "indexed:250"
16//! is_dark = true
17//!
18//! [widgets.button]
19//! fg = "#ffffff"
20//! ```
21
22use super::Theme;
23use crate::WidgetTheme;
24
25/// Error returned when loading a theme from a file or string fails.
26///
27/// Carries either the underlying I/O failure or a human-readable parse
28/// message. Never panics on malformed input — callers decide how to recover.
29#[non_exhaustive]
30#[derive(Debug)]
31pub enum ThemeLoadError {
32    /// The theme file could not be read from disk.
33    Io(std::io::Error),
34    /// The document was read but is not valid TOML, or did not match the
35    /// expected [`ThemeFile`] shape. The string carries the parser's message.
36    Parse(String),
37}
38
39impl std::fmt::Display for ThemeLoadError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            ThemeLoadError::Io(e) => write!(f, "failed to read theme file: {e}"),
43            ThemeLoadError::Parse(msg) => write!(f, "failed to parse theme TOML: {msg}"),
44        }
45    }
46}
47
48impl core::error::Error for ThemeLoadError {
49    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
50        match self {
51            ThemeLoadError::Io(e) => Some(e),
52            ThemeLoadError::Parse(_) => None,
53        }
54    }
55}
56
57impl From<std::io::Error> for ThemeLoadError {
58    fn from(e: std::io::Error) -> Self {
59        ThemeLoadError::Io(e)
60    }
61}
62
63/// A parsed theme document: a base [`Theme`] plus optional [`WidgetTheme`] slots.
64///
65/// Use [`ThemeFile::from_toml_str`] / [`ThemeFile::load`] to construct one, then
66/// feed `theme` into [`crate::Context::set_theme`] and `widgets` into
67/// [`crate::RunConfig::widget_theme`].
68///
69/// # Example
70///
71/// ```no_run
72/// use slt::ThemeFile;
73///
74/// let tf = ThemeFile::from_toml_str(r##"
75/// [theme]
76/// primary = "#ff0000"
77///
78/// [widgets.button]
79/// fg = "#ffffff"
80/// "##).unwrap();
81/// assert_eq!(tf.theme.primary, slt::Color::Rgb(255, 0, 0));
82/// assert!(tf.widgets.is_some());
83/// ```
84#[derive(Debug, Clone)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct ThemeFile {
87    /// The base theme (the `[theme]` table). Missing fields fall back to
88    /// [`Theme::dark()`].
89    #[cfg_attr(feature = "serde", serde(default))]
90    pub theme: Theme,
91    /// Optional per-widget color overrides (the `[widgets]` table).
92    #[cfg_attr(feature = "serde", serde(default))]
93    pub widgets: Option<WidgetTheme>,
94}
95
96impl ThemeFile {
97    /// Parse a [`ThemeFile`] from a TOML string.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ThemeLoadError::Parse`] for malformed TOML or a shape that
102    /// does not match the expected `[theme]` / `[widgets]` layout.
103    ///
104    /// # Example
105    ///
106    /// ```no_run
107    /// use slt::ThemeFile;
108    ///
109    /// let tf = ThemeFile::from_toml_str("[theme]\nprimary = \"#00ff00\"\n").unwrap();
110    /// assert_eq!(tf.theme.primary, slt::Color::Rgb(0, 255, 0));
111    /// ```
112    pub fn from_toml_str(src: &str) -> Result<ThemeFile, ThemeLoadError> {
113        toml::from_str(src).map_err(|e| ThemeLoadError::Parse(e.to_string()))
114    }
115
116    /// Serialize this [`ThemeFile`] back to a TOML string.
117    ///
118    /// The output round-trips through [`ThemeFile::from_toml_str`]. Colors are
119    /// emitted as human-friendly tokens (`#rrggbb`, named, or `indexed:N`).
120    ///
121    /// # Errors
122    ///
123    /// Returns [`ThemeLoadError::Parse`] if serialization fails (e.g. a value
124    /// that TOML cannot represent).
125    ///
126    /// # Example
127    ///
128    /// ```no_run
129    /// use slt::{Theme, ThemeFile};
130    ///
131    /// let tf = ThemeFile { theme: Theme::dracula(), widgets: None };
132    /// let toml = tf.to_toml_string().unwrap();
133    /// assert!(toml.contains("[theme]"));
134    /// ```
135    pub fn to_toml_string(&self) -> Result<String, ThemeLoadError> {
136        toml::to_string(self).map_err(|e| ThemeLoadError::Parse(e.to_string()))
137    }
138
139    /// Read and parse a [`ThemeFile`] from a TOML file at `path`.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`ThemeLoadError::Io`] if the file cannot be read, or
144    /// [`ThemeLoadError::Parse`] if its contents are not valid TOML.
145    ///
146    /// # Example
147    ///
148    /// ```no_run
149    /// use slt::ThemeFile;
150    ///
151    /// let tf = ThemeFile::load("theme.toml").unwrap();
152    /// println!("primary = {:?}", tf.theme.primary);
153    /// ```
154    pub fn load(path: impl AsRef<std::path::Path>) -> Result<ThemeFile, ThemeLoadError> {
155        let src = std::fs::read_to_string(path)?;
156        Self::from_toml_str(&src)
157    }
158}
159
160/// A non-blocking filesystem watcher that hot-reloads a TOML theme file.
161///
162/// Requires the `theme-watch` feature. The watcher runs `notify`'s own
163/// background thread and buffers change events on a channel; [`poll`] drains
164/// the channel, re-reads the file, and returns the freshly parsed
165/// [`ThemeFile`]. On a parse error it logs context to stderr and keeps the last
166/// good theme, so a half-saved edit never breaks the running app.
167///
168/// Designed for SLT's immediate-mode loop: call [`poll`] once per frame and
169/// apply the result via [`crate::Context::set_theme`].
170///
171/// [`poll`]: ThemeWatcher::poll
172///
173/// # Example
174///
175/// ```no_run
176/// use slt::ThemeWatcher;
177///
178/// let mut watcher = ThemeWatcher::new("theme.toml").unwrap();
179/// slt::run(move |ui| {
180///     if let Some(tf) = watcher.poll() {
181///         ui.set_theme(tf.theme);
182///     }
183///     ui.button("Themed");
184/// })
185/// .unwrap();
186/// ```
187#[cfg(feature = "theme-watch")]
188#[cfg_attr(docsrs, doc(cfg(feature = "theme-watch")))]
189pub struct ThemeWatcher {
190    // Held to keep the watch alive; dropping it stops the background thread.
191    _watcher: notify::RecommendedWatcher,
192    rx: std::sync::mpsc::Receiver<()>,
193    path: std::path::PathBuf,
194    last_good: ThemeFile,
195}
196
197#[cfg(feature = "theme-watch")]
198impl ThemeWatcher {
199    /// Start watching the theme file at `path`, loading it once up front.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`ThemeLoadError::Io`] if the initial read fails or the watch
204    /// cannot be registered, or [`ThemeLoadError::Parse`] if the initial file
205    /// is not valid TOML.
206    ///
207    /// # Example
208    ///
209    /// ```no_run
210    /// use slt::ThemeWatcher;
211    ///
212    /// let watcher = ThemeWatcher::new("theme.toml").unwrap();
213    /// ```
214    pub fn new(path: impl AsRef<std::path::Path>) -> Result<ThemeWatcher, ThemeLoadError> {
215        use notify::{RecursiveMode, Watcher};
216
217        let path = path.as_ref().to_path_buf();
218        // Load the initial theme so `last_good` is always valid.
219        let last_good = ThemeFile::load(&path)?;
220
221        let (tx, rx) = std::sync::mpsc::channel::<()>();
222        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
223            // Coalesce every event to a single "something changed" ping; the
224            // poll() side re-reads the file, so the event kind is irrelevant.
225            if res.is_ok() {
226                let _ = tx.send(());
227            }
228        })
229        .map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
230
231        // Watch the parent directory: editors often replace the file (rename)
232        // rather than writing in place, which a file-level watch can miss.
233        let watch_target = path.parent().filter(|p| !p.as_os_str().is_empty());
234        let (target, mode) = match watch_target {
235            Some(dir) => (dir, RecursiveMode::NonRecursive),
236            None => (path.as_path(), RecursiveMode::NonRecursive),
237        };
238        watcher
239            .watch(target, mode)
240            .map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
241
242        Ok(ThemeWatcher {
243            _watcher: watcher,
244            rx,
245            path,
246            last_good,
247        })
248    }
249
250    /// The most recently parsed theme (the initial load, or the last good
251    /// hot-reload). Never returns a theme from a failed parse.
252    pub fn current(&self) -> &ThemeFile {
253        &self.last_good
254    }
255
256    /// Non-blocking poll for a hot-reloaded theme.
257    ///
258    /// Drains pending filesystem events; if any occurred, re-reads and parses
259    /// the watched file. Returns `Some(theme)` only when the file changed *and*
260    /// parsed cleanly. Returns `None` when nothing changed, or when the new
261    /// contents failed to parse — in which case the previous theme is retained
262    /// (accessible via [`ThemeWatcher::current`]) and a message is logged to
263    /// stderr.
264    ///
265    /// # Example
266    ///
267    /// ```no_run
268    /// use slt::ThemeWatcher;
269    ///
270    /// let mut watcher = ThemeWatcher::new("theme.toml").unwrap();
271    /// if let Some(tf) = watcher.poll() {
272    ///     println!("reloaded: {:?}", tf.theme.primary);
273    /// }
274    /// ```
275    // Intentional stderr diagnostic on a half-saved theme file: the hot-reload
276    // loop must surface why a reload was skipped without aborting the app.
277    #[allow(clippy::print_stderr)]
278    pub fn poll(&mut self) -> Option<ThemeFile> {
279        // Drain all buffered events; a burst of writes collapses to one reload.
280        let mut changed = false;
281        while self.rx.try_recv().is_ok() {
282            changed = true;
283        }
284        if !changed {
285            return None;
286        }
287
288        match ThemeFile::load(&self.path) {
289            Ok(tf) => {
290                self.last_good = tf.clone();
291                Some(tf)
292            }
293            Err(e) => {
294                // Keep the last good theme; never panic on a half-saved file.
295                eprintln!(
296                    "slt: theme hot-reload skipped for {}: {e}",
297                    self.path.display()
298                );
299                None
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    #![allow(clippy::unwrap_used)]
308    use super::*;
309    use crate::Color;
310
311    fn all_presets() -> Vec<(&'static str, Theme)> {
312        vec![
313            ("dark", Theme::dark()),
314            ("light", Theme::light()),
315            ("dracula", Theme::dracula()),
316            ("catppuccin", Theme::catppuccin()),
317            ("nord", Theme::nord()),
318            ("solarized_dark", Theme::solarized_dark()),
319            ("solarized_light", Theme::solarized_light()),
320            ("tokyo_night", Theme::tokyo_night()),
321            ("gruvbox_dark", Theme::gruvbox_dark()),
322            ("one_dark", Theme::one_dark()),
323        ]
324    }
325
326    fn theme_eq(a: &Theme, b: &Theme) -> bool {
327        a.primary == b.primary
328            && a.secondary == b.secondary
329            && a.accent == b.accent
330            && a.text == b.text
331            && a.text_dim == b.text_dim
332            && a.border == b.border
333            && a.bg == b.bg
334            && a.success == b.success
335            && a.warning == b.warning
336            && a.error == b.error
337            && a.selected_bg == b.selected_bg
338            && a.selected_fg == b.selected_fg
339            && a.surface == b.surface
340            && a.surface_hover == b.surface_hover
341            && a.surface_text == b.surface_text
342            && a.is_dark == b.is_dark
343            && a.spacing == b.spacing
344    }
345
346    #[test]
347    fn parses_minimal_theme_doc() {
348        let toml = r##"
349            [theme]
350            primary = "#ff6b6b"
351            bg = "#1e1e2e"
352            is_dark = true
353        "##;
354        let tf = ThemeFile::from_toml_str(toml).unwrap();
355        assert_eq!(tf.theme.primary, Color::Rgb(255, 107, 107));
356        assert_eq!(tf.theme.bg, Color::Rgb(30, 30, 46));
357        assert!(tf.theme.is_dark);
358        // Unspecified fields fall back to dark() defaults.
359        assert_eq!(tf.theme.text, Theme::dark().text);
360        assert!(tf.widgets.is_none());
361    }
362
363    #[test]
364    fn named_and_indexed_colors_parse() {
365        let toml = r#"
366            [theme]
367            primary = "cyan"
368            text = "indexed:250"
369            bg = "reset"
370        "#;
371        let tf = ThemeFile::from_toml_str(toml).unwrap();
372        assert_eq!(tf.theme.primary, Color::Cyan);
373        assert_eq!(tf.theme.text, Color::Indexed(250));
374        assert_eq!(tf.theme.bg, Color::Reset);
375    }
376
377    #[test]
378    fn round_trips_every_preset() {
379        for (name, theme) in all_presets() {
380            let tf = ThemeFile {
381                theme,
382                widgets: None,
383            };
384            let serialized = tf.to_toml_string().unwrap();
385            let parsed = Theme::from_toml_str(&serialized).unwrap();
386            assert!(
387                theme_eq(&theme, &parsed),
388                "preset {name} did not round-trip: {theme:?} != {parsed:?}\nTOML:\n{serialized}"
389            );
390        }
391    }
392
393    #[test]
394    fn widgets_block_deserializes() {
395        let toml = r##"
396            [theme]
397            primary = "#ff0000"
398
399            [widgets.table]
400            fg = "#00ff00"
401            theme_bg = "Surface"
402        "##;
403        let tf = ThemeFile::from_toml_str(toml).unwrap();
404        let widgets = tf.widgets.expect("widgets block present");
405        assert_eq!(widgets.table.fg, Some(Color::Rgb(0, 255, 0)));
406        assert_eq!(widgets.table.theme_bg, Some(crate::ThemeColor::Surface));
407        // Unset slots default to empty WidgetColors.
408        assert_eq!(widgets.button.fg, None);
409    }
410
411    #[test]
412    fn malformed_toml_is_parse_error_not_panic() {
413        let err = ThemeFile::from_toml_str("this is = not [valid").unwrap_err();
414        assert!(matches!(err, ThemeLoadError::Parse(_)));
415    }
416
417    #[test]
418    fn bad_color_token_is_parse_error() {
419        let toml = r##"
420            [theme]
421            primary = "#zzzzzz"
422        "##;
423        let err = ThemeFile::from_toml_str(toml).unwrap_err();
424        assert!(matches!(err, ThemeLoadError::Parse(_)));
425    }
426
427    #[test]
428    fn from_hex_parses_short_and_long_forms() {
429        assert_eq!(Color::from_hex("#ff6b6b"), Some(Color::Rgb(255, 107, 107)));
430        assert_eq!(Color::from_hex("#abc"), Some(Color::Rgb(170, 187, 204)));
431        assert_eq!(Color::from_hex("#000"), Some(Color::Rgb(0, 0, 0)));
432        assert_eq!(Color::from_hex("#FFFFFF"), Some(Color::Rgb(255, 255, 255)));
433        assert_eq!(Color::from_hex("ffffff"), None);
434        assert_eq!(Color::from_hex("#xyz"), None);
435        assert_eq!(Color::from_hex("#ff"), None);
436        assert_eq!(Color::from_hex(""), None);
437    }
438
439    #[test]
440    fn from_hex_to_hex_round_trip() {
441        for r in [0u8, 1, 127, 200, 255] {
442            for g in [0u8, 64, 128, 255] {
443                for b in [0u8, 99, 255] {
444                    let c = Color::Rgb(r, g, b);
445                    assert_eq!(Color::from_hex(&c.to_hex()), Some(c));
446                }
447            }
448        }
449    }
450
451    #[test]
452    fn theme_load_ignores_widgets() {
453        let toml = r##"
454            [theme]
455            primary = "#abcdef"
456
457            [widgets.button]
458            fg = "#123456"
459        "##;
460        let theme = Theme::from_toml_str(toml).unwrap();
461        assert_eq!(theme.primary, Color::Rgb(0xab, 0xcd, 0xef));
462    }
463}
464
465#[cfg(all(test, feature = "crossterm"))]
466mod render_tests {
467    #![allow(clippy::unwrap_used)]
468    use super::*;
469    use crate::{ButtonVariant, Color, TestBackend};
470
471    #[test]
472    fn loaded_primary_paints_focused_button() {
473        let tf = ThemeFile::from_toml_str(
474            r##"
475            [theme]
476            primary = "#ff0000"
477            "##,
478        )
479        .unwrap();
480        let loaded_primary = tf.theme.primary;
481        assert_eq!(loaded_primary, Color::Rgb(255, 0, 0));
482
483        let mut tb = TestBackend::new(20, 5);
484        // Focus index 0 so the single button is focused; the Default variant
485        // paints `theme.primary` as the label foreground when focused.
486        tb.render_with_events(Vec::new(), 0, 1, move |ui| {
487            ui.set_theme(tf.theme);
488            let _ = ui.button_with("Go", ButtonVariant::Default);
489        });
490
491        // The widget rendered.
492        tb.assert_contains("Go");
493
494        // The loaded primary is the load-bearing change: it must appear as a
495        // foreground color on at least one painted cell of the focused button.
496        let buffer = tb.buffer();
497        let mut found_primary = false;
498        for y in 0..tb.height() {
499            for x in 0..tb.width() {
500                if buffer.get(x, y).style.fg == Some(loaded_primary) {
501                    found_primary = true;
502                }
503            }
504        }
505        assert!(
506            found_primary,
507            "expected loaded primary {loaded_primary:?} to paint at least one cell"
508        );
509    }
510}
511
512#[cfg(all(test, feature = "theme-watch"))]
513mod watch_tests {
514    #![allow(clippy::unwrap_used)]
515    use super::*;
516    use crate::Color;
517    use std::time::{Duration, Instant};
518
519    /// Spin on poll() until it returns a theme or the deadline elapses.
520    fn poll_until_change(watcher: &mut ThemeWatcher, timeout: Duration) -> Option<ThemeFile> {
521        let deadline = Instant::now() + timeout;
522        loop {
523            if let Some(tf) = watcher.poll() {
524                return Some(tf);
525            }
526            if Instant::now() >= deadline {
527                return None;
528            }
529            std::thread::sleep(Duration::from_millis(25));
530        }
531    }
532
533    fn temp_path(name: &str) -> std::path::PathBuf {
534        let mut dir = std::env::temp_dir();
535        let unique = format!(
536            "slt_theme_watch_{}_{}_{name}",
537            std::process::id(),
538            std::time::SystemTime::now()
539                .duration_since(std::time::UNIX_EPOCH)
540                .unwrap()
541                .as_nanos()
542        );
543        dir.push(unique);
544        dir
545    }
546
547    #[test]
548    fn watcher_reports_changes_and_survives_bad_toml() {
549        let path = temp_path("theme.toml");
550        std::fs::write(&path, "[theme]\nprimary = \"#0000ff\"\n").unwrap();
551
552        let mut watcher = ThemeWatcher::new(&path).unwrap();
553        assert_eq!(watcher.current().theme.primary, Color::Rgb(0, 0, 255));
554
555        // No edits yet: poll() is None.
556        assert!(watcher.poll().is_none());
557
558        // Rewrite with a new primary; expect a reload.
559        std::fs::write(&path, "[theme]\nprimary = \"#ff0000\"\n").unwrap();
560        let reloaded = poll_until_change(&mut watcher, Duration::from_secs(5))
561            .expect("watcher should observe the rewrite");
562        assert_eq!(reloaded.theme.primary, Color::Rgb(255, 0, 0));
563        assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
564
565        // Write invalid TOML: poll() must not surface it and must keep last good.
566        std::fs::write(&path, "this = is [ not valid").unwrap();
567        // Give notify a moment, then drain — should never return Some.
568        std::thread::sleep(Duration::from_millis(200));
569        assert!(watcher.poll().is_none());
570        assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
571
572        let _ = std::fs::remove_file(&path);
573    }
574}