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 }