feat(theme): per-app accent: pm gives window manifest accent before app runs (display ACCENT, refused after BUFFER); theme::with_accent keeps hue, shifts lightness until contrast passes, else theme accent; tally pink

This commit is contained in:
2026-10-28 13:40:38 +01:00
parent 6827e1455d
commit 2b82f052ae
14 changed files with 158 additions and 16 deletions

View File

@@ -0,0 +1,42 @@
//! An app's own accent (its manifest's `[appearance] accent`), for its
//! primary buttons and active states. Its hue is kept; its lightness may
//! be moved toward the theme's far end until everything drawn with it
//! still passes contrast. One that cannot pass, is black or white, or
//! could be taken for the destructive colour, gives way to the theme's.
use crate::contrast::{check, luminance, ratio};
use crate::{Rgba, Theme};
/// How far toward white or black the accent may be moved, in tenths.
const MOST: u8 = 6;
/// Channels all this close to the destructive colour's read as it.
const LIKE_DESTRUCTIVE: u8 = 40;
fn mix(a: Rgba, b: Rgba, tenths: u8) -> Rgba {
let m = |x: u8, y: u8| ((x as u16 * (10 - tenths) as u16 + y as u16 * tenths as u16) / 10) as u8;
Rgba(m(a.0, b.0), m(a.1, b.1), m(a.2, b.2), 255)
}
fn near(a: Rgba, b: Rgba) -> bool {
a.0.abs_diff(b.0) < LIKE_DESTRUCTIVE && a.1.abs_diff(b.1) < LIKE_DESTRUCTIVE && a.2.abs_diff(b.2) < LIKE_DESTRUCTIVE
}
/// `t`, with `accent` in place of its own if it can be made to pass.
pub fn with_accent(t: Theme, accent: Rgba) -> Theme {
let (black, white) = (Rgba::rgb(0), Rgba::rgb(0xffffff));
if accent == black || accent == white || near(accent, t.destructive) {
return t;
}
let toward = if luminance(t.background) < 0.5 { white } else { black };
for tenths in 0..=MOST {
let a = mix(accent, toward, tenths);
// Whichever of the theme's own colours reads best on it.
let on = [t.on_accent, t.text_primary, t.background]
.into_iter()
.fold(t.on_accent, |best, c| if ratio(c, a) > ratio(best, a) { c } else { best });
if let Ok(ok) = check(Theme { accent: a, on_accent: on, ..t }) {
return ok;
}
}
t
}

View File

@@ -10,7 +10,7 @@ fn channel(c: u8) -> f32 {
if c <= 0.040_45 { c / 12.92 } else { libm::powf((c + 0.055) / 1.055, 2.4) }
}
fn luminance(c: Rgba) -> f32 {
pub(crate) fn luminance(c: Rgba) -> f32 {
0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2)
}

View File

@@ -8,6 +8,7 @@
#![no_std]
#![forbid(unsafe_code)]
mod accent;
mod contrast;
mod parse;
pub mod wire;
@@ -15,6 +16,7 @@ pub mod wire;
#[cfg(test)]
mod tests;
pub use accent::with_accent;
pub use contrast::{check, ratio};
pub use parse::{Error, parse};

View File

@@ -51,3 +51,23 @@ fn every_token_is_needed_and_checked() {
assert!(matches!(parse(&DARK.replace("highlight = 18", "glow = 18")), Err(Error::Toml(T::Unknown { .. }))));
assert!(matches!(parse(&std::format!("{DARK}\n[glass]\n")), Err(Error::Toml(T::Duplicate { .. }))));
}
#[test]
fn an_app_accent_keeps_its_hue_and_passes_in_either_theme() {
let pink = Rgba::rgb(0xe91e63);
for text in [DARK, LIGHT] {
let t = parse(text).unwrap();
let a = with_accent(t, pink);
assert_ne!(a.accent, t.accent, "{}: the app's, not the theme's", std::str::from_utf8(t.name()).unwrap());
assert!(a.accent.0 > a.accent.1 && a.accent.0 > a.accent.2 / 2, "still pink: {:?}", a.accent);
check(a).unwrap();
}
}
#[test]
fn some_accents_give_way_to_the_theme() {
let t = parse(DARK).unwrap();
for c in [0x000000, 0xffffff, 0xee5555] {
assert_eq!(with_accent(t, Rgba::rgb(c)), t, "{c:06x}");
}
}