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 }