feat(k-ui): motion: ring springs to focus, toggle knob slides, screens slide+fade in (theme motion tokens, 0 = off); 16ms frame timer only while moving; motion-only frames drawn+presented in part (whole elements, pixel-snapped rects, == full draw); virtio fb flushes only damage; TICKS_PER_MS in types

This commit is contained in:
2026-10-28 13:58:38 +01:00
parent e952a149ec
commit 3b71243dea
11 changed files with 504 additions and 63 deletions

View File

@@ -32,6 +32,9 @@ pub struct VirtioFramebuffer {
width: Dimension, width: Dimension,
height: Dimension, height: Dimension,
stride: usize, stride: usize,
/// What changed since the last flush, as corners: only that goes to
/// the host.
dirty: Option<(usize, usize, usize, usize)>,
} }
impl VirtioFramebuffer { impl VirtioFramebuffer {
@@ -79,6 +82,7 @@ impl VirtioFramebuffer {
width, width,
height, height,
stride: w as usize * FB_BPP, stride: w as usize * FB_BPP,
dirty: None,
}) })
} }
} }
@@ -87,6 +91,17 @@ impl VirtioFramebuffer {
fn pixel(&self, x: usize, y: usize) -> ByteOffset { fn pixel(&self, x: usize, y: usize) -> ByteOffset {
ByteOffset::new(y * self.stride + x * FB_BPP) ByteOffset::new(y * self.stride + x * FB_BPP)
} }
/// Adds the rectangle from (x0, y0) up to (x1, y1) to what changed.
fn touch(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
if x0 >= x1 || y0 >= y1 {
return;
}
self.dirty = Some(match self.dirty {
None => (x0, y0, x1, y1),
Some((a, b, c, d)) => (a.min(x0), b.min(y0), c.max(x1), d.max(y1)),
});
}
} }
impl Framebuffer for VirtioFramebuffer { impl Framebuffer for VirtioFramebuffer {
@@ -108,9 +123,11 @@ impl Framebuffer for VirtioFramebuffer {
if x >= w || y >= h { if x >= w || y >= h {
return; return;
} }
let n = pixels.len().min(w - x);
self.touch(x, y, x + n, y + 1);
let mut run = [0u32; RUN]; let mut run = [0u32; RUN];
let mut at = self.pixel(x, y); let mut at = self.pixel(x, y);
for chunk in pixels[..pixels.len().min(w - x)].chunks(RUN) { for chunk in pixels[..n].chunks(RUN) {
for (slot, c) in run.iter_mut().zip(chunk) { for (slot, c) in run.iter_mut().zip(chunk) {
*slot = c.raw(); *slot = c.raw();
} }
@@ -124,14 +141,17 @@ impl Framebuffer for VirtioFramebuffer {
let (fw, fh) = (self.width.raw() as usize, self.height.raw() as usize); let (fw, fh) = (self.width.raw() as usize, self.height.raw() as usize);
let (x, y) = (x.raw() as usize, y.raw() as usize); let (x, y) = (x.raw() as usize, y.raw() as usize);
let w = (w.raw() as usize).min(fw.saturating_sub(x)); let w = (w.raw() as usize).min(fw.saturating_sub(x));
for row in y..(y + h.raw() as usize).min(fh) { let bottom = (y + h.raw() as usize).min(fh);
self.touch(x, y, x + w, bottom);
for row in y..bottom {
self.pixels.fill_u32(self.pixel(x, row), w, color.raw()); self.pixels.fill_u32(self.pixel(x, row), w, color.raw());
} }
} }
fn clear(&mut self, color: Color) { fn clear(&mut self, color: Color) {
let total = self.width.raw() as usize * self.height.raw() as usize; let (w, h) = (self.width.raw() as usize, self.height.raw() as usize);
self.pixels.fill_u32(ByteOffset::ZERO, total, color.raw()); self.touch(0, 0, w, h);
self.pixels.fill_u32(ByteOffset::ZERO, w * h, color.raw());
} }
fn scroll_up(&mut self, top: ScreenCoord, height: Dimension, by: Dimension, fill: Color) { fn scroll_up(&mut self, top: ScreenCoord, height: Dimension, by: Dimension, fill: Color) {
@@ -140,16 +160,18 @@ impl Framebuffer for VirtioFramebuffer {
let bottom = (top + height.raw() as usize).min(fh); let bottom = (top + height.raw() as usize).min(fh);
let by = (by.raw() as usize).min(bottom.saturating_sub(top)); let by = (by.raw() as usize).min(bottom.saturating_sub(top));
let moved = bottom - top - by; let moved = bottom - top - by;
self.touch(0, top, self.width.raw() as usize, bottom);
self.pixels.copy_within(self.pixel(0, top + by), self.pixel(0, top), moved * self.stride); self.pixels.copy_within(self.pixel(0, top + by), self.pixel(0, top), moved * self.stride);
let width = self.width.raw() as usize; let width = self.width.raw() as usize;
self.pixels.fill_u32(self.pixel(0, bottom - by), by * width, fill.raw()); self.pixels.fill_u32(self.pixel(0, bottom - by), by * width, fill.raw());
} }
/// Sends the host only what changed since the last flush.
fn flush(&mut self) { fn flush(&mut self) {
let w = self.width.raw() as u32; let Some((x0, y0, x1, y1)) = self.dirty.take() else { return };
let h = self.height.raw() as u32; let r = gpu::Rect { x: x0 as u32, y: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 };
let whole = gpu::Rect { x: 0, y: 0, w, h }; let offset = self.pixel(x0, y0).raw() as u64;
gpu::transfer_to_host(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, whole); gpu::transfer_to_host(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, r, offset);
gpu::resource_flush(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, whole); gpu::resource_flush(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, r);
} }
} }

View File

@@ -65,11 +65,13 @@ pub struct Rect {
pub h: u32, pub h: u32,
} }
pub fn transfer_to_host(mmio: &impl MmioAccess, queue: &VirtioQueue, dma: &GpuDmaAddrs, resource_id: u32, rect: Rect) -> bool { /// Copies `rect` of the backing to the host; `offset` is where its top
/// left pixel is in the backing, in bytes.
pub fn transfer_to_host(mmio: &impl MmioAccess, queue: &VirtioQueue, dma: &GpuDmaAddrs, resource_id: u32, rect: Rect, offset: u64) -> bool {
write_cmd_header(&dma.cmd, 0x0105); write_cmd_header(&dma.cmd, 0x0105);
put_rect(&dma.cmd, rect); put_rect(&dma.cmd, rect);
// Offset into the resource's memory, then the resource id. // Offset into the resource's memory, then the resource id.
dma.cmd.write(BODY + 16, 0u64); dma.cmd.write(BODY + 16, offset);
dma.cmd.write(BODY + 24, resource_id); dma.cmd.write(BODY + 24, resource_id);
submit_cmd(mmio, queue, dma, 56) submit_cmd(mmio, queue, dma, 56)
} }

View File

@@ -31,7 +31,7 @@ pub(crate) const TIMER_BIT: usize = 1 << 62;
/// Where boot left storage's process. /// Where boot left storage's process.
const STORAGE_PROCESS: CapSlot = CapSlot::const_new(1); const STORAGE_PROCESS: CapSlot = CapSlot::const_new(1);
const TICKS_PER_MS: u64 = 10_000; use types::time::TICKS_PER_MS;
/// The wait before each restart in a row, then giving up. /// The wait before each restart in a row, then giving up.
const DELAYS_MS: [u64; 4] = [0, 1_000, 5_000, 30_000]; const DELAYS_MS: [u64; 4] = [0, 1_000, 5_000, 30_000];
/// A run this long was not part of a crash loop. /// A run this long was not part of a crash loop.

View File

@@ -10,6 +10,7 @@ extern crate alloc;
pub mod element; pub mod element;
pub mod layout; pub mod layout;
pub mod motion;
pub mod render; pub mod render;
pub mod run; pub mod run;
pub mod text; pub mod text;

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

@@ -0,0 +1,144 @@
//! Movement, in the theme's motion tokens: the focus ring springs to what
//! gains focus (`spring_stiffness`, `spring_damping`); a toggle's knob
//! slides (`duration_fast`) and a new screen comes in (`duration_normal`),
//! both easing out. A theme with zero durations has none of it. Pure:
//! time comes in as milliseconds.
use crate::layout::Rect;
use crate::theme::Theme;
/// Steps no longer than this, so a slow frame does not overshoot.
const MAX_STEP_S: f32 = 1.0 / 60.0;
/// Close enough to rest: pixels, and pixels a second.
const REST: f32 = 0.5;
/// 0 to 1, fast then slowing.
pub fn ease_out(t: f32) -> f32 {
let u = 1.0 - t.clamp(0.0, 1.0);
1.0 - u * u * u
}
/// A value pulled toward its target by a damped spring of unit mass.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spring {
pub value: f32,
velocity: f32,
pub target: f32,
}
impl Spring {
pub fn at(v: f32) -> Self {
Self { value: v, velocity: 0.0, target: v }
}
pub fn step(&mut self, mut dt: f32, stiffness: f32, damping: f32) {
while dt > 0.0 {
let h = dt.min(MAX_STEP_S);
let pull = stiffness * (self.target - self.value) - damping * self.velocity;
self.velocity += pull * h;
self.value += self.velocity * h;
dt -= h;
}
if self.settled() {
*self = Self::at(self.target);
}
}
pub fn settled(&self) -> bool {
libm::fabsf(self.target - self.value) < REST && libm::fabsf(self.velocity) < REST
}
}
/// The focus ring's rectangle, each side on a spring.
#[derive(Clone, Copy, Debug)]
pub struct Ring([Spring; 4]);
impl Ring {
pub fn at(r: Rect) -> Self {
Self([Spring::at(r.x), Spring::at(r.y), Spring::at(r.w), Spring::at(r.h)])
}
pub fn aim(&mut self, r: Rect) {
for (s, v) in self.0.iter_mut().zip([r.x, r.y, r.w, r.h]) {
s.target = v;
}
}
pub fn step(&mut self, dt: f32, t: &Theme) {
for s in &mut self.0 {
s.step(dt, t.spring_stiffness as f32, t.spring_damping as f32);
}
}
pub fn rect(&self) -> Rect {
let [x, y, w, h] = self.0.map(|s| s.value);
Rect { x, y, w, h }
}
pub fn settled(&self) -> bool {
self.0.iter().all(Spring::settled)
}
}
/// Something under way for `ms` milliseconds, from when it is first drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
start: Option<u64>,
ms: u16,
}
impl Tween {
pub fn new(ms: u16) -> Self {
Self { start: None, ms }
}
/// How far along it is at `now`, eased: 1 when done (or of no length).
pub fn progress(&mut self, now: u64) -> f32 {
let start = *self.start.get_or_insert(now);
match self.ms {
0 => 1.0,
ms => ease_out(now.saturating_sub(start) as f32 / ms as f32),
}
}
pub fn done(&self, now: u64) -> bool {
self.ms == 0 || self.start.is_some_and(|s| now.saturating_sub(s) >= self.ms as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_spring_comes_to_rest_on_its_target() {
let t = Theme::WHIMSY_DARK;
let mut s = Spring::at(0.0);
s.target = 100.0;
let mut ms = 0;
while !s.settled() {
s.step(0.016, t.spring_stiffness as f32, t.spring_damping as f32);
ms += 16;
assert!(ms < 2000, "rests within two seconds");
assert!(s.value < 110.0, "overshoots a little at most");
}
assert_eq!(s.value, 100.0);
// A long frame is taken in small steps: no blowing up.
let mut s = Spring::at(0.0);
s.target = 100.0;
s.step(1.0, 2000.0, 1.0);
assert!(s.value.abs() < 1000.0);
}
#[test]
fn a_tween_eases_out_from_its_first_frame() {
let mut tw = Tween::new(200);
assert_eq!(tw.progress(5000), 0.0, "starts when first drawn");
let half = tw.progress(5100);
assert!(half > 0.5, "eases out: past halfway at half time, {half}");
assert!(!tw.done(5150));
assert_eq!(tw.progress(5200), 1.0);
assert!(tw.done(5200));
assert!(Tween::new(0).done(0), "motion off");
}
}

View File

@@ -12,6 +12,8 @@ use crate::theme::{Rgba, TextStyle, Theme, Weight};
/// The focus ring's distance outside the button, and its width. /// The focus ring's distance outside the button, and its width.
const RING_GAP: f32 = 3.0; const RING_GAP: f32 = 3.0;
const RING_WIDTH: f32 = 2.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;
/// A field's outline, and its caret, across. /// A field's outline, and its caret, across.
const OUTLINE: f32 = 1.5; const OUTLINE: f32 = 1.5;
const CARET: f32 = 2.0; const CARET: f32 = 2.0;
@@ -41,15 +43,33 @@ fn rounded(rect: Rect, r: f32) -> Option<tiny_skia::Path> {
p.finish() p.finish()
} }
/// What drawing needs at hand: the pixels, the fonts, the theme, and which /// What K-UI keeps beside the view for one frame.
/// element (pre-order) has focus. #[derive(Clone, Copy, Debug, Default)]
pub struct Frame {
/// Which element (pre-order) has focus.
pub focused: Option<usize>,
/// Where the caret is in the focused field, in characters.
pub caret: usize,
/// Where the focus ring is, on its way to the focused element.
pub ring: Option<Rect>,
/// A toggle (pre-order) sliding to its new state, and how far it is.
pub flip: Option<(usize, f32)>,
/// How far in a new screen has come: 1 is all the way.
pub enter: f32,
}
/// What drawing needs at hand: the pixels, the fonts, the theme, and the
/// frame.
struct Painter<'p, 'a> { struct Painter<'p, 'a> {
pm: &'p mut PixmapMut<'a>, pm: &'p mut PixmapMut<'a>,
fonts: &'p Fonts, fonts: &'p Fonts,
theme: &'p Theme, theme: &'p Theme,
focused: Option<usize>, frame: Frame,
/// Where the caret is in the focused field, in characters. }
caret: usize,
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), m(a.3, b.3))
} }
impl Painter<'_, '_> { impl Painter<'_, '_> {
@@ -103,6 +123,12 @@ impl Painter<'_, '_> {
*index += 1; *index += 1;
let Some(&r) = rects.get(me) else { return }; let Some(&r) = rects.get(me) else { return };
let theme = self.theme; let theme = self.theme;
// Off the pixels (drawing only part of the window): 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(_)) {
return;
}
match &e.kind { match &e.kind {
Kind::Flex(f) => { Kind::Flex(f) => {
for c in &f.children { for c in &f.children {
@@ -138,19 +164,25 @@ impl Painter<'_, '_> {
self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary); self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary);
let (tw, th) = crate::layout::SWITCH; let (tw, th) = crate::layout::SWITCH;
let track = Rect { x: inner.x + inner.w - tw, y: inner.y + (inner.h - th) / 2.0, w: tw, h: th }; let track = Rect { x: inner.x + inner.w - tw, y: inner.y + (inner.h - th) / 2.0, w: tw, h: th };
self.fill(track, th / 2.0, if g.on { theme.accent } else { theme.surface_raised }); // How on it is: all the way, unless it is sliding there.
let sliding = self.frame.flip.filter(|(i, _)| *i == me).map_or(1.0, |(_, p)| p);
let on = if g.on { sliding } else { 1.0 - sliding };
self.fill(track, th / 2.0, mix(theme.surface_raised, theme.accent, on));
let knob = th - 6.0; let knob = th - 6.0;
let kx = if g.on { track.x + tw - knob - 3.0 } else { track.x + 3.0 }; let kx = track.x + 3.0 + (tw - knob - 6.0) * on;
self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary); self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary);
} }
Kind::Input(i) => self.field(i, r, self.focused == Some(me)), Kind::Input(i) => self.field(i, r, self.frame.focused == Some(me)),
} }
if self.focused == Some(me) && e.kind.focusable() { }
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) { /// The focus ring, just outside `r`.
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() }; fn ring(&mut self, r: Rect) {
self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None); let theme = self.theme;
} 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);
} }
} }
@@ -170,7 +202,7 @@ impl Painter<'_, '_> {
}; };
let (size, weight) = theme.text(TextStyle::Body); let (size, weight) = theme.text(TextStyle::Body);
let width = |s: &str| self.fonts.line(s, size, weight).0; let width = |s: &str| self.fonts.line(s, size, weight).0;
let caret = self.caret.min(shown.chars().count()); let caret = self.frame.caret.min(shown.chars().count());
let upto: String = shown.chars().take(caret).collect(); let upto: String = shown.chars().take(caret).collect();
// Moved left so the caret stays inside. // Moved left so the caret stays inside.
let shift = (width(&upto) - inner.w + CARET).max(0.0); let shift = (width(&upto) - inner.w + CARET).max(0.0);
@@ -208,10 +240,40 @@ impl Painter<'_, '_> {
} }
} }
/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index) /// Draws `root`, laid out as `rects`, as `frame` says; a screen still
/// ringed, and its caret at `caret` if it is a field. /// coming in is faded toward the background.
pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>, caret: usize) { pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame) {
let bg = theme.background; let bg = theme.background;
pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3)); pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3));
Painter { pm, fonts, theme, focused, caret }.draw(root, rects, &mut 0); let mut p = Painter { pm, fonts, theme, frame };
p.draw(root, rects, &mut 0);
if let Some(r) = frame.ring {
p.ring(r);
}
if frame.enter < 1.0 {
let veil = Rgba(bg.0, bg.1, bg.2, ((1.0 - frame.enter) * 255.0) as u8);
let all = Rect { x: 0.0, y: 0.0, w: p.pm.width() as f32, h: p.pm.height() as f32 };
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<E>(pm: &mut PixmapMut, root: &Element<E>, 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<Rect> = 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);
}
} }

