use crate::cell::Color;
use ratatui::style::Style;
use serde::Deserialize;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
#[derive(Clone, Debug)]
pub struct Theme {
pub name: String,
pub colors: ColorScheme,
pub spacing: Spacing,
pub symbols: crate::symbols::Symbols,
}
#[derive(Clone, Debug)]
pub struct ColorScheme {
pub foreground: Color,
pub background: Color,
pub primary: Color,
pub secondary: Color,
pub error: Color,
pub warning: Color,
pub success: Color,
pub muted: Color,
pub accent: Color,
pub border: Color,
pub user_border: Color,
pub user_bg: Color,
pub cursor_fg: Color,
pub cursor_bg: Color,
pub selection_bg: Color,
pub code_fg: Color,
pub code_bg: Color,
pub tool_pending_bg: Color,
pub tool_executing_bg: Color,
pub tool_success_bg: Color,
pub tool_error_bg: Color,
}
impl Default for ColorScheme {
fn default() -> Self {
Self::dark()
}
}
impl ColorScheme {
pub fn dark() -> Self {
Self {
foreground: Color::Rgb(205, 214, 244), background: Color::Rgb(0, 0, 0), primary: Color::Rgb(122, 162, 247), secondary: Color::Rgb(158, 206, 106), error: Color::Rgb(247, 118, 142), warning: Color::Rgb(224, 175, 104), success: Color::Rgb(158, 206, 106), muted: Color::Rgb(127, 132, 156), accent: Color::Rgb(187, 154, 247), border: Color::Rgb(88, 91, 112), user_border: Color::Rgb(122, 162, 247), user_bg: Color::Rgb(18, 22, 38), cursor_fg: Color::Rgb(0, 0, 0), cursor_bg: Color::Rgb(205, 214, 244), selection_bg: Color::Rgb(40, 40, 60), code_fg: Color::Rgb(255, 200, 100), code_bg: Color::Rgb(35, 30, 20), tool_pending_bg: Color::Rgb(18, 20, 28), tool_executing_bg: Color::Rgb(28, 24, 14), tool_success_bg: Color::Rgb(16, 26, 14), tool_error_bg: Color::Rgb(32, 16, 18), }
}
pub fn light() -> Self {
Self {
foreground: Color::Rgb(76, 79, 105), background: Color::Rgb(239, 241, 245), primary: Color::Rgb(30, 102, 240), secondary: Color::Rgb(64, 160, 43), error: Color::Rgb(210, 15, 57), warning: Color::Rgb(223, 142, 29), success: Color::Rgb(64, 160, 43), muted: Color::Rgb(92, 95, 119), accent: Color::Rgb(136, 57, 239), border: Color::Rgb(156, 160, 176), user_border: Color::Rgb(30, 102, 240), user_bg: Color::Rgb(225, 236, 255), cursor_fg: Color::Rgb(239, 241, 245),
cursor_bg: Color::Rgb(76, 79, 105),
selection_bg: Color::Rgb(204, 208, 218),
code_fg: Color::Rgb(180, 60, 60), code_bg: Color::Rgb(240, 240, 245), tool_pending_bg: Color::Rgb(235, 238, 245), tool_executing_bg: Color::Rgb(255, 248, 230), tool_success_bg: Color::Rgb(230, 248, 230), tool_error_bg: Color::Rgb(255, 230, 235), }
}
pub fn nord() -> Self {
Self {
foreground: Color::Rgb(216, 222, 233), background: Color::Rgb(46, 52, 64), primary: Color::Rgb(136, 192, 208), secondary: Color::Rgb(163, 190, 140), error: Color::Rgb(191, 97, 106), warning: Color::Rgb(235, 203, 139), success: Color::Rgb(163, 190, 140), muted: Color::Rgb(97, 110, 136), accent: Color::Rgb(180, 142, 173), border: Color::Rgb(76, 86, 106), user_border: Color::Rgb(136, 192, 208), user_bg: Color::Rgb(59, 66, 82), cursor_fg: Color::Rgb(46, 52, 64), cursor_bg: Color::Rgb(216, 222, 233), selection_bg: Color::Rgb(67, 76, 94), code_fg: Color::Rgb(235, 203, 139), code_bg: Color::Rgb(59, 66, 82), tool_pending_bg: Color::Rgb(46, 52, 64), tool_executing_bg: Color::Rgb(59, 56, 40), tool_success_bg: Color::Rgb(40, 56, 44), tool_error_bg: Color::Rgb(56, 42, 44), }
}
pub fn catppuccin() -> Self {
Self {
foreground: Color::Rgb(205, 214, 244), background: Color::Rgb(30, 30, 46), primary: Color::Rgb(137, 180, 250), secondary: Color::Rgb(166, 227, 161), error: Color::Rgb(243, 139, 168), warning: Color::Rgb(249, 226, 175), success: Color::Rgb(166, 227, 161), muted: Color::Rgb(127, 132, 156), accent: Color::Rgb(203, 166, 247), border: Color::Rgb(88, 91, 112), user_border: Color::Rgb(137, 180, 250), user_bg: Color::Rgb(49, 50, 68), cursor_fg: Color::Rgb(30, 30, 46), cursor_bg: Color::Rgb(205, 214, 244), selection_bg: Color::Rgb(69, 71, 90), code_fg: Color::Rgb(249, 226, 175), code_bg: Color::Rgb(49, 50, 68), tool_pending_bg: Color::Rgb(30, 30, 46), tool_executing_bg: Color::Rgb(44, 42, 30), tool_success_bg: Color::Rgb(32, 46, 36), tool_error_bg: Color::Rgb(48, 34, 40), }
}
pub fn github_dark() -> Self {
Self {
foreground: Color::Rgb(201, 209, 217), background: Color::Rgb(13, 17, 23), primary: Color::Rgb(47, 129, 247), secondary: Color::Rgb(63, 185, 80), error: Color::Rgb(248, 81, 73), warning: Color::Rgb(210, 153, 34), success: Color::Rgb(63, 185, 80), muted: Color::Rgb(139, 148, 158), accent: Color::Rgb(163, 113, 247), border: Color::Rgb(48, 54, 61), user_border: Color::Rgb(47, 129, 247), user_bg: Color::Rgb(22, 27, 34), cursor_fg: Color::Rgb(13, 17, 23), cursor_bg: Color::Rgb(201, 209, 217), selection_bg: Color::Rgb(38, 79, 120), code_fg: Color::Rgb(210, 153, 34), code_bg: Color::Rgb(22, 27, 34), tool_pending_bg: Color::Rgb(13, 17, 23), tool_executing_bg: Color::Rgb(34, 30, 18), tool_success_bg: Color::Rgb(18, 30, 20), tool_error_bg: Color::Rgb(34, 18, 20), }
}
pub fn monokai() -> Self {
Self {
foreground: Color::Rgb(248, 248, 242), background: Color::Rgb(39, 40, 34), primary: Color::Rgb(102, 217, 239), secondary: Color::Rgb(166, 226, 46), error: Color::Rgb(249, 38, 114), warning: Color::Rgb(253, 151, 31), success: Color::Rgb(166, 226, 46), muted: Color::Rgb(117, 113, 94), accent: Color::Rgb(174, 129, 255), border: Color::Rgb(73, 72, 62), user_border: Color::Rgb(102, 217, 239), user_bg: Color::Rgb(62, 61, 50), cursor_fg: Color::Rgb(39, 40, 34), cursor_bg: Color::Rgb(248, 248, 240), selection_bg: Color::Rgb(73, 72, 62), code_fg: Color::Rgb(230, 219, 116), code_bg: Color::Rgb(62, 61, 50), tool_pending_bg: Color::Rgb(39, 40, 34), tool_executing_bg: Color::Rgb(48, 40, 24), tool_success_bg: Color::Rgb(34, 44, 26), tool_error_bg: Color::Rgb(50, 30, 38), }
}
pub fn to_style(&self) -> Style {
Style::default().fg(self.foreground).bg(self.background)
}
pub fn to_styles(&self) -> ThemeStyles {
ThemeStyles {
normal: Style::default().fg(self.foreground),
primary: Style::default().fg(self.primary),
secondary: Style::default().fg(self.secondary),
error: Style::default().fg(self.error),
warning: Style::default().fg(self.warning),
success: Style::default().fg(self.success),
muted: Style::default().fg(self.muted),
accent: Style::default().fg(self.accent),
border: Style::default().fg(self.border),
cursor_fg: Style::default().fg(self.cursor_fg),
cursor_bg: Style::default().fg(self.cursor_bg),
selection_bg: Style::default().bg(self.selection_bg),
user_border: Style::default().fg(self.user_border),
user_bg: Style::default().bg(self.user_bg),
tool_pending_bg: Style::default().bg(self.tool_pending_bg),
tool_executing_bg: Style::default().bg(self.tool_executing_bg),
tool_success_bg: Style::default().bg(self.tool_success_bg),
tool_error_bg: Style::default().bg(self.tool_error_bg),
code_fg: Style::default().fg(self.code_fg),
code_bg: Style::default().bg(self.code_bg),
symbols: crate::symbols::Symbols::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ThemeStyles {
pub normal: Style,
pub primary: Style,
pub secondary: Style,
pub error: Style,
pub warning: Style,
pub success: Style,
pub muted: Style,
pub accent: Style,
pub border: Style,
pub cursor_fg: Style,
pub cursor_bg: Style,
pub selection_bg: Style,
pub user_border: Style,
pub user_bg: Style,
pub tool_pending_bg: Style,
pub tool_executing_bg: Style,
pub tool_success_bg: Style,
pub tool_error_bg: Style,
pub code_fg: Style,
pub code_bg: Style,
pub symbols: crate::symbols::Symbols,
}
#[derive(Clone, Debug, Copy)]
pub struct Spacing {
pub padding: u16,
pub margin: u16,
pub border_width: u16,
pub line_spacing: u16,
}
impl Default for Spacing {
fn default() -> Self {
Self {
padding: 1,
margin: 0,
border_width: 1,
line_spacing: 0,
}
}
}
impl Theme {
pub fn dark() -> Self {
Self {
name: "dark".into(),
colors: ColorScheme::dark(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn light() -> Self {
Self {
name: "light".into(),
colors: ColorScheme::light(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn nord() -> Self {
Self {
name: "nord".into(),
colors: ColorScheme::nord(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn catppuccin() -> Self {
Self {
name: "catppuccin".into(),
colors: ColorScheme::catppuccin(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn github_dark() -> Self {
Self {
name: "github_dark".into(),
colors: ColorScheme::github_dark(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn monokai() -> Self {
Self {
name: "monokai".into(),
colors: ColorScheme::monokai(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
pub fn by_name(name: &str) -> Self {
match name {
"oxi_light" | "light" => Self::light(),
"nord" => Self::nord(),
"catppuccin" => Self::catppuccin(),
"github_dark" => Self::github_dark(),
"monokai" => Self::monokai(),
_ => Self::dark(),
}
}
pub fn with_glyph_set(mut self, set: crate::symbols::GlyphSet) -> Self {
self.symbols = set.symbols();
self
}
pub fn set_glyph_set(&mut self, set: crate::symbols::GlyphSet) {
self.symbols = set.symbols();
}
pub fn to_style(&self) -> Style {
self.colors.to_style()
}
pub fn to_styles(&self) -> ThemeStyles {
let mut styles = self.colors.to_styles();
styles.symbols = self.symbols;
styles
}
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
pub const THEME_NAMES: &[&str] = &[
"oxi_dark",
"oxi_light",
"nord",
"catppuccin",
"github_dark",
"monokai",
];
#[derive(Clone, Debug, Deserialize, Default)]
pub struct ThemeFile {
#[serde(default)]
pub name: String,
#[serde(default)]
pub colors: ThemeFileColors,
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct ThemeFileColors {
pub foreground: Option<String>,
pub background: Option<String>,
pub primary: Option<String>,
pub secondary: Option<String>,
pub error: Option<String>,
pub warning: Option<String>,
pub success: Option<String>,
pub muted: Option<String>,
pub accent: Option<String>,
pub border: Option<String>,
pub user_border: Option<String>,
pub user_bg: Option<String>,
pub cursor_fg: Option<String>,
pub cursor_bg: Option<String>,
pub selection_bg: Option<String>,
pub code_fg: Option<String>,
pub code_bg: Option<String>,
pub tool_pending_bg: Option<String>,
pub tool_executing_bg: Option<String>,
pub tool_success_bg: Option<String>,
pub tool_error_bg: Option<String>,
}
impl ThemeFile {
pub fn from_toml(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let theme: ThemeFile = toml::from_str(&content)?;
Ok(theme)
}
pub fn from_json(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let theme: ThemeFile = serde_json::from_str(&content)?;
Ok(theme)
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
match path.extension().and_then(|e| e.to_str()) {
Some("toml") => Self::from_toml(path),
Some("json") => Self::from_json(path),
_ => anyhow::bail!(
"Unsupported theme file format: {:?}. Use .toml or .json",
path.extension()
),
}
}
pub fn into_theme(self) -> Theme {
let defaults = ColorScheme::dark();
fn resolve(value: Option<String>, fallback: Color, field_name: &str) -> Color {
match value.as_deref().and_then(parse_color) {
Some(c) => c,
None => {
if let Some(ref v) = value {
tracing::warn!(
"Invalid theme color for '{}': '{}' - using default",
field_name,
v
);
}
fallback
}
}
}
let colors = ColorScheme {
foreground: resolve(self.colors.foreground, defaults.foreground, "foreground"),
background: resolve(self.colors.background, defaults.background, "background"),
primary: resolve(self.colors.primary, defaults.primary, "primary"),
secondary: resolve(self.colors.secondary, defaults.secondary, "secondary"),
error: resolve(self.colors.error, defaults.error, "error"),
warning: resolve(self.colors.warning, defaults.warning, "warning"),
success: resolve(self.colors.success, defaults.success, "success"),
muted: resolve(self.colors.muted, defaults.muted, "muted"),
accent: resolve(self.colors.accent, defaults.accent, "accent"),
border: resolve(self.colors.border, defaults.border, "border"),
user_border: resolve(self.colors.user_border, defaults.user_border, "user_border"),
user_bg: resolve(self.colors.user_bg, defaults.user_bg, "user_bg"),
cursor_fg: resolve(self.colors.cursor_fg, defaults.cursor_fg, "cursor_fg"),
cursor_bg: resolve(self.colors.cursor_bg, defaults.cursor_bg, "cursor_bg"),
selection_bg: resolve(
self.colors.selection_bg,
defaults.selection_bg,
"selection_bg",
),
code_fg: resolve(self.colors.code_fg, defaults.code_fg, "code_fg"),
code_bg: resolve(self.colors.code_bg, defaults.code_bg, "code_bg"),
tool_pending_bg: resolve(
self.colors.tool_pending_bg,
defaults.tool_pending_bg,
"tool_pending_bg",
),
tool_executing_bg: resolve(
self.colors.tool_executing_bg,
defaults.tool_executing_bg,
"tool_executing_bg",
),
tool_success_bg: resolve(
self.colors.tool_success_bg,
defaults.tool_success_bg,
"tool_success_bg",
),
tool_error_bg: resolve(
self.colors.tool_error_bg,
defaults.tool_error_bg,
"tool_error_bg",
),
};
Theme {
name: if self.name.is_empty() {
"custom".into()
} else {
self.name
},
colors,
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
}
}
}
fn parse_color(s: &str) -> Option<Color> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
return parse_hex(hex);
}
if let Some(idx_str) = s.strip_prefix('i')
&& let Ok(n) = idx_str.parse::<u8>()
{
return Some(Color::Indexed(n));
}
match s.to_lowercase().as_str() {
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"white" => Some(Color::White),
"bright-black" | "brightblack" | "gray" | "grey" => Some(Color::Indexed(8)),
"bright-red" | "brightred" => Some(Color::Indexed(9)),
"bright-green" | "brightgreen" => Some(Color::Indexed(10)),
"bright-yellow" | "brightyellow" => Some(Color::Indexed(11)),
"bright-blue" | "brightblue" => Some(Color::Indexed(12)),
"bright-magenta" | "brightmagenta" => Some(Color::Indexed(13)),
"bright-cyan" | "brightcyan" => Some(Color::Indexed(14)),
"bright-white" | "brightwhite" => Some(Color::Indexed(15)),
"default" => Some(Color::Reset),
_ => None,
}
}
fn parse_hex(hex: &str) -> Option<Color> {
match hex.len() {
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
3 => {
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
Some(Color::Rgb(r, g, b))
}
_ => None,
}
}
pub struct ThemeManager {
theme: Arc<parking_lot::RwLock<Theme>>,
watch_path: Option<PathBuf>,
last_modified: Option<std::time::SystemTime>,
poll_interval: std::time::Duration,
last_poll: Instant,
}
impl ThemeManager {
pub fn new(theme: Theme) -> Self {
Self {
theme: Arc::new(parking_lot::RwLock::new(theme)),
watch_path: None,
last_modified: None,
poll_interval: std::time::Duration::from_secs(1),
last_poll: Instant::now(),
}
}
pub fn dark() -> Self {
Self::new(Theme::dark())
}
pub fn light() -> Self {
Self::new(Theme::light())
}
pub fn watch_file(&mut self, path: impl Into<PathBuf>) -> anyhow::Result<()> {
let path = path.into();
let file = ThemeFile::load(&path)?;
let theme = file.into_theme();
*self.theme.write() = theme;
self.last_modified = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
self.watch_path = Some(path);
Ok(())
}
pub fn theme(&self) -> Theme {
self.theme.read().clone()
}
pub fn theme_handle(&self) -> Arc<parking_lot::RwLock<Theme>> {
Arc::clone(&self.theme)
}
pub fn set_theme(&self, theme: Theme) {
*self.theme.write() = theme;
}
pub fn set_theme_by_name(&self, name: &str) -> bool {
self.set_theme(Theme::by_name(name));
true
}
pub fn check_reload(&mut self) -> bool {
let path = match &self.watch_path {
Some(p) => p.clone(),
None => return false,
};
if self.last_poll.elapsed() < self.poll_interval {
return false;
}
self.last_poll = Instant::now();
let current_mtime = match std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
{
Some(t) => t,
None => return false,
};
let changed = match self.last_modified {
Some(prev) => current_mtime > prev,
None => true,
};
if changed {
match ThemeFile::load(&path) {
Ok(file) => {
let theme = file.into_theme();
*self.theme.write() = theme;
self.last_modified = Some(current_mtime);
tracing::info!("Theme reloaded from {:?}", path);
true
}
Err(e) => {
tracing::warn!("Failed to reload theme from {:?}: {}", path, e);
false
}
}
} else {
false
}
}
pub fn set_poll_interval(&mut self, interval: std::time::Duration) {
self.poll_interval = interval;
}
pub fn check_external(
&mut self,
path: &std::path::Path,
initial: Option<std::time::SystemTime>,
) -> bool {
if self.last_poll.elapsed() < self.poll_interval {
return false;
}
self.last_poll = Instant::now();
let current = match std::fs::metadata(path).ok().and_then(|m| m.modified().ok()) {
Some(t) => t,
None => return false,
};
match initial {
Some(prev) => current > prev,
None => false,
}
}
}
#[derive(Clone, Debug)]
pub struct ThemeRegistry {
builtins: std::collections::HashMap<String, Theme>,
custom: std::collections::HashMap<String, Theme>,
}
impl ThemeRegistry {
pub fn with_builtins() -> Self {
let mut builtins = std::collections::HashMap::with_capacity(THEME_NAMES.len() * 2);
for &name in THEME_NAMES {
builtins.insert(name.to_string(), Theme::by_name(name));
}
builtins.insert("dark".to_string(), Theme::dark());
builtins.insert("light".to_string(), Theme::light());
Self {
builtins,
custom: std::collections::HashMap::new(),
}
}
pub fn add_custom(&mut self, theme: Theme) {
let key = theme.name.to_lowercase();
self.custom.insert(key, theme);
}
pub fn add_custom_file(&mut self, path: &Path) -> anyhow::Result<Theme> {
let file = ThemeFile::load(path)?;
let theme = file.into_theme();
self.add_custom(theme.clone());
Ok(theme)
}
pub fn resolve(&self, name: &str) -> Theme {
let key = name.to_lowercase();
if let Some(theme) = self.custom.get(&key) {
return theme.clone();
}
if let Some(theme) = self.builtins.get(&key) {
return theme.clone();
}
self.builtins
.get("oxi_dark")
.cloned()
.unwrap_or_else(Theme::dark)
}
pub fn custom_names(&self) -> Vec<String> {
self.custom.keys().cloned().collect()
}
pub fn custom_count(&self) -> usize {
self.custom.len()
}
}
impl fmt::Display for Theme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Theme({})", self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_is_dark() {
let theme = Theme::default();
assert_eq!(theme.name, "dark");
}
#[test]
fn dark_theme_has_light_foreground() {
let theme = Theme::dark();
match theme.colors.foreground {
Color::Rgb(r, _, _) => assert!(r > 200, "dark theme foreground should be light"),
_ => panic!("expected Rgb foreground"),
}
}
#[test]
fn light_theme_has_dark_foreground() {
let theme = Theme::light();
match theme.colors.foreground {
Color::Rgb(r, _, _) => assert!(r < 150, "light theme foreground should be dark"),
_ => panic!("expected Rgb foreground"),
}
}
#[test]
fn parse_hex_colors() {
assert_eq!(parse_color("#ff8800"), Some(Color::Rgb(255, 136, 0)));
assert_eq!(parse_color("#f80"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_named_colors() {
assert_eq!(parse_color("red"), Some(Color::Red));
assert_eq!(parse_color("bright-black"), Some(Color::Indexed(8)));
assert_eq!(parse_color("default"), Some(Color::Reset));
}
#[test]
fn parse_indexed_color() {
assert_eq!(parse_color("i42"), Some(Color::Indexed(42)));
}
#[test]
fn theme_manager_set_by_name() {
let mgr = ThemeManager::dark();
for (name, expected) in [
("oxi_dark", "dark"),
("oxi_light", "light"),
("nord", "nord"),
("catppuccin", "catppuccin"),
("github_dark", "github_dark"),
("monokai", "monokai"),
("dark", "dark"),
("light", "light"),
] {
assert!(mgr.set_theme_by_name(name), "expected {name} to resolve");
assert_eq!(
mgr.theme().name,
expected,
"set_theme_by_name({name}) → {}",
mgr.theme().name
);
}
assert!(mgr.set_theme_by_name("nonexistent"));
assert_eq!(mgr.theme().name, "dark");
assert!(mgr.set_theme_by_name(""));
assert_eq!(mgr.theme().name, "dark");
assert!(mgr.set_theme_by_name("default"));
assert_eq!(mgr.theme().name, "dark");
}
#[test]
fn theme_manager_check_external_detects_mtime_change() {
let dir = std::env::temp_dir().join("oxi-tui-check-external");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("settings.toml");
std::fs::write(&path, "theme = \"dark\"\n").unwrap();
let mut mgr = ThemeManager::dark();
mgr.set_poll_interval(std::time::Duration::from_millis(50));
let baseline = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(!mgr.check_external(&path, Some(baseline)));
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(&path, "theme = \"nord\"\n").unwrap();
let new_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(new_mtime > baseline, "mtime should advance");
assert!(mgr.check_external(&path, Some(baseline)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn theme_manager_check_external_returns_false_for_missing_path() {
let mut mgr = ThemeManager::dark();
mgr.set_poll_interval(std::time::Duration::from_millis(10));
let bogus = std::path::PathBuf::from("/nonexistent/oxi-tui/check-external");
assert!(!mgr.check_external(&bogus, None));
}
#[test]
fn theme_manager_check_external_no_baseline_does_not_fire() {
let dir = std::env::temp_dir().join("oxi-tui-check-external-nobase");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.toml");
std::fs::write(&path, "x = 1\n").unwrap();
let mut mgr = ThemeManager::dark();
mgr.set_poll_interval(std::time::Duration::from_millis(10));
assert!(!mgr.check_external(&path, None));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn theme_file_from_json() {
let json = r##"{"name":"test","colors":{"foreground":"#ffffff","background":"#000000"}}"##;
let file: ThemeFile = serde_json::from_str(json).unwrap();
let theme = file.into_theme();
assert_eq!(theme.name, "test");
assert_eq!(theme.colors.foreground, Color::Rgb(255, 255, 255));
assert_eq!(theme.colors.background, Color::Rgb(0, 0, 0));
}
#[test]
fn theme_file_roundtrip() {
let dir = std::env::temp_dir().join("oxi-tui-theme-test");
std::fs::create_dir_all(&dir).unwrap();
let json_path = dir.join("test_theme.json");
std::fs::write(
&json_path,
r##"{"name":"mytheme","colors":{"primary":"#ff0000"}}"##,
)
.unwrap();
let file = ThemeFile::load(&json_path).unwrap();
let theme = file.into_theme();
assert_eq!(theme.name, "mytheme");
assert_eq!(theme.colors.primary, Color::Rgb(255, 0, 0));
assert!(matches!(theme.colors.foreground, Color::Rgb(_, _, _)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn theme_defaults_to_unicode_glyphs() {
let theme = Theme::dark();
assert_eq!(theme.symbols, crate::symbols::Symbols::unicode());
}
#[test]
fn with_glyph_set_propagates_to_styles() {
for set in crate::symbols::GlyphSet::ALL {
let theme = Theme::dark().with_glyph_set(set);
assert_eq!(theme.symbols, set.symbols());
assert_eq!(theme.to_styles().symbols, set.symbols());
}
}
#[test]
fn set_glyph_set_mutates_in_place() {
let mut theme = Theme::dark();
assert_eq!(
theme.symbols.cursor,
crate::symbols::Symbols::unicode().cursor
);
theme.set_glyph_set(crate::symbols::GlyphSet::Ascii);
assert_eq!(theme.symbols, crate::symbols::Symbols::ascii());
}
#[test]
fn theme_by_name_resolves_all_builtins() {
assert_eq!(Theme::by_name("oxi_dark").name, "dark");
assert_eq!(Theme::by_name("oxi_light").name, "light");
assert_eq!(Theme::by_name("nord").name, "nord");
assert_eq!(Theme::by_name("catppuccin").name, "catppuccin");
assert_eq!(Theme::by_name("github_dark").name, "github_dark");
assert_eq!(Theme::by_name("monokai").name, "monokai");
}
#[test]
fn theme_by_name_falls_back_to_dark() {
assert_eq!(Theme::by_name("default").name, "dark");
assert_eq!(Theme::by_name("").name, "dark");
assert_eq!(Theme::by_name("nonexistent").name, "dark");
}
#[test]
fn color_scheme_constructors_distinct_backgrounds() {
let bgs = [
ColorScheme::dark().background,
ColorScheme::light().background,
ColorScheme::nord().background,
ColorScheme::catppuccin().background,
ColorScheme::github_dark().background,
ColorScheme::monokai().background,
];
for i in 0..bgs.len() {
for j in (i + 1)..bgs.len() {
assert_ne!(bgs[i], bgs[j], "backgrounds at {i} and {j} collide");
}
}
}
#[test]
fn registry_resolves_all_six_builtins() {
let reg = ThemeRegistry::with_builtins();
for &name in THEME_NAMES {
let t = reg.resolve(name);
assert!(!t.name.is_empty(), "resolved theme must have a name");
}
assert_eq!(reg.resolve("dark").name, "dark");
assert_eq!(reg.resolve("light").name, "light");
assert_eq!(reg.resolve("nord").name, "nord");
assert_eq!(reg.resolve("catppuccin").name, "catppuccin");
assert_eq!(reg.resolve("github_dark").name, "github_dark");
assert_eq!(reg.resolve("monokai").name, "monokai");
}
#[test]
fn registry_unknown_name_falls_back_to_dark() {
let reg = ThemeRegistry::with_builtins();
assert_eq!(reg.resolve("").name, "dark");
assert_eq!(reg.resolve("default").name, "dark");
assert_eq!(reg.resolve("nonexistent").name, "dark");
assert_eq!(reg.resolve("oxi_dark").name, "dark");
}
#[test]
fn registry_add_custom_and_resolve() {
let mut reg = ThemeRegistry::with_builtins();
let custom = Theme {
name: "my_red".into(),
colors: ColorScheme {
foreground: Color::Rgb(255, 255, 255),
background: Color::Rgb(20, 0, 0),
..ColorScheme::dark()
},
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
};
reg.add_custom(custom);
let resolved = reg.resolve("my_red");
assert_eq!(resolved.name, "my_red");
assert_eq!(resolved.colors.background, Color::Rgb(20, 0, 0));
assert_eq!(reg.resolve("nord").name, "nord");
}
#[test]
fn registry_custom_overrides_builtin_with_same_name() {
let mut reg = ThemeRegistry::with_builtins();
let custom_nord = Theme {
name: "nord".into(),
colors: ColorScheme {
foreground: Color::Rgb(0, 0, 0),
..ColorScheme::dark()
},
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
};
reg.add_custom(custom_nord);
assert_eq!(reg.resolve("nord").colors.foreground, Color::Rgb(0, 0, 0));
}
#[test]
fn registry_custom_names_lists_inserted() {
let mut reg = ThemeRegistry::with_builtins();
assert_eq!(reg.custom_count(), 0);
assert!(reg.custom_names().is_empty());
reg.add_custom(Theme {
name: "alpha".into(),
colors: ColorScheme::dark(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
});
reg.add_custom(Theme {
name: "beta".into(),
colors: ColorScheme::dark(),
spacing: Spacing::default(),
symbols: crate::symbols::Symbols::default(),
});
assert_eq!(reg.custom_count(), 2);
let names = reg.custom_names();
assert!(names.contains(&"alpha".to_string()));
assert!(names.contains(&"beta".to_string()));
}
#[test]
fn registry_add_custom_file_parses_json_and_toml() {
let dir = std::env::temp_dir().join("oxi-tui-registry-test");
std::fs::create_dir_all(&dir).unwrap();
let json_path = dir.join("my_json.json");
std::fs::write(
&json_path,
r##"{"name":"json_theme","colors":{"primary":"#ff00ff"}}"##,
)
.unwrap();
let toml_path = dir.join("my_toml.toml");
std::fs::write(
&toml_path,
r##"name = "toml_theme"
[colors]
primary = "#00ff00"
"##,
)
.unwrap();
let mut reg = ThemeRegistry::with_builtins();
let j = reg.add_custom_file(&json_path).unwrap();
assert_eq!(j.name, "json_theme");
assert_eq!(j.colors.primary, Color::Rgb(255, 0, 255));
let t = reg.add_custom_file(&toml_path).unwrap();
assert_eq!(t.name, "toml_theme");
assert_eq!(t.colors.primary, Color::Rgb(0, 255, 0));
assert_eq!(
reg.resolve("json_theme").colors.primary,
Color::Rgb(255, 0, 255)
);
assert_eq!(
reg.resolve("toml_theme").colors.primary,
Color::Rgb(0, 255, 0)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn registry_add_custom_file_rejects_unknown_extension() {
let dir = std::env::temp_dir().join("oxi-tui-registry-ext");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("bad.txt");
std::fs::write(&p, "name = \"x\"").unwrap();
let mut reg = ThemeRegistry::with_builtins();
let err = reg.add_custom_file(&p).unwrap_err();
assert!(err.to_string().contains("Unsupported"));
assert_eq!(reg.custom_count(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn registry_add_custom_file_missing_path_errors() {
let mut reg = ThemeRegistry::with_builtins();
let bogus = std::path::PathBuf::from("/nonexistent/oxi-tui/test.json");
assert!(reg.add_custom_file(&bogus).is_err());
assert_eq!(reg.custom_count(), 0);
}
}