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
645// ============================================================================
646// SharedResolvedTheme — ArcSwap-backed concurrent access (bd-3l9qr.2)
647// ============================================================================
648
649/// Wait-free shared resolved theme for concurrent read/write.
650///
651/// Wraps a [`ResolvedTheme`] in an [`arc_swap::ArcSwap`] so that the render
652/// thread can read theme colors without locking while the main thread updates
653/// them on theme switch or mode change.
654///
655/// # Example
656///
657/// ```
658/// use ftui_style::theme::{Theme, ResolvedTheme, SharedResolvedTheme};
659///
660/// let theme = Theme::default();
661/// let resolved = theme.resolve(true); // dark mode
662/// let shared = SharedResolvedTheme::new(resolved);
663///
664/// // Wait-free read from render thread
665/// let current = shared.load();
666/// assert_eq!(current.primary, resolved.primary);
667///
668/// // Update on theme switch
669/// let light = theme.resolve(false);
670/// shared.store(light);
671/// ```
672pub struct SharedResolvedTheme {
673    inner: arc_swap::ArcSwap<ResolvedTheme>,
674}
675
676impl SharedResolvedTheme {
677    /// Create shared theme from an initial resolved theme.
678    pub fn new(theme: ResolvedTheme) -> Self {
679        Self {
680            inner: arc_swap::ArcSwap::from_pointee(theme),
681        }
682    }
683
684    /// Wait-free read of current resolved theme.
685    #[inline]
686    pub fn load(&self) -> ResolvedTheme {
687        let guard = self.inner.load();
688        **guard
689    }
690
691    /// Atomically replace the resolved theme (e.g., on theme switch or mode change).
692    #[inline]
693    pub fn store(&self, theme: ResolvedTheme) {
694        self.inner.store(std::sync::Arc::new(theme));
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn adaptive_color_fixed() {
704        let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
705        assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
706        assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
707        assert!(!color.is_adaptive());
708    }
709
710    #[test]
711    fn adaptive_color_adaptive() {
712        let color = AdaptiveColor::adaptive(
713            Color::rgb(255, 255, 255), // light
714            Color::rgb(0, 0, 0),       // dark
715        );
716        assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); // dark
717        assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); // light
718        assert!(color.is_adaptive());
719    }
720
721    #[test]
722    fn theme_default_is_dark() {
723        let theme = Theme::default();
724        // Dark themes typically have dark backgrounds
725        let bg = theme.background.resolve(true);
726        if let Color::Rgb(rgb) = bg {
727            // Background should be dark
728            assert!(rgb.luminance_u8() < 50);
729        }
730    }
731
732    #[test]
733    fn theme_light_has_light_background() {
734        let theme = themes::light();
735        let bg = theme.background.resolve(false);
736        if let Color::Rgb(rgb) = bg {
737            // Light background
738            assert!(rgb.luminance_u8() > 200);
739        }
740    }
741
742    #[test]
743    fn theme_has_all_slots() {
744        let theme = Theme::default();
745        // Just verify all slots exist and resolve without panic
746        let _ = theme.primary.resolve(true);
747        let _ = theme.secondary.resolve(true);
748        let _ = theme.accent.resolve(true);
749        let _ = theme.background.resolve(true);
750        let _ = theme.surface.resolve(true);
751        let _ = theme.overlay.resolve(true);
752        let _ = theme.text.resolve(true);
753        let _ = theme.text_muted.resolve(true);
754        let _ = theme.text_subtle.resolve(true);
755        let _ = theme.success.resolve(true);
756        let _ = theme.warning.resolve(true);
757        let _ = theme.error.resolve(true);
758        let _ = theme.info.resolve(true);
759        let _ = theme.border.resolve(true);
760        let _ = theme.border_focused.resolve(true);
761        let _ = theme.selection_bg.resolve(true);
762        let _ = theme.selection_fg.resolve(true);
763        let _ = theme.scrollbar_track.resolve(true);
764        let _ = theme.scrollbar_thumb.resolve(true);
765    }
766
767    #[test]
768    fn theme_builder_works() {
769        let theme = Theme::builder()
770            .primary(Color::rgb(255, 0, 0))
771            .background(Color::rgb(0, 0, 0))
772            .build();
773
774        assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
775        assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
776    }
777
778    #[test]
779    fn theme_resolve_flattens() {
780        let theme = themes::dark();
781        let resolved = theme.resolve(true);
782
783        // All colors should be the same as resolving individually
784        assert_eq!(resolved.primary, theme.primary.resolve(true));
785        assert_eq!(resolved.text, theme.text.resolve(true));
786        assert_eq!(resolved.background, theme.background.resolve(true));
787    }
788
789    #[test]
790    fn all_presets_exist() {
791        let _ = themes::default();
792        let _ = themes::dark();
793        let _ = themes::light();
794        let _ = themes::nord();
795        let _ = themes::dracula();
796        let _ = themes::solarized_dark();
797        let _ = themes::solarized_light();
798        let _ = themes::monokai();
799    }
800
801    #[test]
802    fn presets_have_different_colors() {
803        let dark = themes::dark();
804        let light = themes::light();
805        let nord = themes::nord();
806
807        // Different themes should have different backgrounds
808        assert_ne!(
809            dark.background.resolve(true),
810            light.background.resolve(false)
811        );
812        assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
813    }
814
815    #[test]
816    fn detect_dark_mode_returns_bool() {
817        // Just verify it doesn't panic
818        let _ = Theme::detect_dark_mode();
819    }
820
821    #[test]
822    fn color_converts_to_adaptive() {
823        let color = Color::rgb(100, 150, 200);
824        let adaptive: AdaptiveColor = color.into();
825        assert_eq!(adaptive.resolve(true), color);
826        assert_eq!(adaptive.resolve(false), color);
827    }
828
829    #[test]
830    fn builder_from_theme() {
831        let base = themes::nord();
832        let modified = ThemeBuilder::from_theme(base.clone())
833            .primary(Color::rgb(255, 0, 0))
834            .build();
835
836        // Modified primary
837        assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
838        // Unchanged secondary (from nord)
839        assert_eq!(modified.secondary, base.secondary);
840    }
841
842    // Count semantic slots to verify we have 15+
843    #[test]
844    fn has_at_least_15_semantic_slots() {
845        let theme = Theme::default();
846        let slot_count = 19; // Counting from the struct definition
847        assert!(slot_count >= 15);
848
849        // Verify by accessing each slot
850        let _slots = [
851            &theme.primary,
852            &theme.secondary,
853            &theme.accent,
854            &theme.background,
855            &theme.surface,
856            &theme.overlay,
857            &theme.text,
858            &theme.text_muted,
859            &theme.text_subtle,
860            &theme.success,
861            &theme.warning,
862            &theme.error,
863            &theme.info,
864            &theme.border,
865            &theme.border_focused,
866            &theme.selection_bg,
867            &theme.selection_fg,
868            &theme.scrollbar_track,
869            &theme.scrollbar_thumb,
870        ];
871    }
872
873    #[test]
874    fn adaptive_color_default_is_gray() {
875        let color = AdaptiveColor::default();
876        assert!(!color.is_adaptive());
877        assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
878        assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
879    }
880
881    #[test]
882    fn theme_builder_default() {
883        let builder = ThemeBuilder::default();
884        let theme = builder.build();
885        // Default builder starts from dark theme
886        assert_eq!(theme, themes::dark());
887    }
888
889    #[test]
890    fn resolved_theme_has_all_19_slots() {
891        let theme = themes::dark();
892        let resolved = theme.resolve(true);
893        // Just verify all slots are accessible without panic
894        let _colors = [
895            resolved.primary,
896            resolved.secondary,
897            resolved.accent,
898            resolved.background,
899            resolved.surface,
900            resolved.overlay,
901            resolved.text,
902            resolved.text_muted,
903            resolved.text_subtle,
904            resolved.success,
905            resolved.warning,
906            resolved.error,
907            resolved.info,
908            resolved.border,
909            resolved.border_focused,
910            resolved.selection_bg,
911            resolved.selection_fg,
912            resolved.scrollbar_track,
913            resolved.scrollbar_thumb,
914        ];
915    }
916
917    #[test]
918    fn dark_and_light_resolve_differently() {
919        let theme = Theme {
920            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
921            ..themes::dark()
922        };
923        let dark_resolved = theme.resolve(true);
924        let light_resolved = theme.resolve(false);
925        assert_ne!(dark_resolved.text, light_resolved.text);
926        assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
927        assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
928    }
929
930    #[test]
931    fn all_dark_presets_have_dark_backgrounds() {
932        for (name, theme) in [
933            ("dark", themes::dark()),
934            ("nord", themes::nord()),
935            ("dracula", themes::dracula()),
936            ("solarized_dark", themes::solarized_dark()),
937            ("monokai", themes::monokai()),
938        ] {
939            let bg = theme.background.resolve(true);
940            if let Color::Rgb(rgb) = bg {
941                assert!(
942                    rgb.luminance_u8() < 100,
943                    "{name} background too bright: {}",
944                    rgb.luminance_u8()
945                );
946            }
947        }
948    }
949
950    #[test]
951    fn all_light_presets_have_light_backgrounds() {
952        for (name, theme) in [
953            ("light", themes::light()),
954            ("solarized_light", themes::solarized_light()),
955        ] {
956            let bg = theme.background.resolve(false);
957            if let Color::Rgb(rgb) = bg {
958                assert!(
959                    rgb.luminance_u8() > 150,
960                    "{name} background too dark: {}",
961                    rgb.luminance_u8()
962                );
963            }
964        }
965    }
966
967    #[test]
968    fn theme_default_equals_dark() {
969        assert_eq!(Theme::default(), themes::dark());
970        assert_eq!(themes::default(), themes::dark());
971    }
972
973    #[test]
974    fn builder_all_setters_chain() {
975        let theme = Theme::builder()
976            .primary(Color::rgb(1, 0, 0))
977            .secondary(Color::rgb(2, 0, 0))
978            .accent(Color::rgb(3, 0, 0))
979            .background(Color::rgb(4, 0, 0))
980            .surface(Color::rgb(5, 0, 0))
981            .overlay(Color::rgb(6, 0, 0))
982            .text(Color::rgb(7, 0, 0))
983            .text_muted(Color::rgb(8, 0, 0))
984            .text_subtle(Color::rgb(9, 0, 0))
985            .success(Color::rgb(10, 0, 0))
986            .warning(Color::rgb(11, 0, 0))
987            .error(Color::rgb(12, 0, 0))
988            .info(Color::rgb(13, 0, 0))
989            .border(Color::rgb(14, 0, 0))
990            .border_focused(Color::rgb(15, 0, 0))
991            .selection_bg(Color::rgb(16, 0, 0))
992            .selection_fg(Color::rgb(17, 0, 0))
993            .scrollbar_track(Color::rgb(18, 0, 0))
994            .scrollbar_thumb(Color::rgb(19, 0, 0))
995            .build();
996        assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
997        assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
998    }
999
1000    #[test]
1001    fn resolved_theme_is_copy() {
1002        let theme = themes::dark();
1003        let resolved = theme.resolve(true);
1004        let copy = resolved;
1005        assert_eq!(resolved, copy);
1006    }
1007
1008    #[test]
1009    fn detect_dark_mode_with_colorfgbg_dark() {
1010        // COLORFGBG "0;0" means fg=0 bg=0 (black bg = dark mode)
1011        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
1012        assert!(result, "bg=0 should be dark mode");
1013    }
1014
1015    #[test]
1016    fn detect_dark_mode_with_colorfgbg_light_15() {
1017        // COLORFGBG "0;15" means bg=15 (white = light mode)
1018        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
1019        assert!(!result, "bg=15 should be light mode");
1020    }
1021
1022    #[test]
1023    fn detect_dark_mode_with_colorfgbg_light_7() {
1024        // COLORFGBG "0;7" means bg=7 (silver = light mode)
1025        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
1026        assert!(!result, "bg=7 should be light mode");
1027    }
1028
1029    #[test]
1030    fn detect_dark_mode_without_env_defaults_dark() {
1031        let result = Theme::detect_dark_mode_from_colorfgbg(None);
1032        assert!(result, "missing COLORFGBG should default to dark");
1033    }
1034
1035    #[test]
1036    fn detect_dark_mode_with_empty_string() {
1037        let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
1038        assert!(result, "empty COLORFGBG should default to dark");
1039    }
1040
1041    #[test]
1042    fn detect_dark_mode_with_no_semicolon() {
1043        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
1044        assert!(result, "COLORFGBG without semicolon should default to dark");
1045    }
1046
1047    #[test]
1048    fn detect_dark_mode_with_multiple_semicolons() {
1049        // Some terminals use "fg;bg;..." format
1050        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
1051        assert!(result, "COLORFGBG with extra parts should use last as bg");
1052    }
1053
1054    #[test]
1055    fn detect_dark_mode_with_whitespace() {
1056        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1057        assert!(!result, "COLORFGBG with whitespace should parse correctly");
1058    }
1059
1060    #[test]
1061    fn detect_dark_mode_with_invalid_number() {
1062        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1063        assert!(
1064            result,
1065            "COLORFGBG with invalid number should default to dark"
1066        );
1067    }
1068
1069    #[test]
1070    fn theme_clone_produces_equal_theme() {
1071        let theme = themes::nord();
1072        let cloned = theme.clone();
1073        assert_eq!(theme, cloned);
1074    }
1075
1076    #[test]
1077    fn theme_equality_different_themes() {
1078        let dark = themes::dark();
1079        let light = themes::light();
1080        assert_ne!(dark, light);
1081    }
1082
1083    #[test]
1084    fn resolved_theme_different_modes_differ() {
1085        // Create a theme with adaptive colors
1086        let theme = Theme {
1087            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1088            background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1089            ..themes::dark()
1090        };
1091        let dark_resolved = theme.resolve(true);
1092        let light_resolved = theme.resolve(false);
1093        assert_ne!(dark_resolved, light_resolved);
1094    }
1095
1096    #[test]
1097    fn resolved_theme_equality_same_mode() {
1098        let theme = themes::dark();
1099        let resolved1 = theme.resolve(true);
1100        let resolved2 = theme.resolve(true);
1101        assert_eq!(resolved1, resolved2);
1102    }
1103
1104    #[test]
1105    fn preset_nord_has_characteristic_colors() {
1106        let nord = themes::nord();
1107        // Nord8 frost blue is the primary color
1108        let primary = nord.primary.resolve(true);
1109        if let Color::Rgb(rgb) = primary {
1110            assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1111        }
1112    }
1113
1114    #[test]
1115    fn preset_dracula_has_characteristic_colors() {
1116        let dracula = themes::dracula();
1117        // Dracula primary is purple
1118        let primary = dracula.primary.resolve(true);
1119        if let Color::Rgb(rgb) = primary {
1120            assert!(
1121                rgb.r > 100 && rgb.b > 200,
1122                "Dracula primary should be purple"
1123            );
1124        }
1125    }
1126
1127    #[test]
1128    fn preset_monokai_has_characteristic_colors() {
1129        let monokai = themes::monokai();
1130        // Monokai primary is cyan
1131        let primary = monokai.primary.resolve(true);
1132        if let Color::Rgb(rgb) = primary {
1133            assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1134        }
1135    }
1136
1137    #[test]
1138    fn preset_solarized_dark_and_light_share_accent_colors() {
1139        let sol_dark = themes::solarized_dark();
1140        let sol_light = themes::solarized_light();
1141        // Solarized uses same accent colors in both modes
1142        assert_eq!(
1143            sol_dark.primary.resolve(true),
1144            sol_light.primary.resolve(true),
1145            "Solarized dark and light should share primary accent"
1146        );
1147    }
1148
1149    #[test]
1150    fn builder_accepts_adaptive_color_directly() {
1151        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1152        let theme = Theme::builder().text(adaptive).build();
1153        assert!(theme.text.is_adaptive());
1154    }
1155
1156    #[test]
1157    fn all_presets_have_distinct_error_colors_from_info() {
1158        for (name, theme) in [
1159            ("dark", themes::dark()),
1160            ("light", themes::light()),
1161            ("nord", themes::nord()),
1162            ("dracula", themes::dracula()),
1163            ("solarized_dark", themes::solarized_dark()),
1164            ("monokai", themes::monokai()),
1165        ] {
1166            let error = theme.error.resolve(true);
1167            let info = theme.info.resolve(true);
1168            assert_ne!(
1169                error, info,
1170                "{name} should have distinct error and info colors"
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn adaptive_color_debug_impl() {
1177        let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1178        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1179        // Just verify Debug doesn't panic
1180        let _ = format!("{:?}", fixed);
1181        let _ = format!("{:?}", adaptive);
1182    }
1183
1184    #[test]
1185    fn theme_debug_impl() {
1186        let theme = themes::dark();
1187        // Just verify Debug doesn't panic and contains something useful
1188        let debug = format!("{:?}", theme);
1189        assert!(debug.contains("Theme"));
1190    }
1191
1192    #[test]
1193    fn resolved_theme_debug_impl() {
1194        let resolved = themes::dark().resolve(true);
1195        let debug = format!("{:?}", resolved);
1196        assert!(debug.contains("ResolvedTheme"));
1197    }
1198
1199    // ====== SharedResolvedTheme tests (bd-3l9qr.2) ======
1200
1201    #[test]
1202    fn shared_theme_load_returns_initial() {
1203        let dark = themes::dark().resolve(true);
1204        let shared = SharedResolvedTheme::new(dark);
1205        assert_eq!(shared.load(), dark);
1206    }
1207
1208    #[test]
1209    fn shared_theme_store_replaces_value() {
1210        let original = themes::dark().resolve(true);
1211        // Build a clearly different theme by mutating a field.
1212        let mut updated = original;
1213        updated.primary = Color::rgb(0, 0, 0);
1214        assert_ne!(original.primary, updated.primary);
1215
1216        let shared = SharedResolvedTheme::new(original);
1217        shared.store(updated);
1218        assert_eq!(shared.load(), updated);
1219        assert_ne!(shared.load(), original);
1220    }
1221
1222    #[test]
1223    fn shared_theme_concurrent_read_write() {
1224        use std::sync::{Arc, Barrier};
1225        use std::thread;
1226
1227        let dark = themes::dark().resolve(true);
1228        let light = themes::dark().resolve(false);
1229        let shared = Arc::new(SharedResolvedTheme::new(dark));
1230        let barrier = Arc::new(Barrier::new(5));
1231
1232        let readers: Vec<_> = (0..4)
1233            .map(|_| {
1234                let s = Arc::clone(&shared);
1235                let b = Arc::clone(&barrier);
1236                let dark_copy = dark;
1237                let light_copy = light;
1238                thread::spawn(move || {
1239                    b.wait();
1240                    for _ in 0..10_000 {
1241                        let theme = s.load();
1242                        // Must be one of the two valid themes (no torn reads).
1243                        assert!(
1244                            theme == dark_copy || theme == light_copy,
1245                            "torn read detected"
1246                        );
1247                    }
1248                })
1249            })
1250            .collect();
1251
1252        let writer = {
1253            let s = Arc::clone(&shared);
1254            let b = Arc::clone(&barrier);
1255            thread::spawn(move || {
1256                b.wait();
1257                for i in 0..1_000 {
1258                    if i % 2 == 0 {
1259                        s.store(light);
1260                    } else {
1261                        s.store(dark);
1262                    }
1263                }
1264            })
1265        };
1266
1267        writer.join().unwrap();
1268        for h in readers {
1269            h.join().unwrap();
1270        }
1271    }
1272}