bread-theme: clamp spring_to's interpolated value to [from,to]

spring_ease's backOut overshoot (peaks ~1.065) drove a shrinking
animation's interpolated value below its target, which could go
negative for a size request and trip GTK's height >= -1 assertion.
Previously only patched at breadbar's call sites; clamp inside
spring_to itself so the safety is automatic for every caller.
This commit is contained in:
Breadway 2026-08-25 16:12:13 +08:00
parent 410f97cb28
commit cff77d473a

View file

@ -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<i32> {
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);
}
}
}