use super::Theme;
use crate::WidgetTheme;
#[non_exhaustive]
#[derive(Debug)]
pub enum ThemeLoadError {
Io(std::io::Error),
Parse(String),
}
impl std::fmt::Display for ThemeLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeLoadError::Io(e) => write!(f, "failed to read theme file: {e}"),
ThemeLoadError::Parse(msg) => write!(f, "failed to parse theme TOML: {msg}"),
}
}
}
impl core::error::Error for ThemeLoadError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
ThemeLoadError::Io(e) => Some(e),
ThemeLoadError::Parse(_) => None,
}
}
}
impl From<std::io::Error> for ThemeLoadError {
fn from(e: std::io::Error) -> Self {
ThemeLoadError::Io(e)
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThemeFile {
#[cfg_attr(feature = "serde", serde(default))]
pub theme: Theme,
#[cfg_attr(feature = "serde", serde(default))]
pub widgets: Option<WidgetTheme>,
}
impl ThemeFile {
pub fn from_toml_str(src: &str) -> Result<ThemeFile, ThemeLoadError> {
toml::from_str(src).map_err(|e| ThemeLoadError::Parse(e.to_string()))
}
pub fn to_toml_string(&self) -> Result<String, ThemeLoadError> {
toml::to_string(self).map_err(|e| ThemeLoadError::Parse(e.to_string()))
}
pub fn load(path: impl AsRef<std::path::Path>) -> Result<ThemeFile, ThemeLoadError> {
let src = std::fs::read_to_string(path)?;
Self::from_toml_str(&src)
}
}
#[cfg(feature = "theme-watch")]
#[cfg_attr(docsrs, doc(cfg(feature = "theme-watch")))]
pub struct ThemeWatcher {
_watcher: notify::RecommendedWatcher,
rx: std::sync::mpsc::Receiver<()>,
path: std::path::PathBuf,
last_good: ThemeFile,
}
#[cfg(feature = "theme-watch")]
impl ThemeWatcher {
pub fn new(path: impl AsRef<std::path::Path>) -> Result<ThemeWatcher, ThemeLoadError> {
use notify::{RecursiveMode, Watcher};
let path = path.as_ref().to_path_buf();
let last_good = ThemeFile::load(&path)?;
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = tx.send(());
}
})
.map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
let watch_target = path.parent().filter(|p| !p.as_os_str().is_empty());
let (target, mode) = match watch_target {
Some(dir) => (dir, RecursiveMode::NonRecursive),
None => (path.as_path(), RecursiveMode::NonRecursive),
};
watcher
.watch(target, mode)
.map_err(|e| ThemeLoadError::Io(std::io::Error::other(e.to_string())))?;
Ok(ThemeWatcher {
_watcher: watcher,
rx,
path,
last_good,
})
}
pub fn current(&self) -> &ThemeFile {
&self.last_good
}
#[allow(clippy::print_stderr)]
pub fn poll(&mut self) -> Option<ThemeFile> {
let mut changed = false;
while self.rx.try_recv().is_ok() {
changed = true;
}
if !changed {
return None;
}
match ThemeFile::load(&self.path) {
Ok(tf) => {
self.last_good = tf.clone();
Some(tf)
}
Err(e) => {
eprintln!(
"slt: theme hot-reload skipped for {}: {e}",
self.path.display()
);
None
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::Color;
fn all_presets() -> Vec<(&'static str, Theme)> {
vec![
("dark", Theme::dark()),
("light", Theme::light()),
("dracula", Theme::dracula()),
("catppuccin", Theme::catppuccin()),
("nord", Theme::nord()),
("solarized_dark", Theme::solarized_dark()),
("solarized_light", Theme::solarized_light()),
("tokyo_night", Theme::tokyo_night()),
("gruvbox_dark", Theme::gruvbox_dark()),
("one_dark", Theme::one_dark()),
]
}
fn theme_eq(a: &Theme, b: &Theme) -> bool {
a.primary == b.primary
&& a.secondary == b.secondary
&& a.accent == b.accent
&& a.text == b.text
&& a.text_dim == b.text_dim
&& a.border == b.border
&& a.bg == b.bg
&& a.success == b.success
&& a.warning == b.warning
&& a.error == b.error
&& a.selected_bg == b.selected_bg
&& a.selected_fg == b.selected_fg
&& a.surface == b.surface
&& a.surface_hover == b.surface_hover
&& a.surface_text == b.surface_text
&& a.is_dark == b.is_dark
&& a.spacing == b.spacing
}
#[test]
fn parses_minimal_theme_doc() {
let toml = r##"
[theme]
primary = "#ff6b6b"
bg = "#1e1e2e"
is_dark = true
"##;
let tf = ThemeFile::from_toml_str(toml).unwrap();
assert_eq!(tf.theme.primary, Color::Rgb(255, 107, 107));
assert_eq!(tf.theme.bg, Color::Rgb(30, 30, 46));
assert!(tf.theme.is_dark);
assert_eq!(tf.theme.text, Theme::dark().text);
assert!(tf.widgets.is_none());
}
#[test]
fn named_and_indexed_colors_parse() {
let toml = r#"
[theme]
primary = "cyan"
text = "indexed:250"
bg = "reset"
"#;
let tf = ThemeFile::from_toml_str(toml).unwrap();
assert_eq!(tf.theme.primary, Color::Cyan);
assert_eq!(tf.theme.text, Color::Indexed(250));
assert_eq!(tf.theme.bg, Color::Reset);
}
#[test]
fn round_trips_every_preset() {
for (name, theme) in all_presets() {
let tf = ThemeFile {
theme,
widgets: None,
};
let serialized = tf.to_toml_string().unwrap();
let parsed = Theme::from_toml_str(&serialized).unwrap();
assert!(
theme_eq(&theme, &parsed),
"preset {name} did not round-trip: {theme:?} != {parsed:?}\nTOML:\n{serialized}"
);
}
}
#[test]
fn widgets_block_deserializes() {
let toml = r##"
[theme]
primary = "#ff0000"
[widgets.table]
fg = "#00ff00"
theme_bg = "Surface"
"##;
let tf = ThemeFile::from_toml_str(toml).unwrap();
let widgets = tf.widgets.expect("widgets block present");
assert_eq!(widgets.table.fg, Some(Color::Rgb(0, 255, 0)));
assert_eq!(widgets.table.theme_bg, Some(crate::ThemeColor::Surface));
assert_eq!(widgets.button.fg, None);
}
#[test]
fn malformed_toml_is_parse_error_not_panic() {
let err = ThemeFile::from_toml_str("this is = not [valid").unwrap_err();
assert!(matches!(err, ThemeLoadError::Parse(_)));
}
#[test]
fn bad_color_token_is_parse_error() {
let toml = r##"
[theme]
primary = "#zzzzzz"
"##;
let err = ThemeFile::from_toml_str(toml).unwrap_err();
assert!(matches!(err, ThemeLoadError::Parse(_)));
}
#[test]
fn from_hex_parses_short_and_long_forms() {
assert_eq!(Color::from_hex("#ff6b6b"), Some(Color::Rgb(255, 107, 107)));
assert_eq!(Color::from_hex("#abc"), Some(Color::Rgb(170, 187, 204)));
assert_eq!(Color::from_hex("#000"), Some(Color::Rgb(0, 0, 0)));
assert_eq!(Color::from_hex("#FFFFFF"), Some(Color::Rgb(255, 255, 255)));
assert_eq!(Color::from_hex("ffffff"), None);
assert_eq!(Color::from_hex("#xyz"), None);
assert_eq!(Color::from_hex("#ff"), None);
assert_eq!(Color::from_hex(""), None);
}
#[test]
fn from_hex_to_hex_round_trip() {
for r in [0u8, 1, 127, 200, 255] {
for g in [0u8, 64, 128, 255] {
for b in [0u8, 99, 255] {
let c = Color::Rgb(r, g, b);
assert_eq!(Color::from_hex(&c.to_hex()), Some(c));
}
}
}
}
#[test]
fn theme_load_ignores_widgets() {
let toml = r##"
[theme]
primary = "#abcdef"
[widgets.button]
fg = "#123456"
"##;
let theme = Theme::from_toml_str(toml).unwrap();
assert_eq!(theme.primary, Color::Rgb(0xab, 0xcd, 0xef));
}
}
#[cfg(all(test, feature = "crossterm"))]
mod render_tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::{ButtonVariant, Color, TestBackend};
#[test]
fn loaded_primary_paints_focused_button() {
let tf = ThemeFile::from_toml_str(
r##"
[theme]
primary = "#ff0000"
"##,
)
.unwrap();
let loaded_primary = tf.theme.primary;
assert_eq!(loaded_primary, Color::Rgb(255, 0, 0));
let mut tb = TestBackend::new(20, 5);
tb.render_with_events(Vec::new(), 0, 1, move |ui| {
ui.set_theme(tf.theme);
let _ = ui.button_with("Go", ButtonVariant::Default);
});
tb.assert_contains("Go");
let buffer = tb.buffer();
let mut found_primary = false;
for y in 0..tb.height() {
for x in 0..tb.width() {
if buffer.get(x, y).style.fg == Some(loaded_primary) {
found_primary = true;
}
}
}
assert!(
found_primary,
"expected loaded primary {loaded_primary:?} to paint at least one cell"
);
}
}
#[cfg(all(test, feature = "theme-watch"))]
mod watch_tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::Color;
use std::time::{Duration, Instant};
fn poll_until_change(watcher: &mut ThemeWatcher, timeout: Duration) -> Option<ThemeFile> {
let deadline = Instant::now() + timeout;
loop {
if let Some(tf) = watcher.poll() {
return Some(tf);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(25));
}
}
fn temp_path(name: &str) -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let unique = format!(
"slt_theme_watch_{}_{}_{name}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
dir.push(unique);
dir
}
#[test]
fn watcher_reports_changes_and_survives_bad_toml() {
let path = temp_path("theme.toml");
std::fs::write(&path, "[theme]\nprimary = \"#0000ff\"\n").unwrap();
let mut watcher = ThemeWatcher::new(&path).unwrap();
assert_eq!(watcher.current().theme.primary, Color::Rgb(0, 0, 255));
assert!(watcher.poll().is_none());
std::fs::write(&path, "[theme]\nprimary = \"#ff0000\"\n").unwrap();
let reloaded = poll_until_change(&mut watcher, Duration::from_secs(5))
.expect("watcher should observe the rewrite");
assert_eq!(reloaded.theme.primary, Color::Rgb(255, 0, 0));
assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
std::fs::write(&path, "this = is [ not valid").unwrap();
std::thread::sleep(Duration::from_millis(200));
assert!(watcher.poll().is_none());
assert_eq!(watcher.current().theme.primary, Color::Rgb(255, 0, 0));
let _ = std::fs::remove_file(&path);
}
}