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