Skip to main content

max / alloy_tui

27.7 KB · 678 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 std::sync::OnceLock;
19
20 use makeover::{Rgb, ThemeColors};
21 use makeover_tui::Palette;
22 use ratatui::style::Color;
23
24 /// What the terminal can show, from the family's renderer.
25 ///
26 /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth`
27 /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now
28 /// that the rendering goes through `makeover-tui`: the quantization below and
29 /// the glyph fallback over there have to agree about what the terminal is, and
30 /// two enums agreeing by convention is how they stop agreeing.
31 pub use makeover_tui::Fidelity;
32
33 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 pub enum Mode {
35 Light,
36 Dark,
37 HighContrast,
38 }
39
40 /// A theme's intents, resolved to the colors ratatui draws with.
41 ///
42 /// `#[non_exhaustive]` because this struct gains a field every time makeover
43 /// gains an intent, and without the attribute each one of those is a major here.
44 /// 3.0.0 is itself that major, forced by the bevel pair; the attribute is what
45 /// stops the next token from forcing another. Added in this release because it
46 /// is the last moment it is free — nothing outside this crate builds a `Theme`
47 /// field-by-field today, since [`Theme::from_theme`] is the only sane way to get
48 /// one and a partial theme is an error rather than a default.
49 ///
50 /// The cost is real and accepted: a downstream crate can no longer construct one
51 /// literally or match it exhaustively. For a palette that is *defined* as
52 /// however many intents makeover currently has, neither is a thing a consumer
53 /// should be doing.
54 #[derive(Debug, Clone, Copy)]
55 #[non_exhaustive]
56 pub struct Theme {
57 pub mode: Mode,
58
59 pub surface_page: Color,
60 pub surface_raised: Color,
61 pub surface_sunken: Color,
62 pub surface_overlay: Color,
63
64 /// makeover's inset content surface: the surface inside a raised container,
65 /// so a list reads as content in a container rather than as bands on a panel.
66 ///
67 /// Not [`surface_sunken`](Theme::surface_sunken), and the distinction is the
68 /// reason this field exists rather than being aliased onto that one. A theme
69 /// is free to author sunken *darker* than raised (goingson does) while a well
70 /// always inverts away from the text, so substituting one for the other lands
71 /// a well on the wrong side of its face on exactly the themes where it
72 /// matters. `makeover-tui` deleted that substitution from the description on
73 /// purpose; reintroducing it here would put it back a layer down.
74 ///
75 /// `None` where makeover derived nothing, which is a theme that authors no
76 /// raised surface or no content color. Left missing rather than guessed, per
77 /// the same rule: [`Palette::fill`] answers a missing well with structure.
78 pub surface_well: Option<Color>,
79
80 pub content_primary: Color,
81 pub content_secondary: Color,
82 pub content_muted: Color,
83
84 pub action_primary: Color,
85
86 pub status_danger: Color,
87 pub status_success: Color,
88 pub status_warning: Color,
89 pub status_info: Color,
90
91 pub line_border: Color,
92 pub border_subtle: Color,
93 pub border_strong: Color,
94
95 /// The lit and shadowed edges of a raised surface, from makeover.
96 ///
97 /// A control is lit from the top left, so its top and left edges take
98 /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
99 /// two recesses it, which is what a pressed state and a text well are. The
100 /// light source does not flip with the theme's polarity — a dark theme is lit
101 /// from the same corner, or the rule stops transferring between widgets,
102 /// which is the whole reason to have one.
103 pub bevel_light: Color,
104 pub bevel_dark: Color,
105
106 pub category: [Color; 6],
107 }
108
109 #[derive(Debug, Clone)]
110 pub enum ThemeError {
111 MissingKey(&'static str),
112 InvalidHex { key: &'static str, value: String },
113 }
114
115 impl std::fmt::Display for ThemeError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
119 ThemeError::InvalidHex { key, value } => {
120 write!(f, "theme key `{key}` has invalid hex value `{value}`")
121 }
122 }
123 }
124 }
125
126 impl std::error::Error for ThemeError {}
127
128 impl Theme {
129 /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
130 /// Requires every intent Alloy renders — a malformed or partial theme is
131 /// rejected explicitly rather than silently rendering with defaults.
132 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
133 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
134 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
135 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
136 key,
137 value: hex.clone(),
138 })
139 };
140
141 // The bevel pair is makeover's, so that a console, a webview and an egui
142 // app light a raised surface the same way. Read through `resolve` rather
143 // than recomputed here, which is the point of it living in the crate.
144 let resolved = makeover::resolve(theme);
145 let intent = |key: &'static str| -> Result<Rgb, ThemeError> {
146 let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
147 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
148 key,
149 value: hex.to_string(),
150 })
151 };
152
153 let surface_page = get("surface.page")?;
154 let content_primary = get("content.primary")?;
155 let line_border = get("line.border")?;
156
157 // These two stay local, and deliberately, though makeover also emits a
158 // `border-strong`. Its version is a fixed 5% darkening of the authored
159 // border, which is a slightly firmer divider; this one is pulled most of
160 // the way to the text color because Alloy spends it on the focus ring,
161 // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md
162 // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at
163 // 3.27:1 and 1.63:1, so they are different tokens wearing one name and
164 // adopting the shared one would take focus to half the required floor.
165 let border_subtle = border_subtle(line_border, surface_page);
166 let border_strong = border_strong(line_border, content_primary);
167
168 let mode = match theme.meta.variant.as_str() {
169 "dark" => Mode::Dark,
170 "high-contrast" => Mode::HighContrast,
171 _ => Mode::Light,
172 };
173
174 Ok(Self {
175 mode,
176
177 surface_page: rgb(surface_page),
178 surface_raised: rgb(get("surface.raised")?),
179 surface_sunken: rgb(get("surface.sunken")?),
180 surface_overlay: rgb(get("surface.overlay")?),
181
182 // Optional where the others are required, because it is derived
183 // rather than authored: makeover emits it only when the theme gave
184 // it both a raised surface and a content color to read the direction
185 // off. Demanding it would reject a theme that is otherwise complete.
186 surface_well: resolved
187 .hex("surface-well")
188 .and_then(Rgb::from_hex)
189 .map(rgb),
190
191 content_primary: rgb(content_primary),
192 content_secondary: rgb(get("content.secondary")?),
193 content_muted: rgb(get("content.muted")?),
194
195 action_primary: rgb(get("action.primary")?),
196
197 status_danger: rgb(get("status.danger")?),
198 status_success: rgb(get("status.success")?),
199 status_warning: rgb(get("status.warning")?),
200 status_info: rgb(get("status.info")?),
201
202 line_border: rgb(line_border),
203 border_subtle: rgb(border_subtle),
204 border_strong: rgb(border_strong),
205
206 bevel_light: rgb(intent("bevel-light")?),
207 bevel_dark: rgb(intent("bevel-dark")?),
208
209 category: [
210 rgb(get("category.one")?),
211 rgb(get("category.two")?),
212 rgb(get("category.three")?),
213 rgb(get("category.four")?),
214 rgb(get("category.five")?),
215 rgb(get("category.six")?),
216 ],
217 })
218 }
219 }
220
221 fn rgb(c: Rgb) -> Color {
222 Color::Rgb(c.r, c.g, c.b)
223 }
224
225 /// What the terminal can show, read once per process.
226 ///
227 /// The answer an application wants in both places it is needed: passed to
228 /// [`Theme::for_terminal`] to quantize the theme, and to [`Theme::palette`] so
229 /// the renderer knows what the colors it was handed were quantized *to*. Asking
230 /// once and threading the same value through is what keeps those two consistent;
231 /// calling [`Fidelity::detect`] twice would too, but nothing enforces that it is
232 /// the same call, and this is cheaper besides.
233 ///
234 /// [`Fidelity::detect`] reads two environment variables. They cannot change under
235 /// a running process in any way that matters, and a bevel is drawn many times a
236 /// frame, so asking once is both cheaper and more consistent than asking per
237 /// render.
238 ///
239 /// Replaces `detect_color_depth`, which answered the same question in Alloy's own
240 /// vocabulary.
241 #[must_use]
242 pub fn fidelity() -> Fidelity {
243 static DETECTED: OnceLock<Fidelity> = OnceLock::new();
244 *DETECTED.get_or_init(Fidelity::detect)
245 }
246
247 /// The palette to quantize into, and what to add to an index in it to get the
248 /// number the terminal wants.
249 ///
250 /// 256 resolves to makeover's fixed region rather than the whole table: the low
251 /// sixteen are repaintable in every emulator, so a match landing there is a
252 /// match against a color the user may have moved out from under it.
253 fn palette_for(fidelity: Fidelity) -> Option<(&'static [makeover::Rgb], usize)> {
254 match fidelity {
255 Fidelity::TrueColor => None,
256 Fidelity::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
257 Fidelity::Ansi16 => Some((&makeover::ANSI_16, 0)),
258 }
259 }
260
261 /// The palette entry for `c`, as an index the terminal will not reinterpret.
262 fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color {
263 match c {
264 Color::Rgb(r, g, b) => {
265 Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8)
266 }
267 other => other,
268 }
269 }
270
271 /// As [`indexed`], but guaranteed to stay legible against `on`.
272 ///
273 /// Only for a color whose job is to be told apart from a known background. It
274 /// answers "nearest entry that still contrasts with `on`" and has no notion of
275 /// which side of `on` the answer should fall, so a pair of colors that must also
276 /// stay apart from *each other* is the one thing it must not be used for: both
277 /// are pushed onto the same contrasting entry. That is why the bevel edges go
278 /// through [`indexed`].
279 fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color {
280 match (c, on) {
281 (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
282 (makeover::quantize_against(
283 Rgb { r, g, b },
284 Rgb {
285 r: br,
286 g: bg,
287 b: bb,
288 },
289 palette,
290 ) + offset) as u8,
291 ),
292 _ => indexed(c, palette, offset),
293 }
294 }
295
296 impl Theme {
297 /// This theme as the terminal can actually draw it.
298 ///
299 /// At [`Fidelity::TrueColor`] the theme is returned untouched. Otherwise every
300 /// color becomes a palette index, which is the point: left as 24-bit, the
301 /// terminal approximates them itself, and its approximation collapses tones
302 /// that the theme keeps apart. Alloy's console lost its frame that way,
303 /// drawing a border in a color the Linux console could not distinguish from
304 /// the page behind it.
305 ///
306 /// Anything that has to be seen against the page is quantized against it
307 /// rather than on its own, so a border stays a border and text stays
308 /// readable. The surfaces themselves are quantized plainly: they are what
309 /// the others are measured against.
310 ///
311 /// The bevel edges are quantized plainly too, for a different reason. They
312 /// are measured against the raised surface they surround rather than against
313 /// the page, and running them through [`indexed_against`] would push both
314 /// onto the same entry and invert the bevel on one side. At
315 /// [`Fidelity::Ansi16`] the palette cannot hold the pair at all and one
316 /// edge lands back on its face, which is a property of sixteen colors rather
317 /// than something this can fix. A caller drawing there does not have to
318 /// handle that itself: [`Theme::palette`] carries the fidelity through to
319 /// `makeover-tui`, which answers it with glyphs instead of tones.
320 #[must_use]
321 pub fn for_terminal(self, fidelity: Fidelity) -> Theme {
322 let Some((palette, offset)) = palette_for(fidelity) else {
323 return self;
324 };
325
326 let plain = |c: Color| indexed(c, palette, offset);
327 let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset);
328
329 Theme {
330 mode: self.mode,
331
332 surface_page: plain(self.surface_page),
333 surface_raised: plain(self.surface_raised),
334 surface_sunken: plain(self.surface_sunken),
335 surface_overlay: plain(self.surface_overlay),
336 // Plainly, like the other surfaces and for the same reason as the
337 // bevel pair: a well is measured against the raised face it is cut
338 // into, not against the page, so quantizing it against the page
339 // would push it toward contrast it is not supposed to have.
340 surface_well: self.surface_well.map(plain),
341
342 content_primary: on_page(self.content_primary),
343 content_secondary: on_page(self.content_secondary),
344 content_muted: on_page(self.content_muted),
345
346 action_primary: on_page(self.action_primary),
347
348 status_danger: on_page(self.status_danger),
349 status_success: on_page(self.status_success),
350 status_warning: on_page(self.status_warning),
351 status_info: on_page(self.status_info),
352
353 line_border: on_page(self.line_border),
354 border_subtle: on_page(self.border_subtle),
355 border_strong: on_page(self.border_strong),
356
357 bevel_light: plain(self.bevel_light),
358 bevel_dark: plain(self.bevel_dark),
359
360 category: self.category.map(on_page),
361 }
362 }
363 }
364
365 impl Theme {
366 /// This theme as `makeover-tui`'s renderer wants it.
367 ///
368 /// The one place a [`Palette`] is assembled. Every widget that draws through
369 /// the family renderer asks here rather than filling the struct itself,
370 /// because two of the fields are decisions rather than lookups — which token
371 /// serves as the well, and whether `fidelity` matches what the colors were
372 /// actually quantized to — and a per-widget copy is a per-widget chance to
373 /// answer them differently.
374 ///
375 /// `fidelity` has to be the same value passed to [`Theme::for_terminal`].
376 /// The renderer takes its colors already quantized and cannot recover the
377 /// depth from them afterwards, so it is told; telling it something else is
378 /// how a frame ends up drawing a glyph fallback over colors that did not
379 /// need one, or skipping the fallback over colors that did.
380 #[must_use]
381 pub fn palette(&self, fidelity: Fidelity) -> Palette {
382 Palette {
383 page: self.surface_page,
384 raised: self.surface_raised,
385 overlay: self.surface_overlay,
386 well: self.surface_well,
387 bevel_light: self.bevel_light,
388 bevel_dark: self.bevel_dark,
389 fidelity,
390 }
391 }
392 }
393
394 /// Alloy's decorative divider: the authored border pulled toward the page.
395 ///
396 /// Public because the console is not the only thing that renders this token.
397 /// The image's desktop skeleton — GTK, sway, yazi and the rest — is generated
398 /// from the same theme file, and a second implementation of this line is a
399 /// second answer to what `border-subtle` is. There is no built-in palette to
400 /// fall back on (docs/TOKENS.md: no hex in Rust), so the generator asks here.
401 pub fn border_subtle(line_border: Rgb, surface_page: Rgb) -> Rgb {
402 mix_linear_srgb(line_border, surface_page, 0.60)
403 }
404
405 /// Alloy's focus and selection border: the authored border pulled toward text.
406 ///
407 /// Held to WCAG AA-UI against the page by TOKENS.md, which is why it is not
408 /// makeover's `border-strong` — see the note in [`Theme::from_theme`]. Public
409 /// for the same reason as [`border_subtle`].
410 pub fn border_strong(line_border: Rgb, content_primary: Rgb) -> Rgb {
411 mix_linear_srgb(line_border, content_primary, 0.65)
412 }
413
414 /// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
415 /// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
416 /// less uniform than OKLab but keeps the derived hex reproducible against the
417 /// contrast tables in TOKENS.md.
418 ///
419 /// Exposed alongside the two derivations above so a caller composing its own
420 /// tone reaches for the same mix the tokens use rather than OKLab's, which
421 /// would answer differently.
422 pub fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
423 let al = srgb_to_linear(a);
424 let bl = srgb_to_linear(b);
425 let m = (
426 al.0 + (bl.0 - al.0) * t,
427 al.1 + (bl.1 - al.1) * t,
428 al.2 + (bl.2 - al.2) * t,
429 );
430 linear_to_srgb(m)
431 }
432
433 fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
434 (
435 channel_to_linear(c.r),
436 channel_to_linear(c.g),
437 channel_to_linear(c.b),
438 )
439 }
440
441 fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
442 Rgb {
443 r: channel_to_srgb(c.0),
444 g: channel_to_srgb(c.1),
445 b: channel_to_srgb(c.2),
446 }
447 }
448
449 fn channel_to_linear(c: u8) -> f32 {
450 let c = c as f32 / 255.0;
451 if c <= 0.04045 {
452 c / 12.92
453 } else {
454 ((c + 0.055) / 1.055).powf(2.4)
455 }
456 }
457
458 fn channel_to_srgb(c: f32) -> u8 {
459 let v = if c <= 0.003_130_8 {
460 c * 12.92
461 } else {
462 1.055 * c.powf(1.0 / 2.4) - 0.055
463 };
464 (v * 255.0).round().clamp(0.0, 255.0) as u8
465 }
466
467 #[cfg(test)]
468 mod tests {
469 use super::*;
470
471 // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
472 // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
473 // must produce #7f786d (the value the contrast-audit table is calibrated on).
474 // If this test fails, the audit table in TOKENS.md is stale, not the code.
475 #[test]
476 fn akari_dawn_border_strong_matches_tokens_md() {
477 let border = Rgb::from_hex("#cabeae").unwrap();
478 let primary = Rgb::from_hex("#1a1816").unwrap();
479 let got = border_strong(border, primary);
480 assert_eq!(
481 (got.r, got.g, got.b),
482 (0x7f, 0x78, 0x6d),
483 "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
484 got.r,
485 got.g,
486 got.b
487 );
488 }
489
490 // The other half of the pair, pinned for the same reason: the image's
491 // desktop skeleton is generated against these two functions, so a drift
492 // here silently repaints every GTK app, sway border and yazi pane.
493 #[test]
494 fn akari_dawn_border_subtle_matches_the_shipped_skeleton() {
495 let border = Rgb::from_hex("#cabeae").unwrap();
496 let page = Rgb::from_hex("#e4ded6").unwrap();
497 let got = border_subtle(border, page);
498 assert_eq!(
499 (got.r, got.g, got.b),
500 (0xda, 0xd2, 0xc7),
501 "border-subtle derivation drifted; got #{:02x}{:02x}{:02x}, expected #dad2c7",
502 got.r,
503 got.g,
504 got.b
505 );
506 }
507
508 // Akari Dawn as far as this matters: the page, the text on it, and the
509 // strong border derived above.
510 fn akari_dawn() -> Theme {
511 let page = Color::Rgb(0xe4, 0xde, 0xd6);
512 Theme {
513 mode: Mode::Light,
514 surface_page: page,
515 surface_raised: page,
516 surface_sunken: page,
517 surface_overlay: page,
518 // As makeover derives it from Akari Dawn's real raised surface,
519 // #ede7de, which this fixture flattens onto the page: the theme's
520 // text is dark, so the well goes the other way and darkens.
521 surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)),
522 content_primary: Color::Rgb(0x1a, 0x18, 0x16),
523 content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
524 content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
525 action_primary: Color::Rgb(0x8a, 0x45, 0x30),
526 status_danger: Color::Rgb(0x8a, 0x45, 0x30),
527 status_success: Color::Rgb(0x8a, 0x45, 0x30),
528 status_warning: Color::Rgb(0x8a, 0x45, 0x30),
529 status_info: Color::Rgb(0x8a, 0x45, 0x30),
530 line_border: Color::Rgb(0xca, 0xbe, 0xae),
531 border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
532 border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
533 // As makeover derives them from Akari Dawn's real raised surface,
534 // #ede7de, which is a step above the page this fixture flattens
535 // every surface onto.
536 bevel_light: Color::Rgb(0xff, 0xfe, 0xf5),
537 bevel_dark: Color::Rgb(0xb3, 0xad, 0xa5),
538 category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
539 }
540 }
541
542 #[test]
543 fn a_capable_terminal_gets_the_theme_as_authored() {
544 let theme = akari_dawn().for_terminal(Fidelity::TrueColor);
545 assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
546 }
547
548 // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
549 // the approximating to the terminal, which is where the collapse happened.
550 #[test]
551 fn a_sixteen_color_terminal_gets_indices() {
552 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
553 for color in [
554 theme.surface_page,
555 theme.content_primary,
556 theme.border_strong,
557 theme.border_subtle,
558 theme.line_border,
559 ] {
560 assert!(matches!(color, Color::Indexed(_)), "{color:?}");
561 }
562 }
563
564 // 256 colors is the shallowest depth that can hold a bevel: the two edges
565 // and the face they surround have to reach three separate entries.
566 #[test]
567 fn a_256_color_terminal_keeps_both_bevel_edges() {
568 let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
569 assert_ne!(theme.bevel_light, theme.surface_raised);
570 assert_ne!(theme.bevel_dark, theme.surface_raised);
571 assert_ne!(theme.bevel_light, theme.bevel_dark);
572 }
573
574 // And sixteen cannot. Asserted rather than left implicit so that a caller
575 // reading this knows to spend the surviving edge on a single-tone shadow
576 // instead of drawing a bevel that resolves on two sides.
577 #[test]
578 fn a_sixteen_color_terminal_loses_one_bevel_edge() {
579 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
580 let light_survives = theme.bevel_light != theme.surface_raised;
581 let dark_survives = theme.bevel_dark != theme.surface_raised;
582 assert!(
583 light_survives != dark_survives,
584 "expected exactly one edge to survive, light {light_survives} dark {dark_survives}"
585 );
586 }
587
588 // The indices handed to a 256-color terminal have to be the ones it paints,
589 // and quantizing against the fixed region returns an index into that region.
590 // Forgetting the offset would silently address the repaintable low sixteen.
591 #[test]
592 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
593 let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
594 for color in [
595 theme.surface_page,
596 theme.content_primary,
597 theme.border_strong,
598 theme.bevel_light,
599 theme.bevel_dark,
600 ] {
601 let Color::Indexed(i) = color else {
602 panic!("{color:?} is not an index")
603 };
604 assert!(i >= 16, "index {i} is in the repaintable range");
605 }
606 }
607
608 // Detection itself is `makeover-tui`'s and tested there. What is still this
609 // crate's problem is that the answer it gives is the one this quantization
610 // was built for, so the two cases with a bug behind them are pinned here as
611 // well: the VT, whose approximation cost the console its frame, and a
612 // terminal that named itself nothing in particular, which must not be
613 // flattened to sixteen colors on no evidence.
614 #[test]
615 fn the_fidelity_this_quantizes_for_is_the_one_the_family_detects() {
616 assert_eq!(Fidelity::from_env("", "linux"), Fidelity::Ansi16);
617 assert_eq!(Fidelity::from_env("", "foot"), Fidelity::TrueColor);
618 assert_eq!(Fidelity::from_env("", "xterm-256color"), Fidelity::Ansi256);
619 }
620
621 // Every intent the renderer asks for has to be carried across, or a widget
622 // drawing through `frame` gets a hole where a surface should be. `well` is
623 // the one that can legitimately be absent, and it is absent as `None` rather
624 // than as some other surface standing in for it.
625 #[test]
626 fn the_renderer_palette_carries_the_theme_across_unsubstituted() {
627 let theme = akari_dawn();
628 let p = theme.palette(Fidelity::Ansi256);
629 assert_eq!(p.page, theme.surface_page);
630 assert_eq!(p.raised, theme.surface_raised);
631 assert_eq!(p.overlay, theme.surface_overlay);
632 assert_eq!(p.bevel_light, theme.bevel_light);
633 assert_eq!(p.bevel_dark, theme.bevel_dark);
634 assert_eq!(p.fidelity, Fidelity::Ansi256);
635 assert_eq!(p.well, theme.surface_well);
636
637 let no_well = Theme {
638 surface_well: None,
639 ..theme
640 };
641 assert_eq!(no_well.palette(Fidelity::TrueColor).well, None);
642 assert_ne!(
643 no_well.palette(Fidelity::TrueColor).well,
644 Some(no_well.surface_sunken)
645 );
646 }
647
648 // A well is measured against the face it is cut into, so it quantizes
649 // plainly like the other surfaces. Run through `indexed_against` it would be
650 // pushed toward contrast with the page, which is the one thing a well is not
651 // supposed to have.
652 #[test]
653 fn a_well_survives_quantization_as_a_surface() {
654 let theme = Theme {
655 surface_raised: Color::Rgb(0xed, 0xe7, 0xde),
656 surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)),
657 ..akari_dawn()
658 };
659 let quantized = theme.for_terminal(Fidelity::Ansi256);
660 let well = quantized.surface_well.expect("a well went missing");
661 assert!(matches!(well, Color::Indexed(_)), "{well:?}");
662 assert_ne!(
663 well, quantized.surface_raised,
664 "the well collapsed onto its face"
665 );
666 }
667
668 // The bug, as a test: the installer's frame drew in border_strong on
669 // surface_page and could not be seen.
670 #[test]
671 fn the_frame_stays_visible_against_the_page() {
672 let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
673 assert_ne!(theme.border_strong, theme.surface_page);
674 assert_ne!(theme.border_subtle, theme.surface_page);
675 assert_ne!(theme.content_primary, theme.surface_page);
676 }
677 }
678