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:
@@ -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;
|
||||
|
||||
144
crates/k-ui/src/motion.rs
Normal file
144
crates/k-ui/src/motion.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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<tiny_skia::Path> {
|
||||
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<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> {
|
||||
pm: &'p mut PixmapMut<'a>,
|
||||
fonts: &'p Fonts,
|
||||
theme: &'p Theme,
|
||||
focused: Option<usize>,
|
||||
/// 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<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>, caret: usize) {
|
||||
/// Draws `root`, laid out as `rects`, as `frame` says; a screen still
|
||||
/// coming in is faded toward the background.
|
||||
pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Key> {
|
||||
match k {
|
||||
Pressed::Tab | Pressed::Down => Some(Key::Next),
|
||||
@@ -49,8 +57,11 @@ pub fn run<A: App>() -> ! {
|
||||
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<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());
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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::<Counter>::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::<Form>::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::<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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<A: App> {
|
||||
/// (more means the end).
|
||||
caret: usize,
|
||||
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`.
|
||||
@@ -113,12 +128,21 @@ fn input_at<'e, E>(e: &'e Element<E>, index: &mut usize, target: usize) -> Optio
|
||||
|
||||
impl<A: App> Ui<A> {
|
||||
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<A::Event>) -> Vec<usize> {
|
||||
@@ -133,8 +157,7 @@ impl<A: App> Ui<A> {
|
||||
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<A: App> Ui<A> {
|
||||
return true;
|
||||
}
|
||||
if caret_only {
|
||||
self.whole = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -171,7 +195,10 @@ impl<A: App> Ui<A> {
|
||||
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<A: App> Ui<A> {
|
||||
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<Rect>) {
|
||||
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<A: App> Ui<A> {
|
||||
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user