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