Skip to main content

rab/agent/ui/
theme.rs

1use crate::tui::Theme;
2use crate::tui::components::markdown::{MarkdownTheme, StyleFn, create_highlight_fn};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::AtomicU16;
8
9// ── Color Types ──────────────────────────────────────────────────
10
11/// A color value in a theme config: hex string "#ff0000", var reference "accent",
12/// 256-color index (0-255), or empty string for default terminal color.
13#[derive(Debug, Clone, Deserialize)]
14#[serde(untagged)]
15pub enum ColorValue {
16    HexOrVar(String),
17    Index(u8),
18}
19
20/// Theme JSON structure matching pi's theme format.
21#[derive(Debug, Clone, Deserialize)]
22pub struct ThemeConfig {
23    pub name: String,
24    #[serde(default)]
25    pub vars: HashMap<String, String>,
26    pub colors: HashMap<String, ColorValue>,
27}
28
29/// Terminal color capability mode.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum ColorMode {
32    TrueColor,
33    Ansi256,
34}
35
36// ── RabTheme ─────────────────────────────────────────────────────
37
38/// The concrete theme used by the rab UI.
39/// Wraps resolved ANSI escape codes for foregrounds, backgrounds, and text styling.
40#[derive(Debug, Clone)]
41pub struct RabTheme {
42    pub name: String,
43    mode: ColorMode,
44    fg_ansi: HashMap<String, String>,
45    bg_ansi: HashMap<String, String>,
46}
47
48impl RabTheme {
49    /// Parse a hex color like "#ff0000" into (r,g,b).
50    fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
51        let hex = hex.trim_start_matches('#');
52        if hex.len() != 6 {
53            return None;
54        }
55        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
56        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
57        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
58        Some((r, g, b))
59    }
60
61    /// Convert an (r,g,b) to the closest 256-color ANSI index.
62    fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
63        const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
64        const GRAY_VALUES: [u8; 24] = [
65            8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188,
66            198, 208, 218, 228, 238,
67        ];
68
69        let find_closest = |value: u8, table: &[u8]| -> usize {
70            let mut min_dist = u16::MAX;
71            let mut min_idx = 0;
72            for (i, &v) in table.iter().enumerate() {
73                let dist = value.abs_diff(v);
74                if (dist as u16) < min_dist {
75                    min_dist = dist as u16;
76                    min_idx = i;
77                }
78            }
79            min_idx
80        };
81
82        let ri = find_closest(r, &CUBE_VALUES);
83        let gi = find_closest(g, &CUBE_VALUES);
84        let bi = find_closest(b, &CUBE_VALUES);
85        let cube_index = 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8;
86
87        // Check grayscale
88        let gray = (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000;
89        let gi = find_closest(gray as u8, &GRAY_VALUES);
90        let gray_index = 232 + gi as u8;
91
92        let spread = r.max(g).max(b) - r.min(g).min(b);
93        if spread < 10 {
94            return gray_index;
95        }
96        cube_index
97    }
98
99    /// Build the ANSI escape code for a foreground color.
100    fn fg_escape(color: &str, mode: ColorMode) -> String {
101        if color.is_empty() {
102            return "\x1b[39m".to_string();
103        }
104        if let Ok(idx) = color.parse::<u8>() {
105            return format!("\x1b[38;5;{}m", idx);
106        }
107        if let Some((r, g, b)) = Self::hex_to_rgb(color) {
108            return match mode {
109                ColorMode::TrueColor => format!("\x1b[38;2;{};{};{}m", r, g, b),
110                ColorMode::Ansi256 => format!("\x1b[38;5;{}m", Self::rgb_to_256(r, g, b)),
111            };
112        }
113        "\x1b[39m".to_string()
114    }
115
116    /// Build the ANSI escape code for a background color.
117    fn bg_escape(color: &str, mode: ColorMode) -> String {
118        if color.is_empty() {
119            return "\x1b[49m".to_string();
120        }
121        if let Ok(idx) = color.parse::<u8>() {
122            return format!("\x1b[48;5;{}m", idx);
123        }
124        if let Some((r, g, b)) = Self::hex_to_rgb(color) {
125            return match mode {
126                ColorMode::TrueColor => format!("\x1b[48;2;{};{};{}m", r, g, b),
127                ColorMode::Ansi256 => format!("\x1b[48;5;{}m", Self::rgb_to_256(r, g, b)),
128            };
129        }
130        "\x1b[49m".to_string()
131    }
132
133    /// Resolve variable references and color values to hex strings.
134    fn resolve_colors(config: &ThemeConfig) -> HashMap<String, String> {
135        let mut resolved: HashMap<String, String> = HashMap::new();
136
137        for (name, value) in &config.colors {
138            let hex = match value {
139                ColorValue::HexOrVar(s) => {
140                    if s.starts_with('#') {
141                        s.clone()
142                    } else if let Some(v) = config.vars.get(s) {
143                        v.clone()
144                    } else {
145                        s.clone()
146                    }
147                }
148                ColorValue::Index(idx) => idx.to_string(),
149            };
150            resolved.insert(name.clone(), hex);
151        }
152        resolved
153    }
154
155    /// Background color keys (matching pi's bgColorKeys set).
156    const BG_KEYS: &'static [&'static str] = &[
157        "selectedBg",
158        "userMessageBg",
159        "customMessageBg",
160        "toolPendingBg",
161        "toolSuccessBg",
162        "toolErrorBg",
163        "thinking_bg",
164    ];
165
166    /// Build a RabTheme from a ThemeConfig.
167    pub fn from_config(config: &ThemeConfig, mode: ColorMode) -> Self {
168        let colors = Self::resolve_colors(config);
169
170        let mut fg_ansi = HashMap::new();
171        let mut bg_ansi = HashMap::new();
172
173        for (key, value) in &colors {
174            if Self::BG_KEYS.contains(&key.as_str()) {
175                bg_ansi.insert(key.clone(), Self::bg_escape(value, mode));
176            } else {
177                fg_ansi.insert(key.clone(), Self::fg_escape(value, mode));
178            }
179        }
180
181        // Add thinking_bg as a derived background from thinkingText
182        if let Some(text_color) = colors.get("thinkingText")
183            && !bg_ansi.contains_key("thinking_bg")
184        {
185            // Darken thinkingText for background
186            let bg_color = if let Some((r, g, b)) = Self::hex_to_rgb(text_color) {
187                let dr = (r as f64 * 0.7) as u8;
188                let dg = (g as f64 * 0.7) as u8;
189                let db = (b as f64 * 0.7) as u8;
190                format!("#{:02x}{:02x}{:02x}", dr, dg, db)
191            } else {
192                text_color.clone()
193            };
194            bg_ansi.insert("thinking_bg".to_string(), Self::bg_escape(&bg_color, mode));
195        }
196
197        Self {
198            name: config.name.clone(),
199            mode,
200            fg_ansi,
201            bg_ansi,
202        }
203    }
204
205    /// Get the ANSI foreground escape code for a color name.
206    pub fn fg_ansi(&self, color: &str) -> &str {
207        self.fg_ansi
208            .get(color)
209            .map(|s| s.as_str())
210            .unwrap_or("\x1b[39m")
211    }
212
213    /// Get the ANSI background escape code for a color name.
214    pub fn bg_ansi(&self, color: &str) -> &str {
215        self.bg_ansi
216            .get(color)
217            .map(|s| s.as_str())
218            .unwrap_or("\x1b[49m")
219    }
220
221    /// Apply a foreground color to text.
222    pub fn fg(&self, color: &str, text: &str) -> String {
223        format!("{}{}\x1b[39m", self.fg_ansi(color), text)
224    }
225
226    /// Apply a background color to text.
227    pub fn bg(&self, color: &str, text: &str) -> String {
228        format!("{}{}\x1b[49m", self.bg_ansi(color), text)
229    }
230
231    /// Apply bold styling.
232    pub fn bold(&self, text: &str) -> String {
233        format!("\x1b[1m{}\x1b[22m", text)
234    }
235
236    /// Apply italic styling.
237    pub fn italic(&self, text: &str) -> String {
238        format!("\x1b[3m{}\x1b[23m", text)
239    }
240
241    /// Apply reverse/inverse video styling (used for intra-line diff highlighting).
242    pub fn inverse(&self, text: &str) -> String {
243        format!("\x1b[7m{}\x1b[27m", text)
244    }
245
246    /// Apply underline styling.
247    pub fn underline(&self, text: &str) -> String {
248        format!("\x1b[4m{}\x1b[24m", text)
249    }
250
251    /// Apply strikethrough styling.
252    pub fn strikethrough(&self, text: &str) -> String {
253        format!("\x1b[9m{}\x1b[29m", text)
254    }
255
256    /// Get the color mode.
257    pub fn color_mode(&self) -> ColorMode {
258        self.mode
259    }
260
261    /// Convenience: apply bold + fg
262    pub fn bold_fg(&self, color: &str, text: &str) -> String {
263        format!("\x1b[1m{}{}\x1b[22m\x1b[39m", self.fg_ansi(color), text)
264    }
265
266    // ── Convenience helpers matching the old RabTheme API ──
267
268    /// Apply accent foreground color.
269    pub fn accent(&self, text: &str) -> String {
270        self.fg("accent", text)
271    }
272
273    /// Apply dim foreground color.
274    pub fn dim(&self, text: &str) -> String {
275        self.fg("dim", text)
276    }
277
278    /// Apply muted foreground color.
279    pub fn muted(&self, text: &str) -> String {
280        self.fg("muted", text)
281    }
282
283    /// Apply success foreground color.
284    pub fn success(&self, text: &str) -> String {
285        self.fg("success", text)
286    }
287
288    /// Apply error foreground color.
289    pub fn error(&self, text: &str) -> String {
290        self.fg("error", text)
291    }
292
293    /// Apply text foreground color.
294    pub fn text_color(&self, text: &str) -> String {
295        self.fg("text", text)
296    }
297
298    /// Apply border foreground color.
299    pub fn border(&self, text: &str) -> String {
300        self.fg("border", text)
301    }
302
303    /// Apply user message background.
304    pub fn user_msg_bg(&self, text: &str) -> String {
305        self.bg("userMessageBg", text)
306    }
307
308    /// Apply thinking block background.
309    pub fn thinking_bg(&self, text: &str) -> String {
310        self.bg("thinking_bg", text)
311    }
312
313    /// Bold + accent foreground.
314    pub fn bold_accent(&self, text: &str) -> String {
315        self.bold_fg("accent", text)
316    }
317
318    // ── Style API ──
319
320    /// Create a `Style` with a foreground color resolved from a color name.
321    pub fn fg_style(&self, color: &str) -> crate::tui::Style {
322        crate::tui::Style::new().fg(self.fg_ansi(color).to_string())
323    }
324
325    /// Create a `Style` with a background color resolved from a color name.
326    pub fn bg_style(&self, color: &str) -> crate::tui::Style {
327        crate::tui::Style::new().bg(self.bg_ansi(color).to_string())
328    }
329}
330
331// ── ThemeKey Enum ───────────────────────────────────────────────
332
333pub use crate::tui::ThemeKey;
334
335impl RabTheme {
336    /// Get ANSI foreground escape code for a `ThemeKey`.
337    pub fn fg_ansi_key(&self, key: ThemeKey) -> &str {
338        self.fg_ansi(key.as_str())
339    }
340
341    /// Get ANSI background escape code for a `ThemeKey`.
342    pub fn bg_ansi_key(&self, key: ThemeKey) -> &str {
343        self.bg_ansi(key.as_str())
344    }
345
346    /// Apply foreground color from a `ThemeKey`.
347    pub fn fg_key(&self, key: ThemeKey, text: &str) -> String {
348        self.fg(key.as_str(), text)
349    }
350
351    /// Apply background color from a `ThemeKey`.
352    pub fn bg_key(&self, key: ThemeKey, text: &str) -> String {
353        self.bg(key.as_str(), text)
354    }
355
356    /// Create a `Style` with foreground from a `ThemeKey`.
357    pub fn fg_style_key(&self, key: ThemeKey) -> crate::tui::Style {
358        self.fg_style(key.as_str())
359    }
360
361    /// Create a `Style` with background from a `ThemeKey`.
362    pub fn bg_style_key(&self, key: ThemeKey) -> crate::tui::Style {
363        self.bg_style(key.as_str())
364    }
365}
366
367impl Theme for RabTheme {
368    fn fg(&self, color: &str, text: &str) -> String {
369        self.fg(color, text)
370    }
371
372    fn bg(&self, color: &str, text: &str) -> String {
373        self.bg(color, text)
374    }
375
376    fn bold(&self, text: &str) -> String {
377        self.bold(text)
378    }
379
380    fn italic(&self, text: &str) -> String {
381        self.italic(text)
382    }
383
384    fn inverse(&self, text: &str) -> String {
385        self.inverse(text)
386    }
387
388    fn fg_ansi(&self, color: &str) -> &str {
389        self.fg_ansi(color)
390    }
391
392    fn fg_ansi_key(&self, key: ThemeKey) -> &str {
393        self.fg_ansi_key(key)
394    }
395}
396
397// ── Global Theme State ───────────────────────────────────────────
398
399use std::sync::{Mutex, OnceLock};
400
401static THEME: OnceLock<Mutex<RabTheme>> = OnceLock::new();
402static THEME_MODE: AtomicU16 = AtomicU16::new(1); // 1=truecolor
403
404fn get_theme_lock() -> &'static Mutex<RabTheme> {
405    THEME.get_or_init(|| Mutex::new(fallback_theme()))
406}
407
408/// Initialize the theme system. Call once at startup.
409pub fn init_theme(theme_name: Option<&str>, force_256: bool) {
410    let mode = if force_256 {
411        ColorMode::Ansi256
412    } else {
413        ColorMode::TrueColor
414    };
415    THEME_MODE.store(
416        if force_256 { 2 } else { 1 },
417        std::sync::atomic::Ordering::Relaxed,
418    );
419
420    let name = theme_name.unwrap_or("dark");
421    match load_theme_config(name) {
422        Ok(config) => {
423            let theme = RabTheme::from_config(&config, mode);
424            if let Ok(mut t) = get_theme_lock().lock() {
425                *t = theme;
426            }
427        }
428        Err(_) => {
429            // Fall back to dark
430            if name != "dark"
431                && let Ok(config) = load_theme_config("dark")
432            {
433                let theme = RabTheme::from_config(&config, mode);
434                if let Ok(mut t) = get_theme_lock().lock() {
435                    *t = theme;
436                }
437            }
438        }
439    }
440}
441
442/// Load a theme by name. Checks built-in themes first, then custom themes directory.
443fn load_theme_config(name: &str) -> Result<ThemeConfig, String> {
444    match name {
445        "dark" => {
446            let json = include_str!("themes/dark.json");
447            serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
448        }
449        "light" => {
450            let json = include_str!("themes/light.json");
451            serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
452        }
453        _ => {
454            let themes_dir = get_themes_dir();
455            let theme_path = themes_dir.join(format!("{}.json", name));
456            if theme_path.exists() {
457                let content = std::fs::read_to_string(&theme_path).map_err(|e| e.to_string())?;
458                serde_json::from_str::<ThemeConfig>(&content).map_err(|e| e.to_string())
459            } else {
460                Err(format!("Theme not found: {}", name))
461            }
462        }
463    }
464}
465
466/// Get the custom themes directory (~/.rab/themes).
467fn get_themes_dir() -> PathBuf {
468    let base = directories::BaseDirs::new()
469        .map(|d| d.home_dir().join(".rab"))
470        .unwrap_or_else(|| PathBuf::from("/tmp/.rab"));
471    let dir = base.join("themes");
472    let _ = std::fs::create_dir_all(&dir);
473    dir
474}
475
476/// Get available theme names.
477pub fn get_available_themes() -> Vec<String> {
478    let mut themes: Vec<String> = vec!["dark".to_string(), "light".to_string()];
479
480    let themes_dir = get_themes_dir();
481    if let Ok(entries) = std::fs::read_dir(&themes_dir) {
482        for entry in entries.flatten() {
483            let path = entry.path();
484            if path.extension().map(|e| e == "json").unwrap_or(false)
485                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
486                && name != "dark"
487                && name != "light"
488            {
489                themes.push(name.to_string());
490            }
491        }
492    }
493
494    themes.sort();
495    themes.dedup();
496    themes
497}
498
499/// Get the current theme.
500pub fn current_theme() -> std::sync::MutexGuard<'static, RabTheme> {
501    get_theme_lock().lock().expect("Theme lock poisoned")
502}
503
504/// Set a new theme by name. Returns success/error.
505pub fn set_theme(name: &str) -> Result<(), String> {
506    let mode = match THEME_MODE.load(std::sync::atomic::Ordering::Relaxed) {
507        2 => ColorMode::Ansi256,
508        _ => ColorMode::TrueColor,
509    };
510    let config = load_theme_config(name)?;
511    let theme = RabTheme::from_config(&config, mode);
512    if let Ok(mut t) = get_theme_lock().lock() {
513        *t = theme;
514    }
515    Ok(())
516}
517
518/// Detect terminal background theme using environment variables.
519/// Returns "dark" or "light".
520pub fn detect_terminal_theme() -> &'static str {
521    if let Ok(colorfgbg) = std::env::var("COLORFGBG")
522        && let Some(bg_str) = colorfgbg.split(';').next_back()
523        && let Ok(bg) = bg_str.trim().parse::<u8>()
524    {
525        let luminance = match bg {
526            0..=7 => 0.2,
527            8..=15 => 0.8,
528            _ => {
529                // 256-color: approximate luminance
530
531                (bg - 16) as f64 / 239.0
532            }
533        };
534        return if luminance > 0.5 { "light" } else { "dark" };
535    }
536    "dark"
537}
538
539/// Fallback theme for when no theme is loaded yet.
540fn fallback_theme() -> RabTheme {
541    let mut config = ThemeConfig {
542        name: "dark".into(),
543        vars: HashMap::new(),
544        colors: HashMap::new(),
545    };
546    let entries: Vec<(&str, &str)> = vec![
547        ("text", "#d4d4d4"),
548        ("dim", "#666666"),
549        ("muted", "#808080"),
550        ("accent", "#8abeb7"),
551        ("success", "#b5bd68"),
552        ("error", "#cc6666"),
553        ("warning", "#ffff00"),
554        ("thinkingText", "#808080"),
555        ("thinking_level_low", "#5f87af"),
556        ("thinking_level_medium", "#81a2be"),
557        ("thinking_level_high", "#b294bb"),
558        ("thinking_level_xhigh", "#d183e8"),
559        ("userMessageBg", "#343541"),
560        ("toolPendingBg", "#282832"),
561        ("toolSuccessBg", "#283228"),
562        ("toolErrorBg", "#3c2828"),
563        ("toolTitle", "#d4d4d4"),
564        ("toolOutput", "#808080"),
565    ];
566    for (k, v) in entries {
567        config
568            .colors
569            .insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
570    }
571    RabTheme::from_config(&config, ColorMode::TrueColor)
572}
573
574/// Build a `MarkdownTheme` from the current `RabTheme`.
575/// Wires all existing `md*` colors and text decorations.
576pub fn get_markdown_theme() -> MarkdownTheme {
577    let theme = current_theme();
578
579    let heading = mk_style(theme.fg_ansi("mdHeading"));
580    let link = mk_style(theme.fg_ansi("mdLink"));
581    let link_url = mk_style(theme.fg_ansi("mdLinkUrl"));
582    let code = mk_style(theme.fg_ansi("mdCode"));
583    let code_block = mk_style(theme.fg_ansi("mdCodeBlock"));
584    let code_block_border = mk_style(theme.fg_ansi("mdCodeBlockBorder"));
585    let quote = mk_style(theme.fg_ansi("mdQuote"));
586    let quote_border = mk_style(theme.fg_ansi("mdQuoteBorder"));
587    let hr = mk_style(theme.fg_ansi("mdHr"));
588    let list_bullet = mk_style(theme.fg_ansi("mdListBullet"));
589
590    // Release the lock before building closures
591    drop(theme);
592
593    let mut md = MarkdownTheme::new(
594        heading,
595        link,
596        link_url,
597        code,
598        code_block,
599        code_block_border,
600        quote,
601        quote_border,
602        hr,
603        list_bullet,
604        style_bold(),
605        style_italic(),
606        style_strikethrough(),
607        style_underline(),
608    );
609    md.highlight_code = create_highlight_fn();
610    md
611}
612
613/// Build a style function that wraps text with a foreground ANSI prefix and reset suffix.
614fn mk_style(prefix: &str) -> StyleFn {
615    let p = prefix.to_string();
616    Arc::new(move |text: &str| format!("{}{}\x1b[39m", p, text))
617}
618
619/// Build a bold style function.
620fn style_bold() -> StyleFn {
621    Arc::new(|text: &str| format!("\x1b[1m{}\x1b[22m", text))
622}
623
624/// Build an italic style function.
625fn style_italic() -> StyleFn {
626    Arc::new(|text: &str| format!("\x1b[3m{}\x1b[23m", text))
627}
628
629/// Build a strikethrough style function.
630fn style_strikethrough() -> StyleFn {
631    Arc::new(|text: &str| format!("\x1b[9m{}\x1b[29m", text))
632}
633
634/// Build an underline style function.
635fn style_underline() -> StyleFn {
636    Arc::new(|text: &str| format!("\x1b[4m{}\x1b[24m", text))
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    fn test_load_dark_theme() {
645        let config = load_theme_config("dark").unwrap();
646        assert_eq!(config.name, "dark");
647        assert!(config.colors.contains_key("accent"));
648        assert!(config.colors.contains_key("text"));
649    }
650
651    #[test]
652    fn test_load_light_theme() {
653        let config = load_theme_config("light").unwrap();
654        assert_eq!(config.name, "light");
655        assert!(config.colors.contains_key("accent"));
656    }
657
658    #[test]
659    fn test_resolve_colors() {
660        let config = load_theme_config("dark").unwrap();
661        let colors = RabTheme::resolve_colors(&config);
662        assert!(colors.contains_key("accent"));
663        assert!(colors.contains_key("text"));
664        assert!(colors.get("accent").unwrap().starts_with('#'));
665    }
666
667    #[test]
668    fn test_theme_from_config() {
669        let config = load_theme_config("dark").unwrap();
670        let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
671        let colored = theme.fg("accent", "hello");
672        assert!(colored.contains("hello"));
673        assert!(colored.contains("\x1b[38;2;"));
674        assert!(colored.ends_with("\x1b[39m"));
675    }
676
677    #[test]
678    fn test_theme_256_fallback() {
679        let config = load_theme_config("dark").unwrap();
680        let theme = RabTheme::from_config(&config, ColorMode::Ansi256);
681        let colored = theme.fg("accent", "hello");
682        assert!(colored.contains("hello"));
683        assert!(colored.contains("\x1b[38;5;"));
684    }
685
686    #[test]
687    fn test_bold_italic() {
688        let config = load_theme_config("dark").unwrap();
689        let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
690        assert_eq!(theme.bold("x"), "\x1b[1mx\x1b[22m");
691        assert_eq!(theme.italic("x"), "\x1b[3mx\x1b[23m");
692    }
693
694    #[test]
695    fn test_hex_to_rgb() {
696        assert_eq!(RabTheme::hex_to_rgb("#ff0000"), Some((255, 0, 0)));
697        assert_eq!(RabTheme::hex_to_rgb("00ff00"), Some((0, 255, 0)));
698        assert_eq!(RabTheme::hex_to_rgb("#zzz"), None);
699    }
700
701    #[test]
702    fn test_fallback_theme() {
703        let theme = fallback_theme();
704        assert_eq!(theme.name, "dark");
705        let text = theme.fg("text", "test");
706        assert!(text.contains("test"));
707    }
708
709    #[test]
710    fn test_set_and_get() {
711        init_theme(Some("dark"), false);
712        let theme = current_theme();
713        assert_eq!(theme.name, "dark");
714    }
715}