From d26b265d38cdcfe15315fa890fda5c2a2442e75b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 28 Oct 2026 14:19:54 +0100 Subject: [PATCH] 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 --- crates/apps/settings/manifest.toml | 2 +- crates/apps/tally/manifest.toml | 2 +- crates/k-ui/src/glass.rs | 314 +++++++++++++++++++++++++++++ crates/k-ui/src/layout.rs | 17 ++ crates/k-ui/src/lib.rs | 2 + crates/k-ui/src/render.rs | 106 ++++++---- crates/k-ui/src/tree.rs | 80 ++++++++ crates/k-ui/src/ui.rs | 96 ++------- crates/theme/src/contrast.rs | 44 +++- crates/theme/src/lib.rs | 5 +- crates/theme/src/tests.rs | 5 +- disk/themes/whimsy-light.toml | 2 +- docs/apps.md | 2 +- 13 files changed, 543 insertions(+), 134 deletions(-) create mode 100644 crates/k-ui/src/glass.rs create mode 100644 crates/k-ui/src/tree.rs diff --git a/crates/apps/settings/manifest.toml b/crates/apps/settings/manifest.toml index 82390e8..19b8a8d 100644 --- a/crates/apps/settings/manifest.toml +++ b/crates/apps/settings/manifest.toml @@ -4,7 +4,7 @@ name = "Settings" version = "1.0.0" binary = "settings" author = "K-OS" -memory = "3 MiB" +memory = "6 MiB" [capabilities] required = ["display", "permissions", "session"] diff --git a/crates/apps/tally/manifest.toml b/crates/apps/tally/manifest.toml index f2e5afd..2a78887 100644 --- a/crates/apps/tally/manifest.toml +++ b/crates/apps/tally/manifest.toml @@ -4,7 +4,7 @@ name = "Tally" version = "1.0.0" binary = "tally" author = "K-OS" -memory = "3 MiB" +memory = "6 MiB" [capabilities] required = ["display"] diff --git a/crates/k-ui/src/glass.rs b/crates/k-ui/src/glass.rs new file mode 100644 index 0000000..e292e8c --- /dev/null +++ b/crates/k-ui/src/glass.rs @@ -0,0 +1,314 @@ +//! The Whimsy look (see the vision's K-UI note): a wallpaper glowing with +//! the theme's orbs, and glass panels over it, the wallpaper blurred and +//! tinted through them. Both are made once per theme and window size, at +//! half the size each way (they are soft, and an app's memory is small), +//! by plain integer code; a frame only copies from them, each pixel +//! doubled, so it costs about what clearing the window does. + +use alloc::vec; +use alloc::vec::Vec; + +use crate::layout::Rect; +use crate::theme::{Rgba, Theme}; + +/// The backdrop is made at 1 / `SCALE` of the window, each way. +const SCALE: usize = 2; +/// Where the orbs sit, as fractions of the window, and how big they are +/// (of its larger side). +const ORBS: [(f32, f32, f32); 3] = [(0.18, 0.22, 0.38), (0.82, 0.38, 0.32), (0.45, 0.92, 0.42)]; +/// An orb is at full strength out to this fraction of its radius, then +/// fades: a shape with an edge, which the blur softens behind the glass. +const ORB_CORE: f32 = 0.6; + +/// An image in pixels of the bytes R, G, B, A (opaque). +struct Image { + w: usize, + h: usize, + px: Vec<[u8; 4]>, +} + +/// The wallpaper, and the glass over it (blurred, tinted), for one theme +/// and window size. +pub struct Backdrop { + key: (Rgba, Rgba, [Rgba; 3], u8, u8, usize, usize), + wall: Image, + glass: Image, +} + +/// `a` moved toward `b` by `k` 256ths. +fn mix(a: [u8; 4], b: Rgba, k: u32) -> [u8; 4] { + let m = |x: u8, y: u8| ((x as u32 * (256 - k) + y as u32 * k) >> 8) as u8; + [m(a[0], b.0), m(a[1], b.1), m(a[2], b.2), 255] +} + +/// How strongly an orb shows (256ths) at a squared distance of `i`/`FALL_STEPS` +/// of its radius squared. +const FALL_STEPS: usize = 256; + +fn falloff() -> [u32; FALL_STEPS + 1] { + let mut t = [0u32; FALL_STEPS + 1]; + for (i, k) in t.iter_mut().enumerate() { + let d = libm::sqrtf(i as f32 / FALL_STEPS as f32); + *k = (((1.0 - d) / (1.0 - ORB_CORE)).clamp(0.0, 1.0) * theme::ORB_STRENGTH * 256.0) as u32; + } + t +} + +impl Backdrop { + /// The backdrop for `t` in a window `w` by `h`: `cached` if it still is. + pub fn for_window(cached: Option, t: &Theme, w: u32, h: u32) -> Option { + let (w, h) = (w as usize, h as usize); + let key = (t.background, t.surface, t.orbs, t.blur_radius, t.panel_opacity, w, h); + if let Some(b) = cached.filter(|b| b.key == key) { + return Some(b); + } + let (sw, sh) = (w.div_ceil(SCALE).max(1), h.div_ceil(SCALE).max(1)); + let bg = [t.background.0, t.background.1, t.background.2, 255]; + let mut wall = Image { w: sw, h: sh, px: vec![bg; sw * sh] }; + let big = sw.max(sh) as f32; + let fall = falloff(); + for (orb, (fx, fy, fr)) in t.orbs.iter().zip(ORBS) { + let (cx, cy, r) = ((fx * sw as f32) as i64, (fy * sh as f32) as i64, ((fr * big) as i64).max(1)); + let r2 = r * r; + let rows = (cy - r).max(0) as usize..((cy + r + 1) as usize).min(sh); + let cols = (cx - r).max(0) as usize..((cx + r + 1) as usize).min(sw); + for y in rows { + let dy2 = (y as i64 - cy) * (y as i64 - cy); + for x in cols.clone() { + let d2 = (x as i64 - cx) * (x as i64 - cx) + dy2; + if d2 < r2 { + let p = &mut wall.px[y * sw + x]; + *p = mix(*p, *orb, fall[(d2 * FALL_STEPS as i64 / r2) as usize]); + } + } + } + } + let mut glass = Image { w: sw, h: sh, px: wall.px.clone() }; + blur(&mut glass, (t.blur_radius as usize).div_ceil(SCALE)); + let opacity = t.panel_opacity as u32 * 256 / 100; + for p in &mut glass.px { + *p = mix(*p, t.surface, opacity); + } + Some(Backdrop { key, wall, glass }) + } + + /// The wallpaper over `part` of the window's pixels `pm` + /// (premultiplied RGBA, `width` pixels a row). + pub fn wall(&self, pm: &mut [u8], width: usize, part: Rect) { + copy(&self.wall, pm, width, part, 0.0); + } + + /// A glass panel over `r`, its corners rounded by `radius`. + pub fn glass(&self, pm: &mut [u8], width: usize, r: Rect, radius: f32) { + copy(&self.glass, pm, width, r, radius); + } +} + +/// The pixels of `r` (whole ones) inside `pm`: columns and rows. +fn span(r: Rect, width: usize, height: usize) -> (core::ops::Range, core::ops::Range) { + let (x0, y0) = (r.x.max(0.0) as usize, r.y.max(0.0) as usize); + let (x1, y1) = (((r.x + r.w).max(0.0) as usize).min(width), ((r.y + r.h).max(0.0) as usize).min(height)); + (x0..x1.max(x0), y0..y1.max(y0)) +} + +/// How much of pixel (`x`, `y`) is inside `r` with its corners rounded by +/// `radius`, in 256ths: worked out only in the corners. +fn cover(r: Rect, radius: f32, x: usize, y: usize) -> u32 { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let dx = (r.x + radius - fx).max(fx - (r.x + r.w - radius)); + let dy = (r.y + radius - fy).max(fy - (r.y + r.h - radius)); + if dx <= 0.0 || dy <= 0.0 { + return 256; + } + ((radius + 0.5 - libm::sqrtf(dx * dx + dy * dy)).clamp(0.0, 1.0) * 256.0) as u32 +} + +fn blend(d: &mut [u8], s: [u8; 4], k: u32) { + for (d, s) in d.iter_mut().zip(s) { + *d = ((s as u32 * k + *d as u32 * (256 - k)) >> 8) as u8; + } +} + +/// Copies `img` (scaled up) into `r` of `pm`, the corners rounded by +/// `radius` and anti-aliased there. +fn copy(img: &Image, pm: &mut [u8], width: usize, r: Rect, radius: f32) { + let radius = radius.min(r.w / 2.0).min(r.h / 2.0).max(0.0); + let (cols, rows) = span(r, width, pm.len() / 4 / width.max(1)); + let (x0, x1) = (cols.start, cols.end); + let mut copied: Option = None; + for y in rows { + let corner = radius > 0.0 && ((y as f32) < r.y + radius || (y as f32 + 1.0) > r.y + r.h - radius); + let at = y * width * 4; + // A row made from the same source row as the one above: the same. + if let Some(above) = copied.filter(|a| *a + 1 == y && a / SCALE == y / SCALE && !corner) { + let from = above * width * 4; + pm.copy_within(from + x0 * 4..from + x1 * 4, at + x0 * 4); + copied = Some(y); + continue; + } + let src = &img.px[(y / SCALE).min(img.h - 1) * img.w..][..img.w]; + let row = &mut pm[at + x0 * 4..at + x1 * 4]; + if corner { + for (i, d) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let s = src[((x0 + i) / SCALE).min(img.w - 1)]; + match cover(r, radius, x0 + i, y) { + 256 => d.copy_from_slice(&s), + 0 => {} + k => blend(d, s, k), + } + } + copied = None; + } else { + for (i, d) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + d.copy_from_slice(&src[((x0 + i) / SCALE).min(img.w - 1)]); + } + copied = Some(y); + } + } +} + +/// The wet highlight: white along the top of `r`, `alpha` (percent) at +/// the edge and gone `depth` pixels down. +pub fn gloss(pm: &mut [u8], width: usize, r: Rect, radius: f32, alpha: u8, depth: f32) { + let radius = radius.min(r.w / 2.0).min(r.h / 2.0).max(0.0); + let top = Rect { h: depth.min(r.h), ..r }; + let (cols, rows) = span(top, width, pm.len() / 4 / width.max(1)); + let white = [255u8; 4]; + for y in rows { + let fade = 1.0 - (y as f32 + 0.5 - r.y) / depth; + let a = (fade.max(0.0) * alpha as f32 * 256.0 / 100.0) as u32; + let row = &mut pm[y * width * 4..(y + 1) * width * 4]; + for x in cols.clone() { + let k = (cover(r, radius, x, y) * a) >> 8; + if k > 0 { + blend(&mut row[x * 4..x * 4 + 4], white, k); + } + } + } +} + +/// How far (`x`, `y`) is outside `r` with its corners rounded by `radius` +/// (negative inside). +fn outside(r: Rect, radius: f32, x: f32, y: f32) -> f32 { + let (hw, hh) = (r.w / 2.0, r.h / 2.0); + let qx = libm::fabsf(x - (r.x + hw)) - (hw - radius); + let qy = libm::fabsf(y - (r.y + hh)) - (hh - radius); + let (ox, oy) = (qx.max(0.0), qy.max(0.0)); + let out = if ox > 0.0 && oy > 0.0 { libm::sqrtf(ox * ox + oy * oy) } else { ox.max(oy) }; + out + qx.max(qy).min(0.0) - radius +} + +/// A soft shadow under `r` (corners rounded by `radius`): cast `down` +/// pixels below it, `dark` (percent) at its edge and gone `spread` out. +/// Only where it shows is touched: what `r` covers is drawn over anyway. +pub fn shadow(pm: &mut [u8], width: usize, r: Rect, radius: f32, down: f32, spread: f32, dark: u8) { + let radius = radius.min(r.w / 2.0).min(r.h / 2.0).max(0.0); + let caster = Rect { y: r.y + down, ..r }; + let around = Rect { x: r.x - spread, y: r.y - spread, w: r.w + 2.0 * spread, h: r.h + down + 2.0 * spread }; + let (cols, rows) = span(around, width, pm.len() / 4 / width.max(1)); + // Well inside `r`: covered by it. + let (in_x, in_y) = (r.x + radius..r.x + r.w - radius, r.y..r.y + r.h); + for y in rows { + let fy = y as f32 + 0.5; + let row = &mut pm[y * width * 4..(y + 1) * width * 4]; + for x in cols.clone() { + let fx = x as f32 + 0.5; + if in_x.contains(&fx) && in_y.contains(&fy) { + continue; + } + let d = outside(caster, radius, fx, fy).max(0.0); + let fade = (1.0 - d / spread).max(0.0); + let k = (fade * fade * dark as f32 * 256.0 / 100.0) as u32; + if k > 0 { + blend(&mut row[x * 4..x * 4 + 4], [0, 0, 0, 255], k); + } + } + } +} + +/// A box blur run three times over (close to a Gaussian), `r` pixels each +/// way, rows then columns. +fn blur(img: &mut Image, r: usize) { + if r == 0 { + return; + } + let (w, h) = (img.w, img.h); + let mut line = vec![[0u8; 4]; w.max(h)]; + for _ in 0..3 { + for y in 0..h { + pass(&mut img.px, y * w, 1, w, r, &mut line); + } + for x in 0..w { + pass(&mut img.px, x, w, h, r, &mut line); + } + } +} + +/// One box pass along `n` pixels starting at `start`, `step` apart; the +/// edges are held. +fn pass(px: &mut [[u8; 4]], start: usize, step: usize, n: usize, r: usize, line: &mut [[u8; 4]]) { + let at = |i: usize| start + i.min(n - 1) * step; + let width = (2 * r + 1) as u32; + for c in 0..4 { + // The window centred on pixel 0, the edge held. + let mut sum = px[at(0)][c] as u32 * (r as u32 + 1) + (1..=r).map(|i| px[at(i)][c] as u32).sum::(); + for (i, out) in line.iter_mut().take(n).enumerate() { + out[c] = (sum / width) as u8; + sum = sum + px[at(i + r + 1)][c] as u32 - px[at(i.saturating_sub(r))][c] as u32; + } + } + for (i, p) in line.iter().take(n).enumerate() { + px[start + i * step] = *p; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(w: usize, h: usize, c: [u8; 4]) -> Image { + Image { w, h, px: vec![c; w * h] } + } + + #[test] + fn blur_spreads_a_shape_and_keeps_flat_colour_flat() { + let mut img = image(40, 40, [10, 20, 30, 255]); + blur(&mut img, 3); + assert!(img.px.iter().all(|p| *p == [10, 20, 30, 255]), "nothing to blur, nothing changes"); + for y in 15..25 { + for x in 15..25 { + img.px[y * 40 + x] = [250, 250, 250, 255]; + } + } + blur(&mut img, 3); + let red = |x: usize, y: usize| img.px[y * 40 + x][0]; + assert!(red(20, 20) > 150, "the middle, {}", red(20, 20)); + assert!((40..200).contains(&red(13, 20)), "just outside the edge: between, {}", red(13, 20)); + assert_eq!(red(2, 2), 10, "far away: untouched"); + } + + #[test] + fn the_backdrop_is_made_once_per_theme_and_size_and_glass_is_tinted() { + let t = Theme::WHIMSY_DARK; + let b = Backdrop::for_window(None, &t, 100, 60).unwrap(); + assert_eq!((b.wall.w, b.wall.h), (50, 30)); + let flat = Theme { orbs: [t.background; 3], ..t }; + let f = Backdrop::for_window(Some(b), &flat, 100, 60).unwrap(); + let bg = [t.background.0, t.background.1, t.background.2, 255]; + assert!(f.wall.px.iter().all(|p| *p == bg), "no orbs, no glow"); + assert_eq!(f.glass.px[0], mix(bg, t.surface, t.panel_opacity as u32 * 256 / 100)); + } + + #[test] + fn a_panel_is_copied_with_round_soft_corners() { + let t = Theme { orbs: [Rgba::rgb(0xffffff); 3], ..Theme::WHIMSY_DARK }; + let b = Backdrop::for_window(None, &t, 40, 40).unwrap(); + let mut pm = vec![0u8; 40 * 40 * 4]; + b.glass(&mut pm, 40, Rect { x: 10.0, y: 10.0, w: 20.0, h: 20.0 }, 8.0); + let at = |x: usize, y: usize| pm[(y * 40 + x) * 4 + 3]; + assert_eq!(at(20, 20), 255, "inside"); + assert_eq!(at(10, 10), 0, "the very corner is cut off"); + assert!((1..255).contains(&at(12, 12)), "the corner's edge is soft: {}", at(12, 12)); + assert_eq!(at(5, 20), 0, "outside"); + } +} diff --git a/crates/k-ui/src/layout.rs b/crates/k-ui/src/layout.rs index eeb4383..d18e8e2 100644 --- a/crates/k-ui/src/layout.rs +++ b/crates/k-ui/src/layout.rs @@ -23,6 +23,23 @@ pub struct Rect { pub h: f32, } +impl Rect { + pub fn overlaps(self, o: Rect) -> bool { + self.x < o.x + o.w && o.x < self.x + self.w && self.y < o.y + o.h && o.y < self.y + self.h + } + + /// Bigger by `by` on every side. + pub fn grow(self, by: f32) -> Rect { + Rect { x: self.x - by, y: self.y - by, w: self.w + 2.0 * by, h: self.h + 2.0 * by } + } + + /// The smallest rectangle holding both. + pub fn union(self, o: Rect) -> Rect { + let (x0, y0) = (self.x.min(o.x), self.y.min(o.y)); + Rect { x: x0, y: y0, w: (self.x + self.w).max(o.x + o.w) - x0, h: (self.y + self.h).max(o.y + o.h) - y0 } + } +} + /// How big text is, from whoever has the fonts. pub trait Measure { fn text(&self, s: &str, style: TextStyle) -> (f32, f32); diff --git a/crates/k-ui/src/lib.rs b/crates/k-ui/src/lib.rs index 9fa8b38..0493f20 100644 --- a/crates/k-ui/src/lib.rs +++ b/crates/k-ui/src/lib.rs @@ -9,12 +9,14 @@ extern crate alloc; pub mod element; +pub mod glass; pub mod layout; pub mod motion; pub mod render; pub mod run; pub mod text; pub mod theme; +mod tree; pub mod ui; pub use element::{Element, Size, button, column, list_item, row, text, text_input, toggle}; diff --git a/crates/k-ui/src/render.rs b/crates/k-ui/src/render.rs index 0256d92..a66559d 100644 --- a/crates/k-ui/src/render.rs +++ b/crates/k-ui/src/render.rs @@ -1,10 +1,13 @@ //! Drawing a laid-out tree into a window's pixels (RGBA, premultiplied): -//! the theme decides every colour and shape. +//! the theme decides every colour and shape. Rows, toggles and fields are +//! glass over the wallpaper (`glass`); buttons are solid, with a shadow +//! and a highlight. use alloc::string::String; use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform}; use crate::element::{Element, Kind}; +use crate::glass::Backdrop; use crate::layout::Rect; use crate::text::Fonts; use crate::theme::{Rgba, TextStyle, Theme, Weight}; @@ -12,8 +15,14 @@ use crate::theme::{Rgba, TextStyle, Theme, Weight}; /// The focus ring's distance outside the button, and its width. const RING_GAP: f32 = 3.0; const RING_WIDTH: f32 = 2.0; -/// How far outside an element its ring reaches. -pub const RING_REACH: f32 = RING_GAP + RING_WIDTH + 1.0; +/// How far outside an element its ring or its shadow reaches. +pub const REACH: f32 = SHADOW.0 + SHADOW.1 + 1.0; +/// The shadow: how far down it is cast, how far it spreads, how dark it is +/// at the edge (percent). +const SHADOW: (f32, f32, u8) = (3.0, 7.0, 28); +/// How far down from a panel's top its highlight fades out: above the +/// text, which starts a row's padding down. +const GLOSS: f32 = 12.0; /// A field's outline, and its caret, across. const OUTLINE: f32 = 1.5; const CARET: f32 = 2.0; @@ -56,6 +65,9 @@ pub struct Frame { pub flip: Option<(usize, f32)>, /// How far in a new screen has come: 1 is all the way. pub enter: f32, + /// Only this part is drawn (what moved), if not all: it must hold + /// whole elements (see `Ui::draw`). + pub clip: Option, } /// What drawing needs at hand: the pixels, the fonts, the theme, and the @@ -65,6 +77,7 @@ struct Painter<'p, 'a> { fonts: &'p Fonts, theme: &'p Theme, frame: Frame, + backdrop: Option<&'p Backdrop>, } fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba { @@ -123,10 +136,10 @@ impl Painter<'_, '_> { *index += 1; let Some(&r) = rects.get(me) else { return }; let theme = self.theme; - // Off the pixels (drawing only part of the window): nothing to do. + // Off the window, or not in the part being drawn: nothing to do. let (pw, ph) = (self.pm.width() as f32, self.pm.height() as f32); - let outside = r.x >= pw || r.y >= ph || r.x + r.w <= 0.0 || r.y + r.h <= 0.0; - if outside && !matches!(e.kind, Kind::Flex(_)) { + let all = Rect { x: 0.0, y: 0.0, w: pw, h: ph }; + if !matches!(e.kind, Kind::Flex(_)) && !(r.overlaps(all) && self.frame.clip.is_none_or(|c| r.overlaps(c))) { return; } match &e.kind { @@ -139,12 +152,14 @@ impl Painter<'_, '_> { Kind::Button(b) => { let (fill, label) = theme.button(b.variant); if let Some(fill) = fill { + self.shadow(r); self.fill(r, theme.corner_radius, fill); + self.gloss(r); } self.text_in(&b.label, TextStyle::Label, r, true, label); } Kind::Item(i) => { - self.fill(r, theme.corner_radius, theme.surface); + self.panel(r); let inner = self.inset(r); match &i.subtitle { Some(sub) => { @@ -159,7 +174,7 @@ impl Painter<'_, '_> { self.text_in(">", TextStyle::Label, chevron, true, theme.text_secondary); } Kind::Toggle(g) => { - self.fill(r, theme.corner_radius, theme.surface); + self.panel(r); let inner = self.inset(r); self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary); let (tw, th) = crate::layout::SWITCH; @@ -190,7 +205,7 @@ impl Painter<'_, '_> { /// placeholder dimmed, and the caret while it has focus. fn field(&mut self, i: &crate::element::Input, r: Rect, focused: bool) { let theme = self.theme; - self.fill(r, theme.corner_radius, theme.surface); + self.panel(r); if let Some(path) = rounded(r, theme.corner_radius) { let stroke = Stroke { width: OUTLINE, ..Stroke::default() }; self.pm.stroke_path(&path, &paint(theme.surface_raised), &stroke, Transform::identity(), None); @@ -226,6 +241,34 @@ impl Painter<'_, '_> { } } + /// A glass panel: its shadow, the wallpaper blurred through it, the + /// surface over that, and its highlight. + fn panel(&mut self, r: Rect) { + let theme = self.theme; + self.shadow(r); + match self.backdrop { + Some(b) => { + let width = self.pm.width() as usize; + b.glass(self.pm.data_mut(), width, r, theme.corner_radius); + } + None => self.fill(r, theme.corner_radius, theme.surface), + } + self.gloss(r); + } + + /// Soft and below: it lifts what casts it off the wallpaper. + fn shadow(&mut self, r: Rect) { + let width = self.pm.width() as usize; + let (down, spread, dark) = SHADOW; + crate::glass::shadow(self.pm.data_mut(), width, r, self.theme.corner_radius, down, spread, dark); + } + + /// The wet highlight along the top. + fn gloss(&mut self, r: Rect) { + let width = self.pm.width() as usize; + crate::glass::gloss(self.pm.data_mut(), width, r, self.theme.corner_radius, self.theme.highlight, GLOSS); + } + /// `r` filled with `c`, its corners rounded by `radius`. fn fill(&mut self, r: Rect, radius: f32, c: Rgba) { if let Some(path) = rounded(r, radius) { @@ -240,12 +283,26 @@ impl Painter<'_, '_> { } } -/// Draws `root`, laid out as `rects`, as `frame` says; a screen still -/// coming in is faded toward the background. -pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame) { +/// Draws `root`, laid out as `rects`, as `frame` says (all of it, or its +/// `clip`), over the wallpaper (`backdrop`, or the plain background). A +/// screen still coming in is faded toward the background. +pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame, backdrop: Option<&Backdrop>) { let bg = theme.background; - pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3)); - let mut p = Painter { pm, fonts, theme, frame }; + let all = Rect { x: 0.0, y: 0.0, w: pm.width() as f32, h: pm.height() as f32 }; + let under = frame.clip.unwrap_or(all); + match backdrop { + Some(b) => { + let width = pm.width() as usize; + b.wall(pm.data_mut(), width, under); + } + None => { + let r = tiny_skia::Rect::from_xywh(under.x, under.y, under.w, under.h); + if let Some(r) = r { + pm.fill_rect(r, &paint(bg), Transform::identity(), None); + } + } + } + let mut p = Painter { pm, fonts, theme, frame, backdrop }; p.draw(root, rects, &mut 0); if let Some(r) = frame.ring { p.ring(r); @@ -256,24 +313,3 @@ pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: & p.fill(all, 0.0, veil); } } - -/// Draws only `part` of the window (what moved), into `pm` there: the -/// rest of `pm` is left as it was. -pub fn render_part(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame, part: Rect) { - let (x0, y0) = (libm::floorf(part.x.max(0.0)), libm::floorf(part.y.max(0.0))); - let x1 = libm::ceilf(part.x + part.w).min(pm.width() as f32); - let y1 = libm::ceilf(part.y + part.h).min(pm.height() as f32); - let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); - let Some(mut piece) = (x1 > x0 && y1 > y0).then(|| tiny_skia::Pixmap::new(w, h)).flatten() else { return }; - let shift = |r: Rect| Rect { x: r.x - x0, y: r.y - y0, ..r }; - let moved: alloc::vec::Vec = rects.iter().map(|r| shift(*r)).collect(); - let frame = Frame { ring: frame.ring.map(shift), ..frame }; - render(&mut piece.as_mut(), root, &moved, fonts, theme, frame); - let stride = pm.width() as usize * 4; - let row = w as usize * 4; - let data = pm.data_mut(); - for (i, line) in piece.data().chunks_exact(row).enumerate() { - let at = (y0 as usize + i) * stride + x0 as usize * 4; - data[at..at + row].copy_from_slice(line); - } -} diff --git a/crates/k-ui/src/tree.rs b/crates/k-ui/src/tree.rs new file mode 100644 index 0000000..c94dd3b --- /dev/null +++ b/crates/k-ui/src/tree.rs @@ -0,0 +1,80 @@ +//! Finding things in a view by their place in pre-order (an element, then +//! its children), the order layout, drawing and focus all walk it in. + +use alloc::vec::Vec; + +use crate::element::{Element, Input, Kind}; +use crate::layout::Rect; +use crate::render::REACH; + +/// The pre-order indices of every element focus can rest on in `e`. +pub fn pressables(e: &Element, index: &mut usize, out: &mut Vec) { + if e.kind.focusable() { + out.push(*index); + } + *index += 1; + if let Kind::Flex(f) = &e.kind { + for c in &f.children { + pressables(c, index, out); + } + } +} + +/// The event of the pre-order `target`th element, if it is a button. +pub fn event_at(e: &Element, index: &mut usize, target: usize) -> Option { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)), + k if me == target => k.event().cloned(), + _ => None, + } +} + +/// The pre-order `target`th element, if it is a field. +pub fn input_at<'e, E>(e: &'e Element, index: &mut usize, target: usize) -> Option<&'e Input> { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().find_map(|c| input_at(c, index, target)), + Kind::Input(i) if me == target => Some(i), + _ => None, + } +} + +/// Whether the pre-order `target`th element is a toggle. +pub fn toggle_at(e: &Element, index: &mut usize, target: usize) -> bool { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().any(|c| toggle_at(c, index, target)), + Kind::Toggle(_) => me == target, + _ => false, + } +} + +/// `part`, grown to take in every element it touches: drawn in pieces, a +/// shape's anti-aliased edge would come out a little different where a +/// piece cuts it. +pub fn whole_elements(view: &Element, rects: &[Rect], part: Rect) -> Rect { + let mut leaves = Vec::new(); + leaf_rects(view, rects, &mut 0, &mut leaves); + let mut part = part; + // Taking one in may reach another: until nothing more is touched. + loop { + let grown = leaves.iter().map(|r| r.grow(REACH)).filter(|r| r.overlaps(part)).fold(part, Rect::union); + if grown == part { + return grown; + } + part = grown; + } +} + +fn leaf_rects(e: &Element, rects: &[Rect], index: &mut usize, out: &mut Vec) { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().for_each(|c| leaf_rects(c, rects, index, out)), + _ => out.extend(rects.get(me)), + } +} diff --git a/crates/k-ui/src/ui.rs b/crates/k-ui/src/ui.rs index 40c1140..8213985 100644 --- a/crates/k-ui/src/ui.rs +++ b/crates/k-ui/src/ui.rs @@ -10,10 +10,12 @@ use alloc::vec::Vec; use tiny_skia::PixmapMut; -use crate::element::{Element, Input, Kind}; +use crate::element::{Element, Input}; +use crate::glass::Backdrop; use crate::layout::{self, Rect}; use crate::motion::{Ring, Tween}; use crate::render::{self, Frame}; +use crate::tree::{event_at, input_at, pressables, toggle_at, whole_elements}; use crate::text::{Fonts, Measurer}; use crate::theme::Theme; @@ -89,46 +91,13 @@ pub struct Ui { whole: bool, /// Where the ring was last drawn. last_ring: Option, -} - -/// The pre-order indices of every element focus can rest on in `e`. -fn pressables(e: &Element, index: &mut usize, out: &mut Vec) { - if e.kind.focusable() { - out.push(*index); - } - *index += 1; - if let Kind::Flex(f) = &e.kind { - for c in &f.children { - pressables(c, index, out); - } - } -} - -/// The event of the pre-order `target`th element, if it is a button. -fn event_at(e: &Element, index: &mut usize, target: usize) -> Option { - let me = *index; - *index += 1; - match &e.kind { - Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)), - k if me == target => k.event().cloned(), - _ => None, - } -} - -/// The pre-order `target`th element, if it is a field. -fn input_at<'e, E>(e: &'e Element, index: &mut usize, target: usize) -> Option<&'e Input> { - let me = *index; - *index += 1; - match &e.kind { - Kind::Flex(f) => f.children.iter().find_map(|c| input_at(c, index, target)), - Kind::Input(i) if me == target => Some(i), - _ => None, - } + /// The wallpaper and its blur, for this theme and window size. + backdrop: Option, } impl Ui { pub fn new(theme: Theme) -> Self { - Self { app: A::init(), focus: 0, scroll: 0.0, caret: usize::MAX, theme, ring: None, flip: None, enter: None, drawn: None, whole: true, last_ring: None } + Self { app: A::init(), focus: 0, scroll: 0.0, caret: usize::MAX, theme, ring: None, flip: None, enter: None, drawn: None, whole: true, last_ring: None, backdrop: None } } /// A new screen: from its top, coming in, the ring where it lands. @@ -260,6 +229,7 @@ impl Ui { pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts, now: u64) -> (bool, Option) { let view = self.app.view(); let (w, h) = (pm.width() as f32, pm.height() as f32); + self.backdrop = Backdrop::for_window(self.backdrop.take(), &self.theme, pm.width(), pm.height()); let measurer = Measurer { fonts, theme: &self.theme }; // As tall as it needs: what does not fit is scrolled to. let tall = layout::natural(&view, &measurer, &self.theme).1.max(h); @@ -297,21 +267,21 @@ impl Ui { } }; let flip = self.flip.as_mut().map(|(i, t)| (*i, t.progress(now))); - let frame = Frame { focused, caret: self.caret, ring, flip, enter }; + let frame = Frame { focused, caret: self.caret, ring, flip, enter, clip: None }; let full = self.whole || self.enter.is_some(); let part = [self.last_ring, ring, flip.and_then(|(i, _)| rects.get(i).copied())] .into_iter() .flatten() - .map(|r| grow(r, render::RING_REACH)) - .reduce(union) + .map(|r| r.grow(render::REACH)) + .reduce(Rect::union) .map(|p| whole_elements(&view, &rects, p)); let drew = match (full, part) { (true, _) => { - render::render(pm, &view, &rects, fonts, &self.theme, frame); + render::render(pm, &view, &rects, fonts, &self.theme, frame, self.backdrop.as_ref()); None } (false, Some(p)) => { - render::render_part(pm, &view, &rects, fonts, &self.theme, frame, p); + render::render(pm, &view, &rects, fonts, &self.theme, Frame { clip: Some(p), ..frame }, self.backdrop.as_ref()); Some(p) } // Nothing moved and nothing changed. @@ -330,51 +300,9 @@ impl Ui { } } -/// `part`, grown to take in every element it touches: drawn in pieces, a -/// shape's anti-aliased edge would come out a little different where a -/// piece cuts it. -fn whole_elements(view: &Element, rects: &[Rect], part: Rect) -> Rect { - let mut leaves = Vec::new(); - leaf_rects(view, rects, &mut 0, &mut leaves); - leaves.into_iter().map(|r| grow(r, 1.0)).filter(|r| overlap(*r, part)).fold(part, union) -} - -fn leaf_rects(e: &Element, rects: &[Rect], index: &mut usize, out: &mut Vec) { - let me = *index; - *index += 1; - match &e.kind { - Kind::Flex(f) => f.children.iter().for_each(|c| leaf_rects(c, rects, index, out)), - _ => out.extend(rects.get(me)), - } -} - -fn overlap(a: Rect, b: Rect) -> bool { - a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h -} - -fn grow(r: Rect, by: f32) -> Rect { - Rect { x: r.x - by, y: r.y - by, w: r.w + 2.0 * by, h: r.h + 2.0 * by } -} - -fn union(a: Rect, b: Rect) -> Rect { - let (x0, y0) = (a.x.min(b.x), a.y.min(b.y)); - Rect { x: x0, y: y0, w: (a.x + a.w).max(b.x + b.w) - x0, h: (a.y + a.h).max(b.y + b.h) - y0 } -} - /// How far right a new screen starts, in pixels. const SLIDE: f32 = 32.0; -/// Whether the pre-order `target`th element is a toggle. -fn toggle_at(e: &Element, index: &mut usize, target: usize) -> bool { - let me = *index; - *index += 1; - match &e.kind { - Kind::Flex(f) => f.children.iter().any(|c| toggle_at(c, index, target)), - Kind::Toggle(_) => me == target, - _ => false, - } -} - /// The scroll that shows `top..top + height` within a window `window` high /// over a view `view` high, moving as little as possible from `scroll`. fn keep_in_sight(scroll: f32, top: f32, height: f32, window: f32, view: f32) -> f32 { diff --git a/crates/theme/src/contrast.rs b/crates/theme/src/contrast.rs index efa63dd..2d13e0c 100644 --- a/crates/theme/src/contrast.rs +++ b/crates/theme/src/contrast.rs @@ -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 { - 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 }); diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index f7b998d..e83c987 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -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; diff --git a/crates/theme/src/tests.rs b/crates/theme/src/tests.rs index a03decc..2965e8e 100644 --- a/crates/theme/src/tests.rs +++ b/crates/theme/src/tests.rs @@ -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); } diff --git a/disk/themes/whimsy-light.toml b/disk/themes/whimsy-light.toml index faa670b..e63bca1 100644 --- a/disk/themes/whimsy-light.toml +++ b/disk/themes/whimsy-light.toml @@ -5,7 +5,7 @@ name = "Whimsy Light" background = "#f3f2f8" surface = "#ffffff" surface_raised = "#e6e4f0" -accent = "#0b6bcb" +accent = "#0a5cb0" on_accent = "#ffffff" text_primary = "#1a1a2e" text_secondary = "#55556e" diff --git a/docs/apps.md b/docs/apps.md index 6de14ed..0d7c2a0 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -64,7 +64,7 @@ binary = "snake" # required: a file in the app's folder command = "snake" # optional: what the shell runs it as (default: binary) author = "K-OS" # optional icon = "icon.png" # optional: a file in the app's folder -memory = "3 MiB" # optional: how much it may use, KiB or MiB, +memory = "6 MiB" # optional: how much it may use, KiB or MiB, # 64 KiB to 16 MiB (default 512 KiB) [capabilities]