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

158
crates/k-ui/src/element.rs Normal file
View File

@@ -0,0 +1,158 @@
//! What a view is made of, built with typed builders: `column()`, `row()`,
//! `text(..)`, `button(..)`. Each only offers what makes sense for it
//! (a text has a style, a button a variant); appearance comes from the
//! theme, never from here.
use alloc::string::String;
use alloc::vec::Vec;
use crate::theme::{ButtonVariant, Spacing, TextColour, TextStyle};
/// How much of its parent's main axis an element takes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Size {
/// What its content needs.
Shrink,
/// A share of what is left.
Fill,
/// Exactly this many pixels: use sparingly.
Fixed(f32),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
Horizontal,
Vertical,
}
pub struct Flex<E> {
pub axis: Axis,
pub gap: Spacing,
pub padding: Spacing,
pub children: Vec<Element<E>>,
}
pub struct Text {
pub content: String,
pub style: TextStyle,
pub colour: TextColour,
}
pub struct Button<E> {
pub label: String,
pub variant: ButtonVariant,
pub on_press: E,
}
pub enum Kind<E> {
Flex(Flex<E>),
Text(Text),
Button(Button<E>),
}
pub struct Element<E> {
pub kind: Kind<E>,
pub size: Size,
}
/// Children stacked top to bottom.
pub fn column<E>() -> FlexBuilder<E> {
FlexBuilder(Flex { axis: Axis::Vertical, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink)
}
/// Children side by side.
pub fn row<E>() -> FlexBuilder<E> {
FlexBuilder(Flex { axis: Axis::Horizontal, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink)
}
pub fn text(content: impl Into<String>) -> TextBuilder {
TextBuilder(Text { content: content.into(), style: TextStyle::Body, colour: TextColour::Primary }, Size::Shrink)
}
/// A button labelled `label` that sends `on_press` when pressed.
pub fn button<E>(label: impl Into<String>, on_press: E) -> ButtonBuilder<E> {
ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink)
}
pub struct FlexBuilder<E>(Flex<E>, Size);
pub struct TextBuilder(Text, Size);
pub struct ButtonBuilder<E>(Button<E>, Size);
macro_rules! sizing {
() => {
pub fn fill(mut self) -> Self {
self.1 = Size::Fill;
self
}
pub fn fixed(mut self, px: f32) -> Self {
self.1 = Size::Fixed(px);
self
}
};
}
impl<E> FlexBuilder<E> {
sizing!();
pub fn gap(mut self, s: Spacing) -> Self {
self.0.gap = s;
self
}
pub fn padding(mut self, s: Spacing) -> Self {
self.0.padding = s;
self
}
pub fn child(mut self, c: impl Into<Element<E>>) -> Self {
self.0.children.push(c.into());
self
}
pub fn children<C: Into<Element<E>>>(mut self, cs: impl IntoIterator<Item = C>) -> Self {
self.0.children.extend(cs.into_iter().map(Into::into));
self
}
}
impl TextBuilder {
sizing!();
pub fn style(mut self, s: TextStyle) -> Self {
self.0.style = s;
self
}
pub fn colour(mut self, c: TextColour) -> Self {
self.0.colour = c;
self
}
}
impl<E> ButtonBuilder<E> {
sizing!();
pub fn variant(mut self, v: ButtonVariant) -> Self {
self.0.variant = v;
self
}
}
impl<E> From<FlexBuilder<E>> for Element<E> {
fn from(b: FlexBuilder<E>) -> Self {
Element { kind: Kind::Flex(b.0), size: b.1 }
}
}
impl<E> From<TextBuilder> for Element<E> {
fn from(b: TextBuilder) -> Self {
Element { kind: Kind::Text(b.0), size: b.1 }
}
}
impl<E> From<ButtonBuilder<E>> for Element<E> {
fn from(b: ButtonBuilder<E>) -> Self {
Element { kind: Kind::Button(b.0), size: b.1 }
}
}

118
crates/k-ui/src/layout.rs Normal file
View File

@@ -0,0 +1,118 @@
//! Where everything goes. A tree is laid out into one rectangle per
//! element, in pre-order (an element, then its children), which is the
//! order the renderer and focus walk it in too.
//!
//! Along a container's main axis children take what their content needs
//! (`Shrink`), exactly their `Fixed` size, or a share of what is left
//! (`Fill`). Across it, containers stretch to fill it and everything else
//! keeps what it needs, from the start.
use alloc::vec::Vec;
use crate::element::{Axis, Element, Kind, Size};
use crate::theme::{Spacing, TextStyle, Theme};
/// The smallest a pressable thing may be, either way (touch targets).
pub const MIN_TARGET: f32 = 44.0;
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// How big text is, from whoever has the fonts.
pub trait Measure {
fn text(&self, s: &str, style: TextStyle) -> (f32, f32);
}
/// A button's room around its label.
pub fn button_padding(theme: &Theme) -> (f32, f32) {
(theme.space(Spacing::Lg), theme.space(Spacing::Sm))
}
/// What `e` needs, width and height.
pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) {
match &e.kind {
Kind::Text(text) => m.text(&text.content, text.style),
Kind::Button(b) => {
let (w, h) = m.text(&b.label, TextStyle::Label);
let (px, py) = button_padding(t);
((w + 2.0 * px).max(MIN_TARGET), (h + 2.0 * py).max(MIN_TARGET))
}
Kind::Flex(f) => {
let pad = 2.0 * t.space(f.padding);
let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32;
let (mut main, mut cross) = (0.0f32, 0.0f32);
for c in &f.children {
let (w, h) = natural(c, m, t);
let (cm, cc) = along(f.axis, w, h);
main += match c.size {
Size::Fixed(px) => px,
_ => cm,
};
cross = cross.max(cc);
}
let (w, h) = across(f.axis, main + gaps, cross);
(w + pad, h + pad)
}
}
}
/// (main, cross) of a (width, height) along `axis`.
fn along(axis: Axis, w: f32, h: f32) -> (f32, f32) {
match axis {
Axis::Horizontal => (w, h),
Axis::Vertical => (h, w),
}
}
/// (width, height) of a (main, cross) along `axis`.
fn across(axis: Axis, main: f32, cross: f32) -> (f32, f32) {
along(axis, main, cross)
}
/// Every element's rectangle, in pre-order, the root filling `area`.
pub fn layout<E>(root: &Element<E>, area: Rect, m: &impl Measure, t: &Theme) -> Vec<Rect> {
let mut out = Vec::new();
place(root, area, m, t, &mut out);
out
}
fn place<E>(e: &Element<E>, r: Rect, m: &impl Measure, t: &Theme, out: &mut Vec<Rect>) {
out.push(r);
let Kind::Flex(f) = &e.kind else { return };
let pad = t.space(f.padding);
let inner = Rect { x: r.x + pad, y: r.y + pad, w: (r.w - 2.0 * pad).max(0.0), h: (r.h - 2.0 * pad).max(0.0) };
let (inner_main, inner_cross) = along(f.axis, inner.w, inner.h);
let gap = t.space(f.gap);
let naturals: Vec<(f32, f32)> = f.children.iter().map(|c| natural(c, m, t)).map(|(w, h)| along(f.axis, w, h)).collect();
let taken: f32 = f
.children
.iter()
.zip(&naturals)
.map(|(c, (main, _))| match c.size {
Size::Fixed(px) => px,
Size::Fill => 0.0,
Size::Shrink => *main,
})
.sum::<f32>()
+ gap * f.children.len().saturating_sub(1) as f32;
let fills = f.children.iter().filter(|c| c.size == Size::Fill).count();
let share = if fills > 0 { (inner_main - taken).max(0.0) / fills as f32 } else { 0.0 };
let mut at = 0.0;
for (c, (main, cross)) in f.children.iter().zip(&naturals) {
let main = match c.size {
Size::Fixed(px) => px,
Size::Fill => share,
Size::Shrink => *main,
};
let cross = if matches!(c.kind, Kind::Flex(_)) { inner_cross } else { cross.min(inner_cross) };
let (dx, dy) = across(f.axis, at, 0.0);
let (w, h) = across(f.axis, main, cross);
place(c, Rect { x: inner.x + dx, y: inner.y + dy, w, h }, m, t, out);
at += main + gap;
}
}

