Skip to main content

max / alloy_tui

22.8 KB · 586 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 /// A theme's intents, resolved to the colors ratatui draws with.
29 ///
30 /// `#[non_exhaustive]` because this struct gains a field every time makeover
31 /// gains an intent, and without the attribute each one of those is a major here.
32 /// 3.0.0 is itself that major, forced by the bevel pair; the attribute is what
33 /// stops the next token from forcing another. Added in this release because it
34 /// is the last moment it is free — nothing outside this crate builds a `Theme`
35 /// field-by-field today, since [`Theme::from_theme`] is the only sane way to get
36 /// one and a partial theme is an error rather than a default.
37 ///
38 /// The cost is real and accepted: a downstream crate can no longer construct one
39 /// literally or match it exhaustively. For a palette that is *defined* as
40 /// however many intents makeover currently has, neither is a thing a consumer
41 /// should be doing.
42 #[derive(Debug, Clone, Copy)]
43 #[non_exhaustive]
44 pub struct Theme {
45 pub mode: Mode,
46
47 pub surface_page: Color,
48 pub surface_raised: Color,
49 pub surface_sunken: Color,
50 pub surface_overlay: Color,
51
52 pub content_primary: Color,
53 pub content_secondary: Color,
54 pub content_muted: Color,
55
56 pub action_primary: Color,
57
58 pub status_danger: Color,
59 pub status_success: Color,
60 pub status_warning: Color,
61 pub status_info: Color,
62
63 pub line_border: Color,
64 pub border_subtle: Color,
65 pub border_strong: Color,
66
67 /// The lit and shadowed edges of a raised surface, from makeover.
68 ///
69 /// A control is lit from the top left, so its top and left edges take
70 /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
71 /// two recesses it, which is what a pressed state and a text well are. The
72 /// light source does not flip with the theme's polarity — a dark theme is lit
73 /// from the same corner, or the rule stops transferring between widgets,
74 /// which is the whole reason to have one.
75 pub bevel_light: Color,
76 pub bevel_dark: Color,
77
78 pub category: [Color; 6],
79 }
80
81 #[derive(Debug, Clone)]
82 pub enum ThemeError {
83 MissingKey(&'static str),
84 InvalidHex { key: &'static str, value: String },
85 }
86
87 impl std::fmt::Display for ThemeError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
91 ThemeError::InvalidHex { key, value } => {
92 write!(f, "theme key `{key}` has invalid hex value `{value}`")
93 }
94 }
95 }
96 }
97
98 impl std::error::Error for ThemeError {}
99
100 impl Theme {
101 /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
102 /// Requires every intent Alloy renders — a malformed or partial theme is
103 /// rejected explicitly rather than silently rendering with defaults.
104 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
105 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
106 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
107 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
108 key,
109 value: hex.clone(),
110 })
111 };
112
113 // The bevel pair is makeover's, so that a console, a webview and an egui
114 // app light a raised surface the same way. Read through `resolve` rather
115 // than recomputed here, which is the point of it living in the crate.
116 let resolved = makeover::resolve(theme);
117 let intent = |key: &'static str| -> Result<Rgb, ThemeError> {
118 let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
119 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
120 key,
121 value: hex.to_string(),
122 })
123 };
124
125 let surface_page = get("surface.page")?;
126 let content_primary = get("content.primary")?;
127 let line_border = get("line.border")?;
128
129 // These two stay local, and deliberately, though makeover also emits a
130 // `border-strong`. Its version is a fixed 5% darkening of the authored
131 // border, which is a slightly firmer divider; this one is pulled most of
132 // the way to the text color because Alloy spends it on the focus ring,
133 // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md
134 // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at
135 // 3.27:1 and 1.63:1, so they are different tokens wearing one name and
136 // adopting the shared one would take focus to half the required floor.
137 let border_subtle = border_subtle(line_border, surface_page);
138 let border_strong = border_strong(line_border, content_primary);
139
140 let mode = match theme.meta.variant.as_str() {
141 "dark" => Mode::Dark,
142 "high-contrast" => Mode::HighContrast,
143 _ => Mode::Light,
144 };
145
146 Ok(Self {
147 mode,
148
149 surface_page: rgb(surface_page),
150 surface_raised: rgb(get("surface.raised")?),
151 surface_sunken: rgb(get("surface.sunken")?),
152 surface_overlay: rgb(get("surface.overlay")?),
153
154 content_primary: rgb(content_primary),
155 content_secondary: rgb(get("content.secondary")?),
156 content_muted: rgb(get("content.muted")?),
157
158 action_primary: rgb(get("action.primary")?),
159
160 status_danger: rgb(get("status.danger")?),
161 status_success: rgb(get("status.success")?),
162 status_warning: rgb(get("status.warning")?),
163 status_info: rgb(get("status.info")?),
164
165 line_border: rgb(line_border),
166 border_subtle: rgb(border_subtle),
167 border_strong: rgb(border_strong),
168
169 bevel_light: rgb(intent("bevel-light")?),
170 bevel_dark: rgb(intent("bevel-dark")?),
171
172 category: [
173 rgb(get("category.one")?),
174 rgb(get("category.two")?),
175 rgb(get("category.three")?),
176 rgb(get("category.four")?),
177 rgb(get("category.five")?),
178 rgb(get("category.six")?),
179 ],
180 })
181 }
182 }
183
184 fn rgb(c: Rgb) -> Color {
185 Color::Rgb(c.r, c.g, c.b)
186 }
187
188 /// How much color the terminal being drawn to can actually show.
189 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
190 pub enum ColorDepth {
191 /// 24-bit. Theme colors are sent as authored.
192 Full,
193 /// The xterm 256-color table, addressed by index.
194 ///
195 /// Enough to keep a two-tone bevel: both Akari themes put the two edges and
196 /// the face they surround on three separate entries here, where sixteen
197 /// colors has nothing between a face and its neighbour and one edge lands
198 /// back on the face.
199 Ansi256,
200 /// The sixteen ANSI colors, addressed by index.
201 Ansi16,
202 }
203
204 impl ColorDepth {
205 /// The palette to quantize into, and what to add to an index in it to get
206 /// the number the terminal wants.
207 ///
208 /// 256 resolves to makeover's fixed region rather than the whole table: the
209 /// low sixteen are repaintable in every emulator, so a match landing there
210 /// is a match against a color the user may have moved out from under it.
211 fn palette(self) -> Option<(&'static [makeover::Rgb], usize)> {
212 match self {
213 ColorDepth::Full => None,
214 ColorDepth::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
215 ColorDepth::Ansi16 => Some((&makeover::ANSI_16, 0)),
216 }
217 }
218 }
219
220 /// What the environment says the terminal can show.
221 ///
222 /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
223 /// and `TERM=linux` is the case this exists for: the Linux virtual console,
224 /// which is what an installer and a machine with no desktop draw on.
225 ///
226 /// A `TERM` ending in `-256color` and no `COLORTERM` is the terminal saying what
227 /// it has. Taking it at its word beats the old behavior of calling it
228 /// [`Full`](ColorDepth::Full) and sending 24-bit for it to approximate, because
229 /// its approximation is per-color and collapses tones the theme keeps apart,
230 /// which is the same failure that cost the console its frame on the VT.
231 ///
232 /// Everything else is assumed to manage 24-bit, which is the safer wrong answer:
233 /// guessing [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity,
234 /// and guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
235 /// the user paid for.
236 pub fn detect_color_depth() -> ColorDepth {
237 depth_from_env(
238 &std::env::var("COLORTERM").unwrap_or_default(),
239 &std::env::var("TERM").unwrap_or_default(),
240 )
241 }
242
243 /// [`detect_color_depth`] with the environment passed in, so the decision can be
244 /// tested without mutating a process-wide variable from a parallel test.
245 fn depth_from_env(colorterm: &str, term: &str) -> ColorDepth {
246 if colorterm == "truecolor" || colorterm == "24bit" {
247 return ColorDepth::Full;
248 }
249 match term {
250 "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
251 _ if term.ends_with("-256color") => ColorDepth::Ansi256,
252 _ => ColorDepth::Full,
253 }
254 }
255
256 /// The palette entry for `c`, as an index the terminal will not reinterpret.
257 fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color {
258 match c {
259 Color::Rgb(r, g, b) => {
260 Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8)
261 }
262 other => other,
263 }
264 }
265
266 /// As [`indexed`], but guaranteed to stay legible against `on`.
267 ///
268 /// Only for a color whose job is to be told apart from a known background. It
269 /// answers "nearest entry that still contrasts with `on`" and has no notion of
270 /// which side of `on` the answer should fall, so a pair of colors that must also
271 /// stay apart from *each other* is the one thing it must not be used for: both
272 /// are pushed onto the same contrasting entry. That is why the bevel edges go
273 /// through [`indexed`].
274 fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color {
275 match (c, on) {
276 (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
277 (makeover::quantize_against(
278 Rgb { r, g, b },
279 Rgb {
280 r: br,
281 g: bg,
282 b: bb,
283 },
284 palette,
285 ) + offset) as u8,
286 ),
287 _ => indexed(c, palette, offset),
288 }
289 }
290
291 impl Theme {
292 /// This theme as the terminal can actually draw it.
293 ///
294 /// At [`ColorDepth::Full`] the theme is returned untouched. Otherwise every
295 /// color becomes a palette index, which is the point: left as 24-bit, the
296 /// terminal approximates them itself, and its approximation collapses tones
297 /// that the theme keeps apart. Alloy's console lost its frame that way,
298 /// drawing a border in a color the Linux console could not distinguish from
299 /// the page behind it.
300 ///
301 /// Anything that has to be seen against the page is quantized against it
302 /// rather than on its own, so a border stays a border and text stays
303 /// readable. The surfaces themselves are quantized plainly: they are what
304 /// the others are measured against.
305 ///
306 /// The bevel edges are quantized plainly too, for a different reason. They
307 /// are measured against the raised surface they surround rather than against
308 /// the page, and running them through [`indexed_against`] would push both
309 /// onto the same entry and invert the bevel on one side. At
310 /// [`ColorDepth::Ansi16`] the palette cannot hold the pair at all and one
311 /// edge lands back on its face, which is a property of sixteen colors rather
312 /// than something this can fix: a caller drawing there should spend the edge
313 /// that survives on a single-tone shadow.
314 #[must_use]
315 pub fn for_terminal(self, depth: ColorDepth) -> Theme {
316 let Some((palette, offset)) = depth.palette() else {
317 return self;
318 };
319
320 let plain = |c: Color| indexed(c, palette, offset);
321 let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset);
322
323 Theme {
324 mode: self.mode,
325
326 surface_page: plain(self.surface_page),
327 surface_raised: plain(self.surface_raised),
328 surface_sunken: plain(self.surface_sunken),
329 surface_overlay: plain(self.surface_overlay),
330
331 content_primary: on_page(self.content_primary),
332 content_secondary: on_page(self.content_secondary),
333 content_muted: on_page(self.content_muted),
334
335 action_primary: on_page(self.action_primary),
336
337 status_danger: on_page(self.status_danger),
338 status_success: on_page(self.status_success),
339 status_warning: on_page(self.status_warning),
340 status_info: on_page(self.status_info),
341
342 line_border: on_page(self.line_border),
343 border_subtle: on_page(self.border_subtle),
344 border_strong: on_page(self.border_strong),
345
346 bevel_light: plain(self.bevel_light),
347 bevel_dark: plain(self.bevel_dark),
348
349 category: self.category.map(on_page),
350 }
351 }
352 }
353
354 /// Alloy's decorative divider: the authored border pulled toward the page.
355 ///
356 /// Public because the console is not the only thing that renders this token.
357 /// The image's desktop skeleton — GTK, sway, yazi and the rest — is generated
358 /// from the same theme file, and a second implementation of this line is a
359 /// second answer to what `border-subtle` is. There is no built-in palette to
360 /// fall back on (docs/TOKENS.md: no hex in Rust), so the generator asks here.
361 pub fn border_subtle(line_border: Rgb, surface_page: Rgb) -> Rgb {
362 mix_linear_srgb(line_border, surface_page, 0.60)
363 }
364
365 /// Alloy's focus and selection border: the authored border pulled toward text.
366 ///
367 /// Held to WCAG AA-UI against the page by TOKENS.md, which is why it is not
368 /// makeover's `border-strong` — see the note in [`Theme::from_theme`]. Public
369 /// for the same reason as [`border_subtle`].
370 pub fn border_strong(line_border: Rgb, content_primary: Rgb) -> Rgb {
371 mix_linear_srgb(line_border, content_primary, 0.65)
372 }
373
374 /// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
375 /// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
376 /// less uniform than OKLab but keeps the derived hex reproducible against the
377 /// contrast tables in TOKENS.md.
378 ///
379 /// Exposed alongside the two derivations above so a caller composing its own
380 /// tone reaches for the same mix the tokens use rather than OKLab's, which
381 /// would answer differently.
382 pub fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
383 let al = srgb_to_linear(a);
384 let bl = srgb_to_linear(b);
385 let m = (
386 al.0 + (bl.0 - al.0) * t,
387 al.1 + (bl.1 - al.1) * t,
388 al.2 + (bl.2 - al.2) * t,
389 );
390 linear_to_srgb(m)
391 }
392
393 fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
394 (
395 channel_to_linear(c.r),
396 channel_to_linear(c.g),
397 channel_to_linear(c.b),
398 )
399 }
400
401 fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
402 Rgb {
403 r: channel_to_srgb(c.0),
404 g: channel_to_srgb(c.1),
405 b: channel_to_srgb(c.2),
406 }
407 }
408
409 fn channel_to_linear(c: u8) -> f32 {
410 let c = c as f32 / 255.0;
411 if c <= 0.04045 {
412 c / 12.92
413 } else {
414 ((c + 0.055) / 1.055).powf(2.4)
415 }
416 }
417
418 fn channel_to_srgb(c: f32) -> u8 {
419 let v = if c <= 0.003_130_8 {
420 c * 12.92
421 } else {
422 1.055 * c.powf(1.0 / 2.4) - 0.055
423 };
424 (v * 255.0).round().clamp(0.0, 255.0) as u8
425 }
426
427 #[cfg(test)]
428 mod tests {
429 use super::*;
430
431 // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
432 // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
433 // must produce #7f786d (the value the contrast-audit table is calibrated on).
434 // If this test fails, the audit table in TOKENS.md is stale, not the code.
435 #[test]
436 fn akari_dawn_border_strong_matches_tokens_md() {
437 let border = Rgb::from_hex("#cabeae").unwrap();
438 let primary = Rgb::from_hex("#1a1816").unwrap();
439 let got = border_strong(border, primary);
440 assert_eq!(
441 (got.r, got.g, got.b),
442 (0x7f, 0x78, 0x6d),
443 "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
444 got.r,
445 got.g,
446 got.b
447 );
448 }
449
450 // The other half of the pair, pinned for the same reason: the image's
451 // desktop skeleton is generated against these two functions, so a drift
452 // here silently repaints every GTK app, sway border and yazi pane.
453 #[test]
454 fn akari_dawn_border_subtle_matches_the_shipped_skeleton() {
455 let border = Rgb::from_hex("#cabeae").unwrap();
456 let page = Rgb::from_hex("#e4ded6").unwrap();
457 let got = border_subtle(border, page);
458 assert_eq!(
459 (got.r, got.g, got.b),
460 (0xda, 0xd2, 0xc7),
461 "border-subtle derivation drifted; got #{:02x}{:02x}{:02x}, expected #dad2c7",
462 got.r,
463 got.g,
464 got.b
465 );
466 }
467
468 // Akari Dawn as far as this matters: the page, the text on it, and the
469 // strong border derived above.
470 fn akari_dawn() -> Theme {
471 let page = Color::Rgb(0xe4, 0xde, 0xd6);
472 Theme {
473 mode: Mode::Light,
474 surface_page: page,
475 surface_raised: page,
476 surface_sunken: page,
477 surface_overlay: page,
478 content_primary: Color::Rgb(0x1a, 0x18, 0x16),
479 content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
480 content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
481 action_primary: Color::Rgb(0x8a, 0x45, 0x30),
482 status_danger: Color::Rgb(0x8a, 0x45, 0x30),
483 status_success: Color::Rgb(0x8a, 0x45, 0x30),
484 status_warning: Color::Rgb(0x8a, 0x45, 0x30),
485 status_info: Color::Rgb(0x8a, 0x45, 0x30),
486 line_border: Color::Rgb(0xca, 0xbe, 0xae),
487 border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
488 border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
489 // As makeover derives them from Akari Dawn's real raised surface,
490 // #ede7de, which is a step above the page this fixture flattens
491 // every surface onto.
492 bevel_light: Color::Rgb(0xff, 0xfe, 0xf5),
493 bevel_dark: Color::Rgb(0xb3, 0xad, 0xa5),
494 category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
495 }
496 }
497
498 #[test]
499 fn a_capable_terminal_gets_the_theme_as_authored() {
500 let theme = akari_dawn().for_terminal(ColorDepth::Full);
501 assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
502 }
503
504 // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
505 // the approximating to the terminal, which is where the collapse happened.
506 #[test]
507 fn a_sixteen_color_terminal_gets_indices() {
508 let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
509 for color in [
510 theme.surface_page,
511 theme.content_primary,
512 theme.border_strong,
513 theme.border_subtle,
514 theme.line_border,
515 ] {
516 assert!(matches!(color, Color::Indexed(_)), "{color:?}");
517 }
518 }
519
520 // 256 colors is the shallowest depth that can hold a bevel: the two edges
521 // and the face they surround have to reach three separate entries.
522 #[test]
523 fn a_256_color_terminal_keeps_both_bevel_edges() {
524 let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
525 assert_ne!(theme.bevel_light, theme.surface_raised);
526 assert_ne!(theme.bevel_dark, theme.surface_raised);
527 assert_ne!(theme.bevel_light, theme.bevel_dark);
528 }
529
530 // And sixteen cannot. Asserted rather than left implicit so that a caller
531 // reading this knows to spend the surviving edge on a single-tone shadow
532 // instead of drawing a bevel that resolves on two sides.
533 #[test]
534 fn a_sixteen_color_terminal_loses_one_bevel_edge() {
535 let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
536 let light_survives = theme.bevel_light != theme.surface_raised;
537 let dark_survives = theme.bevel_dark != theme.surface_raised;
538 assert!(
539 light_survives != dark_survives,
540 "expected exactly one edge to survive, light {light_survives} dark {dark_survives}"
541 );
542 }
543
544 // The indices handed to a 256-color terminal have to be the ones it paints,
545 // and quantizing against the fixed region returns an index into that region.
546 // Forgetting the offset would silently address the repaintable low sixteen.
547 #[test]
548 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
549 let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
550 for color in [
551 theme.surface_page,
552 theme.content_primary,
553 theme.border_strong,
554 theme.bevel_light,
555 theme.bevel_dark,
556 ] {
557 let Color::Indexed(i) = color else {
558 panic!("{color:?} is not an index")
559 };
560 assert!(i >= 16, "index {i} is in the repaintable range");
561 }
562 }
563
564 #[test]
565 fn a_256_color_term_is_detected_from_its_name() {
566 let depth = depth_from_env;
567 assert_eq!(depth("", "xterm-256color"), ColorDepth::Ansi256);
568 assert_eq!(depth("", "screen-256color"), ColorDepth::Ansi256);
569 // A terminal claiming 24-bit is believed over its name.
570 assert_eq!(depth("truecolor", "xterm-256color"), ColorDepth::Full);
571 // The VT is still the VT.
572 assert_eq!(depth("", "linux"), ColorDepth::Ansi16);
573 assert_eq!(depth("", "foot"), ColorDepth::Full);
574 }
575
576 // The bug, as a test: the installer's frame drew in border_strong on
577 // surface_page and could not be seen.
578 #[test]
579 fn the_frame_stays_visible_against_the_page() {
580 let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
581 assert_ne!(theme.border_strong, theme.surface_page);
582 assert_ne!(theme.border_subtle, theme.surface_page);
583 assert_ne!(theme.content_primary, theme.surface_page);
584 }
585 }
586