Skip to main content

max / alloy_tui

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