25
crates/k-ui/src/lib.rs Normal file
View File

@@ -0,0 +1,25 @@
//! K-UI (see the vision's K-UI note): an app says what its UI is, as a
//! pure function of its state; K-UI lays it out, draws it in the theme,
//! and handles focus and keys. It runs inside the app and draws into the
//! app's window.
#![no_std]
#![forbid(unsafe_code)]
extern crate alloc;
pub mod element;
pub mod layout;
pub mod render;
pub mod run;
pub mod text;
pub mod theme;
pub mod ui;
pub use element::{Element, Size, button, column, row, text};
pub use run::run;
pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle};
pub use ui::App;
#[cfg(test)]
mod tests;

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

65
crates/k-ui/src/run.rs Normal file
View File

@@ -0,0 +1,65 @@
//! An app's event loop on K-OS: its window (which the permission manager
//! opened, named from its manifest), keys from the display while it is in
//! front, a redraw after every change. Escape leaves.
use libkos::display::{self, Canvas};
use libkos::input::{InputEvent, Key as Pressed};
use libkos::syscall;
use port_framebuffer::Framebuffer;
use tiny_skia::PixmapMut;
use types::display::ScreenCoord;
use crate::text::Fonts;
use crate::theme::Theme;
use crate::ui::{App, Key, Ui};
fn key(k: Pressed) -> Option<Key> {
match k {
Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next),
Pressed::Up | Pressed::Left => Some(Key::Previous),
Pressed::Enter | Pressed::Space => Some(Key::Press),
_ => None,
}
}
/// Runs `A` until Escape (or until it is stopped).
pub fn run<A: App>() -> ! {
let window = libkos::start::display();
let Some(mut canvas) = window.and_then(Canvas::attach) else {
libkos::println!("k-ui: no window (does the manifest ask for the display?)");
syscall::exit();
};
let (Ok(notify), Some(fonts)) = (syscall::create_notification(), Fonts::load()) else {
libkos::println!("k-ui: cannot start");
syscall::exit();
};
if !display::subscribe(canvas.window(), notify, types::badge::Badge::new(1)) {
libkos::println!("k-ui: no input");
syscall::exit();
}
let mut ui = Ui::<A>::new(&Theme::WHIMSY_DARK);
let mut dirty = true;
loop {
if dirty {
draw(&ui, &mut canvas, &fonts);
dirty = false;
}
let _ = syscall::wait(notify);
while let Some(event) = display::poll_input(canvas.window()) {
match event {
InputEvent::KeyDown(Pressed::Escape) => syscall::exit(),
InputEvent::KeyDown(k) => dirty |= key(k).is_some_and(|k| ui.key(k)),
InputEvent::KeyUp(_) => {}
}
}
}
}
fn draw<A: App>(ui: &Ui<A>, canvas: &mut Canvas, fonts: &Fonts) {
let (w, h) = (canvas.width(), canvas.height());
if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {
ui.draw(&mut pm, fonts);
}
canvas.touch(ScreenCoord::new(0), ScreenCoord::new(0), w, h);
canvas.flush();
}

