| 327 |
327 |
|
},
|
| 328 |
328 |
|
];
|
| 329 |
329 |
|
|
|
330 |
+ |
/// The 256 colors an xterm-compatible terminal addresses by index, so that
|
|
331 |
+ |
/// entry `i` is what the terminal paints for `38;5;i`.
|
|
332 |
+ |
///
|
|
333 |
+ |
/// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
|
|
334 |
+ |
/// system colors, which every emulator lets the user repaint. 16-231 are a
|
|
335 |
+ |
/// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
|
|
336 |
+ |
///
|
|
337 |
+ |
/// So a color whose whole job is to be told apart from another should quantize
|
|
338 |
+ |
/// against [`ANSI_240`] rather than against this table: a match landing in the
|
|
339 |
+ |
/// low sixteen is a match against a color the user may have moved.
|
|
340 |
+ |
pub const ANSI_256: [Rgb; 256] = build_ansi_256();
|
|
341 |
+ |
|
|
342 |
+ |
/// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
|
|
343 |
+ |
/// the sixteen repaintable system colors.
|
|
344 |
+ |
///
|
|
345 |
+ |
/// Quantizing against this returns an index into *this* slice; add
|
|
346 |
+ |
/// [`ANSI_240_OFFSET`] to get the index the terminal wants.
|
|
347 |
+ |
pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
|
|
348 |
+ |
|
|
349 |
+ |
/// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
|
|
350 |
+ |
pub const ANSI_240_OFFSET: usize = 16;
|
|
351 |
+ |
|
|
352 |
+ |
const fn build_ansi_256() -> [Rgb; 256] {
|
|
353 |
+ |
let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
|
354 |
+ |
|
|
355 |
+ |
let mut i = 0;
|
|
356 |
+ |
while i < 16 {
|
|
357 |
+ |
table[i] = ANSI_16[i];
|
|
358 |
+ |
i += 1;
|
|
359 |
+ |
}
|
|
360 |
+ |
|
|
361 |
+ |
// The cube's six levels are not evenly spaced. The step from black to the
|
|
362 |
+ |
// first is more than twice any later one, which is xterm's arrangement
|
|
363 |
+ |
// rather than a choice available here, and it is why the darkest tones a
|
|
364 |
+ |
// theme can reach on 256 colors come from the gray ramp instead.
|
|
365 |
+ |
const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
|
366 |
+ |
let mut r = 0;
|
|
367 |
+ |
while r < 6 {
|
|
368 |
+ |
let mut g = 0;
|
|
369 |
+ |
while g < 6 {
|
|
370 |
+ |
let mut b = 0;
|
|
371 |
+ |
while b < 6 {
|
|
372 |
+ |
table[16 + 36 * r + 6 * g + b] = Rgb {
|
|
373 |
+ |
r: LEVELS[r],
|
|
374 |
+ |
g: LEVELS[g],
|
|
375 |
+ |
b: LEVELS[b],
|
|
376 |
+ |
};
|
|
377 |
+ |
b += 1;
|
|
378 |
+ |
}
|
|
379 |
+ |
g += 1;
|
|
380 |
+ |
}
|
|
381 |
+ |
r += 1;
|
|
382 |
+ |
}
|
|
383 |
+ |
|
|
384 |
+ |
// 8 to 238 in steps of 10. Neither end is black or white; both of those are
|
|
385 |
+ |
// in the cube, so the ramp is 24 steps of gray between them rather than 24
|
|
386 |
+ |
// steps of the whole range.
|
|
387 |
+ |
let mut k = 0;
|
|
388 |
+ |
while k < 24 {
|
|
389 |
+ |
let v = 8 + 10 * k as u8;
|
|
390 |
+ |
table[232 + k as usize] = Rgb { r: v, g: v, b: v };
|
|
391 |
+ |
k += 1;
|
|
392 |
+ |
}
|
|
393 |
+ |
|
|
394 |
+ |
table
|
|
395 |
+ |
}
|
|
396 |
+ |
|
| 330 |
397 |
|
/// The contrast ratio two colors must clear to read as separate areas.
|
| 331 |
398 |
|
///
|
| 332 |
399 |
|
/// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
|
| 511 |
578 |
|
format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
|
| 512 |
579 |
|
);
|
| 513 |
580 |
|
}
|
|
581 |
+ |
if let Some(raised) = get(&intents, "surface-raised") {
|
|
582 |
+ |
// The two edges of a bevel: a raised control is lit from the top left,
|
|
583 |
+ |
// so its top and left edges take `bevel-light` and its bottom and right
|
|
584 |
+ |
// edges `bevel-dark`. Inverting the pair gives a pressed state and an
|
|
585 |
+ |
// inset well, which is what makes the idiom cheap for a consumer.
|
|
586 |
+ |
//
|
|
587 |
+ |
// Derived here rather than composed per-app because the two webviews
|
|
588 |
+ |
// could do it in `color-mix()` and audiofiles, which is egui, could not.
|
|
589 |
+ |
// Geometry (thickness, radius, which side gets which) stays app-side.
|
|
590 |
+ |
//
|
|
591 |
+ |
// The deltas are asymmetric because the eye is: an equal step down reads
|
|
592 |
+ |
// as a smaller change than the same step up, so the shadow is cut deeper
|
|
593 |
+ |
// than the highlight is raised.
|
|
594 |
+ |
//
|
|
595 |
+ |
// A face already at the top of the ramp cannot hold a highlight — the
|
|
596 |
+ |
// lightening clamps and the control bevels on two sides without ever
|
|
597 |
+ |
// resolving as lit. That is a property of the theme, not of this
|
|
598 |
+ |
// derivation; `bevel_edges_are_distinct_from_their_face` names the
|
|
599 |
+ |
// shipped themes it currently bites.
|
|
600 |
+ |
derived.push(("bevel-light".into(), lighten(raised, 0.14)));
|
|
601 |
+ |
derived.push(("bevel-dark".into(), darken(raised, 0.18)));
|
|
602 |
+ |
}
|
| 514 |
603 |
|
if let Some(sunken) = get(&intents, "surface-sunken") {
|
| 515 |
604 |
|
derived.push(("hover-surface".into(), sunken));
|
| 516 |
605 |
|
}
|
| 1459 |
1548 |
|
assert!(t.hex("row-stripe").is_none());
|
| 1460 |
1549 |
|
}
|
| 1461 |
1550 |
|
|
|
1551 |
+ |
#[test]
|
|
1552 |
+ |
fn resolve_bevel_intents() {
|
|
1553 |
+ |
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
|
|
1554 |
+ |
let t = resolve(&theme);
|
|
1555 |
+ |
let raised = Rgb::from_hex("#3b4252").unwrap();
|
|
1556 |
+ |
assert_eq!(
|
|
1557 |
+ |
t.hex("bevel-light").unwrap(),
|
|
1558 |
+ |
lighten(raised, 0.14).to_hex()
|
|
1559 |
+ |
);
|
|
1560 |
+ |
assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
|
|
1561 |
+ |
}
|
|
1562 |
+ |
|
|
1563 |
+ |
// A bevel is two edges around one face, so both edges have to be visibly off
|
|
1564 |
+ |
// that face or the control never resolves as lit. The lightening clamps at
|
|
1565 |
+ |
// the top of the ramp, which means a theme authoring a white raised surface
|
|
1566 |
+ |
// gets a highlight identical to the surface it is meant to sit on.
|
|
1567 |
+ |
//
|
|
1568 |
+ |
// The list is asserted rather than merely reported so that changing a theme
|
|
1569 |
+ |
// has to come here and say so. Shrinking it is the fix; growing it is a
|
|
1570 |
+ |
// regression in the theme, not in this derivation.
|
|
1571 |
+ |
#[test]
|
|
1572 |
+ |
fn bevel_edges_are_distinct_from_their_face() {
|
|
1573 |
+ |
const CANNOT_BEVEL: &[&str] = &["audiofiles", "neobrute", "oxocarbon-light"];
|
|
1574 |
+ |
|
|
1575 |
+ |
let mut degenerate: Vec<String> = Vec::new();
|
|
1576 |
+ |
for (id, source) in embedded_themes() {
|
|
1577 |
+ |
let theme = parse_theme_str(id, source, false).unwrap();
|
|
1578 |
+ |
let t = resolve(&theme);
|
|
1579 |
+ |
let Some(raised) = t.hex("surface-raised") else {
|
|
1580 |
+ |
continue;
|
|
1581 |
+ |
};
|
|
1582 |
+ |
let light = t.hex("bevel-light").expect("raised implies bevel-light");
|
|
1583 |
+ |
let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
|
|
1584 |
+ |
if light == raised || dark == raised {
|
|
1585 |
+ |
degenerate.push(id.to_string());
|
|
1586 |
+ |
}
|
|
1587 |
+ |
}
|
|
1588 |
+ |
degenerate.sort();
|
|
1589 |
+ |
|
|
1590 |
+ |
assert_eq!(
|
|
1591 |
+ |
degenerate, CANNOT_BEVEL,
|
|
1592 |
+ |
"themes whose raised surface cannot hold both bevel edges"
|
|
1593 |
+ |
);
|
|
1594 |
+ |
}
|
|
1595 |
+ |
|
|
1596 |
+ |
// What the bevel pair does on a sixteen-color terminal, measured across the
|
|
1597 |
+ |
// shipped set rather than assumed. Two results, both load-bearing for a
|
|
1598 |
+ |
// consumer that has to render one there.
|
|
1599 |
+ |
//
|
|
1600 |
+ |
// Exactly one edge survives, never both. A raised face quantizes onto one of
|
|
1601 |
+ |
// the palette's three grays, and the palette is too coarse to hold anything
|
|
1602 |
+ |
// between that entry and its neighbour, so whichever edge is pushed toward
|
|
1603 |
+ |
// the end of the ramp the face already sits on lands back on the face. Light
|
|
1604 |
+ |
// themes and most dark ones keep the shadow and lose the highlight; a face
|
|
1605 |
+ |
// that quantizes to black keeps the highlight and loses the shadow.
|
|
1606 |
+ |
//
|
|
1607 |
+ |
// So a low-color consumer draws the single edge it can render, on the side
|
|
1608 |
+ |
// the palette left it, rather than a bevel that resolves on two sides.
|
|
1609 |
+ |
//
|
|
1610 |
+ |
// And `quantize_against` is the wrong function for this pair, though it is
|
|
1611 |
+ |
// the right one for a border. It answers "nearest entry that clears DISTINCT
|
|
1612 |
+ |
// against the background", which has no notion of direction, so both edges
|
|
1613 |
+ |
// are pushed onto the same contrasting entry and the bevel inverts on one
|
|
1614 |
+ |
// side. Plain `quantize` keeps them apart and in the right order.
|
|
1615 |
+ |
#[test]
|
|
1616 |
+ |
fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
|
|
1617 |
+ |
for (id, source) in embedded_themes() {
|
|
1618 |
+ |
let theme = parse_theme_str(id, source, false).unwrap();
|
|
1619 |
+ |
let t = resolve(&theme);
|
|
1620 |
+ |
let (Some(face), Some(light), Some(dark)) = (
|
|
1621 |
+ |
t.hex("surface-raised").and_then(Rgb::from_hex),
|
|
1622 |
+ |
t.hex("bevel-light").and_then(Rgb::from_hex),
|
|
1623 |
+ |
t.hex("bevel-dark").and_then(Rgb::from_hex),
|
|
1624 |
+ |
) else {
|
|
1625 |
+ |
continue;
|
|
1626 |
+ |
};
|
|
1627 |
+ |
|
|
1628 |
+ |
let face_index = quantize(face, &ANSI_16);
|
|
1629 |
+ |
let light_survives = quantize(light, &ANSI_16) != face_index;
|
|
1630 |
+ |
let dark_survives = quantize(dark, &ANSI_16) != face_index;
|
|
1631 |
+ |
assert!(
|
|
1632 |
+ |
light_survives != dark_survives,
|
|
1633 |
+ |
"{id}: expected exactly one bevel edge to survive 16 colors, \
|
|
1634 |
+ |
highlight {light_survives} shadow {dark_survives}"
|
|
1635 |
+ |
);
|
|
1636 |
+ |
|
|
1637 |
+ |
// Direction-blind, so it collapses the pair it is asked to separate.
|
|
1638 |
+ |
assert_eq!(
|
|
1639 |
+ |
quantize_against(light, face, &ANSI_16),
|
|
1640 |
+ |
quantize_against(dark, face, &ANSI_16),
|
|
1641 |
+ |
"{id}: quantize_against is expected to be unusable for a bevel pair"
|
|
1642 |
+ |
);
|
|
1643 |
+ |
}
|
|
1644 |
+ |
}
|
|
1645 |
+ |
|
|
1646 |
+ |
// 256 colors is where the bevel starts working. At 16 every shipped theme
|
|
1647 |
+ |
// loses an edge; here all but the five whose raised surface sits at the very
|
|
1648 |
+ |
// top of the ramp keep both, and those five fail for the reason they fail in
|
|
1649 |
+ |
// truecolor rather than for a palette reason.
|
|
1650 |
+ |
//
|
|
1651 |
+ |
// Three of them cannot bevel at any depth, so they are the
|
|
1652 |
+ |
// `bevel_edges_are_distinct_from_their_face` set. The other two are new here:
|
|
1653 |
+ |
// they hold a highlight in 24-bit, but not one wide enough to survive
|
|
1654 |
+ |
// rounding onto the cube.
|
|
1655 |
+ |
#[test]
|
|
1656 |
+ |
fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
|
|
1657 |
+ |
const LOSES_AN_EDGE: &[&str] = &[
|
|
1658 |
+ |
"audiofiles",
|
|
1659 |
+ |
"gruvbox-light",
|
|
1660 |
+ |
"neobrute",
|
|
1661 |
+ |
"oxocarbon-light",
|
|
1662 |
+ |
"rosepine-dawn",
|
|
1663 |
+ |
];
|
|
1664 |
+ |
|
|
1665 |
+ |
let mut lost: Vec<String> = Vec::new();
|
|
1666 |
+ |
for (id, source) in embedded_themes() {
|
|
1667 |
+ |
let theme = parse_theme_str(id, source, false).unwrap();
|
|
1668 |
+ |
let t = resolve(&theme);
|
|
1669 |
+ |
let (Some(face), Some(light), Some(dark)) = (
|
|
1670 |
+ |
t.hex("surface-raised").and_then(Rgb::from_hex),
|
|
1671 |
+ |
t.hex("bevel-light").and_then(Rgb::from_hex),
|
|
1672 |
+ |
t.hex("bevel-dark").and_then(Rgb::from_hex),
|
|
1673 |
+ |
) else {
|
|
1674 |
+ |
continue;
|
|
1675 |
+ |
};
|
|
1676 |
+ |
|
|
1677 |
+ |
// Against the fixed region, which is what a consumer should use: a
|
|
1678 |
+ |
// match in the low sixteen is a match against a repaintable color.
|
|
1679 |
+ |
let f = quantize(face, ANSI_240);
|
|
1680 |
+ |
let l = quantize(light, ANSI_240);
|
|
1681 |
+ |
let d = quantize(dark, ANSI_240);
|
|
1682 |
+ |
if l == f || d == f || l == d {
|
|
1683 |
+ |
lost.push(id.to_string());
|
|
1684 |
+ |
}
|
|
1685 |
+ |
}
|
|
1686 |
+ |
lost.sort();
|
|
1687 |
+ |
|
|
1688 |
+ |
assert_eq!(
|
|
1689 |
+ |
lost, LOSES_AN_EDGE,
|
|
1690 |
+ |
"themes that cannot hold a two-tone bevel on a 256-color terminal"
|
|
1691 |
+ |
);
|
|
1692 |
+ |
}
|
|
1693 |
+ |
|
|
1694 |
+ |
#[test]
|
|
1695 |
+ |
fn the_256_table_has_its_three_regions() {
|
|
1696 |
+ |
// Index is the escape-sequence index, so the low sixteen must match.
|
|
1697 |
+ |
assert_eq!(ANSI_256[..16], ANSI_16);
|
|
1698 |
+ |
// The cube's corners, at both ends and one interior level.
|
|
1699 |
+ |
assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
|
|
1700 |
+ |
assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
|
|
1701 |
+ |
assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
|
|
1702 |
+ |
// The gray ramp runs 8 to 238 and contains neither black nor white.
|
|
1703 |
+ |
assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
|
|
1704 |
+ |
assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
|
|
1705 |
+ |
// The fixed region is the table minus the repaintable colors.
|
|
1706 |
+ |
assert_eq!(ANSI_240.len(), 240);
|
|
1707 |
+ |
assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
|
|
1708 |
+ |
}
|
|
1709 |
+ |
|
| 1462 |
1710 |
|
#[test]
|
| 1463 |
1711 |
|
fn resolve_overlay_is_dark_translucent_scrim() {
|
| 1464 |
1712 |
|
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
|