Files
feresaria/crates/ferese-animation/src/lib.rs
T

433 lines
12 KiB
Rust

use std::time::Duration;
use ferese_layout::Rect;
const MAX_FRAME_DELTA: Duration = Duration::from_millis(100);
const MAX_STEP_SECONDS: f64 = 1.0 / 240.0;
const CLIENT_COMMIT_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpringConfig {
pub mass: f64,
pub stiffness: f64,
pub damping: f64,
pub position_tolerance: f64,
pub velocity_tolerance: f64,
}
impl Default for SpringConfig {
fn default() -> Self {
Self {
mass: 1.0,
stiffness: 320.0,
damping: 36.0,
position_tolerance: 0.1,
velocity_tolerance: 0.1,
}
}
}
impl SpringConfig {
fn normalized(self) -> Self {
let defaults = Self::default();
Self {
mass: positive_or(self.mass, defaults.mass),
stiffness: positive_or(self.stiffness, defaults.stiffness),
damping: non_negative_or(self.damping, defaults.damping),
position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance),
velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RectVelocity {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnimatedRect {
pub current: Rect,
pub target: Rect,
pub velocity: RectVelocity,
}
impl AnimatedRect {
pub fn new(rect: Rect) -> Self {
Self {
current: rect,
target: rect,
velocity: RectVelocity::default(),
}
}
pub fn set_target(&mut self, target: Rect) {
self.target = normalized_rect(target);
}
pub fn snap(&mut self) {
self.current = self.target;
self.velocity = RectVelocity::default();
}
pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool {
if self.is_settled(config) {
self.snap();
return false;
}
let config = config.normalized();
let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64();
let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize;
let step = seconds / steps as f64;
for _ in 0..steps {
advance_value(
&mut self.current.x,
self.target.x,
&mut self.velocity.x,
step,
config,
);
advance_value(
&mut self.current.y,
self.target.y,
&mut self.velocity.y,
step,
config,
);
advance_value(
&mut self.current.width,
self.target.width,
&mut self.velocity.width,
step,
config,
);
advance_value(
&mut self.current.height,
self.target.height,
&mut self.velocity.height,
step,
config,
);
}
if self.is_settled(config) {
self.snap();
false
} else {
true
}
}
pub fn is_settled(&self, config: SpringConfig) -> bool {
let config = config.normalized();
rect_distance(self.current, self.target) <= config.position_tolerance
&& velocity_magnitude(self.velocity) <= config.velocity_tolerance
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ClientSize {
pub width: i32,
pub height: i32,
}
impl ClientSize {
pub fn from_rect(rect: Rect) -> Self {
Self {
width: (rect.width.round() as i32).max(1),
height: (rect.height.round() as i32).max(1),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ClientGeometry {
pub last_configured_size: Option<ClientSize>,
pub committed_size: Option<ClientSize>,
waiting_since: Option<Duration>,
}
impl ClientGeometry {
pub fn request_size(&mut self, size: ClientSize, now: Duration) -> bool {
if self.last_configured_size == Some(size) {
return false;
}
self.last_configured_size = Some(size);
self.waiting_since = Some(now);
true
}
pub fn commit(&mut self, size: ClientSize) -> bool {
self.committed_size = Some(size);
let matches_target = self.last_configured_size == Some(size);
if matches_target {
self.waiting_since = None;
}
matches_target
}
pub fn timed_out(&self, now: Duration) -> bool {
self.waiting_since
.is_some_and(|started| now.saturating_sub(started) >= CLIENT_COMMIT_TIMEOUT)
}
pub fn expire_wait(&mut self, now: Duration) -> Option<ClientSize> {
if !self.timed_out(now) {
return None;
}
self.waiting_since = None;
self.last_configured_size
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowGeometry {
pub logical: Rect,
pub visual: AnimatedRect,
pub client: ClientGeometry,
}
impl WindowGeometry {
pub fn new(rect: Rect, committed_size: Option<ClientSize>) -> Self {
let rect = normalized_rect(rect);
Self {
logical: rect,
visual: AnimatedRect::new(rect),
client: ClientGeometry {
last_configured_size: committed_size,
committed_size,
waiting_since: None,
},
}
}
pub fn set_logical_target(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> {
let rect = normalized_rect(rect);
let size = ClientSize::from_rect(rect);
self.logical = rect;
self.visual.set_target(rect);
self.client.request_size(size, now).then_some(size)
}
pub fn advance(
&mut self,
delta: Duration,
config: SpringConfig,
animations_enabled: bool,
) -> bool {
if !animations_enabled {
self.visual.snap();
return false;
}
self.visual.advance(delta, config)
}
pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> {
let visual = self.visual.current;
let scale = self.visual_scale()?;
Some(((x - visual.x) / scale, (y - visual.y) / scale))
}
pub fn visual_scale(&self) -> Option<f64> {
let source = self.client.committed_size?;
let visual = self.visual.current;
if source.width <= 0 || source.height <= 0 || visual.width <= 0.0 || visual.height <= 0.0 {
return None;
}
Some((visual.width / f64::from(source.width)).max(visual.height / f64::from(source.height)))
}
}
fn advance_value(
current: &mut f64,
target: f64,
velocity: &mut f64,
delta: f64,
config: SpringConfig,
) {
let acceleration =
(-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
*velocity += acceleration * delta;
let previous_error = *current - target;
*current += *velocity * delta;
let current_error = *current - target;
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
*current = target;
*velocity = 0.0;
}
}
fn normalized_rect(rect: Rect) -> Rect {
Rect::new(
finite_or(rect.x, 0.0),
finite_or(rect.y, 0.0),
positive_or(rect.width, 1.0),
positive_or(rect.height, 1.0),
)
}
fn rect_distance(left: Rect, right: Rect) -> f64 {
(left.x - right.x)
.abs()
.max((left.y - right.y).abs())
.max((left.width - right.width).abs())
.max((left.height - right.height).abs())
}
fn velocity_magnitude(velocity: RectVelocity) -> f64 {
velocity
.x
.abs()
.max(velocity.y.abs())
.max(velocity.width.abs())
.max(velocity.height.abs())
}
fn positive_or(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value
} else {
fallback
}
}
fn non_negative_or(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value >= 0.0 {
value
} else {
fallback
}
}
fn finite_or(value: f64, fallback: f64) -> f64 {
if value.is_finite() { value } else { fallback }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logical_target_changes_without_jumping_visual_geometry() {
let initial = Rect::new(0.0, 0.0, 400.0, 300.0);
let target = Rect::new(200.0, 100.0, 800.0, 600.0);
let mut geometry = WindowGeometry::new(initial, Some(ClientSize::from_rect(initial)));
assert_eq!(
geometry.set_logical_target(target, Duration::ZERO),
Some(ClientSize::from_rect(target))
);
assert_eq!(geometry.logical, target);
assert_eq!(geometry.visual.current, initial);
assert_eq!(geometry.visual.target, target);
}
#[test]
fn repeated_target_does_not_emit_another_configure() {
let rect = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut geometry = WindowGeometry::new(rect, None);
assert!(geometry.set_logical_target(rect, Duration::ZERO).is_some());
assert!(
geometry
.set_logical_target(rect, Duration::from_millis(16))
.is_none()
);
}
#[test]
fn matching_commit_clears_timeout_without_restarting_visual_motion() {
let initial = Rect::new(0.0, 0.0, 400.0, 300.0);
let target = Rect::new(100.0, 50.0, 800.0, 600.0);
let mut geometry = WindowGeometry::new(initial, None);
let requested = geometry.set_logical_target(target, Duration::ZERO).unwrap();
geometry.advance(Duration::from_millis(100), SpringConfig::default(), true);
let visual_before_commit = geometry.visual;
assert!(geometry.client.commit(requested));
assert_eq!(geometry.visual, visual_before_commit);
assert!(!geometry.client.timed_out(Duration::from_secs(1)));
}
#[test]
fn slow_client_times_out_after_half_a_second() {
let rect = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut client = ClientGeometry::default();
client.request_size(ClientSize::from_rect(rect), Duration::ZERO);
assert!(!client.timed_out(Duration::from_millis(499)));
assert!(client.timed_out(Duration::from_millis(500)));
assert_eq!(
client.expire_wait(Duration::from_millis(500)),
Some(ClientSize::from_rect(rect))
);
assert!(!client.timed_out(Duration::from_secs(1)));
}
#[test]
fn inverse_visual_point_matches_uniform_cover_transform() {
let initial = Rect::new(100.0, 50.0, 400.0, 300.0);
let mut geometry = WindowGeometry::new(initial, Some(ClientSize::from_rect(initial)));
geometry.visual.current = Rect::new(200.0, 100.0, 600.0, 600.0);
// Cover scales the 4:3 source by 2x for a square allocation, then clips it.
assert_eq!(
geometry.inverse_visual_point(600.0, 500.0),
Some((200.0, 200.0))
);
}
#[test]
fn default_spring_is_stable_across_refresh_rates_and_does_not_overshoot() {
let start = Rect::new(0.0, 0.0, 100.0, 100.0);
let target = Rect::new(500.0, 300.0, 900.0, 700.0);
let mut sixty = AnimatedRect::new(start);
let mut one_forty_four = AnimatedRect::new(start);
sixty.set_target(target);
one_forty_four.set_target(target);
for _ in 0..120 {
sixty.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default());
assert!(sixty.current.x <= target.x);
assert!(sixty.current.width <= target.width);
}
for _ in 0..288 {
one_forty_four.advance(
Duration::from_secs_f64(1.0 / 144.0),
SpringConfig::default(),
);
}
assert!(sixty.is_settled(SpringConfig::default()));
assert!(one_forty_four.is_settled(SpringConfig::default()));
assert_eq!(sixty.current, target);
assert_eq!(one_forty_four.current, target);
}
#[test]
fn disabled_animation_snaps_to_target() {
let mut geometry = WindowGeometry::new(Rect::new(0.0, 0.0, 100.0, 100.0), None);
let target = Rect::new(100.0, 100.0, 500.0, 400.0);
geometry.set_logical_target(target, Duration::ZERO);
assert!(!geometry.advance(Duration::from_millis(16), SpringConfig::default(), false));
assert_eq!(geometry.visual.current, target);
}
}