Files
wanderbooks/crates/ledger-gui/src/schemes.rs
T
swaitsandClaude Opus 5.5 321b94562a
check / check (push) Canceled after 0s
Projects: classes as entities, and the sidebar fits short windows
A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 03:50:52 -06:00

1010 lines
36 KiB
Rust

//! Color schemes: the viewer's own (Wanderbooks Light and Dark) and twenty
//! families of popular editor schemes, each with all its variants, from
//! each scheme's official palette (table.rs names the sources).
//!
//! ```text
//! GOAL: the viewer can wear any of the well-known editor color schemes,
//! and whichever it wears, every figure stays plainly legible.
//! SOURCE: owner, 2026-10-19 ("full blown colorscheme support … all the top
//! neovim style colorschemes"; "on the order of 20 families, each
//! with a flyout of all its variants"; "a light/dark/all filter").
//! INVARIANTS:
//! 1. A scheme only colors the viewer: it changes no figure, no text and
//! nothing in the books, and the choice is kept with the viewer's own
//! preferences.
//! 2. Every palette a scheme gives keeps every color that carries text
//! or a figure (text, secondary and faintest text, gains, losses,
//! warnings, links) at WCAG AA contrast (4.5:1) on every background
//! it is drawn on (grounds()); a scheme's own color that falls short
//! keeps its hue and saturation and is made lighter (dark schemes) or
//! darker (light ones) just far enough. Gains, losses and text stay
//! plainly different to the eye, and so do the chart series shown
//! together. The scheme holds whatever the system's light or dark
//! mode does later.
//! 3. Each scheme is light or dark as its background is; the filter
//! shows exactly the schemes of the mode chosen.
//! 4. A remembered scheme that no longer exists falls back to the
//! viewer's own scheme of the same mode.
//! 5. The menu lists the families alphabetically (case and accents
//! aside), and each family's variants alphabetically (owner,
//! 2026-10-19).
//! ```
use crate::theme::Palette;
use egui::Color32;
mod table;
/// A scheme's own colors, as its authors named them (see the table's
/// source, docs/color-schemes.md).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Core {
/// The main background.
pub bg: Color32,
/// A panel or sidebar background beside it.
pub dim: Color32,
/// A raised surface (floats, the status line).
pub raised: Color32,
/// The selection.
pub sel: Color32,
/// Borders and splits.
pub border: Color32,
/// The main text.
pub fg: Color32,
/// Secondary text.
pub muted: Color32,
/// Comments: the faintest text.
pub faint: Color32,
/// The scheme's signature accent.
pub accent: Color32,
/// Its named colors.
pub red: Color32,
pub green: Color32,
pub yellow: Color32,
pub orange: Color32,
pub blue: Color32,
pub purple: Color32,
pub cyan: Color32,
}
/// One scheme: a family's variant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Scheme {
/// Its family ("Catppuccin").
pub family: &'static str,
/// Its variant ("Mocha").
pub name: &'static str,
/// Whether it is dark.
pub dark: bool,
/// Its colors; `None` for the viewer's own, whose palettes are
/// hand-made (theme::LIGHT, theme::DARK).
pub core: Option<Core>,
}
/// Which schemes the menu shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Filter {
/// Light and dark.
#[default]
All,
/// Light ones.
Light,
/// Dark ones.
Dark,
}
impl Filter {
/// What it is called in the menu and in the viewer's preferences.
pub fn name(self) -> &'static str {
match self {
Filter::All => "All",
Filter::Light => "Light",
Filter::Dark => "Dark",
}
}
/// The filter of that name, if any.
pub fn from_name(name: &str) -> Option<Self> {
[Filter::All, Filter::Light, Filter::Dark]
.into_iter()
.find(|f| f.name() == name)
}
/// Whether it shows `scheme` (INVARIANT 3).
pub fn shows(self, scheme: &Scheme) -> bool {
match self {
Filter::All => true,
Filter::Light => !scheme.dark,
Filter::Dark => scheme.dark,
}
}
}
/// The viewer's own family.
pub const OWN: &str = "Wanderbooks";
/// The viewer's own schemes.
static OWN_SCHEMES: [Scheme; 2] = [
Scheme {
family: OWN,
name: "Light",
dark: false,
core: None,
},
Scheme {
family: OWN,
name: "Dark",
dark: true,
core: None,
},
];
/// Every scheme: the viewer's own first, then the families in menu order.
pub fn all() -> impl Iterator<Item = &'static Scheme> {
OWN_SCHEMES.iter().chain(table::SCHEMES.iter())
}
/// How a name sorts: without case or accents ("Rosé" as "rose").
fn sort_key(name: &str) -> String {
name.chars()
.map(|c| match c {
'à' | 'á' | 'â' | 'ä' | 'ã' | 'å' => 'a',
'ç' => 'c',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ñ' => 'n',
'ò' | 'ó' | 'ô' | 'ö' | 'õ' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
c => c.to_ascii_lowercase(),
})
.collect()
}
/// The families in alphabetical order (INVARIANT 5), each with its
/// variants that `filter` shows, also alphabetical; a family none of whose
/// variants it shows is left out.
pub fn families(filter: Filter) -> Vec<(&'static str, Vec<&'static Scheme>)> {
let mut out: Vec<(&'static str, Vec<&'static Scheme>)> = Vec::new();
for s in all().filter(|s| filter.shows(s)) {
match out.iter_mut().find(|(family, _)| *family == s.family) {
Some((_, variants)) => variants.push(s),
None => out.push((s.family, vec![s])),
}
}
out.sort_by_key(|(family, _)| sort_key(family));
for (_, variants) in &mut out {
variants.sort_by_key(|s| sort_key(s.name));
}
out
}
/// The viewer's own scheme for light or dark.
pub fn own(dark: bool) -> &'static Scheme {
&OWN_SCHEMES[usize::from(dark)]
}
impl Scheme {
/// How it is kept in the viewer's preferences: "Family/Variant".
pub fn key(&self) -> String {
format!("{}/{}", self.family, self.name)
}
/// The scheme kept as `key`, else the viewer's own of mode `dark`
/// (INVARIANT 4).
pub fn from_key(key: &str, dark: bool) -> &'static Scheme {
all().find(|s| s.key() == key).unwrap_or_else(|| own(dark))
}
/// How the menu names it.
pub fn label(&self) -> String {
if self.family == self.name {
self.name.to_string()
} else {
format!("{} {}", self.family, self.name)
}
}
/// The palette the viewer paints with (INVARIANT 2), made once per
/// scheme.
pub fn palette(&self) -> Palette {
static MADE: std::sync::OnceLock<Vec<Palette>> = std::sync::OnceLock::new();
let made = MADE.get_or_init(|| all().map(Scheme::make_palette).collect());
all()
.position(|s| s == self)
.map_or_else(|| self.make_palette(), |i| made[i])
}
fn make_palette(&self) -> Palette {
readable(match &self.core {
None if self.dark => crate::theme::DARK,
None => crate::theme::LIGHT,
Some(core) => derive(core),
})
}
/// A few of its colors, for the menu's swatch.
pub fn swatch(&self) -> [Color32; 5] {
let p = self.palette();
[p.bg, p.accent, p.positive, p.warning, p.negative]
}
}
/// `a` moved toward `b` by `t` (0: `a`, 1: `b`).
pub fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
let f = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8;
Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b()))
}
/// WCAG relative luminance.
pub fn luminance(c: Color32) -> f32 {
let lin = |v: u8| {
let s = f32::from(v) / 255.0;
if s <= 0.039_28 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
}
/// WCAG contrast ratio of two colors (1 to 21).
pub fn contrast(a: Color32, b: Color32) -> f32 {
let (x, y) = (luminance(a), luminance(b));
(x.max(y) + 0.05) / (x.min(y) + 0.05)
}
/// The least the readable colors reach on every background (INVARIANT 2).
pub const READABLE: f32 = 4.5;
/// `c`, or `c` with its hue and saturation kept and its lightness moved
/// toward `toward` (black or white) just far enough to reach `min`
/// contrast on each of `on` (INVARIANT 2).
pub fn legible(c: Color32, toward: Color32, on: &[Color32], min: f32) -> Color32 {
let ok = |c: Color32| on.iter().all(|b| contrast(c, *b) >= min);
if ok(c) {
return c;
}
let (h, s, l) = hsl(c);
let end = if luminance(toward) > 0.5 { 1.0 } else { 0.0 };
(1..=100)
.map(|i| from_hsl(h, s, l + (end - l) * i as f32 / 100.0))
.find(|c| ok(*c))
.unwrap_or(toward)
}
/// Hue (0 to 1), saturation and lightness of `c`.
fn hsl(c: Color32) -> (f32, f32, f32) {
let [r, g, b] = [c.r(), c.g(), c.b()].map(|v| f32::from(v) / 255.0);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h / 6.0, s, l)
}
/// The color of hue `h` (0 to 1), saturation `s` and lightness `l`.
fn from_hsl(h: f32, s: f32, l: f32) -> Color32 {
let l = l.clamp(0.0, 1.0);
if s == 0.0 {
let v = (l * 255.0).round() as u8;
return Color32::from_rgb(v, v, v);
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
let channel = |t: f32| {
let t = t.rem_euclid(1.0);
let v = if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
};
(v.clamp(0.0, 1.0) * 255.0).round() as u8
};
Color32::from_rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
}
/// A color in CIELAB (D65): lightness, then the two color axes.
fn lab(c: Color32) -> [f32; 3] {
let lin = |v: u8| {
let s = f32::from(v) / 255.0;
if s <= 0.040_45 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
let (r, g, b) = (lin(c.r()), lin(c.g()), lin(c.b()));
let x = (0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b) / 0.950_47;
let y = 0.212_672_9 * r + 0.715_152_2 * g + 0.072_175 * b;
let z = (0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b) / 1.088_83;
let f = |t: f32| {
if t > 216.0 / 24_389.0 {
t.cbrt()
} else {
(24_389.0 / 27.0 * t + 16.0) / 116.0
}
};
let (fx, fy, fz) = (f(x), f(y), f(z));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// How different two colors look: CIE76 ΔE (about 2: just noticeable;
/// 20 and more: plainly different colors).
pub fn distance(a: Color32, b: Color32) -> f32 {
let (a, b) = (lab(a), lab(b));
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// How far apart a chart's series colors are kept, at least.
pub const SERIES_APART: f32 = 20.0;
/// The chart's series colors (the charts use the first three together:
/// Net, Income, Spending): the scheme's own named colors in this order,
/// each one that looks like one already taken passed over (some schemes
/// name one color twice: Monokai Pro's cyan is its blue); when none is
/// left that is plainly different, the one most unlike those taken.
/// `muted` is last. Each is first made readable on the cards, as
/// `readable` would, so two that only part once lifted aren't taken for
/// different.
fn series(k: &Core, card: Color32, muted: Color32) -> [Color32; 6] {
let end = readable_end(card);
let mut left: Vec<Color32> = [
k.blue, k.cyan, k.yellow, k.purple, k.orange, k.green, k.red, k.accent,
]
.into_iter()
.map(|c| legible(c, end, &[card], 3.0))
.collect();
let mut out: Vec<Color32> = vec![left.remove(0)];
while out.len() < 5 {
let nearest = |c: &Color32| {
out.iter()
.map(|t| distance(*t, *c))
.fold(f32::INFINITY, f32::min)
};
let at = left
.iter()
.position(|c| nearest(c) >= SERIES_APART)
.unwrap_or_else(|| {
(0..left.len())
.max_by(|&i, &j| nearest(&left[i]).total_cmp(&nearest(&left[j])))
.expect("eight colors for five places")
});
out.push(left.remove(at));
}
[out[0], out[1], out[2], out[3], out[4], muted]
}
/// The viewer's palette from a scheme's colors.
fn derive(k: &Core) -> Palette {
// The page is the darker of the scheme's two backgrounds, the cards the
// lighter: cards stand out in light and dark schemes alike. A scheme
// with one background gets a second a shade toward its text: the cards
// in a dark scheme (lighter), the page in a light one (darker).
let (page, card) = if luminance(k.dim) <= luminance(k.bg) {
(k.dim, k.bg)
} else {
(k.bg, k.dim)
};
let (page, card) = if page != card {
(page, card)
} else if luminance(k.bg) < luminance(k.fg) {
(page, mix(page, k.fg, 0.04))
} else {
(mix(card, k.fg, 0.04), card)
};
// Secondary text sits between the text and the comments; a scheme
// whose "subtle" tone is no dimmer than its text (Nord's) gets one
// in between.
let muted = if contrast(k.muted, page) >= contrast(k.fg, page) {
mix(k.fg, k.faint, 0.45)
} else {
k.muted
};
let sidebar = mix(page, k.fg, 0.02);
let hover = mix(card, k.fg, 0.06);
let selected = mix(card, k.accent, 0.14);
// A tint light enough that amounts on it keep their own colors (at
// 0.22 it bleached Nord's red to a pink; review, 2026-10-19).
let new_highlight = mix(card, k.yellow, 0.10);
let soft = |c: Color32| mix(card, c, 0.14);
Palette {
bg: page,
surface: card,
sidebar,
hover,
selected,
border: mix(card, k.fg, 0.14),
text: k.fg,
muted,
faint: k.faint,
accent: k.accent,
accent_soft: soft(k.accent),
positive: k.green,
negative: k.red,
warning: k.orange,
warning_soft: soft(k.orange),
negative_soft: soft(k.red),
positive_soft: soft(k.green),
new_highlight,
series: series(k, card, muted),
}
}
/// Every background text or a figure is drawn on: the page, cards, the
/// sidebar, hovered, selected and new rows, and the soft fills of pills
/// and badges.
pub fn grounds(p: &Palette) -> [Color32; 10] {
[
p.bg,
p.surface,
p.sidebar,
p.hover,
p.selected,
p.new_highlight,
p.accent_soft,
p.warning_soft,
p.negative_soft,
p.positive_soft,
]
}
/// `p` with every color that carries text or a figure (the faintest
/// text included: it carries chart amounts and entry numbers) readable on
/// every background it is drawn on (INVARIANT 2): its lightness moved
/// toward black or white, whichever the cards are farther from, just far
/// enough.
fn readable(mut p: Palette) -> Palette {
let grounds = grounds(&p);
let end = readable_end(p.surface);
let read = |c: Color32| legible(c, end, &grounds, READABLE);
// The faintest text first, then each level above it reading at least
// STEP times better: text over secondary over faint, always.
p.faint = read(p.faint);
let above = |c: Color32, below: Color32| {
legible(
c,
end,
&grounds,
(worst(below, &grounds) * STEP).max(READABLE),
)
};
p.muted = above(p.muted, p.faint);
p.text = above(p.text, p.muted);
p.positive = read(p.positive);
p.negative = read(p.negative);
p.warning = read(p.warning);
p.accent = read(p.accent);
// Chart bars sit on the cards, and on the hover tint when pointed at.
let (surface, hover) = (p.surface, p.hover);
p.series = p.series.map(|c| legible(c, end, &[surface, hover], 3.0));
p
}
/// How much better each level of text reads than the one below it, at
/// least, on the ground it reads worst on: text over secondary text over
/// the faintest (review, 2026-10-19: lifting each alone made them alike).
pub const STEP: f32 = 1.2;
/// The contrast `c` has on the ground it reads worst on.
pub fn worst(c: Color32, grounds: &[Color32]) -> f32 {
grounds
.iter()
.map(|g| contrast(c, *g))
.fold(f32::INFINITY, f32::min)
}
/// Black or white, whichever reads better on `c`: for text on a filled
/// color (the logo's initial on the accent).
pub fn on(c: Color32) -> Color32 {
readable_end(c)
}
/// Black or white, whichever reads better on `c`.
fn readable_end(c: Color32) -> Color32 {
if contrast(Color32::BLACK, c) >= contrast(Color32::WHITE, c) {
Color32::BLACK
} else {
Color32::WHITE
}
}
/// `Color32` from three bytes (the table's shorthand).
pub(crate) const fn rgb(r: u8, g: u8, b: u8) -> Color32 {
Color32::from_rgb(r, g, b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn twenty_families_each_with_all_its_variants() {
let families = families(Filter::All);
// The viewer's own, and twenty more.
assert!(families.len() > 20, "{} families", families.len());
// Alphabetical, families and variants (INVARIANT 5).
let keys: Vec<String> = families.iter().map(|f| sort_key(f.0)).collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted);
assert_eq!(families[0].0, "Ayu");
assert_eq!(families.last().unwrap().0, OWN);
for (family, variants) in &families {
let names: Vec<String> = variants.iter().map(|s| sort_key(s.name)).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "{family}");
}
let rose = families.iter().position(|f| f.0 == "Rosé Pine").unwrap();
assert_eq!(families[rose - 1].0, "Oxocarbon", "Rosé sorts as Rose");
let names: Vec<&str> = families.iter().map(|f| f.0).collect();
for want in [
"Catppuccin",
"Tokyo Night",
"Gruvbox",
"Kanagawa",
"Rosé Pine",
"Ayu",
"Nord",
"Dracula",
"One Dark",
"Solarized",
] {
assert!(names.contains(&want), "{want} missing: {names:?}");
}
let ayu = &families.iter().find(|f| f.0 == "Ayu").unwrap().1;
let ayu: Vec<&str> = ayu.iter().map(|s| s.name).collect();
assert_eq!(ayu, ["Dark", "Light", "Mirage"]);
// A family appears once, its variants together; keys are unique.
let mut seen = std::collections::BTreeSet::new();
for (family, _) in &families {
assert!(seen.insert(*family), "{family} twice");
}
let mut keys = std::collections::BTreeSet::new();
for s in all() {
assert!(keys.insert(s.key()), "{} twice", s.key());
}
}
#[test]
fn every_scheme_keeps_figures_and_text_readable() {
for s in all() {
let p = s.palette();
for (what, c) in [
("text", p.text),
("secondary text", p.muted),
("faint text", p.faint),
("positive amounts", p.positive),
("negative amounts", p.negative),
("warnings", p.warning),
("links", p.accent),
] {
for g in grounds(&p) {
let ratio = contrast(c, g);
assert!(
ratio >= READABLE,
"{}: {what} {c:?} on {g:?} is {ratio:.2}:1",
s.label()
);
}
}
for c in p.series {
assert!(
contrast(c, p.surface) >= 3.0,
"{}: a chart series",
s.label()
);
}
// The series the charts show together (Net, Income and
// Spending: 0, 1 and 2) look plainly different.
for i in 0..3 {
for j in 0..i {
let d = distance(p.series[i], p.series[j]);
assert!(
d >= SERIES_APART,
"{}: series {j} and {i} alike (ΔE {d:.1})",
s.label()
);
}
}
// Gains, losses and plain text look different to the eye.
for (what, a, b) in [
("gains and losses", p.positive, p.negative),
("gains and text", p.positive, p.text),
("losses and text", p.negative, p.text),
] {
let d = distance(a, b);
assert!(d >= 20.0, "{}: {what} alike (ΔE {d:.1})", s.label());
}
// Text over secondary over faint, each plainly above the next.
let g = grounds(&p);
let (t, m, f) = (worst(p.text, &g), worst(p.muted, &g), worst(p.faint, &g));
assert!(
t >= m * STEP * 0.999 && m >= f * STEP * 0.999,
"{}: text {t:.2}, secondary {m:.2}, faint {f:.2}",
s.label()
);
for c in p.series {
assert!(
contrast(c, p.hover) >= 3.0,
"{}: a series on hover",
s.label()
);
}
// The logo's initial reads on the accent.
assert!(
contrast(on(p.accent), p.accent) >= READABLE,
"{}",
s.label()
);
// Gains and losses never look alike: their hues lie at least
// 30 degrees apart (or, for a scheme with greyish ones, the
// colors plainly differ).
let ((h0, s0, _), (h1, s1, _)) = (hsl(p.positive), hsl(p.negative));
let apart = (h0 - h1).abs().min(1.0 - (h0 - h1).abs());
let d = {
let f = |x: u8, y: u8| (f32::from(x) - f32::from(y)).powi(2);
let (a, b) = (p.positive, p.negative);
(f(a.r(), b.r()) + f(a.g(), b.g()) + f(a.b(), b.b())).sqrt()
};
assert!(
(s0 > 0.15 && s1 > 0.15 && apart >= 30.0 / 360.0) || d > 60.0,
"{}: + {:?} and - {:?} alike",
s.label(),
p.positive,
p.negative
);
// Cards stand out from the page.
assert_ne!(p.bg, p.surface, "{}", s.label());
}
}
#[test]
fn a_scheme_is_dark_exactly_when_its_background_is() {
for s in all() {
let p = s.palette();
let dark_bg = luminance(p.bg) < luminance(p.text);
assert_eq!(s.dark, dark_bg, "{}", s.label());
}
}
#[test]
fn the_filter_shows_exactly_its_mode() {
let count = |f| families(f).iter().map(|(_, v)| v.len()).sum::<usize>();
assert_eq!(count(Filter::All), all().count());
assert_eq!(count(Filter::Light) + count(Filter::Dark), all().count());
for (_, variants) in families(Filter::Light) {
assert!(variants.iter().all(|s| !s.dark));
}
for (_, variants) in families(Filter::Dark) {
assert!(variants.iter().all(|s| s.dark));
}
// A family with no variant of the mode isn't listed (Nord has no
// light one).
assert!(!families(Filter::Light).iter().any(|(f, _)| *f == "Nord"));
for f in [Filter::All, Filter::Light, Filter::Dark] {
assert_eq!(Filter::from_name(f.name()), Some(f));
}
assert_eq!(Filter::from_name("Sepia"), None);
}
#[test]
fn a_kept_scheme_comes_back_and_a_lost_one_falls_back() {
for s in all() {
assert!(std::ptr::eq(Scheme::from_key(&s.key(), !s.dark), s));
}
assert!(std::ptr::eq(Scheme::from_key("Nope/Gone", true), own(true)));
assert!(std::ptr::eq(Scheme::from_key("", false), own(false)));
assert_eq!(own(false).palette().bg, crate::theme::LIGHT.bg);
assert_eq!(own(true).palette().bg, crate::theme::DARK.bg);
assert_eq!(Scheme::from_key("Ayu/Mirage", false).label(), "Ayu Mirage");
assert_eq!(Scheme::from_key("Nord/Nord", false).label(), "Nord");
}
#[test]
fn hsl_round_trips_and_keeps_hue() {
for c in [
Color32::from_rgb(0xe6, 0x7e, 0x80),
Color32::from_rgb(0xa7, 0xc0, 0x80),
Color32::from_rgb(0x12, 0x34, 0x56),
Color32::from_rgb(0x80, 0x80, 0x80),
Color32::WHITE,
Color32::BLACK,
] {
let (h, s, l) = hsl(c);
assert_eq!(from_hsl(h, s, l), c);
}
// A red made lighter is still a red, not a pink-grey.
let red = Color32::from_rgb(0xe6, 0x7e, 0x80);
let dark = Color32::from_rgb(0x30, 0x30, 0x30);
let fixed = legible(red, Color32::WHITE, &[dark], 7.0);
assert!(contrast(fixed, dark) >= 7.0);
let ((h0, s0, _), (h1, s1, _)) = (hsl(red), hsl(fixed));
assert!(
(h0 - h1).abs() < 0.02 && (s0 - s1).abs() < 0.05,
"{fixed:?}"
);
}
#[test]
fn a_color_already_readable_is_left_as_it_is() {
let white = Color32::WHITE;
let navy = Color32::from_rgb(0x00, 0x00, 0x80);
assert_eq!(legible(navy, Color32::BLACK, &[white], READABLE), navy);
// A pale yellow on white is darkened just enough.
let pale = Color32::from_rgb(0xFF, 0xEE, 0x88);
let fixed = legible(pale, Color32::BLACK, &[white], READABLE);
assert!(contrast(fixed, white) >= READABLE);
assert!(contrast(pale, white) < READABLE);
assert!((contrast(fixed, white) - READABLE) < 1.0, "moved too far");
}
}
/// Every scheme on the viewer's pages, as painted: the same words and
/// figures in the same places, each readable on what is under it.
#[cfg(test)]
mod painted {
use super::*;
use crate::nav::{ReportKind, Route};
use crate::test_support::{Painted, painted, rects};
use egui_kittest::Harness;
/// The text that can be seen, and where it is, to the pixel.
fn words(texts: &[Seen], label: &str) -> Vec<(String, i32, i32)> {
// The scheme button names the scheme in force: that one differs.
// A document's thumbnail still rendering shows "…" (documents.rs,
// Render::Loading) until its picture comes, whenever that is: not
// a word of the page (its contrast is still checked). CI's slower
// machines caught a page drawn with a few still loading.
let button = format!("{} {label}", egui_phosphor::regular::PALETTE);
texts
.iter()
.filter(|t| t.text != button && t.text != "…")
.map(|t| {
(
t.text.clone(),
t.pos.x.round() as i32,
t.pos.y.round() as i32,
)
})
.collect()
}
/// `c` laid over `ground` (a translucent color as seen).
fn over(c: Color32, ground: Color32) -> Color32 {
let [r, g, b, a] = c.to_srgba_unmultiplied();
let t = f32::from(a) / 255.0;
mix(ground, Color32::from_rgb(r, g, b), t)
}
/// One run of painted text, its color as seen, and the ground under
/// it as painted by then.
struct Seen {
text: String,
pos: egui::Pos2,
color: Color32,
ground: Color32,
}
/// Every run of text a frame painted where it can be seen, with the
/// ground under its middle: the fills painted before it, in paint
/// order, each cut to its clip, translucent ones laid over what was
/// below, all on `base` (the page).
fn seen(out: &egui::FullOutput, base: Color32) -> Vec<Seen> {
fn walk(
s: &egui::Shape,
clip: egui::Rect,
base: Color32,
fills: &mut Vec<(egui::Rect, Color32)>,
out: &mut Vec<Seen>,
) {
match s {
egui::Shape::Rect(r) if r.fill.a() > 0 => {
fills.push((r.rect.intersect(clip), r.fill));
}
egui::Shape::Text(t) => {
let rect = egui::Rect::from_min_size(t.pos, t.galley.size());
let mid = rect.center();
if !clip.contains(mid) {
return;
}
let ground = fills
.iter()
.filter(|(r, _)| r.contains(mid))
.fold(base, |g, (_, c)| over(*c, g));
let job = &t.galley.job;
for section in &job.sections {
let range = &section.byte_range;
let text = &job.text[range.start.0..range.end.0];
if text.trim().is_empty() {
continue;
}
let color = t.override_text_color.unwrap_or(
if section.format.color == Color32::PLACEHOLDER {
t.fallback_color
} else {
section.format.color
},
);
out.push(Seen {
text: text.to_string(),
pos: t.pos,
color: over(color, ground),
ground,
});
}
}
egui::Shape::Vec(v) => {
for s in v {
walk(s, clip, base, fills, out);
}
}
_ => {}
}
}
let mut fills = Vec::new();
let mut texts = Vec::new();
for c in &out.shapes {
walk(&c.shape, c.clip_rect, base, &mut fills, &mut texts);
}
texts
}
#[test]
fn the_system_turning_light_or_dark_changes_nothing() {
let (_dir, books) = crate::test_support::Build::new("schemes-flip").books();
let mut h = Harness::builder()
.with_size(egui::vec2(1400.0, 1200.0))
.build_eframe(|cc| crate::App::with_books(&cc.egui_ctx, books, false, Route::Overview));
// As eframe starts: following the system (kittest pins a theme).
h.ctx
.options_mut(|o| o.theme_preference = egui::ThemePreference::System);
h.run_steps(3);
for (key, system) in [
("Catppuccin/Mocha", egui::Theme::Light),
("Catppuccin/Latte", egui::Theme::Dark),
("Wanderbooks/Dark", egui::Theme::Light),
] {
let ctx = h.ctx.clone();
h.state_mut().set_scheme(&ctx, key);
h.run_steps(2);
let before = painted(h.output());
let fills_before: Vec<Color32> =
rects(h.output()).into_iter().map(|(_, c)| c).collect();
h.input_mut().system_theme = Some(system);
h.run_steps(3);
let p = Scheme::from_key(key, false).palette();
let v = h.ctx.global_style().visuals.clone();
assert_eq!(v.override_text_color, Some(p.text), "{key}");
assert_eq!(v.panel_fill, p.bg, "{key}");
let after = painted(h.output());
let colors = |t: &[Painted]| t.iter().map(|t| t.color).collect::<Vec<_>>();
assert_eq!(colors(&after), colors(&before), "{key}: a color changed");
let fills =
|r: Vec<(egui::Rect, Color32)>| r.into_iter().map(|(_, c)| c).collect::<Vec<_>>();
assert_eq!(
fills(rects(h.output())),
fills_before,
"{key}: a fill changed"
);
}
}
/// One page in every scheme; how many texts were checked.
fn page_in_every_scheme(source: &crate::data::Source, route: Route) -> usize {
let mut checked = 0usize;
let books = crate::data::load_once(source).unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1400.0, 2400.0))
.build_eframe(|cc| crate::App::with_books(&cc.egui_ctx, books, false, route.clone()));
h.run_steps(4);
let mut first: Option<Vec<(String, i32, i32)>> = None;
for s in all() {
let ctx = h.ctx.clone();
h.state_mut().set_scheme(&ctx, &s.key());
h.run_steps(3);
// Thumbnails render in the background: all of them first.
h.state_mut().settle(&ctx);
h.run_steps(2);
let texts = seen(h.output(), s.palette().bg);
let p = s.palette();
let shown = crate::theme::pal(&h.ctx);
assert_eq!((shown.bg, shown.text), (p.bg, p.text), "{}", s.label());
let now = words(&texts, &s.label());
assert!(now.len() > 10, "{route:?}: little was painted");
match &first {
None => first = Some(now),
Some(want) => assert_eq!(&now, want, "{route:?} in {}", s.label()),
}
// Each run of text against what was painted under it by
// then.
for t in texts {
let ratio = contrast(t.color, t.ground);
assert!(
ratio >= READABLE,
"{route:?} in {}: {:?} at {:?} is {ratio:.2}:1 ({:?} on {:?})",
s.label(),
t.text,
t.pos,
t.color,
t.ground
);
checked += 1;
}
}
checked
}
#[test]
fn every_scheme_paints_the_same_words_and_figures_readably() {
let dir = crate::test_support::TempDir(
std::env::temp_dir().join(format!("ledger-gui-schemes-painted-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
crate::demo::build(&dir.0, today).unwrap();
let source = crate::data::Source {
data_dir: dir.0.join("books-data"),
today: Some(today),
};
let entry = crate::data::load_once(&source)
.unwrap()
.ledger
.posted_entries()
.next()
.map(|e| e.id())
.expect("the demo books have entries");
let routes = [
Route::Entry(entry),
Route::Company,
Route::Assets,
Route::Documents,
Route::Overview,
Route::Cash,
Route::Profit,
Route::Transactions,
Route::Reports(ReportKind::ProfitAndLoss),
Route::Reports(ReportKind::BalanceSheet),
Route::Accounts,
Route::Parties,
Route::Projects,
Route::Journal,
];
// A page a thread: each checks every scheme.
let checked: usize = std::thread::scope(|scope| {
let threads: Vec<_> = routes
.into_iter()
.map(|route| {
let source = &source;
scope.spawn(move || page_in_every_scheme(source, route))
})
.collect();
threads.into_iter().map(|t| t.join().unwrap()).sum()
});
assert!(checked > 10_000, "{checked} texts checked");
}
}