Skip to main content

oxi_tui/
theme.rs

1//! Theme system for oxi-tui.
2//!
3//! Provides customizable color schemes, font styles, and spacing.
4//! Includes built-in dark and light themes, and supports loading
5//! themes from TOML or JSON files with hot-reloading.
6
7use crate::cell::Color;
8use ratatui::style::Style;
9use serde::Deserialize;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Instant;
14
15// ---------------------------------------------------------------------------
16// Core theme types
17// ---------------------------------------------------------------------------
18
19/// A complete theme definition.
20#[derive(Clone, Debug)]
21pub struct Theme {
22    /// Human-readable theme name.
23    pub name: String,
24    /// Color palette.
25    pub colors: ColorScheme,
26    /// Spacing configuration.
27    pub spacing: Spacing,
28}
29
30// ---------------------------------------------------------------------------
31// Color scheme
32// ---------------------------------------------------------------------------
33
34/// Semantic color palette used by components.
35#[derive(Clone, Debug)]
36pub struct ColorScheme {
37    /// Normal text foreground color.
38    pub foreground: Color,
39    /// Default background color.
40    pub background: Color,
41    /// Primary accent color (UI elements, labels, user "You").
42    pub primary: Color,
43    /// Secondary color (alternative accents).
44    pub secondary: Color,
45    /// Error / danger color.
46    pub error: Color,
47    /// Warning / caution color.
48    pub warning: Color,
49    /// Success / confirmation color.
50    pub success: Color,
51    /// Muted / dimmed text (e.g. placeholders, tool headers).
52    pub muted: Color,
53    /// Accent highlight color.
54    pub accent: Color,
55    /// Border / separator color.
56    pub border: Color,
57    /// User message left-border accent (subtle).
58    pub user_border: Color,
59    /// User message background (subtle tint).
60    pub user_bg: Color,
61    /// Cursor foreground.
62    pub cursor_fg: Color,
63    /// Cursor background.
64    pub cursor_bg: Color,
65    /// Selection / highlight background.
66    pub selection_bg: Color,
67    /// Code (inline `code`) foreground color.
68    pub code_fg: Color,
69    /// Code (inline `code`) background color.
70    pub code_bg: Color,
71    /// Tool call pending background (waiting state).
72    pub tool_pending_bg: Color,
73    /// Tool call executing background (running state).
74    pub tool_executing_bg: Color,
75    /// Tool call success background (completed successfully).
76    pub tool_success_bg: Color,
77    /// Tool call error background (completed with error).
78    pub tool_error_bg: Color,
79}
80
81impl Default for ColorScheme {
82    fn default() -> Self {
83        Self::dark()
84    }
85}
86
87impl ColorScheme {
88    /// Default dark color scheme (true black).
89    pub fn dark() -> Self {
90        Self {
91            foreground: Color::Rgb(205, 214, 244),     // #cdd6f4
92            background: Color::Rgb(0, 0, 0),           // #000000 true black
93            primary: Color::Rgb(122, 162, 247),        // #7aa2f7
94            secondary: Color::Rgb(158, 206, 106),      // #9ece6a
95            error: Color::Rgb(247, 118, 142),          // #f7768e
96            warning: Color::Rgb(224, 175, 104),        // #e0af68
97            success: Color::Rgb(158, 206, 106),        // #9ece6a
98            muted: Color::Rgb(80, 80, 100),            // #505064
99            accent: Color::Rgb(187, 154, 247),         // #bb9af7
100            border: Color::Rgb(30, 30, 30),            // #1e1e1e
101            user_border: Color::Rgb(122, 162, 247),    // #7aa2f7 (matches primary)
102            user_bg: Color::Rgb(18, 22, 38),           // #121626 subtle indigo tint
103            cursor_fg: Color::Rgb(0, 0, 0),            // #000000
104            cursor_bg: Color::Rgb(205, 214, 244),      // #cdd6f4
105            selection_bg: Color::Rgb(40, 40, 60),      // #28283c
106            code_fg: Color::Rgb(255, 200, 100),      // #ffc864 warm amber
107            code_bg: Color::Rgb(35, 30, 20),         // #231e14 warm dark
108            tool_pending_bg: Color::Rgb(18, 20, 28),   // #12141c subtle
109            tool_executing_bg: Color::Rgb(28, 24, 14), // #1c1810 amber tint
110            tool_success_bg: Color::Rgb(16, 26, 14),   // #101a0e green tint
111            tool_error_bg: Color::Rgb(32, 16, 18),     // #201012 red tint
112        }
113    }
114
115    /// Default light color scheme.
116    pub fn light() -> Self {
117        Self {
118            foreground: Color::Rgb(76, 79, 105),   // #4c4f69
119            background: Color::Rgb(239, 241, 245), // #eff1f5
120            primary: Color::Rgb(30, 102, 240),     // #1e66f0
121            secondary: Color::Rgb(64, 160, 43),    // #40a02b
122            error: Color::Rgb(210, 15, 57),        // #d20f39
123            warning: Color::Rgb(223, 142, 29),     // #df8e1d
124            success: Color::Rgb(64, 160, 43),      // #40a02b
125            muted: Color::Indexed(8),
126            accent: Color::Rgb(136, 57, 239), // #8839ef
127            border: Color::Indexed(7),
128            user_border: Color::Rgb(30, 102, 240), // #1e66f0 (matches primary)
129            user_bg: Color::Rgb(225, 236, 255),    // #e1ecff subtle blue tint
130            cursor_fg: Color::Rgb(239, 241, 245),
131            cursor_bg: Color::Rgb(76, 79, 105),
132            selection_bg: Color::Rgb(204, 208, 218),
133            code_fg: Color::Rgb(180, 60, 60),        // #b43c3c dark red
134            code_bg: Color::Rgb(240, 240, 245),       // #f0f0f5 off-white
135            tool_pending_bg: Color::Rgb(235, 238, 245), // #ebeeff subtle blue tint
136            tool_executing_bg: Color::Rgb(255, 248, 230), // #fff8e6 amber tint
137            tool_success_bg: Color::Rgb(230, 248, 230), // #e6f8e6 green tint
138            tool_error_bg: Color::Rgb(255, 230, 235),   // #ffe6eb red tint
139        }
140    }
141
142    /// Convert to ratatui Style with just foreground.
143    pub fn to_style(&self) -> Style {
144        Style::default()
145            .fg(self.foreground.to_ratatui())
146            .bg(self.background.to_ratatui())
147    }
148
149    /// Convert to ratatui Style with all semantic colors.
150    pub fn to_styles(&self) -> ThemeStyles {
151        ThemeStyles {
152            normal: Style::default().fg(self.foreground.to_ratatui()),
153            primary: Style::default().fg(self.primary.to_ratatui()),
154            secondary: Style::default().fg(self.secondary.to_ratatui()),
155            error: Style::default().fg(self.error.to_ratatui()),
156            warning: Style::default().fg(self.warning.to_ratatui()),
157            success: Style::default().fg(self.success.to_ratatui()),
158            muted: Style::default().fg(self.muted.to_ratatui()),
159            accent: Style::default().fg(self.accent.to_ratatui()),
160            border: Style::default().fg(self.border.to_ratatui()),
161            cursor_fg: Style::default().fg(self.cursor_fg.to_ratatui()),
162            cursor_bg: Style::default().fg(self.cursor_bg.to_ratatui()),
163            selection_bg: Style::default().bg(self.selection_bg.to_ratatui()),
164            user_border: Style::default().fg(self.user_border.to_ratatui()),
165            user_bg: Style::default().bg(self.user_bg.to_ratatui()),
166            tool_pending_bg: Style::default().bg(self.tool_pending_bg.to_ratatui()),
167            tool_executing_bg: Style::default().bg(self.tool_executing_bg.to_ratatui()),
168            tool_success_bg: Style::default().bg(self.tool_success_bg.to_ratatui()),
169            tool_error_bg: Style::default().bg(self.tool_error_bg.to_ratatui()),
170        }
171    }
172}
173
174/// Pre-computed ratatui styles for all semantic colors in a ColorScheme.
175#[derive(Debug, Clone, Copy, Default)]
176pub struct ThemeStyles {
177    /// Normal / default text style.
178    pub normal: Style,
179    /// Primary color style.
180    pub primary: Style,
181    /// Secondary color style.
182    pub secondary: Style,
183    /// Error / red style.
184    pub error: Style,
185    /// Warning / yellow style.
186    pub warning: Style,
187    /// Success / green style.
188    pub success: Style,
189    /// Muted / dimmed style (tool headers, borders).
190    pub muted: Style,
191    /// Accent / highlight style.
192    pub accent: Style,
193    /// Border / separator style.
194    pub border: Style,
195    /// Cursor foreground style.
196    pub cursor_fg: Style,
197    /// Cursor background style.
198    pub cursor_bg: Style,
199    /// Selection background style.
200    pub selection_bg: Style,
201    /// User message left-border accent (bright primary).
202    pub user_border: Style,
203    /// User message background (subtle tint).
204    pub user_bg: Style,
205    /// Tool call pending background (waiting state).
206    pub tool_pending_bg: Style,
207    /// Tool call executing background (running state).
208    pub tool_executing_bg: Style,
209    /// Tool call success background (completed successfully).
210    pub tool_success_bg: Style,
211    /// Tool call error background (completed with error).
212    pub tool_error_bg: Style,
213}
214
215// ThemeStyles derives Default
216
217// ---------------------------------------------------------------------------
218// Spacing
219// ---------------------------------------------------------------------------
220
221/// Spacing/padding configuration (in character cells).
222#[derive(Clone, Debug, Copy)]
223pub struct Spacing {
224    /// Padding around content.
225    pub padding: u16,
226    /// Outer margin.
227    pub margin: u16,
228    /// Width of borders.
229    pub border_width: u16,
230    /// Extra line spacing.
231    pub line_spacing: u16,
232}
233
234impl Default for Spacing {
235    fn default() -> Self {
236        Self {
237            padding: 1,
238            margin: 0,
239            border_width: 1,
240            line_spacing: 0,
241        }
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Built-in themes
247// ---------------------------------------------------------------------------
248
249impl Theme {
250    /// Built-in dark theme.
251    pub fn dark() -> Self {
252        Self {
253            name: "dark".into(),
254            colors: ColorScheme::dark(),
255            spacing: Spacing::default(),
256        }
257    }
258
259    /// Built-in light theme.
260    pub fn light() -> Self {
261        Self {
262            name: "light".into(),
263            colors: ColorScheme::light(),
264            spacing: Spacing::default(),
265        }
266    }
267
268    /// Convert theme foreground/background to ratatui Style.
269    pub fn to_style(&self) -> Style {
270        self.colors.to_style()
271    }
272
273    /// Get all semantic styles as ThemeStyles.
274    pub fn to_styles(&self) -> ThemeStyles {
275        self.colors.to_styles()
276    }
277}
278
279impl Default for Theme {
280    fn default() -> Self {
281        Self::dark()
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Theme loading from files (TOML / JSON)
287// ---------------------------------------------------------------------------
288
289/// Serializable representation of a theme file.
290#[derive(Clone, Debug, Deserialize, Default)]
291pub struct ThemeFile {
292    /// Human-readable name of the theme.
293    #[serde(default)]
294    pub name: String,
295    /// Color definitions.
296    #[serde(default)]
297    pub colors: ThemeFileColors,
298}
299
300/// Color overrides from a theme file.
301#[derive(Clone, Debug, Deserialize, Default)]
302pub struct ThemeFileColors {
303    /// Foreground / text color.
304    pub foreground: Option<String>,
305    /// Background color.
306    pub background: Option<String>,
307    /// Primary accent color.
308    pub primary: Option<String>,
309    /// Secondary color.
310    pub secondary: Option<String>,
311    /// Error color.
312    pub error: Option<String>,
313    /// Warning color.
314    pub warning: Option<String>,
315    /// Success color.
316    pub success: Option<String>,
317    /// Muted / dimmed text color.
318    pub muted: Option<String>,
319    /// Accent highlight color.
320    pub accent: Option<String>,
321    /// Border / separator color.
322    pub border: Option<String>,
323    /// User message left-border accent.
324    pub user_border: Option<String>,
325    /// User message background (subtle tint).
326    pub user_bg: Option<String>,
327    /// Cursor foreground color.
328    pub cursor_fg: Option<String>,
329    /// Cursor background color.
330    pub cursor_bg: Option<String>,
331    /// Selection background color.
332    pub selection_bg: Option<String>,
333    /// Code (inline `code`) foreground color.
334    pub code_fg: Option<String>,
335    /// Code (inline `code`) background color.
336    pub code_bg: Option<String>,
337    /// Tool call pending background (waiting state).
338    pub tool_pending_bg: Option<String>,
339    /// Tool call executing background (running state).
340    pub tool_executing_bg: Option<String>,
341    /// Tool call success background (completed successfully).
342    pub tool_success_bg: Option<String>,
343    /// Tool call error background (completed with error).
344    pub tool_error_bg: Option<String>,
345}
346
347impl ThemeFile {
348    /// Load a theme from a TOML file.
349    pub fn from_toml(path: &Path) -> anyhow::Result<Self> {
350        let content = std::fs::read_to_string(path)?;
351        let theme: ThemeFile = toml::from_str(&content)?;
352        Ok(theme)
353    }
354
355    /// Load a theme from a JSON file.
356    pub fn from_json(path: &Path) -> anyhow::Result<Self> {
357        let content = std::fs::read_to_string(path)?;
358        let theme: ThemeFile = serde_json::from_str(&content)?;
359        Ok(theme)
360    }
361
362    /// Load from any supported format (detected by extension).
363    pub fn load(path: &Path) -> anyhow::Result<Self> {
364        match path.extension().and_then(|e| e.to_str()) {
365            Some("toml") => Self::from_toml(path),
366            Some("json") => Self::from_json(path),
367            _ => anyhow::bail!(
368                "Unsupported theme file format: {:?}. Use .toml or .json",
369                path.extension()
370            ),
371        }
372    }
373
374    /// Convert into a full Theme, using dark defaults for any missing fields.
375    pub fn into_theme(self) -> Theme {
376        let defaults = ColorScheme::dark();
377
378        // Helper: parse a color string, logging a warning for invalid user-specified values.
379        fn resolve(value: Option<String>, fallback: Color, field_name: &str) -> Color {
380            match value.as_deref().and_then(parse_color) {
381                Some(c) => c,
382                None => {
383                    if let Some(ref v) = value {
384                        tracing::warn!(
385                            "Invalid theme color for '{}': '{}' - using default",
386                            field_name,
387                            v
388                        );
389                    }
390                    fallback
391                }
392            }
393        }
394
395        let colors = ColorScheme {
396            foreground: resolve(self.colors.foreground, defaults.foreground, "foreground"),
397            background: resolve(self.colors.background, defaults.background, "background"),
398            primary: resolve(self.colors.primary, defaults.primary, "primary"),
399            secondary: resolve(self.colors.secondary, defaults.secondary, "secondary"),
400            error: resolve(self.colors.error, defaults.error, "error"),
401            warning: resolve(self.colors.warning, defaults.warning, "warning"),
402            success: resolve(self.colors.success, defaults.success, "success"),
403            muted: resolve(self.colors.muted, defaults.muted, "muted"),
404            accent: resolve(self.colors.accent, defaults.accent, "accent"),
405            border: resolve(self.colors.border, defaults.border, "border"),
406            user_border: resolve(self.colors.user_border, defaults.user_border, "user_border"),
407            user_bg: resolve(self.colors.user_bg, defaults.user_bg, "user_bg"),
408            cursor_fg: resolve(self.colors.cursor_fg, defaults.cursor_fg, "cursor_fg"),
409            cursor_bg: resolve(self.colors.cursor_bg, defaults.cursor_bg, "cursor_bg"),
410            selection_bg: resolve(
411                self.colors.selection_bg,
412                defaults.selection_bg,
413                "selection_bg",
414            ),
415            code_fg: resolve(self.colors.code_fg, defaults.code_fg, "code_fg"),
416            code_bg: resolve(self.colors.code_bg, defaults.code_bg, "code_bg"),
417            tool_pending_bg: resolve(
418                self.colors.tool_pending_bg,
419                defaults.tool_pending_bg,
420                "tool_pending_bg",
421            ),
422            tool_executing_bg: resolve(
423                self.colors.tool_executing_bg,
424                defaults.tool_executing_bg,
425                "tool_executing_bg",
426            ),
427            tool_success_bg: resolve(
428                self.colors.tool_success_bg,
429                defaults.tool_success_bg,
430                "tool_success_bg",
431            ),
432            tool_error_bg: resolve(
433                self.colors.tool_error_bg,
434                defaults.tool_error_bg,
435                "tool_error_bg",
436            ),
437        };
438        Theme {
439            name: if self.name.is_empty() {
440                "custom".into()
441            } else {
442                self.name
443            },
444            colors,
445            spacing: Spacing::default(),
446        }
447    }
448}
449
450/// Parse a color string.
451///
452/// Accepted forms:
453/// - Named: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`
454/// - Bright named: `bright-black`, `bright-red`, ...
455/// - Hex: `#rrggbb`
456/// - Indexed: `i<N>` where N is 0–255
457fn parse_color(s: &str) -> Option<Color> {
458    let s = s.trim();
459    // Hex
460    if let Some(hex) = s.strip_prefix('#') {
461        return parse_hex(hex);
462    }
463    // Indexed
464    if let Some(idx_str) = s.strip_prefix('i') {
465        if let Ok(n) = idx_str.parse::<u8>() {
466            return Some(Color::Indexed(n));
467        }
468    }
469    // Named
470    match s.to_lowercase().as_str() {
471        "black" => Some(Color::Black),
472        "red" => Some(Color::Red),
473        "green" => Some(Color::Green),
474        "yellow" => Some(Color::Yellow),
475        "blue" => Some(Color::Blue),
476        "magenta" => Some(Color::Magenta),
477        "cyan" => Some(Color::Cyan),
478        "white" => Some(Color::White),
479        "bright-black" | "brightblack" | "gray" | "grey" => Some(Color::Indexed(8)),
480        "bright-red" | "brightred" => Some(Color::Indexed(9)),
481        "bright-green" | "brightgreen" => Some(Color::Indexed(10)),
482        "bright-yellow" | "brightyellow" => Some(Color::Indexed(11)),
483        "bright-blue" | "brightblue" => Some(Color::Indexed(12)),
484        "bright-magenta" | "brightmagenta" => Some(Color::Indexed(13)),
485        "bright-cyan" | "brightcyan" => Some(Color::Indexed(14)),
486        "bright-white" | "brightwhite" => Some(Color::Indexed(15)),
487        "default" => Some(Color::Default),
488        _ => None,
489    }
490}
491
492fn parse_hex(hex: &str) -> Option<Color> {
493    match hex.len() {
494        6 => {
495            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
496            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
497            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
498            Some(Color::Rgb(r, g, b))
499        }
500        3 => {
501            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
502            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
503            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
504            Some(Color::Rgb(r, g, b))
505        }
506        _ => None,
507    }
508}
509
510// ---------------------------------------------------------------------------
511// Theme manager with hot-reload
512// ---------------------------------------------------------------------------
513
514/// Manages the active theme and optionally watches a theme file for changes.
515pub struct ThemeManager {
516    /// Currently active theme.
517    theme: Arc<parking_lot::RwLock<Theme>>,
518    /// Optional file being watched.
519    watch_path: Option<PathBuf>,
520    /// Last known modification time.
521    last_modified: Option<std::time::SystemTime>,
522    /// Polling interval for file changes.
523    poll_interval: std::time::Duration,
524    /// Instant of last poll.
525    last_poll: Instant,
526}
527
528impl ThemeManager {
529    /// Create a new manager with the given theme.
530    pub fn new(theme: Theme) -> Self {
531        Self {
532            theme: Arc::new(parking_lot::RwLock::new(theme)),
533            watch_path: None,
534            last_modified: None,
535            poll_interval: std::time::Duration::from_secs(1),
536            last_poll: Instant::now(),
537        }
538    }
539
540    /// Create a manager that starts with the default dark theme.
541    pub fn dark() -> Self {
542        Self::new(Theme::dark())
543    }
544
545    /// Create a manager that starts with the default light theme.
546    pub fn light() -> Self {
547        Self::new(Theme::light())
548    }
549
550    /// Start watching a theme file for changes.
551    ///
552    /// The file format is auto-detected from the extension (`.toml` or `.json`).
553    /// On each call to [`ThemeManager::check_reload`], the file's mtime is
554    /// compared to the last known value; if it changed, the theme is reloaded.
555    pub fn watch_file(&mut self, path: impl Into<PathBuf>) -> anyhow::Result<()> {
556        let path = path.into();
557        // Immediately load the theme
558        let file = ThemeFile::load(&path)?;
559        let theme = file.into_theme();
560        *self.theme.write() = theme;
561        self.last_modified = std::fs::metadata(&path)
562            .ok()
563            .and_then(|m| m.modified().ok());
564        self.watch_path = Some(path);
565        Ok(())
566    }
567
568    /// Get a clone of the current theme.
569    pub fn theme(&self) -> Theme {
570        self.theme.read().clone()
571    }
572
573    /// Get a handle to the shared theme lock.
574    pub fn theme_handle(&self) -> Arc<parking_lot::RwLock<Theme>> {
575        Arc::clone(&self.theme)
576    }
577
578    /// Replace the active theme.
579    pub fn set_theme(&self, theme: Theme) {
580        *self.theme.write() = theme;
581    }
582
583    /// Set the active theme by name ("dark" or "light").
584    ///
585    /// Returns `true` if the name was recognized.
586    pub fn set_theme_by_name(&self, name: &str) -> bool {
587        let theme = match name {
588            "dark" => Theme::dark(),
589            "light" => Theme::light(),
590            _ => return false,
591        };
592        self.set_theme(theme);
593        true
594    }
595
596    /// Check if the watched file has changed and reload if so.
597    ///
598    /// Call this periodically (e.g. once per event-loop tick).
599    /// Returns `true` if the theme was reloaded.
600    pub fn check_reload(&mut self) -> bool {
601        let path = match &self.watch_path {
602            Some(p) => p.clone(),
603            None => return false,
604        };
605
606        // Throttle polling
607        if self.last_poll.elapsed() < self.poll_interval {
608            return false;
609        }
610        self.last_poll = Instant::now();
611
612        let current_mtime = match std::fs::metadata(&path)
613            .ok()
614            .and_then(|m| m.modified().ok())
615        {
616            Some(t) => t,
617            None => return false,
618        };
619
620        let changed = match self.last_modified {
621            Some(prev) => current_mtime > prev,
622            None => true,
623        };
624
625        if changed {
626            match ThemeFile::load(&path) {
627                Ok(file) => {
628                    let theme = file.into_theme();
629                    *self.theme.write() = theme;
630                    self.last_modified = Some(current_mtime);
631                    tracing::info!("Theme reloaded from {:?}", path);
632                    true
633                }
634                Err(e) => {
635                    tracing::warn!("Failed to reload theme from {:?}: {}", path, e);
636                    false
637                }
638            }
639        } else {
640            false
641        }
642    }
643
644    /// Set the polling interval for file watching (default 1s).
645    pub fn set_poll_interval(&mut self, interval: std::time::Duration) {
646        self.poll_interval = interval;
647    }
648}
649
650impl fmt::Display for Theme {
651    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652        write!(f, "Theme({})", self.name)
653    }
654}
655
656// ---------------------------------------------------------------------------
657// Tests
658// ---------------------------------------------------------------------------
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn default_theme_is_dark() {
666        let theme = Theme::default();
667        assert_eq!(theme.name, "dark");
668    }
669
670    #[test]
671    fn dark_theme_has_light_foreground() {
672        let theme = Theme::dark();
673        // foreground should be a light color
674        match theme.colors.foreground {
675            Color::Rgb(r, _, _) => assert!(r > 200, "dark theme foreground should be light"),
676            _ => panic!("expected Rgb foreground"),
677        }
678    }
679
680    #[test]
681    fn light_theme_has_dark_foreground() {
682        let theme = Theme::light();
683        match theme.colors.foreground {
684            Color::Rgb(r, _, _) => assert!(r < 150, "light theme foreground should be dark"),
685            _ => panic!("expected Rgb foreground"),
686        }
687    }
688
689    #[test]
690    fn parse_hex_colors() {
691        assert_eq!(parse_color("#ff8800"), Some(Color::Rgb(255, 136, 0)));
692        assert_eq!(parse_color("#f80"), Some(Color::Rgb(255, 136, 0)));
693    }
694
695    #[test]
696    fn parse_named_colors() {
697        assert_eq!(parse_color("red"), Some(Color::Red));
698        assert_eq!(parse_color("bright-black"), Some(Color::Indexed(8)));
699        assert_eq!(parse_color("default"), Some(Color::Default));
700    }
701
702    #[test]
703    fn parse_indexed_color() {
704        assert_eq!(parse_color("i42"), Some(Color::Indexed(42)));
705    }
706
707    #[test]
708    fn theme_manager_set_by_name() {
709        let mgr = ThemeManager::dark();
710        assert!(mgr.set_theme_by_name("light"));
711        assert_eq!(mgr.theme().name, "light");
712        assert!(!mgr.set_theme_by_name("nonexistent"));
713        assert_eq!(mgr.theme().name, "light");
714    }
715
716    #[test]
717    fn theme_file_from_json() {
718        let json = r##"{"name":"test","colors":{"foreground":"#ffffff","background":"#000000"}}"##;
719        let file: ThemeFile = serde_json::from_str(json).unwrap();
720        let theme = file.into_theme();
721        assert_eq!(theme.name, "test");
722        assert_eq!(theme.colors.foreground, Color::Rgb(255, 255, 255));
723        assert_eq!(theme.colors.background, Color::Rgb(0, 0, 0));
724    }
725
726    #[test]
727    fn theme_file_roundtrip() {
728        let dir = std::env::temp_dir().join("oxi-tui-theme-test");
729        std::fs::create_dir_all(&dir).unwrap();
730
731        let json_path = dir.join("test_theme.json");
732        std::fs::write(
733            &json_path,
734            r##"{"name":"mytheme","colors":{"primary":"#ff0000"}}"##,
735        )
736        .unwrap();
737        let file = ThemeFile::load(&json_path).unwrap();
738        let theme = file.into_theme();
739        assert_eq!(theme.name, "mytheme");
740        assert_eq!(theme.colors.primary, Color::Rgb(255, 0, 0));
741        // Other fields get dark defaults
742        assert!(matches!(theme.colors.foreground, Color::Rgb(_, _, _)));
743
744        std::fs::remove_dir_all(&dir).ok();
745    }
746}