124
crates/k-ui/src/tests.rs Normal file
View File

@@ -0,0 +1,124 @@
extern crate std;
use alloc::format;
use alloc::vec::Vec;
use crate::element::{Element, button, column, row, text};
use crate::layout::{MIN_TARGET, Measure, Rect, layout};
use crate::text::Fonts;
use crate::theme::{ButtonVariant, Spacing, TextStyle, Theme};
use crate::ui::{App, Key, Ui};
/// Every character 10 wide, every line 20 high.
struct Fixed;
impl Measure for Fixed {
fn text(&self, s: &str, _: TextStyle) -> (f32, f32) {
(10.0 * s.len() as f32, 20.0)
}
}
const T: &Theme = &Theme::WHIMSY_DARK;
const AREA: Rect = Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 };
#[test]
fn a_column_stacks_with_gaps_and_padding() {
let view: Element<()> = column().padding(Spacing::Md).gap(Spacing::Sm).child(text("ab")).child(text("abcd")).into();
let r = layout(&view, AREA, &Fixed, T);
// Padding Md is 16, gap Sm 8.
assert_eq!(r[1], Rect { x: 16.0, y: 16.0, w: 20.0, h: 20.0 });
assert_eq!(r[2], Rect { x: 16.0, y: 44.0, w: 40.0, h: 20.0 });
}
#[test]
fn fill_shares_what_is_left_and_fixed_is_exact() {
let view: Element<()> = row().child(text("abc")).child(text("x").fill()).child(text("y").fixed(50.0)).child(text("z").fill()).into();
let r = layout(&view, AREA, &Fixed, T);
// 400 - 30 - 50 = 320, two fills: 160 each.
let widths: Vec<f32> = r[1..].iter().map(|r| r.w).collect();
assert_eq!(widths, [30.0, 160.0, 50.0, 160.0]);
assert_eq!(r[4].x, 240.0);
}
#[test]
fn buttons_are_at_least_a_touch_target_and_containers_stretch_across() {
let view: Element<()> = column().child(row().child(button("+", ()))).into();
let r = layout(&view, AREA, &Fixed, T);
assert_eq!(r[1].w, 400.0, "the row stretches across the column");
assert!(r[2].w >= MIN_TARGET && r[2].h >= MIN_TARGET);
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Event {
Add,
Reset,
}
struct Counter(u32);
impl App for Counter {
type Event = Event;
fn init() -> Self {
Counter(0)
}
fn update(&mut self, e: Event) {
match e {
Event::Add => self.0 += 1,
Event::Reset => self.0 = 0,
}
}
fn view(&self) -> Element<Event> {
let mut c = column().padding(Spacing::Md).gap(Spacing::Md).child(text(format!("{}", self.0)).style(TextStyle::Heading));
c = c.child(button("Add", Event::Add).variant(ButtonVariant::Primary));
if self.0 > 0 {
c = c.child(button("Reset", Event::Reset).variant(ButtonVariant::Destructive));
}
c.into()
}
}
#[test]
fn keys_move_focus_and_press_the_focused_button() {
let mut ui = Ui::<Counter>::new(T);
assert!(ui.key(Key::Press), "Add has focus first");
assert!(ui.key(Key::Press));
assert_eq!(ui.app.0, 2);
assert!(ui.key(Key::Next));
assert!(ui.key(Key::Press), "Reset");
assert_eq!(ui.app.0, 0);
// Reset is gone, so focus is back on Add.
assert!(ui.key(Key::Press));
assert_eq!(ui.app.0, 1);
assert!(ui.key(Key::Previous));
assert!(ui.key(Key::Press));
assert_eq!(ui.app.0, 0, "Previous wraps round to Reset");
}
/// The straight colour at (x, y) of a premultiplied pixmap, roughly.
fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
let p = pm.pixel(x, y).unwrap().demultiply();
(p.red(), p.green(), p.blue())
}
#[test]
fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
let fonts = Fonts::load().expect("the bundled fonts parse");
let ui = Ui::<Counter>::new(T);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts);
let bg = T.background;
assert_eq!(at(&pm, 319, 199), (bg.0, bg.1, bg.2), "the background is the theme's");
// Lay it out the same way to find the button.
let view = ui.app.view();
let rects = layout(&view, Rect { x: 0.0, y: 0.0, w: 320.0, h: 200.0 }, &crate::text::Measurer { fonts: &fonts, theme: T }, T);
let b = rects[2];
let accent = T.accent;
assert_eq!(at(&pm, (b.x + 4.0) as u32, (b.y + b.h / 2.0) as u32), (accent.0, accent.1, accent.2), "a primary button is the accent");
let focus = T.focus;
assert_eq!(at(&pm, (b.x + b.w / 2.0) as u32, (b.y - 3.0) as u32), (focus.0, focus.1, focus.2), "the focus ring sits around it");
// The heading's text is drawn: something brighter than the background
// in its rectangle.
let h = rects[1];
let lit = (h.x as u32..(h.x + h.w) as u32).any(|x| (h.y as u32..(h.y + h.h) as u32).any(|y| at(&pm, x, y).0 > 150));
assert!(lit, "the count is drawn");
}

