Skip to main content

textual_rs/css/
theme.rs

1//! Built-in themes and theme variable resolution for TCSS color values.
2
3use std::collections::HashMap;
4
5use super::types::TcssColor;
6
7/// Convert RGB (0-255) to HSL (H: 0-360, S: 0.0-1.0, L: 0.0-1.0).
8fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
9    let rf = r as f64 / 255.0;
10    let gf = g as f64 / 255.0;
11    let bf = b as f64 / 255.0;
12
13    let max = rf.max(gf).max(bf);
14    let min = rf.min(gf).min(bf);
15    let delta = max - min;
16
17    let l = (max + min) / 2.0;
18
19    if delta < 1e-10 {
20        return (0.0, 0.0, l);
21    }
22
23    let s = if l <= 0.5 {
24        delta / (max + min)
25    } else {
26        delta / (2.0 - max - min)
27    };
28
29    let h = if (max - rf).abs() < 1e-10 {
30        let mut h = (gf - bf) / delta;
31        if h < 0.0 {
32            h += 6.0;
33        }
34        h * 60.0
35    } else if (max - gf).abs() < 1e-10 {
36        ((bf - rf) / delta + 2.0) * 60.0
37    } else {
38        ((rf - gf) / delta + 4.0) * 60.0
39    };
40
41    (h, s, l)
42}
43
44/// Convert HSL (H: 0-360, S: 0.0-1.0, L: 0.0-1.0) back to RGB (0-255).
45fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
46    if s < 1e-10 {
47        let v = (l * 255.0).round() as u8;
48        return (v, v, v);
49    }
50
51    let q = if l < 0.5 {
52        l * (1.0 + s)
53    } else {
54        l + s - l * s
55    };
56    let p = 2.0 * l - q;
57    let h_norm = h / 360.0;
58
59    let hue_to_rgb = |t: f64| -> f64 {
60        let mut t = t;
61        if t < 0.0 {
62            t += 1.0;
63        }
64        if t > 1.0 {
65            t -= 1.0;
66        }
67        if t < 1.0 / 6.0 {
68            p + (q - p) * 6.0 * t
69        } else if t < 0.5 {
70            q
71        } else if t < 2.0 / 3.0 {
72            p + (q - p) * (2.0 / 3.0 - t) * 6.0
73        } else {
74            p
75        }
76    };
77
78    let r = (hue_to_rgb(h_norm + 1.0 / 3.0) * 255.0).round() as u8;
79    let g = (hue_to_rgb(h_norm) * 255.0).round() as u8;
80    let b = (hue_to_rgb(h_norm - 1.0 / 3.0) * 255.0).round() as u8;
81
82    (r, g, b)
83}
84
85/// Adjust the luminosity of a color by `delta` (positive = lighten, negative = darken).
86/// Only operates on `TcssColor::Rgb`; other variants are returned unchanged.
87pub fn lighten_color(color: TcssColor, delta: f64) -> TcssColor {
88    match color {
89        TcssColor::Rgb(r, g, b) => {
90            let (h, s, l) = rgb_to_hsl(r, g, b);
91            let new_l = (l + delta).clamp(0.0, 1.0);
92            let (nr, ng, nb) = hsl_to_rgb(h, s, new_l);
93            TcssColor::Rgb(nr, ng, nb)
94        }
95        other => other,
96    }
97}
98
99/// A semantic theme with named color slots and shade generation.
100///
101/// Colors are stored as `(u8, u8, u8)` RGB tuples. The `resolve` method
102/// maps variable names like `"primary"`, `"primary-lighten-2"`, or
103/// `"accent-darken-1"` to concrete `TcssColor::Rgb` values.
104#[derive(Debug, Clone)]
105pub struct Theme {
106    /// The unique identifier name for this theme (e.g., "textual-dark").
107    pub name: String,
108    /// Primary brand color used for accented interactive elements.
109    pub primary: (u8, u8, u8),
110    /// Secondary brand color for supporting UI elements.
111    pub secondary: (u8, u8, u8),
112    /// Accent color for highlights and call-to-action elements.
113    pub accent: (u8, u8, u8),
114    /// Surface color for widget backgrounds.
115    pub surface: (u8, u8, u8),
116    /// Panel color for sidebars and container backgrounds.
117    pub panel: (u8, u8, u8),
118    /// Main application background color.
119    pub background: (u8, u8, u8),
120    /// Default text foreground color.
121    pub foreground: (u8, u8, u8),
122    /// Color for success/positive state indicators.
123    pub success: (u8, u8, u8),
124    /// Color for warning/caution state indicators.
125    pub warning: (u8, u8, u8),
126    /// Color for error/danger state indicators.
127    pub error: (u8, u8, u8),
128    /// Whether this is a dark theme; affects luminosity calculations.
129    pub dark: bool,
130    /// Luminosity delta per shade step for lighten/darken operations.
131    pub luminosity_spread: f64,
132    /// User-defined variable overrides. Checked before computed shades.
133    pub variables: HashMap<String, TcssColor>,
134}
135
136impl Theme {
137    /// Resolve a theme variable name to a concrete color.
138    ///
139    /// Supports base names (`"primary"`) and shade variants
140    /// (`"primary-lighten-2"`, `"accent-darken-1"`).
141    /// Checks `variables` HashMap first for user overrides.
142    pub fn resolve(&self, name: &str) -> Option<TcssColor> {
143        // Check user overrides first
144        if let Some(color) = self.variables.get(name) {
145            return Some(*color);
146        }
147
148        // Try to parse shade suffix: "base-lighten-N" or "base-darken-N"
149        let (base_name, shade_delta) = if let Some(rest) = name.strip_suffix("-lighten-1") {
150            (rest, Some(1))
151        } else if let Some(rest) = name.strip_suffix("-lighten-2") {
152            (rest, Some(2))
153        } else if let Some(rest) = name.strip_suffix("-lighten-3") {
154            (rest, Some(3))
155        } else if let Some(rest) = name.strip_suffix("-darken-1") {
156            (rest, Some(-1))
157        } else if let Some(rest) = name.strip_suffix("-darken-2") {
158            (rest, Some(-2))
159        } else if let Some(rest) = name.strip_suffix("-darken-3") {
160            (rest, Some(-3))
161        } else {
162            (name, None)
163        };
164
165        // Look up the base color from struct fields
166        let base_rgb = match base_name {
167            "primary" => Some(self.primary),
168            "secondary" => Some(self.secondary),
169            "accent" => Some(self.accent),
170            "surface" => Some(self.surface),
171            "panel" => Some(self.panel),
172            "background" => Some(self.background),
173            "foreground" => Some(self.foreground),
174            "success" => Some(self.success),
175            "warning" => Some(self.warning),
176            "error" => Some(self.error),
177            _ => None,
178        }?;
179
180        let base_color = TcssColor::Rgb(base_rgb.0, base_rgb.1, base_rgb.2);
181
182        match shade_delta {
183            None => Some(base_color),
184            Some(n) => {
185                let step = self.luminosity_spread / 2.0;
186                let delta = n as f64 * step;
187                Some(lighten_color(base_color, delta))
188            }
189        }
190    }
191}
192
193/// Blend two RGB colors: result = a * (1 - factor) + b * factor
194fn blend_rgb(a: (u8, u8, u8), b: (u8, u8, u8), factor: f64) -> (u8, u8, u8) {
195    let r = (a.0 as f64 * (1.0 - factor) + b.0 as f64 * factor).round() as u8;
196    let g = (a.1 as f64 * (1.0 - factor) + b.1 as f64 * factor).round() as u8;
197    let b_val = (a.2 as f64 * (1.0 - factor) + b.2 as f64 * factor).round() as u8;
198    (r, g, b_val)
199}
200
201/// Returns the default dark theme matching Python Textual's `textual-dark` palette.
202pub fn default_dark_theme() -> Theme {
203    let primary = (1, 120, 212);
204    let surface = (30, 30, 30);
205    let panel = blend_rgb(surface, primary, 0.1);
206
207    Theme {
208        name: "textual-dark".to_string(),
209        primary,
210        secondary: (0, 69, 120),
211        accent: (255, 166, 43),
212        surface,
213        panel,
214        background: (18, 18, 18),
215        foreground: (224, 224, 224),
216        success: (78, 191, 113),
217        warning: (255, 166, 43),
218        error: (186, 60, 91),
219        dark: true,
220        luminosity_spread: 0.15,
221        variables: HashMap::new(),
222    }
223}
224
225/// Returns the default light theme matching Python Textual's `textual-light` palette.
226pub fn default_light_theme() -> Theme {
227    let primary = (0, 120, 212);
228    let surface = (242, 242, 242);
229    let panel = blend_rgb(surface, primary, 0.1);
230
231    Theme {
232        name: "textual-light".to_string(),
233        primary,
234        secondary: (26, 95, 180),
235        accent: (214, 122, 0),
236        surface,
237        panel,
238        background: (255, 255, 255),
239        foreground: (36, 36, 36),
240        success: (22, 128, 57),
241        warning: (214, 122, 0),
242        error: (196, 43, 28),
243        dark: false,
244        luminosity_spread: 0.15,
245        variables: HashMap::new(),
246    }
247}
248
249/// Tokyo Night color scheme — a clean, dark theme inspired by Tokyo at night.
250pub fn tokyo_night_theme() -> Theme {
251    let bg = (26, 27, 38);
252    let primary = (122, 162, 247);
253    let surface = (36, 40, 59);
254    let panel = blend_rgb(surface, primary, 0.1);
255
256    Theme {
257        name: "tokyo-night".to_string(),
258        primary,
259        secondary: (125, 207, 255),
260        accent: (187, 154, 247),
261        surface,
262        panel,
263        background: bg,
264        foreground: (192, 202, 245),
265        success: (115, 218, 202),
266        warning: (224, 175, 104),
267        error: (247, 118, 142),
268        dark: true,
269        luminosity_spread: 0.15,
270        variables: HashMap::new(),
271    }
272}
273
274/// Nord color scheme — an arctic, north-bluish clean palette.
275pub fn nord_theme() -> Theme {
276    let bg = (46, 52, 64);
277    let primary = (136, 192, 208);
278    let surface = (59, 66, 82);
279    let panel = blend_rgb(surface, primary, 0.1);
280
281    Theme {
282        name: "nord".to_string(),
283        primary,
284        secondary: (129, 161, 193),
285        accent: (235, 203, 139),
286        surface,
287        panel,
288        background: bg,
289        foreground: (236, 239, 244),
290        success: (163, 190, 140),
291        warning: (235, 203, 139),
292        error: (191, 97, 106),
293        dark: true,
294        luminosity_spread: 0.15,
295        variables: HashMap::new(),
296    }
297}
298
299/// Gruvbox Dark color scheme — retro groove with warm earth tones.
300pub fn gruvbox_dark_theme() -> Theme {
301    let bg = (40, 40, 40);
302    let primary = (69, 133, 136);
303    let surface = (50, 48, 47);
304    let panel = blend_rgb(surface, primary, 0.1);
305
306    Theme {
307        name: "gruvbox".to_string(),
308        primary,
309        secondary: (131, 165, 152),
310        accent: (215, 153, 33),
311        surface,
312        panel,
313        background: bg,
314        foreground: (235, 219, 178),
315        success: (152, 151, 26),
316        warning: (215, 153, 33),
317        error: (204, 36, 29),
318        dark: true,
319        luminosity_spread: 0.15,
320        variables: HashMap::new(),
321    }
322}
323
324/// Dracula color scheme — a dark theme with vibrant colors.
325pub fn dracula_theme() -> Theme {
326    let bg = (40, 42, 54);
327    let primary = (189, 147, 249);
328    let surface = (68, 71, 90);
329    let panel = blend_rgb(surface, primary, 0.1);
330
331    Theme {
332        name: "dracula".to_string(),
333        primary,
334        secondary: (139, 233, 253),
335        accent: (255, 121, 198),
336        surface,
337        panel,
338        background: bg,
339        foreground: (248, 248, 242),
340        success: (80, 250, 123),
341        warning: (241, 250, 140),
342        error: (255, 85, 85),
343        dark: true,
344        luminosity_spread: 0.15,
345        variables: HashMap::new(),
346    }
347}
348
349/// Catppuccin Mocha color scheme — a soothing pastel theme for the high-spirited.
350pub fn catppuccin_mocha_theme() -> Theme {
351    let bg = (30, 30, 46);
352    let primary = (137, 180, 250);
353    let surface = (49, 50, 68);
354    let panel = blend_rgb(surface, primary, 0.1);
355
356    Theme {
357        name: "catppuccin".to_string(),
358        primary,
359        secondary: (116, 199, 236),
360        accent: (245, 194, 231),
361        surface,
362        panel,
363        background: bg,
364        foreground: (205, 214, 244),
365        success: (166, 227, 161),
366        warning: (249, 226, 175),
367        error: (243, 139, 168),
368        dark: true,
369        luminosity_spread: 0.15,
370        variables: HashMap::new(),
371    }
372}
373
374/// Returns all built-in themes (dark, light, and named community themes).
375pub fn builtin_themes() -> Vec<Theme> {
376    vec![
377        default_dark_theme(),
378        default_light_theme(),
379        tokyo_night_theme(),
380        nord_theme(),
381        gruvbox_dark_theme(),
382        dracula_theme(),
383        catppuccin_mocha_theme(),
384    ]
385}
386
387/// Look up a built-in theme by name.
388pub fn theme_by_name(name: &str) -> Option<Theme> {
389    builtin_themes().into_iter().find(|t| t.name == name)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    // --- HSL round-trip tests ---
397
398    #[test]
399    fn hsl_round_trip_pure_red() {
400        let (h, s, l) = rgb_to_hsl(255, 0, 0);
401        assert!((h - 0.0).abs() < 1.0);
402        assert!((s - 1.0).abs() < 0.01);
403        assert!((l - 0.5).abs() < 0.01);
404        let (r, g, b) = hsl_to_rgb(h, s, l);
405        assert_eq!((r, g, b), (255, 0, 0));
406    }
407
408    #[test]
409    fn hsl_round_trip_white() {
410        let (h, _s, l) = rgb_to_hsl(255, 255, 255);
411        assert!((l - 1.0).abs() < 0.01);
412        let (r, g, b) = hsl_to_rgb(h, 0.0, l);
413        assert_eq!((r, g, b), (255, 255, 255));
414    }
415
416    #[test]
417    fn hsl_round_trip_black() {
418        let (_h, _s, l) = rgb_to_hsl(0, 0, 0);
419        assert!((l - 0.0).abs() < 0.01);
420        let (r, g, b) = hsl_to_rgb(0.0, 0.0, l);
421        assert_eq!((r, g, b), (0, 0, 0));
422    }
423
424    #[test]
425    fn hsl_round_trip_primary_blue() {
426        // #0178D4 = (1, 120, 212)
427        let (h, s, l) = rgb_to_hsl(1, 120, 212);
428        let (r, g, b) = hsl_to_rgb(h, s, l);
429        assert!((r as i16 - 1).abs() <= 1);
430        assert!((g as i16 - 120).abs() <= 1);
431        assert!((b as i16 - 212).abs() <= 1);
432    }
433
434    // --- Default dark theme tests ---
435
436    #[test]
437    fn default_dark_theme_primary() {
438        let theme = default_dark_theme();
439        assert_eq!(theme.primary, (1, 120, 212));
440    }
441
442    #[test]
443    fn default_dark_theme_all_colors() {
444        let theme = default_dark_theme();
445        assert_eq!(theme.name, "textual-dark");
446        assert_eq!(theme.primary, (1, 120, 212));
447        assert_eq!(theme.secondary, (0, 69, 120));
448        assert_eq!(theme.accent, (255, 166, 43));
449        assert_eq!(theme.warning, (255, 166, 43));
450        assert_eq!(theme.error, (186, 60, 91));
451        assert_eq!(theme.success, (78, 191, 113));
452        assert_eq!(theme.foreground, (224, 224, 224));
453        assert_eq!(theme.background, (18, 18, 18));
454        assert_eq!(theme.surface, (30, 30, 30));
455        assert!(theme.dark);
456        assert!((theme.luminosity_spread - 0.15).abs() < 0.001);
457    }
458
459    #[test]
460    fn default_dark_theme_panel_blend() {
461        let theme = default_dark_theme();
462        // panel = surface * 0.9 + primary * 0.1
463        // r = 30*0.9 + 1*0.1 = 27.1 -> 27
464        // g = 30*0.9 + 120*0.1 = 39.0 -> 39
465        // b = 30*0.9 + 212*0.1 = 48.2 -> 48
466        assert_eq!(theme.panel, (27, 39, 48));
467    }
468
469    // --- Resolve base names ---
470
471    #[test]
472    fn resolve_primary_returns_rgb() {
473        let theme = default_dark_theme();
474        assert_eq!(theme.resolve("primary"), Some(TcssColor::Rgb(1, 120, 212)));
475    }
476
477    #[test]
478    fn resolve_all_base_names() {
479        let theme = default_dark_theme();
480        assert_eq!(theme.resolve("secondary"), Some(TcssColor::Rgb(0, 69, 120)));
481        assert_eq!(theme.resolve("accent"), Some(TcssColor::Rgb(255, 166, 43)));
482        assert_eq!(theme.resolve("surface"), Some(TcssColor::Rgb(30, 30, 30)));
483        assert_eq!(theme.resolve("panel"), Some(TcssColor::Rgb(27, 39, 48)));
484        assert_eq!(
485            theme.resolve("background"),
486            Some(TcssColor::Rgb(18, 18, 18))
487        );
488        assert_eq!(
489            theme.resolve("foreground"),
490            Some(TcssColor::Rgb(224, 224, 224))
491        );
492        assert_eq!(theme.resolve("success"), Some(TcssColor::Rgb(78, 191, 113)));
493        assert_eq!(theme.resolve("warning"), Some(TcssColor::Rgb(255, 166, 43)));
494        assert_eq!(theme.resolve("error"), Some(TcssColor::Rgb(186, 60, 91)));
495    }
496
497    #[test]
498    fn resolve_unknown_returns_none() {
499        let theme = default_dark_theme();
500        assert_eq!(theme.resolve("nonexistent"), None);
501        assert_eq!(theme.resolve(""), None);
502        assert_eq!(theme.resolve("primary-lighten-99"), None);
503    }
504
505    // --- Shade generation tests ---
506
507    #[test]
508    fn resolve_primary_lighten_1_is_lighter() {
509        let theme = default_dark_theme();
510        let base = theme.resolve("primary").unwrap();
511        let lighter = theme.resolve("primary-lighten-1").unwrap();
512        // Lighter means higher luminosity
513        let base_l = match base {
514            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
515            _ => panic!("expected Rgb"),
516        };
517        let lighter_l = match lighter {
518            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
519            _ => panic!("expected Rgb"),
520        };
521        assert!(
522            lighter_l > base_l,
523            "lighten-1 should have higher L than base"
524        );
525    }
526
527    #[test]
528    fn resolve_primary_darken_1_is_darker() {
529        let theme = default_dark_theme();
530        let base = theme.resolve("primary").unwrap();
531        let darker = theme.resolve("primary-darken-1").unwrap();
532        let base_l = match base {
533            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
534            _ => panic!("expected Rgb"),
535        };
536        let darker_l = match darker {
537            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
538            _ => panic!("expected Rgb"),
539        };
540        assert!(darker_l < base_l, "darken-1 should have lower L than base");
541    }
542
543    #[test]
544    fn shades_are_monotonically_ordered() {
545        let theme = default_dark_theme();
546        let names = [
547            "primary-darken-3",
548            "primary-darken-2",
549            "primary-darken-1",
550            "primary",
551            "primary-lighten-1",
552            "primary-lighten-2",
553            "primary-lighten-3",
554        ];
555        let luminosities: Vec<f64> = names
556            .iter()
557            .map(|n| {
558                let color = theme
559                    .resolve(n)
560                    .unwrap_or_else(|| panic!("failed to resolve {}", n));
561                match color {
562                    TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
563                    _ => panic!("expected Rgb"),
564                }
565            })
566            .collect();
567
568        for i in 1..luminosities.len() {
569            assert!(
570                luminosities[i] > luminosities[i - 1],
571                "L[{}] ({}) should be > L[{}] ({}), names: {} > {}",
572                i,
573                luminosities[i],
574                i - 1,
575                luminosities[i - 1],
576                names[i],
577                names[i - 1]
578            );
579        }
580    }
581
582    #[test]
583    fn accent_lighten_2_works() {
584        let theme = default_dark_theme();
585        let result = theme.resolve("accent-lighten-2");
586        assert!(result.is_some(), "accent-lighten-2 should resolve");
587        let base_l = match theme.resolve("accent").unwrap() {
588            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
589            _ => panic!("expected Rgb"),
590        };
591        let shade_l = match result.unwrap() {
592            TcssColor::Rgb(r, g, b) => rgb_to_hsl(r, g, b).2,
593            _ => panic!("expected Rgb"),
594        };
595        assert!(shade_l > base_l);
596    }
597
598    // --- Variables override ---
599
600    #[test]
601    fn variables_override_computed_shades() {
602        let mut theme = default_dark_theme();
603        let override_color = TcssColor::Rgb(99, 99, 99);
604        theme
605            .variables
606            .insert("primary".to_string(), override_color);
607        assert_eq!(theme.resolve("primary"), Some(TcssColor::Rgb(99, 99, 99)));
608    }
609
610    #[test]
611    fn variables_override_shade_variant() {
612        let mut theme = default_dark_theme();
613        let override_color = TcssColor::Rgb(42, 42, 42);
614        theme
615            .variables
616            .insert("primary-lighten-1".to_string(), override_color);
617        assert_eq!(
618            theme.resolve("primary-lighten-1"),
619            Some(TcssColor::Rgb(42, 42, 42))
620        );
621    }
622
623    // --- lighten_color direct tests ---
624
625    #[test]
626    fn lighten_color_positive_delta() {
627        let base = TcssColor::Rgb(100, 100, 100);
628        let lighter = lighten_color(base, 0.1);
629        let base_l = rgb_to_hsl(100, 100, 100).2;
630        match lighter {
631            TcssColor::Rgb(r, g, b) => {
632                let new_l = rgb_to_hsl(r, g, b).2;
633                assert!(new_l > base_l);
634            }
635            _ => panic!("expected Rgb"),
636        }
637    }
638
639    #[test]
640    fn lighten_color_negative_delta_darkens() {
641        let base = TcssColor::Rgb(100, 100, 100);
642        let darker = lighten_color(base, -0.1);
643        let base_l = rgb_to_hsl(100, 100, 100).2;
644        match darker {
645            TcssColor::Rgb(r, g, b) => {
646                let new_l = rgb_to_hsl(r, g, b).2;
647                assert!(new_l < base_l);
648            }
649            _ => panic!("expected Rgb"),
650        }
651    }
652
653    #[test]
654    fn lighten_color_clamps_to_max() {
655        let base = TcssColor::Rgb(250, 250, 250);
656        let result = lighten_color(base, 1.0);
657        match result {
658            TcssColor::Rgb(r, g, b) => {
659                let l = rgb_to_hsl(r, g, b).2;
660                assert!(l <= 1.0);
661            }
662            _ => panic!("expected Rgb"),
663        }
664    }
665
666    #[test]
667    fn lighten_color_non_rgb_unchanged() {
668        let reset = TcssColor::Reset;
669        assert_eq!(lighten_color(reset, 0.5), TcssColor::Reset);
670
671        let named = TcssColor::Named("red");
672        assert_eq!(lighten_color(named, 0.5), TcssColor::Named("red"));
673    }
674
675    // --- Light theme tests ---
676
677    #[test]
678    fn default_light_theme_colors() {
679        let theme = default_light_theme();
680        assert_eq!(theme.name, "textual-light");
681        assert_eq!(theme.primary, (0, 120, 212));
682        assert_eq!(theme.background, (255, 255, 255));
683        assert_eq!(theme.foreground, (36, 36, 36));
684        assert!(!theme.dark);
685    }
686
687    #[test]
688    fn light_theme_resolves_variables() {
689        let theme = default_light_theme();
690        assert_eq!(theme.resolve("primary"), Some(TcssColor::Rgb(0, 120, 212)));
691        assert_eq!(
692            theme.resolve("background"),
693            Some(TcssColor::Rgb(255, 255, 255))
694        );
695        assert!(theme.resolve("primary-lighten-1").is_some());
696    }
697
698    // --- Named theme tests ---
699
700    #[test]
701    fn tokyo_night_theme_colors() {
702        let theme = tokyo_night_theme();
703        assert_eq!(theme.name, "tokyo-night");
704        assert_eq!(theme.background, (26, 27, 38));
705        assert_eq!(theme.primary, (122, 162, 247));
706        assert!(theme.dark);
707    }
708
709    #[test]
710    fn nord_theme_colors() {
711        let theme = nord_theme();
712        assert_eq!(theme.name, "nord");
713        assert_eq!(theme.background, (46, 52, 64));
714        assert_eq!(theme.primary, (136, 192, 208));
715        assert!(theme.dark);
716    }
717
718    #[test]
719    fn gruvbox_dark_theme_colors() {
720        let theme = gruvbox_dark_theme();
721        assert_eq!(theme.name, "gruvbox");
722        assert_eq!(theme.background, (40, 40, 40));
723        assert_eq!(theme.primary, (69, 133, 136));
724        assert!(theme.dark);
725    }
726
727    #[test]
728    fn dracula_theme_colors() {
729        let theme = dracula_theme();
730        assert_eq!(theme.name, "dracula");
731        assert_eq!(theme.background, (40, 42, 54));
732        assert_eq!(theme.primary, (189, 147, 249));
733        assert!(theme.dark);
734    }
735
736    #[test]
737    fn catppuccin_mocha_theme_colors() {
738        let theme = catppuccin_mocha_theme();
739        assert_eq!(theme.name, "catppuccin");
740        assert_eq!(theme.background, (30, 30, 46));
741        assert_eq!(theme.primary, (137, 180, 250));
742        assert!(theme.dark);
743    }
744
745    // --- builtin_themes and theme_by_name ---
746
747    #[test]
748    fn builtin_themes_count() {
749        let themes = builtin_themes();
750        assert_eq!(themes.len(), 7);
751    }
752
753    #[test]
754    fn builtin_themes_unique_names() {
755        let themes = builtin_themes();
756        let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
757        let mut unique = names.clone();
758        unique.sort();
759        unique.dedup();
760        assert_eq!(names.len(), unique.len(), "theme names must be unique");
761    }
762
763    #[test]
764    fn theme_by_name_found() {
765        assert!(theme_by_name("textual-dark").is_some());
766        assert!(theme_by_name("textual-light").is_some());
767        assert!(theme_by_name("tokyo-night").is_some());
768        assert!(theme_by_name("nord").is_some());
769        assert!(theme_by_name("gruvbox").is_some());
770        assert!(theme_by_name("dracula").is_some());
771        assert!(theme_by_name("catppuccin").is_some());
772    }
773
774    #[test]
775    fn theme_by_name_not_found() {
776        assert!(theme_by_name("nonexistent").is_none());
777    }
778
779    #[test]
780    fn all_themes_resolve_all_base_names() {
781        let base_names = [
782            "primary",
783            "secondary",
784            "accent",
785            "surface",
786            "panel",
787            "background",
788            "foreground",
789            "success",
790            "warning",
791            "error",
792        ];
793        for theme in builtin_themes() {
794            for name in &base_names {
795                assert!(
796                    theme.resolve(name).is_some(),
797                    "theme '{}' failed to resolve '{}'",
798                    theme.name,
799                    name
800                );
801            }
802        }
803    }
804
805    #[test]
806    fn all_themes_resolve_shades() {
807        for theme in builtin_themes() {
808            // Every theme should produce distinct lighten/darken shades
809            let base = theme.resolve("primary").unwrap();
810            let lighter = theme.resolve("primary-lighten-1").unwrap();
811            let darker = theme.resolve("primary-darken-1").unwrap();
812            assert_ne!(base, lighter, "theme '{}' lighten-1 == base", theme.name);
813            assert_ne!(base, darker, "theme '{}' darken-1 == base", theme.name);
814        }
815    }
816}