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/// Per-token-category colors for syntax highlighting, resolved through the [`Theme`].
134///
135/// Tree-sitter highlight capture names are mapped onto these nine categories so
136/// that code blocks adopt the active theme's palette instead of a hardcoded
137/// One Dark scheme. Neutral tokens (comments, operators, plain variables,
138/// punctuation) continue to resolve through [`Theme::text`]/[`Theme::text_dim`]
139/// and are not represented here.
140///
141/// # Example
142///
143/// ```
144/// use slt::{SyntaxPalette, Theme};
145///
146/// let theme = Theme::nord();
147/// // `keyword`-class tokens render with Nord's mauve, not One Dark purple.
148/// let kw: slt::Color = theme.syntax.keyword;
149/// assert_eq!(kw, SyntaxPalette::nord().keyword);
150/// ```
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub struct SyntaxPalette {
154    /// Keywords (`fn`, `let`, `if`, `return`, ...).
155    pub keyword: Color,
156    /// String and char literals.
157    pub string: Color,
158    /// Numeric literals.
159    pub number: Color,
160    /// Function and method names.
161    pub function: Color,
162    /// Macro invocations (e.g. `println!`).
163    pub macro_: Color,
164    /// Type names, builtins, and constructors.
165    pub type_: Color,
166    /// Constants, builtin constants, and attribute values.
167    pub constant: Color,
168    /// Object/struct properties and fields.
169    pub property: Color,
170    /// Markup tags and tag-like builtin variables.
171    pub tag: Color,
172}
173
174impl SyntaxPalette {
175    /// One Dark (Atom) palette — the historical default used by
176    /// [`Theme::dark`] and [`Theme::one_dark`].
177    pub const fn one_dark() -> Self {
178        Self {
179            keyword: Color::Rgb(198, 120, 221),
180            string: Color::Rgb(152, 195, 121),
181            number: Color::Rgb(209, 154, 102),
182            function: Color::Rgb(97, 175, 239),
183            macro_: Color::Rgb(86, 182, 194),
184            type_: Color::Rgb(229, 192, 123),
185            constant: Color::Rgb(209, 154, 102),
186            property: Color::Rgb(97, 175, 239),
187            tag: Color::Rgb(224, 108, 117),
188        }
189    }
190
191    /// One Light palette — the historical light-variant default used by
192    /// [`Theme::light`] and [`Theme::solarized_light`].
193    pub const fn one_light() -> Self {
194        Self {
195            keyword: Color::Rgb(166, 38, 164),
196            string: Color::Rgb(80, 161, 79),
197            number: Color::Rgb(152, 104, 1),
198            function: Color::Rgb(64, 120, 242),
199            macro_: Color::Rgb(1, 132, 188),
200            type_: Color::Rgb(152, 104, 1),
201            constant: Color::Rgb(152, 104, 1),
202            property: Color::Rgb(64, 120, 242),
203            tag: Color::Rgb(166, 38, 164),
204        }
205    }
206
207    /// Dracula palette.
208    pub const fn dracula() -> Self {
209        Self {
210            keyword: Color::Rgb(255, 121, 198),
211            string: Color::Rgb(241, 250, 140),
212            number: Color::Rgb(189, 147, 249),
213            function: Color::Rgb(80, 250, 123),
214            macro_: Color::Rgb(139, 233, 253),
215            type_: Color::Rgb(139, 233, 253),
216            constant: Color::Rgb(189, 147, 249),
217            property: Color::Rgb(102, 217, 239),
218            tag: Color::Rgb(255, 121, 198),
219        }
220    }
221
222    /// Catppuccin Mocha palette.
223    pub const fn catppuccin() -> Self {
224        Self {
225            keyword: Color::Rgb(203, 166, 247),
226            string: Color::Rgb(166, 227, 161),
227            number: Color::Rgb(250, 179, 135),
228            function: Color::Rgb(137, 180, 250),
229            macro_: Color::Rgb(245, 194, 231),
230            type_: Color::Rgb(249, 226, 175),
231            constant: Color::Rgb(250, 179, 135),
232            property: Color::Rgb(137, 220, 235),
233            tag: Color::Rgb(243, 139, 168),
234        }
235    }
236
237    /// Nord palette.
238    pub const fn nord() -> Self {
239        Self {
240            keyword: Color::Rgb(180, 142, 173),
241            string: Color::Rgb(163, 190, 140),
242            number: Color::Rgb(180, 142, 173),
243            function: Color::Rgb(136, 192, 208),
244            macro_: Color::Rgb(143, 188, 187),
245            type_: Color::Rgb(143, 188, 187),
246            constant: Color::Rgb(208, 135, 112),
247            property: Color::Rgb(129, 161, 193),
248            tag: Color::Rgb(191, 97, 106),
249        }
250    }
251
252    /// Solarized Dark palette.
253    pub const fn solarized_dark() -> Self {
254        Self {
255            keyword: Color::Rgb(133, 153, 0),
256            string: Color::Rgb(42, 161, 152),
257            number: Color::Rgb(211, 54, 130),
258            function: Color::Rgb(38, 139, 210),
259            macro_: Color::Rgb(203, 75, 22),
260            type_: Color::Rgb(181, 137, 0),
261            constant: Color::Rgb(211, 54, 130),
262            property: Color::Rgb(38, 139, 210),
263            tag: Color::Rgb(220, 50, 47),
264        }
265    }
266
267    /// Solarized Light palette.
268    pub const fn solarized_light() -> Self {
269        Self {
270            keyword: Color::Rgb(133, 153, 0),
271            string: Color::Rgb(42, 161, 152),
272            number: Color::Rgb(211, 54, 130),
273            function: Color::Rgb(38, 139, 210),
274            macro_: Color::Rgb(203, 75, 22),
275            type_: Color::Rgb(181, 137, 0),
276            constant: Color::Rgb(211, 54, 130),
277            property: Color::Rgb(38, 139, 210),
278            tag: Color::Rgb(220, 50, 47),
279        }
280    }
281
282    /// Tokyo Night palette.
283    pub const fn tokyo_night() -> Self {
284        Self {
285            keyword: Color::Rgb(187, 154, 247),
286            string: Color::Rgb(158, 206, 106),
287            number: Color::Rgb(255, 158, 100),
288            function: Color::Rgb(122, 162, 247),
289            macro_: Color::Rgb(125, 207, 255),
290            type_: Color::Rgb(43, 178, 187),
291            constant: Color::Rgb(255, 158, 100),
292            property: Color::Rgb(115, 218, 202),
293            tag: Color::Rgb(247, 118, 142),
294        }
295    }
296
297    /// Gruvbox Dark palette.
298    pub const fn gruvbox_dark() -> Self {
299        Self {
300            keyword: Color::Rgb(251, 73, 52),
301            string: Color::Rgb(184, 187, 38),
302            number: Color::Rgb(211, 134, 155),
303            function: Color::Rgb(184, 187, 38),
304            macro_: Color::Rgb(142, 192, 124),
305            type_: Color::Rgb(250, 189, 47),
306            constant: Color::Rgb(211, 134, 155),
307            property: Color::Rgb(131, 165, 152),
308            tag: Color::Rgb(251, 73, 52),
309        }
310    }
311}
312
313impl Default for SyntaxPalette {
314    fn default() -> Self {
315        Self::one_dark()
316    }
317}
318
319/// A color theme that flows through all widgets automatically.
320///
321/// Construct with [`Theme::dark()`] or [`Theme::light()`], or use
322/// [`Theme::builder()`] for custom themes. Pass via [`crate::RunConfig`]
323/// and every widget picks up the colors without any extra wiring.
324///
325/// # Example
326///
327/// ```
328/// use slt::{Color, Theme};
329///
330/// let theme = Theme::builder()
331///     .primary(Color::Rgb(255, 107, 107))
332///     .build();
333/// ```
334#[non_exhaustive]
335#[derive(Debug, Clone, Copy)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337#[cfg_attr(feature = "serde", serde(default))]
338pub struct Theme {
339    /// Primary accent color, used for focused borders and highlights.
340    pub primary: Color,
341    /// Secondary accent color, used for less prominent highlights.
342    pub secondary: Color,
343    /// Accent color for decorative elements.
344    pub accent: Color,
345    /// Default foreground text color.
346    pub text: Color,
347    /// Dimmed text color for secondary labels and hints.
348    pub text_dim: Color,
349    /// Border color for unfocused containers.
350    pub border: Color,
351    /// Background color. Typically [`Color::Reset`] to inherit the terminal background.
352    pub bg: Color,
353    /// Color for success states (e.g., toast notifications).
354    pub success: Color,
355    /// Color for warning states.
356    pub warning: Color,
357    /// Color for error states.
358    pub error: Color,
359    /// Background color for selected list/table rows.
360    pub selected_bg: Color,
361    /// Foreground color for selected list/table rows.
362    pub selected_fg: Color,
363    /// Subtle surface color for card backgrounds and elevated containers.
364    pub surface: Color,
365    /// Hover/active surface color, one step brighter than `surface`.
366    ///
367    /// Used for interactive element hover states. Should be visually
368    /// distinguishable from both `surface` and `border`.
369    pub surface_hover: Color,
370    /// Secondary text color guaranteed readable on `surface` backgrounds.
371    ///
372    /// Use this instead of `text_dim` when rendering on `surface`-colored
373    /// containers. `text_dim` is tuned for the main `bg`; on `surface` it
374    /// may lack contrast.
375    pub surface_text: Color,
376    /// Whether this theme is a dark theme. Used to initialize dark mode in Context.
377    pub is_dark: bool,
378    /// Spacing scale for consistent padding, margin, and gap values.
379    pub spacing: Spacing,
380    /// Per-token-category colors used to drive syntax highlighting.
381    pub syntax: SyntaxPalette,
382}
383
384impl Theme {
385    /// Resolve a [`ThemeColor`] token to a concrete [`Color`].
386    pub fn resolve(&self, token: ThemeColor) -> Color {
387        match token {
388            ThemeColor::Primary => self.primary,
389            ThemeColor::Secondary => self.secondary,
390            ThemeColor::Accent => self.accent,
391            ThemeColor::Text => self.text,
392            ThemeColor::TextDim => self.text_dim,
393            ThemeColor::Border => self.border,
394            ThemeColor::Bg => self.bg,
395            ThemeColor::Success => self.success,
396            ThemeColor::Warning => self.warning,
397            ThemeColor::Error => self.error,
398            ThemeColor::SelectedBg => self.selected_bg,
399            ThemeColor::SelectedFg => self.selected_fg,
400            ThemeColor::Surface => self.surface,
401            ThemeColor::SurfaceHover => self.surface_hover,
402            ThemeColor::SurfaceText => self.surface_text,
403            ThemeColor::Info | ThemeColor::Link | ThemeColor::FocusRing => self.primary,
404            ThemeColor::Custom(c) => c,
405        }
406    }
407
408    /// Return a text color with guaranteed contrast against the given background.
409    ///
410    /// Delegates to [`Color::contrast_fg`].
411    pub fn contrast_text_on(&self, bg: Color) -> Color {
412        Color::contrast_fg(bg)
413    }
414
415    /// Blend a color with the theme's background at the given alpha.
416    ///
417    /// `alpha = 0.0` returns `self.bg`, `alpha = 1.0` returns `color` unchanged.
418    pub fn overlay(&self, color: Color, alpha: f32) -> Color {
419        color.blend(self.bg, alpha)
420    }
421
422    /// Create a dark theme with cyan primary and white text.
423    pub const fn dark() -> Self {
424        Self {
425            primary: Color::Cyan,
426            secondary: Color::Blue,
427            accent: Color::Magenta,
428            text: Color::White,
429            text_dim: Color::Indexed(245),
430            border: Color::Indexed(240),
431            bg: Color::Reset,
432            success: Color::Green,
433            warning: Color::Yellow,
434            error: Color::Red,
435            selected_bg: Color::Cyan,
436            selected_fg: Color::Black,
437            surface: Color::Indexed(236),
438            surface_hover: Color::Indexed(238),
439            surface_text: Color::Indexed(250),
440            is_dark: true,
441            spacing: Spacing::new(1),
442            syntax: SyntaxPalette::one_dark(),
443        }
444    }
445
446    /// Create a light theme with high-contrast dark text on light backgrounds.
447    pub const fn light() -> Self {
448        Self {
449            primary: Color::Rgb(37, 99, 235),
450            secondary: Color::Rgb(14, 116, 144),
451            accent: Color::Rgb(147, 51, 234),
452            text: Color::Rgb(15, 23, 42),
453            text_dim: Color::Rgb(100, 116, 139),
454            border: Color::Rgb(203, 213, 225),
455            bg: Color::Rgb(248, 250, 252),
456            success: Color::Rgb(22, 163, 74),
457            warning: Color::Rgb(202, 138, 4),
458            error: Color::Rgb(220, 38, 38),
459            selected_bg: Color::Rgb(37, 99, 235),
460            selected_fg: Color::White,
461            surface: Color::Rgb(241, 245, 249),
462            surface_hover: Color::Rgb(226, 232, 240),
463            surface_text: Color::Rgb(51, 65, 85),
464            is_dark: false,
465            spacing: Spacing::new(1),
466            syntax: SyntaxPalette::one_light(),
467        }
468    }
469
470    /// Create a [`ThemeBuilder`] for configuring a custom theme.
471    ///
472    /// Unset fields fall back to [`Theme::dark()`] defaults. Use
473    /// [`Theme::builder_from`] to start from a different base, or
474    /// [`Theme::light_builder`] for a light-base shorthand.
475    ///
476    /// # Example
477    ///
478    /// ```
479    /// use slt::{Color, Theme};
480    ///
481    /// let theme = Theme::builder()
482    ///     .primary(Color::Rgb(255, 107, 107))
483    ///     .accent(Color::Cyan)
484    ///     .build();
485    /// ```
486    pub const fn builder() -> ThemeBuilder {
487        ThemeBuilder {
488            primary: None,
489            secondary: None,
490            accent: None,
491            text: None,
492            text_dim: None,
493            border: None,
494            bg: None,
495            success: None,
496            warning: None,
497            error: None,
498            selected_bg: None,
499            selected_fg: None,
500            surface: None,
501            surface_hover: None,
502            surface_text: None,
503            is_dark: None,
504            spacing: None,
505            syntax: None,
506        }
507    }
508
509    /// Create a [`ThemeBuilder`] pre-filled with every field from `base`.
510    ///
511    /// Only fields explicitly overridden via builder methods will differ
512    /// from `base`; unset fields keep `base`'s value (rather than falling
513    /// back to [`Theme::dark()`] defaults as plain [`Theme::builder`]
514    /// does). Useful for deriving variants from any preset.
515    ///
516    /// # Example
517    ///
518    /// ```
519    /// use slt::{Color, Theme};
520    ///
521    /// // Nord variant: keep all Nord colors but override primary.
522    /// let custom_nord = Theme::builder_from(Theme::nord())
523    ///     .primary(Color::Rgb(255, 0, 0))
524    ///     .build();
525    /// assert_eq!(custom_nord.bg, Theme::nord().bg);
526    /// assert_eq!(custom_nord.primary, Color::Rgb(255, 0, 0));
527    /// ```
528    pub const fn builder_from(base: Theme) -> ThemeBuilder {
529        ThemeBuilder {
530            primary: Some(base.primary),
531            secondary: Some(base.secondary),
532            accent: Some(base.accent),
533            text: Some(base.text),
534            text_dim: Some(base.text_dim),
535            border: Some(base.border),
536            bg: Some(base.bg),
537            success: Some(base.success),
538            warning: Some(base.warning),
539            error: Some(base.error),
540            selected_bg: Some(base.selected_bg),
541            selected_fg: Some(base.selected_fg),
542            surface: Some(base.surface),
543            surface_hover: Some(base.surface_hover),
544            surface_text: Some(base.surface_text),
545            is_dark: Some(base.is_dark),
546            spacing: Some(base.spacing),
547            syntax: Some(base.syntax),
548        }
549    }
550
551    /// Convenience: builder pre-filled with all [`Theme::light()`] fields.
552    ///
553    /// Equivalent to `Theme::builder_from(Theme::light())`.
554    ///
555    /// # Example
556    ///
557    /// ```
558    /// use slt::{Color, Theme};
559    ///
560    /// let my_light = Theme::light_builder()
561    ///     .primary(Color::Rgb(0, 100, 200))
562    ///     .build();
563    /// assert!(!my_light.is_dark);
564    /// assert_eq!(my_light.primary, Color::Rgb(0, 100, 200));
565    /// // bg stays light (not dark()'s Reset)
566    /// assert_eq!(my_light.bg, Theme::light().bg);
567    /// ```
568    pub const fn light_builder() -> ThemeBuilder {
569        Self::builder_from(Self::light())
570    }
571
572    /// Dracula theme — purple primary on dark gray.
573    pub fn dracula() -> Self {
574        Self {
575            primary: Color::Rgb(189, 147, 249),
576            secondary: Color::Rgb(139, 233, 253),
577            accent: Color::Rgb(255, 121, 198),
578            text: Color::Rgb(248, 248, 242),
579            text_dim: Color::Rgb(98, 114, 164),
580            border: Color::Rgb(68, 71, 90),
581            bg: Color::Rgb(40, 42, 54),
582            success: Color::Rgb(80, 250, 123),
583            warning: Color::Rgb(241, 250, 140),
584            error: Color::Rgb(255, 85, 85),
585            selected_bg: Color::Rgb(189, 147, 249),
586            selected_fg: Color::Rgb(40, 42, 54),
587            surface: Color::Rgb(68, 71, 90),
588            surface_hover: Color::Rgb(98, 100, 120),
589            surface_text: Color::Rgb(191, 194, 210),
590            is_dark: true,
591            spacing: Spacing::new(1),
592            syntax: SyntaxPalette::dracula(),
593        }
594    }
595
596    /// Catppuccin Mocha theme — lavender primary on dark base.
597    pub fn catppuccin() -> Self {
598        Self {
599            primary: Color::Rgb(180, 190, 254),
600            secondary: Color::Rgb(137, 180, 250),
601            accent: Color::Rgb(245, 194, 231),
602            text: Color::Rgb(205, 214, 244),
603            text_dim: Color::Rgb(127, 132, 156),
604            border: Color::Rgb(88, 91, 112),
605            bg: Color::Rgb(30, 30, 46),
606            success: Color::Rgb(166, 227, 161),
607            warning: Color::Rgb(249, 226, 175),
608            error: Color::Rgb(243, 139, 168),
609            selected_bg: Color::Rgb(180, 190, 254),
610            selected_fg: Color::Rgb(30, 30, 46),
611            surface: Color::Rgb(49, 50, 68),
612            surface_hover: Color::Rgb(69, 71, 90),
613            surface_text: Color::Rgb(166, 173, 200),
614            is_dark: true,
615            spacing: Spacing::new(1),
616            syntax: SyntaxPalette::catppuccin(),
617        }
618    }
619
620    /// Nord theme — frost blue primary on polar night.
621    pub fn nord() -> Self {
622        Self {
623            primary: Color::Rgb(136, 192, 208),
624            secondary: Color::Rgb(129, 161, 193),
625            accent: Color::Rgb(180, 142, 173),
626            text: Color::Rgb(236, 239, 244),
627            text_dim: Color::Rgb(216, 222, 233),
628            border: Color::Rgb(59, 66, 82),
629            bg: Color::Rgb(46, 52, 64),
630            success: Color::Rgb(163, 190, 140),
631            warning: Color::Rgb(235, 203, 139),
632            error: Color::Rgb(191, 97, 106),
633            selected_bg: Color::Rgb(136, 192, 208),
634            selected_fg: Color::Rgb(46, 52, 64),
635            surface: Color::Rgb(59, 66, 82),
636            surface_hover: Color::Rgb(67, 76, 94),
637            surface_text: Color::Rgb(216, 222, 233),
638            is_dark: true,
639            spacing: Spacing::new(1),
640            syntax: SyntaxPalette::nord(),
641        }
642    }
643
644    /// Solarized Dark theme — blue primary on dark base.
645    pub fn solarized_dark() -> Self {
646        Self {
647            primary: Color::Rgb(38, 139, 210),
648            secondary: Color::Rgb(42, 161, 152),
649            accent: Color::Rgb(211, 54, 130),
650            text: Color::Rgb(131, 148, 150),
651            text_dim: Color::Rgb(101, 123, 131),
652            border: Color::Rgb(7, 54, 66),
653            bg: Color::Rgb(0, 43, 54),
654            success: Color::Rgb(133, 153, 0),
655            warning: Color::Rgb(181, 137, 0),
656            error: Color::Rgb(220, 50, 47),
657            selected_bg: Color::Rgb(38, 139, 210),
658            selected_fg: Color::Rgb(253, 246, 227),
659            surface: Color::Rgb(7, 54, 66),
660            surface_hover: Color::Rgb(23, 72, 85),
661            surface_text: Color::Rgb(147, 161, 161),
662            is_dark: true,
663            spacing: Spacing::new(1),
664            syntax: SyntaxPalette::solarized_dark(),
665        }
666    }
667
668    /// Solarized Light theme — warm ochre primary on light base.
669    pub fn solarized_light() -> Self {
670        Self {
671            primary: Color::Rgb(38, 139, 210),
672            secondary: Color::Rgb(42, 161, 152),
673            accent: Color::Rgb(211, 54, 130),
674            text: Color::Rgb(101, 123, 131),
675            text_dim: Color::Rgb(88, 110, 117),
676            border: Color::Rgb(238, 232, 213),
677            bg: Color::Rgb(253, 246, 227),
678            success: Color::Rgb(133, 153, 0),
679            warning: Color::Rgb(181, 137, 0),
680            error: Color::Rgb(220, 50, 47),
681            selected_bg: Color::Rgb(38, 139, 210),
682            selected_fg: Color::Rgb(253, 246, 227),
683            surface: Color::Rgb(238, 232, 213),
684            surface_hover: Color::Rgb(227, 221, 201),
685            surface_text: Color::Rgb(88, 110, 117),
686            is_dark: false,
687            spacing: Spacing::new(1),
688            syntax: SyntaxPalette::solarized_light(),
689        }
690    }
691
692    /// Tokyo Night theme — blue primary on dark storm base.
693    pub fn tokyo_night() -> Self {
694        Self {
695            primary: Color::Rgb(122, 162, 247),
696            secondary: Color::Rgb(125, 207, 255),
697            accent: Color::Rgb(187, 154, 247),
698            text: Color::Rgb(169, 177, 214),
699            text_dim: Color::Rgb(86, 95, 137),
700            border: Color::Rgb(54, 58, 79),
701            bg: Color::Rgb(26, 27, 38),
702            success: Color::Rgb(158, 206, 106),
703            warning: Color::Rgb(224, 175, 104),
704            error: Color::Rgb(247, 118, 142),
705            selected_bg: Color::Rgb(122, 162, 247),
706            selected_fg: Color::Rgb(26, 27, 38),
707            surface: Color::Rgb(36, 40, 59),
708            surface_hover: Color::Rgb(41, 46, 66),
709            surface_text: Color::Rgb(192, 202, 245),
710            is_dark: true,
711            spacing: Spacing::new(1),
712            syntax: SyntaxPalette::tokyo_night(),
713        }
714    }
715
716    /// Gruvbox Dark theme — warm, retro tones on dark background.
717    pub fn gruvbox_dark() -> Self {
718        Self {
719            primary: Color::Rgb(215, 153, 33),
720            secondary: Color::Rgb(69, 133, 136),
721            accent: Color::Rgb(177, 98, 134),
722            text: Color::Rgb(235, 219, 178),
723            text_dim: Color::Rgb(146, 131, 116),
724            border: Color::Rgb(80, 73, 69),
725            bg: Color::Rgb(40, 40, 40),
726            success: Color::Rgb(152, 151, 26),
727            warning: Color::Rgb(250, 189, 47),
728            error: Color::Rgb(204, 36, 29),
729            selected_bg: Color::Rgb(215, 153, 33),
730            selected_fg: Color::Rgb(40, 40, 40),
731            surface: Color::Rgb(60, 56, 54),
732            surface_hover: Color::Rgb(80, 73, 69),
733            surface_text: Color::Rgb(189, 174, 147),
734            is_dark: true,
735            spacing: Spacing::new(1),
736            syntax: SyntaxPalette::gruvbox_dark(),
737        }
738    }
739
740    /// Compact density preset — base spacing = 1 (matches v0.19 default behavior).
741    ///
742    /// Use when terminal space is tight or you want maximum information
743    /// density. Built on [`Theme::dark()`] colors with `Spacing::new(1)`.
744    ///
745    /// # Example
746    ///
747    /// ```
748    /// use slt::Theme;
749    ///
750    /// let theme = Theme::compact();
751    /// assert_eq!(theme.spacing.xs(), 1);
752    /// ```
753    pub const fn compact() -> Self {
754        let base = Self::dark();
755        Self {
756            spacing: Spacing::new(1),
757            ..base
758        }
759    }
760
761    /// Comfortable density preset — base spacing = 2.
762    ///
763    /// Widgets use roughly twice the padding/margin of [`Theme::compact`],
764    /// improving readability in spacious terminals at the cost of fitting
765    /// less content per screen.
766    ///
767    /// # Example
768    ///
769    /// ```
770    /// use slt::Theme;
771    ///
772    /// let theme = Theme::comfortable();
773    /// assert_eq!(theme.spacing.xs(), 2);
774    /// assert_eq!(theme.spacing.sm(), 4);
775    /// ```
776    pub const fn comfortable() -> Self {
777        let base = Self::dark();
778        Self {
779            spacing: Spacing::new(2),
780            ..base
781        }
782    }
783
784    /// Spacious density preset — base spacing = 3.
785    ///
786    /// Tripled padding/margin compared to [`Theme::compact`] — best for
787    /// presentations, demos, or very wide terminals where "breathing room"
788    /// matters more than density.
789    ///
790    /// # Example
791    ///
792    /// ```
793    /// use slt::Theme;
794    ///
795    /// let theme = Theme::spacious();
796    /// assert_eq!(theme.spacing.xs(), 3);
797    /// assert_eq!(theme.spacing.sm(), 6);
798    /// ```
799    pub const fn spacious() -> Self {
800        let base = Self::dark();
801        Self {
802            spacing: Spacing::new(3),
803            ..base
804        }
805    }
806
807    /// Apply a [`Spacing`] scale on top of the current theme, returning a new theme.
808    ///
809    /// All other fields are preserved. Useful to adapt any preset (Nord,
810    /// Dracula, custom) to a new density without rebuilding the colors.
811    ///
812    /// # Example
813    ///
814    /// ```
815    /// use slt::{Spacing, Theme};
816    ///
817    /// let dense_nord = Theme::nord().with_spacing(Spacing::new(2));
818    /// assert_eq!(dense_nord.spacing.xs(), 2);
819    /// // Nord colors preserved.
820    /// assert_eq!(dense_nord.bg, Theme::nord().bg);
821    /// ```
822    pub const fn with_spacing(mut self, spacing: Spacing) -> Self {
823        self.spacing = spacing;
824        self
825    }
826
827    /// One Dark theme (Atom) — cool blues and purples on dark gray.
828    pub fn one_dark() -> Self {
829        Self {
830            primary: Color::Rgb(97, 175, 239),
831            secondary: Color::Rgb(86, 182, 194),
832            accent: Color::Rgb(198, 120, 221),
833            text: Color::Rgb(171, 178, 191),
834            text_dim: Color::Rgb(92, 99, 112),
835            border: Color::Rgb(62, 68, 81),
836            bg: Color::Rgb(40, 44, 52),
837            success: Color::Rgb(152, 195, 121),
838            warning: Color::Rgb(229, 192, 123),
839            error: Color::Rgb(224, 108, 117),
840            selected_bg: Color::Rgb(97, 175, 239),
841            selected_fg: Color::Rgb(40, 44, 52),
842            surface: Color::Rgb(50, 55, 65),
843            surface_hover: Color::Rgb(62, 68, 81),
844            surface_text: Color::Rgb(152, 159, 172),
845            is_dark: true,
846            spacing: Spacing::new(1),
847            syntax: SyntaxPalette::one_dark(),
848        }
849    }
850
851    /// Parse a [`Theme`] from a TOML document, ignoring any `[widgets]` block.
852    ///
853    /// The colors live under a top-level `[theme]` table; missing fields fall
854    /// back to [`Theme::dark()`]. Color values accept `#rrggbb`/`#rgb` hex,
855    /// named colors (`"cyan"`), or `indexed:N` palette indices. To also read
856    /// per-widget overrides, use [`ThemeFile::from_toml_str`] instead.
857    ///
858    /// # Errors
859    ///
860    /// Returns [`ThemeLoadError::Parse`] if the document is not valid TOML or
861    /// does not match the expected shape.
862    ///
863    /// # Example
864    ///
865    /// ```no_run
866    /// use slt::Theme;
867    ///
868    /// let toml = r##"
869    /// [theme]
870    /// primary = "#ff6b6b"
871    /// bg = "#1e1e2e"
872    /// is_dark = true
873    /// "##;
874    /// let theme = Theme::from_toml_str(toml).unwrap();
875    /// assert_eq!(theme.primary, slt::Color::Rgb(255, 107, 107));
876    /// ```
877    #[cfg(feature = "serde")]
878    #[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
879    pub fn from_toml_str(src: &str) -> Result<Theme, ThemeLoadError> {
880        ThemeFile::from_toml_str(src).map(|tf| tf.theme)
881    }
882
883    /// Load a [`Theme`] from a TOML file at `path`, ignoring `[widgets]`.
884    ///
885    /// Convenience over [`ThemeFile::load`] when you only need the base theme.
886    ///
887    /// # Errors
888    ///
889    /// Returns [`ThemeLoadError::Io`] if the file cannot be read, or
890    /// [`ThemeLoadError::Parse`] if it is not valid TOML.
891    ///
892    /// # Example
893    ///
894    /// ```no_run
895    /// use slt::Theme;
896    ///
897    /// let theme = Theme::load("theme.toml").unwrap();
898    /// println!("primary = {:?}", theme.primary);
899    /// ```
900    #[cfg(feature = "serde")]
901    #[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
902    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Theme, ThemeLoadError> {
903        ThemeFile::load(path).map(|tf| tf.theme)
904    }
905}
906
907/// Builder for creating custom themes with defaults from `Theme::dark()`.
908#[must_use = "ThemeBuilder does nothing until .build() is called"]
909pub struct ThemeBuilder {
910    primary: Option<Color>,
911    secondary: Option<Color>,
912    accent: Option<Color>,
913    text: Option<Color>,
914    text_dim: Option<Color>,
915    border: Option<Color>,
916    bg: Option<Color>,
917    success: Option<Color>,
918    warning: Option<Color>,
919    error: Option<Color>,
920    selected_bg: Option<Color>,
921    selected_fg: Option<Color>,
922    surface: Option<Color>,
923    surface_hover: Option<Color>,
924    surface_text: Option<Color>,
925    is_dark: Option<bool>,
926    spacing: Option<Spacing>,
927    syntax: Option<SyntaxPalette>,
928}
929
930impl ThemeBuilder {
931    /// Set the primary color.
932    pub const fn primary(mut self, color: Color) -> Self {
933        self.primary = Some(color);
934        self
935    }
936
937    /// Set the secondary color.
938    pub const fn secondary(mut self, color: Color) -> Self {
939        self.secondary = Some(color);
940        self
941    }
942
943    /// Set the accent color.
944    pub const fn accent(mut self, color: Color) -> Self {
945        self.accent = Some(color);
946        self
947    }
948
949    /// Set the main text color.
950    pub const fn text(mut self, color: Color) -> Self {
951        self.text = Some(color);
952        self
953    }
954
955    /// Set the dimmed text color.
956    pub const fn text_dim(mut self, color: Color) -> Self {
957        self.text_dim = Some(color);
958        self
959    }
960
961    /// Set the border color.
962    pub const fn border(mut self, color: Color) -> Self {
963        self.border = Some(color);
964        self
965    }
966
967    /// Set the background color.
968    pub const fn bg(mut self, color: Color) -> Self {
969        self.bg = Some(color);
970        self
971    }
972
973    /// Set the success indicator color.
974    pub const fn success(mut self, color: Color) -> Self {
975        self.success = Some(color);
976        self
977    }
978
979    /// Set the warning indicator color.
980    pub const fn warning(mut self, color: Color) -> Self {
981        self.warning = Some(color);
982        self
983    }
984
985    /// Set the error indicator color.
986    pub const fn error(mut self, color: Color) -> Self {
987        self.error = Some(color);
988        self
989    }
990
991    /// Set the selected item background color.
992    pub const fn selected_bg(mut self, color: Color) -> Self {
993        self.selected_bg = Some(color);
994        self
995    }
996
997    /// Set the selected item foreground color.
998    pub const fn selected_fg(mut self, color: Color) -> Self {
999        self.selected_fg = Some(color);
1000        self
1001    }
1002
1003    /// Set the surface background color.
1004    pub const fn surface(mut self, color: Color) -> Self {
1005        self.surface = Some(color);
1006        self
1007    }
1008
1009    /// Set the surface hover color.
1010    pub const fn surface_hover(mut self, color: Color) -> Self {
1011        self.surface_hover = Some(color);
1012        self
1013    }
1014
1015    /// Set the surface text color.
1016    pub const fn surface_text(mut self, color: Color) -> Self {
1017        self.surface_text = Some(color);
1018        self
1019    }
1020
1021    /// Set the dark mode flag.
1022    pub const fn is_dark(mut self, is_dark: bool) -> Self {
1023        self.is_dark = Some(is_dark);
1024        self
1025    }
1026
1027    /// Set the spacing scale.
1028    pub const fn spacing(mut self, spacing: Spacing) -> Self {
1029        self.spacing = Some(spacing);
1030        self
1031    }
1032
1033    /// Set the syntax-highlighting palette.
1034    pub const fn syntax(mut self, syntax: SyntaxPalette) -> Self {
1035        self.syntax = Some(syntax);
1036        self
1037    }
1038
1039    /// Build the theme. Unfilled fields use [`Theme::dark()`] defaults.
1040    ///
1041    /// `match` is used in place of [`Option::unwrap_or`] so the entire
1042    /// builder chain compiles in `const` context. All fields
1043    /// involved are `Copy`, so `Some(c) => c` is a plain bit-copy.
1044    pub const fn build(self) -> Theme {
1045        let d = Theme::dark();
1046        Theme {
1047            primary: match self.primary {
1048                Some(c) => c,
1049                None => d.primary,
1050            },
1051            secondary: match self.secondary {
1052                Some(c) => c,
1053                None => d.secondary,
1054            },
1055            accent: match self.accent {
1056                Some(c) => c,
1057                None => d.accent,
1058            },
1059            text: match self.text {
1060                Some(c) => c,
1061                None => d.text,
1062            },
1063            text_dim: match self.text_dim {
1064                Some(c) => c,
1065                None => d.text_dim,
1066            },
1067            border: match self.border {
1068                Some(c) => c,
1069                None => d.border,
1070            },
1071            bg: match self.bg {
1072                Some(c) => c,
1073                None => d.bg,
1074            },
1075            success: match self.success {
1076                Some(c) => c,
1077                None => d.success,
1078            },
1079            warning: match self.warning {
1080                Some(c) => c,
1081                None => d.warning,
1082            },
1083            error: match self.error {
1084                Some(c) => c,
1085                None => d.error,
1086            },
1087            selected_bg: match self.selected_bg {
1088                Some(c) => c,
1089                None => d.selected_bg,
1090            },
1091            selected_fg: match self.selected_fg {
1092                Some(c) => c,
1093                None => d.selected_fg,
1094            },
1095            surface: match self.surface {
1096                Some(c) => c,
1097                None => d.surface,
1098            },
1099            surface_hover: match self.surface_hover {
1100                Some(c) => c,
1101                None => d.surface_hover,
1102            },
1103            surface_text: match self.surface_text {
1104                Some(c) => c,
1105                None => d.surface_text,
1106            },
1107            is_dark: match self.is_dark {
1108                Some(b) => b,
1109                None => d.is_dark,
1110            },
1111            spacing: match self.spacing {
1112                Some(s) => s,
1113                None => d.spacing,
1114            },
1115            syntax: match self.syntax {
1116                Some(s) => s,
1117                None => d.syntax,
1118            },
1119        }
1120    }
1121}
1122
1123impl Default for Theme {
1124    fn default() -> Self {
1125        Self::dark()
1126    }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132
1133    #[test]
1134    fn theme_dark_preset_builds() {
1135        let t = Theme::dark();
1136        assert_eq!(t.primary, Color::Cyan);
1137        assert!(t.is_dark);
1138    }
1139
1140    #[test]
1141    fn dark_preset_uses_one_dark_syntax_palette() {
1142        assert_eq!(Theme::dark().syntax, SyntaxPalette::one_dark());
1143        assert_eq!(Theme::one_dark().syntax, SyntaxPalette::one_dark());
1144    }
1145
1146    #[test]
1147    fn presets_carry_distinct_syntax_palettes() {
1148        // A non-One-Dark preset must override the keyword color.
1149        assert_ne!(
1150            Theme::nord().syntax.keyword,
1151            SyntaxPalette::one_dark().keyword
1152        );
1153        assert_ne!(
1154            Theme::nord().syntax.keyword,
1155            Theme::catppuccin().syntax.keyword
1156        );
1157    }
1158
1159    #[test]
1160    fn builder_syntax_setter_overrides_palette() {
1161        let custom = SyntaxPalette {
1162            keyword: Color::Rgb(1, 2, 3),
1163            ..SyntaxPalette::one_dark()
1164        };
1165        let theme = Theme::builder().syntax(custom).build();
1166        assert_eq!(theme.syntax.keyword, Color::Rgb(1, 2, 3));
1167    }
1168
1169    #[test]
1170    fn builder_from_threads_syntax_palette() {
1171        let theme = Theme::builder_from(Theme::nord())
1172            .primary(Color::Rgb(255, 0, 0))
1173            .build();
1174        // Syntax palette is preserved from the Nord base.
1175        assert_eq!(theme.syntax, Theme::nord().syntax);
1176    }
1177
1178    #[test]
1179    fn syntax_palette_default_is_one_dark() {
1180        assert_eq!(SyntaxPalette::default(), SyntaxPalette::one_dark());
1181    }
1182
1183    #[test]
1184    fn theme_light_preset_builds() {
1185        let t = Theme::light();
1186        assert_eq!(t.selected_fg, Color::White);
1187        assert!(!t.is_dark);
1188    }
1189
1190    #[test]
1191    fn theme_dracula_preset_builds() {
1192        let t = Theme::dracula();
1193        assert_eq!(t.bg, Color::Rgb(40, 42, 54));
1194        assert!(t.is_dark);
1195    }
1196
1197    #[test]
1198    fn theme_catppuccin_preset_builds() {
1199        let t = Theme::catppuccin();
1200        assert_eq!(t.bg, Color::Rgb(30, 30, 46));
1201        assert!(t.is_dark);
1202    }
1203
1204    #[test]
1205    fn theme_nord_preset_builds() {
1206        let t = Theme::nord();
1207        assert_eq!(t.bg, Color::Rgb(46, 52, 64));
1208        assert!(t.is_dark);
1209    }
1210
1211    #[test]
1212    fn theme_solarized_dark_preset_builds() {
1213        let t = Theme::solarized_dark();
1214        assert_eq!(t.bg, Color::Rgb(0, 43, 54));
1215        assert!(t.is_dark);
1216    }
1217
1218    #[test]
1219    fn theme_tokyo_night_preset_builds() {
1220        let t = Theme::tokyo_night();
1221        assert_eq!(t.bg, Color::Rgb(26, 27, 38));
1222        assert!(t.is_dark);
1223    }
1224
1225    #[test]
1226    fn theme_builder_sets_primary_and_accent() {
1227        let theme = Theme::builder()
1228            .primary(Color::Red)
1229            .accent(Color::Yellow)
1230            .build();
1231
1232        assert_eq!(theme.primary, Color::Red);
1233        assert_eq!(theme.accent, Color::Yellow);
1234    }
1235
1236    #[test]
1237    fn theme_builder_defaults_to_dark_for_unset_fields() {
1238        let defaults = Theme::dark();
1239        let theme = Theme::builder().primary(Color::Green).build();
1240
1241        assert_eq!(theme.primary, Color::Green);
1242        assert_eq!(theme.secondary, defaults.secondary);
1243        assert_eq!(theme.text, defaults.text);
1244        assert_eq!(theme.text_dim, defaults.text_dim);
1245        assert_eq!(theme.border, defaults.border);
1246        assert_eq!(theme.surface_hover, defaults.surface_hover);
1247        assert_eq!(theme.is_dark, defaults.is_dark);
1248    }
1249
1250    #[test]
1251    fn theme_builder_can_override_is_dark() {
1252        let theme = Theme::builder().is_dark(false).build();
1253        assert!(!theme.is_dark);
1254    }
1255
1256    #[test]
1257    fn theme_default_matches_dark() {
1258        let default_theme = Theme::default();
1259        let dark = Theme::dark();
1260        assert_eq!(default_theme.primary, dark.primary);
1261        assert_eq!(default_theme.bg, dark.bg);
1262        assert_eq!(default_theme.is_dark, dark.is_dark);
1263    }
1264
1265    #[test]
1266    fn theme_solarized_light_preset_builds() {
1267        let t = Theme::solarized_light();
1268        assert_eq!(t.bg, Color::Rgb(253, 246, 227));
1269        assert!(!t.is_dark);
1270    }
1271
1272    #[test]
1273    fn theme_gruvbox_dark_preset_builds() {
1274        let t = Theme::gruvbox_dark();
1275        assert_eq!(t.bg, Color::Rgb(40, 40, 40));
1276        assert!(t.is_dark);
1277    }
1278
1279    #[test]
1280    fn theme_one_dark_preset_builds() {
1281        let t = Theme::one_dark();
1282        assert_eq!(t.bg, Color::Rgb(40, 44, 52));
1283        assert!(t.is_dark);
1284    }
1285
1286    // --- regression: issue #106 Nord/Solarized text_dim collides with border ---
1287
1288    #[test]
1289    fn theme_text_dim_ne_border() {
1290        for theme in [
1291            Theme::nord(),
1292            Theme::solarized_dark(),
1293            Theme::solarized_light(),
1294        ] {
1295            assert_ne!(theme.text_dim, theme.border, "text_dim == border in theme");
1296        }
1297    }
1298
1299    #[test]
1300    fn spacing_scale_values() {
1301        let sp = Spacing::new(1);
1302        assert_eq!(sp.none(), 0);
1303        assert_eq!(sp.xs(), 1);
1304        assert_eq!(sp.sm(), 2);
1305        assert_eq!(sp.md(), 3);
1306        assert_eq!(sp.lg(), 4);
1307        assert_eq!(sp.xl(), 6);
1308        assert_eq!(sp.xxl(), 8);
1309    }
1310
1311    #[test]
1312    fn spacing_custom_base() {
1313        let sp = Spacing::new(2);
1314        assert_eq!(sp.xs(), 2);
1315        assert_eq!(sp.sm(), 4);
1316        assert_eq!(sp.md(), 6);
1317    }
1318
1319    #[test]
1320    fn theme_color_resolve_maps_correctly() {
1321        let t = Theme::dark();
1322        assert_eq!(t.resolve(ThemeColor::Primary), t.primary);
1323        assert_eq!(t.resolve(ThemeColor::Secondary), t.secondary);
1324        assert_eq!(t.resolve(ThemeColor::Accent), t.accent);
1325        assert_eq!(t.resolve(ThemeColor::Text), t.text);
1326        assert_eq!(t.resolve(ThemeColor::TextDim), t.text_dim);
1327        assert_eq!(t.resolve(ThemeColor::Border), t.border);
1328        assert_eq!(t.resolve(ThemeColor::Bg), t.bg);
1329        assert_eq!(t.resolve(ThemeColor::Success), t.success);
1330        assert_eq!(t.resolve(ThemeColor::Warning), t.warning);
1331        assert_eq!(t.resolve(ThemeColor::Error), t.error);
1332        assert_eq!(t.resolve(ThemeColor::SelectedBg), t.selected_bg);
1333        assert_eq!(t.resolve(ThemeColor::SelectedFg), t.selected_fg);
1334        assert_eq!(t.resolve(ThemeColor::Surface), t.surface);
1335        assert_eq!(t.resolve(ThemeColor::SurfaceHover), t.surface_hover);
1336        assert_eq!(t.resolve(ThemeColor::SurfaceText), t.surface_text);
1337    }
1338
1339    #[test]
1340    fn theme_color_aliases_resolve_to_primary() {
1341        let t = Theme::dark();
1342        assert_eq!(t.resolve(ThemeColor::Info), t.primary);
1343        assert_eq!(t.resolve(ThemeColor::Link), t.primary);
1344        assert_eq!(t.resolve(ThemeColor::FocusRing), t.primary);
1345    }
1346
1347    #[test]
1348    fn theme_color_custom_passes_through() {
1349        let t = Theme::dark();
1350        let custom = Color::Rgb(42, 42, 42);
1351        assert_eq!(t.resolve(ThemeColor::Custom(custom)), custom);
1352    }
1353
1354    #[test]
1355    fn theme_builder_spacing() {
1356        let sp = Spacing::new(3);
1357        let theme = Theme::builder().spacing(sp).build();
1358        assert_eq!(theme.spacing, sp);
1359    }
1360
1361    #[test]
1362    fn theme_contrast_text_on_dark_bg() {
1363        let t = Theme::dark();
1364        let fg = t.contrast_text_on(Color::Rgb(0, 0, 0));
1365        assert_eq!(fg, Color::Rgb(255, 255, 255));
1366    }
1367
1368    #[test]
1369    fn theme_contrast_text_on_light_bg() {
1370        let t = Theme::dark();
1371        let fg = t.contrast_text_on(Color::Rgb(255, 255, 255));
1372        assert_eq!(fg, Color::Rgb(0, 0, 0));
1373    }
1374
1375    // --- regression: issue #109 ThemeBuilder methods are const fn ---
1376
1377    /// If this `const` evaluation ever fails to compile, a setter on
1378    /// `ThemeBuilder` was accidentally demoted to a non-const fn.
1379    const _CONST_THEME: Theme = Theme::builder()
1380        .primary(Color::Rgb(255, 100, 100))
1381        .bg(Color::Rgb(20, 20, 20))
1382        .is_dark(true)
1383        .spacing(Spacing::new(2))
1384        .build();
1385
1386    #[test]
1387    fn theme_builder_const_eval() {
1388        // Set fields take the override.
1389        assert_eq!(_CONST_THEME.primary, Color::Rgb(255, 100, 100));
1390        assert_eq!(_CONST_THEME.bg, Color::Rgb(20, 20, 20));
1391        assert_eq!(_CONST_THEME.spacing, Spacing::new(2));
1392        // Unset fields fall back to Theme::dark() — verifies the const
1393        // `match` arms in build() reproduce the unwrap_or semantics.
1394        let dark = Theme::dark();
1395        assert_eq!(_CONST_THEME.text, dark.text);
1396        assert_eq!(_CONST_THEME.border, dark.border);
1397        assert_eq!(_CONST_THEME.surface, dark.surface);
1398    }
1399
1400    // --- regression: issue #110 builder_from / light_builder ---
1401
1402    #[test]
1403    fn builder_from_preserves_base_fields() {
1404        // builder_from(base) must seed every field from `base`, so an
1405        // empty .build() yields a theme equal to `base`.
1406        let nord = Theme::nord();
1407        let t = Theme::builder_from(nord).build();
1408        assert_eq!(t.primary, nord.primary);
1409        assert_eq!(t.secondary, nord.secondary);
1410        assert_eq!(t.accent, nord.accent);
1411        assert_eq!(t.text, nord.text);
1412        assert_eq!(t.text_dim, nord.text_dim);
1413        assert_eq!(t.border, nord.border);
1414        assert_eq!(t.bg, nord.bg);
1415        assert_eq!(t.success, nord.success);
1416        assert_eq!(t.warning, nord.warning);
1417        assert_eq!(t.error, nord.error);
1418        assert_eq!(t.selected_bg, nord.selected_bg);
1419        assert_eq!(t.selected_fg, nord.selected_fg);
1420        assert_eq!(t.surface, nord.surface);
1421        assert_eq!(t.surface_hover, nord.surface_hover);
1422        assert_eq!(t.surface_text, nord.surface_text);
1423        assert_eq!(t.is_dark, nord.is_dark);
1424        assert_eq!(t.spacing, nord.spacing);
1425    }
1426
1427    #[test]
1428    fn builder_from_overrides_only_specified_fields() {
1429        let t = Theme::builder_from(Theme::nord())
1430            .primary(Color::Rgb(255, 0, 0))
1431            .build();
1432        // Only primary changed; all other Nord fields preserved.
1433        assert_eq!(t.primary, Color::Rgb(255, 0, 0));
1434        assert_eq!(t.bg, Theme::nord().bg);
1435        assert_eq!(t.text, Theme::nord().text);
1436        assert_ne!(t.primary, Theme::nord().primary);
1437    }
1438
1439    #[test]
1440    fn light_builder_starts_from_light_preset() {
1441        // Without builder_from, a plain Theme::builder() would inherit
1442        // dark() defaults — bg == Color::Reset — even when callers want
1443        // a light-base theme. light_builder() must keep light defaults.
1444        let t = Theme::light_builder()
1445            .primary(Color::Rgb(0, 100, 200))
1446            .build();
1447        let light = Theme::light();
1448        assert_eq!(t.primary, Color::Rgb(0, 100, 200));
1449        assert_eq!(t.bg, light.bg);
1450        assert_eq!(t.text, light.text);
1451        assert_eq!(t.surface, light.surface);
1452        assert!(!t.is_dark);
1453    }
1454
1455    /// const-evaluation regression for builder_from + light_builder.
1456    const _CONST_LIGHT: Theme = Theme::light_builder().primary(Color::Rgb(1, 2, 3)).build();
1457
1458    #[test]
1459    fn light_builder_is_const_evaluable() {
1460        assert_eq!(_CONST_LIGHT.primary, Color::Rgb(1, 2, 3));
1461        assert_eq!(_CONST_LIGHT.bg, Theme::light().bg);
1462        const { assert!(!_CONST_LIGHT.is_dark) };
1463    }
1464}