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_source: String,
195 last_good: ThemeFile,
196}
197
198#[cfg(feature = "theme-watch")]
199impl ThemeWatcher {
200 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 let last_source = std::fs::read_to_string(&path)?;
229 let last_good = ThemeFile::from_toml_str(&last_source)?;
230
231 let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
236 let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
237 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 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 pub fn current(&self) -> &ThemeFile {
270 &self.last_good
271 }
272
273 #[allow(clippy::print_stderr)]
295 pub fn poll(&mut self) -> Option<ThemeFile> {
296 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 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 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 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 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 tb.assert_contains("Go");
526
527 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 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 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 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 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 std::fs::write(&path, "this = is [ not valid").unwrap();
615 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}