77
crates/k-ui/src/text.rs Normal file
View File

@@ -0,0 +1,77 @@
//! Fonts: Noto Sans (a Latin subset, see fonts/LICENSE-noto), regular and
//! semibold, rasterised by fontdue; glyphs are kept once made.
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use core::cell::RefCell;
use fontdue::{Font, FontSettings, Metrics};
use crate::layout::Measure;
use crate::theme::{TextStyle, Theme, Weight};
static REGULAR: &[u8] = include_bytes!("../fonts/NotoSans-Regular-Latin.ttf");
static SEMIBOLD: &[u8] = include_bytes!("../fonts/NotoSans-SemiBold-Latin.ttf");
/// A glyph's coverage, a byte per pixel, and where it sits.
pub struct Glyph {
pub metrics: Metrics,
pub coverage: Vec<u8>,
}
pub struct Fonts {
regular: Font,
semibold: Font,
glyphs: RefCell<BTreeMap<(char, u32, bool), Glyph>>,
}
impl Fonts {
pub fn load() -> Option<Self> {
let font = |bytes| Font::from_bytes(bytes, FontSettings::default()).ok();
Some(Self { regular: font(REGULAR)?, semibold: font(SEMIBOLD)?, glyphs: RefCell::new(BTreeMap::new()) })
}
fn font(&self, w: Weight) -> &Font {
match w {
Weight::Regular => &self.regular,
Weight::SemiBold => &self.semibold,
}
}
/// The text's width, and a line's height and ascent, at `size` pixels.
pub fn line(&self, s: &str, size: f32, w: Weight) -> (f32, f32, f32) {
let font = self.font(w);
let width = s.chars().map(|c| font.metrics(c, size).advance_width).sum();
let (height, ascent) = font.horizontal_line_metrics(size).map_or((size * 1.3, size), |m| (m.new_line_size, m.ascent));
(width, height, ascent)
}
/// Calls `draw` with each glyph of `s` and the pen's x for it.
pub fn glyphs(&self, s: &str, size: f32, w: Weight, mut draw: impl FnMut(f32, &Glyph)) {
let font = self.font(w);
let mut glyphs = self.glyphs.borrow_mut();
let mut x = 0.0;
for c in s.chars() {
let key = (c, size.to_bits(), w == Weight::SemiBold);
let g = glyphs.entry(key).or_insert_with(|| {
let (metrics, coverage) = font.rasterize(c, size);
Glyph { metrics, coverage }
});
draw(x, g);
x += g.metrics.advance_width;
}
}
}
/// Text measured in the theme's styles.
pub struct Measurer<'a> {
pub fonts: &'a Fonts,
pub theme: &'a Theme,
}
impl Measure for Measurer<'_> {
fn text(&self, s: &str, style: TextStyle) -> (f32, f32) {
let (size, weight) = self.theme.text(style);
let (w, h, _) = self.fonts.line(s, size, weight);
(w, h)
}
}

