| 349 |
349 |
|
/// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
|
| 350 |
350 |
|
pub const ANSI_240_OFFSET: usize = 16;
|
| 351 |
351 |
|
|
|
352 |
+ |
/// The twelve chromatic ANSI slots, as the intents that paint them.
|
|
353 |
+ |
///
|
|
354 |
+ |
/// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
|
|
355 |
+ |
/// or dark, since red is the theme's danger tone either way, which is exactly
|
|
356 |
+ |
/// why the four achromatic slots are not in this table.
|
|
357 |
+ |
///
|
|
358 |
+ |
/// Lifted from Alloy's `skelgen` on 2026-07-31, which had folded three
|
|
359 |
+ |
/// disagreeing hand-maintained copies into one and is the reason the
|
|
360 |
+ |
/// arrangement is trusted. It moved here so a program that paints its own
|
|
361 |
+ |
/// palette at runtime, rather than reading a generated config, resolves the
|
|
362 |
+ |
/// same slots. Slot 14 was the one the copies disagreed on and is
|
|
363 |
+ |
/// `category.six`, which both the Linux console table and the retired
|
|
364 |
+ |
/// `vtrgb.py` had.
|
|
365 |
+ |
const CHROMATIC: [(usize, &str); 12] = [
|
|
366 |
+ |
(1, "status.danger"),
|
|
367 |
+ |
(2, "status.success"),
|
|
368 |
+ |
(3, "status.warning"),
|
|
369 |
+ |
(4, "status.info"),
|
|
370 |
+ |
(5, "category.five"),
|
|
371 |
+ |
(6, "category.six"),
|
|
372 |
+ |
(9, "action.primary"), // bright red, the theme's warm accent
|
|
373 |
+ |
(10, "status.success"),
|
|
374 |
+ |
(11, "status.warning"),
|
|
375 |
+ |
(12, "status.info"),
|
|
376 |
+ |
(13, "category.five"),
|
|
377 |
+ |
(14, "category.six"),
|
|
378 |
+ |
];
|
|
379 |
+ |
|
|
380 |
+ |
/// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
|
|
381 |
+ |
///
|
|
382 |
+ |
/// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
|
|
383 |
+ |
/// "white", but what a terminal wants there is *the darkest tone* and *the
|
|
384 |
+ |
/// lightest tone*, and which intent that is flips with the theme's polarity. A
|
|
385 |
+ |
/// light theme's darkest tone is its ink; a dark theme's is its deepest
|
|
386 |
+ |
/// surface. Pinning slot 0 to `content.primary` reads correctly on a light
|
|
387 |
+ |
/// theme and hands a dark one a pale cream as "black".
|
|
388 |
+ |
///
|
|
389 |
+ |
/// Slot 7 is a surface and not a text tone, because it is what a program with
|
|
390 |
+ |
/// no way to name anything else draws its container on: a greeter's login card
|
|
391 |
+ |
/// is a light card on the darker field slot 0 paints.
|
|
392 |
+ |
///
|
|
393 |
+ |
/// Anything that is not `dark`, including `high-contrast`, follows the light
|
|
394 |
+ |
/// anchors.
|
|
395 |
+ |
fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
|
|
396 |
+ |
let dark = variant == "dark";
|
|
397 |
+ |
Some(match (index, dark) {
|
|
398 |
+ |
(0, false) => "content.primary", // darkest text tone
|
|
399 |
+ |
(0, true) => "surface.sunken", // darkest surface
|
|
400 |
+ |
(7, false) => "surface.raised", // the login card
|
|
401 |
+ |
(7, true) => "content.secondary", // a readable light tone
|
|
402 |
+ |
(8, _) => "content.muted", // muted chrome, either way
|
|
403 |
+ |
(15, false) => "surface.overlay", // lightest surface
|
|
404 |
+ |
(15, true) => "content.primary", // lightest text tone
|
|
405 |
+ |
_ => return None,
|
|
406 |
+ |
})
|
|
407 |
+ |
}
|
|
408 |
+ |
|
|
409 |
+ |
/// The authored intent painting ANSI slot `index` under a theme of `variant`,
|
|
410 |
+ |
/// as a dotted key into [`ThemeColors::colors`].
|
|
411 |
+ |
///
|
|
412 |
+ |
/// `None` for an index outside 0-15. Every slot in range resolves, so a caller
|
|
413 |
+ |
/// that has the intent can fill all sixteen.
|
|
414 |
+ |
///
|
|
415 |
+ |
/// This is what makes a bare console, a terminal emulator and a generated
|
|
416 |
+ |
/// config agree on what red means. They disagreed for as long as each kept its
|
|
417 |
+ |
/// own table.
|
|
418 |
+ |
#[must_use]
|
|
419 |
+ |
pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
|
|
420 |
+ |
achromatic_slot(index, variant).or_else(|| {
|
|
421 |
+ |
CHROMATIC
|
|
422 |
+ |
.iter()
|
|
423 |
+ |
.find(|(slot, _)| *slot == index)
|
|
424 |
+ |
.map(|(_, intent)| *intent)
|
|
425 |
+ |
})
|
|
426 |
+ |
}
|
|
427 |
+ |
|
| 352 |
428 |
|
const fn build_ansi_256() -> [Rgb; 256] {
|
| 353 |
429 |
|
let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
| 354 |
430 |
|
|
| 1296 |
1372 |
|
assert_eq!(seen.len(), 16);
|
| 1297 |
1373 |
|
}
|
| 1298 |
1374 |
|
|
|
1375 |
+ |
// ---- the intent-to-slot table ----
|
|
1376 |
+ |
|
|
1377 |
+ |
// Sixteen slots, every one of them answered. A caller filling a terminal
|
|
1378 |
+ |
// palette has no fallback for a hole: the slot would keep whatever the
|
|
1379 |
+ |
// emulator started with, and one raw ANSI colour in a themed table is more
|
|
1380 |
+ |
// obviously wrong than all sixteen would be.
|
|
1381 |
+ |
#[test]
|
|
1382 |
+ |
fn every_ansi_slot_names_an_intent_on_either_polarity() {
|
|
1383 |
+ |
for variant in ["light", "dark", "high-contrast"] {
|
|
1384 |
+ |
for index in 0..16 {
|
|
1385 |
+ |
assert!(
|
|
1386 |
+ |
ansi_intent(index, variant).is_some(),
|
|
1387 |
+ |
"slot {index} unanswered on {variant}"
|
|
1388 |
+ |
);
|
|
1389 |
+ |
}
|
|
1390 |
+ |
assert_eq!(ansi_intent(16, variant), None);
|
|
1391 |
+ |
}
|
|
1392 |
+ |
}
|
|
1393 |
+ |
|
|
1394 |
+ |
// The property the four achromatic slots exist to hold: 0 is the darkest
|
|
1395 |
+ |
// tone the theme offers and 15 the lightest, in either polarity. A table
|
|
1396 |
+ |
// that pins slot 0 to `content.primary` passes this on a light theme and
|
|
1397 |
+ |
// inverts on a dark one, which is the bug the polarity split fixes.
|
|
1398 |
+ |
#[test]
|
|
1399 |
+ |
fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
|
|
1400 |
+ |
for id in ["akari-dawn", "akari-night"] {
|
|
1401 |
+ |
let theme = bundled(id);
|
|
1402 |
+ |
let slot = |i: usize| -> Rgb {
|
|
1403 |
+ |
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
|
|
1404 |
+ |
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
|
|
1405 |
+ |
};
|
|
1406 |
+ |
assert!(
|
|
1407 |
+ |
rel_luminance(slot(0)) < rel_luminance(slot(15)),
|
|
1408 |
+ |
"{id}: ANSI 0 {} should be darker than ANSI 15 {}",
|
|
1409 |
+ |
slot(0).to_hex(),
|
|
1410 |
+ |
slot(15).to_hex(),
|
|
1411 |
+ |
);
|
|
1412 |
+ |
}
|
|
1413 |
+ |
}
|
|
1414 |
+ |
|
|
1415 |
+ |
// The pair a greeter draws with: its container on 7, its text on 0. If
|
|
1416 |
+ |
// those collapse the login screen is one flat block, and slot 7 being a
|
|
1417 |
+ |
// surface rather than a text tone is what keeps them apart.
|
|
1418 |
+ |
#[test]
|
|
1419 |
+ |
fn the_container_slot_and_the_text_slot_stay_legible() {
|
|
1420 |
+ |
for id in ["akari-dawn", "akari-night"] {
|
|
1421 |
+ |
let theme = bundled(id);
|
|
1422 |
+ |
let slot = |i: usize| -> Rgb {
|
|
1423 |
+ |
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
|
|
1424 |
+ |
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
|
|
1425 |
+ |
};
|
|
1426 |
+ |
let contrast = wcag_contrast(slot(0), slot(7));
|
|
1427 |
+ |
assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
|
|
1428 |
+ |
}
|
|
1429 |
+ |
}
|
|
1430 |
+ |
|
|
1431 |
+ |
// The hues do not move with polarity. Red is the theme's danger tone on a
|
|
1432 |
+ |
// light theme and on a dark one, which is why only four slots are in the
|
|
1433 |
+ |
// polarity table at all.
|
|
1434 |
+ |
#[test]
|
|
1435 |
+ |
fn the_chromatic_slots_do_not_vary_with_polarity() {
|
|
1436 |
+ |
for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
|
|
1437 |
+ |
assert_eq!(
|
|
1438 |
+ |
ansi_intent(index, "light"),
|
|
1439 |
+ |
ansi_intent(index, "dark"),
|
|
1440 |
+ |
"slot {index} moved with polarity"
|
|
1441 |
+ |
);
|
|
1442 |
+ |
}
|
|
1443 |
+ |
}
|
|
1444 |
+ |
|
|
1445 |
+ |
fn bundled(id: &str) -> ThemeColors {
|
|
1446 |
+ |
let dir = bundled_themes_dir().expect("makeover ships its themes");
|
|
1447 |
+ |
load_theme(&[(dir, false)], id).expect("the akari pair ships")
|
|
1448 |
+ |
}
|
|
1449 |
+ |
|
| 1299 |
1450 |
|
#[test]
|
| 1300 |
1451 |
|
fn quantize_picks_the_obvious_entry() {
|
| 1301 |
1452 |
|
let black = Rgb { r: 0, g: 0, b: 0 };
|