From 87bc68fe8af685c007fc63bf9e80357b034d80cb Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 05:08:41 +0100 Subject: [PATCH] fix(animation): settle fullscreen zoom at pixel precision --- crates/ferese-animation/src/lib.rs | 53 ++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index f9b0aed..2f8b354 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -416,9 +416,22 @@ impl WindowGeometry { } if let Some(zoom) = &mut self.zoom { - let active = zoom.progress.advance(delta, config); - let p = zoom.progress.current; let target = self.visual.target; + // Spring tolerances describe pixels and pixels/second, whereas this + // spring advances unit progress. Without conversion it snaps several + // pixels early on large zooms, visibly shifting the trailing edge. + let travel = rect_distance(zoom.from, target) + .max((zoom.from.x + zoom.from.width - target.x - target.width).abs()) + .max((zoom.from.y + zoom.from.height - target.y - target.height).abs()) + .max(1.0); + let config = config.normalized(); + let progress_config = SpringConfig { + position_tolerance: config.position_tolerance / travel, + velocity_tolerance: config.velocity_tolerance / travel, + ..config + }; + let active = zoom.progress.advance(delta, progress_config); + let p = zoom.progress.current; let lerp = |a: f64, b: f64| a + (b - a) * p; self.visual.current = Rect::new( lerp(zoom.from.x, target.x), @@ -541,6 +554,42 @@ fn finite_or(value: f64, fallback: f64) -> f64 { mod tests { use super::*; + #[test] + fn zoom_finishes_without_a_visible_final_edge_jump() { + let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0); + let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0); + for (start, target, fullscreen_target) in + [(tiled, fullscreen, true), (fullscreen, tiled, false)] + { + let mut geometry = WindowGeometry::new(start, None); + if !fullscreen_target { + geometry.set_presentation_target(start, true, Duration::ZERO); + geometry.advance(Duration::ZERO, SpringConfig::default(), false); + } + geometry.set_presentation_target(target, fullscreen_target, Duration::ZERO); + let mut finished = false; + for _ in 0..1000 { + let before = geometry.visual.current; + if !geometry.advance( + Duration::from_secs_f64(1.0 / 144.0), + SpringConfig::default(), + true, + ) { + let right_before = before.x + before.width; + let right_after = target.x + target.width; + assert!( + (right_after - right_before).abs() < 0.5, + "final right-edge jump: {} pixels", + (right_after - right_before).abs() + ); + finished = true; + break; + } + } + assert!(finished); + } + } + #[test] fn zoom_geometry_and_decorations_share_progress() { let start = Rect::new(200.0, 40.0, 600.0, 800.0);