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

9
crates/theme/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "theme"
version = "0.1.0"
edition = "2024"
[dependencies]
types = { workspace = true }
manifest = { workspace = true }
libm = "0.2"

View File

@@ -0,0 +1,50 @@
//! Contrast as WCAG 2 measures it: the ratio of two colours' relative
//! luminance, from 1 (the same) to 21 (black on white). Text needs 4.5,
//! other things the eye must find (the focus ring, a toggle's track) 3.
use crate::parse::Error;
use crate::{Rgba, Theme};
fn channel(c: u8) -> f32 {
let c = c as f32 / 255.0;
if c <= 0.040_45 { c / 12.92 } else { libm::powf((c + 0.055) / 1.055, 2.4) }
}
fn luminance(c: Rgba) -> f32 {
0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2)
}
pub fn ratio(a: Rgba, b: Rgba) -> f32 {
let (la, lb) = (luminance(a), luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
const TEXT: f32 = 4.5;
const SHAPE: f32 = 3.0;
/// Every pairing K-UI draws, each with what it must reach.
fn pairings(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 10] {
[
("text_primary on background", t.text_primary, t.background, TEXT),
("text_primary on surface", t.text_primary, t.surface, TEXT),
("text_primary on surface_raised", t.text_primary, t.surface_raised, TEXT),
("text_secondary on background", t.text_secondary, t.background, TEXT),
("text_secondary on surface", t.text_secondary, t.surface, TEXT),
("accent on background", t.accent, t.background, TEXT),
("on_accent on accent", t.on_accent, t.accent, TEXT),
("on_destructive on destructive", t.on_destructive, t.destructive, TEXT),
("accent on surface", t.accent, t.surface, SHAPE),
("focus on background", t.focus, t.background, SHAPE),
]
}
/// The theme, if everything drawn with it can be read.
pub fn check(t: Theme) -> Result<Theme, Error> {
for (pair, fg, bg, min) in pairings(&t) {
let r = ratio(fg, bg);
if r < min {
return Err(Error::Contrast { pair, ratio_x100: (r * 100.0) as u32 });
}
}
Ok(t)
}

202
crates/theme/src/lib.rs Normal file
View File

@@ -0,0 +1,202 @@
//! A theme's tokens and the words apps describe their UI in (see the
//! vision's K-UI note): apps say what a thing is (a primary button, a
//! heading), never its colour or size. Themes are files (`themes/*.toml`
//! on the system image), read strictly (`parse`) and refused unless every
//! pairing text is drawn in has enough contrast (`check`). The display
//! holds the one in use and hands it to every window (`wire`). Pure.
#![no_std]
#![forbid(unsafe_code)]
mod contrast;
mod parse;
pub mod wire;
#[cfg(test)]
mod tests;
pub use contrast::{check, ratio};
pub use parse::{Error, parse};
/// A colour as the theme names it: straight (not premultiplied) RGBA.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgba(pub u8, pub u8, pub u8, pub u8);
impl Rgba {
pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
Self(r, g, b, 255)
}
/// From `0xrrggbb`.
pub const fn rgb(v: u32) -> Self {
Self::opaque((v >> 16) as u8, (v >> 8) as u8, v as u8)
}
pub const fn to_rgb(self) -> u32 {
(self.0 as u32) << 16 | (self.1 as u32) << 8 | self.2 as u32
}
}
/// Steps of the spacing scale.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Spacing {
None,
Xs,
Sm,
Md,
Lg,
Xl,
}
/// Steps of the type scale.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextStyle {
Heading,
Subheading,
Body,
Caption,
Label,
}
/// What a text is, for its colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextColour {
Primary,
Secondary,
Accent,
OnAccent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ButtonVariant {
Primary,
Secondary,
Ghost,
Destructive,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weight {
Regular,
SemiBold,
}
/// A theme's name: what Settings lists and a user's choice records.
pub const NAME_BYTES: usize = types::name::BYTES;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Theme {
pub name: [u8; NAME_BYTES],
pub name_len: usize,
pub background: Rgba,
pub surface: Rgba,
pub surface_raised: Rgba,
pub accent: Rgba,
pub on_accent: Rgba,
pub text_primary: Rgba,
pub text_secondary: Rgba,
pub destructive: Rgba,
pub on_destructive: Rgba,
pub focus: Rgba,
/// Soft colours the wallpaper glows with, behind the glass.
pub orbs: [Rgba; 3],
pub corner_radius: f32,
/// The spacing scale's unit, in pixels.
pub spacing_base: f32,
/// Milliseconds; zero turns motion off.
pub duration_fast: u16,
pub duration_normal: u16,
pub spring_stiffness: u16,
pub spring_damping: u16,
/// Pixels.
pub blur_radius: u8,
/// Percent.
pub panel_opacity: u8,
pub highlight: u8,
}
pub(crate) const fn name_bytes(s: &[u8]) -> ([u8; NAME_BYTES], usize) {
let mut out = [0u8; NAME_BYTES];
let mut i = 0;
while i < s.len() && i < NAME_BYTES {
out[i] = s[i];
i += 1;
}
(out, i)
}
impl Theme {
/// What a window shows before the display has said, and what the
/// display uses when no theme file loads; `disk/themes/whimsy-dark.toml`
/// says the same (a test keeps them so).
pub const WHIMSY_DARK: Theme = Theme {
name: name_bytes(b"Whimsy Dark").0,
name_len: name_bytes(b"Whimsy Dark").1,
background: Rgba::rgb(0x0f0e17),
surface: Rgba::rgb(0x1a1a2e),
surface_raised: Rgba::rgb(0x2a2a44),
accent: Rgba::rgb(0x4fc3f7),
on_accent: Rgba::rgb(0x0f0e17),
text_primary: Rgba::rgb(0xe0e0e0),
text_secondary: Rgba::rgb(0x9e9eb8),
destructive: Rgba::rgb(0xef5350),
on_destructive: Rgba::rgb(0x1a0a0a),
focus: Rgba::rgb(0xffffff),
orbs: [Rgba::rgb(0x3a1c71), Rgba::rgb(0x0f4c5c), Rgba::rgb(0x5b1a4a)],
corner_radius: 16.0,
spacing_base: 4.0,
duration_fast: 150,
duration_normal: 280,
spring_stiffness: 300,
spring_damping: 30,
blur_radius: 12,
panel_opacity: 72,
highlight: 18,
};
pub fn name(&self) -> &[u8] {
&self.name[..self.name_len]
}
pub fn space(&self, s: Spacing) -> f32 {
self.spacing_base
* match s {
Spacing::None => 0.0,
Spacing::Xs => 1.0,
Spacing::Sm => 2.0,
Spacing::Md => 4.0,
Spacing::Lg => 6.0,
Spacing::Xl => 8.0,
}
}
/// A text style's size in pixels and its weight.
pub fn text(&self, s: TextStyle) -> (f32, Weight) {
match s {
TextStyle::Heading => (32.0, Weight::SemiBold),
TextStyle::Subheading => (22.0, Weight::SemiBold),
TextStyle::Body => (17.0, Weight::Regular),
TextStyle::Caption => (13.0, Weight::Regular),
TextStyle::Label => (17.0, Weight::SemiBold),
}
}
pub fn text_colour(&self, c: TextColour) -> Rgba {
match c {
TextColour::Primary => self.text_primary,
TextColour::Secondary => self.text_secondary,
TextColour::Accent => self.accent,
TextColour::OnAccent => self.on_accent,
}
}
/// A button's fill (none for a ghost) and its label's colour.
pub fn button(&self, v: ButtonVariant) -> (Option<Rgba>, Rgba) {
match v {
ButtonVariant::Primary => (Some(self.accent), self.on_accent),
ButtonVariant::Secondary => (Some(self.surface_raised), self.text_primary),
ButtonVariant::Ghost => (None, self.accent),
ButtonVariant::Destructive => (Some(self.destructive), self.on_destructive),
}
}
}

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,
})
}

