feat(k-ui): first slice: App trait + run loop, builders (column/row/text/button), flex layout, theme tokens (Whimsy Dark), tiny-skia + fontdue render (Noto Sans Latin subset), keyboard focus ring; tally demo; manifest memory key (pm spawns with it)

This commit is contained in:
2026-10-28 03:20:41 +01:00
parent c256f4ecf3
commit fe73b89525
27 changed files with 1428 additions and 6 deletions

131
crates/k-ui/src/render.rs Normal file
View File

@@ -0,0 +1,131 @@
//! Drawing a laid-out tree into a window's pixels (RGBA, premultiplied):
//! the theme decides every colour and shape.
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform};
use crate::element::{Element, Kind};
use crate::layout::Rect;
use crate::text::Fonts;
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;
fn paint(c: Rgba) -> Paint<'static> {
let mut p = Paint::default();
p.set_color(Color::from_rgba8(c.0, c.1, c.2, c.3));
p.anti_alias = true;
p
}
/// A rectangle with corners of radius `r` (at most half its height).
fn rounded(rect: Rect, r: f32) -> Option<tiny_skia::Path> {
let r = r.min(rect.h / 2.0).min(rect.w / 2.0).max(0.0);
let (x0, y0, x1, y1) = (rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
let mut p = PathBuilder::new();
p.move_to(x0 + r, y0);
p.line_to(x1 - r, y0);
p.quad_to(x1, y0, x1, y0 + r);
p.line_to(x1, y1 - r);
p.quad_to(x1, y1, x1 - r, y1);
p.line_to(x0 + r, y1);
p.quad_to(x0, y1, x0, y1 - r);
p.line_to(x0, y0 + r);
p.quad_to(x0, y0, x0 + r, y0);
p.close();
p.finish()
}
/// What drawing needs at hand: the pixels, the fonts, the theme, and which
/// element (pre-order) has focus.
struct Painter<'p, 'a> {
pm: &'p mut PixmapMut<'a>,
fonts: &'p Fonts,
theme: &'p Theme,
focused: Option<usize>,
}
impl Painter<'_, '_> {
/// Glyph coverage blended over the pixels in colour `c`, the pen from
/// `x` on `baseline`.
fn glyphs(&mut self, s: &str, (size, weight): (f32, Weight), x: f32, baseline: f32, c: Rgba) {
let (pw, ph) = (self.pm.width() as i32, self.pm.height() as i32);
let data = self.pm.data_mut();
self.fonts.glyphs(s, size, weight, |pen, g| {
let m = &g.metrics;
let gx = libm::roundf(x + pen) as i32 + m.xmin;
let gy = libm::roundf(baseline) as i32 - m.height as i32 - m.ymin;
for row in 0..m.height as i32 {
let y = gy + row;
if !(0..ph).contains(&y) {
continue;
}
for col in 0..m.width as i32 {
let x = gx + col;
if !(0..pw).contains(&x) {
continue;
}
let cov = g.coverage[(row * m.width as i32 + col) as usize] as u32 * c.3 as u32 / 255;
if cov == 0 {
continue;
}
let at = ((y * pw + x) * 4) as usize;
let px = &mut data[at..at + 4];
let inv = 255 - cov;
for (i, src) in [c.0, c.1, c.2].into_iter().enumerate() {
px[i] = ((src as u32 * cov + px[i] as u32 * inv) / 255) as u8;
}
px[3] = (cov + px[3] as u32 * inv / 255) as u8;
}
}
});
}
/// Text `s` centred vertically in `r`, from its left (or centred
/// across it too).
fn text_in(&mut self, s: &str, style: TextStyle, r: Rect, centre: bool, c: Rgba) {
let (size, weight) = self.theme.text(style);
let (w, h, ascent) = self.fonts.line(s, size, weight);
let x = if centre { r.x + (r.w - w) / 2.0 } else { r.x };
let baseline = r.y + (r.h - h) / 2.0 + ascent;
self.glyphs(s, (size, weight), x, baseline, c);
}
fn draw<E>(&mut self, e: &Element<E>, rects: &[Rect], index: &mut usize) {
let me = *index;
*index += 1;
let Some(&r) = rects.get(me) else { return };
let theme = self.theme;
match &e.kind {
Kind::Flex(f) => {
for c in &f.children {
self.draw(c, rects, index);
}
}
Kind::Text(t) => self.text_in(&t.content, t.style, r, false, theme.text_colour(t.colour)),
Kind::Button(b) => {
let (fill, label) = theme.button(b.variant);
if let (Some(fill), Some(path)) = (fill, rounded(r, theme.corner_radius)) {
self.pm.fill_path(&path, &paint(fill), FillRule::Winding, Transform::identity(), None);
}
self.text_in(&b.label, TextStyle::Label, r, true, label);
if self.focused == Some(me) {
let ring = Rect { x: r.x - RING_GAP, y: r.y - RING_GAP, w: r.w + 2.0 * RING_GAP, h: r.h + 2.0 * RING_GAP };
if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) {
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() };
self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None);
}
}
}
}
}
}
/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index)
/// ringed.
pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>) {
let bg = theme.background;
pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3));
Painter { pm, fonts, theme, focused }.draw(root, rects, &mut 0);
}