Skip to main content

max / makeover

21.5 KB · 600 lines History Blame Raw
1 //! Low-color terminals
2
3 use crate::{Rgb, wcag_contrast};
4
5 // Names this module's prose links to, resolved for rustdoc.
6 #[allow(unused_imports)]
7 use crate::{ThemeColors, mix};
8
9 /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
10 /// arrangement the Linux console and most emulators start from.
11 ///
12 /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
13 /// rather than white, which is the entry a themed surface usually lands on, and
14 /// index 15 is the true white.
15 ///
16 /// Emulators let the user repaint all sixteen, so this is the standard
17 /// arrangement rather than a promise about any one terminal. The Linux console
18 /// keeps it, which is the case that matters: a console app cannot fall back to
19 /// 24-bit color there.
20 pub const ANSI_16: [Rgb; 16] = [
21 Rgb {
22 r: 0x00,
23 g: 0x00,
24 b: 0x00,
25 },
26 Rgb {
27 r: 0xaa,
28 g: 0x00,
29 b: 0x00,
30 },
31 Rgb {
32 r: 0x00,
33 g: 0xaa,
34 b: 0x00,
35 },
36 Rgb {
37 r: 0xaa,
38 g: 0x55,
39 b: 0x00,
40 },
41 Rgb {
42 r: 0x00,
43 g: 0x00,
44 b: 0xaa,
45 },
46 Rgb {
47 r: 0xaa,
48 g: 0x00,
49 b: 0xaa,
50 },
51 Rgb {
52 r: 0x00,
53 g: 0xaa,
54 b: 0xaa,
55 },
56 Rgb {
57 r: 0xaa,
58 g: 0xaa,
59 b: 0xaa,
60 },
61 Rgb {
62 r: 0x55,
63 g: 0x55,
64 b: 0x55,
65 },
66 Rgb {
67 r: 0xff,
68 g: 0x55,
69 b: 0x55,
70 },
71 Rgb {
72 r: 0x55,
73 g: 0xff,
74 b: 0x55,
75 },
76 Rgb {
77 r: 0xff,
78 g: 0xff,
79 b: 0x55,
80 },
81 Rgb {
82 r: 0x55,
83 g: 0x55,
84 b: 0xff,
85 },
86 Rgb {
87 r: 0xff,
88 g: 0x55,
89 b: 0xff,
90 },
91 Rgb {
92 r: 0x55,
93 g: 0xff,
94 b: 0xff,
95 },
96 Rgb {
97 r: 0xff,
98 g: 0xff,
99 b: 0xff,
100 },
101 ];
102
103 /// The 256 colors an xterm-compatible terminal addresses by index, so that
104 /// entry `i` is what the terminal paints for `38;5;i`.
105 ///
106 /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
107 /// system colors, which every emulator lets the user repaint. 16-231 are a
108 /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
109 ///
110 /// So a color whose whole job is to be told apart from another should quantize
111 /// against [`ANSI_240`] rather than against this table: a match landing in the
112 /// low sixteen is a match against a color the user may have moved.
113 pub const ANSI_256: [Rgb; 256] = build_ansi_256();
114
115 /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
116 /// the sixteen repaintable system colors.
117 ///
118 /// Quantizing against this returns an index into *this* slice; add
119 /// [`ANSI_240_OFFSET`] to get the index the terminal wants.
120 pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
121
122 /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
123 pub const ANSI_240_OFFSET: usize = 16;
124
125 /// The twelve chromatic ANSI slots, as the intents that paint them.
126 ///
127 /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
128 /// or dark, since red is the theme's danger tone either way, which is exactly
129 /// why the four achromatic slots are not in this table.
130 ///
131 /// Here rather than in each consumer, so a program that paints its own palette
132 /// at runtime resolves the same slots as one reading a generated config. Slot
133 /// 14 is `category.six`.
134 const CHROMATIC: [(usize, &str); 12] = [
135 (1, "status.danger"),
136 (2, "status.success"),
137 (3, "status.warning"),
138 (4, "status.info"),
139 (5, "category.five"),
140 (6, "category.six"),
141 (9, "action.primary"), // bright red, the theme's warm accent
142 (10, "status.success"),
143 (11, "status.warning"),
144 (12, "status.info"),
145 (13, "category.five"),
146 (14, "category.six"),
147 ];
148
149 /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
150 ///
151 /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
152 /// "white", but what a terminal wants there is *the darkest tone* and *the
153 /// lightest tone*, and which intent that is flips with the theme's polarity. A
154 /// light theme's darkest tone is its ink; a dark theme's is its deepest
155 /// surface. Pinning slot 0 to `content.primary` reads correctly on a light
156 /// theme and hands a dark one a pale cream as "black".
157 ///
158 /// Slot 7 is a surface and not a text tone, because it is what a program with
159 /// no way to name anything else draws its container on: a greeter's login card
160 /// is a light card on the darker field slot 0 paints.
161 ///
162 /// Anything that is not `dark`, including `high-contrast`, follows the light
163 /// anchors.
164 fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
165 let dark = variant == "dark";
166 Some(match (index, dark) {
167 (0, false) => "content.primary", // darkest text tone
168 (0, true) => "surface.sunken", // darkest surface
169 (7, false) => "surface.raised", // the login card
170 (7, true) => "content.secondary", // a readable light tone
171 (8, _) => "content.muted", // muted chrome, either way
172 (15, false) => "surface.overlay", // lightest surface
173 (15, true) => "content.primary", // lightest text tone
174 _ => return None,
175 })
176 }
177
178 /// The authored intent painting ANSI slot `index` under a theme of `variant`,
179 /// as a dotted key into [`ThemeColors::colors`].
180 ///
181 /// `None` for an index outside 0-15. Every slot in range resolves, so a caller
182 /// that has the intent can fill all sixteen.
183 ///
184 /// This is what makes a bare console, a terminal emulator and a generated
185 /// config agree on what red means. They disagreed for as long as each kept its
186 /// own table.
187 #[must_use]
188 pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
189 achromatic_slot(index, variant).or_else(|| {
190 CHROMATIC
191 .iter()
192 .find(|(slot, _)| *slot == index)
193 .map(|(_, intent)| *intent)
194 })
195 }
196
197 const fn build_ansi_256() -> [Rgb; 256] {
198 let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
199
200 let mut i = 0;
201 while i < 16 {
202 table[i] = ANSI_16[i];
203 i += 1;
204 }
205
206 // The cube's six levels are not evenly spaced. The step from black to the
207 // first is more than twice any later one, which is xterm's arrangement
208 // rather than a choice available here, and it is why the darkest tones a
209 // theme can reach on 256 colors come from the gray ramp instead.
210 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
211 let mut r = 0;
212 while r < 6 {
213 let mut g = 0;
214 while g < 6 {
215 let mut b = 0;
216 while b < 6 {
217 table[16 + 36 * r + 6 * g + b] = Rgb {
218 r: LEVELS[r],
219 g: LEVELS[g],
220 b: LEVELS[b],
221 };
222 b += 1;
223 }
224 g += 1;
225 }
226 r += 1;
227 }
228
229 // 8 to 238 in steps of 10. Neither end is black or white; both of those are
230 // in the cube, so the ramp is 24 steps of gray between them rather than 24
231 // steps of the whole range.
232 let mut k = 0;
233 while k < 24 {
234 let v = 8 + 10 * k as u8;
235 table[232 + k as usize] = Rgb { r: v, g: v, b: v };
236 k += 1;
237 }
238
239 table
240 }
241
242 /// The contrast ratio two colors must clear to read as separate areas.
243 ///
244 /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
245 /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
246 /// text can ask for more by checking [`wcag_contrast`] itself.
247 pub const DISTINCT: f32 = 3.0;
248
249 /// Perceptual distance between two colors, for choosing the closest of a set.
250 fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
251 let (x, y) = (a.to_oklab(), b.to_oklab());
252 ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
253 }
254
255 /// Index of the entry in `palette` that looks most like `c`.
256 ///
257 /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
258 /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
259 /// nearest match computed in it picks visibly wrong entries in the mid tones.
260 ///
261 /// # Panics
262 ///
263 /// If `palette` is empty.
264 pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
265 assert!(!palette.is_empty(), "a palette needs at least one color");
266 let mut best = 0;
267 let mut best_distance = f32::INFINITY;
268 for (index, entry) in palette.iter().enumerate() {
269 let distance = oklab_distance(c, *entry);
270 if distance < best_distance {
271 best = index;
272 best_distance = distance;
273 }
274 }
275 best
276 }
277
278 /// Index of the entry in `palette` closest to `fg` that still reads against
279 /// `bg`.
280 ///
281 /// [`quantize`] answers about one color at a time, and two colors that differ
282 /// can quantize to the same entry: a themed page and a border drawn on it are
283 /// often a few steps apart in a 24-bit theme and land together on a 16-color
284 /// terminal, leaving one flat area where there was a frame. Alloy's console
285 /// showed exactly this, and it is not a contrived pairing: a light page and the
286 /// mid-tone border derived from it both land on index 7.
287 ///
288 /// So the background is quantized first, because what the border must be
289 /// distinguished from is the entry the terminal will actually paint, not the
290 /// color the theme asked for. Then the nearest entry to `fg` clearing
291 /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
292 /// furthest does: at that point the palette cannot honor the design, and the
293 /// most legible approximation beats the closest invisible one.
294 ///
295 /// Only for colors whose whole job is to be told apart from their background.
296 /// Applied to every token it would push a deliberately quiet one until it
297 /// shouted.
298 ///
299 /// # Panics
300 ///
301 /// If `palette` is empty.
302 pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
303 assert!(!palette.is_empty(), "a palette needs at least one color");
304 let shown = palette[quantize(bg, palette)];
305
306 let mut order: Vec<usize> = (0..palette.len()).collect();
307 order.sort_by(|a, b| {
308 oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
309 });
310
311 order
312 .iter()
313 .copied()
314 .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
315 .unwrap_or_else(|| {
316 order
317 .iter()
318 .copied()
319 .max_by(|a, b| {
320 wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
321 })
322 .expect("the palette is not empty")
323 })
324 }
325
326 #[cfg(test)]
327 mod tests {
328 use super::*;
329 use crate::color::rel_luminance;
330 use crate::fixture::bundled;
331 use crate::{embedded_themes, parse_theme_str, resolve};
332
333 // ---- low-color terminals ----
334
335 #[test]
336 fn the_ansi_palette_is_sixteen_distinct_colors() {
337 let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
338 seen.sort_unstable();
339 seen.dedup();
340 assert_eq!(seen.len(), 16);
341 }
342
343 // ---- the intent-to-slot table ----
344
345 // Sixteen slots, every one of them answered. A caller filling a terminal
346 // palette has no fallback for a hole: the slot would keep whatever the
347 // emulator started with, and one raw ANSI colour in a themed table is more
348 // obviously wrong than all sixteen would be.
349 #[test]
350 fn every_ansi_slot_names_an_intent_on_either_polarity() {
351 for variant in ["light", "dark", "high-contrast"] {
352 for index in 0..16 {
353 assert!(
354 ansi_intent(index, variant).is_some(),
355 "slot {index} unanswered on {variant}"
356 );
357 }
358 assert_eq!(ansi_intent(16, variant), None);
359 }
360 }
361
362 // The property the four achromatic slots exist to hold: 0 is the darkest
363 // tone the theme offers and 15 the lightest, in either polarity. A table
364 // that pins slot 0 to `content.primary` passes this on a light theme and
365 // inverts on a dark one, which is the bug the polarity split fixes.
366 #[test]
367 fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
368 for id in ["akari-dawn", "akari-night"] {
369 let theme = bundled(id);
370 let slot = |i: usize| -> Rgb {
371 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
372 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
373 };
374 assert!(
375 rel_luminance(slot(0)) < rel_luminance(slot(15)),
376 "{id}: ANSI 0 {} should be darker than ANSI 15 {}",
377 slot(0).to_hex(),
378 slot(15).to_hex(),
379 );
380 }
381 }
382
383 // The pair a greeter draws with: its container on 7, its text on 0. If
384 // those collapse the login screen is one flat block, and slot 7 being a
385 // surface rather than a text tone is what keeps them apart.
386 #[test]
387 fn the_container_slot_and_the_text_slot_stay_legible() {
388 for id in ["akari-dawn", "akari-night"] {
389 let theme = bundled(id);
390 let slot = |i: usize| -> Rgb {
391 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
392 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
393 };
394 let contrast = wcag_contrast(slot(0), slot(7));
395 assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
396 }
397 }
398
399 // The hues do not move with polarity. Red is the theme's danger tone on a
400 // light theme and on a dark one, which is why only four slots are in the
401 // polarity table at all.
402 #[test]
403 fn the_chromatic_slots_do_not_vary_with_polarity() {
404 for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
405 assert_eq!(
406 ansi_intent(index, "light"),
407 ansi_intent(index, "dark"),
408 "slot {index} moved with polarity"
409 );
410 }
411 }
412
413 #[test]
414 fn quantize_picks_the_obvious_entry() {
415 let black = Rgb { r: 0, g: 0, b: 0 };
416 let white = Rgb {
417 r: 255,
418 g: 255,
419 b: 255,
420 };
421 assert_eq!(quantize(black, &ANSI_16), 0);
422 assert_eq!(quantize(white, &ANSI_16), 15);
423 }
424
425 // Nearest-entry quantization is per-color, so two colors a theme keeps
426 // apart can arrive as one. These two are both closest to the palette's
427 // light gray, and a border drawn in one on a page painted the other is not
428 // drawn at all.
429 #[test]
430 fn two_colors_can_quantize_to_one_entry() {
431 let page = Rgb::from_hex("#a8a8a8").unwrap();
432 let border = Rgb::from_hex("#b4b4b4").unwrap();
433
434 assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
435 assert_ne!(
436 quantize_against(border, page, &ANSI_16),
437 quantize(page, &ANSI_16)
438 );
439 }
440
441 #[test]
442 fn quantize_against_keeps_the_border_off_the_page() {
443 let page = Rgb::from_hex("#e4ded6").unwrap();
444 let border = Rgb::from_hex("#7f786d").unwrap();
445
446 let shown_page = ANSI_16[quantize(page, &ANSI_16)];
447 let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
448
449 assert!(
450 wcag_contrast(shown_border, shown_page) >= DISTINCT,
451 "border {} on page {} is {:.2}:1",
452 shown_border.to_hex(),
453 shown_page.to_hex(),
454 wcag_contrast(shown_border, shown_page)
455 );
456 }
457
458 // A color that already reads against its background is left where it is,
459 // so this can be applied without redesigning what already worked.
460 #[test]
461 fn quantize_against_leaves_a_readable_color_alone() {
462 let page = Rgb::from_hex("#e4ded6").unwrap();
463 let text = Rgb::from_hex("#1a1816").unwrap();
464
465 assert_eq!(
466 quantize_against(text, page, &ANSI_16),
467 quantize(text, &ANSI_16)
468 );
469 }
470
471 // With nothing in the palette to satisfy the request, the most legible
472 // entry is the answer. Returning the nearest one would return the
473 // background itself, which is the failure this function exists to avoid.
474 #[test]
475 fn an_impossible_palette_gets_the_most_legible_entry() {
476 let page = Rgb::from_hex("#ffffff").unwrap();
477 let border = Rgb::from_hex("#fefefe").unwrap();
478 let palette = [
479 Rgb::from_hex("#ffffff").unwrap(),
480 Rgb::from_hex("#fdfdfd").unwrap(),
481 ];
482
483 let chosen = palette[quantize_against(border, page, &palette)];
484 assert_eq!(chosen.to_hex(), "#fdfdfd");
485 }
486
487 // What the bevel pair does on a sixteen-color terminal, measured across the
488 // shipped set rather than assumed. Two results, both load-bearing for a
489 // consumer that has to render one there.
490 //
491 // Exactly one edge survives, never both. A raised face quantizes onto one of
492 // the palette's three grays, and the palette is too coarse to hold anything
493 // between that entry and its neighbour, so whichever edge is pushed toward
494 // the end of the ramp the face already sits on lands back on the face. Light
495 // themes and most dark ones keep the shadow and lose the highlight; a face
496 // that quantizes to black keeps the highlight and loses the shadow.
497 //
498 // So a low-color consumer draws the single edge it can render, on the side
499 // the palette left it, rather than a bevel that resolves on two sides.
500 //
501 // And `quantize_against` is the wrong function for this pair, though it is
502 // the right one for a border. It answers "nearest entry that clears DISTINCT
503 // against the background", which has no notion of direction, so both edges
504 // are pushed onto the same contrasting entry and the bevel inverts on one
505 // side. Plain `quantize` keeps them apart and in the right order.
506 #[test]
507 fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
508 for (id, source) in embedded_themes() {
509 let theme = parse_theme_str(id, source, false).unwrap();
510 let t = resolve(&theme);
511 let (Some(face), Some(light), Some(dark)) = (
512 t.hex("surface-raised").and_then(Rgb::from_hex),
513 t.hex("bevel-light").and_then(Rgb::from_hex),
514 t.hex("bevel-dark").and_then(Rgb::from_hex),
515 ) else {
516 continue;
517 };
518
519 let face_index = quantize(face, &ANSI_16);
520 let light_survives = quantize(light, &ANSI_16) != face_index;
521 let dark_survives = quantize(dark, &ANSI_16) != face_index;
522 assert!(
523 light_survives != dark_survives,
524 "{id}: expected exactly one bevel edge to survive 16 colors, \
525 highlight {light_survives} shadow {dark_survives}"
526 );
527
528 // Direction-blind, so it collapses the pair it is asked to separate.
529 assert_eq!(
530 quantize_against(light, face, &ANSI_16),
531 quantize_against(dark, face, &ANSI_16),
532 "{id}: quantize_against is expected to be unusable for a bevel pair"
533 );
534 }
535 }
536
537 // 256 colors is where the bevel starts working. At 16 every shipped theme
538 // loses an edge; here all but the five whose raised surface sits at the very
539 // top of the ramp keep both, and those five fail for the reason they fail in
540 // truecolor rather than for a palette reason.
541 //
542 // Three of them cannot bevel at any depth, so they are the
543 // `bevel_edges_are_distinct_from_their_face` set. The other two are new here:
544 // they hold a highlight in 24-bit, but not one wide enough to survive
545 // rounding onto the cube.
546 #[test]
547 fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
548 const LOSES_AN_EDGE: &[&str] = &[
549 "gruvbox-light",
550 "neobrute",
551 "oxocarbon-light",
552 "rosepine-dawn",
553 ];
554
555 let mut lost: Vec<String> = Vec::new();
556 for (id, source) in embedded_themes() {
557 let theme = parse_theme_str(id, source, false).unwrap();
558 let t = resolve(&theme);
559 let (Some(face), Some(light), Some(dark)) = (
560 t.hex("surface-raised").and_then(Rgb::from_hex),
561 t.hex("bevel-light").and_then(Rgb::from_hex),
562 t.hex("bevel-dark").and_then(Rgb::from_hex),
563 ) else {
564 continue;
565 };
566
567 // Against the fixed region, which is what a consumer should use: a
568 // match in the low sixteen is a match against a repaintable color.
569 let f = quantize(face, ANSI_240);
570 let l = quantize(light, ANSI_240);
571 let d = quantize(dark, ANSI_240);
572 if l == f || d == f || l == d {
573 lost.push(id.to_string());
574 }
575 }
576 lost.sort();
577
578 assert_eq!(
579 lost, LOSES_AN_EDGE,
580 "themes that cannot hold a two-tone bevel on a 256-color terminal"
581 );
582 }
583
584 #[test]
585 fn the_256_table_has_its_three_regions() {
586 // Index is the escape-sequence index, so the low sixteen must match.
587 assert_eq!(ANSI_256[..16], ANSI_16);
588 // The cube's corners, at both ends and one interior level.
589 assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
590 assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
591 assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
592 // The gray ramp runs 8 to 238 and contains neither black nor white.
593 assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
594 assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
595 // The fixed region is the table minus the repaintable colors.
596 assert_eq!(ANSI_240.len(), 240);
597 assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
598 }
599 }
600