crates/veilvoice-gui/src/palettes.rs
what this file is for · veilvoice-gui · 700 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! User-defined colour schemes, and the contrast check that keeps them usable.
//!
//! A reader can drop a small text file into a `palettes/` directory beside the
//! preferences file and have it appear in the theme picker alongside the nine
//! built-in schemes. The format is the same `key = value` shape
//! [`crate::prefs`] already uses, for the same reason: it is trivial to write
//! by hand, trivial to parse without a dependency, and has no syntax in which
//! something surprising can hide.
//!
//! # A palette file is untrusted input
//!
//! It arrives from the filesystem, it may have been written by hand at two in
//! the morning, and it may have been copied from a web page by somebody who has
//! never seen a hex colour. So it is parsed like anything else this project
//! reads: every field validated, every failure named, and **refused rather than
//! patched up**.
//!
//! Refusing matters more here than it first appears. The obvious lenient design
//! is to fill in whatever is missing from the default theme -- and that produces
//! a palette which is *mostly* the user's, with a few colours from somewhere
//! else, and no indication which. The user sees an application that looks
//! subtly wrong and has nothing to go on. An error naming the missing token is
//! worth more than a window that opens.
//!
//! # Contrast is computed, not trusted
//!
//! The request that prompted this asked for "whatever colour with correct
//! contrast to read". Correct contrast is not an aesthetic judgement, it is
//! arithmetic: WCAG 2.1 defines relative luminance and a contrast ratio, and a
//! ratio below 4.5 means body text a substantial number of people cannot read.
//!
//! So a palette whose foreground fails against its own background is **refused
//! with the measured ratio in the message**, rather than accepted and left to
//! produce an application nobody can use. It is the one validation here that
//! is about the user rather than about the parser, and it is the reason this
//! module exists at all rather than the fields being read straight into a
//! struct.
//!
//! The thresholds are stated where they are enforced, and they are the
//! standard's, not invented here:
//!
//! | Pair | Minimum | Why |
//! |---|---|---|
//! | `fg` on `bg` | 4.5 | Body text, WCAG AA |
//! | `fg` on `bg_soft` | 4.5 | The same text on a raised surface |
//! | `muted` on `bg` | 3.0 | Secondary text, AA large-text threshold |
//! | `accent` on `bg` | 3.0 | Links and controls, AA non-text contrast |
//! | `err` on `bg` | 3.0 | A warning nobody can read is worse than none |
//!
//! `muted` is deliberately held to 3.0 rather than 4.5. It is used for
//! secondary text that is meant to recede, every built-in theme would fail at
//! 4.5, and pretending otherwise would mean shipping a rule the project's own
//! themes break.
//!
//! # In plain words
//!
//! Lets you write your own colour scheme and have VeilVoice use it.
//!
//! Drop a small text file in a folder and it appears in the list. Every colour has
//! to be there, and the text has to be readable against the background: a scheme
//! whose text fails that check is refused rather than applied, with the measured
//! numbers so you know how far off it is and which way to move.
use crate::theme::Theme;
use egui::Color32;
use std::path::{Path, PathBuf};
/// Every token a palette file has to define.
///
/// Listed rather than derived so that a missing one is named in the error. The
/// order is the order they are reported in.
pub const REQUIRED: &[&str] = &[
"bg", "bg-soft", "bg-inset", "border", "fg", "muted", "accent", "accent-2", "cyan", "ok",
"warn", "err",
];
/// The most palette files that will be read from the directory.
///
/// A bound on work driven by the contents of a directory, in the same spirit as
/// the bounds in the search index and the release verifier. Nobody has forty
/// colour schemes; a directory containing forty thousand files is a mistake or
/// a prank, and either way the application should still start.
pub const MAX_PALETTES: usize = 40;
/// The largest palette file that will be read.
pub const MAX_BYTES: u64 = 16 * 1024;
/// Relative luminance, as defined by WCAG 2.1.
///
/// The channel transfer is the standard's, including the 0.03928 knee: it is
/// not the same as a plain gamma of 2.2, and using one would shift every ratio
/// slightly -- enough to pass a palette the standard fails.
fn luminance(colour: Color32) -> f32 {
fn channel(value: u8) -> f32 {
let v = value as f32 / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * channel(colour.r()) + 0.7152 * channel(colour.g()) + 0.0722 * channel(colour.b())
}
/// The WCAG contrast ratio between two colours, from 1.0 to 21.0.
///
/// Symmetric: the order of the arguments does not matter, which is why the
/// lighter of the two is worked out here rather than assumed by the caller.
pub fn contrast(a: Color32, b: Color32) -> f32 {
let (x, y) = (luminance(a), luminance(b));
let (lighter, darker) = if x > y { (x, y) } else { (y, x) };
(lighter + 0.05) / (darker + 0.05)
}
/// The contrast pairs a palette has to satisfy, with the reason for each.
const PAIRS: &[(&str, &str, f32)] = &[
("fg", "bg", 4.5),
("fg", "bg-soft", 4.5),
("muted", "bg", 3.0),
("accent", "bg", 3.0),
("err", "bg", 3.0),
];
/// Check a palette's contrast, returning one message per failing pair.
///
/// Returns the measured ratio in each message. "Your colours are too similar"
/// is not actionable; "fg on bg is 2.1:1, and body text needs 4.5:1" tells
/// somebody exactly how far off they are and which way to move.
pub fn contrast_problems(theme: &Theme) -> Vec<String> {
let value = |token: &str| -> Color32 {
match token {
"bg" => theme.bg,
"bg-soft" => theme.bg_soft,
"fg" => theme.fg,
"muted" => theme.muted,
"accent" => theme.accent,
"err" => theme.err,
_ => theme.fg,
}
};
let mut problems = Vec::new();
for (front, back, minimum) in PAIRS {
let ratio = contrast(value(front), value(back));
if ratio < *minimum {
problems.push(format!(
"{front} on {back} is {ratio:.1}:1, and needs at least {minimum:.1}:1"
));
}
}
problems
}
/// One parsed colour scheme, before it is accepted.
#[derive(Debug)]
struct Parsed {
id: String,
name: String,
light: bool,
colours: Vec<(String, Color32)>,
}
fn parse_hex(text: &str) -> Option<Color32> {
let text = text.trim().trim_start_matches('#');
if text.len() != 6 || !text.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(text, 16).ok()?;
Some(Color32::from_rgb(
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
))
}
/// Parse a palette file's text. Returns the scheme, or every problem found.
///
/// Every problem, not the first: somebody fixing a hand-written file should
/// learn about all four mistakes in one go rather than rerunning after each.
fn parse(text: &str, fallback_id: &str) -> Result<Parsed, Vec<String>> {
let mut id = fallback_id.to_string();
let mut name = String::new();
let mut light = false;
let mut colours: Vec<(String, Color32)> = Vec::new();
let mut problems = Vec::new();
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') && !line.contains('=') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
problems.push(format!("line {}: expected `key = value`", number + 1));
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"id" => id = value.to_string(),
"name" => name = value.to_string(),
"light" => light = value.eq_ignore_ascii_case("true"),
_ if REQUIRED.contains(&key) => match parse_hex(value) {
Some(colour) => colours.push((key.to_string(), colour)),
None => problems.push(format!(
"line {}: {key} is `{value}`, which is not a #rrggbb colour",
number + 1
)),
},
_ => problems.push(format!(
"line {}: `{key}` is not a palette token",
number + 1
)),
}
}
for token in REQUIRED {
if !colours.iter().any(|(k, _)| k == token) {
problems.push(format!("no `{token}` is defined"));
}
}
// An id has to survive being written to the preferences file and read back,
// and it shares a namespace with the built-in ids and with the website's
// `data-theme` values. Anything outside this set is refused rather than
// sanitised, because a silently renamed theme is one the user cannot select
// again.
if id.is_empty()
|| !id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
problems.push(format!(
"`{id}` is not a usable id: lower-case letters, digits and hyphens only"
));
}
// Deliberately the *built-in* list rather than `theme::by_id`, which also
// searches palettes already loaded. Checking the combined table would make
// a palette collide with itself the second time it is read.
if crate::theme::THEMES.iter().any(|t| t.id == id) {
problems.push(format!("`{id}` is already the id of a built-in theme"));
}
if problems.is_empty() {
if name.is_empty() {
name = id.clone();
}
Ok(Parsed {
id,
name,
light,
colours,
})
} else {
Err(problems)
}
}
fn build(parsed: Parsed) -> Theme {
let get = |token: &str| -> Color32 {
parsed
.colours
.iter()
.find(|(k, _)| k == token)
.map(|(_, c)| *c)
// Unreachable: `parse` refuses a palette with a missing token, and
// this is only called on one that passed. Falling back to black
// rather than panicking keeps a paint loop out of the panic path.
.unwrap_or(Color32::BLACK)
};
Theme {
// Leaked on purpose -- see `load` for why this is bounded and safe.
id: Box::leak(parsed.id.into_boxed_str()),
name: Box::leak(parsed.name.into_boxed_str()),
light: parsed.light,
bg: get("bg"),
bg_soft: get("bg-soft"),
bg_inset: get("bg-inset"),
border: get("border"),
fg: get("fg"),
muted: get("muted"),
accent: get("accent"),
accent_2: get("accent-2"),
cyan: get("cyan"),
ok: get("ok"),
warn: get("warn"),
err: get("err"),
}
}
/// Where palettes live, beside the preferences file.
pub fn default_dir() -> Option<PathBuf> {
crate::prefs::default_path().map(|p| {
p.parent()
.map(|d| d.join("palettes"))
.unwrap_or_else(|| PathBuf::from("palettes"))
})
}
/// Read every palette in `dir`, returning the usable ones and every complaint.
///
/// Both halves are returned rather than one or the other. A palette that failed
/// must not silently vanish -- the user wrote a file, put it in the right place,
/// and is entitled to be told why it is not in the picker. The settings tab
/// shows these messages verbatim.
///
/// # On leaking
///
/// [`Theme`] holds `&'static str` for its id and name so that reading the
/// active theme in a paint loop is one relaxed atomic load and a slice index,
/// with no lock and no allocation -- see the module docs on `theme.rs`. Custom
/// palettes therefore leak those two strings.
///
/// That is a deliberate, bounded, load-once leak: at most [`MAX_PALETTES`]
/// entries, read during startup, never in a loop, and each a few dozen bytes.
/// The alternative -- reference counting or a lock around the palette table --
/// would put synchronisation into the hottest read in the application to avoid
/// leaking about a kilobyte, once.
pub fn load(dir: &Path) -> (Vec<Theme>, Vec<String>) {
let mut themes = Vec::new();
let mut problems = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
// No directory is the normal case, not an error worth reporting.
return (themes, problems);
};
let mut files: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "palette"))
.collect();
// Sorted so the picker's order is the same on every machine and every run.
files.sort();
if files.len() > MAX_PALETTES {
problems.push(format!(
"{} palette files found; reading the first {MAX_PALETTES}",
files.len()
));
files.truncate(MAX_PALETTES);
}
for path in files {
let shown = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Open first, then ask the *handle* what it is. Calling `metadata` on
// the path and then opening it is two operations on something that can
// change in between, and it answers the wrong question anyway: a FIFO
// reports a length of zero, sails past a size check, and then blocks
// `read_to_string` for ever -- during startup, before the window
// exists, so the application simply never appears.
let handle = match std::fs::File::open(&path) {
Ok(handle) => handle,
Err(error) => {
problems.push(format!("{shown}: {error}"));
continue;
}
};
match handle.metadata() {
Ok(meta) if !meta.is_file() => {
problems.push(format!("{shown}: not a regular file, ignored"));
continue;
}
Err(error) => {
problems.push(format!("{shown}: {error}"));
continue;
}
_ => {}
}
// Bound the read rather than the file. One byte past the limit is
// enough to know the limit was passed, and nothing larger is ever held
// in memory -- so a file that grows between being checked and being
// read cannot get past this.
let mut bytes = Vec::new();
use std::io::Read as _;
if let Err(error) = handle.take(MAX_BYTES + 1).read_to_end(&mut bytes) {
problems.push(format!("{shown}: {error}"));
continue;
}
if bytes.len() as u64 > MAX_BYTES {
problems.push(format!("{shown}: larger than {MAX_BYTES} bytes, ignored"));
continue;
}
let Ok(text) = String::from_utf8(bytes) else {
problems.push(format!("{shown}: not readable as UTF-8"));
continue;
};
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
match parse(&text, &stem) {
Err(found) => {
for line in found {
problems.push(format!("{shown}: {line}"));
}
}
Ok(parsed) => {
let theme = build(parsed);
let contrast = contrast_problems(&theme);
if contrast.is_empty() {
themes.push(theme);
} else {
for line in contrast {
problems.push(format!("{shown}: {line}"));
}
}
}
}
}
(themes, problems)
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str = "\
id = midnight
name = Midnight
light = false
bg = #101014
bg-soft = #1a1a22
bg-inset = #0b0b0e
border = #2f2f3a
fg = #e6e6f0
muted = #9a9ab0
accent = #7aa2f7
accent-2 = #bb9af7
cyan = #7dcfff
ok = #9ece6a
warn = #e0af68
err = #f7768e
";
#[test]
fn a_well_formed_palette_is_accepted() {
let parsed = parse(GOOD, "fallback").expect("should parse");
assert_eq!(parsed.id, "midnight");
assert_eq!(parsed.name, "Midnight");
assert_eq!(parsed.colours.len(), REQUIRED.len());
}
#[test]
fn the_contrast_ratio_matches_the_standard() {
// The two anchors every implementation is checked against: black on
// white is exactly 21, and a colour against itself is exactly 1.
let ratio = contrast(Color32::BLACK, Color32::WHITE);
assert!((ratio - 21.0).abs() < 0.01, "black on white was {ratio}");
let same = contrast(
Color32::from_rgb(0x7a, 0xa2, 0xf7),
Color32::from_rgb(0x7a, 0xa2, 0xf7),
);
assert!(
(same - 1.0).abs() < 0.001,
"a colour against itself was {same}"
);
}
#[test]
fn contrast_is_symmetric() {
let a = Color32::from_rgb(0x1a, 0x1b, 0x26);
let b = Color32::from_rgb(0xc0, 0xca, 0xf5);
assert!((contrast(a, b) - contrast(b, a)).abs() < 1e-6);
}
#[test]
fn every_built_in_theme_passes_its_own_contrast_check() {
// If a rule the project enforces on other people's palettes fails on
// its own, the rule is wrong or the theme is. Either way it must not
// ship, and finding out here is better than from a user who cannot
// read the window.
for theme in crate::theme::THEMES {
let problems = contrast_problems(theme);
assert!(
problems.is_empty(),
"the built-in '{}' theme fails the contrast rule: {:?}",
theme.id,
problems
);
}
}
#[test]
fn an_unreadable_palette_is_refused_with_the_measured_ratio() {
let text = GOOD.replace("fg = #e6e6f0", "fg = #17171d");
let parsed = parse(&text, "x").expect("parses");
let theme = build(parsed);
let problems = contrast_problems(&theme);
assert!(!problems.is_empty(), "near-invisible text was accepted");
assert!(
problems[0].contains(":1"),
"the message must carry the measured ratio, got {:?}",
problems[0]
);
}
#[test]
fn a_missing_token_is_named_rather_than_filled_in() {
let text = GOOD.replace("cyan = #7dcfff\n", "");
let problems = parse(&text, "x").expect_err("should be refused");
assert!(
problems.iter().any(|p| p.contains("`cyan`")),
"the missing token was not named: {problems:?}"
);
}
#[test]
fn every_problem_is_reported_not_just_the_first() {
let text = GOOD
.replace("cyan = #7dcfff\n", "")
.replace("ok = #9ece6a", "ok = green");
let problems = parse(&text, "x").expect_err("should be refused");
assert!(
problems.len() >= 2,
"only {} problem(s) reported: {problems:?}",
problems.len()
);
}
#[test]
fn a_bad_colour_is_refused_rather_than_guessed() {
for bad in ["green", "#fff", "#gggggg", "", "0x112233", "#1122333"] {
let text = GOOD.replace("ok = #9ece6a", &format!("ok = {bad}"));
assert!(
parse(&text, "x").is_err(),
"the value {bad:?} was accepted as a colour"
);
}
}
#[test]
fn a_palette_cannot_impersonate_a_built_in_theme() {
let text = GOOD.replace("id = midnight", "id = tokyo-night");
let problems = parse(&text, "x").expect_err("should be refused");
assert!(problems.iter().any(|p| p.contains("built-in")));
}
#[test]
fn an_id_that_would_not_survive_the_preferences_file_is_refused() {
for bad in ["Midnight", "mid night", "../../etc/passwd", "mid=night", ""] {
let text = GOOD.replace("id = midnight", &format!("id = {bad}"));
assert!(
parse(&text, "fallbackid").is_err(),
"the id {bad:?} was accepted"
);
}
}
/// A scratch directory that cleans up after itself.
///
/// No `tempfile` dependency for four tests: this project's argument is that
/// its supply chain is small enough to read, and a crate pulled in to make
/// a directory would be a poor trade.
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
// The process id keeps two `cargo test` runs from colliding, and
// the tag keeps the tests within one run apart.
let dir = std::env::temp_dir().join(format!(
"veilvoice-palettes-{}-{}",
std::process::id(),
tag
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch directory");
Self(dir)
}
fn write(&self, name: &str, text: &str) {
std::fs::write(self.0.join(name), text).expect("write palette");
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_palette_file_on_disk_becomes_a_theme() {
let scratch = Scratch::new("good");
scratch.write("midnight.palette", GOOD);
let (themes, problems) = load(&scratch.0);
assert!(problems.is_empty(), "unexpected complaints: {problems:?}");
assert_eq!(themes.len(), 1, "the file did not become a theme");
assert_eq!(themes[0].id, "midnight");
assert_eq!(themes[0].name, "Midnight");
assert_eq!(themes[0].bg, Color32::from_rgb(0x10, 0x10, 0x14));
}
#[test]
fn a_rejected_palette_is_reported_rather_than_dropped_in_silence() {
let scratch = Scratch::new("bad");
scratch.write("broken.palette", &GOOD.replace("cyan = #7dcfff\n", ""));
let (themes, problems) = load(&scratch.0);
assert!(themes.is_empty(), "a palette missing a token was accepted");
assert!(
problems.iter().any(|p| p.contains("broken.palette")),
"the complaint does not name the file: {problems:?}"
);
assert!(
problems.iter().any(|p| p.contains("`cyan`")),
"the complaint does not name the missing token: {problems:?}"
);
}
#[test]
fn one_bad_palette_does_not_hide_a_good_one() {
// The lenient-parser trap in reverse: a directory with a mistake in it
// must still yield the palettes that are fine, or one typo costs the
// user every scheme they wrote.
let scratch = Scratch::new("mixed");
scratch.write("midnight.palette", GOOD);
scratch.write("broken.palette", "id = nonsense\n");
scratch.write("notes.txt", "this is not a palette and must be ignored");
let (themes, problems) = load(&scratch.0);
assert_eq!(themes.len(), 1, "the good palette was lost: {problems:?}");
assert_eq!(themes[0].id, "midnight");
assert!(
problems.iter().any(|p| p.contains("broken.palette")),
"the broken one was not reported: {problems:?}"
);
assert!(
!problems.iter().any(|p| p.contains("notes.txt")),
"a file that is not a palette was complained about: {problems:?}"
);
}
#[test]
fn palettes_load_in_a_stable_order() {
// The picker's order has to be the same on every machine and every
// run, and `read_dir` guarantees no order at all.
let scratch = Scratch::new("order");
for (name, id) in [("zulu", "zulu"), ("alpha", "alpha"), ("mike", "mike")] {
scratch.write(
&format!("{name}.palette"),
&GOOD.replace("id = midnight", &format!("id = {id}")),
);
}
let (themes, _) = load(&scratch.0);
let ids: Vec<&str> = themes.iter().map(|t| t.id).collect();
assert_eq!(ids, ["alpha", "mike", "zulu"]);
}
#[test]
fn something_that_is_not_a_regular_file_is_refused_not_read() {
// A FIFO is the case that mattered -- it reports a length of zero,
// passes a size check and then blocks `read_to_string` for ever during
// startup. `mkfifo` is not portable, so the rule is exercised with a
// directory named like a palette: same path through the loader, same
// question asked of the handle, and it must not be read.
let scratch = Scratch::new("special");
std::fs::create_dir(scratch.0.join("trap.palette")).expect("make a directory");
let (themes, problems) = load(&scratch.0);
assert!(themes.is_empty());
assert!(
problems.iter().any(|p| p.contains("trap.palette")),
"the entry was skipped without a word: {problems:?}"
);
}
#[test]
fn a_file_over_the_size_bound_is_refused() {
let scratch = Scratch::new("huge");
let padding = "\n# ".to_string() + &"x".repeat(MAX_BYTES as usize);
scratch.write("fat.palette", &(GOOD.to_string() + &padding));
let (themes, problems) = load(&scratch.0);
assert!(themes.is_empty(), "an oversized palette was accepted");
assert!(
problems.iter().any(|p| p.contains("larger than")),
"the size refusal was not reported: {problems:?}"
);
}
#[test]
fn a_missing_directory_is_not_an_error() {
let (themes, problems) = load(Path::new("no/such/directory/anywhere"));
assert!(themes.is_empty());
assert!(
problems.is_empty(),
"a missing directory reported {problems:?}"
);
}
}