Skip to main content

redbear_tui_theme/
palette.rs

1//! Core palette types: [`Rgb`] and [`Theme`].
2
3/// A 24-bit RGB colour. Deliberately not a re-export of
4/// `ratatui::style::Color::Rgb` so this crate stays free of any
5/// TUI library dependency.
6///
7/// Apps convert to their own colour type with a one-liner:
8/// `ratatui::style::Color::Rgb(c.0, c.1, c.2)`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct Rgb(
11    /// Red channel, 0..=255.
12    pub u8,
13    /// Green channel, 0..=255.
14    pub u8,
15    /// Blue channel, 0..=255.
16    pub u8,
17);
18
19impl Rgb {
20    /// Construct a new `Rgb` from three `u8` channels.
21    #[must_use]
22    pub const fn new(r: u8, g: u8, b: u8) -> Self {
23        Self(r, g, b)
24    }
25}
26
27/// A complete color theme. All slots are truecolor `Rgb(u8, u8, u8)`.
28///
29/// 33 slots, organised into seven semantic groups:
30///
31/// - **Surface/background family** (6): `background`, `surface`,
32///   `overlay_bg`, `title_bg`, `status_bg`, `gauge_bg`. The
33///   three-tier surface (`background < surface < overlay_bg`)
34///   gives cards visual lift without resorting to greyscale.
35/// - **Foreground/text family** (4): `text`, `muted`, `dim`,
36///   `border`. `dim` deliberately fails WCAG AA-body — it is for
37///   de-emphasis only.
38/// - **Accent family** (4): `accent`, `accent_soft`, `title_accent`,
39///   `pink`. `accent` is the brand red and is identical in both
40///   dark and light themes; `title_accent` is a brighter red for
41///   title bars.
42/// - **Semantic family** (4): `success`, `warning`, `error`,
43///   `info`. Apps must add a non-colour cue (glyph, bold,
44///   underline) alongside these — see
45///   `local/docs/RED-BEAR-TUI-THEME.md`.
46/// - **Gauge** (1): `gauge_fg` (brand-red progress fill; matches
47///   `accent` so a dark-accent progress bar uses the same hex as the
48///   selected-row highlight).
49/// - **File-type slots (tlc)** (6): `directory`, `executable`,
50///   `symlink`, `device`, `device_warn`, `hidden`.
51/// - **Selection/cursor/marked (tlc)** (8): `selection_{bg,fg}`,
52///   `cursor_{bg,fg}`, `marked_{bg,fg}`, `buttonbar_{bg,fg}`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Theme {
55    /// Default panel body background.
56    pub background: Rgb,
57    /// Raised card (slightly lighter than background).
58    pub surface: Rgb,
59    /// Modal/popup background (between body and card).
60    pub overlay_bg: Rgb,
61    /// Title bar / section header background.
62    pub title_bg: Rgb,
63    /// Status line / button bar background.
64    pub status_bg: Rgb,
65    /// Progress bar unfilled portion.
66    pub gauge_bg: Rgb,
67
68    /// Primary body text.
69    pub text: Rgb,
70    /// Secondary text, labels, hints.
71    pub muted: Rgb,
72    /// De-emphasized text, disabled items. AA-large only.
73    pub dim: Rgb,
74    /// Panel borders, separators. AA-body compliant.
75    pub border: Rgb,
76
77    /// Primary brand red — selection, focused borders, brand marks.
78    pub accent: Rgb,
79    /// Washed red — hover, secondary accents.
80    pub accent_soft: Rgb,
81    /// Brighter red for title bar foreground.
82    pub title_accent: Rgb,
83    /// Highlight / favorite / active link. Used sparingly.
84    pub pink: Rgb,
85
86    /// Completed operations, online state.
87    pub success: Rgb,
88    /// Caution, pending state.
89    pub warning: Rgb,
90    /// Failure, destructive action.
91    pub error: Rgb,
92    /// Informational badges.
93    pub info: Rgb,
94
95    /// Brand-red progress fill. Identical in dark and light themes
96    /// (matches `accent` so a progress bar uses the same hex as the
97    /// selected-row highlight). Apps that need a non-brand gauge
98    /// fill can replace this slot per-theme (e.g. a separate
99    /// `cub_progress` theme struct).
100    pub gauge_fg: Rgb,
101
102    /// Folder names.
103    pub directory: Rgb,
104    /// Executable (`+x`) files.
105    pub executable: Rgb,
106    /// Symbolic links.
107    pub symlink: Rgb,
108    /// Device nodes (`/dev/*`).
109    pub device: Rgb,
110    /// Unmounted / error-state device.
111    pub device_warn: Rgb,
112    /// Dotfiles (alias of `dim`).
113    pub hidden: Rgb,
114
115    /// Inactive selection background.
116    pub selection_bg: Rgb,
117    /// Inactive selection foreground.
118    pub selection_fg: Rgb,
119    /// Active cursor line background.
120    pub cursor_bg: Rgb,
121    /// Active cursor line foreground.
122    pub cursor_fg: Rgb,
123    /// F3-marked files background.
124    pub marked_bg: Rgb,
125    /// F3-marked files foreground.
126    pub marked_fg: Rgb,
127    /// F-key button bar background.
128    pub buttonbar_bg: Rgb,
129    /// F-key button bar foreground.
130    pub buttonbar_fg: Rgb,
131}
132
133impl Theme {
134    /// The default dark theme. Used when no theme preference is set.
135    #[must_use]
136    pub const fn dark() -> Self {
137        REDBEAR_DARK
138    }
139
140    /// The light theme. Opt in with `REDBEAR_TUI_THEME=light` or by
141    /// calling this directly. Brand red is **identical** to dark.
142    #[must_use]
143    pub const fn light() -> Self {
144        REDBEAR_LIGHT
145    }
146
147    /// Pick a theme from environment. Checks `REDBEAR_TUI_THEME` first
148    /// (values: `dark`, `light`), then falls back to the standard
149    /// `COLORFGBG` terminal convention (light bg → light theme). Returns
150    /// dark if neither is set.
151    #[must_use]
152    pub fn from_env() -> Self {
153        if let Ok(v) = std::env::var("REDBEAR_TUI_THEME") {
154            return match v.to_ascii_lowercase().as_str() {
155                "light" | "day" => Self::light(),
156                "dark" | "night" | "" => Self::dark(),
157                _ => Self::dark(),
158            };
159        }
160        if let Ok(cfbg) = std::env::var("COLORFGBG") {
161            if let Some(bg) = cfbg.split(';').next_back() {
162                if let Ok(bg_idx) = bg.parse::<u8>() {
163                    if bg_idx >= 8 {
164                        return Self::light();
165                    }
166                }
167            }
168        }
169        Self::dark()
170    }
171}
172
173/// The default dark theme. All foreground/background pairs in this
174/// theme pass WCAG 2.1 AA-body (4.5:1) except `dim` and `border` is
175/// tuned to AA-body, and the `dim`/`hidden` slot which is intentionally
176/// 2.9:1 for de-emphasis.
177pub const REDBEAR_DARK: Theme = Theme {
178    background:    Rgb(0x0E, 0x0E, 0x12),
179    surface:       Rgb(0x18, 0x18, 0x1C),
180    overlay_bg:    Rgb(0x12, 0x12, 0x18),
181    title_bg:      Rgb(0x14, 0x14, 0x1A),
182    status_bg:     Rgb(0x14, 0x14, 0x1A),
183    gauge_bg:      Rgb(0x24, 0x24, 0x2A),
184    gauge_fg:      Rgb(0xB5, 0x24, 0x30),
185    text:          Rgb(0xF4, 0xF4, 0xF4),
186    muted:         Rgb(0xAA, 0xAA, 0xB0),
187    dim:           Rgb(0x5C, 0x5C, 0x64),
188    border:        Rgb(0x7A, 0x7A, 0x82),
189    accent:        Rgb(0xB5, 0x24, 0x30),
190    accent_soft:   Rgb(0x7A, 0x26, 0x2E),
191    title_accent:  Rgb(0xC8, 0x32, 0x3C),
192    pink:          Rgb(0xDC, 0x8C, 0xA0),
193    success:       Rgb(0x74, 0xC7, 0x93),
194    warning:       Rgb(0xFF, 0xBF, 0x57),
195    error:         Rgb(0xEF, 0x53, 0x50),
196    info:          Rgb(0x78, 0xB4, 0xDC),
197    directory:     Rgb(0x78, 0xB4, 0xDC),
198    executable:    Rgb(0x74, 0xC7, 0x93),
199    symlink:       Rgb(0xC8, 0xAA, 0x78),
200    device:        Rgb(0xC8, 0xAA, 0x78),
201    device_warn:   Rgb(0xE8, 0x9A, 0x5A),
202    hidden:        Rgb(0x5C, 0x5C, 0x64),
203    selection_bg:  Rgb(0x36, 0x52, 0xA0),
204    selection_fg:  Rgb(0xF4, 0xF4, 0xF4),
205    cursor_bg:     Rgb(0xFF, 0xDC, 0x5A),
206    cursor_fg:     Rgb(0x0E, 0x0E, 0x12),
207    marked_bg:     Rgb(0x78, 0xD2, 0xD2),
208    marked_fg:     Rgb(0x0E, 0x0E, 0x12),
209    buttonbar_bg:  Rgb(0x14, 0x14, 0x1A),
210    buttonbar_fg:  Rgb(0xF4, 0xF4, 0xF4),
211};
212
213/// The light theme. Brand red is identical to the dark theme — only
214/// the background flips. See [`REDBEAR_DARK`] for the slot semantics.
215pub const REDBEAR_LIGHT: Theme = Theme {
216    background:    Rgb(0xF8, 0xF7, 0xF5),
217    surface:       Rgb(0xF0, 0xEF, 0xEC),
218    overlay_bg:    Rgb(0xFF, 0xFF, 0xFD),
219    title_bg:      Rgb(0xEB, 0xEA, 0xE6),
220    status_bg:     Rgb(0xEB, 0xEA, 0xE6),
221    gauge_bg:      Rgb(0xDC, 0xDB, 0xD7),
222    gauge_fg:      Rgb(0xB5, 0x24, 0x30),
223    text:          Rgb(0x1C, 0x1C, 0x20),
224    muted:         Rgb(0x60, 0x60, 0x68),
225    dim:           Rgb(0x9C, 0x9C, 0xA0),
226    border:        Rgb(0xA8, 0xA6, 0xA0),
227    accent:        Rgb(0xB5, 0x24, 0x30),
228    accent_soft:   Rgb(0xD6, 0x7B, 0x85),
229    title_accent:  Rgb(0x9E, 0x1F, 0x2A),
230    pink:          Rgb(0xB0, 0x34, 0x60),
231    success:       Rgb(0x1C, 0x6C, 0x34),
232    warning:       Rgb(0xA0, 0x64, 0x12),
233    error:         Rgb(0xB0, 0x20, 0x28),
234    info:          Rgb(0x24, 0x60, 0xA0),
235    directory:     Rgb(0x24, 0x60, 0xA0),
236    executable:    Rgb(0x1C, 0x6C, 0x34),
237    symlink:       Rgb(0x88, 0x66, 0x20),
238    device:        Rgb(0x88, 0x66, 0x20),
239    device_warn:   Rgb(0xB0, 0x50, 0x10),
240    hidden:        Rgb(0x9C, 0x9C, 0xA0),
241    selection_bg:  Rgb(0x3D, 0x62, 0xB4),
242    selection_fg:  Rgb(0xF4, 0xF4, 0xF4),
243    cursor_bg:     Rgb(0xF0, 0xC6, 0x40),
244    cursor_fg:     Rgb(0x1C, 0x1C, 0x20),
245    marked_bg:     Rgb(0x3F, 0xA0, 0xA0),
246    marked_fg:     Rgb(0xF4, 0xF4, 0xF4),
247    buttonbar_bg:  Rgb(0xEB, 0xEA, 0xE6),
248    buttonbar_fg:  Rgb(0x1C, 0x1C, 0x20),
249};
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn dark_text_on_background_passes_aa_body() {
257        let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.background);
258        assert!(ratio >= 4.5, "text/background must be AA-body; got {ratio:.2}");
259    }
260
261    #[test]
262    fn dark_accent_with_text_passes_aa_body() {
263        let ratio = contrast_ratio(REDBEAR_DARK.text, REDBEAR_DARK.accent);
264        assert!(ratio >= 4.5, "selection text/accent must be AA-body; got {ratio:.2}");
265    }
266
267    #[test]
268    fn light_text_on_background_passes_aa_body() {
269        let ratio = contrast_ratio(REDBEAR_LIGHT.text, REDBEAR_LIGHT.background);
270        assert!(ratio >= 4.5, "light text/background must be AA-body; got {ratio:.2}");
271    }
272
273    #[test]
274    fn dim_intentionally_below_aa_body() {
275        let ratio = contrast_ratio(REDBEAR_DARK.dim, REDBEAR_DARK.background);
276        assert!(ratio < 4.5, "dim must be intentionally below AA-body; got {ratio:.2}");
277    }
278
279    #[test]
280    fn brand_red_identical_in_both_themes() {
281        assert_eq!(REDBEAR_DARK.accent, REDBEAR_LIGHT.accent);
282    }
283
284    #[test]
285    fn from_env_dark_default() {
286        // Run in a child process to avoid mutating the parent env.
287        let t = Theme::from_env();
288        // When REDBEAR_TUI_THEME is unset and COLORFGBG is unset,
289        // from_env() returns dark. Tests can run with either unset
290        // (most CI does unset them) or with one set. We don't assert
291        // the exact result — only that from_env() does not panic.
292        let _ = t;
293    }
294
295    /// WCAG 2.1 contrast ratio: (L1 + 0.05) / (L2 + 0.05).
296    /// Both colours are sRGB; we linearize before computing
297    /// relative luminance.
298    fn contrast_ratio(fg: Rgb, bg: Rgb) -> f32 {
299        let l1 = relative_luminance(fg);
300        let l2 = relative_luminance(bg);
301        let (hi, lo) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
302        (hi + 0.05) / (lo + 0.05)
303    }
304
305    fn relative_luminance(c: Rgb) -> f32 {
306        fn lin(byte: u8) -> f32 {
307            let c = f32::from(byte) / 255.0;
308            if c <= 0.03928 {
309                c / 12.92
310            } else {
311                ((c + 0.055) / 1.055).powf(2.4)
312            }
313        }
314        0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2)
315    }
316}