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) } pub fn expire_wait(&mut self, now: Duration) -> Option { 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) -> 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) } 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 { 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); } }