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:
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user