Skip to main content

slt/style/
theme.rs

1use super::*;
2
3/// Spacing scale for consistent padding, margin, and gap values.
4///
5/// All values are in terminal cells. The scale is relative to [`Spacing::base`],
6/// which defaults to 1 cell. Use [`Theme::spacing`] to access the active scale,
7/// or [`crate::Context::spacing`] for convenience.
8///
9/// # Example
10///
11/// ```
12/// use slt::Spacing;
13///
14/// let sp = Spacing::new(1);
15/// assert_eq!(sp.sm(), 2);
16/// assert_eq!(sp.md(), 3);
17/// assert_eq!(sp.xl(), 6);
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct Spacing {
22    /// Base unit in terminal cells. Defaults to 1.
23    pub base: u32,
24}
25
26impl Spacing {
27    /// Create a spacing scale with the given base unit.
28    pub const fn new(base: u32) -> Self {
29        Self { base }
30    }
31
32    /// Zero spacing.
33    pub const fn none(&self) -> u32 {
34        0
35    }
36
37    /// Extra-small spacing (1× base).
38    pub const fn xs(&self) -> u32 {
39        self.base
40    }
41
42    /// Small spacing (2× base).
43    pub const fn sm(&self) -> u32 {
44        self.base * 2
45    }
46
47    /// Medium spacing (3× base).
48    pub const fn md(&self) -> u32 {
49        self.base * 3
50    }
51
52    /// Large spacing (4× base).
53    pub const fn lg(&self) -> u32 {
54        self.base * 4
55    }
56
57    /// Extra-large spacing (6× base).
58    pub const fn xl(&self) -> u32 {
59        self.base * 6
60    }
61
62    /// Double extra-large spacing (8× base).
63    pub const fn xxl(&self) -> u32 {
64        self.base * 8
65    }
66}
67
68impl Default for Spacing {
69    fn default() -> Self {
70        Self { base: 1 }
71    }
72}
73
74/// Semantic color token that resolves against the active [`Theme`].
75///
76/// Use this when you want colors to automatically follow theme changes
77/// without hardcoding specific [`Color`] values. Resolve with
78/// [`Theme::resolve`] or [`crate::Context::color`].
79///
80/// # Example
81///
82/// ```
83/// use slt::{Theme, ThemeColor};
84///
85/// let theme = Theme::dark();
86/// let color = theme.resolve(ThemeColor::Primary);
87/// assert_eq!(color, theme.primary);
88/// ```
89#[non_exhaustive]
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub enum ThemeColor {
93    /// Primary accent color.
94    Primary,
95    /// Secondary accent color.
96    Secondary,
97    /// Decorative accent color.
98    Accent,
99    /// Default text color.
100    Text,
101    /// Dimmed text color.
102    TextDim,
103    /// Border color for unfocused containers.
104    Border,
105    /// Background color.
106    Bg,
107    /// Success indicator color.
108    Success,
109    /// Warning indicator color.
110    Warning,
111    /// Error indicator color.
112    Error,
113    /// Selected item background.
114    SelectedBg,
115    /// Selected item foreground.
116    SelectedFg,
117    /// Surface background for elevated containers.
118    Surface,
119    /// Surface hover state.
120    SurfaceHover,
121    /// Text color readable on surface backgrounds.
122    SurfaceText,
123    /// Informational indicator (resolves to primary).
124    Info,
125    /// Hyperlink color (resolves to primary).
126    Link,
127    /// Focus ring outline color (resolves to primary).
128    FocusRing,
129    /// A literal color value, not resolved from the theme.
130    Custom(Color),
131}
132
133/// A color theme that flows through all widgets automatically.
134///
135/// Construct with [`Theme::dark()`] or [`Theme::light()`], or use
136/// [`Theme::builder()`] for custom themes. Pass via [`crate::RunConfig`]
137/// and every widget picks up the colors without any extra wiring.
138///
139/// # Example
140///
141/// ```
142/// use slt::{Color, Theme};
143///
144/// let theme = Theme::builder()
145///     .primary(Color::Rgb(255, 107, 107))
146///     .build();
147/// ```
148#[non_exhaustive]
149#[derive(Debug, Clone, Copy)]
150#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
151pub struct Theme {
152    /// Primary accent color, used for focused borders and highlights.
153    pub primary: Color,
154    /// Secondary accent color, used for less prominent highlights.
155    pub secondary: Color,
156    /// Accent color for decorative elements.
157    pub accent: Color,
158    /// Default foreground text color.
159    pub text: Color,
160    /// Dimmed text color for secondary labels and hints.
161    pub text_dim: Color,
162    /// Border color for unfocused containers.
163    pub border: Color,
164    /// Background color. Typically [`Color::Reset`] to inherit the terminal background.
165    pub bg: Color,
166    /// Color for success states (e.g., toast notifications).
167    pub success: Color,
168    /// Color for warning states.
169    pub warning: Color,
170    /// Color for error states.
171    pub error: Color,
172    /// Background color for selected list/table rows.
173    pub selected_bg: Color,
174    /// Foreground color for selected list/table rows.
175    pub selected_fg: Color,
176    /// Subtle surface color for card backgrounds and elevated containers.
177    pub surface: Color,
178    /// Hover/active surface color, one step brighter than `surface`.
179    ///
180    /// Used for interactive element hover states. Should be visually
181    /// distinguishable from both `surface` and `border`.
182    pub surface_hover: Color,
183    /// Secondary text color guaranteed readable on `surface` backgrounds.
184    ///
185    /// Use this instead of `text_dim` when rendering on `surface`-colored
186    /// containers. `text_dim` is tuned for the main `bg`; on `surface` it
187    /// may lack contrast.
188    pub surface_text: Color,
189    /// Whether this theme is a dark theme. Used to initialize dark mode in Context.
190    pub is_dark: bool,
191    /// Spacing scale for consistent padding, margin, and gap values.
192    pub spacing: Spacing,
193}
194
195impl Theme {
196    /// Resolve a [`ThemeColor`] token to a concrete [`Color`].
197    pub fn resolve(&self, token: ThemeColor) -> Color {
198        match token {
199            ThemeColor::Primary => self.primary,
200            ThemeColor::Secondary => self.secondary,
201            ThemeColor::Accent => self.accent,
202            ThemeColor::Text => self.text,
203            ThemeColor::TextDim => self.text_dim,
204            ThemeColor::Border => self.border,
205            ThemeColor::Bg => self.bg,
206            ThemeColor::Success => self.success,
207            ThemeColor::Warning => self.warning,
208            ThemeColor::Error => self.error,
209            ThemeColor::SelectedBg => self.selected_bg,
210            ThemeColor::SelectedFg => self.selected_fg,
211            ThemeColor::Surface => self.surface,
212            ThemeColor::SurfaceHover => self.surface_hover,
213            ThemeColor::SurfaceText => self.surface_text,
214            ThemeColor::Info | ThemeColor::Link | ThemeColor::FocusRing => self.primary,
215            ThemeColor::Custom(c) => c,
216        }
217    }
218
219    /// Return a text color with guaranteed contrast against the given background.
220    ///
221    /// Delegates to [`Color::contrast_fg`].
222    pub fn contrast_text_on(&self, bg: Color) -> Color {
223        Color::contrast_fg(bg)
224    }
225
226    /// Blend a color with the theme's background at the given alpha.
227    ///
228    /// `alpha = 0.0` returns `self.bg`, `alpha = 1.0` returns `color` unchanged.
229    pub fn overlay(&self, color: Color, alpha: f32) -> Color {
230        color.blend(self.bg, alpha)
231    }
232
233    /// Create a dark theme with cyan primary and white text.
234    pub fn dark() -> Self {
235        Self {
236            primary: Color::Cyan,
237            secondary: Color::Blue,
238            accent: Color::Magenta,
239            text: Color::White,
240            text_dim: Color::Indexed(245),
241            border: Color::Indexed(240),
242            bg: Color::Reset,
243            success: Color::Green,
244            warning: Color::Yellow,
245            error: Color::Red,
246            selected_bg: Color::Cyan,
247            selected_fg: Color::Black,
248            surface: Color::Indexed(236),
249            surface_hover: Color::Indexed(238),
250            surface_text: Color::Indexed(250),
251            is_dark: true,
252            spacing: Spacing::new(1),
253        }
254    }
255
256    /// Create a light theme with high-contrast dark text on light backgrounds.
257    pub fn light() -> Self {
258        Self {
259            primary: Color::Rgb(37, 99, 235),
260            secondary: Color::Rgb(14, 116, 144),
261            accent: Color::Rgb(147, 51, 234),
262            text: Color::Rgb(15, 23, 42),
263            text_dim: Color::Rgb(100, 116, 139),
264            border: Color::Rgb(203, 213, 225),
265            bg: Color::Rgb(248, 250, 252),
266            success: Color::Rgb(22, 163, 74),
267            warning: Color::Rgb(202, 138, 4),
268            error: Color::Rgb(220, 38, 38),
269            selected_bg: Color::Rgb(37, 99, 235),
270            selected_fg: Color::White,
271            surface: Color::Rgb(241, 245, 249),
272            surface_hover: Color::Rgb(226, 232, 240),
273            surface_text: Color::Rgb(51, 65, 85),
274            is_dark: false,
275            spacing: Spacing::new(1),
276        }
277    }
278
279    /// Create a [`ThemeBuilder`] for configuring a custom theme.
280    ///
281    /// # Example
282    ///
283    /// ```
284    /// use slt::{Color, Theme};
285    ///
286    /// let theme = Theme::builder()
287    ///     .primary(Color::Rgb(255, 107, 107))
288    ///     .accent(Color::Cyan)
289    ///     .build();
290    /// ```
291    pub fn builder() -> ThemeBuilder {
292        ThemeBuilder {
293            primary: None,
294            secondary: None,
295            accent: None,
296            text: None,
297            text_dim: None,
298            border: None,
299            bg: None,
300            success: None,
301            warning: None,
302            error: None,
303            selected_bg: None,
304            selected_fg: None,
305            surface: None,
306            surface_hover: None,
307            surface_text: None,
308            is_dark: None,
309            spacing: None,
310        }
311    }
312
313    /// Dracula theme — purple primary on dark gray.
314    pub fn dracula() -> Self {
315        Self {
316            primary: Color::Rgb(189, 147, 249),
317            secondary: Color::Rgb(139, 233, 253),
318            accent: Color::Rgb(255, 121, 198),
319            text: Color::Rgb(248, 248, 242),
320            text_dim: Color::Rgb(98, 114, 164),
321            border: Color::Rgb(68, 71, 90),
322            bg: Color::Rgb(40, 42, 54),
323            success: Color::Rgb(80, 250, 123),
324            warning: Color::Rgb(241, 250, 140),
325            error: Color::Rgb(255, 85, 85),
326            selected_bg: Color::Rgb(189, 147, 249),
327            selected_fg: Color::Rgb(40, 42, 54),
328            surface: Color::Rgb(68, 71, 90),
329            surface_hover: Color::Rgb(98, 100, 120),
330            surface_text: Color::Rgb(191, 194, 210),
331            is_dark: true,
332            spacing: Spacing::new(1),
333        }
334    }
335
336    /// Catppuccin Mocha theme — lavender primary on dark base.
337    pub fn catppuccin() -> Self {
338        Self {
339            primary: Color::Rgb(180, 190, 254),
340            secondary: Color::Rgb(137, 180, 250),
341            accent: Color::Rgb(245, 194, 231),
342            text: Color::Rgb(205, 214, 244),
343            text_dim: Color::Rgb(127, 132, 156),
344            border: Color::Rgb(88, 91, 112),
345            bg: Color::Rgb(30, 30, 46),
346            success: Color::Rgb(166, 227, 161),
347            warning: Color::Rgb(249, 226, 175),
348            error: Color::Rgb(243, 139, 168),
349            selected_bg: Color::Rgb(180, 190, 254),
350            selected_fg: Color::Rgb(30, 30, 46),
351            surface: Color::Rgb(49, 50, 68),
352            surface_hover: Color::Rgb(69, 71, 90),
353            surface_text: Color::Rgb(166, 173, 200),
354            is_dark: true,
355            spacing: Spacing::new(1),
356        }
357    }
358
359    /// Nord theme — frost blue primary on polar night.
360    pub fn nord() -> Self {
361        Self {
362            primary: Color::Rgb(136, 192, 208),
363            secondary: Color::Rgb(129, 161, 193),
364            accent: Color::Rgb(180, 142, 173),
365            text: Color::Rgb(236, 239, 244),
366            text_dim: Color::Rgb(76, 86, 106),
367            border: Color::Rgb(76, 86, 106),
368            bg: Color::Rgb(46, 52, 64),
369            success: Color::Rgb(163, 190, 140),
370            warning: Color::Rgb(235, 203, 139),
371            error: Color::Rgb(191, 97, 106),
372            selected_bg: Color::Rgb(136, 192, 208),
373            selected_fg: Color::Rgb(46, 52, 64),
374            surface: Color::Rgb(59, 66, 82),
375            surface_hover: Color::Rgb(67, 76, 94),
376            surface_text: Color::Rgb(216, 222, 233),
377            is_dark: true,
378            spacing: Spacing::new(1),
379        }
380    }
381
382    /// Solarized Dark theme — blue primary on dark base.
383    pub fn solarized_dark() -> Self {
384        Self {
385            primary: Color::Rgb(38, 139, 210),
386            secondary: Color::Rgb(42, 161, 152),
387            accent: Color::Rgb(211, 54, 130),
388            text: Color::Rgb(131, 148, 150),
389            text_dim: Color::Rgb(88, 110, 117),
390            border: Color::Rgb(88, 110, 117),
391            bg: Color::Rgb(0, 43, 54),
392            success: Color::Rgb(133, 153, 0),
393            warning: Color::Rgb(181, 137, 0),
394            error: Color::Rgb(220, 50, 47),
395            selected_bg: Color::Rgb(38, 139, 210),
396            selected_fg: Color::Rgb(253, 246, 227),
397            surface: Color::Rgb(7, 54, 66),
398            surface_hover: Color::Rgb(23, 72, 85),
399            surface_text: Color::Rgb(147, 161, 161),
400            is_dark: true,
401            spacing: Spacing::new(1),
402        }
403    }
404
405    /// Solarized Light theme — warm ochre primary on light base.
406    pub fn solarized_light() -> Self {
407        Self {
408            primary: Color::Rgb(38, 139, 210),
409            secondary: Color::Rgb(42, 161, 152),
410            accent: Color::Rgb(211, 54, 130),
411            text: Color::Rgb(101, 123, 131),
412            text_dim: Color::Rgb(147, 161, 161),
413            border: Color::Rgb(147, 161, 161),
414            bg: Color::Rgb(253, 246, 227),
415            success: Color::Rgb(133, 153, 0),
416            warning: Color::Rgb(181, 137, 0),
417            error: Color::Rgb(220, 50, 47),
418            selected_bg: Color::Rgb(38, 139, 210),
419            selected_fg: Color::Rgb(253, 246, 227),
420            surface: Color::Rgb(238, 232, 213),
421            surface_hover: Color::Rgb(227, 221, 201),
422            surface_text: Color::Rgb(88, 110, 117),
423            is_dark: false,
424            spacing: Spacing::new(1),
425        }
426    }
427
428    /// Tokyo Night theme — blue primary on dark storm base.
429    pub fn tokyo_night() -> Self {
430        Self {
431            primary: Color::Rgb(122, 162, 247),
432            secondary: Color::Rgb(125, 207, 255),
433            accent: Color::Rgb(187, 154, 247),
434            text: Color::Rgb(169, 177, 214),
435            text_dim: Color::Rgb(86, 95, 137),
436            border: Color::Rgb(54, 58, 79),
437            bg: Color::Rgb(26, 27, 38),
438            success: Color::Rgb(158, 206, 106),
439            warning: Color::Rgb(224, 175, 104),
440            error: Color::Rgb(247, 118, 142),
441            selected_bg: Color::Rgb(122, 162, 247),
442            selected_fg: Color::Rgb(26, 27, 38),
443            surface: Color::Rgb(36, 40, 59),
444            surface_hover: Color::Rgb(41, 46, 66),
445            surface_text: Color::Rgb(192, 202, 245),
446            is_dark: true,
447            spacing: Spacing::new(1),
448        }
449    }
450
451    /// Gruvbox Dark theme — warm, retro tones on dark background.
452    pub fn gruvbox_dark() -> Self {
453        Self {
454            primary: Color::Rgb(215, 153, 33),
455            secondary: Color::Rgb(69, 133, 136),
456            accent: Color::Rgb(177, 98, 134),
457            text: Color::Rgb(235, 219, 178),
458            text_dim: Color::Rgb(146, 131, 116),
459            border: Color::Rgb(80, 73, 69),
460            bg: Color::Rgb(40, 40, 40),
461            success: Color::Rgb(152, 151, 26),
462            warning: Color::Rgb(250, 189, 47),
463            error: Color::Rgb(204, 36, 29),
464            selected_bg: Color::Rgb(215, 153, 33),
465            selected_fg: Color::Rgb(40, 40, 40),
466            surface: Color::Rgb(60, 56, 54),
467            surface_hover: Color::Rgb(80, 73, 69),
468            surface_text: Color::Rgb(189, 174, 147),
469            is_dark: true,
470            spacing: Spacing::new(1),
471        }
472    }
473
474    /// One Dark theme (Atom) — cool blues and purples on dark gray.
475    pub fn one_dark() -> Self {
476        Self {
477            primary: Color::Rgb(97, 175, 239),
478            secondary: Color::Rgb(86, 182, 194),
479            accent: Color::Rgb(198, 120, 221),
480            text: Color::Rgb(171, 178, 191),
481            text_dim: Color::Rgb(92, 99, 112),
482            border: Color::Rgb(62, 68, 81),
483            bg: Color::Rgb(40, 44, 52),
484            success: Color::Rgb(152, 195, 121),
485            warning: Color::Rgb(229, 192, 123),
486            error: Color::Rgb(224, 108, 117),
487            selected_bg: Color::Rgb(97, 175, 239),
488            selected_fg: Color::Rgb(40, 44, 52),
489            surface: Color::Rgb(50, 55, 65),
490            surface_hover: Color::Rgb(62, 68, 81),
491            surface_text: Color::Rgb(152, 159, 172),
492            is_dark: true,
493            spacing: Spacing::new(1),
494        }
495    }
496}
497
498/// Builder for creating custom themes with defaults from `Theme::dark()`.
499pub struct ThemeBuilder {
500    primary: Option<Color>,
501    secondary: Option<Color>,
502    accent: Option<Color>,
503    text: Option<Color>,
504    text_dim: Option<Color>,
505    border: Option<Color>,
506    bg: Option<Color>,
507    success: Option<Color>,
508    warning: Option<Color>,
509    error: Option<Color>,
510    selected_bg: Option<Color>,
511    selected_fg: Option<Color>,
512    surface: Option<Color>,
513    surface_hover: Option<Color>,
514    surface_text: Option<Color>,
515    is_dark: Option<bool>,
516    spacing: Option<Spacing>,
517}
518
519impl ThemeBuilder {
520    /// Set the primary color.
521    pub fn primary(mut self, color: Color) -> Self {
522        self.primary = Some(color);
523        self
524    }
525
526    /// Set the secondary color.
527    pub fn secondary(mut self, color: Color) -> Self {
528        self.secondary = Some(color);
529        self
530    }
531
532    /// Set the accent color.
533    pub fn accent(mut self, color: Color) -> Self {
534        self.accent = Some(color);
535        self
536    }
537
538    /// Set the main text color.
539    pub fn text(mut self, color: Color) -> Self {
540        self.text = Some(color);
541        self
542    }
543
544    /// Set the dimmed text color.
545    pub fn text_dim(mut self, color: Color) -> Self {
546        self.text_dim = Some(color);
547        self
548    }
549
550    /// Set the border color.
551    pub fn border(mut self, color: Color) -> Self {
552        self.border = Some(color);
553        self
554    }
555
556    /// Set the background color.
557    pub fn bg(mut self, color: Color) -> Self {
558        self.bg = Some(color);
559        self
560    }
561
562    /// Set the success indicator color.
563    pub fn success(mut self, color: Color) -> Self {
564        self.success = Some(color);
565        self
566    }
567
568    /// Set the warning indicator color.
569    pub fn warning(mut self, color: Color) -> Self {
570        self.warning = Some(color);
571        self
572    }
573
574    /// Set the error indicator color.
575    pub fn error(mut self, color: Color) -> Self {
576        self.error = Some(color);
577        self
578    }
579
580    /// Set the selected item background color.
581    pub fn selected_bg(mut self, color: Color) -> Self {
582        self.selected_bg = Some(color);
583        self
584    }
585
586    /// Set the selected item foreground color.
587    pub fn selected_fg(mut self, color: Color) -> Self {
588        self.selected_fg = Some(color);
589        self
590    }
591
592    /// Set the surface background color.
593    pub fn surface(mut self, color: Color) -> Self {
594        self.surface = Some(color);
595        self
596    }
597
598    /// Set the surface hover color.
599    pub fn surface_hover(mut self, color: Color) -> Self {
600        self.surface_hover = Some(color);
601        self
602    }
603
604    /// Set the surface text color.
605    pub fn surface_text(mut self, color: Color) -> Self {
606        self.surface_text = Some(color);
607        self
608    }
609
610    /// Set the dark mode flag.
611    pub fn is_dark(mut self, is_dark: bool) -> Self {
612        self.is_dark = Some(is_dark);
613        self
614    }
615
616    /// Set the spacing scale.
617    pub fn spacing(mut self, spacing: Spacing) -> Self {
618        self.spacing = Some(spacing);
619        self
620    }
621
622    /// Build the theme. Unfilled fields use [`Theme::dark()`] defaults.
623    pub fn build(self) -> Theme {
624        let defaults = Theme::dark();
625        Theme {
626            primary: self.primary.unwrap_or(defaults.primary),
627            secondary: self.secondary.unwrap_or(defaults.secondary),
628            accent: self.accent.unwrap_or(defaults.accent),
629            text: self.text.unwrap_or(defaults.text),
630            text_dim: self.text_dim.unwrap_or(defaults.text_dim),
631            border: self.border.unwrap_or(defaults.border),
632            bg: self.bg.unwrap_or(defaults.bg),
633            success: self.success.unwrap_or(defaults.success),
634            warning: self.warning.unwrap_or(defaults.warning),
635            error: self.error.unwrap_or(defaults.error),
636            selected_bg: self.selected_bg.unwrap_or(defaults.selected_bg),
637            selected_fg: self.selected_fg.unwrap_or(defaults.selected_fg),
638            surface: self.surface.unwrap_or(defaults.surface),
639            surface_hover: self.surface_hover.unwrap_or(defaults.surface_hover),
640            surface_text: self.surface_text.unwrap_or(defaults.surface_text),
641            is_dark: self.is_dark.unwrap_or(defaults.is_dark),
642            spacing: self.spacing.unwrap_or(defaults.spacing),
643        }
644    }
645}
646
647impl Default for Theme {
648    fn default() -> Self {
649        Self::dark()
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn theme_dark_preset_builds() {
659        let t = Theme::dark();
660        assert_eq!(t.primary, Color::Cyan);
661        assert!(t.is_dark);
662    }
663
664    #[test]
665    fn theme_light_preset_builds() {
666        let t = Theme::light();
667        assert_eq!(t.selected_fg, Color::White);
668        assert!(!t.is_dark);
669    }
670
671    #[test]
672    fn theme_dracula_preset_builds() {
673        let t = Theme::dracula();
674        assert_eq!(t.bg, Color::Rgb(40, 42, 54));
675        assert!(t.is_dark);
676    }
677
678    #[test]
679    fn theme_catppuccin_preset_builds() {
680        let t = Theme::catppuccin();
681        assert_eq!(t.bg, Color::Rgb(30, 30, 46));
682        assert!(t.is_dark);
683    }
684
685    #[test]
686    fn theme_nord_preset_builds() {
687        let t = Theme::nord();
688        assert_eq!(t.bg, Color::Rgb(46, 52, 64));
689        assert!(t.is_dark);
690    }
691
692    #[test]
693    fn theme_solarized_dark_preset_builds() {
694        let t = Theme::solarized_dark();
695        assert_eq!(t.bg, Color::Rgb(0, 43, 54));
696        assert!(t.is_dark);
697    }
698
699    #[test]
700    fn theme_tokyo_night_preset_builds() {
701        let t = Theme::tokyo_night();
702        assert_eq!(t.bg, Color::Rgb(26, 27, 38));
703        assert!(t.is_dark);
704    }
705
706    #[test]
707    fn theme_builder_sets_primary_and_accent() {
708        let theme = Theme::builder()
709            .primary(Color::Red)
710            .accent(Color::Yellow)
711            .build();
712
713        assert_eq!(theme.primary, Color::Red);
714        assert_eq!(theme.accent, Color::Yellow);
715    }
716
717    #[test]
718    fn theme_builder_defaults_to_dark_for_unset_fields() {
719        let defaults = Theme::dark();
720        let theme = Theme::builder().primary(Color::Green).build();
721
722        assert_eq!(theme.primary, Color::Green);
723        assert_eq!(theme.secondary, defaults.secondary);
724        assert_eq!(theme.text, defaults.text);
725        assert_eq!(theme.text_dim, defaults.text_dim);
726        assert_eq!(theme.border, defaults.border);
727        assert_eq!(theme.surface_hover, defaults.surface_hover);
728        assert_eq!(theme.is_dark, defaults.is_dark);
729    }
730
731    #[test]
732    fn theme_builder_can_override_is_dark() {
733        let theme = Theme::builder().is_dark(false).build();
734        assert!(!theme.is_dark);
735    }
736
737    #[test]
738    fn theme_default_matches_dark() {
739        let default_theme = Theme::default();
740        let dark = Theme::dark();
741        assert_eq!(default_theme.primary, dark.primary);
742        assert_eq!(default_theme.bg, dark.bg);
743        assert_eq!(default_theme.is_dark, dark.is_dark);
744    }
745
746    #[test]
747    fn theme_solarized_light_preset_builds() {
748        let t = Theme::solarized_light();
749        assert_eq!(t.bg, Color::Rgb(253, 246, 227));
750        assert!(!t.is_dark);
751    }
752
753    #[test]
754    fn theme_gruvbox_dark_preset_builds() {
755        let t = Theme::gruvbox_dark();
756        assert_eq!(t.bg, Color::Rgb(40, 40, 40));
757        assert!(t.is_dark);
758    }
759
760    #[test]
761    fn theme_one_dark_preset_builds() {
762        let t = Theme::one_dark();
763        assert_eq!(t.bg, Color::Rgb(40, 44, 52));
764        assert!(t.is_dark);
765    }
766
767    #[test]
768    fn spacing_scale_values() {
769        let sp = Spacing::new(1);
770        assert_eq!(sp.none(), 0);
771        assert_eq!(sp.xs(), 1);
772        assert_eq!(sp.sm(), 2);
773        assert_eq!(sp.md(), 3);
774        assert_eq!(sp.lg(), 4);
775        assert_eq!(sp.xl(), 6);
776        assert_eq!(sp.xxl(), 8);
777    }
778
779    #[test]
780    fn spacing_custom_base() {
781        let sp = Spacing::new(2);
782        assert_eq!(sp.xs(), 2);
783        assert_eq!(sp.sm(), 4);
784        assert_eq!(sp.md(), 6);
785    }
786
787    #[test]
788    fn theme_color_resolve_maps_correctly() {
789        let t = Theme::dark();
790        assert_eq!(t.resolve(ThemeColor::Primary), t.primary);
791        assert_eq!(t.resolve(ThemeColor::Secondary), t.secondary);
792        assert_eq!(t.resolve(ThemeColor::Accent), t.accent);
793        assert_eq!(t.resolve(ThemeColor::Text), t.text);
794        assert_eq!(t.resolve(ThemeColor::TextDim), t.text_dim);
795        assert_eq!(t.resolve(ThemeColor::Border), t.border);
796        assert_eq!(t.resolve(ThemeColor::Bg), t.bg);
797        assert_eq!(t.resolve(ThemeColor::Success), t.success);
798        assert_eq!(t.resolve(ThemeColor::Warning), t.warning);
799        assert_eq!(t.resolve(ThemeColor::Error), t.error);
800        assert_eq!(t.resolve(ThemeColor::SelectedBg), t.selected_bg);
801        assert_eq!(t.resolve(ThemeColor::SelectedFg), t.selected_fg);
802        assert_eq!(t.resolve(ThemeColor::Surface), t.surface);
803        assert_eq!(t.resolve(ThemeColor::SurfaceHover), t.surface_hover);
804        assert_eq!(t.resolve(ThemeColor::SurfaceText), t.surface_text);
805    }
806
807    #[test]
808    fn theme_color_aliases_resolve_to_primary() {
809        let t = Theme::dark();
810        assert_eq!(t.resolve(ThemeColor::Info), t.primary);
811        assert_eq!(t.resolve(ThemeColor::Link), t.primary);
812        assert_eq!(t.resolve(ThemeColor::FocusRing), t.primary);
813    }
814
815    #[test]
816    fn theme_color_custom_passes_through() {
817        let t = Theme::dark();
818        let custom = Color::Rgb(42, 42, 42);
819        assert_eq!(t.resolve(ThemeColor::Custom(custom)), custom);
820    }
821
822    #[test]
823    fn theme_builder_spacing() {
824        let sp = Spacing::new(3);
825        let theme = Theme::builder().spacing(sp).build();
826        assert_eq!(theme.spacing, sp);
827    }
828
829    #[test]
830    fn theme_contrast_text_on_dark_bg() {
831        let t = Theme::dark();
832        let fg = t.contrast_text_on(Color::Rgb(0, 0, 0));
833        assert_eq!(fg, Color::Rgb(255, 255, 255));
834    }
835
836    #[test]
837    fn theme_contrast_text_on_light_bg() {
838        let t = Theme::dark();
839        let fg = t.contrast_text_on(Color::Rgb(255, 255, 255));
840        assert_eq!(fg, Color::Rgb(0, 0, 0));
841    }
842}