53
crates/theme/src/tests.rs Normal file
View File

@@ -0,0 +1,53 @@
extern crate std;
use crate::*;
const DARK: &str = include_str!("../../../disk/themes/whimsy-dark.toml");
const LIGHT: &str = include_str!("../../../disk/themes/whimsy-light.toml");
#[test]
fn the_shipped_themes_load_and_pass_contrast() {
for text in [DARK, LIGHT] {
let t = parse(text).unwrap();
check(t).unwrap_or_else(|e| panic!("{}: {e:?}", core::str::from_utf8(t.name()).unwrap()));
}
assert_eq!(parse(LIGHT).unwrap().name(), b"Whimsy Light");
}
#[test]
fn the_built_in_theme_is_the_dark_file() {
assert_eq!(parse(DARK).unwrap(), Theme::WHIMSY_DARK);
}
#[test]
fn a_theme_survives_the_wire_but_its_name() {
for text in [DARK, LIGHT] {
let t = parse(text).unwrap();
let back = wire::decode(wire::encode(&t));
assert_eq!(back, Theme { name: [0; NAME_BYTES], name_len: 0, ..t });
}
}
#[test]
fn unreadable_pairings_are_refused() {
let grey = DARK.replace("text_secondary = \"#9e9eb8\"", "text_secondary = \"#3a3a4a\"");
assert!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on background", .. })));
assert!((ratio(Rgba::rgb(0), Rgba::rgb(0xffffff)) - 21.0).abs() < 0.01);
}
#[test]
fn every_token_is_needed_and_checked() {
use manifest::Error as T;
let missing = DARK.replace("focus = \"#ffffff\"\n", "");
assert_eq!(parse(&missing), Err(Error::Toml(T::Missing("focus"))));
for (from, to) in [
("corner_radius = 16", "corner_radius = 99"),
("base = 4", "base = \"4\""),
("\"#ffffff\"", "\"white\""),
("orbs = [\"#3a1c71\", \"#0f4c5c\", \"#5b1a4a\"]", "orbs = [\"#3a1c71\"]"),
] {
assert!(matches!(parse(&DARK.replace(from, to)), Err(Error::Toml(T::Invalid { .. }))), "{to}");
}
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 { .. }))));
}