View File

@@ -2,19 +2,27 @@
//! opened, named from its manifest), keys from the display while it is in //! opened, named from its manifest), keys from the display while it is in
//! front, a redraw after every change. Escape goes back, and leaves from //! front, a redraw after every change. Escape goes back, and leaves from
//! the first screen. It draws in the theme the display has in use, and //! the first screen. It draws in the theme the display has in use, and
//! again in the new one when that changes. //! again in the new one when that changes. While something moves it draws
//! a frame each `FRAME_MS`, woken by its timer as well as by input.
use libkos::display::{self, Canvas, Event}; use libkos::display::{self, Canvas, Event};
use libkos::input::{InputEvent, Key as Pressed}; use libkos::input::{InputEvent, Key as Pressed};
use libkos::syscall; use libkos::syscall;
use port_framebuffer::Framebuffer; use port_framebuffer::Framebuffer;
use tiny_skia::PixmapMut; use tiny_skia::PixmapMut;
use types::display::ScreenCoord; use types::display::{Dimension, ScreenCoord};
use crate::text::Fonts; use crate::text::Fonts;
use crate::theme::Theme; use crate::theme::Theme;
use crate::ui::{App, Key, Outcome, Ui}; use crate::ui::{App, Key, Outcome, Ui};
/// About 60 frames a second, while anything moves.
const FRAME_MS: u64 = 16;
fn now_ms() -> u64 {
syscall::get_time().raw() / types::time::TICKS_PER_MS
}
fn key(k: Pressed) -> Option<Key> { fn key(k: Pressed) -> Option<Key> {
match k { match k {
Pressed::Tab | Pressed::Down => Some(Key::Next), Pressed::Tab | Pressed::Down => Some(Key::Next),
@@ -49,8 +57,11 @@ pub fn run<A: App>() -> ! {
let mut dirty = true; let mut dirty = true;
loop { loop {
if dirty { if dirty {
draw(&mut ui, &mut canvas, &fonts); let moving = draw(&mut ui, &mut canvas, &fonts);
dirty = false; // The next frame, or none: a stale wake only polls.
let next = if moving { now_ms() + FRAME_MS } else { 0 };
let _ = syscall::set_timer(notify, types::time::Ticks::from_raw(next * types::time::TICKS_PER_MS));
dirty = moving;
} }
let _ = syscall::wait(notify); let _ = syscall::wait(notify);
while let Some(event) = display::poll(canvas.window()) { while let Some(event) = display::poll(canvas.window()) {
@@ -77,11 +88,22 @@ fn theme(window: types::bounded::CapSlot) -> Theme {
display::theme(window).map_or(Theme::WHIMSY_DARK, |(_, words)| ::theme::wire::decode(words)) display::theme(window).map_or(Theme::WHIMSY_DARK, |(_, words)| ::theme::wire::decode(words))
} }
fn draw<A: App>(ui: &mut Ui<A>, canvas: &mut Canvas, fonts: &Fonts) { /// Draws a frame; whether something is still moving.
fn draw<A: App>(ui: &mut Ui<A>, canvas: &mut Canvas, fonts: &Fonts) -> bool {
let (w, h) = (canvas.width(), canvas.height()); 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) { let (moving, part) = match PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {
ui.draw(&mut pm, fonts); Some(mut pm) => ui.draw(&mut pm, fonts, now_ms()),
None => (false, None),
};
match part {
None => canvas.touch(ScreenCoord::new(0), ScreenCoord::new(0), w, h),
Some(r) => {
// Whole pixels, clipped by the canvas.
let (x, y) = (r.x.max(0.0) as u16, r.y.max(0.0) as u16);
let (pw, ph) = (libm::ceilf(r.x + r.w) as u16 - x, libm::ceilf(r.y + r.h) as u16 - y);
canvas.touch(ScreenCoord::new(x), ScreenCoord::new(y), Dimension::new(pw), Dimension::new(ph));
}
} }
canvas.touch(ScreenCoord::new(0), ScreenCoord::new(0), w, h);
canvas.flush(); canvas.flush();
moving
} }

View File

@@ -106,7 +106,7 @@ fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
let fonts = Fonts::load().expect("the bundled fonts parse"); let fonts = Fonts::load().expect("the bundled fonts parse");
let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK); let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts); ui.draw(&mut pm.as_mut(), &fonts, 0);
let bg = T.background; let bg = T.background;
assert_eq!(at(&pm, 319, 199), (bg.0, bg.1, bg.2), "the background is the theme's"); 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. // Lay it out the same way to find the button.
@@ -187,7 +187,7 @@ fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() {
ui.key(Key::Next); ui.key(Key::Next);
} }
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts); ui.draw(&mut pm.as_mut(), &fonts, 0);
let focus = T.focus; let focus = T.focus;
// Its ring is drawn somewhere on screen: the view scrolled to it. // Its ring is drawn somewhere on screen: the view scrolled to it.
let ringed = (0..200).any(|y| at(&pm, 160, y) == (focus.0, focus.1, focus.2)); let ringed = (0..200).any(|y| at(&pm, 160, y) == (focus.0, focus.1, focus.2));
@@ -256,8 +256,62 @@ fn a_field_is_drawn_with_its_label_text_and_caret() {
let mut ui = Ui::<Form>::new(Theme::WHIMSY_DARK); let mut ui = Ui::<Form>::new(Theme::WHIMSY_DARK);
ui.app.name = std::string::String::new(); ui.app.name = std::string::String::new();
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts); ui.draw(&mut pm.as_mut(), &fonts, 0);
// Somewhere on the field's left is the accent caret. // Somewhere on the field's left is the accent caret.
let caret = (0..320).flat_map(|x| (0..200).map(move |y| (x, y))).any(|(x, y)| at(&pm, x, y) == (T.accent.0, T.accent.1, T.accent.2)); let caret = (0..320).flat_map(|x| (0..200).map(move |y| (x, y))).any(|(x, y)| at(&pm, x, y) == (T.accent.0, T.accent.1, T.accent.2));
assert!(caret, "the focused field shows its caret"); assert!(caret, "the focused field shows its caret");
} }
#[test]
fn the_ring_springs_to_new_focus_and_comes_to_rest() {
let fonts = Fonts::load().unwrap();
let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK);
ui.key(Key::Press);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
assert!(!ui.draw(&mut pm.as_mut(), &fonts, 0).0, "the ring starts where focus is");
ui.key(Key::Next);
assert!(ui.draw(&mut pm.as_mut(), &fonts, 16).0, "on its way to Reset");
let mut now = 16;
while ui.draw(&mut pm.as_mut(), &fonts, now).0 {
now += 16;
assert!(now < 3000, "it comes to rest");
}
// Motion off: no frames in between.
let mut still = Theme::WHIMSY_DARK;
(still.duration_fast, still.duration_normal) = (0, 0);
let mut ui = Ui::<Counter>::new(still);
ui.key(Key::Press);
ui.draw(&mut pm.as_mut(), &fonts, 0);
ui.key(Key::Next);
assert!(!ui.draw(&mut pm.as_mut(), &fonts, 16).0);
}
#[test]
fn drawing_only_what_moved_ends_where_drawing_it_all_does() {
let fonts = Fonts::load().unwrap();
let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK);
ui.key(Key::Press);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts, 0);
ui.key(Key::Next);
let mut now = 0;
let mut parts = 0;
loop {
now += 16;
let (moving, part) = ui.draw(&mut pm.as_mut(), &fonts, now);
parts += usize::from(part.is_some_and(|p| p.w < 320.0));
if !moving {
break;
}
}
assert!(parts > 3, "the ring's frames were drawn in part");
// The same state, drawn whole in one go.
let mut fresh = Ui::<Counter>::new(Theme::WHIMSY_DARK);
fresh.key(Key::Press);
fresh.key(Key::Next);
let mut whole = tiny_skia::Pixmap::new(320, 200).unwrap();
fresh.draw(&mut whole.as_mut(), &fonts, 0);
assert!(pm.data() == whole.data(), "no trace of where the ring was");
}

