git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/scroll_motion.rs (24.6K)

  1 //! Smooth scrolling — the one place wheel/trackpad deltas turn into an
  2 //! animated scroll offset, shared by every scrolling widget and available to
  3 //! apps that own their offsets themselves.
  4 //!
  5 //! Three input regimes, decided by [`ScrollPhase`] (which the runner sets from
  6 //! the Wayland `axis_source` / `axis_stop` events before each dispatch):
  7 //!
  8 //! - **Wheel** (discrete clicks, `LineDelta`): each notch moves the *target*;
  9 //!   the drawn offset eases toward it with a frame-rate-independent
 10 //!   exponential approach. Rapid notches accumulate into one glide instead of
 11 //!   a staircase.
 12 //! - **Finger** (trackpad, `PixelDelta` with a finger/continuous source): the
 13 //!   offset follows the gesture 1:1 — nothing is smoother than the hand — while
 14 //!   a velocity estimate is kept.
 15 //! - **FingerEnd** (`axis_stop`, the finger lift): the estimated velocity
 16 //!   carries the offset on, decaying under friction, so a flick coasts.
 17 //!
 18 //! The model is one [`ScrollAxis`] per direction, paired as a
 19 //! [`ScrollMotion`]. A host keeps its existing `scroll_y: f32` field as the
 20 //! *drawn* offset and lets the motion drive it: feed events with
 21 //! [`ScrollMotion::apply`], advance with [`ScrollMotion::tick`] once per
 22 //! frame, and copy [`ScrollAxis::pos`] out. Hosts that also write the field
 23 //! directly (drag, keyboard, auto-snap to a selection) call
 24 //! [`ScrollMotion::reconcile`] first so the motion adopts the external write
 25 //! instead of fighting it.
 26 //!
 27 //! Everything is pure time-based math — no clock, GPU, or loop — except the
 28 //! finger-velocity estimate, which timestamps events with `Instant`.
 29 //!
 30 //! Tunables come from `input.kdl` (`<app>` domain, then `cce-ui`):
 31 //!
 32 //! ```text
 33 //! cce-ui {
 34 //!     input {
 35 //!         smooth_scroll true      // wheel notches glide (false = instant)
 36 //!         scroll_ease 12.0        // wheel glide rate, 1/s (higher = snappier)
 37 //!         kinetic_scroll true     // trackpad flicks coast after the lift
 38 //!         scroll_friction 6.0     // coast decay, 1/s (higher = shorter coast)
 39 //!     }
 40 //! }
 41 //! ```
 42 
 43 use std::sync::atomic::{AtomicU8, Ordering};
 44 use std::time::Instant;
 45 
 46 use crate::widget::MouseScrollDelta;
 47 
 48 /// Which stage of a scroll gesture the current wheel event belongs to. The
 49 /// runner sets this from the Wayland axis source/stop before dispatching;
 50 /// consumers read it through [`current_scroll_phase`].
 51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 52 pub enum ScrollPhase {
 53     /// A discrete wheel click (or a synthesized delta with no gesture).
 54     Wheel,
 55     /// Continuous finger/trackpad motion; the gesture is still in progress.
 56     Finger,
 57     /// The finger lifted (`axis_stop`) — the event carries no delta.
 58     FingerEnd,
 59 }
 60 
 61 static PHASE: AtomicU8 = AtomicU8::new(0);
 62 
 63 /// Publish the phase of the wheel event about to be dispatched. Runner-side.
 64 pub fn set_scroll_phase(phase: ScrollPhase) {
 65     PHASE.store(phase as u8, Ordering::Relaxed);
 66 }
 67 
 68 /// The phase of the wheel event currently being dispatched. Outside a
 69 /// dispatch it reports the last one, which only matters for hosts that
 70 /// synthesize their own wheel events (they get `Wheel` semantics unless a
 71 /// real gesture is mid-flight).
 72 pub fn current_scroll_phase() -> ScrollPhase {
 73     match PHASE.load(Ordering::Relaxed) {
 74         1 => ScrollPhase::Finger,
 75         2 => ScrollPhase::FingerEnd,
 76         _ => ScrollPhase::Wheel,
 77     }
 78 }
 79 
 80 /// Pixels one wheel notch moves a list — the toolkit's line unit, shared so
 81 /// every scrolling host steps the same distance per click.
 82 pub const LINE_PX: f32 = 24.0;
 83 
 84 /// Exponential-approach convergence: the drawn offset snaps to its target
 85 /// once within this many pixels.
 86 const SNAP_PX: f32 = 0.5;
 87 /// A coast below this speed (px/s) stops.
 88 const COAST_STOP_SPEED: f32 = 5.0;
 89 /// A finger held still this long (seconds) before lifting yields no fling.
 90 const FLING_STALE_S: f32 = 0.08;
 91 /// Velocity-estimate blend per finger event (new sample weight).
 92 const VEL_BLEND: f32 = 0.35;
 93 
 94 /// The process-wide smooth-scroll tunables, resolved once from `input.kdl`.
 95 #[derive(Debug, Clone, Copy, PartialEq)]
 96 pub struct ScrollSettings {
 97     /// Wheel notches glide toward their target (false = the legacy jump).
 98     pub smooth: bool,
 99     /// Wheel glide rate, 1/s. 12 reaches 95% of a notch in ~250ms.
100     pub ease_rate: f32,
101     /// Trackpad flicks coast after the lift.
102     pub kinetic: bool,
103     /// Coast decay, 1/s. 6 halves the speed every ~115ms.
104     pub friction: f32,
105 }
106 
107 impl Default for ScrollSettings {
108     fn default() -> Self {
109         Self { smooth: true, ease_rate: 12.0, kinetic: true, friction: 6.0 }
110     }
111 }
112 
113 static SETTINGS: std::sync::OnceLock<ScrollSettings> = std::sync::OnceLock::new();
114 
115 /// This app's effective smooth-scroll settings (`<app>` → `cce-ui` → defaults).
116 /// With animations off ([`crate::motion`]) a wheel notch jumps and a flick
117 /// stops at the lift — the legacy behavior — whatever input.kdl says; that
118 /// is checked per call, so it follows the switch while the app runs.
119 pub fn scroll_settings() -> ScrollSettings {
120     let configured = configured_scroll_settings();
121     if crate::motion::enabled() {
122         configured
123     } else {
124         ScrollSettings { smooth: false, kinetic: false, ..configured }
125     }
126 }
127 
128 fn configured_scroll_settings() -> ScrollSettings {
129     *SETTINGS.get_or_init(|| {
130         let input = crate::input::cached();
131         let app = crate::config::get_app_name().unwrap_or_default();
132         let d = ScrollSettings::default();
133         let flag = |key: &str, default: bool| {
134             input
135                 .resolve_setting(&app, "", key)
136                 .and_then(crate::input::SettingValue::as_bool)
137                 .unwrap_or(default)
138         };
139         let rate = |key: &str, default: f32| {
140             input
141                 .resolve_setting(&app, "", key)
142                 .and_then(crate::input::SettingValue::as_f64)
143                 .map(|v| v as f32)
144                 .filter(|v| v.is_finite() && *v > 0.0)
145                 .unwrap_or(default)
146         };
147         ScrollSettings {
148             smooth: flag("smooth_scroll", d.smooth),
149             ease_rate: rate("scroll_ease", d.ease_rate),
150             kinetic: flag("kinetic_scroll", d.kinetic),
151             friction: rate("scroll_friction", d.friction),
152         }
153     })
154 }
155 
156 /// The range an axis may occupy. Lists are `0..=max_scroll`; a canvas that
157 /// pans freely is [`Bounds::UNBOUNDED`].
158 #[derive(Debug, Clone, Copy, PartialEq)]
159 pub struct Bounds {
160     pub lo: f32,
161     pub hi: f32,
162 }
163 
164 impl Bounds {
165     pub const UNBOUNDED: Bounds = Bounds { lo: f32::NEG_INFINITY, hi: f32::INFINITY };
166 
167     /// `0..=max`, with a negative `max` (content shorter than the viewport)
168     /// collapsing to `0..=0`.
169     pub fn max(max: f32) -> Bounds {
170         Bounds { lo: 0.0, hi: max.max(0.0) }
171     }
172 
173     pub fn clamp(&self, v: f32) -> f32 {
174         v.clamp(self.lo, self.hi)
175     }
176 }
177 
178 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
179 enum Mode {
180     Idle,
181     /// Wheel glide: `pos` chases `target`.
182     Easing,
183     /// Finger down: `pos` is the gesture, `vel` is being estimated.
184     Tracking,
185     /// Finger lifted: `pos` integrates `vel` under friction.
186     Coasting,
187 }
188 
189 /// One scroll direction: the drawn offset, where it is heading, and how fast.
190 #[derive(Debug, Clone, Copy)]
191 pub struct ScrollAxis {
192     pos: f32,
193     target: f32,
194     vel: f32,
195     mode: Mode,
196 }
197 
198 impl Default for ScrollAxis {
199     fn default() -> Self {
200         Self::new(0.0)
201     }
202 }
203 
204 impl ScrollAxis {
205     pub fn new(pos: f32) -> Self {
206         Self { pos, target: pos, vel: 0.0, mode: Mode::Idle }
207     }
208 
209     /// The offset to draw at this frame.
210     pub fn pos(&self) -> f32 {
211         self.pos
212     }
213 
214     /// Where the offset is heading (equals `pos` unless a wheel glide is in
215     /// flight). Hosts that virtualize rows may prefetch toward this.
216     pub fn target(&self) -> f32 {
217         self.target
218     }
219 
220     /// Current speed in px/s (finger estimate while tracking, coast speed after).
221     pub fn velocity(&self) -> f32 {
222         self.vel
223     }
224 
225     /// Whether `tick` will still move the offset.
226     pub fn is_animating(&self) -> bool {
227         matches!(self.mode, Mode::Easing | Mode::Coasting)
228     }
229 
230     /// Snap to `pos` and cancel any motion.
231     pub fn jump_to(&mut self, pos: f32) {
232         self.pos = pos;
233         self.target = pos;
234         self.vel = 0.0;
235         self.mode = Mode::Idle;
236     }
237 
238     /// Adopt a host-side write to the drawn offset (a scrollbar drag, an
239     /// auto-snap to a selection): if the host's value differs from ours, the
240     /// host moved it and any motion in flight is abandoned.
241     pub fn reconcile(&mut self, host_pos: f32) {
242         if (host_pos - self.pos).abs() > 1e-3 {
243             self.jump_to(host_pos);
244         }
245     }
246 
247     /// Re-clamp after the content or viewport changed size.
248     pub fn set_bounds(&mut self, b: Bounds) {
249         let p = b.clamp(self.pos);
250         let t = b.clamp(self.target);
251         if p != self.pos || t != self.target {
252             self.pos = p;
253             self.target = t;
254             if p == t && self.mode == Mode::Easing {
255                 self.mode = Mode::Idle;
256             }
257         }
258     }
259 
260     /// A wheel notch worth `delta` pixels: move the target and glide there
261     /// (or jump, with smoothing off). Returns whether anything will move.
262     pub fn wheel(&mut self, delta: f32, b: Bounds, s: &ScrollSettings) -> bool {
263         if delta == 0.0 {
264             return false;
265         }
266         // A wheel click during a coast redirects it rather than adding to a
267         // fling the user has visibly abandoned.
268         if self.mode == Mode::Coasting {
269             self.vel = 0.0;
270             self.target = self.pos;
271         }
272         let new_target = b.clamp(self.target + delta);
273         if (new_target - self.target).abs() < 1e-3 {
274             // Already heading there (or pinned at the bound): nothing new moves.
275             return false;
276         }
277         self.target = new_target;
278         if s.smooth {
279             self.mode = Mode::Easing;
280         } else {
281             self.pos = new_target;
282             self.mode = Mode::Idle;
283         }
284         true
285     }
286 
287     /// Finger motion worth `delta` pixels, `dt` seconds after the previous
288     /// finger event: the offset follows 1:1 and the velocity estimate blends
289     /// in this sample. Returns whether the offset moved.
290     pub fn finger(&mut self, delta: f32, dt: f32, b: Bounds) -> bool {
291         let old = self.pos;
292         let new_pos = b.clamp(self.pos + delta);
293         self.pos = new_pos;
294         self.target = new_pos;
295         self.mode = Mode::Tracking;
296         let applied = new_pos - old;
297         let sample = applied / dt.clamp(0.004, 0.1);
298         self.vel = if applied == 0.0 && delta != 0.0 {
299             // Pinned against a bound: no fling into the wall.
300             0.0
301         } else {
302             self.vel * (1.0 - VEL_BLEND) + sample * VEL_BLEND
303         };
304         (self.pos - old).abs() > 1e-3
305     }
306 
307     /// The finger lifted `since_last` seconds after its last motion: coast on
308     /// the estimated velocity (or stop dead, with kinetic scrolling off or a
309     /// finger that had come to rest). Returns whether a coast started.
310     pub fn finger_end(&mut self, since_last: f32, s: &ScrollSettings) -> bool {
311         if self.mode != Mode::Tracking {
312             return false;
313         }
314         if !s.kinetic || since_last > FLING_STALE_S || self.vel.abs() < COAST_STOP_SPEED {
315             self.vel = 0.0;
316             self.mode = Mode::Idle;
317             return false;
318         }
319         self.mode = Mode::Coasting;
320         true
321     }
322 
323     /// Glide to an absolute offset (keyboard paging, "scroll to selection").
324     /// Returns whether anything will move.
325     pub fn scroll_to(&mut self, target: f32, b: Bounds, s: &ScrollSettings) -> bool {
326         let t = b.clamp(target);
327         if (t - self.pos).abs() < 1e-3 && (t - self.target).abs() < 1e-3 {
328             return false;
329         }
330         self.vel = 0.0;
331         self.target = t;
332         if s.smooth {
333             self.mode = Mode::Easing;
334         } else {
335             self.pos = t;
336             self.mode = Mode::Idle;
337         }
338         true
339     }
340 
341     /// Advance `dt` seconds. Returns whether the drawn offset changed — the
342     /// host's repaint signal; check [`Self::is_animating`] to keep frames
343     /// coming.
344     pub fn tick(&mut self, dt: f32, b: Bounds, s: &ScrollSettings) -> bool {
345         let old = self.pos;
346         match self.mode {
347             Mode::Idle | Mode::Tracking => return false,
348             // Settings that forbid the motion already in flight (animations
349             // switched off mid-glide) land it rather than finish it.
350             Mode::Easing if !s.smooth => {
351                 self.pos = self.target;
352                 self.mode = Mode::Idle;
353             }
354             Mode::Coasting if !s.kinetic => {
355                 self.vel = 0.0;
356                 self.target = self.pos;
357                 self.mode = Mode::Idle;
358             }
359             Mode::Easing => {
360                 let remaining = self.target - self.pos;
361                 if remaining.abs() <= SNAP_PX {
362                     self.pos = self.target;
363                     self.mode = Mode::Idle;
364                 } else {
365                     // Frame-rate independent: the same fraction of the remaining
366                     // distance per unit time whatever the frame pacing.
367                     self.pos += remaining * (1.0 - (-s.ease_rate * dt).exp());
368                 }
369             }
370             Mode::Coasting => {
371                 let p = b.clamp(self.pos + self.vel * dt);
372                 self.pos = p;
373                 self.target = p;
374                 self.vel *= (-s.friction * dt).exp();
375                 if p == b.lo || p == b.hi || self.vel.abs() < COAST_STOP_SPEED {
376                     self.vel = 0.0;
377                     self.mode = Mode::Idle;
378                 }
379             }
380         }
381         (self.pos - old).abs() > 1e-4
382     }
383 }
384 
385 /// A two-axis scroll offset with the event-to-motion mapping shared by every
386 /// host: `LineDelta` notches scale by the line unit, `PixelDelta`s are pixels,
387 /// and the phase decides wheel-glide vs finger-track vs fling.
388 #[derive(Debug, Clone, Copy)]
389 pub struct ScrollMotion {
390     pub x: ScrollAxis,
391     pub y: ScrollAxis,
392     /// Timestamp of the last finger event, for the velocity estimate and the
393     /// stale-fling check.
394     last_finger: Option<Instant>,
395 }
396 
397 impl Default for ScrollMotion {
398     fn default() -> Self {
399         Self::new()
400     }
401 }
402 
403 impl ScrollMotion {
404     pub fn new() -> Self {
405         Self { x: ScrollAxis::default(), y: ScrollAxis::default(), last_finger: None }
406     }
407 
408     pub fn at(x: f32, y: f32) -> Self {
409         Self { x: ScrollAxis::new(x), y: ScrollAxis::new(y), last_finger: None }
410     }
411 
412     pub fn is_animating(&self) -> bool {
413         self.x.is_animating() || self.y.is_animating()
414     }
415 
416     /// Adopt host-side writes to both drawn offsets (see [`ScrollAxis::reconcile`]).
417     pub fn reconcile(&mut self, x: f32, y: f32) {
418         self.x.reconcile(x);
419         self.y.reconcile(y);
420     }
421 
422     pub fn set_bounds(&mut self, bx: Bounds, by: Bounds) {
423         self.x.set_bounds(bx);
424         self.y.set_bounds(by);
425     }
426 
427     /// The wheel delta as content pixels, sign-flipped into "offset grows
428     /// when the content moves up" — the convention every host used inline
429     /// (`-y * 24.0`, `-pos.y`). `line_px` is the per-notch unit for each axis.
430     pub fn delta_px(delta: &MouseScrollDelta, line_px: (f32, f32)) -> (f32, f32) {
431         match delta {
432             MouseScrollDelta::LineDelta(x, y) => (-x * line_px.0, -y * line_px.1),
433             MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
434         }
435     }
436 
437     /// Feed one wheel event, using the runner-published phase. Returns
438     /// whether the offset or its target moved (the host's "raise the
439     /// scrollbar" signal, and a repaint request when true).
440     pub fn apply(&mut self, delta: &MouseScrollDelta, line_px: (f32, f32), bx: Bounds, by: Bounds) -> bool {
441         let (dx, dy) = Self::delta_px(delta, line_px);
442         self.apply_px(dx, dy, matches!(delta, MouseScrollDelta::LineDelta(..)), bx, by)
443     }
444 
445     /// [`Self::apply`] with the conversion already done. `discrete` marks a
446     /// wheel-notch delta; a pixel delta takes the finger path only while the
447     /// runner reports a finger gesture, else it is applied instantly.
448     pub fn apply_px(&mut self, dx: f32, dy: f32, discrete: bool, bx: Bounds, by: Bounds) -> bool {
449         let s = scroll_settings();
450         let phase = if discrete { ScrollPhase::Wheel } else { current_scroll_phase() };
451         match phase {
452             ScrollPhase::Wheel => {
453                 let mut moved = false;
454                 if discrete {
455                     moved |= self.x.wheel(dx, bx, &s);
456                     moved |= self.y.wheel(dy, by, &s);
457                 } else {
458                     // A pixel delta outside any gesture (a synthesized or
459                     // sourceless event): direct, like the finger path, but
460                     // never flings.
461                     moved |= self.x.finger(dx, 1.0, bx);
462                     moved |= self.y.finger(dy, 1.0, by);
463                     self.x.vel = 0.0;
464                     self.y.vel = 0.0;
465                     self.x.mode = Mode::Idle;
466                     self.y.mode = Mode::Idle;
467                 }
468                 moved
469             }
470             ScrollPhase::Finger => {
471                 let now = Instant::now();
472                 let dt = self.last_finger.map_or(0.016, |t| now.duration_since(t).as_secs_f32());
473                 self.last_finger = Some(now);
474                 let mut moved = false;
475                 moved |= self.x.finger(dx, dt, bx);
476                 moved |= self.y.finger(dy, dt, by);
477                 moved
478             }
479             ScrollPhase::FingerEnd => {
480                 let since = self.last_finger.map_or(1.0, |t| t.elapsed().as_secs_f32());
481                 let mut coasting = false;
482                 coasting |= self.x.finger_end(since, &s);
483                 coasting |= self.y.finger_end(since, &s);
484                 self.last_finger = None;
485                 coasting
486             }
487         }
488     }
489 
490     /// Advance both axes. Returns whether either drawn offset changed.
491     pub fn tick(&mut self, dt: f32, bx: Bounds, by: Bounds) -> bool {
492         let s = scroll_settings();
493         let mut moved = false;
494         moved |= self.x.tick(dt, bx, &s);
495         moved |= self.y.tick(dt, by, &s);
496         moved
497     }
498 }
499 
500 #[cfg(test)]
501 mod tests {
502     use super::*;
503 
504     fn smooth() -> ScrollSettings {
505         ScrollSettings { smooth: true, ease_rate: 12.0, kinetic: true, friction: 6.0 }
506     }
507 
508     fn settle(a: &mut ScrollAxis, b: Bounds, s: &ScrollSettings) -> u32 {
509         let mut frames = 0;
510         while a.is_animating() && frames < 10_000 {
511             a.tick(1.0 / 60.0, b, s);
512             frames += 1;
513         }
514         frames
515     }
516 
517     #[test]
518     fn wheel_glides_to_target_and_settles() {
519         let s = smooth();
520         let b = Bounds::max(1000.0);
521         let mut a = ScrollAxis::new(0.0);
522         assert!(a.wheel(24.0, b, &s));
523         assert_eq!(a.target(), 24.0);
524         assert_eq!(a.pos(), 0.0, "the wheel moves the target, not the drawn offset");
525         assert!(a.tick(1.0 / 60.0, b, &s));
526         assert!(a.pos() > 0.0 && a.pos() < 24.0);
527         let frames = settle(&mut a, b, &s);
528         assert_eq!(a.pos(), 24.0);
529         assert!(frames > 3 && frames < 60, "settled in {frames} frames");
530     }
531 
532     #[test]
533     fn notches_accumulate_into_one_glide() {
534         let s = smooth();
535         let b = Bounds::max(1000.0);
536         let mut a = ScrollAxis::new(0.0);
537         a.wheel(24.0, b, &s);
538         a.tick(1.0 / 60.0, b, &s);
539         a.wheel(24.0, b, &s);
540         assert_eq!(a.target(), 48.0);
541         settle(&mut a, b, &s);
542         assert_eq!(a.pos(), 48.0);
543     }
544 
545     #[test]
546     fn wheel_target_clamps_to_bounds() {
547         let s = smooth();
548         let b = Bounds::max(30.0);
549         let mut a = ScrollAxis::new(0.0);
550         a.wheel(100.0, b, &s);
551         assert_eq!(a.target(), 30.0);
552         assert!(!a.wheel(100.0, b, &s), "a notch past the end moves nothing");
553         settle(&mut a, b, &s);
554         assert_eq!(a.pos(), 30.0);
555     }
556 
557     #[test]
558     fn smoothing_off_jumps() {
559         let s = ScrollSettings { smooth: false, ..smooth() };
560         let b = Bounds::max(1000.0);
561         let mut a = ScrollAxis::new(0.0);
562         a.wheel(24.0, b, &s);
563         assert_eq!(a.pos(), 24.0);
564         assert!(!a.is_animating());
565     }
566 
567     #[test]
568     fn finger_tracks_one_to_one_then_flings() {
569         let s = smooth();
570         let b = Bounds::max(10_000.0);
571         let mut a = ScrollAxis::new(0.0);
572         // A steady 15px every 8ms swipe.
573         for _ in 0..10 {
574             assert!(a.finger(15.0, 0.008, b));
575         }
576         assert_eq!(a.pos(), 150.0);
577         assert!(!a.is_animating(), "no motion of its own while the finger is down");
578         assert!(a.finger_end(0.01, &s));
579         let before = a.pos();
580         let frames = settle(&mut a, b, &s);
581         assert!(a.pos() > before + 50.0, "coasted from {before} to {}", a.pos());
582         assert!(frames > 5);
583         assert_eq!(a.velocity(), 0.0);
584     }
585 
586     #[test]
587     fn resting_finger_does_not_fling() {
588         let s = smooth();
589         let b = Bounds::max(10_000.0);
590         let mut a = ScrollAxis::new(0.0);
591         for _ in 0..10 {
592             a.finger(15.0, 0.008, b);
593         }
594         assert!(!a.finger_end(0.5, &s), "a finger held still before lifting stops dead");
595         assert_eq!(a.pos(), 150.0);
596     }
597 
598     #[test]
599     fn kinetic_off_stops_dead() {
600         let s = ScrollSettings { kinetic: false, ..smooth() };
601         let b = Bounds::max(10_000.0);
602         let mut a = ScrollAxis::new(0.0);
603         for _ in 0..10 {
604             a.finger(15.0, 0.008, b);
605         }
606         assert!(!a.finger_end(0.01, &s));
607         assert!(!a.is_animating());
608     }
609 
610     #[test]
611     fn coast_stops_at_the_bound() {
612         let s = smooth();
613         let b = Bounds::max(200.0);
614         let mut a = ScrollAxis::new(0.0);
615         for _ in 0..10 {
616             a.finger(15.0, 0.008, b);
617         }
618         a.finger_end(0.01, &s);
619         settle(&mut a, b, &s);
620         assert_eq!(a.pos(), 200.0);
621         assert_eq!(a.velocity(), 0.0);
622     }
623 
624     #[test]
625     fn wheel_during_coast_redirects() {
626         let s = smooth();
627         let b = Bounds::max(10_000.0);
628         let mut a = ScrollAxis::new(0.0);
629         for _ in 0..10 {
630             a.finger(15.0, 0.008, b);
631         }
632         a.finger_end(0.01, &s);
633         a.tick(1.0 / 60.0, b, &s);
634         let p = a.pos();
635         a.wheel(-24.0, b, &s);
636         assert_eq!(a.velocity(), 0.0);
637         assert!((a.target() - (p - 24.0)).abs() < 1e-3);
638     }
639 
640     #[test]
641     fn reconcile_adopts_host_writes() {
642         let s = smooth();
643         let b = Bounds::max(1000.0);
644         let mut a = ScrollAxis::new(0.0);
645         a.wheel(240.0, b, &s);
646         a.tick(1.0 / 60.0, b, &s);
647         // The host dragged the thumb to 500 behind our back.
648         a.reconcile(500.0);
649         assert_eq!(a.pos(), 500.0);
650         assert_eq!(a.target(), 500.0);
651         assert!(!a.is_animating());
652         // An unchanged host value is not a write.
653         a.wheel(24.0, b, &s);
654         a.reconcile(500.0);
655         assert!(a.is_animating());
656     }
657 
658     #[test]
659     fn bounds_shrink_reclamps_and_settles() {
660         let s = smooth();
661         let mut a = ScrollAxis::new(0.0);
662         a.wheel(900.0, Bounds::max(1000.0), &s);
663         settle(&mut a, Bounds::max(1000.0), &s);
664         a.set_bounds(Bounds::max(100.0));
665         assert_eq!(a.pos(), 100.0);
666         assert_eq!(a.target(), 100.0);
667     }
668 
669     #[test]
670     fn scroll_to_glides_keyboard_pages() {
671         let s = smooth();
672         let b = Bounds::max(1000.0);
673         let mut a = ScrollAxis::new(0.0);
674         assert!(a.scroll_to(400.0, b, &s));
675         assert_eq!(a.pos(), 0.0);
676         settle(&mut a, b, &s);
677         assert_eq!(a.pos(), 400.0);
678     }
679 
680     #[test]
681     fn ease_is_frame_rate_independent() {
682         let s = smooth();
683         let b = Bounds::max(1000.0);
684         let mut fast = ScrollAxis::new(0.0);
685         let mut slow = ScrollAxis::new(0.0);
686         fast.wheel(500.0, b, &s);
687         slow.wheel(500.0, b, &s);
688         for _ in 0..12 {
689             fast.tick(1.0 / 120.0, b, &s);
690         }
691         slow.tick(0.1, b, &s);
692         assert!((fast.pos() - slow.pos()).abs() < 1.0, "120Hz {} vs 10Hz {}", fast.pos(), slow.pos());
693     }
694 
695     #[test]
696     fn delta_conversion_matches_the_legacy_convention() {
697         let (dx, dy) = ScrollMotion::delta_px(&MouseScrollDelta::LineDelta(0.0, -2.0), (LINE_PX, LINE_PX));
698         assert_eq!((dx, dy), (0.0, 48.0));
699         let (dx, dy) = ScrollMotion::delta_px(
700             &MouseScrollDelta::PixelDelta(crate::widget::Position { x: 3.0, y: -10.0 }),
701             (LINE_PX, LINE_PX),
702         );
703         assert_eq!((dx, dy), (-3.0, 10.0));
704     }
705 
706     #[test]
707     fn unbounded_axis_pans_negative() {
708         let s = smooth();
709         let mut a = ScrollAxis::new(0.0);
710         a.wheel(-300.0, Bounds::UNBOUNDED, &s);
711         settle(&mut a, Bounds::UNBOUNDED, &s);
712         assert_eq!(a.pos(), -300.0);
713     }
714 }