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_source: String,
195    last_good: ThemeFile,
196}
197
198#[cfg(feature = "theme-watch")]
199impl ThemeWatcher {
200    /// Start watching the theme file at `path`, loading it once up front.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ThemeLoadError::Io`] if the initial read fails or the watch
205    /// cannot be registered, or [`ThemeLoadError::Parse`] if the initial file
206    /// is not valid TOML.
207    ///
208    /// # Example
209    ///
210    /// ```no_run
211    /// use slt::ThemeWatcher;
212    ///
213    /// let watcher = ThemeWatcher::new("theme.toml").unwrap();
214    /// ```
215    pub fn new(path: impl AsRef<std::path::Path>) -> Result<ThemeWatcher, ThemeLoadError> {
216        use notify::{RecursiveMode, Watcher};
217
218        let path = path.as_ref();
219        let path = if path.is_absolute() {
220            path.to_path_buf()
221        } else {
222            std::env::current_dir()?.join(path)
223        };
224        // Keep the last observed source as well as the parsed theme. Some
225        // backends emit an initial event when a watch is registered, and
226        // editors can emit several events for one save. Identical contents
227        // must not look like a hot reload to the application.
228        let last_source = std::fs::read_to_string(&path)?;
229        let last_good = ThemeFile::from_toml_str(&last_source)?;
230
231        // A filesystem burst only means "re-read the current file once". A
232        // capacity-one channel coalesces duplicate events and prevents a noisy
233        // parent directory from growing an unbounded notification queue while
234        // the UI is busy or polls infrequently.
235        let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
236        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
237            // Some backends report only the watched directory rather than the
238            // changed file. Forward every successful event and let poll()
239            // compare the source, which is both portable and preserves
240            // atomic-save support without surfacing sibling-file changes.
241            if res.is_ok() {
242                let _ = tx.try_send(());
243            }
244        })
245        .map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
246
247        // Watch the parent directory: editors often replace the file (rename)
248        // rather than writing in place, which a file-level watch can miss.
249        let watch_target = path.parent().filter(|p| !p.as_os_str().is_empty());
250        let (target, mode) = match watch_target {
251            Some(dir) => (dir, RecursiveMode::NonRecursive),
252            None => (path.as_path(), RecursiveMode::NonRecursive),
253        };
254        watcher
255            .watch(target, mode)
256            .map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
257
258        Ok(ThemeWatcher {
259            _watcher: watcher,
260            rx,
261            path,
262            last_source,
263            last_good,
264        })
265    }
266
267    /// The most recently parsed theme (the initial load, or the last good
268    /// hot-reload). Never returns a theme from a failed parse.
269    pub fn current(&self) -> &ThemeFile {
270        &self.last_good
271    }
272
273    /// Non-blocking poll for a hot-reloaded theme.
274    ///
275    /// Drains pending filesystem events; if any occurred, re-reads and parses
276    /// the watched file. Returns `Some(theme)` only when the file changed *and*
277    /// parsed cleanly. Returns `None` when nothing changed, or when the new
278    /// contents failed to parse — in which case the previous theme is retained
279    /// (accessible via [`ThemeWatcher::current`]) and a message is logged to
280    /// stderr.
281    ///
282    /// # Example
283    ///
284    /// ```no_run
285    /// use slt::ThemeWatcher;
286    ///
287    /// let mut watcher = ThemeWatcher::new("theme.toml").unwrap();
288    /// if let Some(tf) = watcher.poll() {
289    ///     println!("reloaded: {:?}", tf.theme.primary);
290    /// }
291    /// ```
292    // Intentional stderr diagnostic on a half-saved theme file: the hot-reload
293    // loop must surface why a reload was skipped without aborting the app.
294    #[allow(clippy::print_stderr)]
295    pub fn poll(&mut self) -> Option<ThemeFile> {
296        // Drain all buffered events; a burst of writes collapses to one reload.
297        let mut changed = false;
298        while self.rx.try_recv().is_ok() {
299            changed = true;
300        }
301        if !changed {
302            return None;
303        }
304
305        let source = match std::fs::read_to_string(&self.path) {
306            Ok(source) => source,
307            Err(e) => {
308                eprintln!(
309                    "slt: theme hot-reload skipped for {}: {}",
310                    self.path.display(),
311                    ThemeLoadError::Io(e)
312                );
313                return None;
314            }
315        };
316        if source == self.last_source {
317            return None;
318        }
319        self.last_source.clone_from(&source);
320
321        match ThemeFile::from_toml_str(&source) {
322            Ok(tf) => {
323                self.last_good = tf.clone();
324                Some(tf)
325            }
326            Err(e) => {
327                // Keep the last good theme; never panic on a half-saved file.
328                eprintln!(
329                    "slt: theme hot-reload skipped for {}: {e}",
330                    self.path.display()
331                );
332                None
333            }
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    #![allow(clippy::unwrap_used)]
341    use super::*;
342    use crate::Color;
343
344    fn all_presets() -> Vec<(&'static str, Theme)> {
345        vec![
346            ("dark", Theme::dark()),
347            ("light", Theme::light()),
348            ("dracula", Theme::dracula()),
349            ("catppuccin", Theme::catppuccin()),
350            ("nord", Theme::nord()),
351            ("solarized_dark", Theme::solarized_dark()),
352            ("solarized_light", Theme::solarized_light()),
353            ("tokyo_night", Theme::tokyo_night()),
354            ("gruvbox_dark", Theme::gruvbox_dark()),
355            ("one_dark", Theme::one_dark()),
356        ]
357    }
358
359    fn theme_eq(a: &Theme, b: &Theme) -> bool {
360        a.primary == b.primary
361            && a.secondary == b.secondary
362            && a.accent == b.accent
363            && a.text == b.text
364            && a.text_dim == b.text_dim
365            && a.border == b.border
366            && a.bg == b.bg
367            && a.success == b.success
368            && a.warning == b.warning
369            && a.error == b.error
370            && a.selected_bg == b.selected_bg
371            && a.selected_fg == b.selected_fg
372            && a.surface == b.surface
373            && a.surface_hover == b.surface_hover
374            && a.surface_text == b.surface_text
375            && a.is_dark == b.is_dark
376            && a.spacing == b.spacing
377    }
378
379    #[test]
380    fn parses_minimal_theme_doc() {
381        let toml = r##"
382            [theme]
383            primary = "#ff6b6b"
384            bg = "#1e1e2e"
385            is_dark = true
386        "##;
387        let tf = ThemeFile::from_toml_str(toml).unwrap();
388        assert_eq!(tf.theme.primary, Color::Rgb(255, 107, 107));
389        assert_eq!(tf.theme.bg, Color::Rgb(30, 30, 46));
390        assert!(tf.theme.is_dark);
391        // Unspecified fields fall back to dark() defaults.
392        assert_eq!(tf.theme.text, Theme::dark().text);
393        assert!(tf.widgets.is_none());
394    }
395
396    #[test]
397    fn named_and_indexed_colors_parse() {
398        let toml = r#"
399            [theme]
400            primary = "cyan"
401            text = "indexed:250"
402            bg = "reset"
403        "#;
404        let tf = ThemeFile::from_toml_str(toml).unwrap();
405        assert_eq!(tf.theme.primary, Color::Cyan);
406        assert_eq!(tf.theme.text, Color::Indexed(250));
407        assert_eq!(tf.theme.bg, Color::Reset);
408    }
409
410    #[test]
411    fn round_trips_every_preset() {
412        for (name, theme) in all_presets() {
413            let tf = ThemeFile {
414                theme,
415                widgets: None,
416            };
417            let serialized = tf.to_toml_string().unwrap();
418            let parsed = Theme::from_toml_str(&serialized).unwrap();
419            assert!(
420                theme_eq(&theme, &parsed),
421                "preset {name} did not round-trip: {theme:?} != {parsed:?}\nTOML:\n{serialized}"
422            );
423        }
424    }
425
426    #[test]
427    fn widgets_block_deserializes() {
428        let toml = r##"
429            [theme]
430            primary = "#ff0000"
431
432            [widgets.table]
433            fg = "#00ff00"
434            theme_bg = "Surface"
435        "##;
436        let tf = ThemeFile::from_toml_str(toml).unwrap();
437        let widgets = tf.widgets.expect("widgets block present");
438        assert_eq!(widgets.table.fg, Some(Color::Rgb(0, 255, 0)));
439        assert_eq!(widgets.table.theme_bg, Some(crate::ThemeColor::Surface));
440        // Unset slots default to empty WidgetColors.
441        assert_eq!(widgets.button.fg, None);
442    }
443
444    #[test]
445    fn malformed_toml_is_parse_error_not_panic() {
446        let err = ThemeFile::from_toml_str("this is = not [valid").unwrap_err();
447        assert!(matches!(err, ThemeLoadError::Parse(_)));
448    }
449
450    #[test]
451    fn bad_color_token_is_parse_error() {
452        let toml = r##"
453            [theme]
454            primary = "#zzzzzz"
455        "##;
456        let err = ThemeFile::from_toml_str(toml).unwrap_err();
457        assert!(matches!(err, ThemeLoadError::Parse(_)));
458    }
459
460    #[test]
461    fn from_hex_parses_short_and_long_forms() {
462        assert_eq!(Color::from_hex("#ff6b6b"), Some(Color::Rgb(255, 107, 107)));
463        assert_eq!(Color::from_hex("#abc"), Some(Color::Rgb(170, 187, 204)));
464        assert_eq!(Color::from_hex("#000"), Some(Color::Rgb(0, 0, 0)));
465        assert_eq!(Color::from_hex("#FFFFFF"), Some(Color::Rgb(255, 255, 255)));
466        assert_eq!(Color::from_hex("ffffff"), None);
467        assert_eq!(Color::from_hex("#xyz"), None);
468        assert_eq!(Color::from_hex("#ff"), None);
469        assert_eq!(Color::from_hex(""), None);
470    }
471
472    #[test]
473    fn from_hex_to_hex_round_trip() {
474        for r in [0u8, 1, 127, 200, 255] {
475            for g in [0u8, 64, 128, 255] {
476                for b in [0u8, 99, 255] {
477                    let c = Color::Rgb(r, g, b);
478                    assert_eq!(Color::from_hex(&c.to_hex()), Some(c));
479                }
480            }
481        }
482    }
483
484    #[test]
485    fn theme_load_ignores_widgets() {
486        let toml = r##"
487            [theme]
488            primary = "#abcdef"
489
490            [widgets.button]
491            fg = "#123456"
492        "##;
493        let theme = Theme::from_toml_str(toml).unwrap();
494        assert_eq!(theme.primary, Color::Rgb(0xab, 0xcd, 0xef));
495    }
496}
497
498#[cfg(all(test, feature = "crossterm"))]
499mod render_tests {
500    #![allow(clippy::unwrap_used)]
501    use super::*;
502    use crate::{ButtonVariant, Color, TestBackend};
503
504    #[test]
505    fn loaded_primary_paints_focused_button() {
506        let tf = ThemeFile::from_toml_str(
507            r##"
508            [theme]
509            primary = "#ff0000"
510            "##,
511        )
512        .unwrap();
513        let loaded_primary = tf.theme.primary;
514        assert_eq!(loaded_primary, Color::Rgb(255, 0, 0));
515
516        let mut tb = TestBackend::new(20, 5);
517        // Focus index 0 so the single button is focused; the Default variant
518        // paints `theme.primary` as the label foreground when focused.
519        tb.render_with_events(Vec::new(), 0, 1, move |ui| {
520            ui.set_theme(tf.theme);
521            let _ = ui.button_with("Go", ButtonVariant::Default);
522        });
523
524        // The widget rendered.
525        tb.assert_contains("Go");
526
527        // The loaded primary is the load-bearing change: it must appear as a
528        // foreground color on at least one painted cell of the focused button.
529        let buffer = tb.buffer();
530        let mut found_primary = false;
531        for y in 0..tb.height() {
532            for x in 0..tb.width() {
533                if buffer.get(x, y).style.fg == Some(loaded_primary) {
534                    found_primary = true;
535                }
536            }
537        }
538        assert!(
539            found_primary,
540            "expected loaded primary {loaded_primary:?} to paint at least one cell"
541        );
542    }
543}
544
545#[cfg(all(test, feature = "theme-watch"))]
546mod watch_tests {
547    #![allow(clippy::unwrap_used)]
548    use super::*;
549    use crate::Color;
550    use std::time::{Duration, Instant};
551
552    /// Spin on poll() until it returns a theme or the deadline elapses.
553    fn poll_until_change(watcher: &mut ThemeWatcher, timeout: Duration) -> Option<ThemeFile> {
554        let deadline = Instant::now() + timeout;
555        loop {
556            if let Some(tf) = watcher.poll() {
557                return Some(tf);
558            }
559            if Instant::now() >= deadline {
560                return None;
561            }
562            std::thread::sleep(Duration::from_millis(25));
563        }
564    }
565
566    fn temp_path(name: &str) -> std::path::PathBuf {
567        let mut dir = std::env::temp_dir();
568        let unique = format!(
569            "slt_theme_watch_{}_{}_{name}",
570            std::process::id(),
571            std::time::SystemTime::now()
572                .duration_since(std::time::UNIX_EPOCH)
573                .unwrap()
574                .as_nanos()
575        );
576        dir.push(unique);
577        std::fs::create_dir_all(&dir).unwrap();
578        dir.push(name);
579        dir
580    }
581
582    #[test]
583    fn watcher_reports_changes_and_survives_bad_toml() {
584        let path = temp_path("theme.toml");
585        std::fs::write(&path, "[theme]\nprimary = \"#0000ff\"\n").unwrap();
586
587        let mut watcher = ThemeWatcher::new(&path).unwrap();
588        assert_eq!(watcher.current().theme.primary, Color::Rgb(0, 0, 255));
589
590        // Registration events and same-content rewrites are not reloads.
591        assert!(watcher.poll().is_none());
592        std::fs::write(&path, "[theme]\nprimary = \"#0000ff\"\n").unwrap();
593        std::thread::sleep(Duration::from_millis(200));
594        assert!(watcher.poll().is_none());
595
596        // The parent directory is watched for atomic saves, but sibling files
597        // must not trigger a reload of the theme.
598        let sibling = path.with_file_name("unrelated.toml");
599        std::fs::write(&sibling, "unrelated = true\n").unwrap();
600        for i in 0..1_024 {
601            std::fs::write(&sibling, format!("unrelated = {i}\n")).unwrap();
602        }
603        std::thread::sleep(Duration::from_millis(200));
604        assert!(watcher.poll().is_none());
605
606        // Rewrite with a new primary; expect a reload.
607        std::fs::write(&path, "[theme]\nprimary = \"#ff0000\"\n").unwrap();
608        let reloaded = poll_until_change(&mut watcher, Duration::from_secs(5))
609            .expect("watcher should observe the rewrite");
610        assert_eq!(reloaded.theme.primary, Color::Rgb(255, 0, 0));
611        assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
612
613        // Write invalid TOML: poll() must not surface it and must keep last good.
614        std::fs::write(&path, "this = is [ not valid").unwrap();
615        // Give notify a moment, then drain — should never return Some.
616        std::thread::sleep(Duration::from_millis(200));
617        assert!(watcher.poll().is_none());
618        assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
619
620        let _ = std::fs::remove_dir_all(path.parent().unwrap());
621    }
622}