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}