feat(k-ui): glass look: orb wallpaper + blurred, tinted glass panels (half-res backdrop per theme+size, integer code, frames only copy), soft analytic shadow, top gloss; contrast checked over wallpaper/glass per orb (light accent -> #0a5cb0); partial frames drawn in place; Rect geometry, tree lookups split out; settings/tally 6 MiB

This commit is contained in:
2026-10-28 14:19:54 +01:00
parent 3b71243dea
commit d26b265d38
13 changed files with 543 additions and 134 deletions

View File

@@ -1,6 +1,9 @@
//! 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.
//! What text sits on is not one colour: the wallpaper glows with the orbs,
//! and glass panels let it through, so each is checked over the plain
//! background and over every orb.
use crate::parse::Error;
use crate::{Rgba, Theme};
@@ -22,25 +25,48 @@ pub fn ratio(a: Rgba, b: Rgba) -> f32 {
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] {
/// Pairings drawn on solid colours, each with what it must reach.
fn solid(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 4] {
[
("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),
]
}
/// Pairings drawn on the wallpaper (`w`) and on glass over it (`g`).
fn over(t: &Theme, w: Rgba, g: Rgba) -> [(&'static str, Rgba, Rgba, f32); 6] {
[
("text_primary on the wallpaper", t.text_primary, w, TEXT),
("text_secondary on the wallpaper", t.text_secondary, w, TEXT),
("accent on the wallpaper", t.accent, w, TEXT),
("focus on the wallpaper", t.focus, w, SHAPE),
("text_primary on glass", t.text_primary, g, TEXT),
("text_secondary on glass", t.text_secondary, g, TEXT),
]
}
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
Rgba(m(a.0, b.0), m(a.1, b.1), m(a.2, b.2), 255)
}
/// The wallpaper where an orb is strongest.
pub fn wallpaper(t: &Theme, orb: Rgba) -> Rgba {
mix(t.background, orb, crate::ORB_STRENGTH)
}
/// A glass panel over `under`.
pub fn glass(t: &Theme, under: Rgba) -> Rgba {
mix(under, t.surface, t.panel_opacity as f32 / 100.0)
}
/// 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 walls = [t.background].into_iter().chain(t.orbs.iter().map(|o| wallpaper(&t, *o)));
let pairs = walls.flat_map(|w| over(&t, w, glass(&t, w))).chain(solid(&t));
for (pair, fg, bg, min) in pairs {
let r = ratio(fg, bg);
if r < min {
return Err(Error::Contrast { pair, ratio_x100: (r * 100.0) as u32 });

View File

@@ -17,7 +17,7 @@ pub mod wire;
mod tests;
pub use accent::with_accent;
pub use contrast::{check, ratio};
pub use contrast::{check, glass, ratio, wallpaper};
pub use parse::{Error, parse};
/// A colour as the theme names it: straight (not premultiplied) RGBA.
@@ -83,6 +83,9 @@ pub enum Weight {
SemiBold,
}
/// How strongly the orbs show through the background at their hearts.
pub const ORB_STRENGTH: f32 = 0.5;
/// A theme's name: what Settings lists and a user's choice records.
pub const NAME_BYTES: usize = types::name::BYTES;

View File

@@ -31,7 +31,10 @@ fn a_theme_survives_the_wire_but_its_name() {
#[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!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on the wallpaper", .. })));
// An orb too bright for the text over it.
let glare = DARK.replace("\"#0f4c5c\"", "\"#f0f0f0\"");
assert!(matches!(check(parse(&glare).unwrap()), Err(Error::Contrast { .. })));
assert!((ratio(Rgba::rgb(0), Rgba::rgb(0xffffff)) - 21.0).abs() < 0.01);
}