Skip to main content

slt/style/
theme.rs

1use super::*;
2
3/// Spacing scale for consistent padding, margin, and gap values.
4///
5/// All values are in terminal cells. The scale is relative to [`Spacing::base`],
6/// which defaults to 1 cell. Use [`Theme::spacing`] to access the active scale,
7/// or [`crate::Context::spacing`] for convenience.
8///
9/// # Example
10///
11/// ```
12/// use slt::Spacing;
13///
14/// let sp = Spacing::new(1);
15/// assert_eq!(sp.sm(), 2);
16/// assert_eq!(sp.md(), 3);
17/// assert_eq!(sp.xl(), 6);
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct Spacing {
22    /// Base unit in terminal cells. Defaults to 1.
23    pub base: u32,
24}
25
26impl Spacing {
27    /// Create a spacing scale with the given base unit.
28    pub const fn new(base: u32) -> Self {
29        Self { base }
30    }
31
32    /// Zero spacing.
33    pub const fn none(&self) -> u32 {
34        0
35    }
36
37    /// Extra-small spacing (1× base).
38    pub const fn xs(&self) -> u32 {
39        self.base
40    }
41
42    /// Small spacing (2× base).
43    pub const fn sm(&self) -> u32 {
44        self.base * 2
45    }
46
47    /// Medium spacing (3× base).
48    pub const fn md(&self) -> u32 {
49        self.base * 3
50    }
51
52    /// Large spacing (4× base).
53    pub const fn lg(&self) -> u32 {
54        self.base * 4
55    }
56
57    /// Extra-large spacing (6× base).
58    pub const fn xl(&self) -> u32 {
59        self.base * 6
60    }
61
62    /// Double extra-large spacing (8× base).
63    pub const fn xxl(&self) -> u32 {
64        self.base * 8
65    }
66}
67
68impl Default for Spacing {
69    fn default() -> Self {
70        Self { base: 1 }
71    }
72}
73
74/// Semantic color token that resolves against the active [`Theme`].
75///
76/// Use this when you want colors to automatically follow theme changes
77/// without hardcoding specific [`Color`] values. Resolve with
78/// [`Theme::resolve`] or [`crate::Context::color`].
79///
80/// # Example
81///
82/// ```
83/// use slt::{Theme, ThemeColor};
84///
85/// let theme = Theme::dark();
86/// let color = theme.resolve(ThemeColor::Primary);
87/// assert_eq!(color, theme.primary);
88/// ```
89#[non_exhaustive]
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub enum ThemeColor {
93    /// Primary accent color.
94    Primary,
95    /// Secondary accent color.
96    Secondary,
97    /// Decorative accent color.
98    Accent,
99    /// Default text color.
100    Text,
101    /// Dimmed text color.
102    TextDim,
103    /// Border color for unfocused containers.
104    Border,
105    /// Background color.
106    Bg,
107    /// Success indicator color.
108    Success,
109    /// Warning indicator color.
110    Warning,
111    /// Error indicator color.
112    Error,
113    /// Selected item background.
114    SelectedBg,
115    /// Selected item foreground.
116    SelectedFg,
117    /// Surface background for elevated containers.
118    Surface,
119    /// Surface hover state.
120    SurfaceHover,
121    /// Text color readable on surface backgrounds.
122    SurfaceText,
123    /// Informational indicator (resolves to primary).
124    Info,
125    /// Hyperlink color (resolves to primary).
126    Link,
127    /// Focus ring outline color (resolves to primary).
128    FocusRing,
129    /// A literal color value, not resolved from the theme.
130    Custom(Color),
131}
132
133/// A color theme that flows through all widgets automatically.
134///
135/// Construct with [`Theme::dark()`] or [`Theme::light()`], or use
136/// [`Theme::builder()`] for custom themes. Pass via [`crate::RunConfig`]
137/// and every widget picks up the colors without any extra wiring.
138///
139/// # Example
140///
141/// ```
142/// use slt::{Color, Theme};
143///
144/// let theme = Theme::builder()
145///     .primary(Color::Rgb(255, 107, 107))
146///     .build();
147/// ```
148#[non_exhaustive]
149#[derive(Debug, Clone, Copy)]
150#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
151pub struct Theme {
152    /// Primary accent color, used for focused borders and highlights.
153    pub primary: Color,
154    /// Secondary accent color, used for less prominent highlights.
155    pub secondary: Color,
156    /// Accent color for decorative elements.
157    pub accent: Color,
158    /// Default foreground text color.
159    pub text: Color,
160    /// Dimmed text color for secondary labels and hints.
161    pub text_dim: Color,
162    /// Border color for unfocused containers.
163    pub border: Color,
164    /// Background color. Typically [`Color::Reset`] to inherit the terminal background.
165    pub bg: Color,
166    /// Color for success states (e.g., toast notifications).
167    pub success: Color,
168    /// Color for warning states.
169    pub warning: Color,
170    /// Color for error states.
171    pub error: Color,
172    /// Background color for selected list/table rows.
173    pub selected_bg: Color,
174    /// Foreground color for selected list/table rows.
175    pub selected_fg: Color,
176    /// Subtle surface color for card backgrounds and elevated containers.
177    pub surface: Color,
178    /// Hover/active surface color, one step brighter than `surface`.
179    ///
180    /// Used for interactive element hover states. Should be visually
181    /// distinguishable from both `surface` and `border`.
182    pub surface_hover: Color,
183    /// Secondary text color guaranteed readable on `surface` backgrounds.
184    ///
185    /// Use this instead of `text_dim` when rendering on `surface`-colored
186    /// containers. `text_dim` is tuned for the main `bg`; on `surface` it
187    /// may lack contrast.
188    pub surface_text: Color,
189    /// Whether this theme is a dark theme. Used to initialize dark mode in Context.
190    pub is_dark: bool,
191    /// Spacing scale for consistent padding, margin, and gap values.
192    pub spacing: Spacing,
193}
194
195impl Theme {
196    /// Resolve a [`ThemeColor`] token to a concrete [`Color`].
197    pub fn resolve(&self, token: ThemeColor) -> Color {
198        match token {
199            ThemeColor::Primary => self.primary,
200            ThemeColor::Secondary => self.secondary,
201            ThemeColor::Accent => self.accent,
202            ThemeColor::Text => self.text,
203            ThemeColor::TextDim => self.text_dim,
204            ThemeColor::Border => self.border,
205            ThemeColor::Bg => self.bg,
206            ThemeColor::Success => self.success,
207            ThemeColor::Warning => self.warning,
208            ThemeColor::Error => self.error,
209            ThemeColor::SelectedBg => self.selected_bg,
210            ThemeColor::SelectedFg => self.selected_fg,
211            ThemeColor::Surface => self.surface,
212            ThemeColor::SurfaceHover => self.surface_hover,
213            ThemeColor::SurfaceText => self.surface_text,
214            ThemeColor::Info | ThemeColor::Link | ThemeColor::FocusRing => self.primary,
215            ThemeColor::Custom(c) => c,
216        }
217    }
218
219    /// Return a text color with guaranteed contrast against the given background.
220    ///
221    /// Delegates to [`Color::contrast_fg`].
222    pub fn contrast_text_on(&self, bg: Color) -> Color {
223        Color::contrast_fg(bg)
224    }
225
226    /// Blend a color with the theme's background at the given alpha.
227    ///
228    /// `alpha = 0.0` returns `self.bg`, `alpha = 1.0` returns `color` unchanged.
229    pub fn overlay(&self, color: Color, alpha: f32) -> Color {
230        color.blend(self.bg, alpha)
231    }
232
233    /// Create a dark theme with cyan primary and white text.
234    pub const fn dark() -> Self {
235        Self {
236            primary: Color::Cyan,
237            secondary: Color::Blue,
238            accent: Color::Magenta,
239            text: Color::White,
240            text_dim: Color::Indexed(245),
241            border: Color::Indexed(240),
242            bg: Color::Reset,
243            success: Color::Green,
244            warning: Color::Yellow,
245            error: Color::Red,
246            selected_bg: Color::Cyan,
247            selected_fg: Color::Black,
248            surface: Color::Indexed(236),
249            surface_hover: Color::Indexed(238),
250            surface_text: Color::Indexed(250),
251            is_dark: true,
252            spacing: Spacing::new(1),
253        }
254    }
255
256    /// Create a light theme with high-contrast dark text on light backgrounds.
257    pub const fn light() -> Self {
258        Self {
259            primary: Color::Rgb(37, 99, 235),
260            secondary: Color::Rgb(14, 116, 144),
261            accent: Color::Rgb(147, 51, 234),
262            text: Color::Rgb(15, 23, 42),
263            text_dim: Color::Rgb(100, 116, 139),
264            border: Color::Rgb(203, 213, 225),
265            bg: Color::Rgb(248, 250, 252),
266            success: Color::Rgb(22, 163, 74),
267            warning: Color::Rgb(202, 138, 4),
268            error: Color::Rgb(220, 38, 38),
269            selected_bg: Color::Rgb(37, 99, 235),
270            selected_fg: Color::White,
271            surface: Color::Rgb(241, 245, 249),
272            surface_hover: Color::Rgb(226, 232, 240),
273            surface_text: Color::Rgb(51, 65, 85),
274            is_dark: false,
275            spacing: Spacing::new(1),
276        }
277    }
278
279    /// Create a [`ThemeBuilder`] for configuring a custom theme.
280    ///
281    /// Unset fields fall back to [`Theme::dark()`] defaults. Use
282    /// [`Theme::builder_from`] to start from a different base, or
283    /// [`Theme::light_builder`] for a light-base shorthand.
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// use slt::{Color, Theme};
289    ///
290    /// let theme = Theme::builder()
291    ///     .primary(Color::Rgb(255, 107, 107))
292    ///     .accent(Color::Cyan)
293    ///     .build();
294    /// ```
295    pub const fn builder() -> ThemeBuilder {
296        ThemeBuilder {
297            primary: None,
298            secondary: None,
299            accent: None,
300            text: None,
301            text_dim: None,
302            border: None,
303            bg: None,
304            success: None,
305            warning: None,
306            error: None,
307            selected_bg: None,
308            selected_fg: None,
309            surface: None,
310            surface_hover: None,
311            surface_text: None,
312            is_dark: None,
313            spacing: None,
314        }
315    }
316
317    /// Create a [`ThemeBuilder`] pre-filled with every field from `base`.
318    ///
319    /// Only fields explicitly overridden via builder methods will differ
320    /// from `base`; unset fields keep `base`'s value (rather than falling
321    /// back to [`Theme::dark()`] defaults as plain [`Theme::builder`]
322    /// does). Useful for deriving variants from any preset.
323    ///
324    /// # Example
325    ///
326    /// ```
327    /// use slt::{Color, Theme};
328    ///
329    /// // Nord variant: keep all Nord colors but override primary.
330    /// let custom_nord = Theme::builder_from(Theme::nord())
331    ///     .primary(Color::Rgb(255, 0, 0))
332    ///     .build();
333    /// assert_eq!(custom_nord.bg, Theme::nord().bg);
334    /// assert_eq!(custom_nord.primary, Color::Rgb(255, 0, 0));
335    /// ```
336    pub const fn builder_from(base: Theme) -> ThemeBuilder {
337        ThemeBuilder {
338            primary: Some(base.primary),
339            secondary: Some(base.secondary),
340            accent: Some(base.accent),
341            text: Some(base.text),
342            text_dim: Some(base.text_dim),
343            border: Some(base.border),
344            bg: Some(base.bg),
345            success: Some(base.success),
346            warning: Some(base.warning),
347            error: Some(base.error),
348            selected_bg: Some(base.selected_bg),
349            selected_fg: Some(base.selected_fg),
350            surface: Some(base.surface),
351            surface_hover: Some(base.surface_hover),
352            surface_text: Some(base.surface_text),
353            is_dark: Some(base.is_dark),
354            spacing: Some(base.spacing),
355        }
356    }
357
358    /// Convenience: builder pre-filled with all [`Theme::light()`] fields.
359    ///
360    /// Equivalent to `Theme::builder_from(Theme::light())`.
361    ///
362    /// # Example
363    ///
364    /// ```
365    /// use slt::{Color, Theme};
366    ///
367    /// let my_light = Theme::light_builder()
368    ///     .primary(Color::Rgb(0, 100, 200))
369    ///     .build();
370    /// assert!(!my_light.is_dark);
371    /// assert_eq!(my_light.primary, Color::Rgb(0, 100, 200));
372    /// // bg stays light (not dark()'s Reset)
373    /// assert_eq!(my_light.bg, Theme::light().bg);
374    /// ```
375    pub const fn light_builder() -> ThemeBuilder {
376        Self::builder_from(Self::light())
377    }
378
379    /// Dracula theme — purple primary on dark gray.
380    pub fn dracula() -> Self {
381        Self {
382            primary: Color::Rgb(189, 147, 249),
383            secondary: Color::Rgb(139, 233, 253),
384            accent: Color::Rgb(255, 121, 198),
385            text: Color::Rgb(248, 248, 242),
386            text_dim: Color::Rgb(98, 114, 164),
387            border: Color::Rgb(68, 71, 90),
388            bg: Color::Rgb(40, 42, 54),
389            success: Color::Rgb(80, 250, 123),
390            warning: Color::Rgb(241, 250, 140),
391            error: Color::Rgb(255, 85, 85),
392            selected_bg: Color::Rgb(189, 147, 249),
393            selected_fg: Color::Rgb(40, 42, 54),
394            surface: Color::Rgb(68, 71, 90),
395            surface_hover: Color::Rgb(98, 100, 120),
396            surface_text: Color::Rgb(191, 194, 210),
397            is_dark: true,
398            spacing: Spacing::new(1),
399        }
400    }
401
402    /// Catppuccin Mocha theme — lavender primary on dark base.
403    pub fn catppuccin() -> Self {
404        Self {
405            primary: Color::Rgb(180, 190, 254),
406            secondary: Color::Rgb(137, 180, 250),
407            accent: Color::Rgb(245, 194, 231),
408            text: Color::Rgb(205, 214, 244),
409            text_dim: Color::Rgb(127, 132, 156),
410            border: Color::Rgb(88, 91, 112),
411            bg: Color::Rgb(30, 30, 46),
412            success: Color::Rgb(166, 227, 161),
413            warning: Color::Rgb(249, 226, 175),
414            error: Color::Rgb(243, 139, 168),
415            selected_bg: Color::Rgb(180, 190, 254),
416            selected_fg: Color::Rgb(30, 30, 46),
417            surface: Color::Rgb(49, 50, 68),
418            surface_hover: Color::Rgb(69, 71, 90),
419            surface_text: Color::Rgb(166, 173, 200),
420            is_dark: true,
421            spacing: Spacing::new(1),
422        }
423    }
424
425    /// Nord theme — frost blue primary on polar night.
426    pub fn nord() -> Self {
427        Self {
428            primary: Color::Rgb(136, 192, 208),
429            secondary: Color::Rgb(129, 161, 193),
430            accent: Color::Rgb(180, 142, 173),
431            text: Color::Rgb(236, 239, 244),
432            text_dim: Color::Rgb(216, 222, 233),
433            border: Color::Rgb(59, 66, 82),
434            bg: Color::Rgb(46, 52, 64),
435            success: Color::Rgb(163, 190, 140),
436            warning: Color::Rgb(235, 203, 139),
437            error: Color::Rgb(191, 97, 106),
438            selected_bg: Color::Rgb(136, 192, 208),
439            selected_fg: Color::Rgb(46, 52, 64),
440            surface: Color::Rgb(59, 66, 82),
441            surface_hover: Color::Rgb(67, 76, 94),
442            surface_text: Color::Rgb(216, 222, 233),
443            is_dark: true,
444            spacing: Spacing::new(1),
445        }
446    }
447
448    /// Solarized Dark theme — blue primary on dark base.
449    pub fn solarized_dark() -> Self {
450        Self {
451            primary: Color::Rgb(38, 139, 210),
452            secondary: Color::Rgb(42, 161, 152),
453            accent: Color::Rgb(211, 54, 130),
454            text: Color::Rgb(131, 148, 150),
455            text_dim: Color::Rgb(101, 123, 131),
456            border: Color::Rgb(7, 54, 66),
457            bg: Color::Rgb(0, 43, 54),
458            success: Color::Rgb(133, 153, 0),
459            warning: Color::Rgb(181, 137, 0),
460            error: Color::Rgb(220, 50, 47),
461            selected_bg: Color::Rgb(38, 139, 210),
462            selected_fg: Color::Rgb(253, 246, 227),
463            surface: Color::Rgb(7, 54, 66),
464            surface_hover: Color::Rgb(23, 72, 85),
465            surface_text: Color::Rgb(147, 161, 161),
466            is_dark: true,
467            spacing: Spacing::new(1),
468        }
469    }
470
471    /// Solarized Light theme — warm ochre primary on light base.
472    pub fn solarized_light() -> Self {
473        Self {
474            primary: Color::Rgb(38, 139, 210),
475            secondary: Color::Rgb(42, 161, 152),
476            accent: Color::Rgb(211, 54, 130),
477            text: Color::Rgb(101, 123, 131),
478            text_dim: Color::Rgb(88, 110, 117),
479            border: Color::Rgb(238, 232, 213),
480            bg: Color::Rgb(253, 246, 227),
481            success: Color::Rgb(133, 153, 0),
482            warning: Color::Rgb(181, 137, 0),
483            error: Color::Rgb(220, 50, 47),
484            selected_bg: Color::Rgb(38, 139, 210),
485            selected_fg: Color::Rgb(253, 246, 227),
486            surface: Color::Rgb(238, 232, 213),
487            surface_hover: Color::Rgb(227, 221, 201),
488            surface_text: Color::Rgb(88, 110, 117),
489            is_dark: false,
490            spacing: Spacing::new(1),
491        }
492    }
493
494    /// Tokyo Night theme — blue primary on dark storm base.
495    pub fn tokyo_night() -> Self {
496        Self {
497            primary: Color::Rgb(122, 162, 247),
498            secondary: Color::Rgb(125, 207, 255),
499            accent: Color::Rgb(187, 154, 247),
500            text: Color::Rgb(169, 177, 214),
501            text_dim: Color::Rgb(86, 95, 137),
502            border: Color::Rgb(54, 58, 79),
503            bg: Color::Rgb(26, 27, 38),
504            success: Color::Rgb(158, 206, 106),
505            warning: Color::Rgb(224, 175, 104),
506            error: Color::Rgb(247, 118, 142),
507            selected_bg: Color::Rgb(122, 162, 247),
508            selected_fg: Color::Rgb(26, 27, 38),
509            surface: Color::Rgb(36, 40, 59),
510            surface_hover: Color::Rgb(41, 46, 66),
511            surface_text: Color::Rgb(192, 202, 245),
512            is_dark: true,
513            spacing: Spacing::new(1),
514        }
515    }
516
517    /// Gruvbox Dark theme — warm, retro tones on dark background.
518    pub fn gruvbox_dark() -> Self {
519        Self {
520            primary: Color::Rgb(215, 153, 33),
521            secondary: Color::Rgb(69, 133, 136),
522            accent: Color::Rgb(177, 98, 134),
523            text: Color::Rgb(235, 219, 178),
524            text_dim: Color::Rgb(146, 131, 116),
525            border: Color::Rgb(80, 73, 69),
526            bg: Color::Rgb(40, 40, 40),
527            success: Color::Rgb(152, 151, 26),
528            warning: Color::Rgb(250, 189, 47),
529            error: Color::Rgb(204, 36, 29),
530            selected_bg: Color::Rgb(215, 153, 33),
531            selected_fg: Color::Rgb(40, 40, 40),
532            surface: Color::Rgb(60, 56, 54),
533            surface_hover: Color::Rgb(80, 73, 69),
534            surface_text: Color::Rgb(189, 174, 147),
535            is_dark: true,
536            spacing: Spacing::new(1),
537        }
538    }
539
540    /// Compact density preset — base spacing = 1 (matches v0.19 default behavior).
541    ///
542    /// Use when terminal space is tight or you want maximum information
543    /// density. Built on [`Theme::dark()`] colors with `Spacing::new(1)`.
544    ///
545    /// # Example
546    ///
547    /// ```
548    /// use slt::Theme;
549    ///
550    /// let theme = Theme::compact();
551    /// assert_eq!(theme.spacing.xs(), 1);
552    /// ```
553    pub const fn compact() -> Self {
554        let base = Self::dark();
555        Self {
556            spacing: Spacing::new(1),
557            ..base
558        }
559    }
560
561    /// Comfortable density preset — base spacing = 2.
562    ///
563    /// Widgets use roughly twice the padding/margin of [`Theme::compact`],
564    /// improving readability in spacious terminals at the cost of fitting
565    /// less content per screen.
566    ///
567    /// # Example
568    ///
569    /// ```
570    /// use slt::Theme;
571    ///
572    /// let theme = Theme::comfortable();
573    /// assert_eq!(theme.spacing.xs(), 2);
574    /// assert_eq!(theme.spacing.sm(), 4);
575    /// ```
576    pub const fn comfortable() -> Self {
577        let base = Self::dark();
578        Self {
579            spacing: Spacing::new(2),
580            ..base
581        }
582    }
583
584    /// Spacious density preset — base spacing = 3.
585    ///
586    /// Tripled padding/margin compared to [`Theme::compact`] — best for
587    /// presentations, demos, or very wide terminals where "breathing room"
588    /// matters more than density.
589    ///
590    /// # Example
591    ///
592    /// ```
593    /// use slt::Theme;
594    ///
595    /// let theme = Theme::spacious();
596    /// assert_eq!(theme.spacing.xs(), 3);
597    /// assert_eq!(theme.spacing.sm(), 6);
598    /// ```
599    pub const fn spacious() -> Self {
600        let base = Self::dark();
601        Self {
602            spacing: Spacing::new(3),
603            ..base
604        }
605    }
606
607    /// Apply a [`Spacing`] scale on top of the current theme, returning a new theme.
608    ///
609    /// All other fields are preserved. Useful to adapt any preset (Nord,
610    /// Dracula, custom) to a new density without rebuilding the colors.
611    ///
612    /// # Example
613    ///
614    /// ```
615    /// use slt::{Spacing, Theme};
616    ///
617    /// let dense_nord = Theme::nord().with_spacing(Spacing::new(2));
618    /// assert_eq!(dense_nord.spacing.xs(), 2);
619    /// // Nord colors preserved.
620    /// assert_eq!(dense_nord.bg, Theme::nord().bg);
621    /// ```
622    pub const fn with_spacing(mut self, spacing: Spacing) -> Self {
623        self.spacing = spacing;
624        self
625    }
626
627    /// One Dark theme (Atom) — cool blues and purples on dark gray.
628    pub fn one_dark() -> Self {
629        Self {
630            primary: Color::Rgb(97, 175, 239),
631            secondary: Color::Rgb(86, 182, 194),
632            accent: Color::Rgb(198, 120, 221),
633            text: Color::Rgb(171, 178, 191),
634            text_dim: Color::Rgb(92, 99, 112),
635            border: Color::Rgb(62, 68, 81),
636            bg: Color::Rgb(40, 44, 52),
637            success: Color::Rgb(152, 195, 121),
638            warning: Color::Rgb(229, 192, 123),
639            error: Color::Rgb(224, 108, 117),
640            selected_bg: Color::Rgb(97, 175, 239),
641            selected_fg: Color::Rgb(40, 44, 52),
642            surface: Color::Rgb(50, 55, 65),
643            surface_hover: Color::Rgb(62, 68, 81),
644            surface_text: Color::Rgb(152, 159, 172),
645            is_dark: true,
646            spacing: Spacing::new(1),
647        }
648    }
649}
650
651/// Builder for creating custom themes with defaults from `Theme::dark()`.
652pub struct ThemeBuilder {
653    primary: Option<Color>,
654    secondary: Option<Color>,
655    accent: Option<Color>,
656    text: Option<Color>,
657    text_dim: Option<Color>,
658    border: Option<Color>,
659    bg: Option<Color>,
660    success: Option<Color>,
661    warning: Option<Color>,
662    error: Option<Color>,
663    selected_bg: Option<Color>,
664    selected_fg: Option<Color>,
665    surface: Option<Color>,
666    surface_hover: Option<Color>,
667    surface_text: Option<Color>,
668    is_dark: Option<bool>,
669    spacing: Option<Spacing>,
670}
671
672impl ThemeBuilder {
673    /// Set the primary color.
674    pub const fn primary(mut self, color: Color) -> Self {
675        self.primary = Some(color);
676        self
677    }
678
679    /// Set the secondary color.
680    pub const fn secondary(mut self, color: Color) -> Self {
681        self.secondary = Some(color);
682        self
683    }
684
685    /// Set the accent color.
686    pub const fn accent(mut self, color: Color) -> Self {
687        self.accent = Some(color);
688        self
689    }
690
691    /// Set the main text color.
692    pub const fn text(mut self, color: Color) -> Self {
693        self.text = Some(color);
694        self
695    }
696
697    /// Set the dimmed text color.
698    pub const fn text_dim(mut self, color: Color) -> Self {
699        self.text_dim = Some(color);
700        self
701    }
702
703    /// Set the border color.
704    pub const fn border(mut self, color: Color) -> Self {
705        self.border = Some(color);
706        self
707    }
708
709    /// Set the background color.
710    pub const fn bg(mut self, color: Color) -> Self {
711        self.bg = Some(color);
712        self
713    }
714
715    /// Set the success indicator color.
716    pub const fn success(mut self, color: Color) -> Self {
717        self.success = Some(color);
718        self
719    }
720
721    /// Set the warning indicator color.
722    pub const fn warning(mut self, color: Color) -> Self {
723        self.warning = Some(color);
724        self
725    }
726
727    /// Set the error indicator color.
728    pub const fn error(mut self, color: Color) -> Self {
729        self.error = Some(color);
730        self
731    }
732
733    /// Set the selected item background color.
734    pub const fn selected_bg(mut self, color: Color) -> Self {
735        self.selected_bg = Some(color);
736        self
737    }
738
739    /// Set the selected item foreground color.
740    pub const fn selected_fg(mut self, color: Color) -> Self {
741        self.selected_fg = Some(color);
742        self
743    }
744
745    /// Set the surface background color.
746    pub const fn surface(mut self, color: Color) -> Self {
747        self.surface = Some(color);
748        self
749    }
750
751    /// Set the surface hover color.
752    pub const fn surface_hover(mut self, color: Color) -> Self {
753        self.surface_hover = Some(color);
754        self
755    }
756
757    /// Set the surface text color.
758    pub const fn surface_text(mut self, color: Color) -> Self {
759        self.surface_text = Some(color);
760        self
761    }
762
763    /// Set the dark mode flag.
764    pub const fn is_dark(mut self, is_dark: bool) -> Self {
765        self.is_dark = Some(is_dark);
766        self
767    }
768
769    /// Set the spacing scale.
770    pub const fn spacing(mut self, spacing: Spacing) -> Self {
771        self.spacing = Some(spacing);
772        self
773    }
774
775    /// Build the theme. Unfilled fields use [`Theme::dark()`] defaults.
776    ///
777    /// `match` is used in place of [`Option::unwrap_or`] so the entire
778    /// builder chain compiles in `const` context (MSRV 1.81). All fields
779    /// involved are `Copy`, so `Some(c) => c` is a plain bit-copy.
780    pub const fn build(self) -> Theme {
781        let d = Theme::dark();
782        Theme {
783            primary: match self.primary {
784                Some(c) => c,
785                None => d.primary,
786            },
787            secondary: match self.secondary {
788                Some(c) => c,
789                None => d.secondary,
790            },
791            accent: match self.accent {
792                Some(c) => c,
793                None => d.accent,
794            },
795            text: match self.text {
796                Some(c) => c,
797                None => d.text,
798            },
799            text_dim: match self.text_dim {
800                Some(c) => c,
801                None => d.text_dim,
802            },
803            border: match self.border {
804                Some(c) => c,
805                None => d.border,
806            },
807            bg: match self.bg {
808                Some(c) => c,
809                None => d.bg,
810            },
811            success: match self.success {
812                Some(c) => c,
813                None => d.success,
814            },
815            warning: match self.warning {
816                Some(c) => c,
817                None => d.warning,
818            },
819            error: match self.error {
820                Some(c) => c,
821                None => d.error,
822            },
823            selected_bg: match self.selected_bg {
824                Some(c) => c,
825                None => d.selected_bg,
826            },
827            selected_fg: match self.selected_fg {
828                Some(c) => c,
829                None => d.selected_fg,
830            },
831            surface: match self.surface {
832                Some(c) => c,
833                None => d.surface,
834            },
835            surface_hover: match self.surface_hover {
836                Some(c) => c,
837                None => d.surface_hover,
838            },
839            surface_text: match self.surface_text {
840                Some(c) => c,
841                None => d.surface_text,
842            },
843            is_dark: match self.is_dark {
844                Some(b) => b,
845                None => d.is_dark,
846            },
847            spacing: match self.spacing {
848                Some(s) => s,
849                None => d.spacing,
850            },
851        }
852    }
853}
854
855impl Default for Theme {
856    fn default() -> Self {
857        Self::dark()
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn theme_dark_preset_builds() {
867        let t = Theme::dark();
868        assert_eq!(t.primary, Color::Cyan);
869        assert!(t.is_dark);
870    }
871
872    #[test]
873    fn theme_light_preset_builds() {
874        let t = Theme::light();
875        assert_eq!(t.selected_fg, Color::White);
876        assert!(!t.is_dark);
877    }
878
879    #[test]
880    fn theme_dracula_preset_builds() {
881        let t = Theme::dracula();
882        assert_eq!(t.bg, Color::Rgb(40, 42, 54));
883        assert!(t.is_dark);
884    }
885
886    #[test]
887    fn theme_catppuccin_preset_builds() {
888        let t = Theme::catppuccin();
889        assert_eq!(t.bg, Color::Rgb(30, 30, 46));
890        assert!(t.is_dark);
891    }
892
893    #[test]
894    fn theme_nord_preset_builds() {
895        let t = Theme::nord();
896        assert_eq!(t.bg, Color::Rgb(46, 52, 64));
897        assert!(t.is_dark);
898    }
899
900    #[test]
901    fn theme_solarized_dark_preset_builds() {
902        let t = Theme::solarized_dark();
903        assert_eq!(t.bg, Color::Rgb(0, 43, 54));
904        assert!(t.is_dark);
905    }
906
907    #[test]
908    fn theme_tokyo_night_preset_builds() {
909        let t = Theme::tokyo_night();
910        assert_eq!(t.bg, Color::Rgb(26, 27, 38));
911        assert!(t.is_dark);
912    }
913
914    #[test]
915    fn theme_builder_sets_primary_and_accent() {
916        let theme = Theme::builder()
917            .primary(Color::Red)
918            .accent(Color::Yellow)
919            .build();
920
921        assert_eq!(theme.primary, Color::Red);
922        assert_eq!(theme.accent, Color::Yellow);
923    }
924
925    #[test]
926    fn theme_builder_defaults_to_dark_for_unset_fields() {
927        let defaults = Theme::dark();
928        let theme = Theme::builder().primary(Color::Green).build();
929
930        assert_eq!(theme.primary, Color::Green);
931        assert_eq!(theme.secondary, defaults.secondary);
932        assert_eq!(theme.text, defaults.text);
933        assert_eq!(theme.text_dim, defaults.text_dim);
934        assert_eq!(theme.border, defaults.border);
935        assert_eq!(theme.surface_hover, defaults.surface_hover);
936        assert_eq!(theme.is_dark, defaults.is_dark);
937    }
938
939    #[test]
940    fn theme_builder_can_override_is_dark() {
941        let theme = Theme::builder().is_dark(false).build();
942        assert!(!theme.is_dark);
943    }
944
945    #[test]
946    fn theme_default_matches_dark() {
947        let default_theme = Theme::default();
948        let dark = Theme::dark();
949        assert_eq!(default_theme.primary, dark.primary);
950        assert_eq!(default_theme.bg, dark.bg);
951        assert_eq!(default_theme.is_dark, dark.is_dark);
952    }
953
954    #[test]
955    fn theme_solarized_light_preset_builds() {
956        let t = Theme::solarized_light();
957        assert_eq!(t.bg, Color::Rgb(253, 246, 227));
958        assert!(!t.is_dark);
959    }
960
961    #[test]
962    fn theme_gruvbox_dark_preset_builds() {
963        let t = Theme::gruvbox_dark();
964        assert_eq!(t.bg, Color::Rgb(40, 40, 40));
965        assert!(t.is_dark);
966    }
967
968    #[test]
969    fn theme_one_dark_preset_builds() {
970        let t = Theme::one_dark();
971        assert_eq!(t.bg, Color::Rgb(40, 44, 52));
972        assert!(t.is_dark);
973    }
974
975    // --- regression: issue #106 Nord/Solarized text_dim collides with border ---
976
977    #[test]
978    fn theme_text_dim_ne_border() {
979        for theme in [
980            Theme::nord(),
981            Theme::solarized_dark(),
982            Theme::solarized_light(),
983        ] {
984            assert_ne!(theme.text_dim, theme.border, "text_dim == border in theme");
985        }
986    }
987
988    #[test]
989    fn spacing_scale_values() {
990        let sp = Spacing::new(1);
991        assert_eq!(sp.none(), 0);
992        assert_eq!(sp.xs(), 1);
993        assert_eq!(sp.sm(), 2);
994        assert_eq!(sp.md(), 3);
995        assert_eq!(sp.lg(), 4);
996        assert_eq!(sp.xl(), 6);
997        assert_eq!(sp.xxl(), 8);
998    }
999
1000    #[test]
1001    fn spacing_custom_base() {
1002        let sp = Spacing::new(2);
1003        assert_eq!(sp.xs(), 2);
1004        assert_eq!(sp.sm(), 4);
1005        assert_eq!(sp.md(), 6);
1006    }
1007
1008    #[test]
1009    fn theme_color_resolve_maps_correctly() {
1010        let t = Theme::dark();
1011        assert_eq!(t.resolve(ThemeColor::Primary), t.primary);
1012        assert_eq!(t.resolve(ThemeColor::Secondary), t.secondary);
1013        assert_eq!(t.resolve(ThemeColor::Accent), t.accent);
1014        assert_eq!(t.resolve(ThemeColor::Text), t.text);
1015        assert_eq!(t.resolve(ThemeColor::TextDim), t.text_dim);
1016        assert_eq!(t.resolve(ThemeColor::Border), t.border);
1017        assert_eq!(t.resolve(ThemeColor::Bg), t.bg);
1018        assert_eq!(t.resolve(ThemeColor::Success), t.success);
1019        assert_eq!(t.resolve(ThemeColor::Warning), t.warning);
1020        assert_eq!(t.resolve(ThemeColor::Error), t.error);
1021        assert_eq!(t.resolve(ThemeColor::SelectedBg), t.selected_bg);
1022        assert_eq!(t.resolve(ThemeColor::SelectedFg), t.selected_fg);
1023        assert_eq!(t.resolve(ThemeColor::Surface), t.surface);
1024        assert_eq!(t.resolve(ThemeColor::SurfaceHover), t.surface_hover);
1025        assert_eq!(t.resolve(ThemeColor::SurfaceText), t.surface_text);
1026    }
1027
1028    #[test]
1029    fn theme_color_aliases_resolve_to_primary() {
1030        let t = Theme::dark();
1031        assert_eq!(t.resolve(ThemeColor::Info), t.primary);
1032        assert_eq!(t.resolve(ThemeColor::Link), t.primary);
1033        assert_eq!(t.resolve(ThemeColor::FocusRing), t.primary);
1034    }
1035
1036    #[test]
1037    fn theme_color_custom_passes_through() {
1038        let t = Theme::dark();
1039        let custom = Color::Rgb(42, 42, 42);
1040        assert_eq!(t.resolve(ThemeColor::Custom(custom)), custom);
1041    }
1042
1043    #[test]
1044    fn theme_builder_spacing() {
1045        let sp = Spacing::new(3);
1046        let theme = Theme::builder().spacing(sp).build();
1047        assert_eq!(theme.spacing, sp);
1048    }
1049
1050    #[test]
1051    fn theme_contrast_text_on_dark_bg() {
1052        let t = Theme::dark();
1053        let fg = t.contrast_text_on(Color::Rgb(0, 0, 0));
1054        assert_eq!(fg, Color::Rgb(255, 255, 255));
1055    }
1056
1057    #[test]
1058    fn theme_contrast_text_on_light_bg() {
1059        let t = Theme::dark();
1060        let fg = t.contrast_text_on(Color::Rgb(255, 255, 255));
1061        assert_eq!(fg, Color::Rgb(0, 0, 0));
1062    }
1063
1064    // --- regression: issue #109 ThemeBuilder methods are const fn ---
1065
1066    /// If this `const` evaluation ever fails to compile, a setter on
1067    /// `ThemeBuilder` was accidentally demoted to a non-const fn.
1068    const _CONST_THEME: Theme = Theme::builder()
1069        .primary(Color::Rgb(255, 100, 100))
1070        .bg(Color::Rgb(20, 20, 20))
1071        .is_dark(true)
1072        .spacing(Spacing::new(2))
1073        .build();
1074
1075    #[test]
1076    fn theme_builder_const_eval() {
1077        // Set fields take the override.
1078        assert_eq!(_CONST_THEME.primary, Color::Rgb(255, 100, 100));
1079        assert_eq!(_CONST_THEME.bg, Color::Rgb(20, 20, 20));
1080        assert_eq!(_CONST_THEME.spacing, Spacing::new(2));
1081        // Unset fields fall back to Theme::dark() — verifies the const
1082        // `match` arms in build() reproduce the unwrap_or semantics.
1083        let dark = Theme::dark();
1084        assert_eq!(_CONST_THEME.text, dark.text);
1085        assert_eq!(_CONST_THEME.border, dark.border);
1086        assert_eq!(_CONST_THEME.surface, dark.surface);
1087    }
1088
1089    // --- regression: issue #110 builder_from / light_builder ---
1090
1091    #[test]
1092    fn builder_from_preserves_base_fields() {
1093        // builder_from(base) must seed every field from `base`, so an
1094        // empty .build() yields a theme equal to `base`.
1095        let nord = Theme::nord();
1096        let t = Theme::builder_from(nord).build();
1097        assert_eq!(t.primary, nord.primary);
1098        assert_eq!(t.secondary, nord.secondary);
1099        assert_eq!(t.accent, nord.accent);
1100        assert_eq!(t.text, nord.text);
1101        assert_eq!(t.text_dim, nord.text_dim);
1102        assert_eq!(t.border, nord.border);
1103        assert_eq!(t.bg, nord.bg);
1104        assert_eq!(t.success, nord.success);
1105        assert_eq!(t.warning, nord.warning);
1106        assert_eq!(t.error, nord.error);
1107        assert_eq!(t.selected_bg, nord.selected_bg);
1108        assert_eq!(t.selected_fg, nord.selected_fg);
1109        assert_eq!(t.surface, nord.surface);
1110        assert_eq!(t.surface_hover, nord.surface_hover);
1111        assert_eq!(t.surface_text, nord.surface_text);
1112        assert_eq!(t.is_dark, nord.is_dark);
1113        assert_eq!(t.spacing, nord.spacing);
1114    }
1115
1116    #[test]
1117    fn builder_from_overrides_only_specified_fields() {
1118        let t = Theme::builder_from(Theme::nord())
1119            .primary(Color::Rgb(255, 0, 0))
1120            .build();
1121        // Only primary changed; all other Nord fields preserved.
1122        assert_eq!(t.primary, Color::Rgb(255, 0, 0));
1123        assert_eq!(t.bg, Theme::nord().bg);
1124        assert_eq!(t.text, Theme::nord().text);
1125        assert_ne!(t.primary, Theme::nord().primary);
1126    }
1127
1128    #[test]
1129    fn light_builder_starts_from_light_preset() {
1130        // Without builder_from, a plain Theme::builder() would inherit
1131        // dark() defaults — bg == Color::Reset — even when callers want
1132        // a light-base theme. light_builder() must keep light defaults.
1133        let t = Theme::light_builder()
1134            .primary(Color::Rgb(0, 100, 200))
1135            .build();
1136        let light = Theme::light();
1137        assert_eq!(t.primary, Color::Rgb(0, 100, 200));
1138        assert_eq!(t.bg, light.bg);
1139        assert_eq!(t.text, light.text);
1140        assert_eq!(t.surface, light.surface);
1141        assert!(!t.is_dark);
1142    }
1143
1144    /// const-evaluation regression for builder_from + light_builder.
1145    const _CONST_LIGHT: Theme = Theme::light_builder().primary(Color::Rgb(1, 2, 3)).build();
1146
1147    #[test]
1148    fn light_builder_is_const_evaluable() {
1149        assert_eq!(_CONST_LIGHT.primary, Color::Rgb(1, 2, 3));
1150        assert_eq!(_CONST_LIGHT.bg, Theme::light().bg);
1151        const { assert!(!_CONST_LIGHT.is_dark) };
1152    }
1153}