73
crates/theme/src/wire.rs Normal file
View File

@@ -0,0 +1,73 @@
//! A theme in message words, as the display hands it to a window
//! (`types::display::window::THEME`). Its name stays behind.
use crate::{Rgba, Theme};
pub const WORDS: usize = types::display::window::THEME_WORDS;
fn colours(t: &Theme) -> [Rgba; 12] {
[
t.background,
t.surface,
t.surface_raised,
t.accent,
t.on_accent,
t.text_primary,
t.text_secondary,
t.destructive,
t.on_destructive,
t.focus,
t.orbs[0],
t.orbs[1],
]
}
pub fn encode(t: &Theme) -> [usize; WORDS] {
let c = colours(t);
let mut w = [0usize; WORDS];
for i in 0..6 {
w[i] = c[2 * i].to_rgb() as usize | (c[2 * i + 1].to_rgb() as usize) << 32;
}
w[6] = t.corner_radius as usize
| (t.spacing_base as usize) << 8
| (t.blur_radius as usize) << 16
| (t.panel_opacity as usize) << 24
| (t.highlight as usize) << 32
| (t.duration_fast as usize) << 40;
w[7] = t.duration_normal as usize
| (t.spring_stiffness as usize) << 16
| (t.spring_damping as usize & 0xff) << 32
| (t.orbs[2].to_rgb() as usize & 0xff_ffff) << 40;
w
}
pub fn decode(w: [usize; WORDS]) -> Theme {
let c = |i: usize| Rgba::rgb((w[i / 2] >> (32 * (i % 2))) as u32 & 0xff_ffff);
let byte = |word: usize, at: u32| (w[word] >> at) as u8;
let half = |word: usize, at: u32| (w[word] >> at) as u16;
Theme {
name: [0; crate::NAME_BYTES],
name_len: 0,
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: [c(10), c(11), Rgba::rgb((w[7] >> 40) as u32 & 0xff_ffff)],
corner_radius: byte(6, 0) as f32,
spacing_base: byte(6, 8) as f32,
blur_radius: byte(6, 16),
panel_opacity: byte(6, 24),
highlight: byte(6, 32),
duration_fast: half(6, 40),
duration_normal: half(7, 0),
spring_stiffness: half(7, 16),
// At most 200 (see `parse`): a byte.
spring_damping: byte(7, 32) as u16,
}
}