Skip to main content

max / alloy_tui

12.6 KB · 376 lines History Blame Raw
1 //! Theme palette: makeover intents resolved into ratatui `Color`s, plus
2 //! the two Alloy-derived border tokens.
3 //!
4 //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same
5 //! schema every make-family app already reads) and derives two extra tokens
6 //! locally so theme files stay minimal and cross-app compatible:
7 //!
8 //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
9 //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
10 //!
11 //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
12 //!
13 //! ratatui is immediate-mode with per-widget styling — there is no global
14 //! visuals object. Widgets in this crate take a `&Theme` at construction time
15 //! and pull colors from it. Apps build one `Theme` per theme load (via
16 //! `makeover::load_theme` + `Theme::from_theme`) and thread it through.
17
18 use makeover::{Rgb, ThemeColors};
19 use ratatui::style::Color;
20
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 pub enum Mode {
23 Light,
24 Dark,
25 HighContrast,
26 }
27
28 #[derive(Debug, Clone, Copy)]
29 pub struct Theme {
30 pub mode: Mode,
31
32 pub surface_page: Color,
33 pub surface_raised: Color,
34 pub surface_sunken: Color,
35 pub surface_overlay: Color,
36
37 pub content_primary: Color,
38 pub content_secondary: Color,
39 pub content_muted: Color,
40
41 pub action_primary: Color,
42
43 pub status_danger: Color,
44 pub status_success: Color,
45 pub status_warning: Color,
46 pub status_info: Color,
47
48 pub line_border: Color,
49 pub border_subtle: Color,
50 pub border_strong: Color,
51
52 pub category: [Color; 6],
53 }
54
55 #[derive(Debug, Clone)]
56 pub enum ThemeError {
57 MissingKey(&'static str),
58 InvalidHex { key: &'static str, value: String },
59 }
60
61 impl std::fmt::Display for ThemeError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
65 ThemeError::InvalidHex { key, value } => {
66 write!(f, "theme key `{key}` has invalid hex value `{value}`")
67 }
68 }
69 }
70 }
71
72 impl std::error::Error for ThemeError {}
73
74 impl Theme {
75 /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
76 /// Requires every intent Alloy renders — a malformed or partial theme is
77 /// rejected explicitly rather than silently rendering with defaults.
78 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
79 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
80 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
81 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
82 key,
83 value: hex.clone(),
84 })
85 };
86
87 let surface_page = get("surface.page")?;
88 let content_primary = get("content.primary")?;
89 let line_border = get("line.border")?;
90
91 let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
92 let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
93
94 let mode = match theme.meta.variant.as_str() {
95 "dark" => Mode::Dark,
96 "high-contrast" => Mode::HighContrast,
97 _ => Mode::Light,
98 };
99
100 Ok(Self {
101 mode,
102
103 surface_page: rgb(surface_page),
104 surface_raised: rgb(get("surface.raised")?),
105 surface_sunken: rgb(get("surface.sunken")?),
106 surface_overlay: rgb(get("surface.overlay")?),
107
108 content_primary: rgb(content_primary),
109 content_secondary: rgb(get("content.secondary")?),
110 content_muted: rgb(get("content.muted")?),
111
112 action_primary: rgb(get("action.primary")?),
113
114 status_danger: rgb(get("status.danger")?),
115 status_success: rgb(get("status.success")?),
116 status_warning: rgb(get("status.warning")?),
117 status_info: rgb(get("status.info")?),
118
119 line_border: rgb(line_border),
120 border_subtle: rgb(border_subtle),
121 border_strong: rgb(border_strong),
122
123 category: [
124 rgb(get("category.one")?),
125 rgb(get("category.two")?),
126 rgb(get("category.three")?),
127 rgb(get("category.four")?),
128 rgb(get("category.five")?),
129 rgb(get("category.six")?),
130 ],
131 })
132 }
133 }
134
135 fn rgb(c: Rgb) -> Color {
136 Color::Rgb(c.r, c.g, c.b)
137 }
138
139 /// How much color the terminal being drawn to can actually show.
140 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
141 pub enum ColorDepth {
142 /// 24-bit. Theme colors are sent as authored.
143 Full,
144 /// The sixteen ANSI colors, addressed by index.
145 Ansi16,
146 }
147
148 /// What the environment says the terminal can show.
149 ///
150 /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
151 /// and `TERM=linux` is the case this exists for: the Linux virtual console,
152 /// which is what an installer and a machine with no desktop draw on. Anything
153 /// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing
154 /// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity;
155 /// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
156 /// the user paid for.
157 pub fn detect_color_depth() -> ColorDepth {
158 let colorterm = std::env::var("COLORTERM").unwrap_or_default();
159 if colorterm == "truecolor" || colorterm == "24bit" {
160 return ColorDepth::Full;
161 }
162 match std::env::var("TERM").unwrap_or_default().as_str() {
163 "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
164 _ => ColorDepth::Full,
165 }
166 }
167
168 /// The palette entry for `c`, as an index the terminal will not reinterpret.
169 fn indexed(c: Color) -> Color {
170 match c {
171 Color::Rgb(r, g, b) => {
172 Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8)
173 }
174 other => other,
175 }
176 }
177
178 /// As [`indexed`], but guaranteed to stay legible against `on`.
179 fn indexed_against(c: Color, on: Color) -> Color {
180 match (c, on) {
181 (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => {
182 Color::Indexed(makeover::quantize_against(
183 Rgb { r, g, b },
184 Rgb {
185 r: br,
186 g: bg,
187 b: bb,
188 },
189 &makeover::ANSI_16,
190 ) as u8)
191 }
192 _ => indexed(c),
193 }
194 }
195
196 impl Theme {
197 /// This theme as the terminal can actually draw it.
198 ///
199 /// At [`ColorDepth::Full`] the theme is returned untouched. At
200 /// [`ColorDepth::Ansi16`] every color becomes a palette index, which is the
201 /// point: left as 24-bit, the terminal approximates them itself, and its
202 /// approximation collapses tones that the theme keeps apart. Alloy's
203 /// console lost its frame that way, drawing a border in a color the Linux
204 /// console could not distinguish from the page behind it.
205 ///
206 /// Anything that has to be seen against the page is quantized against it
207 /// rather than on its own, so a border stays a border and text stays
208 /// readable. The surfaces themselves are quantized plainly: they are what
209 /// the others are measured against.
210 #[must_use]
211 pub fn for_terminal(self, depth: ColorDepth) -> Theme {
212 if depth == ColorDepth::Full {
213 return self;
214 }
215
216 let page = indexed(self.surface_page);
217 let on_page = |c: Color| indexed_against(c, self.surface_page);
218
219 Theme {
220 mode: self.mode,
221
222 surface_page: page,
223 surface_raised: indexed(self.surface_raised),
224 surface_sunken: indexed(self.surface_sunken),
225 surface_overlay: indexed(self.surface_overlay),
226
227 content_primary: on_page(self.content_primary),
228 content_secondary: on_page(self.content_secondary),
229 content_muted: on_page(self.content_muted),
230
231 action_primary: on_page(self.action_primary),
232
233 status_danger: on_page(self.status_danger),
234 status_success: on_page(self.status_success),
235 status_warning: on_page(self.status_warning),
236 status_info: on_page(self.status_info),
237
238 line_border: on_page(self.line_border),
239 border_subtle: on_page(self.border_subtle),
240 border_strong: on_page(self.border_strong),
241
242 category: self.category.map(on_page),
243 }
244 }
245 }
246
247 // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
248 // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
249 // less uniform than OKLab but keeps the derived hex reproducible against the
250 // contrast tables in TOKENS.md.
251 fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
252 let al = srgb_to_linear(a);
253 let bl = srgb_to_linear(b);
254 let m = (
255 al.0 + (bl.0 - al.0) * t,
256 al.1 + (bl.1 - al.1) * t,
257 al.2 + (bl.2 - al.2) * t,
258 );
259 linear_to_srgb(m)
260 }
261
262 fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
263 (
264 channel_to_linear(c.r),
265 channel_to_linear(c.g),
266 channel_to_linear(c.b),
267 )
268 }
269
270 fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
271 Rgb {
272 r: channel_to_srgb(c.0),
273 g: channel_to_srgb(c.1),
274 b: channel_to_srgb(c.2),
275 }
276 }
277
278 fn channel_to_linear(c: u8) -> f32 {
279 let c = c as f32 / 255.0;
280 if c <= 0.04045 {
281 c / 12.92
282 } else {
283 ((c + 0.055) / 1.055).powf(2.4)
284 }
285 }
286
287 fn channel_to_srgb(c: f32) -> u8 {
288 let v = if c <= 0.003_130_8 {
289 c * 12.92
290 } else {
291 1.055 * c.powf(1.0 / 2.4) - 0.055
292 };
293 (v * 255.0).round().clamp(0.0, 255.0) as u8
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::*;
299
300 // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
301 // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
302 // must produce #7f786d (the value the contrast-audit table is calibrated on).
303 // If this test fails, the audit table in TOKENS.md is stale, not the code.
304 #[test]
305 fn akari_dawn_border_strong_matches_tokens_md() {
306 let border = Rgb::from_hex("#cabeae").unwrap();
307 let primary = Rgb::from_hex("#1a1816").unwrap();
308 let got = mix_linear_srgb(border, primary, 0.65);
309 assert_eq!(
310 (got.r, got.g, got.b),
311 (0x7f, 0x78, 0x6d),
312 "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
313 got.r,
314 got.g,
315 got.b
316 );
317 }
318
319 // Akari Dawn as far as this matters: the page, the text on it, and the
320 // strong border derived above.
321 fn akari_dawn() -> Theme {
322 let page = Color::Rgb(0xe4, 0xde, 0xd6);
323 Theme {
324 mode: Mode::Light,
325 surface_page: page,
326 surface_raised: page,
327 surface_sunken: page,
328 surface_overlay: page,
329 content_primary: Color::Rgb(0x1a, 0x18, 0x16),
330 content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
331 content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
332 action_primary: Color::Rgb(0x8a, 0x45, 0x30),
333 status_danger: Color::Rgb(0x8a, 0x45, 0x30),
334 status_success: Color::Rgb(0x8a, 0x45, 0x30),
335 status_warning: Color::Rgb(0x8a, 0x45, 0x30),
336 status_info: Color::Rgb(0x8a, 0x45, 0x30),
337 line_border: Color::Rgb(0xca, 0xbe, 0xae),
338 border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
339 border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
340 category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
341 }
342 }
343
344 #[test]
345 fn a_capable_terminal_gets_the_theme_as_authored() {
346 let theme = akari_dawn().for_terminal(ColorDepth::Full);
347 assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
348 }
349
350 // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
351 // the approximating to the terminal, which is where the collapse happened.
352 #[test]
353 fn a_sixteen_color_terminal_gets_indices() {
354 let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
355 for color in [
356 theme.surface_page,
357 theme.content_primary,
358 theme.border_strong,
359 theme.border_subtle,
360 theme.line_border,
361 ] {
362 assert!(matches!(color, Color::Indexed(_)), "{color:?}");
363 }
364 }
365
366 // The bug, as a test: the installer's frame drew in border_strong on
367 // surface_page and could not be seen.
368 #[test]
369 fn the_frame_stays_visible_against_the_page() {
370 let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
371 assert_ne!(theme.border_strong, theme.surface_page);
372 assert_ne!(theme.border_subtle, theme.surface_page);
373 assert_ne!(theme.content_primary, theme.surface_page);
374 }
375 }
376