Skip to main content

max / alloy_tui

22.1 KB · 522 lines History Blame Raw
1 //! Alloy's theme: the family's resolved intents plus the two border tokens
2 //! Alloy derives for itself.
3 //!
4 //! The intent-to-`Color` mapping is [`makeover_tui::Theme`]. It used to be a
5 //! second copy here, and the two agreeing about which intent a surface reads
6 //! from was a convention rather than a fact. Quantisation moved with it, for the
7 //! same reason and one more: [`makeover_tui::Theme`] is `#[non_exhaustive]`, so
8 //! a copy out here could not rebuild a quantised version of it anyway.
9 //!
10 //! # The two tokens that stayed, and why they are not duplication
11 //!
12 //! Per the Alloy repo's docs/TOKENS.md, Alloy derives two tokens locally so
13 //! theme files stay minimal and cross-app compatible:
14 //!
15 //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
16 //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
17 //!
18 //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
19 //!
20 //! makeover emits a `border-strong` too, and [`makeover_tui::Theme`] carries it:
21 //! a flat 5% darkening of the authored border, a slightly firmer divider. Alloy's
22 //! is pulled most of the way to the text colour because DESIGN-LANGUAGE.md makes
23 //! it the entire focus cue and TOKENS.md holds it to WCAG AA-UI against the page.
24 //! On Akari Dawn the two land at 3.27:1 and 1.63:1. They are different tokens
25 //! wearing one name, and adopting the shared one would take focus to half the
26 //! required floor.
27 //!
28 //! That is exactly why the family intents sit behind a named field rather than
29 //! being flattened in or reached through `Deref`: at every call site,
30 //! `theme.border_strong` is Alloy's focus ring and `theme.makeover.border_strong`
31 //! is the divider, and neither can be mistaken for the other.
32 //!
33 //! ratatui is immediate-mode with per-widget styling — there is no global visuals
34 //! object. Widgets take a `&Theme` at construction and pull colours from it. Apps
35 //! build one per theme load (`makeover::load_theme` + [`Theme::from_theme`]) and
36 //! thread it through.
37
38 use std::sync::OnceLock;
39
40 use makeover::{Rgb, ThemeColors};
41 use makeover_tui::{Palette, Quantize};
42 use ratatui::style::Color;
43
44 /// What the terminal can show, from the family's renderer.
45 ///
46 /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth`
47 /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now
48 /// that the rendering goes through `makeover-tui`: the quantization there and
49 /// the glyph fallback there have to agree about what the terminal is, and two
50 /// enums agreeing by convention is how they stop agreeing.
51 pub use makeover_tui::Fidelity;
52
53 /// A theme's polarity, and why a theme could not be resolved. Both the family's.
54 pub use makeover_tui::{Mode, ThemeError};
55
56 /// A theme's intents as Alloy renders them.
57 ///
58 /// `#[non_exhaustive]` because Alloy can grow a derived token of its own without
59 /// that being a major, the same way [`makeover_tui::Theme`] can grow an intent.
60 #[derive(Debug, Clone, Copy)]
61 #[non_exhaustive]
62 pub struct Theme {
63 /// The family's intents: surfaces, content, status, the bevel pair, and
64 /// makeover's own `border-strong`.
65 ///
66 /// Named rather than flattened. See the module header: reading
67 /// `theme.makeover.border_strong` where you meant Alloy's focus ring is a
68 /// mistake worth being able to see.
69 pub makeover: makeover_tui::Theme,
70
71 /// Alloy's decorative divider. Not a focus ring.
72 pub border_subtle: Color,
73 /// Alloy's focus and selection border, held to WCAG AA-UI against the page.
74 ///
75 /// Not [`makeover_tui::Theme::border_strong`], which is a divider.
76 pub border_strong: Color,
77 }
78
79 impl Theme {
80 /// Resolve a loaded makeover `ThemeColors` into Alloy's theme.
81 ///
82 /// The family half is [`makeover_tui::Theme::from_theme`], which rejects a
83 /// partial theme by naming the key it wanted. The two tokens below are then
84 /// derived from intents that call has already proven present.
85 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
86 let makeover_theme = makeover_tui::Theme::from_theme(theme)?;
87
88 // Derived in Rgb rather than off the resolved `Color`s: TOKENS.md's
89 // audit math is in linear sRGB over 8-bit channels, and going through
90 // ratatui's Color and back would be a round trip for nothing. Each key
91 // is required, and `from_theme` above already failed if it were absent.
92 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
93 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
94 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
95 key,
96 value: hex.clone(),
97 })
98 };
99 let line = get("line.border")?;
100
101 Ok(Self {
102 makeover: makeover_theme,
103 border_subtle: rgb(border_subtle(line, get("surface.page")?)),
104 border_strong: rgb(border_strong(line, get("content.primary")?)),
105 })
106 }
107
108 /// This theme as the terminal can actually draw it.
109 ///
110 /// The family half is [`makeover_tui::Theme::for_terminal`], which owns the
111 /// rules: quantised against the page where a colour must stay legible
112 /// against it, plainly where it is measured against the surface it sits on.
113 /// Alloy's two tokens are borders seen against the page, so they take the
114 /// against-the-page path, through the same [`Quantize`] rather than a second
115 /// implementation of it.
116 ///
117 /// Quantised against the page *as authored*, not as quantised. The family
118 /// half does the same, and matching matters: measuring against an already
119 /// indexed page would answer a different question than the one the rule asks.
120 #[must_use]
121 pub fn for_terminal(self, fidelity: Fidelity) -> Self {
122 let Some(q) = Quantize::for_fidelity(fidelity) else {
123 return self;
124 };
125 let page = self.makeover.surface_page;
126
127 Self {
128 makeover: self.makeover.for_terminal(fidelity),
129 border_subtle: q.against(self.border_subtle, page),
130 border_strong: q.against(self.border_strong, page),
131 }
132 }
133
134 /// This theme as `makeover-tui`'s renderer wants it.
135 ///
136 /// Delegated whole: neither of Alloy's tokens is a surface or a bevel edge,
137 /// so the renderer's palette is entirely the family's.
138 ///
139 /// `fidelity` has to be the same value passed to [`Theme::for_terminal`].
140 /// The renderer takes its colours already quantised and cannot recover the
141 /// depth from them afterwards, so it is told; telling it something else is
142 /// how a frame ends up drawing a glyph fallback over colours that did not
143 /// need one, or skipping it over colours that did.
144 #[must_use]
145 pub fn palette(&self, fidelity: Fidelity) -> Palette {
146 self.makeover.palette(fidelity)
147 }
148 }
149
150 fn rgb(c: Rgb) -> Color {
151 Color::Rgb(c.r, c.g, c.b)
152 }
153
154 /// What the terminal can show, read once per process.
155 ///
156 /// The answer an application wants in both places it is needed: passed to
157 /// [`Theme::for_terminal`] to quantize the theme, and to [`Theme::palette`] so
158 /// the renderer knows what the colors it was handed were quantized *to*. Asking
159 /// once and threading the same value through is what keeps those two consistent;
160 /// calling [`Fidelity::detect`] twice would too, but nothing enforces that it is
161 /// the same call, and this is cheaper besides.
162 ///
163 /// [`Fidelity::detect`] reads two environment variables. They cannot change under
164 /// a running process in any way that matters, and a bevel is drawn many times a
165 /// frame, so asking once is both cheaper and more consistent than asking per
166 /// render.
167 #[must_use]
168 pub fn fidelity() -> Fidelity {
169 static DETECTED: OnceLock<Fidelity> = OnceLock::new();
170 *DETECTED.get_or_init(Fidelity::detect)
171 }
172
173 /// A theme with distinct, easily-named colours, for tests that assert on which
174 /// token reached which cell.
175 ///
176 /// Built by loading a real bundled theme and overwriting every field, because
177 /// [`makeover_tui::Theme`] is `#[non_exhaustive]`: a crate outside it cannot
178 /// write the literal, though it may mutate the fields of one it owns. Was three
179 /// identical literals in `bevel`, `help` and `connector`, differing only in
180 /// `mode`.
181 #[cfg(test)]
182 pub(crate) fn test_theme(mode: Mode) -> Theme {
183 let dir = makeover::bundled_themes_dir().expect("makeover ships a themes dir");
184 let colors = makeover::load_theme(&[(dir, false)], "goingson").expect("bundled theme loads");
185 let mut m = makeover_tui::Theme::from_theme(&colors).expect("bundled theme resolves");
186
187 m.mode = mode;
188 m.surface_page = Color::Rgb(0, 0, 0);
189 m.surface_raised = Color::Rgb(1, 1, 1);
190 m.surface_sunken = Color::Rgb(2, 2, 2);
191 m.surface_overlay = Color::Rgb(3, 3, 3);
192 m.surface_well = Some(Color::Rgb(9, 9, 9));
193 m.content_primary = Color::Rgb(4, 4, 4);
194 m.content_secondary = Color::Rgb(5, 5, 5);
195 m.content_muted = Color::Rgb(6, 6, 6);
196 m.action_primary = Color::Rgb(7, 7, 7);
197 m.status_danger = Color::Rgb(8, 8, 8);
198 m.status_success = Color::Rgb(9, 9, 9);
199 m.status_warning = Color::Rgb(10, 10, 10);
200 m.status_info = Color::Rgb(11, 11, 11);
201 m.line_border = Color::Rgb(12, 12, 12);
202 m.bevel_light = Color::Rgb(16, 16, 16);
203 m.bevel_dark = Color::Rgb(17, 17, 17);
204 m.category = [Color::Rgb(15, 15, 15); 6];
205
206 Theme {
207 makeover: m,
208 border_subtle: Color::Rgb(13, 13, 13),
209 border_strong: Color::Rgb(14, 14, 14),
210 }
211 }
212
213 /// Alloy's decorative divider: the authored border pulled toward the page.
214 ///
215 /// Public because the console is not the only thing that renders this token.
216 /// The image's desktop skeleton — GTK, sway, yazi and the rest — is generated
217 /// from the same theme file, and a second implementation of this line is a
218 /// second answer to what `border-subtle` is. There is no built-in palette to
219 /// fall back on (docs/TOKENS.md: no hex in Rust), so the generator asks here.
220 pub fn border_subtle(line_border: Rgb, surface_page: Rgb) -> Rgb {
221 mix_linear_srgb(line_border, surface_page, 0.60)
222 }
223
224 /// Alloy's focus and selection border: the authored border pulled toward text.
225 ///
226 /// Held to WCAG AA-UI against the page by TOKENS.md, which is why it is not
227 /// makeover's `border-strong` — see the note in [`Theme::from_theme`]. Public
228 /// for the same reason as [`border_subtle`].
229 pub fn border_strong(line_border: Rgb, content_primary: Rgb) -> Rgb {
230 mix_linear_srgb(line_border, content_primary, 0.65)
231 }
232
233 /// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
234 /// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
235 /// less uniform than OKLab but keeps the derived hex reproducible against the
236 /// contrast tables in TOKENS.md.
237 ///
238 /// Exposed alongside the two derivations above so a caller composing its own
239 /// tone reaches for the same mix the tokens use rather than OKLab's, which
240 /// would answer differently.
241 pub fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
242 let al = srgb_to_linear(a);
243 let bl = srgb_to_linear(b);
244 let m = (
245 al.0 + (bl.0 - al.0) * t,
246 al.1 + (bl.1 - al.1) * t,
247 al.2 + (bl.2 - al.2) * t,
248 );
249 linear_to_srgb(m)
250 }
251
252 fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
253 (
254 channel_to_linear(c.r),
255 channel_to_linear(c.g),
256 channel_to_linear(c.b),
257 )
258 }
259
260 fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
261 Rgb {
262 r: channel_to_srgb(c.0),
263 g: channel_to_srgb(c.1),
264 b: channel_to_srgb(c.2),
265 }
266 }
267
268 fn channel_to_linear(c: u8) -> f32 {
269 let c = c as f32 / 255.0;
270 if c <= 0.04045 {
271 c / 12.92
272 } else {
273 ((c + 0.055) / 1.055).powf(2.4)
274 }
275 }
276
277 fn channel_to_srgb(c: f32) -> u8 {
278 let v = if c <= 0.003_130_8 {
279 c * 12.92
280 } else {
281 1.055 * c.powf(1.0 / 2.4) - 0.055
282 };
283 (v * 255.0).round().clamp(0.0, 255.0) as u8
284 }
285
286 #[cfg(test)]
287 mod tests {
288 use super::*;
289
290 // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
291 // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
292 // must produce #7f786d (the value the contrast-audit table is calibrated on).
293 // If this test fails, the audit table in TOKENS.md is stale, not the code.
294 #[test]
295 fn akari_dawn_border_strong_matches_tokens_md() {
296 let border = Rgb::from_hex("#cabeae").unwrap();
297 let primary = Rgb::from_hex("#1a1816").unwrap();
298 let got = border_strong(border, primary);
299 assert_eq!(
300 (got.r, got.g, got.b),
301 (0x7f, 0x78, 0x6d),
302 "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
303 got.r,
304 got.g,
305 got.b
306 );
307 }
308
309 // The other half of the pair, pinned for the same reason: the image's
310 // desktop skeleton is generated against these two functions, so a drift
311 // here silently repaints every GTK app, sway border and yazi pane.
312 #[test]
313 fn akari_dawn_border_subtle_matches_the_shipped_skeleton() {
314 let border = Rgb::from_hex("#cabeae").unwrap();
315 let page = Rgb::from_hex("#e4ded6").unwrap();
316 let got = border_subtle(border, page);
317 assert_eq!(
318 (got.r, got.g, got.b),
319 (0xda, 0xd2, 0xc7),
320 "border-subtle derivation drifted; got #{:02x}{:02x}{:02x}, expected #dad2c7",
321 got.r,
322 got.g,
323 got.b
324 );
325 }
326
327 // Akari Dawn as far as this matters: the page, the text on it, and the
328 // strong border derived above.
329 fn akari_dawn() -> Theme {
330 let page = Color::Rgb(0xe4, 0xde, 0xd6);
331 let mut m = test_theme(Mode::Light).makeover;
332 m.surface_page = page;
333 m.surface_raised = page;
334 m.surface_sunken = page;
335 m.surface_overlay = page;
336 // As makeover derives it from Akari Dawn's real raised surface,
337 // #ede7de, which this fixture flattens onto the page: the theme's
338 // text is dark, so the well goes the other way and darkens.
339 m.surface_well = Some(Color::Rgb(0xd6, 0xd0, 0xc7));
340 m.content_primary = Color::Rgb(0x1a, 0x18, 0x16);
341 m.content_secondary = Color::Rgb(0x1a, 0x18, 0x16);
342 m.content_muted = Color::Rgb(0x7f, 0x78, 0x6d);
343 m.action_primary = Color::Rgb(0x8a, 0x45, 0x30);
344 m.status_danger = Color::Rgb(0x8a, 0x45, 0x30);
345 m.status_success = Color::Rgb(0x8a, 0x45, 0x30);
346 m.status_warning = Color::Rgb(0x8a, 0x45, 0x30);
347 m.status_info = Color::Rgb(0x8a, 0x45, 0x30);
348 m.line_border = Color::Rgb(0xca, 0xbe, 0xae);
349 // As makeover derives them from Akari Dawn's real raised surface,
350 // #ede7de, which is a step above the page this fixture flattens
351 // every surface onto.
352 m.bevel_light = Color::Rgb(0xff, 0xfe, 0xf5);
353 m.bevel_dark = Color::Rgb(0xb3, 0xad, 0xa5);
354 m.category = [Color::Rgb(0x8a, 0x45, 0x30); 6];
355
356 Theme {
357 makeover: m,
358 border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
359 border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
360 }
361 }
362
363 // The reason Alloy keeps deriving its own. makeover's `border-strong` is a
364 // 5% darkening of the authored border, a firmer divider; Alloy's is pulled
365 // most of the way to the text because TOKENS.md holds the focus ring to
366 // WCAG AA-UI against the page. If these ever coincide, one of the two
367 // derivations moved and a focus ring is about to be drawn as a divider.
368 #[test]
369 fn alloys_focus_ring_is_not_makeovers_divider() {
370 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
371 let metas = makeover::list_themes_from_dirs(&[(dir.clone(), false)]);
372 let mut checked = 0;
373 for meta in &metas {
374 let colors =
375 makeover::load_theme(&[(dir.clone(), false)], &meta.id).expect("theme loads");
376 let theme = Theme::from_theme(&colors).expect("theme resolves");
377 assert_ne!(
378 theme.border_strong, theme.makeover.border_strong,
379 "on `{}` Alloy's focus ring and makeover's divider are the same colour",
380 meta.id
381 );
382 checked += 1;
383 }
384 assert!(checked > 0, "no themes were checked");
385 }
386
387 #[test]
388 fn a_capable_terminal_gets_the_theme_as_authored() {
389 let theme = akari_dawn().for_terminal(Fidelity::TrueColor);
390 assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
391 }
392
393 // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
394 // the approximating to the terminal, which is where the collapse happened.
395 #[test]
396 fn a_sixteen_color_terminal_gets_indices() {
397 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
398 for color in [
399 theme.makeover.surface_page,
400 theme.makeover.content_primary,
401 theme.border_strong,
402 theme.border_subtle,
403 theme.makeover.line_border,
404 ] {
405 assert!(matches!(color, Color::Indexed(_)), "{color:?}");
406 }
407 }
408
409 // 256 colors is the shallowest depth that can hold a bevel: the two edges
410 // and the face they surround have to reach three separate entries.
411 #[test]
412 fn a_256_color_terminal_keeps_both_bevel_edges() {
413 let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
414 assert_ne!(theme.makeover.bevel_light, theme.makeover.surface_raised);
415 assert_ne!(theme.makeover.bevel_dark, theme.makeover.surface_raised);
416 assert_ne!(theme.makeover.bevel_light, theme.makeover.bevel_dark);
417 }
418
419 // And sixteen cannot. Asserted rather than left implicit so that a caller
420 // reading this knows to spend the surviving edge on a single-tone shadow
421 // instead of drawing a bevel that resolves on two sides.
422 #[test]
423 fn a_sixteen_color_terminal_loses_one_bevel_edge() {
424 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
425 let light_survives = theme.makeover.bevel_light != theme.makeover.surface_raised;
426 let dark_survives = theme.makeover.bevel_dark != theme.makeover.surface_raised;
427 assert!(
428 light_survives != dark_survives,
429 "expected exactly one edge to survive, light {light_survives} dark {dark_survives}"
430 );
431 }
432
433 // The indices handed to a 256-color terminal have to be the ones it paints,
434 // and quantizing against the fixed region returns an index into that region.
435 // Forgetting the offset would silently address the repaintable low sixteen.
436 #[test]
437 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
438 let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
439 for color in [
440 theme.makeover.surface_page,
441 theme.makeover.content_primary,
442 theme.border_strong,
443 theme.makeover.bevel_light,
444 theme.makeover.bevel_dark,
445 ] {
446 let Color::Indexed(i) = color else {
447 panic!("{color:?} is not an index")
448 };
449 assert!(i >= 16, "index {i} is in the repaintable range");
450 }
451 }
452
453 // Detection itself is `makeover-tui`'s and tested there. What is still this
454 // crate's problem is that the answer it gives is the one this quantization
455 // was built for, so the two cases with a bug behind them are pinned here as
456 // well: the VT, whose approximation cost the console its frame, and a
457 // terminal that named itself nothing in particular, which must not be
458 // flattened to sixteen colors on no evidence.
459 #[test]
460 fn the_fidelity_this_quantizes_for_is_the_one_the_family_detects() {
461 assert_eq!(Fidelity::from_env("", "linux"), Fidelity::Ansi16);
462 assert_eq!(Fidelity::from_env("", "foot"), Fidelity::TrueColor);
463 assert_eq!(Fidelity::from_env("", "xterm-256color"), Fidelity::Ansi256);
464 }
465
466 // Every intent the renderer asks for has to be carried across, or a widget
467 // drawing through `frame` gets a hole where a surface should be. `well` is
468 // the one that can legitimately be absent, and it is absent as `None` rather
469 // than as some other surface standing in for it.
470 #[test]
471 fn the_renderer_palette_carries_the_theme_across_unsubstituted() {
472 let theme = akari_dawn();
473 let p = theme.palette(Fidelity::Ansi256);
474 assert_eq!(p.page, theme.makeover.surface_page);
475 assert_eq!(p.raised, theme.makeover.surface_raised);
476 assert_eq!(p.overlay, theme.makeover.surface_overlay);
477 assert_eq!(p.bevel_light, theme.makeover.bevel_light);
478 assert_eq!(p.bevel_dark, theme.makeover.bevel_dark);
479 assert_eq!(p.fidelity, Fidelity::Ansi256);
480 assert_eq!(p.well, theme.makeover.surface_well);
481
482 let mut no_well = theme;
483 no_well.makeover.surface_well = None;
484 assert_eq!(no_well.palette(Fidelity::TrueColor).well, None);
485 assert_ne!(
486 no_well.palette(Fidelity::TrueColor).well,
487 Some(no_well.makeover.surface_sunken)
488 );
489 }
490
491 // A well is measured against the face it is cut into, so it quantizes
492 // plainly like the other surfaces. Run through `indexed_against` it would be
493 // pushed toward contrast with the page, which is the one thing a well is not
494 // supposed to have.
495 #[test]
496 fn a_well_survives_quantization_as_a_surface() {
497 let mut theme = akari_dawn();
498 theme.makeover.surface_raised = Color::Rgb(0xed, 0xe7, 0xde);
499 theme.makeover.surface_well = Some(Color::Rgb(0xd6, 0xd0, 0xc7));
500 let quantized = theme.for_terminal(Fidelity::Ansi256);
501 let well = quantized
502 .makeover
503 .surface_well
504 .expect("a well went missing");
505 assert!(matches!(well, Color::Indexed(_)), "{well:?}");
506 assert_ne!(
507 well, quantized.makeover.surface_raised,
508 "the well collapsed onto its face"
509 );
510 }
511
512 // The bug, as a test: the installer's frame drew in border_strong on
513 // surface_page and could not be seen.
514 #[test]
515 fn the_frame_stays_visible_against_the_page() {
516 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
517 assert_ne!(theme.border_strong, theme.makeover.surface_page);
518 assert_ne!(theme.border_subtle, theme.makeover.surface_page);
519 assert_ne!(theme.makeover.content_primary, theme.makeover.surface_page);
520 }
521 }
522