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

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