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)]
266pub struct ThemeBuilder {
267    theme: Theme,
268}
269
270impl ThemeBuilder {
271    /// Create a new builder starting from the default dark theme.
272    pub fn new() -> Self {
273        Self {
274            theme: themes::dark(),
275        }
276    }
277
278    /// Start from a base theme.
279    pub fn from_theme(theme: Theme) -> Self {
280        Self { theme }
281    }
282
283    /// Set the primary color.
284    pub fn primary(mut self, color: impl Into<AdaptiveColor>) -> Self {
285        self.theme.primary = color.into();
286        self
287    }
288
289    /// Set the secondary color.
290    pub fn secondary(mut self, color: impl Into<AdaptiveColor>) -> Self {
291        self.theme.secondary = color.into();
292        self
293    }
294
295    /// Set the accent color.
296    pub fn accent(mut self, color: impl Into<AdaptiveColor>) -> Self {
297        self.theme.accent = color.into();
298        self
299    }
300
301    /// Set the background color.
302    pub fn background(mut self, color: impl Into<AdaptiveColor>) -> Self {
303        self.theme.background = color.into();
304        self
305    }
306
307    /// Set the surface color.
308    pub fn surface(mut self, color: impl Into<AdaptiveColor>) -> Self {
309        self.theme.surface = color.into();
310        self
311    }
312
313    /// Set the overlay color.
314    pub fn overlay(mut self, color: impl Into<AdaptiveColor>) -> Self {
315        self.theme.overlay = color.into();
316        self
317    }
318
319    /// Set the text color.
320    pub fn text(mut self, color: impl Into<AdaptiveColor>) -> Self {
321        self.theme.text = color.into();
322        self
323    }
324
325    /// Set the muted text color.
326    pub fn text_muted(mut self, color: impl Into<AdaptiveColor>) -> Self {
327        self.theme.text_muted = color.into();
328        self
329    }
330
331    /// Set the subtle text color.
332    pub fn text_subtle(mut self, color: impl Into<AdaptiveColor>) -> Self {
333        self.theme.text_subtle = color.into();
334        self
335    }
336
337    /// Set the success color.
338    pub fn success(mut self, color: impl Into<AdaptiveColor>) -> Self {
339        self.theme.success = color.into();
340        self
341    }
342
343    /// Set the warning color.
344    pub fn warning(mut self, color: impl Into<AdaptiveColor>) -> Self {
345        self.theme.warning = color.into();
346        self
347    }
348
349    /// Set the error color.
350    pub fn error(mut self, color: impl Into<AdaptiveColor>) -> Self {
351        self.theme.error = color.into();
352        self
353    }
354
355    /// Set the info color.
356    pub fn info(mut self, color: impl Into<AdaptiveColor>) -> Self {
357        self.theme.info = color.into();
358        self
359    }
360
361    /// Set the border color.
362    pub fn border(mut self, color: impl Into<AdaptiveColor>) -> Self {
363        self.theme.border = color.into();
364        self
365    }
366
367    /// Set the focused border color.
368    pub fn border_focused(mut self, color: impl Into<AdaptiveColor>) -> Self {
369        self.theme.border_focused = color.into();
370        self
371    }
372
373    /// Set the selection background color.
374    pub fn selection_bg(mut self, color: impl Into<AdaptiveColor>) -> Self {
375        self.theme.selection_bg = color.into();
376        self
377    }
378
379    /// Set the selection foreground color.
380    pub fn selection_fg(mut self, color: impl Into<AdaptiveColor>) -> Self {
381        self.theme.selection_fg = color.into();
382        self
383    }
384
385    /// Set the scrollbar track color.
386    pub fn scrollbar_track(mut self, color: impl Into<AdaptiveColor>) -> Self {
387        self.theme.scrollbar_track = color.into();
388        self
389    }
390
391    /// Set the scrollbar thumb color.
392    pub fn scrollbar_thumb(mut self, color: impl Into<AdaptiveColor>) -> Self {
393        self.theme.scrollbar_thumb = color.into();
394        self
395    }
396
397    /// Build the theme.
398    pub fn build(self) -> Theme {
399        self.theme
400    }
401}
402
403impl Default for ThemeBuilder {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409/// Built-in theme presets.
410pub mod themes {
411    use super::*;
412
413    /// Default sensible theme (dark mode).
414    #[must_use]
415    pub fn default() -> Theme {
416        dark()
417    }
418
419    /// Dark theme.
420    #[must_use]
421    pub fn dark() -> Theme {
422        Theme {
423            primary: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Blue
424            secondary: AdaptiveColor::fixed(Color::rgb(163, 113, 247)), // Purple
425            accent: AdaptiveColor::fixed(Color::rgb(255, 123, 114)), // Coral
426
427            background: AdaptiveColor::fixed(Color::rgb(22, 27, 34)), // Dark gray
428            surface: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),    // Slightly lighter
429            overlay: AdaptiveColor::fixed(Color::rgb(48, 54, 61)),    // Even lighter
430
431            text: AdaptiveColor::fixed(Color::rgb(230, 237, 243)), // Bright
432            text_muted: AdaptiveColor::fixed(Color::rgb(139, 148, 158)), // Gray
433            text_subtle: AdaptiveColor::fixed(Color::rgb(110, 118, 129)), // Darker gray
434
435            success: AdaptiveColor::fixed(Color::rgb(63, 185, 80)), // Green
436            warning: AdaptiveColor::fixed(Color::rgb(210, 153, 34)), // Yellow
437            error: AdaptiveColor::fixed(Color::rgb(248, 81, 73)),   // Red
438            info: AdaptiveColor::fixed(Color::rgb(88, 166, 255)),   // Blue
439
440            border: AdaptiveColor::fixed(Color::rgb(48, 54, 61)), // Subtle
441            border_focused: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), // Accent
442
443            selection_bg: AdaptiveColor::fixed(Color::rgb(56, 139, 253)), // Blue
444            selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
445
446            scrollbar_track: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),
447            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(72, 79, 88)),
448        }
449    }
450
451    /// Light theme.
452    #[must_use]
453    pub fn light() -> Theme {
454        Theme {
455            primary: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Blue
456            secondary: AdaptiveColor::fixed(Color::rgb(130, 80, 223)), // Purple
457            accent: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),  // Red
458
459            background: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), // White
460            surface: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),    // Light gray
461            overlay: AdaptiveColor::fixed(Color::rgb(255, 255, 255)),    // White
462
463            text: AdaptiveColor::fixed(Color::rgb(31, 35, 40)), // Dark
464            text_muted: AdaptiveColor::fixed(Color::rgb(87, 96, 106)), // Gray
465            text_subtle: AdaptiveColor::fixed(Color::rgb(140, 149, 159)), // Light gray
466
467            success: AdaptiveColor::fixed(Color::rgb(26, 127, 55)), // Green
468            warning: AdaptiveColor::fixed(Color::rgb(158, 106, 3)), // Yellow
469            error: AdaptiveColor::fixed(Color::rgb(207, 34, 46)),   // Red
470            info: AdaptiveColor::fixed(Color::rgb(9, 105, 218)),    // Blue
471
472            border: AdaptiveColor::fixed(Color::rgb(208, 215, 222)), // Light gray
473            border_focused: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), // Accent
474
475            selection_bg: AdaptiveColor::fixed(Color::rgb(221, 244, 255)), // Light blue
476            selection_fg: AdaptiveColor::fixed(Color::rgb(31, 35, 40)),    // Dark
477
478            scrollbar_track: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),
479            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(175, 184, 193)),
480        }
481    }
482
483    /// Nord color scheme (dark variant).
484    #[must_use]
485    pub fn nord() -> Theme {
486        Theme {
487            primary: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8 (frost)
488            secondary: AdaptiveColor::fixed(Color::rgb(180, 142, 173)), // Nord15 (purple)
489            accent: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),   // Nord11 (aurora red)
490
491            background: AdaptiveColor::fixed(Color::rgb(46, 52, 64)), // Nord0
492            surface: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),    // Nord1
493            overlay: AdaptiveColor::fixed(Color::rgb(67, 76, 94)),    // Nord2
494
495            text: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
496            text_muted: AdaptiveColor::fixed(Color::rgb(216, 222, 233)), // Nord4
497            text_subtle: AdaptiveColor::fixed(Color::rgb(129, 161, 193)), // Nord9
498
499            success: AdaptiveColor::fixed(Color::rgb(163, 190, 140)), // Nord14 (green)
500            warning: AdaptiveColor::fixed(Color::rgb(235, 203, 139)), // Nord13 (yellow)
501            error: AdaptiveColor::fixed(Color::rgb(191, 97, 106)),    // Nord11 (red)
502            info: AdaptiveColor::fixed(Color::rgb(129, 161, 193)),    // Nord9 (blue)
503
504            border: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
505            border_focused: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), // Nord8
506
507            selection_bg: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), // Nord3
508            selection_fg: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), // Nord6
509
510            scrollbar_track: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),
511            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(76, 86, 106)),
512        }
513    }
514
515    /// Dracula color scheme.
516    #[must_use]
517    pub fn dracula() -> Theme {
518        Theme {
519            primary: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
520            secondary: AdaptiveColor::fixed(Color::rgb(255, 121, 198)), // Pink
521            accent: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),  // Cyan
522
523            background: AdaptiveColor::fixed(Color::rgb(40, 42, 54)), // Background
524            surface: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
525            overlay: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),    // Current line
526
527            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
528            text_muted: AdaptiveColor::fixed(Color::rgb(188, 188, 188)), // Lighter
529            text_subtle: AdaptiveColor::fixed(Color::rgb(98, 114, 164)), // Comment
530
531            success: AdaptiveColor::fixed(Color::rgb(80, 250, 123)), // Green
532            warning: AdaptiveColor::fixed(Color::rgb(255, 184, 108)), // Orange
533            error: AdaptiveColor::fixed(Color::rgb(255, 85, 85)),    // Red
534            info: AdaptiveColor::fixed(Color::rgb(139, 233, 253)),   // Cyan
535
536            border: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
537            border_focused: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), // Purple
538
539            selection_bg: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), // Current line
540            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
541
542            scrollbar_track: AdaptiveColor::fixed(Color::rgb(40, 42, 54)),
543            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),
544        }
545    }
546
547    /// Solarized Dark color scheme.
548    #[must_use]
549    pub fn solarized_dark() -> Theme {
550        Theme {
551            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
552            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
553            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
554
555            background: AdaptiveColor::fixed(Color::rgb(0, 43, 54)), // Base03
556            surface: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
557            overlay: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),    // Base02
558
559            text: AdaptiveColor::fixed(Color::rgb(131, 148, 150)), // Base0
560            text_muted: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
561            text_subtle: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
562
563            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
564            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
565            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
566            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
567
568            border: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
569            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
570
571            selection_bg: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), // Base02
572            selection_fg: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
573
574            scrollbar_track: AdaptiveColor::fixed(Color::rgb(0, 43, 54)),
575            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),
576        }
577    }
578
579    /// Solarized Light color scheme.
580    #[must_use]
581    pub fn solarized_light() -> Theme {
582        Theme {
583            primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
584            secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), // Violet
585            accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)),   // Orange
586
587            background: AdaptiveColor::fixed(Color::rgb(253, 246, 227)), // Base3
588            surface: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),    // Base2
589            overlay: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),    // Base3
590
591            text: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), // Base00
592            text_muted: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), // Base01
593            text_subtle: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), // Base1
594
595            success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), // Green
596            warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), // Yellow
597            error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)),   // Red
598            info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)),   // Blue
599
600            border: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
601            border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), // Blue
602
603            selection_bg: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), // Base2
604            selection_fg: AdaptiveColor::fixed(Color::rgb(88, 110, 117)),  // Base01
605
606            scrollbar_track: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),
607            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),
608        }
609    }
610
611    /// Monokai color scheme.
612    #[must_use]
613    pub fn monokai() -> Theme {
614        Theme {
615            primary: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
616            secondary: AdaptiveColor::fixed(Color::rgb(174, 129, 255)), // Purple
617            accent: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink
618
619            background: AdaptiveColor::fixed(Color::rgb(39, 40, 34)), // Background
620            surface: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
621            overlay: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),    // Lighter
622
623            text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
624            text_muted: AdaptiveColor::fixed(Color::rgb(189, 189, 189)), // Gray
625            text_subtle: AdaptiveColor::fixed(Color::rgb(117, 113, 94)), // Comment
626
627            success: AdaptiveColor::fixed(Color::rgb(166, 226, 46)), // Green
628            warning: AdaptiveColor::fixed(Color::rgb(230, 219, 116)), // Yellow
629            error: AdaptiveColor::fixed(Color::rgb(249, 38, 114)),   // Pink/red
630            info: AdaptiveColor::fixed(Color::rgb(102, 217, 239)),   // Cyan
631
632            border: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), // Lighter bg
633            border_focused: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), // Cyan
634
635            selection_bg: AdaptiveColor::fixed(Color::rgb(73, 72, 62)), // Selection
636            selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), // Foreground
637
638            scrollbar_track: AdaptiveColor::fixed(Color::rgb(39, 40, 34)),
639            scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),
640        }
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn adaptive_color_fixed() {
650        let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
651        assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
652        assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
653        assert!(!color.is_adaptive());
654    }
655
656    #[test]
657    fn adaptive_color_adaptive() {
658        let color = AdaptiveColor::adaptive(
659            Color::rgb(255, 255, 255), // light
660            Color::rgb(0, 0, 0),       // dark
661        );
662        assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); // dark
663        assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); // light
664        assert!(color.is_adaptive());
665    }
666
667    #[test]
668    fn theme_default_is_dark() {
669        let theme = Theme::default();
670        // Dark themes typically have dark backgrounds
671        let bg = theme.background.resolve(true);
672        if let Color::Rgb(rgb) = bg {
673            // Background should be dark
674            assert!(rgb.luminance_u8() < 50);
675        }
676    }
677
678    #[test]
679    fn theme_light_has_light_background() {
680        let theme = themes::light();
681        let bg = theme.background.resolve(false);
682        if let Color::Rgb(rgb) = bg {
683            // Light background
684            assert!(rgb.luminance_u8() > 200);
685        }
686    }
687
688    #[test]
689    fn theme_has_all_slots() {
690        let theme = Theme::default();
691        // Just verify all slots exist and resolve without panic
692        let _ = theme.primary.resolve(true);
693        let _ = theme.secondary.resolve(true);
694        let _ = theme.accent.resolve(true);
695        let _ = theme.background.resolve(true);
696        let _ = theme.surface.resolve(true);
697        let _ = theme.overlay.resolve(true);
698        let _ = theme.text.resolve(true);
699        let _ = theme.text_muted.resolve(true);
700        let _ = theme.text_subtle.resolve(true);
701        let _ = theme.success.resolve(true);
702        let _ = theme.warning.resolve(true);
703        let _ = theme.error.resolve(true);
704        let _ = theme.info.resolve(true);
705        let _ = theme.border.resolve(true);
706        let _ = theme.border_focused.resolve(true);
707        let _ = theme.selection_bg.resolve(true);
708        let _ = theme.selection_fg.resolve(true);
709        let _ = theme.scrollbar_track.resolve(true);
710        let _ = theme.scrollbar_thumb.resolve(true);
711    }
712
713    #[test]
714    fn theme_builder_works() {
715        let theme = Theme::builder()
716            .primary(Color::rgb(255, 0, 0))
717            .background(Color::rgb(0, 0, 0))
718            .build();
719
720        assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
721        assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
722    }
723
724    #[test]
725    fn theme_resolve_flattens() {
726        let theme = themes::dark();
727        let resolved = theme.resolve(true);
728
729        // All colors should be the same as resolving individually
730        assert_eq!(resolved.primary, theme.primary.resolve(true));
731        assert_eq!(resolved.text, theme.text.resolve(true));
732        assert_eq!(resolved.background, theme.background.resolve(true));
733    }
734
735    #[test]
736    fn all_presets_exist() {
737        let _ = themes::default();
738        let _ = themes::dark();
739        let _ = themes::light();
740        let _ = themes::nord();
741        let _ = themes::dracula();
742        let _ = themes::solarized_dark();
743        let _ = themes::solarized_light();
744        let _ = themes::monokai();
745    }
746
747    #[test]
748    fn presets_have_different_colors() {
749        let dark = themes::dark();
750        let light = themes::light();
751        let nord = themes::nord();
752
753        // Different themes should have different backgrounds
754        assert_ne!(
755            dark.background.resolve(true),
756            light.background.resolve(false)
757        );
758        assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
759    }
760
761    #[test]
762    fn detect_dark_mode_returns_bool() {
763        // Just verify it doesn't panic
764        let _ = Theme::detect_dark_mode();
765    }
766
767    #[test]
768    fn color_converts_to_adaptive() {
769        let color = Color::rgb(100, 150, 200);
770        let adaptive: AdaptiveColor = color.into();
771        assert_eq!(adaptive.resolve(true), color);
772        assert_eq!(adaptive.resolve(false), color);
773    }
774
775    #[test]
776    fn builder_from_theme() {
777        let base = themes::nord();
778        let modified = ThemeBuilder::from_theme(base.clone())
779            .primary(Color::rgb(255, 0, 0))
780            .build();
781
782        // Modified primary
783        assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
784        // Unchanged secondary (from nord)
785        assert_eq!(modified.secondary, base.secondary);
786    }
787
788    // Count semantic slots to verify we have 15+
789    #[test]
790    fn has_at_least_15_semantic_slots() {
791        let theme = Theme::default();
792        let slot_count = 19; // Counting from the struct definition
793        assert!(slot_count >= 15);
794
795        // Verify by accessing each slot
796        let _slots = [
797            &theme.primary,
798            &theme.secondary,
799            &theme.accent,
800            &theme.background,
801            &theme.surface,
802            &theme.overlay,
803            &theme.text,
804            &theme.text_muted,
805            &theme.text_subtle,
806            &theme.success,
807            &theme.warning,
808            &theme.error,
809            &theme.info,
810            &theme.border,
811            &theme.border_focused,
812            &theme.selection_bg,
813            &theme.selection_fg,
814            &theme.scrollbar_track,
815            &theme.scrollbar_thumb,
816        ];
817    }
818
819    #[test]
820    fn adaptive_color_default_is_gray() {
821        let color = AdaptiveColor::default();
822        assert!(!color.is_adaptive());
823        assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
824        assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
825    }
826
827    #[test]
828    fn theme_builder_default() {
829        let builder = ThemeBuilder::default();
830        let theme = builder.build();
831        // Default builder starts from dark theme
832        assert_eq!(theme, themes::dark());
833    }
834
835    #[test]
836    fn resolved_theme_has_all_19_slots() {
837        let theme = themes::dark();
838        let resolved = theme.resolve(true);
839        // Just verify all slots are accessible without panic
840        let _colors = [
841            resolved.primary,
842            resolved.secondary,
843            resolved.accent,
844            resolved.background,
845            resolved.surface,
846            resolved.overlay,
847            resolved.text,
848            resolved.text_muted,
849            resolved.text_subtle,
850            resolved.success,
851            resolved.warning,
852            resolved.error,
853            resolved.info,
854            resolved.border,
855            resolved.border_focused,
856            resolved.selection_bg,
857            resolved.selection_fg,
858            resolved.scrollbar_track,
859            resolved.scrollbar_thumb,
860        ];
861    }
862
863    #[test]
864    fn dark_and_light_resolve_differently() {
865        let theme = Theme {
866            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
867            ..themes::dark()
868        };
869        let dark_resolved = theme.resolve(true);
870        let light_resolved = theme.resolve(false);
871        assert_ne!(dark_resolved.text, light_resolved.text);
872        assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
873        assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
874    }
875
876    #[test]
877    fn all_dark_presets_have_dark_backgrounds() {
878        for (name, theme) in [
879            ("dark", themes::dark()),
880            ("nord", themes::nord()),
881            ("dracula", themes::dracula()),
882            ("solarized_dark", themes::solarized_dark()),
883            ("monokai", themes::monokai()),
884        ] {
885            let bg = theme.background.resolve(true);
886            if let Color::Rgb(rgb) = bg {
887                assert!(
888                    rgb.luminance_u8() < 100,
889                    "{name} background too bright: {}",
890                    rgb.luminance_u8()
891                );
892            }
893        }
894    }
895
896    #[test]
897    fn all_light_presets_have_light_backgrounds() {
898        for (name, theme) in [
899            ("light", themes::light()),
900            ("solarized_light", themes::solarized_light()),
901        ] {
902            let bg = theme.background.resolve(false);
903            if let Color::Rgb(rgb) = bg {
904                assert!(
905                    rgb.luminance_u8() > 150,
906                    "{name} background too dark: {}",
907                    rgb.luminance_u8()
908                );
909            }
910        }
911    }
912
913    #[test]
914    fn theme_default_equals_dark() {
915        assert_eq!(Theme::default(), themes::dark());
916        assert_eq!(themes::default(), themes::dark());
917    }
918
919    #[test]
920    fn builder_all_setters_chain() {
921        let theme = Theme::builder()
922            .primary(Color::rgb(1, 0, 0))
923            .secondary(Color::rgb(2, 0, 0))
924            .accent(Color::rgb(3, 0, 0))
925            .background(Color::rgb(4, 0, 0))
926            .surface(Color::rgb(5, 0, 0))
927            .overlay(Color::rgb(6, 0, 0))
928            .text(Color::rgb(7, 0, 0))
929            .text_muted(Color::rgb(8, 0, 0))
930            .text_subtle(Color::rgb(9, 0, 0))
931            .success(Color::rgb(10, 0, 0))
932            .warning(Color::rgb(11, 0, 0))
933            .error(Color::rgb(12, 0, 0))
934            .info(Color::rgb(13, 0, 0))
935            .border(Color::rgb(14, 0, 0))
936            .border_focused(Color::rgb(15, 0, 0))
937            .selection_bg(Color::rgb(16, 0, 0))
938            .selection_fg(Color::rgb(17, 0, 0))
939            .scrollbar_track(Color::rgb(18, 0, 0))
940            .scrollbar_thumb(Color::rgb(19, 0, 0))
941            .build();
942        assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
943        assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
944    }
945
946    #[test]
947    fn resolved_theme_is_copy() {
948        let theme = themes::dark();
949        let resolved = theme.resolve(true);
950        let copy = resolved;
951        assert_eq!(resolved, copy);
952    }
953
954    #[test]
955    fn detect_dark_mode_with_colorfgbg_dark() {
956        // COLORFGBG "0;0" means fg=0 bg=0 (black bg = dark mode)
957        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
958        assert!(result, "bg=0 should be dark mode");
959    }
960
961    #[test]
962    fn detect_dark_mode_with_colorfgbg_light_15() {
963        // COLORFGBG "0;15" means bg=15 (white = light mode)
964        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
965        assert!(!result, "bg=15 should be light mode");
966    }
967
968    #[test]
969    fn detect_dark_mode_with_colorfgbg_light_7() {
970        // COLORFGBG "0;7" means bg=7 (silver = light mode)
971        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
972        assert!(!result, "bg=7 should be light mode");
973    }
974
975    #[test]
976    fn detect_dark_mode_without_env_defaults_dark() {
977        let result = Theme::detect_dark_mode_from_colorfgbg(None);
978        assert!(result, "missing COLORFGBG should default to dark");
979    }
980
981    #[test]
982    fn detect_dark_mode_with_empty_string() {
983        let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
984        assert!(result, "empty COLORFGBG should default to dark");
985    }
986
987    #[test]
988    fn detect_dark_mode_with_no_semicolon() {
989        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
990        assert!(result, "COLORFGBG without semicolon should default to dark");
991    }
992
993    #[test]
994    fn detect_dark_mode_with_multiple_semicolons() {
995        // Some terminals use "fg;bg;..." format
996        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
997        assert!(result, "COLORFGBG with extra parts should use last as bg");
998    }
999
1000    #[test]
1001    fn detect_dark_mode_with_whitespace() {
1002        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1003        assert!(!result, "COLORFGBG with whitespace should parse correctly");
1004    }
1005
1006    #[test]
1007    fn detect_dark_mode_with_invalid_number() {
1008        let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1009        assert!(
1010            result,
1011            "COLORFGBG with invalid number should default to dark"
1012        );
1013    }
1014
1015    #[test]
1016    fn theme_clone_produces_equal_theme() {
1017        let theme = themes::nord();
1018        let cloned = theme.clone();
1019        assert_eq!(theme, cloned);
1020    }
1021
1022    #[test]
1023    fn theme_equality_different_themes() {
1024        let dark = themes::dark();
1025        let light = themes::light();
1026        assert_ne!(dark, light);
1027    }
1028
1029    #[test]
1030    fn resolved_theme_different_modes_differ() {
1031        // Create a theme with adaptive colors
1032        let theme = Theme {
1033            text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1034            background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1035            ..themes::dark()
1036        };
1037        let dark_resolved = theme.resolve(true);
1038        let light_resolved = theme.resolve(false);
1039        assert_ne!(dark_resolved, light_resolved);
1040    }
1041
1042    #[test]
1043    fn resolved_theme_equality_same_mode() {
1044        let theme = themes::dark();
1045        let resolved1 = theme.resolve(true);
1046        let resolved2 = theme.resolve(true);
1047        assert_eq!(resolved1, resolved2);
1048    }
1049
1050    #[test]
1051    fn preset_nord_has_characteristic_colors() {
1052        let nord = themes::nord();
1053        // Nord8 frost blue is the primary color
1054        let primary = nord.primary.resolve(true);
1055        if let Color::Rgb(rgb) = primary {
1056            assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1057        }
1058    }
1059
1060    #[test]
1061    fn preset_dracula_has_characteristic_colors() {
1062        let dracula = themes::dracula();
1063        // Dracula primary is purple
1064        let primary = dracula.primary.resolve(true);
1065        if let Color::Rgb(rgb) = primary {
1066            assert!(
1067                rgb.r > 100 && rgb.b > 200,
1068                "Dracula primary should be purple"
1069            );
1070        }
1071    }
1072
1073    #[test]
1074    fn preset_monokai_has_characteristic_colors() {
1075        let monokai = themes::monokai();
1076        // Monokai primary is cyan
1077        let primary = monokai.primary.resolve(true);
1078        if let Color::Rgb(rgb) = primary {
1079            assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1080        }
1081    }
1082
1083    #[test]
1084    fn preset_solarized_dark_and_light_share_accent_colors() {
1085        let sol_dark = themes::solarized_dark();
1086        let sol_light = themes::solarized_light();
1087        // Solarized uses same accent colors in both modes
1088        assert_eq!(
1089            sol_dark.primary.resolve(true),
1090            sol_light.primary.resolve(true),
1091            "Solarized dark and light should share primary accent"
1092        );
1093    }
1094
1095    #[test]
1096    fn builder_accepts_adaptive_color_directly() {
1097        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1098        let theme = Theme::builder().text(adaptive).build();
1099        assert!(theme.text.is_adaptive());
1100    }
1101
1102    #[test]
1103    fn all_presets_have_distinct_error_colors_from_info() {
1104        for (name, theme) in [
1105            ("dark", themes::dark()),
1106            ("light", themes::light()),
1107            ("nord", themes::nord()),
1108            ("dracula", themes::dracula()),
1109            ("solarized_dark", themes::solarized_dark()),
1110            ("monokai", themes::monokai()),
1111        ] {
1112            let error = theme.error.resolve(true);
1113            let info = theme.info.resolve(true);
1114            assert_ne!(
1115                error, info,
1116                "{name} should have distinct error and info colors"
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn adaptive_color_debug_impl() {
1123        let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1124        let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1125        // Just verify Debug doesn't panic
1126        let _ = format!("{:?}", fixed);
1127        let _ = format!("{:?}", adaptive);
1128    }
1129
1130    #[test]
1131    fn theme_debug_impl() {
1132        let theme = themes::dark();
1133        // Just verify Debug doesn't panic and contains something useful
1134        let debug = format!("{:?}", theme);
1135        assert!(debug.contains("Theme"));
1136    }
1137
1138    #[test]
1139    fn resolved_theme_debug_impl() {
1140        let resolved = themes::dark().resolve(true);
1141        let debug = format!("{:?}", resolved);
1142        assert!(debug.contains("ResolvedTheme"));
1143    }
1144}