130
crates/k-ui/src/theme.rs Normal file
View File

@@ -0,0 +1,130 @@
//! The theme's tokens (see the vision's K-UI note): apps say what a thing
//! is (a primary button, a heading), never its colour or size. One theme
//! for now, built in; loading themes from TOML comes later.
/// 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)
}
}
/// 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,
}
pub struct Theme {
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 focus: Rgba,
pub corner_radius: f32,
/// The spacing scale's unit, in pixels.
pub spacing_base: f32,
}
impl Theme {
pub const WHIMSY_DARK: Theme = Theme {
background: Rgba::opaque(0x0f, 0x0e, 0x17),
surface: Rgba::opaque(0x1a, 0x1a, 0x2e),
surface_raised: Rgba::opaque(0x2a, 0x2a, 0x44),
accent: Rgba::opaque(0x4f, 0xc3, 0xf7),
on_accent: Rgba::opaque(0x0f, 0x0e, 0x17),
text_primary: Rgba::opaque(0xe0, 0xe0, 0xe0),
text_secondary: Rgba::opaque(0x9e, 0x9e, 0xb8),
destructive: Rgba::opaque(0xef, 0x53, 0x50),
focus: Rgba::opaque(0xff, 0xff, 0xff),
corner_radius: 16.0,
spacing_base: 4.0,
};
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.text_primary),
}
}
}

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

