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::Color;
25use std::env;
26
27/// An adaptive color that can change based on light/dark mode.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AdaptiveColor {
30    /// A fixed color that doesn't change with mode.
31    Fixed(Color),
32    /// A color that adapts to light/dark mode.
33    Adaptive {
34        /// Color to use in light mode.
35        light: Color,
36        /// Color to use in dark mode.
37        dark: Color,
38    },
39}
40
41impl AdaptiveColor {
42    /// Create a fixed color.
43    #[inline]
44    pub const fn fixed(color: Color) -> Self {
45        Self::Fixed(color)
46    }
47
48    /// Create an adaptive color with light/dark variants.
49    #[inline]
50    pub const fn adaptive(light: Color, dark: Color) -> Self {
51        Self::Adaptive { light, dark }
52    }
53
54    /// Resolve the color based on the current mode.
55    ///
56    /// # Arguments
57    /// * `is_dark` - true for dark mode, false for light mode
58    #[inline]
59    pub const fn resolve(&self, is_dark: bool) -> Color {
60        match self {
61            Self::Fixed(c) => *c,
62            Self::Adaptive { light, dark } => {
63                if is_dark {
64                    *dark
65                } else {
66                    *light
67                }
68            }
69        }
70    }
71
72    /// Check if this color adapts to mode.
73    #[inline]
74    pub const fn is_adaptive(&self) -> bool {
75        matches!(self, Self::Adaptive { .. })
76    }
77}
78
79impl Default for AdaptiveColor {
80    fn default() -> Self {
81        Self::Fixed(Color::rgb(128, 128, 128))
82    }
83}
84
85impl From<Color> for AdaptiveColor {
86    fn from(color: Color) -> Self {
87        Self::Fixed(color)
88    }
89}
90
91/// A theme with semantic color slots.
92///
93/// Themes provide consistent styling across an application by mapping
94/// semantic names (like "error" or "primary") to actual colors.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Theme {
97    // Primary UI colors
98    /// Primary accent color (e.g., buttons, highlights).
99    pub primary: AdaptiveColor,
100    /// Secondary accent color.
101    pub secondary: AdaptiveColor,
102    /// Tertiary accent color.
103    pub accent: AdaptiveColor,
104
105    // Backgrounds
106    /// Main background color.
107    pub background: AdaptiveColor,
108    /// Surface color (cards, panels).
109    pub surface: AdaptiveColor,
110    /// Overlay color (dialogs, dropdowns).
111    pub overlay: AdaptiveColor,
112
113    // Text
114    /// Primary text color.
115    pub text: AdaptiveColor,
116    /// Muted text color.
117    pub text_muted: AdaptiveColor,
118    /// Subtle text color (hints, placeholders).
119    pub text_subtle: AdaptiveColor,
120
121    // Semantic colors
122    /// Success color (green).
123    pub success: AdaptiveColor,
124    /// Warning color (yellow/orange).
125    pub warning: AdaptiveColor,
126    /// Error color (red).
127    pub error: AdaptiveColor,
128    /// Info color (blue).
129    pub info: AdaptiveColor,
130
131    // Borders
132    /// Default border color.
133    pub border: AdaptiveColor,
134    /// Focused element border.
135    pub border_focused: AdaptiveColor,
136
137    // Selection
138    /// Selection background.
139    pub selection_bg: AdaptiveColor,
140    /// Selection foreground.
141    pub selection_fg: AdaptiveColor,
142
143    // Scrollbar
144    /// Scrollbar track color.
145    pub scrollbar_track: AdaptiveColor,
146    /// Scrollbar thumb color.
147    pub scrollbar_thumb: AdaptiveColor,
148}
149
150impl Default for Theme {
151    fn default() -> Self {
152        themes::dark()
153    }
154}
155
156impl Theme {
157    /// Create a new theme builder.
158    pub fn builder() -> ThemeBuilder {
159        ThemeBuilder::new()
160    }
161
162    /// Detect whether dark mode should be used.
163    ///
164    /// Detection heuristics:
165    /// 1. Check COLORFGBG environment variable
166    /// 2. Default to dark mode (most terminals are dark)
167    ///
168    /// Note: OSC 11 background query would be more accurate but requires
169    /// terminal interaction which isn't always safe or fast.
170    #[must_use]
171    pub fn detect_dark_mode() -> bool {
172        Self::detect_dark_mode_from_colorfgbg(env::var("COLORFGBG").ok().as_deref())
173    }
174
175    fn detect_dark_mode_from_colorfgbg(colorfgbg: Option<&str>) -> bool {
176        // COLORFGBG format: "fg;bg" where values are ANSI color indices
177        // Common light terminals use bg=15 (white), dark use bg=0 (black)
178        if let Some(colorfgbg) = colorfgbg
179            && let Some(bg_part) = colorfgbg.split(';').next_back()
180            && let Ok(bg) = bg_part.trim().parse::<u8>()
181        {
182            // High ANSI indices (7, 15) typically mean light background
183            return bg != 7 && bg != 15;
184        }
185
186        // Default to dark mode (most common for terminals)
187        true
188    }
189
190    /// Create a resolved copy of this theme for a specific mode.
191    ///
192    /// This flattens all adaptive colors to fixed colors based on the mode.
193    #[must_use]
194    pub fn resolve(&self, is_dark: bool) -> ResolvedTheme {
195        ResolvedTheme {
196            primary: self.primary.resolve(is_dark),
197            secondary: self.secondary.resolve(is_dark),
198            accent: self.accent.resolve(is_dark),
199            background: self.background.resolve(is_dark),
200            surface: self.surface.resolve(is_dark),
201            overlay: self.overlay.resolve(is_dark),
202            text: self.text.resolve(is_dark),
203            text_muted: self.text_muted.resolve(is_dark),
204            text_subtle: self.text_subtle.resolve(is_dark),
205            success: self.success.resolve(is_dark),
206            warning: self.warning.resolve(is_dark),
207            error: self.error.resolve(is_dark),
208            info: self.info.resolve(is_dark),
209            border: self.border.resolve(is_dark),
210            border_focused: self.border_focused.resolve(is_dark),
211            selection_bg: self.selection_bg.resolve(is_dark),
212            selection_fg: self.selection_fg.resolve(is_dark),
213            scrollbar_track: self.scrollbar_track.resolve(is_dark),
214            scrollbar_thumb: self.scrollbar_thumb.resolve(is_dark),
215        }
216    }
217}
218
219/// A theme with all colors resolved to fixed values.
220///
221/// This is the result of calling `Theme::resolve()` with a specific mode.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct ResolvedTheme {
224    /// Primary accent color.
225    pub primary: Color,
226    /// Secondary accent color.
227    pub secondary: Color,
228    /// Tertiary accent color.
229    pub accent: Color,
230    /// Main background color.
231    pub background: Color,
232    /// Surface color (cards, panels).
233    pub surface: Color,
234    /// Overlay color (dialogs, dropdowns).
235    pub overlay: Color,
236    /// Primary text color.
237    pub text: Color,
238    /// Muted text color.
239    pub text_muted: Color,
240    /// Subtle text color (hints, placeholders).
241    pub text_subtle: Color,
242    /// Success color.
243    pub success: Color,
244    /// Warning color.
245    pub warning: Color,
246    /// Error color.
247    pub error: Color,
248    /// Info color.
249    pub info: Color,
250    /// Default border color.
251    pub border: Color,
252    /// Focused element border.
253    pub border_focused: Color,
254    /// Selection background.
255    pub selection_bg: Color,
256    /// Selection foreground.
257    pub selection_fg: Color,
258    /// Scrollbar track color.
259    pub scrollbar_track: Color,
260    /// Scrollbar thumb color.
261    pub scrollbar_thumb: Color,
262}
263
264/// Builder for creating custom themes.
265#[derive(Debug, Clone)]
266#[must_use]
267pub struct ThemeBuilder {
268    theme: Theme,
269}
270
271impl ThemeBuilder {
272    /// Create a new builder starting from the default dark theme.
273    pub fn new() -> Self {
274        Self {
275            theme: themes::dark(),
276        }
277    }
278
279    /// Start from a base theme.
280    pub fn from_theme(theme: Theme) -> Self {
281        Self { theme }
282    }
283
284    /// Set the primary color.
285    pub fn primary(mut self, color: impl Into<AdaptiveColor>) -> Self {
286        self.theme.primary = color.into();
287        self
288    }
289
290    /// Set the secondary color.
291    pub fn secondary(mut self, color: impl Into<AdaptiveColor>) -> Self {
292        self.theme.secondary = color.into();
293        self
294    }
295
296    /// Set the accent color.
297    pub fn accent(mut self, color: impl Into<AdaptiveColor>) -> Self {
298        self.theme.accent = color.into();
299        self
300    }
301
302    /// Set the background color.
303    pub fn background(mut self, color: impl Into<AdaptiveColor>) -> Self {
304        self.theme.background = color.into();
305        self
306    }
307
308    /// Set the surface color.
309    pub fn surface(mut self, color: impl Into<AdaptiveColor>) -> Self {
310        self.theme.surface = color.into();
311        self
312    }
313
314    /// Set the overlay color.
315    pub fn overlay(mut self, color: impl Into<AdaptiveColor>) -> Self {
316        self.theme.overlay = color.into();
317        self
318    }
319
320    /// Set the text color.
321    pub fn text(mut self, color: impl Into<AdaptiveColor>) -> Self {
322        self.theme.text = color.into();
323        self
324    }
325
326    /// Set the muted text color.
327    pub fn text_muted(mut self, color: impl Into<AdaptiveColor>) -> Self {
328        self.theme.text_muted = color.into();
329        self
330    }
331
332    /// Set the subtle text color.
333    pub fn text_subtle(mut self, color: impl Into<AdaptiveColor>) -> Self {
334        self.theme.text_subtle = color.into();
335        self
336    }
337
338    /// Set the success color.
339    pub fn success(mut self, color: impl Into<AdaptiveColor>) -> Self {
340        self.theme.success = color.into();
341        self
342    }
343
344    /// Set the warning color.
345    pub fn warning(mut self, color: impl Into<AdaptiveColor>) -> Self {
346        self.theme.warning = color.into();
347        self
348    }
349
350    /// Set the error color.
351    pub fn error(mut self, color: impl Into<AdaptiveColor>) -> Self {
352        self.theme.error = color.into();
353        self
354    }
355
356    /// Set the info color.
357    pub fn info(mut self, color: impl Into<AdaptiveColor>) -> Self {
358        self.theme.info = color.into();
359        self
360    }
361
362    /// Set the border color.
363    pub fn border(mut self, color: impl Into<AdaptiveColor>) -> Self {
364        self.theme.border = color.into();
365        self
366    }
367
368    /// Set the focused border color.
369    pub fn border_focused(mut self, color: impl Into<AdaptiveColor>) -> Self {
370        self.theme.border_focused = color.into();
371        self
372    }
373
374    /// Set the selection background color.
375    pub fn selection_bg(mut self, color: impl Into<AdaptiveColor>) -> Self {
376        self.theme.selection_bg = color.into();
377        self
378    }
379
380    /// Set the selection foreground color.
381    pub fn selection_fg(mut self, color: impl Into<AdaptiveColor>) -> Self {
382        self.theme.selection_fg = color.into();
383        self
384    }
385
386    /// Set the scrollbar track color.
387    pub fn scrollbar_track(mut self, color: impl Into<AdaptiveColor>) -> Self {
388        self.theme.scrollbar_track = color.into();
389        self
390    }
391
392    /// Set the scrollbar thumb color.
393    pub fn scrollbar_thumb(mut self, color: impl Into<AdaptiveColor>) -> Self {
394        self.theme.scrollbar_thumb = color.into();
395        self
396    }
397
398    /// Build the theme.
399    pub fn build(self) -> Theme {
400        self.theme
401    }
402}
403
404impl Default for ThemeBuilder {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410/// Built-in theme presets.
411pub mod themes {
412    use super::*;
413
414    /// Default sensible theme (dark mode).
415    #[must_use]
416    pub fn default() -> Theme {
417        dark()
418    }
419
420    /// Dark theme.
421    #[must_use]
422    pub fn dark() -> Theme {
423        Theme {
424            primary: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Blue
425            secondary: AdaptiveColor::fixed(Color::rgb(163, 113, 247)), // Purple
426            accent: AdaptiveColor::fixed(Color::rgb(255, 123, 114)), // Coral
427
428            background: AdaptiveColor::fixed(Color::rgb(22, 27, 34)), // Dark gray
429            surface: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),    // Slightly lighter
430            overlay: AdaptiveColor::fixed(Color::rgb(48, 54, 61)),    // Even lighter
431
432            text: AdaptiveColor::fixed(Color::rgb(230, 237, 243)), // Bright
433            text_muted: AdaptiveColor::fixed(Color::rgb(139, 148, 158)), // Gray
434            text_subtle: AdaptiveColor::fixed(Color::rgb(110, 118, 129)), // Darker gray
435
436            success: AdaptiveColor::fixed(Color::rgb(63, 185, 80)), // Green
437            warning: AdaptiveColor::fixed(Color::rgb(210, 153, 34)), // Yellow
438            error: AdaptiveColor::fixed(Color::rgb(248, 81, 73)),   // Red
439            info: AdaptiveColor::fixed(Color::rgb(88, 166, 255)),   // Blue
440
441            border: AdaptiveColor::fixed(Color::rgb(48, 54, 61)), // Subtle
442            border_focused: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Accent
443
444            selection_bg: AdaptiveColor::fixed(Color::rgb(56, 139, 253)), // Blue
445            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
446
447            scrollbar_track: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),
448            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(72, 79, 88)),
449        }
450    }
451
452    /// Light theme.
453    #[must_use]
454    pub fn light() -> Theme {
455        Theme {
456            primary: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Blue
457            secondary: AdaptiveColor::fixed(Color::rgb(130, 80, 223)), // Purple
458            accent: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),  // Red
459
460            background: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
461            surface: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),    // Light gray
462            overlay: AdaptiveColor::fixed(Color::rgb(255, 255, 255)),    // White
463
464            text: AdaptiveColor::fixed(Color::rgb(31, 35, 40)), // Dark
465            text_muted: AdaptiveColor::fixed(Color::rgb(87, 96, 106)), // Gray
466            text_subtle: AdaptiveColor::fixed(Color::rgb(140, 149, 159)), // Light gray
467
468            success: AdaptiveColor::fixed(Color::rgb(26, 127, 55)), // Green
469            warning: AdaptiveColor::fixed(Color::rgb(158, 106, 3)), // Yellow
470            error: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),   // Red
471            info: AdaptiveColor::fixed(Color::rgb(9, 105, 218)),    // Blue
472
473            border: AdaptiveColor::fixed(Color::rgb(208, 215, 222)), // Light gray
474            border_focused: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Accent
475
476            selection_bg: AdaptiveColor::fixed(Color::rgb(221, 244, 255)), // Light blue
477            selection_fg: AdaptiveColor::fixed(Color::rgb(31, 35, 40)),    // Dark
478
479            scrollbar_track: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),
480            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(175, 184, 193)),
481        }
482    }
483
484    /// Nord color scheme (dark variant).
485    #[must_use]
486    pub fn nord() -> Theme {
487        Theme {
488            primary: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8 (frost)
489            secondary: AdaptiveColor::fixed(Color::rgb(180, 142, 173)), // Nord15 (purple)
490            accent: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),   // Nord11 (aurora red)
491
492            background: AdaptiveColor::fixed(Color::rgb(46, 52, 64)), // Nord0
493            surface: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),    // Nord1
494            overlay: AdaptiveColor::fixed(Color::rgb(67, 76, 94)),    // Nord2
495
496            text: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
497            text_muted: AdaptiveColor::fixed(Color::rgb(216, 222, 233)), // Nord4
498            text_subtle: AdaptiveColor::fixed(Color::rgb(129, 161, 193)), // Nord9
499
500            success: AdaptiveColor::fixed(Color::rgb(163, 190, 140)), // Nord14 (green)
501            warning: AdaptiveColor::fixed(Color::rgb(235, 203, 139)), // Nord13 (yellow)
502            error: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),    // Nord11 (red)
503            info: AdaptiveColor::fixed(Color::rgb(129, 161, 193)),    // Nord9 (blue)
504
505            border: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
506            border_focused: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8
507
508            selection_bg: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
509            selection_fg: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
510
511            scrollbar_track: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),
512            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(76, 86, 106)),
513        }
514    }
515
516    /// Dracula color scheme.
517    #[must_use]
518    pub fn dracula() -> Theme {
519        Theme {
520            primary: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
521            secondary: AdaptiveColor::fixed(Color::rgb(255, 121, 198)), // Pink
522            accent: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),  // Cyan
523
524            background: AdaptiveColor::fixed(Color::rgb(40, 42, 54)), // Background
525            surface: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
526            overlay: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
527
528            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
529            text_muted: AdaptiveColor::fixed(Color::rgb(188, 188, 188)), // Lighter
530            text_subtle: AdaptiveColor::fixed(Color::rgb(98, 114, 164)), // Comment
531
532            success: AdaptiveColor::fixed(Color::rgb(80, 250, 123)), // Green
533            warning: AdaptiveColor::fixed(Color::rgb(255, 184, 108)), // Orange
534            error: AdaptiveColor::fixed(Color::rgb(255, 85, 85)),    // Red
535            info: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),   // Cyan
536
537            border: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
538            border_focused: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
539
540            selection_bg: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
541            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
542
543            scrollbar_track: AdaptiveColor::fixed(Color::rgb(40, 42, 54)),
544            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),
545        }
546    }
547
548    /// Solarized Dark color scheme.
549    #[must_use]
550    pub fn solarized_dark() -> Theme {
551        Theme {
552            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
553            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
554            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
555
556            background: AdaptiveColor::fixed(Color::rgb(0, 43, 54)), // Base03
557            surface: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
558            overlay: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
559
560            text: AdaptiveColor::fixed(Color::rgb(131, 148, 150)), // Base0
561            text_muted: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
562            text_subtle: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
563
564            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
565            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
566            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
567            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
568
569            border: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
570            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
571
572            selection_bg: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
573            selection_fg: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
574
575            scrollbar_track: AdaptiveColor::fixed(Color::rgb(0, 43, 54)),
576            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),
577        }
578    }
579
580    /// Solarized Light color scheme.
581    #[must_use]
582    pub fn solarized_light() -> Theme {
583        Theme {
584            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
585            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
586            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
587
588            background: AdaptiveColor::fixed(Color::rgb(253, 246, 227)), // Base3
589            surface: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),    // Base2
590            overlay: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),    // Base3
591
592            text: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
593            text_muted: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
594            text_subtle: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
595
596            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
597            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
598            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
599            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
600
601            border: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
602            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
603
604            selection_bg: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
605            selection_fg: AdaptiveColor::fixed(Color::rgb(88, 110, 117)),  // Base01
606
607            scrollbar_track: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),
608            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),
609        }
610    }
611
612    /// Monokai color scheme.
613    #[must_use]
614    pub fn monokai() -> Theme {
615        Theme {
616            primary: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
617            secondary: AdaptiveColor::fixed(Color::rgb(174, 129, 255)), // Purple
618            accent: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink
619
620            background: AdaptiveColor::fixed(Color::rgb(39, 40, 34)), // Background
621            surface: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
622            overlay: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
623
624            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
625            text_muted: AdaptiveColor::fixed(Color::rgb(189, 189, 189)), // Gray
626            text_subtle: AdaptiveColor::fixed(Color::rgb(117, 113, 94)), // Comment
627
628            success: AdaptiveColor::fixed(Color::rgb(166, 226, 46)), // Green
629            warning: AdaptiveColor::fixed(Color::rgb(230, 219, 116)), // Yellow
630            error: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink/red
631            info: AdaptiveColor::fixed(Color::rgb(102, 217, 239)),   // Cyan
632
633            border: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), // Lighter bg
634            border_focused: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
635
636            selection_bg: AdaptiveColor::fixed(Color::rgb(73, 72, 62)), // Selection
637            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
638
639            scrollbar_track: AdaptiveColor::fixed(Color::rgb(39, 40, 34)),
640            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),
641        }
642    }
643
644    /// Doom color scheme (High contrast, Industrial).
645    #[must_use]
646    pub fn doom() -> Theme {
647        Theme {
648            primary: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), // Firebrick
649            secondary: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), // LimeGreen
650            accent: AdaptiveColor::fixed(Color::rgb(255, 255, 0)),  // Yellow (Ammo)
651
652            background: AdaptiveColor::fixed(Color::rgb(26, 26, 26)), // Dark Grey
653            surface: AdaptiveColor::fixed(Color::rgb(47, 47, 47)),    // Gunmetal
654            overlay: AdaptiveColor::fixed(Color::rgb(64, 64, 64)),    // Dim Grey
655
656            text: AdaptiveColor::fixed(Color::rgb(211, 211, 211)), // Light Grey
657            text_muted: AdaptiveColor::fixed(Color::rgb(128, 128, 128)), // Grey
658            text_subtle: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), // Dim Grey
659
660            success: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), // Green
661            warning: AdaptiveColor::fixed(Color::rgb(255, 215, 0)), // Gold
662            error: AdaptiveColor::fixed(Color::rgb(139, 0, 0)),     // Dark Red
663            info: AdaptiveColor::fixed(Color::rgb(65, 105, 225)),   // Royal Blue
664
665            border: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), // Dim Grey
666            border_focused: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), // Firebrick
667
668            selection_bg: AdaptiveColor::fixed(Color::rgb(139, 0, 0)), // Dark Red
669            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
670
671            scrollbar_track: AdaptiveColor::fixed(Color::rgb(26, 26, 26)),
672            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(178, 34, 34)),
673        }
674    }
675
676    /// Quake color scheme (Earth tones, Medieval).
677    #[must_use]
678    pub fn quake() -> Theme {
679        Theme {
680            primary: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), // SaddleBrown
681            secondary: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), // DarkOliveGreen
682            accent: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), // Peru
683
684            background: AdaptiveColor::fixed(Color::rgb(28, 28, 28)), // Very Dark Grey
685            surface: AdaptiveColor::fixed(Color::rgb(46, 39, 34)),    // Deep Brown/Grey
686            overlay: AdaptiveColor::fixed(Color::rgb(62, 54, 48)),    // Lighter Brown/Grey
687
688            text: AdaptiveColor::fixed(Color::rgb(210, 180, 140)), // Tan
689            text_muted: AdaptiveColor::fixed(Color::rgb(139, 115, 85)), // Dark Tan
690            text_subtle: AdaptiveColor::fixed(Color::rgb(101, 84, 61)), // Deep Tan
691
692            success: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), // Olive
693            warning: AdaptiveColor::fixed(Color::rgb(210, 105, 30)), // Chocolate
694            error: AdaptiveColor::fixed(Color::rgb(128, 0, 0)),     // Maroon
695            info: AdaptiveColor::fixed(Color::rgb(70, 130, 180)),   // SteelBlue
696
697            border: AdaptiveColor::fixed(Color::rgb(93, 64, 55)), // Rusty Brown
698            border_focused: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), // Peru
699
700            selection_bg: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), // SaddleBrown
701            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 222, 173)), // NavajoWhite
702
703            scrollbar_track: AdaptiveColor::fixed(Color::rgb(28, 28, 28)),
704            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(139, 69, 19)),
705        }
706    }
707}
708
709// ============================================================================
710// SharedResolvedTheme — ArcSwap-backed concurrent access (bd-3l9qr.2)
711// ============================================================================
712
713/// Wait-free shared resolved theme for concurrent read/write.
714///
715/// Wraps a [`ResolvedTheme`] in an [`arc_swap::ArcSwap`] so that the render
716/// thread can read theme colors without locking while the main thread updates
717/// them on theme switch or mode change.
718///
719/// # Example
720///
721/// ```
722/// use ftui_style::theme::{Theme, ResolvedTheme, SharedResolvedTheme};
723///
724/// let theme = Theme::default();
725/// let resolved = theme.resolve(true); // dark mode
726/// let shared = SharedResolvedTheme::new(resolved);
727///
728/// // Wait-free read from render thread
729/// let current = shared.load();
730/// assert_eq!(current.primary, resolved.primary);
731///
732/// // Update on theme switch
733/// let light = theme.resolve(false);
734/// shared.store(light);
735/// ```
736pub struct SharedResolvedTheme {
737    inner: arc_swap::ArcSwap<ResolvedTheme>,
738}
739
740impl SharedResolvedTheme {
741    /// Create shared theme from an initial resolved theme.
742    pub fn new(theme: ResolvedTheme) -> Self {
743        Self {
744            inner: arc_swap::ArcSwap::from_pointee(theme),
745        }
746    }
747
748    /// Wait-free read of current resolved theme.
749    #[inline]
750    pub fn load(&self) -> ResolvedTheme {
751        let guard = self.inner.load();
752        **guard
753    }
754
755    /// Atomically replace the resolved theme (e.g., on theme switch or mode change).
756    #[inline]
757    pub fn store(&self, theme: ResolvedTheme) {
758        self.inner.store(std::sync::Arc::new(theme));
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[test]
767    fn adaptive_color_fixed() {
768        let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
769        assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
770        assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
771        assert!(!color.is_adaptive());
772    }
773
774    #[test]
775    fn adaptive_color_adaptive() {
776        let color = AdaptiveColor::adaptive(
777            Color::rgb(255, 255, 255), // light
778            Color::rgb(0, 0, 0),       // dark
779        );
780        assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); // dark
781        assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); // light
782        assert!(color.is_adaptive());
783    }
784
785    #[test]
786    fn theme_default_is_dark() {
787        let theme = Theme::default();
788        // Dark themes typically have dark backgrounds
789        let bg = theme.background.resolve(true);
790        if let Color::Rgb(rgb) = bg {
791            // Background should be dark
792            assert!(rgb.luminance_u8() < 50);
793        }
794    }
795
796    #[test]
797    fn theme_light_has_light_background() {
798        let theme = themes::light();
799        let bg = theme.background.resolve(false);
800        if let Color::Rgb(rgb) = bg {
801            // Light background
802            assert!(rgb.luminance_u8() > 200);
803        }
804    }
805
806    #[test]
807    fn theme_has_all_slots() {
808        let theme = Theme::default();
809        // Just verify all slots exist and resolve without panic
810        let _ = theme.primary.resolve(true);
811        let _ = theme.secondary.resolve(true);
812        let _ = theme.accent.resolve(true);
813        let _ = theme.background.resolve(true);
814        let _ = theme.surface.resolve(true);
815        let _ = theme.overlay.resolve(true);
816        let _ = theme.text.resolve(true);
817        let _ = theme.text_muted.resolve(true);
818        let _ = theme.text_subtle.resolve(true);
819        let _ = theme.success.resolve(true);
820        let _ = theme.warning.resolve(true);
821        let _ = theme.error.resolve(true);
822        let _ = theme.info.resolve(true);
823        let _ = theme.border.resolve(true);
824        let _ = theme.border_focused.resolve(true);
825        let _ = theme.selection_bg.resolve(true);
826        let _ = theme.selection_fg.resolve(true);
827        let _ = theme.scrollbar_track.resolve(true);
828        let _ = theme.scrollbar_thumb.resolve(true);
829    }
830
831    #[test]
832    fn theme_builder_works() {
833        let theme = Theme::builder()
834            .primary(Color::rgb(255, 0, 0))
835            .background(Color::rgb(0, 0, 0))
836            .build();
837
838        assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
839        assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
840    }
841
842    #[test]
843    fn theme_resolve_flattens() {
844        let theme = themes::dark();
845        let resolved = theme.resolve(true);
846
847        // All colors should be the same as resolving individually
848        assert_eq!(resolved.primary, theme.primary.resolve(true));
849        assert_eq!(resolved.text, theme.text.resolve(true));
850        assert_eq!(resolved.background, theme.background.resolve(true));
851    }
852
853    #[test]
854    fn all_presets_exist() {
855        let _ = themes::default();
856        let _ = themes::dark();
857        let _ = themes::light();
858        let _ = themes::nord();
859        let _ = themes::dracula();
860        let _ = themes::solarized_dark();
861        let _ = themes::solarized_light();
862        let _ = themes::monokai();
863    }
864
865    #[test]
866    fn presets_have_different_colors() {
867        let dark = themes::dark();
868        let light = themes::light();
869        let nord = themes::nord();
870
871        // Different themes should have different backgrounds
872        assert_ne!(
873            dark.background.resolve(true),
874            light.background.resolve(false)
875        );
876        assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
877    }
878
879    #[test]
880    fn detect_dark_mode_returns_bool() {
881        // Just verify it doesn't panic
882        let _ = Theme::detect_dark_mode();
883    }
884
885    #[test]
886    fn color_converts_to_adaptive() {
887        let color = Color::rgb(100, 150, 200);
888        let adaptive: AdaptiveColor = color.into();
889        assert_eq!(adaptive.resolve(true), color);
890        assert_eq!(adaptive.resolve(false), color);
891    }
892
893    #[test]
894    fn builder_from_theme() {
895        let base = themes::nord();
896        let modified = ThemeBuilder::from_theme(base.clone())
897            .primary(Color::rgb(255, 0, 0))
898            .build();
899
900        // Modified primary
901        assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
902        // Unchanged secondary (from nord)
903        assert_eq!(modified.secondary, base.secondary);
904    }
905
906    // Count semantic slots to verify we have 15+
907    #[test]
908    fn has_at_least_15_semantic_slots() {
909        let theme = Theme::default();
910        let slot_count = 19; // Counting from the struct definition
911        assert!(slot_count >= 15);
912
913        // Verify by accessing each slot
914        let _slots = [
915            &theme.primary,
916            &theme.secondary,
917            &theme.accent,
918            &theme.background,
919            &theme.surface,
920            &theme.overlay,
921            &theme.text,
922            &theme.text_muted,
923            &theme.text_subtle,
924            &theme.success,
925            &theme.warning,
926            &theme.error,
927            &theme.info,
928            &theme.border,
929            &theme.border_focused,
930            &theme.selection_bg,
931            &theme.selection_fg,
932            &theme.scrollbar_track,
933            &theme.scrollbar_thumb,
934        ];
935    }
936
937    #[test]
938    fn adaptive_color_default_is_gray() {
939        let color = AdaptiveColor::default();
940        assert!(!color.is_adaptive());
941        assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
942        assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
943    }
944
945    #[test]
946    fn theme_builder_default() {
947        let builder = ThemeBuilder::default();
948        let theme = builder.build();
949        // Default builder starts from dark theme
950        assert_eq!(theme, themes::dark());
951    }
952
953    #[test]
954    fn resolved_theme_has_all_19_slots() {
955        let theme = themes::dark();
956        let resolved = theme.resolve(true);
957        // Just verify all slots are accessible without panic
958        let _colors = [
959            resolved.primary,
960            resolved.secondary,
961            resolved.accent,
962            resolved.background,
963            resolved.surface,
964            resolved.overlay,
965            resolved.text,
966            resolved.text_muted,
967            resolved.text_subtle,
968            resolved.success,
969            resolved.warning,
970            resolved.error,
971            resolved.info,
972            resolved.border,
973            resolved.border_focused,
974            resolved.selection_bg,
975            resolved.selection_fg,
976            resolved.scrollbar_track,
977            resolved.scrollbar_thumb,
978        ];
979    }
980
981    #[test]
982    fn dark_and_light_resolve_differently() {
983        let theme = Theme {
984            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
985            ..themes::dark()
986        };
987        let dark_resolved = theme.resolve(true);
988        let light_resolved = theme.resolve(false);
989        assert_ne!(dark_resolved.text, light_resolved.text);
990        assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
991        assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
992    }
993
994    #[test]
995    fn all_dark_presets_have_dark_backgrounds() {
996        for (name, theme) in [
997            ("dark", themes::dark()),
998            ("nord", themes::nord()),
999            ("dracula", themes::dracula()),
1000            ("solarized_dark", themes::solarized_dark()),
1001            ("monokai", themes::monokai()),
1002        ] {
1003            let bg = theme.background.resolve(true);
1004            if let Color::Rgb(rgb) = bg {
1005                assert!(
1006                    rgb.luminance_u8() < 100,
1007                    "{name} background too bright: {}",
1008                    rgb.luminance_u8()
1009                );
1010            }
1011        }
1012    }
1013
1014    #[test]
1015    fn all_light_presets_have_light_backgrounds() {
1016        for (name, theme) in [
1017            ("light", themes::light()),
1018            ("solarized_light", themes::solarized_light()),
1019        ] {
1020            let bg = theme.background.resolve(false);
1021            if let Color::Rgb(rgb) = bg {
1022                assert!(
1023                    rgb.luminance_u8() > 150,
1024                    "{name} background too dark: {}",
1025                    rgb.luminance_u8()
1026                );
1027            }
1028        }
1029    }
1030
1031    #[test]
1032    fn theme_default_equals_dark() {
1033        assert_eq!(Theme::default(), themes::dark());
1034        assert_eq!(themes::default(), themes::dark());
1035    }
1036
1037    #[test]
1038    fn builder_all_setters_chain() {
1039        let theme = Theme::builder()
1040            .primary(Color::rgb(1, 0, 0))
1041            .secondary(Color::rgb(2, 0, 0))
1042            .accent(Color::rgb(3, 0, 0))
1043            .background(Color::rgb(4, 0, 0))
1044            .surface(Color::rgb(5, 0, 0))
1045            .overlay(Color::rgb(6, 0, 0))
1046            .text(Color::rgb(7, 0, 0))
1047            .text_muted(Color::rgb(8, 0, 0))
1048            .text_subtle(Color::rgb(9, 0, 0))
1049            .success(Color::rgb(10, 0, 0))
1050            .warning(Color::rgb(11, 0, 0))
1051            .error(Color::rgb(12, 0, 0))
1052            .info(Color::rgb(13, 0, 0))
1053            .border(Color::rgb(14, 0, 0))
1054            .border_focused(Color::rgb(15, 0, 0))
1055            .selection_bg(Color::rgb(16, 0, 0))
1056            .selection_fg(Color::rgb(17, 0, 0))
1057            .scrollbar_track(Color::rgb(18, 0, 0))
1058            .scrollbar_thumb(Color::rgb(19, 0, 0))
1059            .build();
1060        assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
1061        assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
1062    }
1063
1064    #[test]
1065    fn resolved_theme_is_copy() {
1066        let theme = themes::dark();
1067        let resolved = theme.resolve(true);
1068        let copy = resolved;
1069        assert_eq!(resolved, copy);
1070    }
1071
1072    #[test]
1073    fn detect_dark_mode_with_colorfgbg_dark() {
1074        // COLORFGBG "0;0" means fg=0 bg=0 (black bg = dark mode)
1075        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
1076        assert!(result, "bg=0 should be dark mode");
1077    }
1078
1079    #[test]
1080    fn detect_dark_mode_with_colorfgbg_light_15() {
1081        // COLORFGBG "0;15" means bg=15 (white = light mode)
1082        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
1083        assert!(!result, "bg=15 should be light mode");
1084    }
1085
1086    #[test]
1087    fn detect_dark_mode_with_colorfgbg_light_7() {
1088        // COLORFGBG "0;7" means bg=7 (silver = light mode)
1089        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
1090        assert!(!result, "bg=7 should be light mode");
1091    }
1092
1093    #[test]
1094    fn detect_dark_mode_without_env_defaults_dark() {
1095        let result = Theme::detect_dark_mode_from_colorfgbg(None);
1096        assert!(result, "missing COLORFGBG should default to dark");
1097    }
1098
1099    #[test]
1100    fn detect_dark_mode_with_empty_string() {
1101        let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
1102        assert!(result, "empty COLORFGBG should default to dark");
1103    }
1104
1105    #[test]
1106    fn detect_dark_mode_with_no_semicolon() {
1107        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
1108        assert!(result, "COLORFGBG without semicolon should default to dark");
1109    }
1110
1111    #[test]
1112    fn detect_dark_mode_with_multiple_semicolons() {
1113        // Some terminals use "fg;bg;..." format
1114        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
1115        assert!(result, "COLORFGBG with extra parts should use last as bg");
1116    }
1117
1118    #[test]
1119    fn detect_dark_mode_with_whitespace() {
1120        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1121        assert!(!result, "COLORFGBG with whitespace should parse correctly");
1122    }
1123
1124    #[test]
1125    fn detect_dark_mode_with_invalid_number() {
1126        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1127        assert!(
1128            result,
1129            "COLORFGBG with invalid number should default to dark"
1130        );
1131    }
1132
1133    #[test]
1134    fn theme_clone_produces_equal_theme() {
1135        let theme = themes::nord();
1136        let cloned = theme.clone();
1137        assert_eq!(theme, cloned);
1138    }
1139
1140    #[test]
1141    fn theme_equality_different_themes() {
1142        let dark = themes::dark();
1143        let light = themes::light();
1144        assert_ne!(dark, light);
1145    }
1146
1147    #[test]
1148    fn resolved_theme_different_modes_differ() {
1149        // Create a theme with adaptive colors
1150        let theme = Theme {
1151            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1152            background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1153            ..themes::dark()
1154        };
1155        let dark_resolved = theme.resolve(true);
1156        let light_resolved = theme.resolve(false);
1157        assert_ne!(dark_resolved, light_resolved);
1158    }
1159
1160    #[test]
1161    fn resolved_theme_equality_same_mode() {
1162        let theme = themes::dark();
1163        let resolved1 = theme.resolve(true);
1164        let resolved2 = theme.resolve(true);
1165        assert_eq!(resolved1, resolved2);
1166    }
1167
1168    #[test]
1169    fn preset_nord_has_characteristic_colors() {
1170        let nord = themes::nord();
1171        // Nord8 frost blue is the primary color
1172        let primary = nord.primary.resolve(true);
1173        if let Color::Rgb(rgb) = primary {
1174            assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1175        }
1176    }
1177
1178    #[test]
1179    fn preset_dracula_has_characteristic_colors() {
1180        let dracula = themes::dracula();
1181        // Dracula primary is purple
1182        let primary = dracula.primary.resolve(true);
1183        if let Color::Rgb(rgb) = primary {
1184            assert!(
1185                rgb.r > 100 && rgb.b > 200,
1186                "Dracula primary should be purple"
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn preset_monokai_has_characteristic_colors() {
1193        let monokai = themes::monokai();
1194        // Monokai primary is cyan
1195        let primary = monokai.primary.resolve(true);
1196        if let Color::Rgb(rgb) = primary {
1197            assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1198        }
1199    }
1200
1201    #[test]
1202    fn preset_solarized_dark_and_light_share_accent_colors() {
1203        let sol_dark = themes::solarized_dark();
1204        let sol_light = themes::solarized_light();
1205        // Solarized uses same accent colors in both modes
1206        assert_eq!(
1207            sol_dark.primary.resolve(true),
1208            sol_light.primary.resolve(true),
1209            "Solarized dark and light should share primary accent"
1210        );
1211    }
1212
1213    #[test]
1214    fn builder_accepts_adaptive_color_directly() {
1215        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1216        let theme = Theme::builder().text(adaptive).build();
1217        assert!(theme.text.is_adaptive());
1218    }
1219
1220    #[test]
1221    fn all_presets_have_distinct_error_colors_from_info() {
1222        for (name, theme) in [
1223            ("dark", themes::dark()),
1224            ("light", themes::light()),
1225            ("nord", themes::nord()),
1226            ("dracula", themes::dracula()),
1227            ("solarized_dark", themes::solarized_dark()),
1228            ("monokai", themes::monokai()),
1229        ] {
1230            let error = theme.error.resolve(true);
1231            let info = theme.info.resolve(true);
1232            assert_ne!(
1233                error, info,
1234                "{name} should have distinct error and info colors"
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    fn adaptive_color_debug_impl() {
1241        let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1242        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1243        // Just verify Debug doesn't panic
1244        let _ = format!("{:?}", fixed);
1245        let _ = format!("{:?}", adaptive);
1246    }
1247
1248    #[test]
1249    fn theme_debug_impl() {
1250        let theme = themes::dark();
1251        // Just verify Debug doesn't panic and contains something useful
1252        let debug = format!("{:?}", theme);
1253        assert!(debug.contains("Theme"));
1254    }
1255
1256    #[test]
1257    fn resolved_theme_debug_impl() {
1258        let resolved = themes::dark().resolve(true);
1259        let debug = format!("{:?}", resolved);
1260        assert!(debug.contains("ResolvedTheme"));
1261    }
1262
1263    // ====== SharedResolvedTheme tests (bd-3l9qr.2) ======
1264
1265    #[test]
1266    fn shared_theme_load_returns_initial() {
1267        let dark = themes::dark().resolve(true);
1268        let shared = SharedResolvedTheme::new(dark);
1269        assert_eq!(shared.load(), dark);
1270    }
1271
1272    #[test]
1273    fn shared_theme_store_replaces_value() {
1274        let original = themes::dark().resolve(true);
1275        // Build a clearly different theme by mutating a field.
1276        let mut updated = original;
1277        updated.primary = Color::rgb(0, 0, 0);
1278        assert_ne!(original.primary, updated.primary);
1279
1280        let shared = SharedResolvedTheme::new(original);
1281        shared.store(updated);
1282        assert_eq!(shared.load(), updated);
1283        assert_ne!(shared.load(), original);
1284    }
1285
1286    #[test]
1287    fn shared_theme_concurrent_read_write() {
1288        use std::sync::{Arc, Barrier};
1289        use std::thread;
1290
1291        let dark = themes::dark().resolve(true);
1292        let light = themes::dark().resolve(false);
1293        let shared = Arc::new(SharedResolvedTheme::new(dark));
1294        let barrier = Arc::new(Barrier::new(5));
1295
1296        let readers: Vec<_> = (0..4)
1297            .map(|_| {
1298                let s = Arc::clone(&shared);
1299                let b = Arc::clone(&barrier);
1300                let dark_copy = dark;
1301                let light_copy = light;
1302                thread::spawn(move || {
1303                    b.wait();
1304                    for _ in 0..10_000 {
1305                        let theme = s.load();
1306                        // Must be one of the two valid themes (no torn reads).
1307                        assert!(
1308                            theme == dark_copy || theme == light_copy,
1309                            "torn read detected"
1310                        );
1311                    }
1312                })
1313            })
1314            .collect();
1315
1316        let writer = {
1317            let s = Arc::clone(&shared);
1318            let b = Arc::clone(&barrier);
1319            thread::spawn(move || {
1320                b.wait();
1321                for i in 0..1_000 {
1322                    if i % 2 == 0 {
1323                        s.store(light);
1324                    } else {
1325                        s.store(dark);
1326                    }
1327                }
1328            })
1329        };
1330
1331        writer.join().unwrap();
1332        for h in readers {
1333            h.join().unwrap();
1334        }
1335    }
1336}