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