feat(theme): themes as files (theme crate: toml subset + ints, contrast check, wire); whimsy dark + light; display reads themes/, serves THEME/THEME_NAME, init SET_THEME -> THEME_CHANGED to all windows, bar themed; k-ui follows; settings appearance; init keeps choice in sealed users/<id>/settings/, reapplies at sign-in + display restart

This commit is contained in:
2026-10-28 13:36:57 +01:00
parent 29becf297c
commit 6827e1455d
43 changed files with 1140 additions and 179 deletions

126
crates/theme/src/parse.rs Normal file
View File

@@ -0,0 +1,126 @@
//! A theme file, read strictly: every token present once, nothing unknown,
//! every value in range. Contrast is `check`'s.
use manifest::Error as Toml;
use manifest::toml::{self, Value};
use crate::{NAME_BYTES, Rgba, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
/// Not the TOML subset, a token given twice or unknown, or out of range.
Toml(Toml),
/// Two colours drawn on one another are too close: the pairing, and
/// the ratio they have, times 100.
Contrast { pair: &'static str, ratio_x100: u32 },
}
const COLOURS: [&str; 10] = [
"background",
"surface",
"surface_raised",
"accent",
"on_accent",
"text_primary",
"text_secondary",
"destructive",
"on_destructive",
"focus",
];
/// The whole numbers: table, key, and the range allowed.
const NUMBERS: [(&str, &str, u32, u32); 9] = [
("shape", "corner_radius", 0, 32),
("spacing", "base", 2, 8),
("motion", "duration_fast", 0, 2000),
("motion", "duration_normal", 0, 2000),
("motion", "spring_stiffness", 1, 2000),
("motion", "spring_damping", 1, 200),
("glass", "blur_radius", 0, 32),
("glass", "panel_opacity", 0, 100),
("glass", "highlight", 0, 100),
];
fn colour(s: &str) -> Option<Rgba> {
manifest::Accent::parse(s).map(|a| Rgba::rgb(a.0))
}
fn once<T>(slot: &mut Option<T>, value: Option<T>, line: usize) -> Result<(), Toml> {
if slot.is_some() {
return Err(Toml::Duplicate { line });
}
*slot = Some(value.ok_or(Toml::Invalid { line })?);
Ok(())
}
/// Reads a theme file. It is not checked for contrast here.
pub fn parse(text: &str) -> Result<Theme, Error> {
let mut name: Option<([u8; NAME_BYTES], usize)> = None;
let mut colours: [Option<Rgba>; 10] = [None; 10];
let mut orbs: Option<[Rgba; 3]> = None;
let mut numbers: [Option<u32>; 9] = [None; 9];
toml::parse(text, |e| {
let line = e.line;
match (e.table, e.key, e.value) {
("meta", "name", Value::Str(s)) => {
let fits = (1..=NAME_BYTES).contains(&s.len());
once(&mut name, fits.then(|| crate::name_bytes(s.as_bytes())), line)
}
("colour", key, Value::Str(s)) => {
let i = COLOURS.iter().position(|k| *k == key).ok_or(Toml::Unknown { line })?;
once(&mut colours[i], colour(s), line)
}
("wallpaper", "orbs", Value::List(list)) => {
let mut out = [Rgba::rgb(0); 3];
let mut n = 0;
for s in list.iter() {
*out.get_mut(n).ok_or(Toml::Invalid { line })? = colour(s).ok_or(Toml::Invalid { line })?;
n += 1;
}
once(&mut orbs, (n == 3).then_some(out), line)
}
(table, key, Value::Int(v)) => {
let i = NUMBERS.iter().position(|(t, k, ..)| *t == table && *k == key).ok_or(Toml::Unknown { line })?;
let (_, _, lo, hi) = NUMBERS[i];
once(&mut numbers[i], (lo..=hi).contains(&v).then_some(v), line)
}
("meta" | "colour" | "wallpaper" | "shape" | "spacing" | "motion" | "glass", _, _) => Err(Toml::Invalid { line }),
_ => Err(Toml::Unknown { line }),
}
})
.map_err(Error::Toml)?;
let (name, name_len) = name.ok_or(Error::Toml(Toml::Missing("name")))?;
let mut c = [Rgba::rgb(0); 10];
for (i, slot) in colours.iter().enumerate() {
c[i] = slot.ok_or(Error::Toml(Toml::Missing(COLOURS[i])))?;
}
let mut n = [0u32; 9];
for (i, slot) in numbers.iter().enumerate() {
n[i] = slot.ok_or(Error::Toml(Toml::Missing(NUMBERS[i].1)))?;
}
Ok(Theme {
name,
name_len,
background: c[0],
surface: c[1],
surface_raised: c[2],
accent: c[3],
on_accent: c[4],
text_primary: c[5],
text_secondary: c[6],
destructive: c[7],
on_destructive: c[8],
focus: c[9],
orbs: orbs.ok_or(Error::Toml(Toml::Missing("orbs")))?,
corner_radius: n[0] as f32,
spacing_base: n[1] as f32,
// In range, so these fit.
duration_fast: n[2] as u16,
duration_normal: n[3] as u16,
spring_stiffness: n[4] as u16,
spring_damping: n[5] as u16,
blur_radius: n[6] as u8,
panel_opacity: n[7] as u8,
highlight: n[8] as u8,
})
}