Skip to main content

rosace_scroll/
physics.rs

1/// The axis or axes along which a [`ScrollView`] scrolls.
2#[derive(Debug, Clone, Copy, PartialEq)]
3pub enum ScrollDirection {
4    Vertical,
5    Horizontal,
6    Both,
7}
8
9/// Physics model that governs how a [`ScrollView`] responds to input and decelerates.
10#[derive(Debug, Clone, Copy)]
11pub enum ScrollPhysics {
12    /// Natural momentum with friction decay. `friction` in (0.0, 1.0) — 0.92 is a natural feel.
13    Momentum { friction: f32 },
14    /// Stops immediately on release.
15    Clamped,
16    /// Snaps to page boundaries.
17    Paged { page_size: f32 },
18    /// Momentum with rubber-band overscroll (D108/Phase 26 Step 2) — content
19    /// can be dragged past its edge (resisted) and springs back once
20    /// released, the iOS scroll feel. `friction` decays velocity same as
21    /// [`ScrollPhysics::Momentum`]; `spring_stiffness` governs how quickly
22    /// an out-of-bounds offset eases back to the nearest bound once
23    /// velocity has settled (see `ScrollController::settle_bounce`) — an
24    /// exponential ease, the same shape `PaintCtx::animate_to` already uses
25    /// elsewhere in this codebase, not a full mass-spring simulation.
26    Bounce { friction: f32, spring_stiffness: f32 },
27}
28
29impl Default for ScrollPhysics {
30    fn default() -> Self {
31        ScrollPhysics::Momentum { friction: 0.92 }
32    }
33}
34
35/// Per-widget-type default physics, keyed by platform (D108/Phase 26 Step 2).
36/// This is the ONLY place platform is consulted for scroll behavior — one
37/// pure lookup, never branches scattered through widget code — and it is
38/// always the lowest-priority source: an app's own theme `ext` value or an
39/// explicit `.physics(...)` on a `ScrollView` both override it. See
40/// `rosace-widgets/src/tree/scroll_view.rs`'s `resolve_physics`.
41#[derive(Debug, Clone, Copy)]
42pub struct ScrollStyle {
43    pub physics: ScrollPhysics,
44}
45
46impl ScrollStyle {
47    /// iOS/macOS default to rubber-band `Bounce` (the platform-native feel);
48    /// every other platform defaults to plain `Momentum`. Android's overscroll
49    /// "glow" is a separate visual effect on similar physics, not modeled
50    /// here — out of scope (see `.steering/PHASE_26.md`).
51    pub fn default_for_platform(platform: rosace_core::Platform) -> ScrollPhysics {
52        // friction=0.88 (not the earlier 0.92) — 0.92 measured out to a
53        // 1.2s-1.9s coast tail even for realistic release speeds (confirmed
54        // by direct calculation during real trackpad testing), which read
55        // as sluggish/stuck rather than a natural decelerating glide.
56        // Combined with `COAST_STOP_THRESHOLD`/`MAX_VELOCITY`, 0.88 brings
57        // the full realistic range down to ~0.35s-0.7s.
58        match platform {
59            rosace_core::Platform::Ios | rosace_core::Platform::MacOs => {
60                ScrollPhysics::Bounce { friction: 0.88, spring_stiffness: 12.0 }
61            }
62            _ => ScrollPhysics::Momentum { friction: 0.88 },
63        }
64    }
65}
66
67/// Per-frame simulation state for momentum scrolling.
68pub struct MomentumState {
69    pub velocity_x: f32,
70    pub velocity_y: f32,
71}
72
73impl MomentumState {
74    pub fn new() -> Self {
75        Self {
76            velocity_x: 0.0,
77            velocity_y: 0.0,
78        }
79    }
80
81    /// Apply a drag delta to velocity (called on pointer move).
82    pub fn push(&mut self, dx: f32, dy: f32) {
83        self.velocity_x = dx;
84        self.velocity_y = dy;
85    }
86
87    /// Advance momentum simulation by one frame. Returns `(dx, dy)` to apply to offset.
88    pub fn tick(&mut self, physics: ScrollPhysics) -> (f32, f32) {
89        match physics {
90            ScrollPhysics::Clamped => {
91                let out = (self.velocity_x, self.velocity_y);
92                self.velocity_x = 0.0;
93                self.velocity_y = 0.0;
94                out
95            }
96            ScrollPhysics::Momentum { friction } | ScrollPhysics::Bounce { friction, .. } => {
97                let out = (self.velocity_x, self.velocity_y);
98                self.velocity_x *= friction;
99                self.velocity_y *= friction;
100                // Stop tiny residual motion.
101                if self.velocity_x.abs() < 0.5 {
102                    self.velocity_x = 0.0;
103                }
104                if self.velocity_y.abs() < 0.5 {
105                    self.velocity_y = 0.0;
106                }
107                out
108            }
109            ScrollPhysics::Paged { page_size: _ } => {
110                // Snap: return remaining distance toward nearest page boundary.
111                let out = (self.velocity_x, self.velocity_y);
112                self.velocity_x = 0.0;
113                self.velocity_y = 0.0;
114                out
115            }
116        }
117    }
118
119    /// Returns `true` when both velocity components are below the stop threshold.
120    pub fn is_settled(&self) -> bool {
121        self.velocity_x.abs() < 0.5 && self.velocity_y.abs() < 0.5
122    }
123}
124
125impl Default for MomentumState {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Clamp an offset so it stays within valid scroll bounds.
132pub fn clamp_offset(offset: [f32; 2], content: [f32; 2], viewport: [f32; 2]) -> [f32; 2] {
133    let max_x = (content[0] - viewport[0]).max(0.0);
134    let max_y = (content[1] - viewport[1]).max(0.0);
135    [offset[0].clamp(0.0, max_x), offset[1].clamp(0.0, max_y)]
136}
137
138/// Snap `offset` to the nearest multiple of `page_size`.
139pub fn snap_to_page(offset: f32, page_size: f32) -> f32 {
140    (offset / page_size).round() * page_size
141}
142
143// ---------------------------------------------------------------------------
144// Tests
145// ---------------------------------------------------------------------------
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn clamp_offset_returns_zero_when_content_fits_viewport() {
152        let result = clamp_offset([5.0, 10.0], [100.0, 200.0], [150.0, 250.0]);
153        assert_eq!(result, [0.0, 0.0]);
154    }
155
156    #[test]
157    fn clamp_offset_keeps_offset_within_bounds() {
158        // content 500x800, viewport 300x400 → max_x=200, max_y=400
159        let result = clamp_offset([250.0, 450.0], [500.0, 800.0], [300.0, 400.0]);
160        assert_eq!(result[0], 200.0);
161        assert_eq!(result[1], 400.0);
162    }
163
164    #[test]
165    fn clamp_offset_allows_valid_offset() {
166        let result = clamp_offset([50.0, 100.0], [500.0, 800.0], [300.0, 400.0]);
167        assert_eq!(result[0], 50.0);
168        assert_eq!(result[1], 100.0);
169    }
170
171    #[test]
172    fn snap_to_page_rounds_to_nearest_boundary() {
173        assert_eq!(snap_to_page(260.0, 200.0), 200.0);
174        assert_eq!(snap_to_page(350.0, 200.0), 400.0);
175        assert_eq!(snap_to_page(0.0, 200.0), 0.0);
176    }
177
178    #[test]
179    fn momentum_state_tick_clamped_zeroes_velocity() {
180        let mut state = MomentumState::new();
181        state.push(50.0, 30.0);
182        let (dx, dy) = state.tick(ScrollPhysics::Clamped);
183        assert_eq!(dx, 50.0);
184        assert_eq!(dy, 30.0);
185        assert_eq!(state.velocity_x, 0.0);
186        assert_eq!(state.velocity_y, 0.0);
187    }
188
189    #[test]
190    fn momentum_state_tick_momentum_decays_velocity() {
191        let mut state = MomentumState::new();
192        state.push(100.0, 80.0);
193        state.tick(ScrollPhysics::Momentum { friction: 0.92 });
194        assert!((state.velocity_x - 92.0).abs() < 0.01);
195        assert!((state.velocity_y - 73.6).abs() < 0.01);
196    }
197
198    #[test]
199    fn momentum_state_tick_momentum_stops_tiny_residual() {
200        let mut state = MomentumState::new();
201        state.velocity_x = 0.3;
202        state.velocity_y = 0.4;
203        state.tick(ScrollPhysics::Momentum { friction: 0.92 });
204        assert_eq!(state.velocity_x, 0.0);
205        assert_eq!(state.velocity_y, 0.0);
206    }
207
208    #[test]
209    fn momentum_state_is_settled_when_both_below_threshold() {
210        let mut state = MomentumState::new();
211        assert!(state.is_settled());
212        state.push(10.0, 5.0);
213        assert!(!state.is_settled());
214        state.velocity_x = 0.4;
215        state.velocity_y = 0.4;
216        assert!(state.is_settled());
217    }
218
219    #[test]
220    fn momentum_state_tick_bounce_decays_velocity_same_as_momentum() {
221        let mut state = MomentumState::new();
222        state.push(100.0, 80.0);
223        state.tick(ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 });
224        assert!((state.velocity_x - 92.0).abs() < 0.01);
225        assert!((state.velocity_y - 73.6).abs() < 0.01);
226    }
227
228    #[test]
229    fn default_for_platform_is_bounce_on_ios_and_macos() {
230        assert!(matches!(
231            ScrollStyle::default_for_platform(rosace_core::Platform::Ios),
232            ScrollPhysics::Bounce { .. }
233        ));
234        assert!(matches!(
235            ScrollStyle::default_for_platform(rosace_core::Platform::MacOs),
236            ScrollPhysics::Bounce { .. }
237        ));
238    }
239
240    #[test]
241    fn default_for_platform_is_momentum_elsewhere() {
242        for p in [
243            rosace_core::Platform::Android,
244            rosace_core::Platform::Windows,
245            rosace_core::Platform::Linux,
246            rosace_core::Platform::Web,
247        ] {
248            assert!(matches!(ScrollStyle::default_for_platform(p), ScrollPhysics::Momentum { .. }));
249        }
250    }
251}