use crate::physics::ScrollPhysics;
use rosace_state::Atom;
#[derive(Clone)]
pub struct ScrollController {
pub offset: Atom<[f32; 2]>,
pub content_size: Atom<[f32; 2]>,
pub viewport_size: Atom<[f32; 2]>,
last_drag_point: Atom<Option<[f32; 2]>>,
drag_origin: Atom<Option<[f32; 2]>>,
velocity: Atom<[f32; 2]>,
last_offset_for_velocity: Atom<[f32; 2]>,
was_pressed: Atom<bool>,
wheel_idle_time: Atom<f32>,
}
pub const WHEEL_IDLE_GRACE: f32 = 0.12;
pub const MAX_VELOCITY: f32 = 2500.0;
pub const COAST_STOP_THRESHOLD: f32 = 15.0;
impl ScrollController {
pub fn for_ctx(ctx: &mut rosace_core::Context) -> Self {
let ctrl = ctx.state(Self::new()).get();
let id = ctx.component_id();
ctrl.offset.subscribe(id);
ctrl.content_size.subscribe(id);
ctrl.viewport_size.subscribe(id);
ctrl
}
pub fn new() -> Self {
Self {
offset: rosace_state::use_atom([0.0f32; 2]),
content_size: rosace_state::use_atom([0.0f32; 2]),
viewport_size: rosace_state::use_atom([0.0f32; 2]),
last_drag_point: rosace_state::use_atom(None),
drag_origin: rosace_state::use_atom(None),
velocity: rosace_state::use_atom([0.0f32; 2]),
last_offset_for_velocity: rosace_state::use_atom([0.0f32; 2]),
was_pressed: rosace_state::use_atom(false),
wheel_idle_time: rosace_state::use_atom(f32::MAX),
}
}
pub fn scroll_to(&self, x: f32, y: f32) {
let [cw, ch] = self.content_size.get();
let [vw, vh] = self.viewport_size.get();
let nx = x.clamp(0.0, (cw - vw).max(0.0));
let ny = y.clamp(0.0, (ch - vh).max(0.0));
self.offset.set([nx, ny]);
}
pub fn scroll_to_top(&self) {
let [x, _] = self.offset.get();
self.offset.set([x, 0.0]);
}
pub fn scroll_to_bottom(&self) {
let [x, _] = self.offset.get();
let [_, ch] = self.content_size.get();
let [_, vh] = self.viewport_size.get();
self.offset.set([x, (ch - vh).max(0.0)]);
}
pub fn scroll_by(&self, dx: f32, dy: f32) {
let [ox, oy] = self.offset.get();
let [cw, ch] = self.content_size.get();
let [vw, vh] = self.viewport_size.get();
let new_x = (ox + dx).clamp(0.0, (cw - vw).max(0.0));
let new_y = (oy + dy).clamp(0.0, (ch - vh).max(0.0));
self.offset.set([new_x, new_y]);
}
pub fn offset(&self) -> [f32; 2] {
self.offset.get()
}
pub fn save_position(&self) -> [f32; 2] {
self.offset.get()
}
pub fn restore_position(&self, pos: [f32; 2]) {
self.offset.set(pos);
}
pub const DRAG_SLOP: f32 = 6.0;
pub fn drag_delta(&self, x: f32, y: f32) -> (f32, f32) {
let prev = self.last_drag_point.get();
self.last_drag_point.set(Some([x, y]));
let Some([px, py]) = prev else {
self.drag_origin.set(Some([x, y]));
return (0.0, 0.0);
};
if let Some([ox, oy]) = self.drag_origin.get() {
if (x - ox).hypot(y - oy) <= Self::DRAG_SLOP {
return (0.0, 0.0); }
self.drag_origin.set(None); }
(x - px, y - py)
}
pub fn end_drag(&self) {
self.last_drag_point.set(None);
self.drag_origin.set(None);
}
pub fn track_velocity(&self, dt: f32) {
if dt <= 0.0 {
return;
}
let now = self.offset.get();
let prev = self.last_offset_for_velocity.get();
let vx = ((now[0] - prev[0]) / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY);
let vy = ((now[1] - prev[1]) / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY);
self.velocity.set([vx, vy]);
self.last_offset_for_velocity.set(now);
}
pub fn velocity(&self) -> [f32; 2] {
self.velocity.get()
}
pub fn set_velocity(&self, v: [f32; 2]) {
self.velocity.set([v[0].clamp(-MAX_VELOCITY, MAX_VELOCITY), v[1].clamp(-MAX_VELOCITY, MAX_VELOCITY)]);
}
pub fn was_pressed(&self) -> bool {
self.was_pressed.get()
}
pub fn set_was_pressed(&self, v: bool) {
self.was_pressed.set(v);
}
pub fn mark_wheel_active(&self) {
self.wheel_idle_time.set(0.0);
}
pub fn advance_wheel_idle(&self, dt: f32) {
let t = self.wheel_idle_time.get();
if t < f32::MAX / 2.0 {
self.wheel_idle_time.set(t + dt);
}
}
pub fn wheel_recently_active(&self) -> bool {
self.wheel_idle_time.get() < WHEEL_IDLE_GRACE
}
pub fn velocity_magnitude(&self) -> f32 {
let [vx, vy] = self.velocity.get();
(vx * vx + vy * vy).sqrt()
}
pub fn is_overscrolled(&self) -> bool {
let [ox, oy] = self.offset.get();
let [cw, ch] = self.content_size.get();
let [vw, vh] = self.viewport_size.get();
let max_x = (cw - vw).max(0.0);
let max_y = (ch - vh).max(0.0);
ox < 0.0 || ox > max_x || oy < 0.0 || oy > max_y
}
pub fn coast(&self, physics: ScrollPhysics, dt: f32) -> bool {
if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
if self.is_overscrolled() {
self.velocity.set([0.0, 0.0]);
return self.settle_bounce(spring_stiffness, dt);
}
}
let [vx, vy] = self.velocity.get(); if vx.abs() > COAST_STOP_THRESHOLD || vy.abs() > COAST_STOP_THRESHOLD {
let friction = match physics {
ScrollPhysics::Momentum { friction } | ScrollPhysics::Bounce { friction, .. } => friction,
_ => { self.velocity.set([0.0, 0.0]); return false; }
};
let dt = dt.max(0.0001);
self.apply_momentum(vx * dt, vy * dt, physics);
let decay = friction.powf(dt / (1.0 / 60.0));
let (nvx, nvy) = (vx * decay, vy * decay);
self.velocity.set(if nvx.abs() < COAST_STOP_THRESHOLD && nvy.abs() < COAST_STOP_THRESHOLD { [0.0, 0.0] } else { [nvx, nvy] });
return true;
}
if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
return self.settle_bounce(spring_stiffness, dt);
}
false
}
pub fn stop_coasting(&self) {
self.velocity.set([0.0, 0.0]);
self.scroll_by(0.0, 0.0);
}
pub fn apply_momentum(&self, dx: f32, dy: f32, physics: ScrollPhysics) {
let [ox, oy] = self.offset.get();
let [cw, ch] = self.content_size.get();
let [vw, vh] = self.viewport_size.get();
let max_x = (cw - vw).max(0.0);
let max_y = (ch - vh).max(0.0);
match physics {
ScrollPhysics::Bounce { .. } => {
let nx = bounce_axis(ox, dx, max_x);
let ny = bounce_axis(oy, dy, max_y);
self.offset.set([nx, ny]);
}
_ => {
let nx = (ox + dx).clamp(0.0, max_x);
let ny = (oy + dy).clamp(0.0, max_y);
self.offset.set([nx, ny]);
}
}
}
pub fn try_apply_delta(&self, dx: f32, dy: f32, physics: ScrollPhysics) -> bool {
let before = self.offset.get();
self.apply_momentum(dx, dy, physics);
self.offset.get() != before
}
pub fn settle_bounce(&self, spring_stiffness: f32, dt: f32) -> bool {
let [ox, oy] = self.offset.get();
let [cw, ch] = self.content_size.get();
let [vw, vh] = self.viewport_size.get();
let max_x = (cw - vw).max(0.0);
let max_y = (ch - vh).max(0.0);
let target_x = ox.clamp(0.0, max_x);
let target_y = oy.clamp(0.0, max_y);
if (ox - target_x).abs() < 0.5 && (oy - target_y).abs() < 0.5 {
if ox != target_x || oy != target_y {
self.offset.set([target_x, target_y]);
}
return false;
}
let alpha = 1.0 - (-dt * spring_stiffness).exp();
let nx = ox + (target_x - ox) * alpha;
let ny = oy + (target_y - oy) * alpha;
self.offset.set([nx, ny]);
true
}
}
const MAX_OVERSCROLL: f32 = 120.0;
fn bounce_axis(offset: f32, delta: f32, max: f32) -> f32 {
let overscroll = |o: f32| if o < 0.0 { o } else if o > max { o - max } else { 0.0 };
let before = overscroll(offset);
let raw_next = offset + delta;
let after_raw = overscroll(raw_next);
let next = if after_raw.abs() > before.abs() {
offset + delta * 0.35
} else {
raw_next
};
next.clamp(-MAX_OVERSCROLL, max + MAX_OVERSCROLL)
}
impl Default for ScrollController {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn controller_with_size(content_w: f32, content_h: f32, vp_w: f32, vp_h: f32) -> ScrollController {
let c = ScrollController::new();
c.content_size.set([content_w, content_h]);
c.viewport_size.set([vp_w, vp_h]);
c
}
#[test]
fn scroll_by_clamps_to_bounds() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(9999.0, 9999.0);
let [x, y] = c.offset();
assert_eq!(x, 200.0); assert_eq!(y, 400.0); }
#[test]
fn scroll_by_negative_clamps_to_zero() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(100.0, 100.0);
c.scroll_by(-9999.0, -9999.0);
let [x, y] = c.offset();
assert_eq!(x, 0.0);
assert_eq!(y, 0.0);
}
#[test]
fn scroll_to_top_sets_y_to_zero() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(50.0, 200.0);
c.scroll_to_top();
let [_x, y] = c.offset();
assert_eq!(y, 0.0);
}
#[test]
fn scroll_to_bottom_sets_y_to_max() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_to_bottom();
let [_x, y] = c.offset();
assert_eq!(y, 400.0); }
#[test]
fn save_and_restore_position() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(50.0, 100.0);
let pos = c.save_position();
c.scroll_by(50.0, 100.0);
c.restore_position(pos);
assert_eq!(c.offset(), [50.0, 100.0]);
}
#[test]
fn drag_delta_is_zero_on_first_call_then_real_deltas_after() {
let c = ScrollController::new();
assert_eq!(c.drag_delta(100.0, 50.0), (0.0, 0.0));
assert_eq!(c.drag_delta(110.0, 45.0), (10.0, -5.0));
assert_eq!(c.drag_delta(90.0, 45.0), (-20.0, 0.0));
}
#[test]
fn click_jitter_within_slop_never_pans() {
let c = ScrollController::new();
assert_eq!(c.drag_delta(100.0, 100.0), (0.0, 0.0)); assert_eq!(c.drag_delta(102.0, 101.0), (0.0, 0.0)); assert_eq!(c.drag_delta(99.0, 100.0), (0.0, 0.0)); assert_eq!(c.drag_delta(120.0, 100.0), (21.0, 0.0));
assert_eq!(c.drag_delta(125.0, 104.0), (5.0, 4.0));
}
#[test]
fn end_drag_resets_so_the_next_drag_starts_fresh() {
let c = ScrollController::new();
c.drag_delta(100.0, 100.0);
c.end_drag();
assert_eq!(c.drag_delta(150.0, 120.0), (0.0, 0.0));
}
#[test]
fn track_velocity_reflects_the_real_offset_speed() {
let c = controller_with_size(500.0, 2000.0, 300.0, 400.0);
c.scroll_by(0.0, 100.0);
c.track_velocity(0.5); assert_eq!(c.velocity(), [0.0, 200.0]);
}
#[test]
fn was_pressed_round_trips() {
let c = ScrollController::new();
assert!(!c.was_pressed());
c.set_was_pressed(true);
assert!(c.was_pressed());
}
#[test]
fn try_apply_delta_reports_true_while_room_remains() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
let moved = c.try_apply_delta(0.0, 50.0, ScrollPhysics::Momentum { friction: 0.92 });
assert!(moved, "there's 400px of room (max_y=400), a 50px step must move it");
assert_eq!(c.offset(), [0.0, 50.0]);
}
#[test]
fn try_apply_delta_reports_false_once_hard_clamped_and_exhausted() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(0.0, 400.0); let moved = c.try_apply_delta(0.0, 50.0, ScrollPhysics::Momentum { friction: 0.92 });
assert!(!moved, "already at the hard bound with no Bounce give — nothing left to absorb");
assert_eq!(c.offset(), [0.0, 400.0], "the declined delta must not have been applied");
}
#[test]
fn try_apply_delta_still_reports_true_for_resisted_bounce_overscroll() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.scroll_by(0.0, 400.0); let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
let moved = c.try_apply_delta(0.0, 50.0, physics);
assert!(moved, "Bounce still has overscroll room even at the hard bound — must consume it");
assert!(c.offset()[1] > 400.0, "must have stretched past the hard bound, got {:?}", c.offset());
}
#[test]
fn try_apply_delta_reports_false_once_bounce_overscroll_is_also_maxed_out() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
for _ in 0..50 {
c.try_apply_delta(0.0, 500.0, physics);
}
let before = c.offset();
let moved = c.try_apply_delta(0.0, 500.0, physics);
assert!(!moved, "fully stretched to MAX_OVERSCROLL — genuinely exhausted, must decline");
assert_eq!(c.offset(), before);
}
#[test]
fn apply_momentum_hard_clamps_under_momentum_physics() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.apply_momentum(9999.0, 9999.0, ScrollPhysics::Momentum { friction: 0.92 });
assert_eq!(c.offset(), [200.0, 400.0]); }
#[test]
fn apply_momentum_allows_resisted_overscroll_under_bounce() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
c.apply_momentum(0.0, -40.0, physics); let [_, y] = c.offset();
assert!(y < 0.0, "overscroll must go negative under Bounce, got {y}");
assert_eq!(y, -14.0, "resisted to 35% of the raw delta"); }
#[test]
fn apply_momentum_moving_back_toward_bounds_is_not_resisted() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
c.apply_momentum(0.0, -40.0, physics); c.apply_momentum(0.0, 14.0, physics); let [_, y] = c.offset();
assert!((y - 0.0).abs() < 0.01, "expected to land back at 0, got {y}");
}
#[test]
fn settle_bounce_eases_an_overscrolled_offset_back_to_the_bound() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.offset.set([0.0, -20.0]); let mut still_settling = true;
for _ in 0..200 {
still_settling = c.settle_bounce(12.0, 0.05);
if !still_settling {
break;
}
}
assert!(!still_settling, "must eventually settle");
assert_eq!(c.offset(), [0.0, 0.0]);
}
#[test]
fn settle_bounce_is_a_no_op_when_already_in_bounds() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.offset.set([50.0, 100.0]);
assert!(!c.settle_bounce(12.0, 0.05));
assert_eq!(c.offset(), [50.0, 100.0]);
}
#[test]
fn coast_springs_back_immediately_when_already_overscrolled_under_bounce_not_after_velocity_decays() {
let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
c.offset.set([0.0, -60.0]); c.set_velocity([0.0, -400.0]); let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
let still_active = c.coast(physics, 1.0 / 60.0);
assert!(still_active, "must still be settling, not yet at rest");
let [_, y] = c.offset();
assert!(
y > -60.0,
"must have started easing back toward the bound on the VERY FIRST call, not stayed frozen at -60 while velocity decays: got {y}"
);
assert_eq!(c.velocity(), [0.0, 0.0], "velocity is superseded by spring recovery once overscrolled");
}
#[test]
fn set_velocity_clamps_to_max_velocity() {
let c = ScrollController::new();
c.set_velocity([0.0, 100_000.0]);
assert_eq!(c.velocity(), [0.0, MAX_VELOCITY]);
c.set_velocity([0.0, -100_000.0]);
assert_eq!(c.velocity(), [0.0, -MAX_VELOCITY]);
}
#[test]
fn track_velocity_clamps_to_max_velocity() {
let c = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
c.scroll_by(0.0, 10_000.0); c.track_velocity(1.0 / 60.0); assert_eq!(c.velocity(), [0.0, MAX_VELOCITY]);
}
#[test]
fn full_realistic_velocity_range_settles_within_under_a_second() {
let physics = ScrollPhysics::Momentum { friction: 0.88 };
for v0 in [200.0, 800.0, 2500.0, 100_000.0] {
let c = controller_with_size(500.0, 1_000_000.0, 300.0, 400.0);
c.set_velocity([0.0, v0]);
let mut elapsed = 0.0;
let dt = 1.0 / 60.0;
while c.coast(physics, dt) && elapsed < 5.0 {
elapsed += dt;
}
assert!(elapsed < 1.0, "v0={v0} took {elapsed:.2}s to settle, expected well under 1s");
}
}
#[test]
fn coast_applies_a_dt_scaled_step_not_the_raw_px_per_second_value() {
let c = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
c.set_velocity([0.0, 800.0]); let dt = 1.0 / 60.0; c.coast(ScrollPhysics::Momentum { friction: 0.92 }, dt);
let [_, y] = c.offset();
assert!(y < 50.0, "one frame of coast at 800px/s, dt=1/60 must move roughly 13px, not the raw velocity, got {y}");
assert!(y > 0.0, "must still move forward some real amount, got {y}");
}
#[test]
fn coast_velocity_decay_is_dt_independent_over_a_fixed_time_span() {
let physics = ScrollPhysics::Momentum { friction: 0.92 };
let coarse = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
coarse.set_velocity([0.0, 600.0]);
for _ in 0..30 {
coarse.coast(physics, 1.0 / 30.0); }
let fine = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
fine.set_velocity([0.0, 600.0]);
for _ in 0..60 {
fine.coast(physics, 1.0 / 60.0); }
let [_, y_coarse] = coarse.offset();
let [_, y_fine] = fine.offset();
let diff = (y_coarse - y_fine).abs();
assert!(
diff < y_coarse.max(y_fine) * 0.15,
"total coast distance over the same real time must be roughly frame-rate independent: coarse={y_coarse} fine={y_fine}"
);
}
#[test]
fn wheel_recently_active_is_true_immediately_after_marking_then_false_once_the_grace_period_elapses() {
let c = ScrollController::new();
assert!(!c.wheel_recently_active(), "nothing marked yet");
c.mark_wheel_active();
assert!(c.wheel_recently_active(), "must report recently active right after marking");
for _ in 0..20 {
c.advance_wheel_idle(WHEEL_IDLE_GRACE / 10.0);
}
assert!(!c.wheel_recently_active(), "must go stale once the grace period has elapsed");
}
#[test]
fn wheel_recently_active_survives_a_gap_shorter_than_the_grace_period() {
let c = ScrollController::new();
c.mark_wheel_active();
c.advance_wheel_idle(WHEEL_IDLE_GRACE * 0.3); assert!(c.wheel_recently_active(), "a short gap within the grace period must not reset activity");
}
}