@@ -0,0 +1,107 @@
//! An app and what K-UI keeps about it: which pressable thing has focus.
//! Keys move focus (Tab, the arrows) and press (Enter, Space); a press is
//! the button's event, handed to the app. Pure, apart from the pixels it
//! draws into.
use alloc::vec::Vec;
use tiny_skia::PixmapMut;
use crate::element::{Element, Kind};
use crate::layout::{self, Rect};
use crate::render;
use crate::text::{Fonts, Measurer};
use crate::theme::Theme;
/// Every K-UI app.
pub trait App {
type Event: Clone;
fn init() -> Self;
/// Changes the state; the view follows.
fn update(&mut self, event: Self::Event);
/// What the state looks like: a pure function of it.
fn view(&self) -> Element<Self::Event>;
}
/// A key, as far as K-UI cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
Next,
Previous,
Press,
}
pub struct Ui<A: App> {
pub app: A,
/// Which pressable element has focus: its place among them, in
/// pre-order.
focus: usize,
theme: &'static Theme,
}
/// The pre-order indices of every pressable element in `e`.
fn pressables<E>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) {
if matches!(e.kind, Kind::Button(_)) {
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: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Option<E> {
let me = *index;
*index += 1;
match &e.kind {
Kind::Button(b) if me == target => Some(b.on_press.clone()),
Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)),
_ => None,
}
}
impl<A: App> Ui<A> {
pub fn new(theme: &'static Theme) -> Self {
Self { app: A::init(), focus: 0, theme }
}
fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> {
let mut out = Vec::new();
pressables(view, &mut 0, &mut out);
out
}
/// Handles a key; whether anything changed.
pub fn key(&mut self, key: Key) -> bool {
let view = self.app.view();
let targets = self.pressables(&view);
if targets.is_empty() {
return false;
}
let n = targets.len();
match key {
Key::Next => self.focus = (self.focus + 1) % n,
Key::Previous => self.focus = (self.focus + n - 1) % n,
Key::Press => {
let Some(event) = event_at(&view, &mut 0, targets[self.focus.min(n - 1)]) else { return false };
self.app.update(event);
// The view may have fewer pressable things now.
let now = self.pressables(&self.app.view()).len();
self.focus = self.focus.min(now.saturating_sub(1));
}
}
true
}
/// Lays the view out over the whole of `pm` and draws it.
pub fn draw(&self, pm: &mut PixmapMut, fonts: &Fonts) {
let view = self.app.view();
let area = Rect { x: 0.0, y: 0.0, w: pm.width() as f32, h: pm.height() as f32 };
let rects = layout::layout(&view, area, &Measurer { fonts, theme: self.theme }, self.theme);
let focused = self.pressables(&view).get(self.focus).copied();
render::render(pm, &view, &rects, fonts, self.theme, focused);
}
}