Skip to main content

tui_nuances/
theme.rs

1//! The [`Theme`] struct: one named set of colors for every widget.
2
3use crate::color::{mix, readable_on, to_rgb};
4use ratatui::style::Color;
5
6/// Default series palette, used when a theme does not bring its own.
7pub const DEFAULT_PALETTE: [Color; 8] = [
8    Color::Cyan,
9    Color::Yellow,
10    Color::Magenta,
11    Color::Green,
12    Color::LightRed,
13    Color::LightBlue,
14    Color::LightGreen,
15    Color::White,
16];
17
18/// Compact color spec the named themes are generated from (hex `0xRRGGBB`).
19pub(crate) struct Spec {
20    pub bg: u32,
21    pub fg: u32,
22    pub muted: u32,
23    pub surface: u32,
24    pub accent: u32,
25    pub secondary: u32,
26    pub success: u32,
27    pub warning: u32,
28    pub error: u32,
29    pub info: u32,
30    pub palette: &'static [u32],
31}
32
33/// `0xRRGGBB` → [`Color::Rgb`].
34pub(crate) const fn rgb(hex: u32) -> Color {
35    Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38/// A named set of colors shared by every widget of an application.
39///
40/// The first group of fields (`foreground` … `highlight`) is the general UI
41/// vocabulary; the second group (`palette` … `tooltip_bg`) are the data
42/// visualisation colors, kept under the same names `tui-chart` used so
43/// migrating is a rename of the import.
44///
45/// With the `serde` feature a theme (de)serialises as a flat map of
46/// `"#rrggbb"` / color-name strings; missing keys fall back to
47/// [`Theme::default`], and unset `background`/`surface` are omitted.
48#[derive(Debug, Clone, PartialEq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[cfg_attr(feature = "serde", serde(default))]
51pub struct Theme {
52    /// Display name (also used by [`Theme::next`] / [`Theme::prev`]).
53    pub name: String,
54    /// Background fill; `None` keeps the terminal background.
55    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
56    pub background: Option<Color>,
57    /// Panel / card background; `None` means "same as background".
58    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
59    pub surface: Option<Color>,
60    /// Default text color.
61    pub foreground: Color,
62    /// De-emphasised text (hints, tick labels, legends).
63    pub muted: Color,
64    /// Text of disabled elements.
65    pub disabled: Color,
66    /// Unfocused border color.
67    pub border: Color,
68    /// Focused border color.
69    pub border_focused: Color,
70    /// Title color.
71    pub title: Color,
72    /// Main brand / interaction color.
73    pub primary: Color,
74    /// Secondary brand color.
75    pub secondary: Color,
76    /// Accent color (single-series charts, gradients, highlights).
77    pub accent: Color,
78    /// Positive status color.
79    pub success: Color,
80    /// Warning status color.
81    pub warning: Color,
82    /// Error status color.
83    pub error: Color,
84    /// Informational status color.
85    pub info: Color,
86    /// Background of the selected item.
87    pub selection_bg: Color,
88    /// Foreground of the selected item.
89    pub selection_fg: Color,
90    /// Highlight color (search matches, markers).
91    pub highlight: Color,
92    /// Series palette (at least 8 colors); series `i` uses `palette[i % len]`.
93    pub palette: Vec<Color>,
94    /// "Up" color (bullish candles, bids, increases).
95    pub bull: Color,
96    /// "Down" color (bearish candles, asks, decreases).
97    pub bear: Color,
98    /// Neutral / total color.
99    pub neutral: Color,
100    /// Axis line color.
101    pub axis: Color,
102    /// Grid line color.
103    pub grid: Color,
104    /// Crosshair guide line color.
105    pub crosshair: Color,
106    /// Crosshair tooltip text color.
107    pub tooltip_fg: Color,
108    /// Crosshair tooltip background color.
109    pub tooltip_bg: Color,
110}
111
112impl Default for Theme {
113    /// Terminal-native theme built from named ANSI colors only.
114    fn default() -> Self {
115        Self {
116            name: "default".into(),
117            background: None,
118            surface: None,
119            foreground: Color::White,
120            muted: Color::Gray,
121            disabled: Color::DarkGray,
122            border: Color::Gray,
123            border_focused: Color::Cyan,
124            title: Color::White,
125            primary: Color::Cyan,
126            secondary: Color::Magenta,
127            accent: Color::Cyan,
128            success: Color::Green,
129            warning: Color::Yellow,
130            error: Color::Red,
131            info: Color::Blue,
132            selection_bg: Color::Blue,
133            selection_fg: Color::White,
134            highlight: Color::Yellow,
135            palette: DEFAULT_PALETTE.to_vec(),
136            bull: Color::Green,
137            bear: Color::Red,
138            neutral: Color::Blue,
139            axis: Color::DarkGray,
140            grid: Color::DarkGray,
141            crosshair: Color::Gray,
142            tooltip_fg: Color::White,
143            tooltip_bg: Color::DarkGray,
144        }
145    }
146}
147
148impl Theme {
149    /// Build a theme from the handful of colors an application already has.
150    ///
151    /// Everything else is derived: RGB (and named/indexed) colors are blended
152    /// between `background` (black when `None`) and `foreground`, so muted
153    /// text, grid, borders, disabled text, surface and selection stay in
154    /// tune. Success/bull use `bull`, error/bear use `bear`, primary/accent use
155    /// `accent`; the palette starts with `accent, bull, bear`. Use the builder
156    /// setters afterwards to fine-tune.
157    ///
158    /// ```
159    /// use ratatui::style::Color;
160    /// use tui_nuances::Theme;
161    ///
162    /// let t = Theme::from_base(
163    ///     "app",
164    ///     Some(Color::Rgb(16, 16, 24)),
165    ///     Color::Rgb(230, 230, 240),
166    ///     Color::Rgb(213, 0, 28),
167    ///     Color::Green,
168    ///     Color::Red,
169    /// );
170    /// assert_eq!(t.primary, Color::Rgb(213, 0, 28));
171    /// assert!(t.palette.len() >= 8);
172    /// ```
173    #[must_use]
174    pub fn from_base(
175        name: &str,
176        background: Option<Color>,
177        foreground: Color,
178        accent: Color,
179        bull: Color,
180        bear: Color,
181    ) -> Self {
182        let base = background.unwrap_or(Color::Rgb(0, 0, 0));
183        let blend = |t: f64, fallback: Color| {
184            if to_rgb(base).is_some() && to_rgb(foreground).is_some() {
185                mix(base, foreground, t)
186            } else {
187                fallback
188            }
189        };
190        let muted = blend(0.6, Color::Gray);
191        let grid = blend(0.25, Color::DarkGray);
192        let disabled = blend(0.4, Color::DarkGray);
193        let surface = background.map(|_| blend(0.08, base));
194        let selection_bg = if to_rgb(base).is_some() && to_rgb(accent).is_some() {
195            mix(base, accent, 0.35)
196        } else {
197            accent
198        };
199        let mut palette = vec![accent, bull, bear];
200        palette.extend(
201            DEFAULT_PALETTE
202                .iter()
203                .copied()
204                .filter(|c| ![accent, bull, bear].contains(c)),
205        );
206        Self {
207            name: name.into(),
208            background,
209            surface,
210            foreground,
211            muted,
212            disabled,
213            border: blend(0.35, Color::Gray),
214            border_focused: accent,
215            title: foreground,
216            primary: accent,
217            secondary: blend(0.5, Color::Magenta),
218            accent,
219            success: bull,
220            warning: Color::Yellow,
221            error: bear,
222            info: accent,
223            selection_bg,
224            selection_fg: readable_on(selection_bg),
225            highlight: Color::Yellow,
226            palette,
227            bull,
228            bear,
229            neutral: accent,
230            axis: grid,
231            grid,
232            crosshair: muted,
233            tooltip_fg: foreground,
234            tooltip_bg: surface.or(background).unwrap_or(grid),
235        }
236    }
237
238    /// Build a theme from just a series palette (terminal background, white
239    /// text). The first three colors become accent / bull / bear; the palette
240    /// is cycled up to at least 8 entries. An empty slice yields the default
241    /// palette.
242    ///
243    /// ```
244    /// use ratatui::style::Color;
245    /// use tui_nuances::Theme;
246    ///
247    /// let t = Theme::from_palette("pair", &[Color::Red, Color::Blue]);
248    /// assert_eq!(t.palette.len(), 8);
249    /// assert_eq!(t.color(2), Color::Red);
250    /// ```
251    #[must_use]
252    pub fn from_palette(name: &str, colors: &[Color]) -> Self {
253        let src: &[Color] = if colors.is_empty() {
254            &DEFAULT_PALETTE
255        } else {
256            colors
257        };
258        let at = |i: usize| src[i % src.len()];
259        let mut t = Self::from_base(name, None, Color::White, at(0), at(1), at(2));
260        let n = src.len().max(8);
261        t.palette = (0..n).map(at).collect();
262        t
263    }
264
265    /// Expand a hex [`Spec`] into a full theme (used by the named themes).
266    pub(crate) fn from_spec(name: &str, s: Spec) -> Self {
267        let (bg, fg, accent) = (rgb(s.bg), rgb(s.fg), rgb(s.accent));
268        let surface = rgb(s.surface);
269        let selection_bg = mix(bg, accent, 0.35);
270        let mut t = Self {
271            name: name.into(),
272            background: Some(bg),
273            surface: Some(surface),
274            foreground: fg,
275            muted: rgb(s.muted),
276            disabled: mix(bg, fg, 0.38),
277            border: mix(bg, fg, 0.3),
278            border_focused: accent,
279            title: fg,
280            primary: accent,
281            secondary: rgb(s.secondary),
282            accent,
283            success: rgb(s.success),
284            warning: rgb(s.warning),
285            error: rgb(s.error),
286            info: rgb(s.info),
287            selection_bg,
288            selection_fg: readable_on(selection_bg),
289            highlight: rgb(s.warning),
290            palette: s.palette.iter().map(|&c| rgb(c)).collect(),
291            bull: rgb(s.success),
292            bear: rgb(s.error),
293            neutral: rgb(s.muted),
294            axis: rgb(s.muted),
295            grid: mix(bg, fg, 0.2),
296            crosshair: rgb(s.muted),
297            tooltip_fg: fg,
298            tooltip_bg: surface,
299        };
300        crate::roles::apply(&mut t);
301        t
302    }
303
304    /// Apply `f` to every color field (and palette entry).
305    pub(crate) fn map_colors(&mut self, f: impl Fn(Color) -> Color) {
306        self.background = self.background.map(&f);
307        self.surface = self.surface.map(&f);
308        for c in [
309            &mut self.foreground,
310            &mut self.muted,
311            &mut self.disabled,
312            &mut self.border,
313            &mut self.border_focused,
314            &mut self.title,
315            &mut self.primary,
316            &mut self.secondary,
317            &mut self.accent,
318            &mut self.success,
319            &mut self.warning,
320            &mut self.error,
321            &mut self.info,
322            &mut self.selection_bg,
323            &mut self.selection_fg,
324            &mut self.highlight,
325            &mut self.bull,
326            &mut self.bear,
327            &mut self.neutral,
328            &mut self.axis,
329            &mut self.grid,
330            &mut self.crosshair,
331            &mut self.tooltip_fg,
332            &mut self.tooltip_bg,
333        ] {
334            *c = f(*c);
335        }
336        for c in &mut self.palette {
337            *c = f(*c);
338        }
339    }
340
341    /// Every color of the theme (background and surface included when set,
342    /// palette last). Handy for audits and tests.
343    #[must_use]
344    pub fn colors(&self) -> Vec<Color> {
345        let mut t = self.clone();
346        let out = std::cell::RefCell::new(Vec::new());
347        t.map_colors(|c| {
348            out.borrow_mut().push(c);
349            c
350        });
351        out.into_inner()
352    }
353
354    /// Set the display name.
355    #[must_use]
356    pub fn name(mut self, name: impl Into<String>) -> Self {
357        self.name = name.into();
358        self
359    }
360
361    /// Set the series palette (an empty palette is ignored).
362    #[must_use]
363    pub fn palette(mut self, palette: Vec<Color>) -> Self {
364        if !palette.is_empty() {
365            self.palette = palette;
366        }
367        self
368    }
369
370    /// Set (or clear) the background fill.
371    #[must_use]
372    pub fn background(mut self, background: Option<Color>) -> Self {
373        self.background = background;
374        self
375    }
376
377    /// Set (or clear) the panel / card background.
378    #[must_use]
379    pub fn surface(mut self, surface: Option<Color>) -> Self {
380        self.surface = surface;
381        self
382    }
383
384    color_setters!(
385        foreground,
386        muted,
387        disabled,
388        border,
389        border_focused,
390        title,
391        primary,
392        secondary,
393        accent,
394        success,
395        warning,
396        error,
397        info,
398        selection_bg,
399        selection_fg,
400        highlight,
401        bull,
402        bear,
403        neutral,
404        axis,
405        grid,
406        crosshair,
407        tooltip_fg,
408        tooltip_bg,
409    );
410
411    /// Set the tooltip foreground/background colors.
412    #[must_use]
413    pub fn tooltip(mut self, fg: Color, bg: Color) -> Self {
414        self.tooltip_fg = fg;
415        self.tooltip_bg = bg;
416        self
417    }
418
419    /// Set the selection foreground/background colors.
420    #[must_use]
421    pub fn selection(mut self, fg: Color, bg: Color) -> Self {
422        self.selection_fg = fg;
423        self.selection_bg = bg;
424        self
425    }
426
427    /// Palette color for series `i` (cycles; falls back to `accent` if empty).
428    #[must_use]
429    pub fn color(&self, i: usize) -> Color {
430        if self.palette.is_empty() {
431            self.accent
432        } else {
433            self.palette[i % self.palette.len()]
434        }
435    }
436}
437
438impl From<&Theme> for Theme {
439    fn from(t: &Theme) -> Self {
440        t.clone()
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn base() -> Theme {
449        Theme::from_base(
450            "t",
451            Some(Color::Rgb(0, 0, 0)),
452            Color::Rgb(200, 200, 200),
453            Color::Rgb(10, 20, 30),
454            Color::Green,
455            Color::Red,
456        )
457    }
458
459    #[test]
460    fn from_base_keeps_inputs() {
461        let t = base();
462        assert_eq!(t.name, "t");
463        assert_eq!(t.background, Some(Color::Rgb(0, 0, 0)));
464        assert_eq!(t.foreground, Color::Rgb(200, 200, 200));
465        assert_eq!(
466            (t.accent, t.primary),
467            (Color::Rgb(10, 20, 30), Color::Rgb(10, 20, 30))
468        );
469        assert_eq!((t.bull, t.success), (Color::Green, Color::Green));
470        assert_eq!((t.bear, t.error), (Color::Red, Color::Red));
471    }
472
473    #[test]
474    fn from_base_blends_muted_and_grid() {
475        let t = base();
476        assert_eq!(t.muted, Color::Rgb(120, 120, 120));
477        assert_eq!(t.grid, Color::Rgb(50, 50, 50));
478        assert_eq!(t.axis, t.grid);
479        assert_eq!(t.crosshair, t.muted);
480    }
481
482    #[test]
483    fn from_base_palette_starts_with_base_colors() {
484        let t = base();
485        assert_eq!(&t.palette[..3], &[t.accent, Color::Green, Color::Red]);
486        assert!(t.palette.len() >= 8);
487        assert_eq!(t.palette.iter().filter(|c| **c == Color::Green).count(), 1);
488    }
489
490    #[test]
491    fn from_base_without_background() {
492        let t = Theme::from_base(
493            "n",
494            None,
495            Color::Rgb(200, 200, 200),
496            Color::Cyan,
497            Color::Green,
498            Color::Red,
499        );
500        assert_eq!(t.background, None);
501        assert_eq!(t.surface, None);
502        assert_eq!(t.muted, Color::Rgb(120, 120, 120));
503    }
504
505    #[test]
506    fn from_base_unresolvable_falls_back() {
507        let t = Theme::from_base(
508            "r",
509            None,
510            Color::Reset,
511            Color::Cyan,
512            Color::Green,
513            Color::Red,
514        );
515        assert_eq!(t.muted, Color::Gray);
516        assert_eq!(t.grid, Color::DarkGray);
517    }
518
519    #[test]
520    fn from_base_selection_is_readable() {
521        let t = base();
522        assert!(crate::color::contrast_ratio(t.selection_fg, t.selection_bg) >= 4.5);
523    }
524
525    #[test]
526    fn from_palette_cycles_to_eight() {
527        let t = Theme::from_palette("p", &[Color::Red, Color::Blue, Color::Green]);
528        assert_eq!(t.palette.len(), 8);
529        assert_eq!(t.palette[3], Color::Red);
530        assert_eq!(
531            (t.accent, t.bull, t.bear),
532            (Color::Red, Color::Blue, Color::Green)
533        );
534    }
535
536    #[test]
537    fn from_palette_empty_uses_default() {
538        let t = Theme::from_palette("e", &[]);
539        assert_eq!(t.palette, DEFAULT_PALETTE.to_vec());
540    }
541
542    #[test]
543    fn from_palette_keeps_long_palettes() {
544        let long: Vec<Color> = (0..12).map(Color::Indexed).collect();
545        assert_eq!(Theme::from_palette("l", &long).palette, long);
546    }
547
548    #[test]
549    fn setters_override_fields() {
550        let t = Theme::default()
551            .name("x")
552            .background(Some(Color::Black))
553            .surface(Some(Color::Blue))
554            .foreground(Color::Red)
555            .primary(Color::Green)
556            .tooltip(Color::Black, Color::White)
557            .selection(Color::Cyan, Color::Magenta);
558        assert_eq!(t.name, "x");
559        assert_eq!(t.background, Some(Color::Black));
560        assert_eq!(t.surface, Some(Color::Blue));
561        assert_eq!(t.foreground, Color::Red);
562        assert_eq!(t.primary, Color::Green);
563        assert_eq!((t.tooltip_fg, t.tooltip_bg), (Color::Black, Color::White));
564        assert_eq!(
565            (t.selection_fg, t.selection_bg),
566            (Color::Cyan, Color::Magenta)
567        );
568    }
569
570    #[test]
571    fn empty_palette_setter_ignored() {
572        let before = Theme::default();
573        assert_eq!(before.clone().palette(vec![]).palette, before.palette);
574        assert_eq!(before.palette(vec![Color::Red]).palette, vec![Color::Red]);
575    }
576
577    #[test]
578    fn color_cycles_and_handles_empty() {
579        let t = Theme::default().palette(vec![Color::Red, Color::Blue]);
580        assert_eq!(t.color(0), Color::Red);
581        assert_eq!(t.color(3), Color::Blue);
582        let mut e = Theme::default();
583        e.palette.clear();
584        assert_eq!(e.color(5), e.accent);
585    }
586
587    #[test]
588    fn colors_lists_everything() {
589        let t = base();
590        assert!(t.colors().len() >= 24 + 8);
591    }
592
593    #[test]
594    fn from_ref_clones() {
595        let t = base();
596        assert_eq!(Theme::from(&t), t);
597    }
598}