Skip to main content

ftui_style/
theme.rs

1#![forbid(unsafe_code)]
2
3//! Theme system with semantic color slots.
4//!
5//! A Theme provides semantic color slots that map to actual colors. This enables
6//! consistent styling and easy theme switching (light/dark mode, custom themes).
7//!
8//! # Example
9//! ```
10//! use ftui_style::theme::{Theme, AdaptiveColor};
11//! use ftui_style::color::Color;
12//!
13//! // Use the default dark theme
14//! let theme = Theme::default();
15//! let text_color = theme.text.resolve(true); // true = dark mode
16//!
17//! // Create a custom theme
18//! let custom = Theme::builder()
19//!     .text(Color::rgb(200, 200, 200))
20//!     .background(Color::rgb(20, 20, 20))
21//!     .build();
22//! ```
23
24use crate::color::{
25    Color, Rgb, WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT, WCAG_AAA_NORMAL_TEXT, contrast_ratio,
26};
27use std::env;
28
29/// An adaptive color that can change based on light/dark mode.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AdaptiveColor {
32    /// A fixed color that doesn't change with mode.
33    Fixed(Color),
34    /// A color that adapts to light/dark mode.
35    Adaptive {
36        /// Color to use in light mode.
37        light: Color,
38        /// Color to use in dark mode.
39        dark: Color,
40    },
41}
42
43impl AdaptiveColor {
44    /// Create a fixed color.
45    #[inline]
46    pub const fn fixed(color: Color) -> Self {
47        Self::Fixed(color)
48    }
49
50    /// Create an adaptive color with light/dark variants.
51    #[inline]
52    pub const fn adaptive(light: Color, dark: Color) -> Self {
53        Self::Adaptive { light, dark }
54    }
55
56    /// Resolve the color based on the current mode.
57    ///
58    /// # Arguments
59    /// * `is_dark` - true for dark mode, false for light mode
60    #[inline]
61    pub const fn resolve(&self, is_dark: bool) -> Color {
62        match self {
63            Self::Fixed(c) => *c,
64            Self::Adaptive { light, dark } => {
65                if is_dark {
66                    *dark
67                } else {
68                    *light
69                }
70            }
71        }
72    }
73
74    /// Check if this color adapts to mode.
75    #[inline]
76    pub const fn is_adaptive(&self) -> bool {
77        matches!(self, Self::Adaptive { .. })
78    }
79}
80
81impl Default for AdaptiveColor {
82    fn default() -> Self {
83        Self::Fixed(Color::rgb(128, 128, 128))
84    }
85}
86
87impl From<Color> for AdaptiveColor {
88    fn from(color: Color) -> Self {
89        Self::Fixed(color)
90    }
91}
92
93/// A theme with semantic color slots.
94///
95/// Themes provide consistent styling across an application by mapping
96/// semantic names (like "error" or "primary") to actual colors.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Theme {
99    // Primary UI colors
100    /// Primary accent color (e.g., buttons, highlights).
101    pub primary: AdaptiveColor,
102    /// Secondary accent color.
103    pub secondary: AdaptiveColor,
104    /// Tertiary accent color.
105    pub accent: AdaptiveColor,
106
107    // Backgrounds
108    /// Main background color.
109    pub background: AdaptiveColor,
110    /// Surface color (cards, panels).
111    pub surface: AdaptiveColor,
112    /// Overlay color (dialogs, dropdowns).
113    pub overlay: AdaptiveColor,
114
115    // Text
116    /// Primary text color.
117    pub text: AdaptiveColor,
118    /// Muted text color.
119    pub text_muted: AdaptiveColor,
120    /// Subtle text color (hints, placeholders).
121    pub text_subtle: AdaptiveColor,
122
123    // Semantic colors
124    /// Success color (green).
125    pub success: AdaptiveColor,
126    /// Warning color (yellow/orange).
127    pub warning: AdaptiveColor,
128    /// Error color (red).
129    pub error: AdaptiveColor,
130    /// Info color (blue).
131    pub info: AdaptiveColor,
132
133    // Borders
134    /// Default border color.
135    pub border: AdaptiveColor,
136    /// Focused element border.
137    pub border_focused: AdaptiveColor,
138
139    // Selection
140    /// Selection background.
141    pub selection_bg: AdaptiveColor,
142    /// Selection foreground.
143    pub selection_fg: AdaptiveColor,
144
145    // Scrollbar
146    /// Scrollbar track color.
147    pub scrollbar_track: AdaptiveColor,
148    /// Scrollbar thumb color.
149    pub scrollbar_thumb: AdaptiveColor,
150}
151
152impl Default for Theme {
153    fn default() -> Self {
154        themes::dark()
155    }
156}
157
158impl Theme {
159    /// Create a new theme builder.
160    pub fn builder() -> ThemeBuilder {
161        ThemeBuilder::new()
162    }
163
164    /// Detect whether dark mode should be used.
165    ///
166    /// Detection heuristics:
167    /// 1. Check COLORFGBG environment variable
168    /// 2. Default to dark mode (most terminals are dark)
169    ///
170    /// Note: OSC 11 background query would be more accurate but requires
171    /// terminal interaction which isn't always safe or fast.
172    #[must_use]
173    pub fn detect_dark_mode() -> bool {
174        Self::detect_dark_mode_from_colorfgbg(env::var("COLORFGBG").ok().as_deref())
175    }
176
177    fn detect_dark_mode_from_colorfgbg(colorfgbg: Option<&str>) -> bool {
178        // COLORFGBG format: "fg;bg" where values are ANSI color indices
179        // Common light terminals use bg=15 (white), dark use bg=0 (black)
180        if let Some(colorfgbg) = colorfgbg
181            && let Some(bg_part) = colorfgbg.split(';').next_back()
182            && let Ok(bg) = bg_part.trim().parse::<u8>()
183        {
184            // High ANSI indices (7, 15) typically mean light background
185            return bg != 7 && bg != 15;
186        }
187
188        // Default to dark mode (most common for terminals)
189        true
190    }
191
192    /// Create a resolved copy of this theme for a specific mode.
193    ///
194    /// This flattens all adaptive colors to fixed colors based on the mode.
195    #[must_use]
196    pub fn resolve(&self, is_dark: bool) -> ResolvedTheme {
197        ResolvedTheme {
198            primary: self.primary.resolve(is_dark),
199            secondary: self.secondary.resolve(is_dark),
200            accent: self.accent.resolve(is_dark),
201            background: self.background.resolve(is_dark),
202            surface: self.surface.resolve(is_dark),
203            overlay: self.overlay.resolve(is_dark),
204            text: self.text.resolve(is_dark),
205            text_muted: self.text_muted.resolve(is_dark),
206            text_subtle: self.text_subtle.resolve(is_dark),
207            success: self.success.resolve(is_dark),
208            warning: self.warning.resolve(is_dark),
209            error: self.error.resolve(is_dark),
210            info: self.info.resolve(is_dark),
211            border: self.border.resolve(is_dark),
212            border_focused: self.border_focused.resolve(is_dark),
213            selection_bg: self.selection_bg.resolve(is_dark),
214            selection_fg: self.selection_fg.resolve(is_dark),
215            scrollbar_track: self.scrollbar_track.resolve(is_dark),
216            scrollbar_thumb: self.scrollbar_thumb.resolve(is_dark),
217        }
218    }
219}
220
221/// A theme with all colors resolved to fixed values.
222///
223/// This is the result of calling `Theme::resolve()` with a specific mode.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct ResolvedTheme {
226    /// Primary accent color.
227    pub primary: Color,
228    /// Secondary accent color.
229    pub secondary: Color,
230    /// Tertiary accent color.
231    pub accent: Color,
232    /// Main background color.
233    pub background: Color,
234    /// Surface color (cards, panels).
235    pub surface: Color,
236    /// Overlay color (dialogs, dropdowns).
237    pub overlay: Color,
238    /// Primary text color.
239    pub text: Color,
240    /// Muted text color.
241    pub text_muted: Color,
242    /// Subtle text color (hints, placeholders).
243    pub text_subtle: Color,
244    /// Success color.
245    pub success: Color,
246    /// Warning color.
247    pub warning: Color,
248    /// Error color.
249    pub error: Color,
250    /// Info color.
251    pub info: Color,
252    /// Default border color.
253    pub border: Color,
254    /// Focused element border.
255    pub border_focused: Color,
256    /// Selection background.
257    pub selection_bg: Color,
258    /// Selection foreground.
259    pub selection_fg: Color,
260    /// Scrollbar track color.
261    pub scrollbar_track: Color,
262    /// Scrollbar thumb color.
263    pub scrollbar_thumb: Color,
264}
265
266/// Themeable colors for pane interaction affordances (bd-3bfbp).
267///
268/// Covers the splitter rail/handle in its three interaction states
269/// (idle / hover / active), the magnetic-snap dock-preview emphasis, and the
270/// keyboard focus ring around the active pane. Every color is *derived* from a
271/// [`ResolvedTheme`] slot — there are no ad-hoc hardcoded values — and then
272/// contrast-clamped so the affordance stays visible against the pane surface it
273/// is drawn on:
274///
275/// - In the default profile each state meets at least WCAG AA: the large-text
276///   bar (3.0:1) for the ever-present low-emphasis idle divider, and the
277///   normal-text bar (4.5:1) for the meaning-bearing hover/active/snap/ring
278///   states.
279/// - In the high-contrast profile (`high_contrast = true`) every state is
280///   lifted to WCAG AAA (7.0:1).
281///
282/// Clamping preserves the themed hue when it already passes the target, and
283/// otherwise blends it toward whichever extreme (pure black or pure white)
284/// maximizes contrast with the surface, by the smallest amount that reaches the
285/// target ratio. This keeps affordances on-palette in well-designed themes
286/// while guaranteeing they never become invisible in low-contrast ones.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct PaneAffordanceTheme {
289    /// Surface the affordances are drawn on (the pane background).
290    pub background: Color,
291    /// Idle splitter rail/handle (low-emphasis, always-present divider).
292    pub splitter_idle: Color,
293    /// Hovered splitter (pointer over the handle).
294    pub splitter_hover: Color,
295    /// Actively dragged splitter.
296    pub splitter_active: Color,
297    /// Magnetic-snap / dock-preview emphasis.
298    pub snap: Color,
299    /// Keyboard focus ring around the active pane.
300    pub focus_ring: Color,
301    /// Whether this was built in the high-contrast profile.
302    pub high_contrast: bool,
303}
304
305impl PaneAffordanceTheme {
306    /// Derive affordance colors from a resolved theme.
307    ///
308    /// `high_contrast` selects the WCAG target tier (AA vs AAA). Pass the
309    /// effective accessibility preference for the host here.
310    #[must_use]
311    pub fn from_resolved(theme: &ResolvedTheme, high_contrast: bool) -> Self {
312        let bg = theme.surface;
313        let (idle_target, emph_target) = if high_contrast {
314            (WCAG_AAA_NORMAL_TEXT, WCAG_AAA_NORMAL_TEXT)
315        } else {
316            (WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT)
317        };
318        Self {
319            background: bg,
320            splitter_idle: ensure_min_contrast(theme.border, bg, idle_target),
321            splitter_hover: ensure_min_contrast(theme.primary, bg, emph_target),
322            splitter_active: ensure_min_contrast(theme.border_focused, bg, emph_target),
323            snap: ensure_min_contrast(theme.warning, bg, emph_target),
324            focus_ring: ensure_min_contrast(theme.border_focused, bg, emph_target),
325            high_contrast,
326        }
327    }
328
329    /// The minimum WCAG contrast ratio across all affordance states vs the
330    /// surface. Useful as a single accessibility health metric in tests.
331    #[must_use]
332    pub fn min_contrast_ratio(&self) -> f64 {
333        let bg = self.background.to_rgb();
334        [
335            self.splitter_idle,
336            self.splitter_hover,
337            self.splitter_active,
338            self.snap,
339            self.focus_ring,
340        ]
341        .into_iter()
342        .map(|c| contrast_ratio(c.to_rgb(), bg))
343        .fold(f64::INFINITY, f64::min)
344    }
345}
346
347/// Blend `color` toward the maximum-contrast extreme for `bg` by the smallest
348/// step that reaches `target` contrast.
349///
350/// Returns `color` unchanged when it already passes the target, or the extreme
351/// (pure black/white) when even a full blend cannot reach it (e.g. a target
352/// above the surface's own max achievable ratio). Deterministic: a fixed 16-step
353/// blend, no floating-point search state.
354fn ensure_min_contrast(color: Color, bg: Color, target: f64) -> Color {
355    let bg_rgb = bg.to_rgb();
356    let c_rgb = color.to_rgb();
357    if contrast_ratio(c_rgb, bg_rgb) >= target {
358        return color;
359    }
360    // Blend toward whichever extreme maximizes contrast with the surface. The
361    // crossover between black and white as the better foil sits near a surface
362    // luminance of ~0.18, so compare the realized ratios rather than testing a
363    // luminance midpoint.
364    let white = Rgb::new(255, 255, 255);
365    let black = Rgb::new(0, 0, 0);
366    let extreme = if contrast_ratio(white, bg_rgb) >= contrast_ratio(black, bg_rgb) {
367        white
368    } else {
369        black
370    };
371    const STEPS: u16 = 16;
372    for step in 1..=STEPS {
373        let t = f64::from(step) / f64::from(STEPS);
374        let blended = lerp_rgb(c_rgb, extreme, t);
375        if contrast_ratio(blended, bg_rgb) >= target {
376            return Color::Rgb(blended);
377        }
378    }
379    Color::Rgb(extreme)
380}
381
382/// Linearly interpolate between two RGB colors (`t` clamped to `[0, 1]`).
383fn lerp_rgb(a: Rgb, b: Rgb, t: f64) -> Rgb {
384    let t = t.clamp(0.0, 1.0);
385    let lerp = |x: u8, y: u8| -> u8 {
386        let xf = f64::from(x);
387        let yf = f64::from(y);
388        (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
389    };
390    Rgb::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b))
391}
392
393/// Builder for creating custom themes.
394#[derive(Debug, Clone)]
395#[must_use]
396pub struct ThemeBuilder {
397    theme: Theme,
398}
399
400impl ThemeBuilder {
401    /// Create a new builder starting from the default dark theme.
402    pub fn new() -> Self {
403        Self {
404            theme: themes::dark(),
405        }
406    }
407
408    /// Start from a base theme.
409    pub fn from_theme(theme: Theme) -> Self {
410        Self { theme }
411    }
412
413    /// Set the primary color.
414    pub fn primary(mut self, color: impl Into<AdaptiveColor>) -> Self {
415        self.theme.primary = color.into();
416        self
417    }
418
419    /// Set the secondary color.
420    pub fn secondary(mut self, color: impl Into<AdaptiveColor>) -> Self {
421        self.theme.secondary = color.into();
422        self
423    }
424
425    /// Set the accent color.
426    pub fn accent(mut self, color: impl Into<AdaptiveColor>) -> Self {
427        self.theme.accent = color.into();
428        self
429    }
430
431    /// Set the background color.
432    pub fn background(mut self, color: impl Into<AdaptiveColor>) -> Self {
433        self.theme.background = color.into();
434        self
435    }
436
437    /// Set the surface color.
438    pub fn surface(mut self, color: impl Into<AdaptiveColor>) -> Self {
439        self.theme.surface = color.into();
440        self
441    }
442
443    /// Set the overlay color.
444    pub fn overlay(mut self, color: impl Into<AdaptiveColor>) -> Self {
445        self.theme.overlay = color.into();
446        self
447    }
448
449    /// Set the text color.
450    pub fn text(mut self, color: impl Into<AdaptiveColor>) -> Self {
451        self.theme.text = color.into();
452        self
453    }
454
455    /// Set the muted text color.
456    pub fn text_muted(mut self, color: impl Into<AdaptiveColor>) -> Self {
457        self.theme.text_muted = color.into();
458        self
459    }
460
461    /// Set the subtle text color.
462    pub fn text_subtle(mut self, color: impl Into<AdaptiveColor>) -> Self {
463        self.theme.text_subtle = color.into();
464        self
465    }
466
467    /// Set the success color.
468    pub fn success(mut self, color: impl Into<AdaptiveColor>) -> Self {
469        self.theme.success = color.into();
470        self
471    }
472
473    /// Set the warning color.
474    pub fn warning(mut self, color: impl Into<AdaptiveColor>) -> Self {
475        self.theme.warning = color.into();
476        self
477    }
478
479    /// Set the error color.
480    pub fn error(mut self, color: impl Into<AdaptiveColor>) -> Self {
481        self.theme.error = color.into();
482        self
483    }
484
485    /// Set the info color.
486    pub fn info(mut self, color: impl Into<AdaptiveColor>) -> Self {
487        self.theme.info = color.into();
488        self
489    }
490
491    /// Set the border color.
492    pub fn border(mut self, color: impl Into<AdaptiveColor>) -> Self {
493        self.theme.border = color.into();
494        self
495    }
496
497    /// Set the focused border color.
498    pub fn border_focused(mut self, color: impl Into<AdaptiveColor>) -> Self {
499        self.theme.border_focused = color.into();
500        self
501    }
502
503    /// Set the selection background color.
504    pub fn selection_bg(mut self, color: impl Into<AdaptiveColor>) -> Self {
505        self.theme.selection_bg = color.into();
506        self
507    }
508
509    /// Set the selection foreground color.
510    pub fn selection_fg(mut self, color: impl Into<AdaptiveColor>) -> Self {
511        self.theme.selection_fg = color.into();
512        self
513    }
514
515    /// Set the scrollbar track color.
516    pub fn scrollbar_track(mut self, color: impl Into<AdaptiveColor>) -> Self {
517        self.theme.scrollbar_track = color.into();
518        self
519    }
520
521    /// Set the scrollbar thumb color.
522    pub fn scrollbar_thumb(mut self, color: impl Into<AdaptiveColor>) -> Self {
523        self.theme.scrollbar_thumb = color.into();
524        self
525    }
526
527    /// Build the theme.
528    pub fn build(self) -> Theme {
529        self.theme
530    }
531}
532
533impl Default for ThemeBuilder {
534    fn default() -> Self {
535        Self::new()
536    }
537}
538
539/// Built-in theme presets.
540pub mod themes {
541    use super::*;
542
543    /// Default sensible theme (dark mode).
544    #[must_use]
545    pub fn default() -> Theme {
546        dark()
547    }
548
549    /// Dark theme.
550    #[must_use]
551    pub fn dark() -> Theme {
552        Theme {
553            primary: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Blue
554            secondary: AdaptiveColor::fixed(Color::rgb(163, 113, 247)), // Purple
555            accent: AdaptiveColor::fixed(Color::rgb(255, 123, 114)), // Coral
556
557            background: AdaptiveColor::fixed(Color::rgb(22, 27, 34)), // Dark gray
558            surface: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),    // Slightly lighter
559            overlay: AdaptiveColor::fixed(Color::rgb(48, 54, 61)),    // Even lighter
560
561            text: AdaptiveColor::fixed(Color::rgb(230, 237, 243)), // Bright
562            text_muted: AdaptiveColor::fixed(Color::rgb(139, 148, 158)), // Gray
563            text_subtle: AdaptiveColor::fixed(Color::rgb(110, 118, 129)), // Darker gray
564
565            success: AdaptiveColor::fixed(Color::rgb(63, 185, 80)), // Green
566            warning: AdaptiveColor::fixed(Color::rgb(210, 153, 34)), // Yellow
567            error: AdaptiveColor::fixed(Color::rgb(248, 81, 73)),   // Red
568            info: AdaptiveColor::fixed(Color::rgb(88, 166, 255)),   // Blue
569
570            border: AdaptiveColor::fixed(Color::rgb(48, 54, 61)), // Subtle
571            border_focused: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Accent
572
573            selection_bg: AdaptiveColor::fixed(Color::rgb(56, 139, 253)), // Blue
574            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
575
576            scrollbar_track: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),
577            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(72, 79, 88)),
578        }
579    }
580
581    /// Light theme.
582    #[must_use]
583    pub fn light() -> Theme {
584        Theme {
585            primary: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Blue
586            secondary: AdaptiveColor::fixed(Color::rgb(130, 80, 223)), // Purple
587            accent: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),  // Red
588
589            background: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
590            surface: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),    // Light gray
591            overlay: AdaptiveColor::fixed(Color::rgb(255, 255, 255)),    // White
592
593            text: AdaptiveColor::fixed(Color::rgb(31, 35, 40)), // Dark
594            text_muted: AdaptiveColor::fixed(Color::rgb(87, 96, 106)), // Gray
595            text_subtle: AdaptiveColor::fixed(Color::rgb(140, 149, 159)), // Light gray
596
597            success: AdaptiveColor::fixed(Color::rgb(26, 127, 55)), // Green
598            warning: AdaptiveColor::fixed(Color::rgb(158, 106, 3)), // Yellow
599            error: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),   // Red
600            info: AdaptiveColor::fixed(Color::rgb(9, 105, 218)),    // Blue
601
602            border: AdaptiveColor::fixed(Color::rgb(208, 215, 222)), // Light gray
603            border_focused: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Accent
604
605            selection_bg: AdaptiveColor::fixed(Color::rgb(221, 244, 255)), // Light blue
606            selection_fg: AdaptiveColor::fixed(Color::rgb(31, 35, 40)),    // Dark
607
608            scrollbar_track: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),
609            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(175, 184, 193)),
610        }
611    }
612
613    /// Nord color scheme (dark variant).
614    #[must_use]
615    pub fn nord() -> Theme {
616        Theme {
617            primary: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8 (frost)
618            secondary: AdaptiveColor::fixed(Color::rgb(180, 142, 173)), // Nord15 (purple)
619            accent: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),   // Nord11 (aurora red)
620
621            background: AdaptiveColor::fixed(Color::rgb(46, 52, 64)), // Nord0
622            surface: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),    // Nord1
623            overlay: AdaptiveColor::fixed(Color::rgb(67, 76, 94)),    // Nord2
624
625            text: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
626            text_muted: AdaptiveColor::fixed(Color::rgb(216, 222, 233)), // Nord4
627            text_subtle: AdaptiveColor::fixed(Color::rgb(129, 161, 193)), // Nord9
628
629            success: AdaptiveColor::fixed(Color::rgb(163, 190, 140)), // Nord14 (green)
630            warning: AdaptiveColor::fixed(Color::rgb(235, 203, 139)), // Nord13 (yellow)
631            error: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),    // Nord11 (red)
632            info: AdaptiveColor::fixed(Color::rgb(129, 161, 193)),    // Nord9 (blue)
633
634            border: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
635            border_focused: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8
636
637            selection_bg: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
638            selection_fg: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
639
640            scrollbar_track: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),
641            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(76, 86, 106)),
642        }
643    }
644
645    /// Dracula color scheme.
646    #[must_use]
647    pub fn dracula() -> Theme {
648        Theme {
649            primary: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
650            secondary: AdaptiveColor::fixed(Color::rgb(255, 121, 198)), // Pink
651            accent: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),  // Cyan
652
653            background: AdaptiveColor::fixed(Color::rgb(40, 42, 54)), // Background
654            surface: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
655            overlay: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
656
657            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
658            text_muted: AdaptiveColor::fixed(Color::rgb(188, 188, 188)), // Lighter
659            text_subtle: AdaptiveColor::fixed(Color::rgb(98, 114, 164)), // Comment
660
661            success: AdaptiveColor::fixed(Color::rgb(80, 250, 123)), // Green
662            warning: AdaptiveColor::fixed(Color::rgb(255, 184, 108)), // Orange
663            error: AdaptiveColor::fixed(Color::rgb(255, 85, 85)),    // Red
664            info: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),   // Cyan
665
666            border: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
667            border_focused: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
668
669            selection_bg: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
670            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
671
672            scrollbar_track: AdaptiveColor::fixed(Color::rgb(40, 42, 54)),
673            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),
674        }
675    }
676
677    /// Solarized Dark color scheme.
678    #[must_use]
679    pub fn solarized_dark() -> Theme {
680        Theme {
681            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
682            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
683            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
684
685            background: AdaptiveColor::fixed(Color::rgb(0, 43, 54)), // Base03
686            surface: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
687            overlay: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
688
689            text: AdaptiveColor::fixed(Color::rgb(131, 148, 150)), // Base0
690            text_muted: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
691            text_subtle: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
692
693            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
694            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
695            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
696            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
697
698            border: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
699            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
700
701            selection_bg: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
702            selection_fg: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
703
704            scrollbar_track: AdaptiveColor::fixed(Color::rgb(0, 43, 54)),
705            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),
706        }
707    }
708
709    /// Solarized Light color scheme.
710    #[must_use]
711    pub fn solarized_light() -> Theme {
712        Theme {
713            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
714            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
715            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
716
717            background: AdaptiveColor::fixed(Color::rgb(253, 246, 227)), // Base3
718            surface: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),    // Base2
719            overlay: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),    // Base3
720
721            text: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
722            text_muted: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
723            text_subtle: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
724
725            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
726            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
727            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
728            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
729
730            border: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
731            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
732
733            selection_bg: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
734            selection_fg: AdaptiveColor::fixed(Color::rgb(88, 110, 117)),  // Base01
735
736            scrollbar_track: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),
737            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),
738        }
739    }
740
741    /// Monokai color scheme.
742    #[must_use]
743    pub fn monokai() -> Theme {
744        Theme {
745            primary: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
746            secondary: AdaptiveColor::fixed(Color::rgb(174, 129, 255)), // Purple
747            accent: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink
748
749            background: AdaptiveColor::fixed(Color::rgb(39, 40, 34)), // Background
750            surface: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
751            overlay: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
752
753            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
754            text_muted: AdaptiveColor::fixed(Color::rgb(189, 189, 189)), // Gray
755            text_subtle: AdaptiveColor::fixed(Color::rgb(117, 113, 94)), // Comment
756
757            success: AdaptiveColor::fixed(Color::rgb(166, 226, 46)), // Green
758            warning: AdaptiveColor::fixed(Color::rgb(230, 219, 116)), // Yellow
759            error: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink/red
760            info: AdaptiveColor::fixed(Color::rgb(102, 217, 239)),   // Cyan
761
762            border: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), // Lighter bg
763            border_focused: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
764
765            selection_bg: AdaptiveColor::fixed(Color::rgb(73, 72, 62)), // Selection
766            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
767
768            scrollbar_track: AdaptiveColor::fixed(Color::rgb(39, 40, 34)),
769            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),
770        }
771    }
772
773    /// Doom color scheme (High contrast, Industrial).
774    #[must_use]
775    pub fn doom() -> Theme {
776        Theme {
777            primary: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), // Firebrick
778            secondary: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), // LimeGreen
779            accent: AdaptiveColor::fixed(Color::rgb(255, 255, 0)),  // Yellow (Ammo)
780
781            background: AdaptiveColor::fixed(Color::rgb(26, 26, 26)), // Dark Grey
782            surface: AdaptiveColor::fixed(Color::rgb(47, 47, 47)),    // Gunmetal
783            overlay: AdaptiveColor::fixed(Color::rgb(64, 64, 64)),    // Dim Grey
784
785            text: AdaptiveColor::fixed(Color::rgb(211, 211, 211)), // Light Grey
786            text_muted: AdaptiveColor::fixed(Color::rgb(128, 128, 128)), // Grey
787            text_subtle: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), // Dim Grey
788
789            success: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), // Green
790            warning: AdaptiveColor::fixed(Color::rgb(255, 215, 0)), // Gold
791            error: AdaptiveColor::fixed(Color::rgb(139, 0, 0)),     // Dark Red
792            info: AdaptiveColor::fixed(Color::rgb(65, 105, 225)),   // Royal Blue
793
794            border: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), // Dim Grey
795            border_focused: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), // Firebrick
796
797            selection_bg: AdaptiveColor::fixed(Color::rgb(139, 0, 0)), // Dark Red
798            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
799
800            scrollbar_track: AdaptiveColor::fixed(Color::rgb(26, 26, 26)),
801            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(178, 34, 34)),
802        }
803    }
804
805    /// Quake color scheme (Earth tones, Medieval).
806    #[must_use]
807    pub fn quake() -> Theme {
808        Theme {
809            primary: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), // SaddleBrown
810            secondary: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), // DarkOliveGreen
811            accent: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), // Peru
812
813            background: AdaptiveColor::fixed(Color::rgb(28, 28, 28)), // Very Dark Grey
814            surface: AdaptiveColor::fixed(Color::rgb(46, 39, 34)),    // Deep Brown/Grey
815            overlay: AdaptiveColor::fixed(Color::rgb(62, 54, 48)),    // Lighter Brown/Grey
816
817            text: AdaptiveColor::fixed(Color::rgb(210, 180, 140)), // Tan
818            text_muted: AdaptiveColor::fixed(Color::rgb(139, 115, 85)), // Dark Tan
819            text_subtle: AdaptiveColor::fixed(Color::rgb(101, 84, 61)), // Deep Tan
820
821            success: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), // Olive
822            warning: AdaptiveColor::fixed(Color::rgb(210, 105, 30)), // Chocolate
823            error: AdaptiveColor::fixed(Color::rgb(128, 0, 0)),     // Maroon
824            info: AdaptiveColor::fixed(Color::rgb(70, 130, 180)),   // SteelBlue
825
826            border: AdaptiveColor::fixed(Color::rgb(93, 64, 55)), // Rusty Brown
827            border_focused: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), // Peru
828
829            selection_bg: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), // SaddleBrown
830            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 222, 173)), // NavajoWhite
831
832            scrollbar_track: AdaptiveColor::fixed(Color::rgb(28, 28, 28)),
833            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(139, 69, 19)),
834        }
835    }
836}
837
838// ============================================================================
839// SharedResolvedTheme — ArcSwap-backed concurrent access (bd-3l9qr.2)
840// ============================================================================
841
842/// Wait-free shared resolved theme for concurrent read/write.
843///
844/// Wraps a [`ResolvedTheme`] in an [`arc_swap::ArcSwap`] so that the render
845/// thread can read theme colors without locking while the main thread updates
846/// them on theme switch or mode change.
847///
848/// # Example
849///
850/// ```
851/// use ftui_style::theme::{Theme, ResolvedTheme, SharedResolvedTheme};
852///
853/// let theme = Theme::default();
854/// let resolved = theme.resolve(true); // dark mode
855/// let shared = SharedResolvedTheme::new(resolved);
856///
857/// // Wait-free read from render thread
858/// let current = shared.load();
859/// assert_eq!(current.primary, resolved.primary);
860///
861/// // Update on theme switch
862/// let light = theme.resolve(false);
863/// shared.store(light);
864/// ```
865pub struct SharedResolvedTheme {
866    inner: arc_swap::ArcSwap<ResolvedTheme>,
867}
868
869impl SharedResolvedTheme {
870    /// Create shared theme from an initial resolved theme.
871    pub fn new(theme: ResolvedTheme) -> Self {
872        Self {
873            inner: arc_swap::ArcSwap::from_pointee(theme),
874        }
875    }
876
877    /// Wait-free read of current resolved theme.
878    #[inline]
879    pub fn load(&self) -> ResolvedTheme {
880        let guard = self.inner.load();
881        **guard
882    }
883
884    /// Atomically replace the resolved theme (e.g., on theme switch or mode change).
885    #[inline]
886    pub fn store(&self, theme: ResolvedTheme) {
887        self.inner.store(std::sync::Arc::new(theme));
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    /// All built-in themes, exercised for affordance contrast in both modes.
896    fn all_named_themes() -> Vec<(&'static str, Theme)> {
897        vec![
898            ("default", themes::default()),
899            ("dark", themes::dark()),
900            ("light", themes::light()),
901            ("nord", themes::nord()),
902            ("dracula", themes::dracula()),
903            ("solarized_dark", themes::solarized_dark()),
904            ("solarized_light", themes::solarized_light()),
905            ("monokai", themes::monokai()),
906            ("doom", themes::doom()),
907            ("quake", themes::quake()),
908        ]
909    }
910
911    /// Maximum contrast ratio any color can reach against `bg` (black or white).
912    fn max_achievable_contrast(bg: Color) -> f64 {
913        let bg = bg.to_rgb();
914        contrast_ratio(Rgb::new(255, 255, 255), bg).max(contrast_ratio(Rgb::new(0, 0, 0), bg))
915    }
916
917    fn assert_meets(state: Color, bg: Color, target: f64, ctx: &str) {
918        // The clamp guarantees `target`, or the surface's own ceiling when the
919        // target exceeds what any color can reach against this surface.
920        let bar = target.min(max_achievable_contrast(bg));
921        let got = contrast_ratio(state.to_rgb(), bg.to_rgb());
922        assert!(
923            got + 1e-9 >= bar,
924            "{ctx}: contrast {got:.3} below required {bar:.3}"
925        );
926    }
927
928    #[test]
929    fn pane_affordance_theme_is_visible_in_every_named_theme() {
930        for (name, theme) in all_named_themes() {
931            for &is_dark in &[true, false] {
932                let resolved = theme.resolve(is_dark);
933                let bg = resolved.surface;
934
935                // Default profile: idle holds the AA large-text bar; the
936                // meaning-bearing states hold the AA normal-text bar.
937                let aff = PaneAffordanceTheme::from_resolved(&resolved, false);
938                let ctx = format!("{name}/dark={is_dark}/default");
939                assert_meets(aff.splitter_idle, bg, WCAG_AA_LARGE_TEXT, &ctx);
940                assert_meets(aff.splitter_hover, bg, WCAG_AA_NORMAL_TEXT, &ctx);
941                assert_meets(aff.splitter_active, bg, WCAG_AA_NORMAL_TEXT, &ctx);
942                assert_meets(aff.snap, bg, WCAG_AA_NORMAL_TEXT, &ctx);
943                assert_meets(aff.focus_ring, bg, WCAG_AA_NORMAL_TEXT, &ctx);
944                assert!(!aff.high_contrast);
945
946                // High-contrast profile: every state lifts to AAA (capped by the
947                // surface ceiling where AAA is physically unreachable).
948                let hc = PaneAffordanceTheme::from_resolved(&resolved, true);
949                let hctx = format!("{name}/dark={is_dark}/high_contrast");
950                assert_meets(hc.splitter_idle, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
951                assert_meets(hc.splitter_hover, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
952                assert_meets(hc.splitter_active, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
953                assert_meets(hc.snap, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
954                assert_meets(hc.focus_ring, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
955                assert!(hc.high_contrast);
956
957                // High contrast must never be *worse* than default.
958                assert!(
959                    hc.min_contrast_ratio() + 1e-9 >= aff.min_contrast_ratio(),
960                    "{name}/dark={is_dark}: high-contrast min {:.3} < default min {:.3}",
961                    hc.min_contrast_ratio(),
962                    aff.min_contrast_ratio()
963                );
964            }
965        }
966    }
967
968    #[test]
969    fn pane_affordance_states_are_derived_from_the_theme() {
970        let resolved = themes::dark().resolve(true);
971        let dark = PaneAffordanceTheme::from_resolved(&resolved, false);
972        let light = PaneAffordanceTheme::from_resolved(&themes::light().resolve(false), false);
973
974        // The surface passes through verbatim (it is the canvas, not clamped).
975        assert_eq!(dark.background, resolved.surface);
976
977        // A slot that already clears the target is used verbatim — proving the
978        // affordance is *derived* from the theme, not synthesized. The dark
979        // theme's bright primary clears AA on its very dark surface.
980        assert!(
981            contrast_ratio(resolved.primary.to_rgb(), resolved.surface.to_rgb())
982                >= WCAG_AA_NORMAL_TEXT
983        );
984        assert_eq!(dark.splitter_hover, resolved.primary);
985
986        // Two distinct themes produce distinct affordances — proving the colors
987        // track the theme rather than being a hardcoded constant.
988        assert_ne!(dark.background, light.background);
989        assert_ne!(dark.splitter_hover, light.splitter_hover);
990    }
991
992    #[test]
993    fn ensure_min_contrast_is_a_noop_when_already_compliant() {
994        // White on near-black easily clears AAA, so it is returned untouched.
995        let bg = Color::rgb(10, 12, 16);
996        let fg = Color::rgb(255, 255, 255);
997        assert_eq!(ensure_min_contrast(fg, bg, WCAG_AAA_NORMAL_TEXT), fg);
998    }
999
1000    #[test]
1001    fn ensure_min_contrast_lifts_low_contrast_colors() {
1002        // A mid-gray foil on a mid-gray surface fails AA; the clamp must lift it.
1003        let bg = Color::rgb(120, 120, 120);
1004        let fg = Color::rgb(130, 130, 130);
1005        let before = contrast_ratio(fg.to_rgb(), bg.to_rgb());
1006        assert!(before < WCAG_AA_LARGE_TEXT);
1007        let fixed = ensure_min_contrast(fg, bg, WCAG_AA_LARGE_TEXT);
1008        let after = contrast_ratio(fixed.to_rgb(), bg.to_rgb());
1009        assert!(
1010            after + 1e-9 >= WCAG_AA_LARGE_TEXT.min(max_achievable_contrast(bg)),
1011            "clamp failed to lift contrast: {before:.3} -> {after:.3}"
1012        );
1013    }
1014
1015    #[test]
1016    fn from_resolved_is_deterministic() {
1017        let resolved = themes::nord().resolve(true);
1018        let a = PaneAffordanceTheme::from_resolved(&resolved, true);
1019        let b = PaneAffordanceTheme::from_resolved(&resolved, true);
1020        assert_eq!(a, b);
1021    }
1022
1023    #[test]
1024    fn adaptive_color_fixed() {
1025        let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1026        assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
1027        assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
1028        assert!(!color.is_adaptive());
1029    }
1030
1031    #[test]
1032    fn adaptive_color_adaptive() {
1033        let color = AdaptiveColor::adaptive(
1034            Color::rgb(255, 255, 255), // light
1035            Color::rgb(0, 0, 0),       // dark
1036        );
1037        assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); // dark
1038        assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); // light
1039        assert!(color.is_adaptive());
1040    }
1041
1042    #[test]
1043    fn theme_default_is_dark() {
1044        let theme = Theme::default();
1045        // Dark themes typically have dark backgrounds
1046        let bg = theme.background.resolve(true);
1047        if let Color::Rgb(rgb) = bg {
1048            // Background should be dark
1049            assert!(rgb.luminance_u8() < 50);
1050        }
1051    }
1052
1053    #[test]
1054    fn theme_light_has_light_background() {
1055        let theme = themes::light();
1056        let bg = theme.background.resolve(false);
1057        if let Color::Rgb(rgb) = bg {
1058            // Light background
1059            assert!(rgb.luminance_u8() > 200);
1060        }
1061    }
1062
1063    #[test]
1064    fn theme_has_all_slots() {
1065        let theme = Theme::default();
1066        // Just verify all slots exist and resolve without panic
1067        let _ = theme.primary.resolve(true);
1068        let _ = theme.secondary.resolve(true);
1069        let _ = theme.accent.resolve(true);
1070        let _ = theme.background.resolve(true);
1071        let _ = theme.surface.resolve(true);
1072        let _ = theme.overlay.resolve(true);
1073        let _ = theme.text.resolve(true);
1074        let _ = theme.text_muted.resolve(true);
1075        let _ = theme.text_subtle.resolve(true);
1076        let _ = theme.success.resolve(true);
1077        let _ = theme.warning.resolve(true);
1078        let _ = theme.error.resolve(true);
1079        let _ = theme.info.resolve(true);
1080        let _ = theme.border.resolve(true);
1081        let _ = theme.border_focused.resolve(true);
1082        let _ = theme.selection_bg.resolve(true);
1083        let _ = theme.selection_fg.resolve(true);
1084        let _ = theme.scrollbar_track.resolve(true);
1085        let _ = theme.scrollbar_thumb.resolve(true);
1086    }
1087
1088    #[test]
1089    fn theme_builder_works() {
1090        let theme = Theme::builder()
1091            .primary(Color::rgb(255, 0, 0))
1092            .background(Color::rgb(0, 0, 0))
1093            .build();
1094
1095        assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
1096        assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
1097    }
1098
1099    #[test]
1100    fn theme_resolve_flattens() {
1101        let theme = themes::dark();
1102        let resolved = theme.resolve(true);
1103
1104        // All colors should be the same as resolving individually
1105        assert_eq!(resolved.primary, theme.primary.resolve(true));
1106        assert_eq!(resolved.text, theme.text.resolve(true));
1107        assert_eq!(resolved.background, theme.background.resolve(true));
1108    }
1109
1110    #[test]
1111    fn all_presets_exist() {
1112        let _ = themes::default();
1113        let _ = themes::dark();
1114        let _ = themes::light();
1115        let _ = themes::nord();
1116        let _ = themes::dracula();
1117        let _ = themes::solarized_dark();
1118        let _ = themes::solarized_light();
1119        let _ = themes::monokai();
1120    }
1121
1122    #[test]
1123    fn presets_have_different_colors() {
1124        let dark = themes::dark();
1125        let light = themes::light();
1126        let nord = themes::nord();
1127
1128        // Different themes should have different backgrounds
1129        assert_ne!(
1130            dark.background.resolve(true),
1131            light.background.resolve(false)
1132        );
1133        assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
1134    }
1135
1136    #[test]
1137    fn detect_dark_mode_returns_bool() {
1138        // Just verify it doesn't panic
1139        let _ = Theme::detect_dark_mode();
1140    }
1141
1142    #[test]
1143    fn color_converts_to_adaptive() {
1144        let color = Color::rgb(100, 150, 200);
1145        let adaptive: AdaptiveColor = color.into();
1146        assert_eq!(adaptive.resolve(true), color);
1147        assert_eq!(adaptive.resolve(false), color);
1148    }
1149
1150    #[test]
1151    fn builder_from_theme() {
1152        let base = themes::nord();
1153        let modified = ThemeBuilder::from_theme(base.clone())
1154            .primary(Color::rgb(255, 0, 0))
1155            .build();
1156
1157        // Modified primary
1158        assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
1159        // Unchanged secondary (from nord)
1160        assert_eq!(modified.secondary, base.secondary);
1161    }
1162
1163    // Count semantic slots to verify we have 15+
1164    #[test]
1165    fn has_at_least_15_semantic_slots() {
1166        let theme = Theme::default();
1167        let slot_count = 19; // Counting from the struct definition
1168        assert!(slot_count >= 15);
1169
1170        // Verify by accessing each slot
1171        let _slots = [
1172            &theme.primary,
1173            &theme.secondary,
1174            &theme.accent,
1175            &theme.background,
1176            &theme.surface,
1177            &theme.overlay,
1178            &theme.text,
1179            &theme.text_muted,
1180            &theme.text_subtle,
1181            &theme.success,
1182            &theme.warning,
1183            &theme.error,
1184            &theme.info,
1185            &theme.border,
1186            &theme.border_focused,
1187            &theme.selection_bg,
1188            &theme.selection_fg,
1189            &theme.scrollbar_track,
1190            &theme.scrollbar_thumb,
1191        ];
1192    }
1193
1194    #[test]
1195    fn adaptive_color_default_is_gray() {
1196        let color = AdaptiveColor::default();
1197        assert!(!color.is_adaptive());
1198        assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
1199        assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
1200    }
1201
1202    #[test]
1203    fn theme_builder_default() {
1204        let builder = ThemeBuilder::default();
1205        let theme = builder.build();
1206        // Default builder starts from dark theme
1207        assert_eq!(theme, themes::dark());
1208    }
1209
1210    #[test]
1211    fn resolved_theme_has_all_19_slots() {
1212        let theme = themes::dark();
1213        let resolved = theme.resolve(true);
1214        // Just verify all slots are accessible without panic
1215        let _colors = [
1216            resolved.primary,
1217            resolved.secondary,
1218            resolved.accent,
1219            resolved.background,
1220            resolved.surface,
1221            resolved.overlay,
1222            resolved.text,
1223            resolved.text_muted,
1224            resolved.text_subtle,
1225            resolved.success,
1226            resolved.warning,
1227            resolved.error,
1228            resolved.info,
1229            resolved.border,
1230            resolved.border_focused,
1231            resolved.selection_bg,
1232            resolved.selection_fg,
1233            resolved.scrollbar_track,
1234            resolved.scrollbar_thumb,
1235        ];
1236    }
1237
1238    #[test]
1239    fn dark_and_light_resolve_differently() {
1240        let theme = Theme {
1241            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1242            ..themes::dark()
1243        };
1244        let dark_resolved = theme.resolve(true);
1245        let light_resolved = theme.resolve(false);
1246        assert_ne!(dark_resolved.text, light_resolved.text);
1247        assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
1248        assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
1249    }
1250
1251    #[test]
1252    fn all_dark_presets_have_dark_backgrounds() {
1253        for (name, theme) in [
1254            ("dark", themes::dark()),
1255            ("nord", themes::nord()),
1256            ("dracula", themes::dracula()),
1257            ("solarized_dark", themes::solarized_dark()),
1258            ("monokai", themes::monokai()),
1259        ] {
1260            let bg = theme.background.resolve(true);
1261            if let Color::Rgb(rgb) = bg {
1262                assert!(
1263                    rgb.luminance_u8() < 100,
1264                    "{name} background too bright: {}",
1265                    rgb.luminance_u8()
1266                );
1267            }
1268        }
1269    }
1270
1271    #[test]
1272    fn all_light_presets_have_light_backgrounds() {
1273        for (name, theme) in [
1274            ("light", themes::light()),
1275            ("solarized_light", themes::solarized_light()),
1276        ] {
1277            let bg = theme.background.resolve(false);
1278            if let Color::Rgb(rgb) = bg {
1279                assert!(
1280                    rgb.luminance_u8() > 150,
1281                    "{name} background too dark: {}",
1282                    rgb.luminance_u8()
1283                );
1284            }
1285        }
1286    }
1287
1288    #[test]
1289    fn theme_default_equals_dark() {
1290        assert_eq!(Theme::default(), themes::dark());
1291        assert_eq!(themes::default(), themes::dark());
1292    }
1293
1294    #[test]
1295    fn builder_all_setters_chain() {
1296        let theme = Theme::builder()
1297            .primary(Color::rgb(1, 0, 0))
1298            .secondary(Color::rgb(2, 0, 0))
1299            .accent(Color::rgb(3, 0, 0))
1300            .background(Color::rgb(4, 0, 0))
1301            .surface(Color::rgb(5, 0, 0))
1302            .overlay(Color::rgb(6, 0, 0))
1303            .text(Color::rgb(7, 0, 0))
1304            .text_muted(Color::rgb(8, 0, 0))
1305            .text_subtle(Color::rgb(9, 0, 0))
1306            .success(Color::rgb(10, 0, 0))
1307            .warning(Color::rgb(11, 0, 0))
1308            .error(Color::rgb(12, 0, 0))
1309            .info(Color::rgb(13, 0, 0))
1310            .border(Color::rgb(14, 0, 0))
1311            .border_focused(Color::rgb(15, 0, 0))
1312            .selection_bg(Color::rgb(16, 0, 0))
1313            .selection_fg(Color::rgb(17, 0, 0))
1314            .scrollbar_track(Color::rgb(18, 0, 0))
1315            .scrollbar_thumb(Color::rgb(19, 0, 0))
1316            .build();
1317        assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
1318        assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
1319    }
1320
1321    #[test]
1322    fn resolved_theme_is_copy() {
1323        let theme = themes::dark();
1324        let resolved = theme.resolve(true);
1325        let copy = resolved;
1326        assert_eq!(resolved, copy);
1327    }
1328
1329    #[test]
1330    fn detect_dark_mode_with_colorfgbg_dark() {
1331        // COLORFGBG "0;0" means fg=0 bg=0 (black bg = dark mode)
1332        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
1333        assert!(result, "bg=0 should be dark mode");
1334    }
1335
1336    #[test]
1337    fn detect_dark_mode_with_colorfgbg_light_15() {
1338        // COLORFGBG "0;15" means bg=15 (white = light mode)
1339        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
1340        assert!(!result, "bg=15 should be light mode");
1341    }
1342
1343    #[test]
1344    fn detect_dark_mode_with_colorfgbg_light_7() {
1345        // COLORFGBG "0;7" means bg=7 (silver = light mode)
1346        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
1347        assert!(!result, "bg=7 should be light mode");
1348    }
1349
1350    #[test]
1351    fn detect_dark_mode_without_env_defaults_dark() {
1352        let result = Theme::detect_dark_mode_from_colorfgbg(None);
1353        assert!(result, "missing COLORFGBG should default to dark");
1354    }
1355
1356    #[test]
1357    fn detect_dark_mode_with_empty_string() {
1358        let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
1359        assert!(result, "empty COLORFGBG should default to dark");
1360    }
1361
1362    #[test]
1363    fn detect_dark_mode_with_no_semicolon() {
1364        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
1365        assert!(result, "COLORFGBG without semicolon should default to dark");
1366    }
1367
1368    #[test]
1369    fn detect_dark_mode_with_multiple_semicolons() {
1370        // Some terminals use "fg;bg;..." format
1371        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
1372        assert!(result, "COLORFGBG with extra parts should use last as bg");
1373    }
1374
1375    #[test]
1376    fn detect_dark_mode_with_whitespace() {
1377        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1378        assert!(!result, "COLORFGBG with whitespace should parse correctly");
1379    }
1380
1381    #[test]
1382    fn detect_dark_mode_with_invalid_number() {
1383        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1384        assert!(
1385            result,
1386            "COLORFGBG with invalid number should default to dark"
1387        );
1388    }
1389
1390    #[test]
1391    fn theme_clone_produces_equal_theme() {
1392        let theme = themes::nord();
1393        let cloned = theme.clone();
1394        assert_eq!(theme, cloned);
1395    }
1396
1397    #[test]
1398    fn theme_equality_different_themes() {
1399        let dark = themes::dark();
1400        let light = themes::light();
1401        assert_ne!(dark, light);
1402    }
1403
1404    #[test]
1405    fn resolved_theme_different_modes_differ() {
1406        // Create a theme with adaptive colors
1407        let theme = Theme {
1408            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1409            background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1410            ..themes::dark()
1411        };
1412        let dark_resolved = theme.resolve(true);
1413        let light_resolved = theme.resolve(false);
1414        assert_ne!(dark_resolved, light_resolved);
1415    }
1416
1417    #[test]
1418    fn resolved_theme_equality_same_mode() {
1419        let theme = themes::dark();
1420        let resolved1 = theme.resolve(true);
1421        let resolved2 = theme.resolve(true);
1422        assert_eq!(resolved1, resolved2);
1423    }
1424
1425    #[test]
1426    fn preset_nord_has_characteristic_colors() {
1427        let nord = themes::nord();
1428        // Nord8 frost blue is the primary color
1429        let primary = nord.primary.resolve(true);
1430        if let Color::Rgb(rgb) = primary {
1431            assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1432        }
1433    }
1434
1435    #[test]
1436    fn preset_dracula_has_characteristic_colors() {
1437        let dracula = themes::dracula();
1438        // Dracula primary is purple
1439        let primary = dracula.primary.resolve(true);
1440        if let Color::Rgb(rgb) = primary {
1441            assert!(
1442                rgb.r > 100 && rgb.b > 200,
1443                "Dracula primary should be purple"
1444            );
1445        }
1446    }
1447
1448    #[test]
1449    fn preset_monokai_has_characteristic_colors() {
1450        let monokai = themes::monokai();
1451        // Monokai primary is cyan
1452        let primary = monokai.primary.resolve(true);
1453        if let Color::Rgb(rgb) = primary {
1454            assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1455        }
1456    }
1457
1458    #[test]
1459    fn preset_solarized_dark_and_light_share_accent_colors() {
1460        let sol_dark = themes::solarized_dark();
1461        let sol_light = themes::solarized_light();
1462        // Solarized uses same accent colors in both modes
1463        assert_eq!(
1464            sol_dark.primary.resolve(true),
1465            sol_light.primary.resolve(true),
1466            "Solarized dark and light should share primary accent"
1467        );
1468    }
1469
1470    #[test]
1471    fn builder_accepts_adaptive_color_directly() {
1472        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1473        let theme = Theme::builder().text(adaptive).build();
1474        assert!(theme.text.is_adaptive());
1475    }
1476
1477    #[test]
1478    fn all_presets_have_distinct_error_colors_from_info() {
1479        for (name, theme) in [
1480            ("dark", themes::dark()),
1481            ("light", themes::light()),
1482            ("nord", themes::nord()),
1483            ("dracula", themes::dracula()),
1484            ("solarized_dark", themes::solarized_dark()),
1485            ("monokai", themes::monokai()),
1486        ] {
1487            let error = theme.error.resolve(true);
1488            let info = theme.info.resolve(true);
1489            assert_ne!(
1490                error, info,
1491                "{name} should have distinct error and info colors"
1492            );
1493        }
1494    }
1495
1496    #[test]
1497    fn adaptive_color_debug_impl() {
1498        let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1499        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1500        // Just verify Debug doesn't panic
1501        let _ = format!("{:?}", fixed);
1502        let _ = format!("{:?}", adaptive);
1503    }
1504
1505    #[test]
1506    fn theme_debug_impl() {
1507        let theme = themes::dark();
1508        // Just verify Debug doesn't panic and contains something useful
1509        let debug = format!("{:?}", theme);
1510        assert!(debug.contains("Theme"));
1511    }
1512
1513    #[test]
1514    fn resolved_theme_debug_impl() {
1515        let resolved = themes::dark().resolve(true);
1516        let debug = format!("{:?}", resolved);
1517        assert!(debug.contains("ResolvedTheme"));
1518    }
1519
1520    // ====== SharedResolvedTheme tests (bd-3l9qr.2) ======
1521
1522    #[test]
1523    fn shared_theme_load_returns_initial() {
1524        let dark = themes::dark().resolve(true);
1525        let shared = SharedResolvedTheme::new(dark);
1526        assert_eq!(shared.load(), dark);
1527    }
1528
1529    #[test]
1530    fn shared_theme_store_replaces_value() {
1531        let original = themes::dark().resolve(true);
1532        // Build a clearly different theme by mutating a field.
1533        let mut updated = original;
1534        updated.primary = Color::rgb(0, 0, 0);
1535        assert_ne!(original.primary, updated.primary);
1536
1537        let shared = SharedResolvedTheme::new(original);
1538        shared.store(updated);
1539        assert_eq!(shared.load(), updated);
1540        assert_ne!(shared.load(), original);
1541    }
1542
1543    #[test]
1544    fn shared_theme_concurrent_read_write() {
1545        use std::sync::{Arc, Barrier};
1546        use std::thread;
1547
1548        let dark = themes::dark().resolve(true);
1549        let light = themes::dark().resolve(false);
1550        let shared = Arc::new(SharedResolvedTheme::new(dark));
1551        let barrier = Arc::new(Barrier::new(5));
1552
1553        let readers: Vec<_> = (0..4)
1554            .map(|_| {
1555                let s = Arc::clone(&shared);
1556                let b = Arc::clone(&barrier);
1557                let dark_copy = dark;
1558                let light_copy = light;
1559                thread::spawn(move || {
1560                    b.wait();
1561                    for _ in 0..10_000 {
1562                        let theme = s.load();
1563                        // Must be one of the two valid themes (no torn reads).
1564                        assert!(
1565                            theme == dark_copy || theme == light_copy,
1566                            "torn read detected"
1567                        );
1568                    }
1569                })
1570            })
1571            .collect();
1572
1573        let writer = {
1574            let s = Arc::clone(&shared);
1575            let b = Arc::clone(&barrier);
1576            thread::spawn(move || {
1577                b.wait();
1578                for i in 0..1_000 {
1579                    if i % 2 == 0 {
1580                        s.store(light);
1581                    } else {
1582                        s.store(dark);
1583                    }
1584                }
1585            })
1586        };
1587
1588        writer.join().unwrap();
1589        for h in readers {
1590            h.join().unwrap();
1591        }
1592    }
1593}