diff --git a/bread-theme/src/anim.rs b/bread-theme/src/anim.rs index 839dc56..f0860fd 100644 --- a/bread-theme/src/anim.rs +++ b/bread-theme/src/anim.rs @@ -29,6 +29,28 @@ fn spring_ease(t: f64) -> f64 { 1.0 + t1 * t1 * ((c + 1.0) * t1 + c) } +/// One frame's worth of `spring_to`'s interpolation math — split out from +/// the tick callback below purely so it has a name a unit test can call +/// directly instead of re-deriving the same arithmetic. +/// +/// `spring_ease` is a backOut curve that legitimately overshoots past 1.0 +/// (peaks ~1.065) partway through the run — fine for a growing animation +/// (`to` > `from`), but on a *shrink* (`from` > `to`, e.g. a drawer +/// collapsing to 0) that overshoot drives the raw interpolated value BELOW +/// `to`, which can go negative for a size request and trip GTK's +/// `height >= -1` assertion. Clamping here, once, protects every caller +/// automatically instead of relying on each call site to remember +/// `h.max(0)` — this already bit breadbar once (see +/// `breadbar::main::animate_drawer_height`'s own clamp, now +/// redundant-but-harmless defense in depth on top of this). +fn frame_value(from: i32, to: i32, t: f64) -> i32 { + let eased = spring_ease(t); + let value = from as f64 + (to - from) as f64 * eased; + let lo = from.min(to) as f64; + let hi = from.max(to) as f64; + value.clamp(lo, hi).round() as i32 +} + /// Interpolates from `from` to `to` over `duration_ms`, calling `on_frame` /// with each intermediate value (and, on the final tick, the exact `to` — /// never an off-by-rounding near-miss) via `widget`'s frame clock. @@ -60,9 +82,61 @@ pub fn spring_to( on_frame(to); return ControlFlow::Break; } - let t = spring_ease(elapsed / duration_ms); - let value = from as f64 + (to - from) as f64 * t; - on_frame(value.round() as i32); + on_frame(frame_value(from, to, elapsed / duration_ms)); ControlFlow::Continue }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Samples [`frame_value`] — the exact function `spring_to`'s tick + /// callback calls every frame — across the full `t` timeline at fine + /// granularity, bypassing the real frame clock (which needs a running + /// main loop the test environment doesn't have). + fn sample_all_frames(from: i32, to: i32) -> Vec { + let steps = 2000; + (0..=steps) + .map(|i| frame_value(from, to, i as f64 / steps as f64)) + .collect() + } + + #[test] + fn spring_ease_overshoots_past_one_for_a_backout_curve() { + // Sanity check on the premise: without a clamp, a shrink would + // legitimately go negative around this point in the curve. + assert!(spring_ease(0.8) > 1.0, "expected overshoot past 1.0"); + } + + #[test] + fn shrink_never_emits_a_value_outside_the_from_to_range() { + // from > to (a drawer collapsing to 0) is exactly the case the + // unclamped overshoot could drive negative. + for value in sample_all_frames(480, 0) { + assert!( + (0..=480).contains(&value), + "shrink emitted {value}, outside [0, 480]" + ); + } + } + + #[test] + fn grow_never_emits_a_value_outside_the_from_to_range() { + for value in sample_all_frames(0, 480) { + assert!( + (0..=480).contains(&value), + "grow emitted {value}, outside [0, 480]" + ); + } + } + + #[test] + fn zero_span_never_clamps_outside_the_single_point() { + // from == to: lo == hi == that point for every t, including past + // the overshoot peak. + for value in sample_all_frames(120, 120) { + assert_eq!(value, 120); + } + } +}