View File

@@ -3,14 +3,17 @@
//! it in sight. Keys move focus (Tab, the arrows), press (Enter, Space) and //! it in sight. Keys move focus (Tab, the arrows), press (Enter, Space) and
//! go back (Escape); a field takes what is typed, Backspace, and Left and //! go back (Escape); a field takes what is typed, Backspace, and Left and
//! Right for its caret. A press or an edit is the element's event, handed //! Right for its caret. A press or an edit is the element's event, handed
//! to the app. Pure, apart from the pixels it draws into. //! to the app. It moves things as `motion` says: the ring to what gains
//! focus, a pressed toggle, a new screen. Pure, apart from the pixels it
//! draws into; time is handed in.
use alloc::vec::Vec; use alloc::vec::Vec;
use tiny_skia::PixmapMut; use tiny_skia::PixmapMut;
use crate::element::{Element, Input, Kind}; use crate::element::{Element, Input, Kind};
use crate::layout::{self, Rect}; use crate::layout::{self, Rect};
use crate::render; use crate::motion::{Ring, Tween};
use crate::render::{self, Frame};
use crate::text::{Fonts, Measurer}; use crate::text::{Fonts, Measurer};
use crate::theme::Theme; use crate::theme::Theme;
@@ -74,6 +77,18 @@ pub struct Ui<A: App> {
/// (more means the end). /// (more means the end).
caret: usize, caret: usize,
theme: Theme, theme: Theme,
/// The ring, once drawn: it springs from where it was.
ring: Option<Ring>,
/// A toggle (pre-order) that was pressed, sliding.
flip: Option<(usize, Tween)>,
/// A new screen, coming in.
enter: Option<Tween>,
/// When the last frame was drawn, in milliseconds.
drawn: Option<u64>,
/// Something besides motion changed: the next frame is drawn whole.
whole: bool,
/// Where the ring was last drawn.
last_ring: Option<Rect>,
} }
/// The pre-order indices of every element focus can rest on in `e`. /// The pre-order indices of every element focus can rest on in `e`.
@@ -113,12 +128,21 @@ fn input_at<'e, E>(e: &'e Element<E>, index: &mut usize, target: usize) -> Optio
impl<A: App> Ui<A> { impl<A: App> Ui<A> {
pub fn new(theme: Theme) -> Self { pub fn new(theme: Theme) -> Self {
Self { app: A::init(), focus: 0, scroll: 0.0, caret: usize::MAX, theme } 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 }
}
/// A new screen: from its top, coming in, the ring where it lands.
fn new_screen(&mut self) {
(self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX);
self.enter = Some(Tween::new(self.theme.duration_normal));
self.ring = None;
self.whole = true;
} }
/// A new theme: everything is drawn in it from the next frame. /// A new theme: everything is drawn in it from the next frame.
pub fn set_theme(&mut self, theme: Theme) { pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme; self.theme = theme;
self.whole = true;
} }
fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> { fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> {
@@ -133,8 +157,7 @@ impl<A: App> Ui<A> {
if !self.app.back() { if !self.app.back() {
return Outcome::Leave; return Outcome::Leave;
} }
// A new screen: from its top. self.new_screen();
(self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX);
return Outcome::Changed; return Outcome::Changed;
} }
if self.press(key) { Outcome::Changed } else { Outcome::Nothing } if self.press(key) { Outcome::Changed } else { Outcome::Nothing }
@@ -156,6 +179,7 @@ impl<A: App> Ui<A> {
return true; return true;
} }
if caret_only { if caret_only {
self.whole = true;
return true; return true;
} }
} }
@@ -171,7 +195,10 @@ impl<A: App> Ui<A> {
Key::Previous => self.focus = (self.focus + n - 1) % n, Key::Previous => self.focus = (self.focus + n - 1) % n,
Key::Press => { Key::Press => {
let Some(event) = event_at(&view, &mut 0, target) else { return false }; let Some(event) = event_at(&view, &mut 0, target) else { return false };
self.send(event); let toggle = toggle_at(&view, &mut 0, target);
if self.send(event) && toggle {
self.flip = Some((target, Tween::new(self.theme.duration_fast)));
}
return true; return true;
} }
_ => return false, _ => return false,
@@ -210,22 +237,27 @@ impl<A: App> Ui<A> {
Some((field.on_change)(text)) Some((field.on_change)(text))
} }
/// Hands `event` to the app; a new screen starts from its top. /// Hands `event` to the app; whether it stayed on the same screen.
fn send(&mut self, event: A::Event) { fn send(&mut self, event: A::Event) -> bool {
let screen = self.app.screen(); let screen = self.app.screen();
self.app.update(event); self.app.update(event);
self.whole = true;
if self.app.screen() != screen { if self.app.screen() != screen {
(self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX); self.new_screen();
} else { return false;
// The view may have fewer things to focus now.
let now = self.pressables(&self.app.view()).len();
self.focus = self.focus.min(now.saturating_sub(1));
} }
// The view may have fewer things to focus 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, scrolled so /// Lays the view out over the whole of `pm` and draws it as it is at
/// the focused element is in sight. /// `now` (milliseconds), scrolled so the focused element is in sight:
pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) { /// whether something is still moving (draw again soon), and what of
/// `pm` changed (`None`: all of it). When only motion changed (the
/// ring, a sliding toggle) only where it moved is drawn.
pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts, now: u64) -> (bool, Option<Rect>) {
let view = self.app.view(); let view = self.app.view();
let (w, h) = (pm.width() as f32, pm.height() as f32); let (w, h) = (pm.width() as f32, pm.height() as f32);
let measurer = Measurer { fonts, theme: &self.theme }; let measurer = Measurer { fonts, theme: &self.theme };
@@ -234,12 +266,112 @@ impl<A: App> Ui<A> {
let mut rects = layout::layout(&view, Rect { x: 0.0, y: 0.0, w, h: tall }, &measurer, &self.theme); let mut rects = layout::layout(&view, Rect { x: 0.0, y: 0.0, w, h: tall }, &measurer, &self.theme);
let focused = self.pressables(&view).get(self.focus).copied(); let focused = self.pressables(&view).get(self.focus).copied();
if let Some(r) = focused.and_then(|i| rects.get(i)) { if let Some(r) = focused.and_then(|i| rects.get(i)) {
self.scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall); let scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall);
self.whole |= scroll != self.scroll;
self.scroll = scroll;
} }
// A new screen slides in from a little to the right.
let enter = self.enter.as_mut().map_or(1.0, |t| t.progress(now));
let dx = (1.0 - enter) * SLIDE;
for r in &mut rects { for r in &mut rects {
r.y -= self.scroll; r.y -= self.scroll;
r.x += dx;
// On whole pixels: crisp, and the same drawn in a piece or whole.
let (x1, y1) = (libm::roundf(r.x + r.w), libm::roundf(r.y + r.h));
(r.x, r.y) = (libm::roundf(r.x), libm::roundf(r.y));
(r.w, r.h) = (x1 - r.x, y1 - r.y);
} }
render::render(pm, &view, &rects, fonts, &self.theme, focused, self.caret); let dt = self.drawn.map_or(0.0, |d| now.saturating_sub(d).min(100) as f32 / 1000.0);
self.drawn = Some(now);
let target = focused.and_then(|i| rects.get(i)).copied();
let ring = match (target, self.ring.as_mut()) {
(None, _) => None,
(Some(r), Some(ring)) if self.theme.duration_normal > 0 => {
ring.aim(r);
ring.step(dt, &self.theme);
Some(ring.rect())
}
(Some(r), _) => {
self.ring = Some(Ring::at(r));
Some(r)
}
};
let flip = self.flip.as_mut().map(|(i, t)| (*i, t.progress(now)));
let frame = Frame { focused, caret: self.caret, ring, flip, enter };
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(|p| whole_elements(&view, &rects, p));
let drew = match (full, part) {
(true, _) => {
render::render(pm, &view, &rects, fonts, &self.theme, frame);
None
}
(false, Some(p)) => {
render::render_part(pm, &view, &rects, fonts, &self.theme, frame, p);
Some(p)
}
// Nothing moved and nothing changed.
(false, None) => Some(Rect::default()),
};
self.whole = false;
self.last_ring = ring;
if self.flip.is_some_and(|(_, t)| t.done(now)) {
self.flip = None;
}
if self.enter.is_some_and(|t| t.done(now)) {
self.enter = None;
}
let moving = self.flip.is_some() || self.enter.is_some() || self.ring.is_some_and(|r| !r.settled());
(moving, drew)
}
}
/// `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<E>(view: &Element<E>, 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>(e: &Element<E>, rects: &[Rect], index: &mut usize, out: &mut Vec<Rect>) {
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>(e: &Element<E>, 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,
} }
} }

View File

@@ -29,9 +29,8 @@ const CELL_HEIGHT: usize = raster::font::HEIGHT as usize;
const FG: Color = Color::rgb(220, 220, 220); const FG: Color = Color::rgb(220, 220, 220);
const BG: Color = Color::rgb(16, 16, 24); const BG: Color = Color::rgb(16, 16, 24);
/// Output is shown at most this long after it is written. The timebase is /// Output is shown at most this long after it is written.
/// QEMU virt's 10 MHz. const FLUSH_DELAY: u64 = 20 * types::time::TICKS_PER_MS;
const FLUSH_DELAY: u64 = 20 * 10_000;
struct Screen { struct Screen {
/// The terminal's window, all of which scrolls. /// The terminal's window, all of which scrolls.

View File

@@ -1,3 +1,6 @@
/// The timebase: QEMU virt's 10 MHz.
pub const TICKS_PER_MS: u64 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Ticks(u64); pub struct Ticks(u64);