diff --git a/crates/drivers/framebuffer/virtio/src/lib.rs b/crates/drivers/framebuffer/virtio/src/lib.rs index d5d25a8..20a31c7 100644 --- a/crates/drivers/framebuffer/virtio/src/lib.rs +++ b/crates/drivers/framebuffer/virtio/src/lib.rs @@ -32,6 +32,9 @@ pub struct VirtioFramebuffer { width: Dimension, height: Dimension, stride: usize, + /// What changed since the last flush, as corners: only that goes to + /// the host. + dirty: Option<(usize, usize, usize, usize)>, } impl VirtioFramebuffer { @@ -79,6 +82,7 @@ impl VirtioFramebuffer { width, height, stride: w as usize * FB_BPP, + dirty: None, }) } } @@ -87,6 +91,17 @@ impl VirtioFramebuffer { fn pixel(&self, x: usize, y: usize) -> ByteOffset { 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 { @@ -108,9 +123,11 @@ impl Framebuffer for VirtioFramebuffer { if x >= w || y >= h { return; } + let n = pixels.len().min(w - x); + self.touch(x, y, x + n, y + 1); let mut run = [0u32; RUN]; 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) { *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 (x, y) = (x.raw() as usize, y.raw() as usize); 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()); } } fn clear(&mut self, color: Color) { - let total = self.width.raw() as usize * self.height.raw() as usize; - self.pixels.fill_u32(ByteOffset::ZERO, total, color.raw()); + let (w, h) = (self.width.raw() as usize, self.height.raw() as usize); + 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) { @@ -140,16 +160,18 @@ impl Framebuffer for VirtioFramebuffer { let bottom = (top + height.raw() as usize).min(fh); let by = (by.raw() as usize).min(bottom.saturating_sub(top)); 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); let width = self.width.raw() as usize; 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) { - let w = self.width.raw() as u32; - let h = self.height.raw() as u32; - let whole = gpu::Rect { x: 0, y: 0, w, h }; - gpu::transfer_to_host(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, whole); - gpu::resource_flush(&self.mmio, &self.queue, &self.dma, RESOURCE_ID, whole); + let Some((x0, y0, x1, y1)) = self.dirty.take() else { return }; + let r = gpu::Rect { x: x0 as u32, y: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 }; + let offset = self.pixel(x0, y0).raw() as u64; + 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, r); } } diff --git a/crates/hw-virtio/src/gpu.rs b/crates/hw-virtio/src/gpu.rs index ae72ed9..6140bd6 100644 --- a/crates/hw-virtio/src/gpu.rs +++ b/crates/hw-virtio/src/gpu.rs @@ -65,11 +65,13 @@ pub struct Rect { 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); put_rect(&dma.cmd, rect); // 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); submit_cmd(mmio, queue, dma, 56) } diff --git a/crates/init/src/supervise.rs b/crates/init/src/supervise.rs index ad8a607..ad6b045 100644 --- a/crates/init/src/supervise.rs +++ b/crates/init/src/supervise.rs @@ -31,7 +31,7 @@ pub(crate) const TIMER_BIT: usize = 1 << 62; /// Where boot left storage's process. 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. const DELAYS_MS: [u64; 4] = [0, 1_000, 5_000, 30_000]; /// A run this long was not part of a crash loop. diff --git a/crates/k-ui/src/lib.rs b/crates/k-ui/src/lib.rs index 649cab9..9fa8b38 100644 --- a/crates/k-ui/src/lib.rs +++ b/crates/k-ui/src/lib.rs @@ -10,6 +10,7 @@ extern crate alloc; pub mod element; pub mod layout; +pub mod motion; pub mod render; pub mod run; pub mod text; diff --git a/crates/k-ui/src/motion.rs b/crates/k-ui/src/motion.rs new file mode 100644 index 0000000..9cd6924 --- /dev/null +++ b/crates/k-ui/src/motion.rs @@ -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, + 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"); + } +} diff --git a/crates/k-ui/src/render.rs b/crates/k-ui/src/render.rs index 02e4e95..0256d92 100644 --- a/crates/k-ui/src/render.rs +++ b/crates/k-ui/src/render.rs @@ -12,6 +12,8 @@ use crate::theme::{Rgba, TextStyle, Theme, Weight}; /// The focus ring's distance outside the button, and its width. const RING_GAP: f32 = 3.0; const RING_WIDTH: f32 = 2.0; +/// How far outside an element its ring reaches. +pub const RING_REACH: f32 = RING_GAP + RING_WIDTH + 1.0; /// A field's outline, and its caret, across. const OUTLINE: f32 = 1.5; const CARET: f32 = 2.0; @@ -41,15 +43,33 @@ fn rounded(rect: Rect, r: f32) -> Option { p.finish() } -/// What drawing needs at hand: the pixels, the fonts, the theme, and which -/// element (pre-order) has focus. +/// What K-UI keeps beside the view for one frame. +#[derive(Clone, Copy, Debug, Default)] +pub struct Frame { + /// Which element (pre-order) has focus. + pub focused: Option, + /// 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, + /// 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> { pm: &'p mut PixmapMut<'a>, fonts: &'p Fonts, theme: &'p Theme, - focused: Option, - /// Where the caret is in the focused field, in characters. - caret: usize, + frame: Frame, +} + +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<'_, '_> { @@ -103,6 +123,12 @@ impl Painter<'_, '_> { *index += 1; let Some(&r) = rects.get(me) else { return }; let theme = self.theme; + // Off the pixels (drawing only part of the window): nothing to do. + 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 { Kind::Flex(f) => { for c in &f.children { @@ -138,19 +164,25 @@ impl Painter<'_, '_> { self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary); 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 }; - 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 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); } - 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) { - let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() }; - self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None); - } + } + + /// The focus ring, just outside `r`. + fn ring(&mut self, r: Rect) { + 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 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(); // Moved left so the caret stays inside. 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) -/// ringed, and its caret at `caret` if it is a field. -pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option, caret: usize) { +/// Draws `root`, laid out as `rects`, as `frame` says; a screen still +/// coming in is faded toward the background. +pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame) { let bg = theme.background; 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(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, frame: Frame, part: Rect) { + let (x0, y0) = (libm::floorf(part.x.max(0.0)), libm::floorf(part.y.max(0.0))); + let x1 = libm::ceilf(part.x + part.w).min(pm.width() as f32); + let y1 = libm::ceilf(part.y + part.h).min(pm.height() as f32); + let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); + let Some(mut piece) = (x1 > x0 && y1 > y0).then(|| tiny_skia::Pixmap::new(w, h)).flatten() else { return }; + let shift = |r: Rect| Rect { x: r.x - x0, y: r.y - y0, ..r }; + let moved: alloc::vec::Vec = rects.iter().map(|r| shift(*r)).collect(); + let frame = Frame { ring: frame.ring.map(shift), ..frame }; + render(&mut piece.as_mut(), root, &moved, fonts, theme, frame); + let stride = pm.width() as usize * 4; + let row = w as usize * 4; + let data = pm.data_mut(); + for (i, line) in piece.data().chunks_exact(row).enumerate() { + let at = (y0 as usize + i) * stride + x0 as usize * 4; + data[at..at + row].copy_from_slice(line); + } } diff --git a/crates/k-ui/src/run.rs b/crates/k-ui/src/run.rs index a15ae35..436062a 100644 --- a/crates/k-ui/src/run.rs +++ b/crates/k-ui/src/run.rs @@ -2,19 +2,27 @@ //! 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 //! 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::input::{InputEvent, Key as Pressed}; use libkos::syscall; use port_framebuffer::Framebuffer; use tiny_skia::PixmapMut; -use types::display::ScreenCoord; +use types::display::{Dimension, ScreenCoord}; use crate::text::Fonts; use crate::theme::Theme; 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 { match k { Pressed::Tab | Pressed::Down => Some(Key::Next), @@ -49,8 +57,11 @@ pub fn run() -> ! { let mut dirty = true; loop { if dirty { - draw(&mut ui, &mut canvas, &fonts); - dirty = false; + let moving = draw(&mut ui, &mut canvas, &fonts); + // 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); 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)) } -fn draw(ui: &mut Ui, canvas: &mut Canvas, fonts: &Fonts) { +/// Draws a frame; whether something is still moving. +fn draw(ui: &mut Ui, canvas: &mut Canvas, fonts: &Fonts) -> bool { 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); + let (moving, part) = match PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) { + 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(); + moving } diff --git a/crates/k-ui/src/tests.rs b/crates/k-ui/src/tests.rs index 604cd16..e9ecb0a 100644 --- a/crates/k-ui/src/tests.rs +++ b/crates/k-ui/src/tests.rs @@ -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 mut ui = Ui::::new(Theme::WHIMSY_DARK); 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; 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. @@ -187,7 +187,7 @@ fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() { ui.key(Key::Next); } 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; // 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)); @@ -256,8 +256,62 @@ fn a_field_is_drawn_with_its_label_text_and_caret() { let mut ui = Ui::
::new(Theme::WHIMSY_DARK); ui.app.name = std::string::String::new(); 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. 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"); } + +#[test] +fn the_ring_springs_to_new_focus_and_comes_to_rest() { + let fonts = Fonts::load().unwrap(); + let mut ui = Ui::::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::::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::::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::::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"); +} + + diff --git a/crates/k-ui/src/ui.rs b/crates/k-ui/src/ui.rs index f238939..40c1140 100644 --- a/crates/k-ui/src/ui.rs +++ b/crates/k-ui/src/ui.rs @@ -3,14 +3,17 @@ //! 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 //! 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 tiny_skia::PixmapMut; use crate::element::{Element, Input, Kind}; 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::theme::Theme; @@ -74,6 +77,18 @@ pub struct Ui { /// (more means the end). caret: usize, theme: Theme, + /// The ring, once drawn: it springs from where it was. + ring: Option, + /// A toggle (pre-order) that was pressed, sliding. + flip: Option<(usize, Tween)>, + /// A new screen, coming in. + enter: Option, + /// When the last frame was drawn, in milliseconds. + drawn: Option, + /// Something besides motion changed: the next frame is drawn whole. + whole: bool, + /// Where the ring was last drawn. + last_ring: Option, } /// 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, index: &mut usize, target: usize) -> Optio impl Ui { 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. pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; + self.whole = true; } fn pressables(&self, view: &Element) -> Vec { @@ -133,8 +157,7 @@ impl Ui { if !self.app.back() { return Outcome::Leave; } - // A new screen: from its top. - (self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX); + self.new_screen(); return Outcome::Changed; } if self.press(key) { Outcome::Changed } else { Outcome::Nothing } @@ -156,6 +179,7 @@ impl Ui { return true; } if caret_only { + self.whole = true; return true; } } @@ -171,7 +195,10 @@ impl Ui { Key::Previous => self.focus = (self.focus + n - 1) % n, Key::Press => { 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 false, @@ -210,22 +237,27 @@ impl Ui { Some((field.on_change)(text)) } - /// Hands `event` to the app; a new screen starts from its top. - fn send(&mut self, event: A::Event) { + /// Hands `event` to the app; whether it stayed on the same screen. + fn send(&mut self, event: A::Event) -> bool { let screen = self.app.screen(); self.app.update(event); + self.whole = true; if self.app.screen() != screen { - (self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX); - } else { - // 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)); + self.new_screen(); + 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)); + true } - /// Lays the view out over the whole of `pm` and draws it, scrolled so - /// the focused element is in sight. - pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) { + /// Lays the view out over the whole of `pm` and draws it as it is at + /// `now` (milliseconds), scrolled so the focused element is in sight: + /// 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) { let view = self.app.view(); let (w, h) = (pm.width() as f32, pm.height() as f32); let measurer = Measurer { fonts, theme: &self.theme }; @@ -234,12 +266,112 @@ impl Ui { 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(); 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 { 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(view: &Element, rects: &[Rect], part: Rect) -> Rect { + let mut leaves = Vec::new(); + leaf_rects(view, rects, &mut 0, &mut leaves); + leaves.into_iter().map(|r| grow(r, 1.0)).filter(|r| overlap(*r, part)).fold(part, union) +} + +fn leaf_rects(e: &Element, rects: &[Rect], index: &mut usize, out: &mut Vec) { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().for_each(|c| leaf_rects(c, rects, index, out)), + _ => out.extend(rects.get(me)), + } +} + +fn overlap(a: Rect, b: Rect) -> bool { + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h +} + +fn grow(r: Rect, by: f32) -> Rect { + Rect { x: r.x - by, y: r.y - by, w: r.w + 2.0 * by, h: r.h + 2.0 * by } +} + +fn union(a: Rect, b: Rect) -> Rect { + let (x0, y0) = (a.x.min(b.x), a.y.min(b.y)); + Rect { x: x0, y: y0, w: (a.x + a.w).max(b.x + b.w) - x0, h: (a.y + a.h).max(b.y + b.h) - y0 } +} + +/// How far right a new screen starts, in pixels. +const SLIDE: f32 = 32.0; + +/// Whether the pre-order `target`th element is a toggle. +fn toggle_at(e: &Element, index: &mut usize, target: usize) -> bool { + let me = *index; + *index += 1; + match &e.kind { + Kind::Flex(f) => f.children.iter().any(|c| toggle_at(c, index, target)), + Kind::Toggle(_) => me == target, + _ => false, } } diff --git a/crates/servers/terminal-server/src/lib.rs b/crates/servers/terminal-server/src/lib.rs index 5f47209..3cb2627 100644 --- a/crates/servers/terminal-server/src/lib.rs +++ b/crates/servers/terminal-server/src/lib.rs @@ -29,9 +29,8 @@ const CELL_HEIGHT: usize = raster::font::HEIGHT as usize; const FG: Color = Color::rgb(220, 220, 220); const BG: Color = Color::rgb(16, 16, 24); -/// Output is shown at most this long after it is written. The timebase is -/// QEMU virt's 10 MHz. -const FLUSH_DELAY: u64 = 20 * 10_000; +/// Output is shown at most this long after it is written. +const FLUSH_DELAY: u64 = 20 * types::time::TICKS_PER_MS; struct Screen { /// The terminal's window, all of which scrolls. diff --git a/crates/types/src/time.rs b/crates/types/src/time.rs index 0bcb768..ae692aa 100644 --- a/crates/types/src/time.rs +++ b/crates/types/src/time.rs @@ -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)] pub struct Ticks(u64);