diff --git a/Cargo.lock b/Cargo.lock index afcd006..8fc1a54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 62c02d2..eb5b55f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/crates/ferese-animation/Cargo.toml b/crates/ferese-animation/Cargo.toml new file mode 100644 index 0000000..6c05919 --- /dev/null +++ b/crates/ferese-animation/Cargo.toml @@ -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" } diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs new file mode 100644 index 0000000..8baf35d --- /dev/null +++ b/crates/ferese-animation/src/lib.rs @@ -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, + pub committed_size: Option, + waiting_since: Option, +} + +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) -> 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 { + 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); + } +}