feat: add timestamp-driven geometry model

This commit is contained in:
Samuel Onoja committed 2026-09-24 11:38:31 +01:00
1 parent 1d43a5fc86
commit 7f847b2c0c
4 files changed
+410 -1

No files matched your search

Generated
+7
View File
@@ -376,6 +376,13 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "ferese-animation"
version = "0.1.0"
dependencies = [
"ferese-layout",
]
[[package]]
name = "ferese-core"
version = "0.1.0"
+6 -1
View File
@@ -1,5 +1,10 @@
[workspace]
members = ["compositor", "crates/ferese-core", "crates/ferese-layout"]
members = [
"compositor",
"crates/ferese-animation",
"crates/ferese-core",
"crates/ferese-layout",
]
resolver = "3"
[workspace.package]
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ferese-animation"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-layout = { path = "../ferese-layout" }
+388
View File
@@ -0,0 +1,388 @@
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)
}
}
#[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)
}
}
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)));
}
#[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);
}
}