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

src/widget/scroll_motion.rs (27K)

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