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 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#[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")]
188pub struct ThemeWatcher {
189 _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 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 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 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 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 pub fn current(&self) -> &ThemeFile {
252 &self.last_good
253 }
254
255 #[allow(clippy::print_stderr)]
277 pub fn poll(&mut self) -> Option<ThemeFile> {
278 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 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 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 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 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 tb.assert_contains("Go");
492
493 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 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 assert!(watcher.poll().is_none());
556
557 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 std::fs::write(&path, "this = is [ not valid").unwrap();
566 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}