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:
158
crates/k-ui/src/element.rs
Normal file
158
crates/k-ui/src/element.rs
Normal 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
118
crates/k-ui/src/layout.rs
Normal 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
25
crates/k-ui/src/lib.rs
Normal 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
131
crates/k-ui/src/render.rs
Normal 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
65
crates/k-ui/src/run.rs
Normal 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
124
crates/k-ui/src/tests.rs
Normal 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
77
crates/k-ui/src/text.rs
Normal 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
130
crates/k-ui/src/theme.rs
Normal 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
107
crates/k-ui/src/ui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user