1use super::Theme;
23use crate::WidgetTheme;
24
25#[non_exhaustive]
30#[derive(Debug)]
31pub enum ThemeLoadError {
32 Io(std::io::Error),
34 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#[derive(Debug, Clone)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct ThemeFile {
87 #[cfg_attr(feature = "serde", serde(default))]
90 pub theme: Theme,
91 #[cfg_attr(feature = "serde", serde(default))]
93 pub widgets: Option<WidgetTheme>,
94}
95
96impl ThemeFile {
97 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 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 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#[cfg(feature = "theme-watch")]
188#[cfg_attr(docsrs, doc(cfg(feature = "theme-watch")))]
189pub struct ThemeWatcher {
190 _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 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 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 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 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 pub fn current(&self) -> &ThemeFile {
253 &self.last_good
254 }
255
256 #[allow(clippy::print_stderr)]
278 pub fn poll(&mut self) -> Option<ThemeFile> {
279 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 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 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 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 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 tb.assert_contains("Go");
493
494 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 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 assert!(watcher.poll().is_none());
557
558 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 std::fs::write(&path, "this = is [ not valid").unwrap();
567 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}