Skip to main content

vfa_tui/ui/
theme.rs

1use ratatui::style::{Color, Modifier, Style};
2
3/// Detected terminal color capability level.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ColorSupport {
6    /// Terminal supports 256 colors (xterm-256color, etc.)
7    TrueColor256,
8    /// Terminal supports only basic 8 ANSI colors
9    Basic8,
10    /// No color output (--no-color flag or dumb terminal)
11    None,
12}
13
14/// Detect the terminal's color support level.
15///
16/// Detection logic:
17/// 1. If `no_color` is true (--no-color flag), returns `ColorSupport::None`
18/// 2. Checks `COLORTERM` env var for "truecolor" or "24bit" → 256
19/// 3. Checks `TERM` env var for "256color" substring → 256
20/// 4. If `TERM` is "dumb" or empty → None
21/// 5. Otherwise → Basic8 (safe fallback)
22///
23/// Note: Per Requirement 18.6, we do NOT use env vars to alter display content
24/// or sort order. Color detection is the one permitted use of env vars for
25/// rendering (it affects style, not content structure).
26pub fn detect_color_support(no_color: bool) -> ColorSupport {
27    if no_color {
28        return ColorSupport::None;
29    }
30
31    // Check NO_COLOR convention (https://no-color.org/)
32    if std::env::var("NO_COLOR").is_ok() {
33        return ColorSupport::None;
34    }
35
36    // Check COLORTERM for truecolor/24bit support (implies 256+)
37    if let Ok(colorterm) = std::env::var("COLORTERM") {
38        let ct = colorterm.to_lowercase();
39        if ct.contains("truecolor") || ct.contains("24bit") {
40            return ColorSupport::TrueColor256;
41        }
42    }
43
44    // Check TERM for 256color support
45    if let Ok(term) = std::env::var("TERM") {
46        let t = term.to_lowercase();
47        if t.contains("256color") {
48            return ColorSupport::TrueColor256;
49        }
50        if t == "dumb" || t.is_empty() {
51            return ColorSupport::None;
52        }
53    }
54
55    // Default: assume basic 8-color support
56    ColorSupport::Basic8
57}
58
59// ---------------------------------------------------------------------------
60// Theme mode + detection (Requirement 35)
61// ---------------------------------------------------------------------------
62
63/// Resolved theme mode — always Dark or Light at runtime (never System/Auto).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ThemeMode {
66    Dark,
67    Light,
68}
69
70/// CLI-level theme preference before resolution (Req 35.3).
71#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
72pub enum ThemePreference {
73    /// Detect from terminal background (default).
74    Auto,
75    /// Force dark palette.
76    Dark,
77    /// Force light palette.
78    Light,
79}
80
81/// Parse the `COLORFGBG` environment variable value into a [`ThemeMode`].
82///
83/// Format is `fg;bg` (some terminals emit `fg;default;bg`); the background
84/// ANSI colour index is the **last numeric field**. A background index ≥ 7
85/// means a light terminal (Req 35.2). We scan fields from the right and take
86/// the first that parses as a `u8`, so a stray trailing separator (e.g.
87/// `"15;"`, seen on some xterm variants) does not discard a valid index.
88/// Returns `None` when no field is numeric, so the caller can fall through to
89/// the next heuristic.
90pub fn parse_colorfgbg(value: &str) -> Option<ThemeMode> {
91    let bg_idx: u8 = value
92        .rsplit(';')
93        .find_map(|f| f.trim().parse::<u8>().ok())?;
94    Some(if bg_idx >= 7 {
95        ThemeMode::Light
96    } else {
97        ThemeMode::Dark
98    })
99}
100
101/// Pure classification of detection inputs into a [`ThemeMode`].
102///
103/// Priority (Req 35.1 → 35.2 → fallback):
104/// 1. `luma` from the `terminal-light` OSC 11 query: `> 0.6` → Light, else Dark.
105/// 2. `COLORFGBG` parsing via [`parse_colorfgbg`].
106/// 3. Default to Dark (safe assumption for most developer terminals).
107///
108/// Kept side-effect free so the full decision tree is unit-testable without
109/// touching the terminal or environment.
110fn classify_theme(luma: Option<f32>, colorfgbg: Option<&str>) -> ThemeMode {
111    if let Some(l) = luma {
112        return if l > 0.6 {
113            ThemeMode::Light
114        } else {
115            ThemeMode::Dark
116        };
117    }
118    if let Some(value) = colorfgbg {
119        if let Some(mode) = parse_colorfgbg(value) {
120            return mode;
121        }
122    }
123    ThemeMode::Dark
124}
125
126/// Detect the system theme from terminal background luminance (Req 35.1–35.2).
127///
128/// Primary: `terminal-light` (OSC 11 escape query). Secondary: `COLORFGBG`.
129/// Fallback: Dark. Performs terminal/environment I/O — only call from an
130/// interactive (TUI) startup path, never from headless mode (Req 35.9).
131pub fn detect_system_theme() -> ThemeMode {
132    let luma = terminal_light::luma().ok();
133    let colorfgbg = std::env::var("COLORFGBG").ok();
134    classify_theme(luma, colorfgbg.as_deref())
135}
136
137/// Resolve a CLI [`ThemePreference`] into a concrete [`ThemeMode`] (Req 35.3, 35.9).
138///
139/// `Auto` triggers system detection, except in headless mode where stdin may
140/// not be a TTY — there it defaults to Dark without probing the terminal.
141pub fn resolve_theme(preference: ThemePreference, is_headless: bool) -> ThemeMode {
142    match preference {
143        ThemePreference::Dark => ThemeMode::Dark,
144        ThemePreference::Light => ThemeMode::Light,
145        ThemePreference::Auto => {
146            if is_headless {
147                ThemeMode::Dark
148            } else {
149                detect_system_theme()
150            }
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Palette (Requirement 35.4 / 35.5)
157// ---------------------------------------------------------------------------
158
159/// Semantic colour palette — all theme-dependent colours in one place.
160///
161/// Style methods pull from the active palette rather than hardcoding colours,
162/// so a single palette swap re-themes the entire UI.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Palette {
165    /// Primary text.
166    pub fg: Color,
167    /// Secondary/muted text.
168    pub fg_dim: Color,
169    /// `None` = inherit terminal background.
170    pub bg: Option<Color>,
171    /// Alternate background for contrast (status bar, selections).
172    pub bg_alt: Option<Color>,
173    /// Primary accent (focused borders, highlights).
174    pub accent: Color,
175    /// Subdued accent (unfocused, help text).
176    pub accent_dim: Color,
177    /// Pass, installed, current.
178    pub success: Color,
179    /// Outdated, stale, warnings.
180    pub warning: Color,
181    /// Failed, drifted, critical violations.
182    pub error: Color,
183    /// Informational, running, in-progress.
184    pub info: Color,
185    /// Default border colour.
186    pub border: Color,
187    /// Focused panel border.
188    pub border_focused: Color,
189    /// Foreground in selected/highlighted items.
190    pub selection_fg: Color,
191    /// Background in selected/highlighted items.
192    pub selection_bg: Color,
193}
194
195/// Dark palette — optimized for dark terminal backgrounds (Req 35.4).
196pub fn dark_palette() -> Palette {
197    Palette {
198        fg: Color::White,
199        fg_dim: Color::DarkGray,
200        bg: None,
201        bg_alt: Some(Color::White),
202        accent: Color::Cyan,
203        accent_dim: Color::DarkGray,
204        success: Color::Green,
205        warning: Color::Yellow,
206        error: Color::Red,
207        info: Color::Magenta,
208        border: Color::Gray,
209        border_focused: Color::Cyan,
210        selection_fg: Color::Black,
211        selection_bg: Color::LightBlue,
212    }
213}
214
215/// Light palette — optimized for light terminal backgrounds (Req 35.5).
216pub fn light_palette() -> Palette {
217    Palette {
218        fg: Color::Black,
219        fg_dim: Color::DarkGray,
220        bg: None,
221        bg_alt: Some(Color::Black),
222        accent: Color::Blue,
223        accent_dim: Color::Gray,
224        success: Color::Green,
225        warning: Color::Indexed(208), // orange-ish for visibility on light bg
226        error: Color::Red,
227        info: Color::Magenta,
228        border: Color::Gray,
229        border_focused: Color::Blue,
230        selection_fg: Color::White,
231        selection_bg: Color::Blue,
232    }
233}
234
235/// Build the palette for a resolved [`ThemeMode`].
236fn palette_for(mode: ThemeMode) -> Palette {
237    match mode {
238        ThemeMode::Dark => dark_palette(),
239        ThemeMode::Light => light_palette(),
240    }
241}
242
243/// Map a colour that falls outside the basic 8-colour ANSI set to a safe basic
244/// equivalent. Bright (`Light*`) and 256-indexed colours may not render on a
245/// strict 8-colour terminal — e.g. a `LightBlue` selection background can be
246/// dropped entirely, leaving the selected row with no highlight. Basic colours
247/// pass through unchanged.
248fn to_basic8(c: Color) -> Color {
249    match c {
250        Color::LightRed => Color::Red,
251        Color::LightGreen => Color::Green,
252        Color::LightYellow => Color::Yellow,
253        Color::LightBlue => Color::Blue,
254        Color::LightMagenta => Color::Magenta,
255        Color::LightCyan => Color::Cyan,
256        Color::Indexed(208) => Color::Yellow, // light-mode warning orange → nearest basic
257        other => other,
258    }
259}
260
261/// Downgrade every field of a palette to basic colours (for `Basic8` terminals).
262fn downgrade_palette(p: Palette) -> Palette {
263    Palette {
264        fg: to_basic8(p.fg),
265        fg_dim: to_basic8(p.fg_dim),
266        bg: p.bg.map(to_basic8),
267        bg_alt: p.bg_alt.map(to_basic8),
268        accent: to_basic8(p.accent),
269        accent_dim: to_basic8(p.accent_dim),
270        success: to_basic8(p.success),
271        warning: to_basic8(p.warning),
272        error: to_basic8(p.error),
273        info: to_basic8(p.info),
274        border: to_basic8(p.border),
275        border_focused: to_basic8(p.border_focused),
276        selection_fg: to_basic8(p.selection_fg),
277        selection_bg: to_basic8(p.selection_bg),
278    }
279}
280
281/// Build the active palette for a `(mode, color_support)` pair, downgrading
282/// bright/256 colours to basic ones when the terminal only supports 8 colours.
283/// Deterministic in both inputs (Req 35.8).
284fn build_palette(mode: ThemeMode, color_support: ColorSupport) -> Palette {
285    let palette = palette_for(mode);
286    if color_support == ColorSupport::Basic8 {
287        downgrade_palette(palette)
288    } else {
289        palette
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Theme
295// ---------------------------------------------------------------------------
296
297/// Theme configuration supporting `--no-color` mode and adaptive light/dark
298/// palettes.
299///
300/// The theme is deterministic: given the same `(ThemeMode, ColorSupport)`,
301/// all style methods return identical `Style` values (Requirement 35.8 /
302/// Property 34). `ColorSupport::None` always takes precedence over the mode —
303/// no colours are emitted regardless of palette (Req 35.7).
304pub struct Theme {
305    pub no_color: bool,
306    pub color_support: ColorSupport,
307    pub mode: ThemeMode,
308    palette: Palette,
309}
310
311impl Theme {
312    /// Create a new theme with automatic colour-support detection and an
313    /// explicitly resolved [`ThemeMode`].
314    pub fn new(no_color: bool, mode: ThemeMode) -> Self {
315        let color_support = detect_color_support(no_color);
316        Self {
317            no_color,
318            color_support,
319            mode,
320            palette: build_palette(mode, color_support),
321        }
322    }
323
324    /// Create a theme with an explicit colour-support level (useful for
325    /// testing). Defaults to [`ThemeMode::Dark`] for backward compatibility.
326    pub fn with_color_support(color_support: ColorSupport) -> Self {
327        Self::with_color_support_mode(color_support, ThemeMode::Dark)
328    }
329
330    /// Create a theme with an explicit colour-support level and mode.
331    pub fn with_color_support_mode(color_support: ColorSupport, mode: ThemeMode) -> Self {
332        Self {
333            no_color: color_support == ColorSupport::None,
334            color_support,
335            mode,
336            palette: build_palette(mode, color_support),
337        }
338    }
339
340    /// Toggle between Dark and Light mode at runtime (Req 35.6), rebuilding
341    /// the active palette.
342    pub fn toggle_mode(&mut self) {
343        self.mode = match self.mode {
344            ThemeMode::Dark => ThemeMode::Light,
345            ThemeMode::Light => ThemeMode::Dark,
346        };
347        self.palette = build_palette(self.mode, self.color_support);
348    }
349
350    /// Whether any colour output is enabled.
351    fn has_color(&self) -> bool {
352        self.color_support != ColorSupport::None
353    }
354
355    pub fn sidebar_style(&self) -> Style {
356        if !self.has_color() {
357            Style::default()
358        } else {
359            Style::default().fg(self.palette.fg)
360        }
361    }
362
363    pub fn sidebar_selected(&self) -> Style {
364        if !self.has_color() {
365            Style::default().add_modifier(Modifier::REVERSED)
366        } else {
367            Style::default()
368                .fg(self.palette.selection_fg)
369                .bg(self.palette.accent)
370                .add_modifier(Modifier::BOLD)
371        }
372    }
373
374    pub fn list_item(&self) -> Style {
375        if !self.has_color() {
376            Style::default()
377        } else {
378            Style::default().fg(self.palette.fg)
379        }
380    }
381
382    pub fn list_selected(&self) -> Style {
383        if !self.has_color() {
384            Style::default().add_modifier(Modifier::REVERSED)
385        } else {
386            Style::default()
387                .fg(self.palette.selection_fg)
388                .bg(self.palette.selection_bg)
389                .add_modifier(Modifier::BOLD)
390        }
391    }
392
393    pub fn detail_key(&self) -> Style {
394        if !self.has_color() {
395            Style::default().add_modifier(Modifier::BOLD)
396        } else {
397            Style::default()
398                .fg(self.palette.warning)
399                .add_modifier(Modifier::BOLD)
400        }
401    }
402
403    pub fn detail_value(&self) -> Style {
404        if !self.has_color() {
405            Style::default()
406        } else {
407            Style::default().fg(self.palette.fg)
408        }
409    }
410
411    pub fn status_bar(&self) -> Style {
412        if !self.has_color() {
413            Style::default().add_modifier(Modifier::REVERSED)
414        } else {
415            // Inverted bar: dark text on light bar (dark mode), and the
416            // reverse in light mode. selection_fg / bg_alt are mirror images
417            // across the two palettes, giving correct contrast in both.
418            let mut style = Style::default().fg(self.palette.selection_fg);
419            if let Some(bg) = self.palette.bg_alt {
420                style = style.bg(bg);
421            }
422            style
423        }
424    }
425
426    pub fn help_bar(&self) -> Style {
427        if !self.has_color() {
428            Style::default().add_modifier(Modifier::DIM)
429        } else {
430            Style::default().fg(self.palette.accent_dim)
431        }
432    }
433
434    pub fn error_style(&self) -> Style {
435        if !self.has_color() {
436            Style::default().add_modifier(Modifier::BOLD)
437        } else {
438            Style::default()
439                .fg(self.palette.error)
440                .add_modifier(Modifier::BOLD)
441        }
442    }
443
444    pub fn search_style(&self) -> Style {
445        if !self.has_color() {
446            Style::default().add_modifier(Modifier::UNDERLINED)
447        } else {
448            Style::default()
449                .fg(self.palette.success)
450                .add_modifier(Modifier::BOLD)
451        }
452    }
453
454    pub fn border_style(&self) -> Style {
455        if !self.has_color() {
456            Style::default()
457        } else {
458            Style::default().fg(self.palette.border)
459        }
460    }
461
462    /// Style for the focused panel border (Requirement 16.6).
463    /// Visually distinct from unfocused borders to indicate active panel.
464    pub fn border_focused(&self) -> Style {
465        if !self.has_color() {
466            Style::default().add_modifier(Modifier::BOLD)
467        } else {
468            Style::default()
469                .fg(self.palette.border_focused)
470                .add_modifier(Modifier::BOLD)
471        }
472    }
473
474    /// Style for unfocused panel border (Requirement 16.6).
475    /// Subdued compared to focused border.
476    pub fn border_unfocused(&self) -> Style {
477        if !self.has_color() {
478            Style::default().add_modifier(Modifier::DIM)
479        } else {
480            Style::default().fg(self.palette.accent_dim)
481        }
482    }
483
484    /// Style for validation gate status: NotRun (dim/gray).
485    pub fn gate_not_run(&self) -> Style {
486        if !self.has_color() {
487            Style::default().add_modifier(Modifier::DIM)
488        } else {
489            Style::default().fg(self.palette.accent_dim)
490        }
491    }
492
493    /// Style for validation gate status: Running (warning/yellow).
494    pub fn gate_running(&self) -> Style {
495        if !self.has_color() {
496            Style::default().add_modifier(Modifier::BOLD)
497        } else {
498            Style::default()
499                .fg(self.palette.warning)
500                .add_modifier(Modifier::BOLD)
501        }
502    }
503
504    /// Style for validation gate status: Passed (green).
505    pub fn gate_passed(&self) -> Style {
506        if !self.has_color() {
507            Style::default()
508        } else {
509            Style::default().fg(self.palette.success)
510        }
511    }
512
513    /// Style for validation gate status: Failed (red).
514    pub fn gate_failed(&self) -> Style {
515        if !self.has_color() {
516            Style::default().add_modifier(Modifier::BOLD)
517        } else {
518            Style::default()
519                .fg(self.palette.error)
520                .add_modifier(Modifier::BOLD)
521        }
522    }
523
524    /// Style for validation gate status: TimedOut (info/magenta).
525    pub fn gate_timed_out(&self) -> Style {
526        if !self.has_color() {
527            Style::default().add_modifier(Modifier::BOLD)
528        } else {
529            Style::default()
530                .fg(self.palette.info)
531                .add_modifier(Modifier::BOLD)
532        }
533    }
534
535    /// Style for filter chips display.
536    pub fn filter_chip(&self) -> Style {
537        if !self.has_color() {
538            Style::default().add_modifier(Modifier::REVERSED)
539        } else {
540            Style::default()
541                .fg(self.palette.selection_fg)
542                .bg(self.palette.accent)
543        }
544    }
545
546    /// Style for sparkline bar (filled portion).
547    pub fn sparkline_filled(&self) -> Style {
548        if !self.has_color() {
549            Style::default()
550        } else {
551            Style::default().fg(self.palette.accent)
552        }
553    }
554
555    /// Style for sparkline bar (empty portion).
556    pub fn sparkline_empty(&self) -> Style {
557        if !self.has_color() {
558            Style::default().add_modifier(Modifier::DIM)
559        } else {
560            Style::default().fg(self.palette.accent_dim)
561        }
562    }
563
564    /// Style for help overlay title.
565    pub fn help_overlay_title(&self) -> Style {
566        if !self.has_color() {
567            Style::default().add_modifier(Modifier::BOLD)
568        } else {
569            Style::default()
570                .fg(self.palette.accent)
571                .add_modifier(Modifier::BOLD)
572        }
573    }
574
575    /// Style for help overlay section header.
576    pub fn help_overlay_section(&self) -> Style {
577        if !self.has_color() {
578            Style::default().add_modifier(Modifier::BOLD)
579        } else {
580            Style::default()
581                .fg(self.palette.warning)
582                .add_modifier(Modifier::BOLD)
583        }
584    }
585
586    /// Style for completion suggestion (highlighted).
587    pub fn completion_highlight(&self) -> Style {
588        if !self.has_color() {
589            Style::default().add_modifier(Modifier::REVERSED)
590        } else {
591            Style::default()
592                .fg(self.palette.selection_fg)
593                .bg(self.palette.success)
594        }
595    }
596
597    /// Style for completion suggestion (normal).
598    pub fn completion_normal(&self) -> Style {
599        if !self.has_color() {
600            Style::default()
601        } else {
602            Style::default().fg(self.palette.fg)
603        }
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use proptest::prelude::*;
611
612    #[test]
613    fn no_color_mode_uses_no_fg_color() {
614        let theme = Theme::with_color_support(ColorSupport::None);
615        assert_eq!(theme.sidebar_style().fg, None);
616        assert_eq!(theme.list_item().fg, None);
617        assert_eq!(theme.detail_value().fg, None);
618    }
619
620    #[test]
621    fn color_mode_has_colors() {
622        let theme = Theme::with_color_support(ColorSupport::TrueColor256);
623        assert!(theme.sidebar_style().fg.is_some());
624        assert!(theme.list_selected().bg.is_some());
625        assert!(theme.detail_key().fg.is_some());
626    }
627
628    #[test]
629    fn eight_color_mode_has_colors() {
630        let theme = Theme::with_color_support(ColorSupport::Basic8);
631        assert!(theme.sidebar_style().fg.is_some());
632        assert!(theme.list_selected().bg.is_some());
633    }
634
635    #[test]
636    fn basic8_downgrades_bright_selection_bg() {
637        // Regression: the dark palette's selection background is LightBlue
638        // (ANSI bright), which can be dropped on a strict 8-colour terminal,
639        // making the selected row invisible. On Basic8 it must downgrade to a
640        // basic colour. 256/true-colour keeps the brighter LightBlue.
641        let basic8 = Theme::with_color_support_mode(ColorSupport::Basic8, ThemeMode::Dark);
642        assert_eq!(basic8.list_selected().bg, Some(Color::Blue));
643
644        let rich = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
645        assert_eq!(rich.list_selected().bg, Some(Color::LightBlue));
646    }
647
648    #[test]
649    fn basic8_downgrades_indexed_warning() {
650        // Light-mode warning is Indexed(208) (orange); not representable on a
651        // basic-8 terminal → must downgrade to a basic colour.
652        let basic8 = Theme::with_color_support_mode(ColorSupport::Basic8, ThemeMode::Light);
653        assert_eq!(basic8.detail_key().fg, Some(Color::Yellow));
654
655        let rich = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Light);
656        assert_eq!(rich.detail_key().fg, Some(Color::Indexed(208)));
657    }
658
659    #[test]
660    fn dark_and_light_palettes_differ_in_color_mode() {
661        // Guards against palette_for / build_palette accidentally returning the
662        // same palette for both modes (which the determinism property alone
663        // would NOT catch).
664        for support in [ColorSupport::TrueColor256, ColorSupport::Basic8] {
665            let dark = Theme::with_color_support_mode(support, ThemeMode::Dark);
666            let light = Theme::with_color_support_mode(support, ThemeMode::Light);
667            assert_ne!(
668                dark.sidebar_style().fg,
669                light.sidebar_style().fg,
670                "dark and light must differ for {support:?}"
671            );
672            assert_ne!(
673                dark.border_focused().fg,
674                light.border_focused().fg,
675                "dark and light accents must differ for {support:?}"
676            );
677        }
678    }
679
680    #[test]
681    fn focused_border_differs_from_unfocused() {
682        let theme = Theme::with_color_support(ColorSupport::TrueColor256);
683        let focused = theme.border_focused();
684        let unfocused = theme.border_unfocused();
685        assert_ne!(focused.fg, unfocused.fg);
686    }
687
688    #[test]
689    fn no_color_focused_uses_modifiers() {
690        let theme = Theme::with_color_support(ColorSupport::None);
691        let focused = theme.border_focused();
692        let unfocused = theme.border_unfocused();
693        assert!(focused.add_modifier.contains(Modifier::BOLD));
694        assert!(unfocused.add_modifier.contains(Modifier::DIM));
695    }
696
697    #[test]
698    fn color_support_detection_no_color_flag() {
699        assert_eq!(detect_color_support(true), ColorSupport::None);
700    }
701
702    #[test]
703    fn theme_deterministic_same_support_level() {
704        // Requirement 35.8: same inputs produce same styles.
705        let theme1 = Theme::with_color_support(ColorSupport::TrueColor256);
706        let theme2 = Theme::with_color_support(ColorSupport::TrueColor256);
707        assert_eq!(theme1.sidebar_style(), theme2.sidebar_style());
708        assert_eq!(theme1.list_selected(), theme2.list_selected());
709        assert_eq!(theme1.error_style(), theme2.error_style());
710        assert_eq!(theme1.border_focused(), theme2.border_focused());
711    }
712
713    #[test]
714    fn with_color_support_sets_no_color_correctly() {
715        let theme_none = Theme::with_color_support(ColorSupport::None);
716        assert!(theme_none.no_color);
717
718        let theme_256 = Theme::with_color_support(ColorSupport::TrueColor256);
719        assert!(!theme_256.no_color);
720
721        let theme_8 = Theme::with_color_support(ColorSupport::Basic8);
722        assert!(!theme_8.no_color);
723    }
724
725    #[test]
726    fn gate_styles_have_color_in_color_mode() {
727        let theme = Theme::with_color_support(ColorSupport::TrueColor256);
728        assert!(theme.gate_passed().fg.is_some());
729        assert!(theme.gate_failed().fg.is_some());
730        assert!(theme.gate_running().fg.is_some());
731        assert!(theme.gate_timed_out().fg.is_some());
732    }
733
734    #[test]
735    fn no_color_help_bar_uses_dim() {
736        let theme = Theme::with_color_support(ColorSupport::None);
737        let style = theme.help_bar();
738        assert!(style.add_modifier.contains(Modifier::DIM));
739    }
740
741    // -----------------------------------------------------------------------
742    // Task 15.4 — theme detection + palette tests (Requirement 35)
743    // -----------------------------------------------------------------------
744
745    #[test]
746    fn classify_falls_back_to_dark_when_detection_fails() {
747        // terminal-light failure (None luma) + no COLORFGBG → Dark (Req 35.2).
748        assert_eq!(classify_theme(None, None), ThemeMode::Dark);
749    }
750
751    #[test]
752    fn classify_uses_luma_threshold() {
753        // luma > 0.6 → Light, else Dark (Req 35.1). COLORFGBG ignored when
754        // luma is present.
755        assert_eq!(classify_theme(Some(0.9), Some("15;0")), ThemeMode::Light);
756        assert_eq!(classify_theme(Some(0.61), None), ThemeMode::Light);
757        assert_eq!(classify_theme(Some(0.6), None), ThemeMode::Dark);
758        assert_eq!(classify_theme(Some(0.1), Some("0;15")), ThemeMode::Dark);
759    }
760
761    #[test]
762    fn colorfgbg_parsing() {
763        // "0;15" → bg 15 (≥ 7) → Light; "15;0" → bg 0 → Dark (Req 35.2).
764        assert_eq!(parse_colorfgbg("0;15"), Some(ThemeMode::Light));
765        assert_eq!(parse_colorfgbg("15;0"), Some(ThemeMode::Dark));
766        // Three-field form `fg;default;bg` — last field is the background.
767        assert_eq!(parse_colorfgbg("15;default;0"), Some(ThemeMode::Dark));
768        assert_eq!(parse_colorfgbg("0;default;15"), Some(ThemeMode::Light));
769        // Boundary: index 7 counts as Light.
770        assert_eq!(parse_colorfgbg("0;7"), Some(ThemeMode::Light));
771        assert_eq!(parse_colorfgbg("0;6"), Some(ThemeMode::Dark));
772        // Trailing separator (some xterm variants) — fall back to the last
773        // numeric field rather than discarding it.
774        assert_eq!(parse_colorfgbg("15;"), Some(ThemeMode::Light));
775        assert_eq!(parse_colorfgbg("0;15;"), Some(ThemeMode::Light));
776        // Missing / unparsable → None (caller falls back).
777        assert_eq!(parse_colorfgbg(""), None);
778        assert_eq!(parse_colorfgbg("not;a;number"), None);
779    }
780
781    #[test]
782    fn colorfgbg_used_when_luma_missing() {
783        assert_eq!(classify_theme(None, Some("0;15")), ThemeMode::Light);
784        assert_eq!(classify_theme(None, Some("15;0")), ThemeMode::Dark);
785        // Unparsable COLORFGBG with no luma → Dark fallback.
786        assert_eq!(classify_theme(None, Some("garbage")), ThemeMode::Dark);
787    }
788
789    #[test]
790    fn resolve_theme_explicit_overrides_detection() {
791        // Explicit flags ignore detection entirely (Req 35.3).
792        assert_eq!(resolve_theme(ThemePreference::Dark, false), ThemeMode::Dark);
793        assert_eq!(
794            resolve_theme(ThemePreference::Light, false),
795            ThemeMode::Light
796        );
797        assert_eq!(resolve_theme(ThemePreference::Dark, true), ThemeMode::Dark);
798        assert_eq!(
799            resolve_theme(ThemePreference::Light, true),
800            ThemeMode::Light
801        );
802    }
803
804    #[test]
805    fn resolve_theme_auto_headless_defaults_dark() {
806        // Headless must not probe the terminal — Auto → Dark (Req 35.9).
807        assert_eq!(resolve_theme(ThemePreference::Auto, true), ThemeMode::Dark);
808    }
809
810    #[test]
811    fn light_mode_uses_light_palette_colors() {
812        // Req 35.5: Light mode + colour support returns light palette colours.
813        let theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Light);
814        assert_eq!(theme.mode, ThemeMode::Light);
815        assert_eq!(theme.sidebar_style().fg, Some(Color::Black));
816        assert_eq!(theme.border_focused().fg, Some(Color::Blue));
817        assert_eq!(theme.list_selected().bg, Some(Color::Blue));
818    }
819
820    #[test]
821    fn dark_mode_uses_dark_palette_colors() {
822        // Req 35.4: Dark mode + colour support returns dark palette colours.
823        let theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
824        assert_eq!(theme.mode, ThemeMode::Dark);
825        assert_eq!(theme.sidebar_style().fg, Some(Color::White));
826        assert_eq!(theme.border_focused().fg, Some(Color::Cyan));
827        assert_eq!(theme.list_selected().bg, Some(Color::LightBlue));
828    }
829
830    #[test]
831    fn no_color_ignores_mode() {
832        // Req 35.7: ColorSupport::None emits no colours regardless of mode.
833        for mode in [ThemeMode::Dark, ThemeMode::Light] {
834            let theme = Theme::with_color_support_mode(ColorSupport::None, mode);
835            assert_eq!(theme.sidebar_style().fg, None);
836            assert_eq!(theme.list_selected().fg, None);
837            assert_eq!(theme.list_selected().bg, None);
838            assert_eq!(theme.border_focused().fg, None);
839            assert_eq!(theme.gate_passed().fg, None);
840        }
841    }
842
843    #[test]
844    fn runtime_toggle_flips_mode_and_styles() {
845        // Req 35.6: toggling flips the mode and produces different output.
846        let mut theme = Theme::with_color_support_mode(ColorSupport::TrueColor256, ThemeMode::Dark);
847        let dark_fg = theme.sidebar_style().fg;
848        let dark_border = theme.border_focused().fg;
849        theme.toggle_mode();
850        assert_eq!(theme.mode, ThemeMode::Light);
851        assert_ne!(theme.sidebar_style().fg, dark_fg);
852        assert_ne!(theme.border_focused().fg, dark_border);
853        // Toggling back restores the original.
854        theme.toggle_mode();
855        assert_eq!(theme.mode, ThemeMode::Dark);
856        assert_eq!(theme.sidebar_style().fg, dark_fg);
857        assert_eq!(theme.border_focused().fg, dark_border);
858    }
859
860    fn all_styles(theme: &Theme) -> Vec<Style> {
861        vec![
862            theme.sidebar_style(),
863            theme.sidebar_selected(),
864            theme.list_item(),
865            theme.list_selected(),
866            theme.detail_key(),
867            theme.detail_value(),
868            theme.status_bar(),
869            theme.help_bar(),
870            theme.error_style(),
871            theme.search_style(),
872            theme.border_style(),
873            theme.border_focused(),
874            theme.border_unfocused(),
875            theme.gate_not_run(),
876            theme.gate_running(),
877            theme.gate_passed(),
878            theme.gate_failed(),
879            theme.gate_timed_out(),
880            theme.filter_chip(),
881            theme.sparkline_filled(),
882            theme.sparkline_empty(),
883            theme.help_overlay_title(),
884            theme.help_overlay_section(),
885            theme.completion_highlight(),
886            theme.completion_normal(),
887        ]
888    }
889
890    fn mode_strategy() -> impl Strategy<Value = ThemeMode> {
891        prop_oneof![Just(ThemeMode::Dark), Just(ThemeMode::Light)]
892    }
893
894    fn support_strategy() -> impl Strategy<Value = ColorSupport> {
895        prop_oneof![
896            Just(ColorSupport::TrueColor256),
897            Just(ColorSupport::Basic8),
898            Just(ColorSupport::None),
899        ]
900    }
901
902    proptest! {
903        /// Property 34: for any (ThemeMode, ColorSupport), every style method
904        /// returns a valid (non-panicking) Style, and two themes built from the
905        /// same inputs produce bit-identical output (Req 35.8).
906        #[test]
907        fn prop34_theme_styles_deterministic(
908            mode in mode_strategy(),
909            support in support_strategy(),
910        ) {
911            let a = Theme::with_color_support_mode(support, mode);
912            let b = Theme::with_color_support_mode(support, mode);
913            // Constructing + calling every method must not panic, and must match.
914            prop_assert_eq!(all_styles(&a), all_styles(&b));
915            // None support never emits a foreground/background colour.
916            if support == ColorSupport::None {
917                for style in all_styles(&a) {
918                    prop_assert_eq!(style.fg, None);
919                    prop_assert_eq!(style.bg, None);
920                }
921            }
922        }
923    }
924}