teksilo_widgets/common/scrollable.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! One scroll handler for every scrollable surface.
5//!
6//! The scrollable widgets in this crate each hand-rolled the same `on_scroll`
7//! body: convert a [`ScrollDelta`] to pixels, clamp each axis, animate or set,
8//! and answer `Ignored` at a hard boundary so the event chains to an ancestor.
9//! They agreed on the arithmetic and disagreed on everything around it — which
10//! of them honoured `Contain`, which animated, which read a line height. This
11//! module is that body written once, plus the two things none of them had: a
12//! finger's pan, and the rubber band that pan needs at the edge.
13//!
14//! [`ScrollArea`](crate::ScrollArea) is the reference adopter and the one to
15//! copy. The data views ([`ListView`](crate::ListView),
16//! [`TreeView`](crate::TreeView), [`GridView`](crate::GridView),
17//! [`TableView`](crate::TableView),
18//! [`TreeTableView`](crate::TreeTableView)) and the three text surfaces
19//! ([`RichTextEditor`](crate::rich_text::RichTextEditor), [`CodeEditor`](crate::CodeEditor)
20//! and [`LogView`](crate::LogView)) install it too. A widget that handles a
21//! wheel without owning a scroll offset — `SpinBox` steps a number, `TabBar`
22//! remaps a notch sideways — is not a scrollable and does not appear here.
23//!
24//! # The two paths
25//!
26//! [`handle_scroll_event`] branches on
27//! [`EventContext::scroll_source`](teksilo_core::widget::EventContext::scroll_source),
28//! not on the phase:
29//!
30//! * **Everything except [`ScrollSource::TouchPan`]** — a wheel notch, a
31//! trackpad stream, a programmatic scroll — takes the path this crate has
32//! always taken: clamp against the *animation target* rather than the
33//! rendered offset (so a mid-tween boundary chains correctly), then either
34//! tween over [`ScrollHandlingOptions::smooth_duration`] or set outright,
35//! per axis and only where the clamp moved.
36//! * **[`ScrollSource::TouchPan`]** — a pan synthesised from a direct pointer
37//! by the router, and the coast that follows it — goes through a
38//! [`KineticScroller`], which is what supplies the rubber band. A pan never
39//! tweens: a finger is already the animation.
40//!
41//! Splitting on the source and not the phase is what makes the migration safe.
42//! A legacy [`WidgetEvent::Scroll`] reports [`ScrollSource::Wheel`], so every
43//! existing call site and every existing test keeps the path it had. The
44//! distinction is load-bearing rather than cosmetic, and is pinned by
45//! `a_trackpad_stream_takes_the_wheel_path_and_not_the_kinetic_one`
46//! (`tests/scrollables_touch.rs`): a trackpad stream carries the same
47//! `Began`/`Changed`/`Ended` phases a synthesised pan does, so a phase test
48//! would route a pointing device into the kinetic path and start tracking
49//! velocity for a contact that will never lift.
50//!
51//! # Who owns what
52//!
53//! The scroller belongs to the widget, not to this module: it is the widget
54//! that knows its viewport (which is the only thing the rubber-band curve reads
55//! beyond the range) and the widget whose layout pass is where that number
56//! becomes available. So the surface owns an `Rc<RefCell<KineticScroller>>`,
57//! calls [`set_viewport`](KineticScroller::set_viewport) from its own layout,
58//! and hands the handle to [`handle_scroll_event`] on every event. The range is
59//! read from the [`ScrollableAxes`] signals each time, so it is never stale.
60//!
61//! The **coast** is not owned here at all. A release hands its velocity to the
62//! tree's `FlingDriver`, which re-dispatches it as
63//! [`ScrollPhase::Fling`] deltas along the same claimant chain the pan walked —
64//! that is what makes a flick that runs out of an inner list scroll the outer
65//! one. A fling delta therefore arrives here as an ordinary positive-or-negative
66//! offset change and is applied with a **hard clamp**: the driver's simulation
67//! is unbounded and stopping it at the edge is this surface's job, not the
68//! band's.
69//!
70//! # Adoption
71//!
72//! ```ignore
73//! let axes = ScrollableAxes::new(scroll_x, scroll_y, max_x, max_y);
74//! let behavior = ScrollableBehavior::new(axes)
75//! .with_scroller(self.scroller.clone())
76//! .axes(PanAxes::BOTH)
77//! .smooth(self.smooth_scrolling)
78//! .line_height(self.line_height)
79//! .reduced_motion(ctx.prefers_reduced_motion());
80//! let handlers = behavior.install(HandlerSet::new());
81//! ```
82//!
83//! `install` attaches both halves: the `on_scroll` handler *and* the
84//! [`PanClaim`] that makes the node a pan claimant in the first place. A
85//! surface that installs the handler without the claim is a surface a finger
86//! cannot scroll, which is the bug this module exists to stop shipping.
87//!
88//! Reference: `docs/kinetic-scrolling.md`.
89
90use std::cell::RefCell;
91use std::rc::Rc;
92use std::time::Duration;
93
94use teksilo_canvas::{Point, Vec2};
95use teksilo_core::OverscrollBehavior;
96use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
97use teksilo_core::kinetic::KineticScroller;
98use teksilo_core::pointer::touch_action::{Axis, PanAxes, PanClaim};
99use teksilo_core::pointer::{EventTime, ScrollPhase, ScrollSource};
100use teksilo_core::signal::Signal;
101use teksilo_core::widget::EventContext;
102use teksilo_core::widget_builder::HandlerSet;
103use teksilo_tokens::{Easing, OverscrollStyle, PointerKindMask, ScrollPhysicsTokens};
104
105use crate::common::scroll::{scroll_clamp_axis, scroll_response};
106
107/// The 150 ms ease-out every smooth-scrolling surface in this crate uses for a
108/// wheel notch. Unchanged by the touch programme — a wheel still feels the way
109/// it always did.
110pub const SMOOTH_SCROLL_DURATION: Duration = Duration::from_millis(150);
111
112// ---------------------------------------------------------------------------
113// ScrollableAxes
114// ---------------------------------------------------------------------------
115
116/// The reactive state one scrollable surface scrolls: where it is on each axis,
117/// how far it can go, and how far past the end it is currently being held.
118///
119/// Every field is a `Signal`, and they are the *shared* ones — the same handles
120/// a `ScrollBar` reads and an `ensure_visible` writes — so this type is a view
121/// onto the widget's state rather than a second copy of it. Cloning is cloning
122/// handles.
123///
124/// An offset must be [`Signal::new_animated`] on a surface that turns
125/// [`ScrollHandlingOptions::smooth`] on **and gives that axis a range**:
126/// `animate_to` on a plain signal panics. An axis with a permanently zero
127/// range is never written by either path, so a surface that scrolls on one
128/// axis only may leave the other plain.
129#[derive(Clone, Debug)]
130pub struct ScrollableAxes {
131 /// Horizontal offset, `0.0` at the leading edge.
132 pub x: Signal<f32>,
133 /// Vertical offset, `0.0` at the top.
134 pub y: Signal<f32>,
135 /// Largest legal [`x`](Self::x) — content width minus viewport width, never
136 /// below zero.
137 pub max_x: Signal<f32>,
138 /// Largest legal [`y`](Self::y).
139 pub max_y: Signal<f32>,
140 /// How far past the range the content is being held right now, per axis,
141 /// after the rubber band. Always `ZERO` under [`OverscrollStyle::Clamp`],
142 /// under reduced motion, and outside a live pan.
143 ///
144 /// Published for a surface that wants to draw the stretch or the glow; the
145 /// offset itself never leaves the range, so a surface that ignores this
146 /// signal is still correct.
147 pub overscroll: Signal<Vec2>,
148}
149
150impl ScrollableAxes {
151 /// Both axes, with a fresh overscroll signal.
152 pub fn new(x: Signal<f32>, y: Signal<f32>, max_x: Signal<f32>, max_y: Signal<f32>) -> Self {
153 Self {
154 x,
155 y,
156 max_x,
157 max_y,
158 overscroll: Signal::new(Vec2::ZERO),
159 }
160 }
161
162 /// A vertical-only surface: the horizontal axis is pinned at zero with a
163 /// zero range, so nothing can ever move it.
164 ///
165 /// The pinned axis is a plain signal. Both paths write an axis only when
166 /// its clamp actually moved, and an axis whose range is zero and whose
167 /// offset is already zero never moves — so the tween that would panic on a
168 /// plain signal is unreachable here.
169 pub fn vertical(y: Signal<f32>, max_y: Signal<f32>) -> Self {
170 Self::new(Signal::new(0.0), y, Signal::new(0.0), max_y)
171 }
172
173 /// A horizontal-only surface. The pinned axis is plain, for the reason
174 /// given on [`vertical`](Self::vertical).
175 pub fn horizontal(x: Signal<f32>, max_x: Signal<f32>) -> Self {
176 Self::new(x, Signal::new(0.0), max_x, Signal::new(0.0))
177 }
178
179 /// Write an offset back, notifying only on a real change.
180 ///
181 /// `Signal::set` notifies unconditionally, and a pan delivers a sample per
182 /// frame; re-dirtying a scrollable for a movement smaller than
183 /// [`SCROLL_MOVE_EPSILON`](teksilo_core::SCROLL_MOVE_EPSILON) would cost a
184 /// relayout per frame for a picture that cannot change.
185 fn publish(&self, offset: Point) {
186 if (self.x.get() - offset.x).abs() > f32::EPSILON {
187 self.x.set(offset.x);
188 }
189 if (self.y.get() - offset.y).abs() > f32::EPSILON {
190 self.y.set(offset.y);
191 }
192 }
193
194 /// Write the overscroll back, notifying only on a real change.
195 fn publish_overscroll(&self, overscroll: Vec2) {
196 if self.overscroll.get() != overscroll {
197 self.overscroll.set(overscroll);
198 }
199 }
200}
201
202// ---------------------------------------------------------------------------
203// ScrollHandlingOptions
204// ---------------------------------------------------------------------------
205
206/// Everything [`handle_scroll_event`] needs to know that is not state.
207///
208/// Snapshot, not signals: a scrollable builds one of these in `build()`, where
209/// the theme and the reduced-motion preference are in scope, and the handler
210/// closure captures it. Both change through a rebuild, which is the level a
211/// density switch and a preference change already mark.
212#[derive(Clone, Copy, Debug)]
213pub struct ScrollHandlingOptions {
214 /// Pixels one [`ScrollDelta::Lines`] unit is worth.
215 pub line_height: f32,
216 /// Whether a wheel notch tweens to its target instead of jumping. Never
217 /// consulted on the pan path — a finger is already the animation.
218 ///
219 /// With this on, every [`ScrollableAxes`] offset **that has a range** must
220 /// be [`Signal::new_animated`] — `animate_to` panics on a plain signal.
221 /// The qualifier is load-bearing, not a caveat: an axis whose maximum is
222 /// permanently zero is never written by either path, which is exactly why
223 /// [`ScrollableAxes::vertical`] and [`ScrollableAxes::horizontal`] pin
224 /// their unused axis with a plain `Signal::new(0.0)` and why `ListView`,
225 /// `TreeView` and `GridView` each pair `ScrollableAxes::vertical` with a
226 /// `smooth_scrolling` that defaults to `true`. The rule is stated once on
227 /// [`ScrollableAxes`] itself; this is the same rule.
228 pub smooth: bool,
229 /// How long that tween lasts.
230 pub smooth_duration: Duration,
231 /// Whether a boundary scroll chains outward ([`OverscrollBehavior::Chain`])
232 /// or is absorbed ([`OverscrollBehavior::Contain`]).
233 pub overscroll_behavior: OverscrollBehavior,
234 /// Which overscroll feel this surface asks for when [`rubber_band`] is on.
235 ///
236 /// [`rubber_band`]: Self::rubber_band
237 pub overscroll_style: OverscrollStyle,
238 /// Which axes a finger may pan. An axis outside this set takes no movement
239 /// from a pan (the wheel path is unaffected — a wheel has always reached
240 /// every axis the range allows).
241 pub axes: PanAxes,
242 /// Whether this surface follows the finger past its own end.
243 ///
244 /// **Off by default, and that is the load-bearing default.** A band that
245 /// engages absorbs the movement, so a nested list that rubber-banded at its
246 /// end would never hand the gesture to the container around it. The band
247 /// belongs to the outermost surface of a scroll chain; everything inside it
248 /// clamps and chains, which is also what every desktop toolkit does.
249 pub rubber_band: bool,
250 /// Which pointer kinds may pan this surface. Defaults to
251 /// [`PointerKindMask::DIRECT`] — a mouse scrolls with its wheel and must
252 /// never be treated as a panning pointer.
253 pub pan_devices: PointerKindMask,
254 /// `prefers-reduced-motion`, snapshotted at build. Hard-clamps the band.
255 pub reduced_motion: bool,
256 /// The theme's scroll-physics constants. Only the rubber-band friction
257 /// factor is read here — the fling's constants belong to the tree's
258 /// driver, which reads them from the same tokens.
259 pub physics: ScrollPhysicsTokens,
260}
261
262impl Default for ScrollHandlingOptions {
263 fn default() -> Self {
264 Self {
265 line_height: 20.0,
266 smooth: true,
267 smooth_duration: SMOOTH_SCROLL_DURATION,
268 overscroll_behavior: OverscrollBehavior::Chain,
269 overscroll_style: OverscrollStyle::RubberBand,
270 axes: PanAxes::BOTH,
271 rubber_band: false,
272 pan_devices: PointerKindMask::DIRECT,
273 reduced_motion: false,
274 physics: ScrollPhysicsTokens::DEFAULT,
275 }
276 }
277}
278
279impl ScrollHandlingOptions {
280 /// The style the scroller is actually configured with: the requested one
281 /// when the surface opted into the band, a hard clamp otherwise.
282 fn effective_style(&self) -> OverscrollStyle {
283 if self.rubber_band {
284 self.overscroll_style
285 } else {
286 OverscrollStyle::Clamp
287 }
288 }
289}
290
291// ---------------------------------------------------------------------------
292// handle_scroll_event
293// ---------------------------------------------------------------------------
294
295/// Apply one scroll event to `axes`, and answer the boundary question.
296///
297/// `Handled` means an axis absorbed some of the movement. `Ignored` means it
298/// absorbed none, which is the signal that sends the event to the next
299/// container outward — along the pan claimant chain for a finger, up the
300/// ordinary bubble for a wheel. [`OverscrollBehavior::Contain`] turns a
301/// declined *scroll* into `Handled`; it never contains the gesture's
302/// end-of-stream bookkeeping, which every claimant on a chain must see.
303///
304/// Returns `Ignored` unchanged for any event that is not a
305/// [`WidgetEvent::Scroll`], so a caller can chain its own arms after it.
306pub fn handle_scroll_event(
307 event: &WidgetEvent,
308 axes: &ScrollableAxes,
309 scroller: &Rc<RefCell<KineticScroller>>,
310 options: &ScrollHandlingOptions,
311 ctx: &mut EventContext,
312) -> EventResponse {
313 let WidgetEvent::Scroll {
314 delta,
315 phase,
316 window_position,
317 pointer,
318 ..
319 } = event
320 else {
321 return EventResponse::Ignored;
322 };
323
324 let (dx, dy) = match delta {
325 ScrollDelta::Lines { x, y } => (x * options.line_height, y * options.line_height),
326 ScrollDelta::Pixels { x, y } => (*x, *y),
327 };
328
329 if ctx.scroll_source() == ScrollSource::TouchPan {
330 pan_step(
331 axes,
332 scroller,
333 options,
334 *phase,
335 Vec2::new(dx, dy),
336 // Window-space on purpose: `KineticScroller::pan`'s tracker follows
337 // the pointer, and a frame that moved with the widget being
338 // measured would fold that widget's own motion into the velocity.
339 window_position.unwrap_or(Point::ZERO),
340 pointer.time,
341 )
342 } else {
343 wheel_step(axes, options, dx, dy)
344 }
345}
346
347/// The pre-touch path, preserved exactly.
348///
349/// The base is the animation **target** rather than the rendered offset, so a
350/// notch that arrives mid-tween accumulates onto where the previous notch was
351/// heading and a boundary reached by the tween still chains.
352fn wheel_step(
353 axes: &ScrollableAxes,
354 options: &ScrollHandlingOptions,
355 dx: f32,
356 dy: f32,
357) -> EventResponse {
358 let max_y = axes.max_y.get();
359 let max_x = axes.max_x.get();
360 let cur_y = axes.y.get();
361 let cur_x = axes.x.get();
362 let base_y = axes.y.animation_target().unwrap_or(cur_y);
363 let base_x = axes.x.animation_target().unwrap_or(cur_x);
364
365 let (target_x, moved_x) = scroll_clamp_axis(base_x, dx, max_x);
366 let (target_y, moved_y) = scroll_clamp_axis(base_y, dy, max_y);
367
368 // Per axis, and only when that axis' clamp actually moved. Writing an
369 // unmoved axis costs a notification for a value that did not change —
370 // which every scrollable in this crate but `ScrollArea` guarded against by
371 // hand, two of them with the guard's reason written at the site. It would
372 // also put a tween on an axis a surface may legitimately keep as a plain
373 // signal, where `animate_to` panics.
374 if moved_x {
375 if options.smooth {
376 axes.x
377 .animate_to(target_x, options.smooth_duration, Easing::EaseOut);
378 } else {
379 axes.x.set(target_x);
380 }
381 }
382 if moved_y {
383 if options.smooth {
384 axes.y
385 .animate_to(target_y, options.smooth_duration, Easing::EaseOut);
386 } else {
387 axes.y.set(target_y);
388 }
389 }
390
391 scroll_response(
392 moved_x || moved_y,
393 options.overscroll_behavior == OverscrollBehavior::Contain,
394 )
395}
396
397/// The finger's path: the scroller decides where the content goes, and how far
398/// past the end it is being held.
399fn pan_step(
400 axes: &ScrollableAxes,
401 scroller: &Rc<RefCell<KineticScroller>>,
402 options: &ScrollHandlingOptions,
403 phase: ScrollPhase,
404 delta: Vec2,
405 position: Point,
406 time: EventTime,
407) -> EventResponse {
408 let contain = options.overscroll_behavior == OverscrollBehavior::Contain;
409 let mut s = scroller.borrow_mut();
410
411 s.set_reduced_motion(options.reduced_motion);
412 s.set_range_x(0.0, axes.max_x.get());
413 s.set_range_y(0.0, axes.max_y.get());
414
415 // Re-seed from the signals when somebody else moved the offset — a scroll
416 // bar drag, an `ensure_visible`, a keyboard page. Comparing against what
417 // the scroller last published rather than assigning unconditionally is
418 // what lets a rubber band accumulate across samples: `set_offset` clamps,
419 // so an unconditional re-seed would erase the overscroll every frame.
420 let published = s.offset();
421 let (sx, sy) = (axes.x.get(), axes.y.get());
422 if (published.x - sx).abs() > f32::EPSILON || (published.y - sy).abs() > f32::EPSILON {
423 s.set_offset(Point::new(sx, sy));
424 }
425
426 // An axis this surface does not pan on takes nothing, so the event chains
427 // on it. `TouchAction` has already filtered what the *gesture* may do; this
428 // is the surface's own narrower say (a horizontal tab strip inside a
429 // vertical list claims X only).
430 let dx = if options.axes.contains(Axis::X) {
431 delta.x
432 } else {
433 0.0
434 };
435 let dy = if options.axes.contains(Axis::Y) {
436 delta.y
437 } else {
438 0.0
439 };
440
441 match phase {
442 // The gesture is over. Release the band — the content returns to a
443 // legal offset and the overscroll signal to zero — and decline, so the
444 // walk carries the same `Ended` to every claimant outward. An end of
445 // stream is bookkeeping, not movement: a claimant that answered
446 // `Handled` here would leave the containers around it holding a band
447 // nobody ever told them to let go of, and `Contain` has nothing to
448 // contain.
449 ScrollPhase::Ended | ScrollPhase::MomentumEnded | ScrollPhase::Cancelled => {
450 let settled = s.offset();
451 s.set_offset(settled);
452 drop(s);
453 axes.publish(settled);
454 axes.publish_overscroll(Vec2::ZERO);
455 EventResponse::Ignored
456 }
457 // A coast. The tree's `FlingDriver` integrates an *unbounded*
458 // simulation and hands out its per-tick deltas, so the boundary is
459 // enforced here, with a hard clamp and no band: a coast that reaches
460 // the end must decline and chain, not slide on with decreasing gain.
461 ScrollPhase::Fling | ScrollPhase::Momentum => {
462 let base = s.offset();
463 let (nx, moved_x) = scroll_clamp_axis(base.x, dx, axes.max_x.get());
464 let (ny, moved_y) = scroll_clamp_axis(base.y, dy, axes.max_y.get());
465 let settled = Point::new(nx, ny);
466 s.set_offset(settled);
467 drop(s);
468 axes.publish(settled);
469 axes.publish_overscroll(Vec2::ZERO);
470 scroll_response(moved_x || moved_y, contain)
471 }
472 // The finger is down and moving.
473 _ => {
474 let step = s.pan(time, position, Vec2::new(dx, dy));
475 drop(s);
476 axes.publish(step.offset);
477 axes.publish_overscroll(step.overscroll);
478 scroll_response(step.absorbed_any(), contain)
479 }
480 }
481}
482
483/// Rewrite a Shift+wheel notch into a horizontal one, or decline.
484///
485/// A vertical-only wheel held with Shift scrolls a horizontally-scrollable
486/// surface sideways — the convention `TabBar` established in this crate and
487/// every desktop toolkit shares. The transform is a *delta* rewrite, which
488/// [`handle_scroll_event`] cannot express because it reads the delta off the
489/// event; a surface that wants it builds the rewritten event here and hands
490/// that to the shared handler from its [`ScrollableBehavior::before`] arm, so
491/// the arithmetic is still written once.
492///
493/// Declines — returning `None`, meaning "no remap, treat this event as it
494/// came" — for anything but a wheel-family [`WidgetEvent::Scroll`] held with
495/// Shift whose horizontal component is zero. Two of those clauses carry the
496/// rule rather than the example:
497///
498/// * A delta with a real horizontal component is a trackpad's own two-axis
499/// stream, and rewriting it would throw the axis the user actually moved.
500/// * A [`ScrollSource::TouchPan`] is never remapped. A finger has no Shift
501/// key, so the modifier could only arrive from a keyboard held during a
502/// pan, and turning that pan sideways is not what the hand asked for.
503pub fn shift_wheel_remap(event: &WidgetEvent, ctx: &EventContext) -> Option<WidgetEvent> {
504 let WidgetEvent::Scroll {
505 delta,
506 modifiers,
507 window_position,
508 phase,
509 pointer,
510 } = event
511 else {
512 return None;
513 };
514 if !modifiers.shift() || ctx.scroll_source() == ScrollSource::TouchPan {
515 return None;
516 }
517 let remapped = match delta {
518 ScrollDelta::Lines { x, y } if x.abs() < f32::EPSILON => {
519 ScrollDelta::Lines { x: *y, y: 0.0 }
520 }
521 ScrollDelta::Pixels { x, y } if x.abs() < f32::EPSILON => {
522 ScrollDelta::Pixels { x: *y, y: 0.0 }
523 }
524 _ => return None,
525 };
526 Some(WidgetEvent::Scroll {
527 delta: remapped,
528 modifiers: *modifiers,
529 window_position: *window_position,
530 phase: *phase,
531 pointer: *pointer,
532 })
533}
534
535// ---------------------------------------------------------------------------
536// ScrollableBehavior
537// ---------------------------------------------------------------------------
538
539/// The whole of what a widget must do to become scrollable, as one value it
540/// installs onto its [`HandlerSet`].
541///
542/// Two halves, and both matter. The `on_scroll` handler is the arithmetic;
543/// the [`PanClaim`] is what puts the node on the claimant chain a synthesised
544/// pan walks. Installing one without the other yields a surface that scrolls on
545/// a wheel and ignores a finger, which is exactly the state this crate was in
546/// before this module.
547pub struct ScrollableBehavior {
548 axes: ScrollableAxes,
549 scroller: Rc<RefCell<KineticScroller>>,
550 options: ScrollHandlingOptions,
551 #[allow(clippy::type_complexity)]
552 before: Option<Rc<dyn Fn(&WidgetEvent, &mut EventContext) -> Option<EventResponse>>>,
553 #[allow(clippy::type_complexity)]
554 after: Option<Rc<dyn Fn(&WidgetEvent, EventResponse, &mut EventContext)>>,
555}
556
557impl std::fmt::Debug for ScrollableBehavior {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 f.debug_struct("ScrollableBehavior")
560 .field("options", &self.options)
561 .field("has_before", &self.before.is_some())
562 .field("has_after", &self.after.is_some())
563 .finish()
564 }
565}
566
567impl ScrollableBehavior {
568 /// A behaviour over `axes`, with a scroller of its own.
569 ///
570 /// A surface that must reach the scroller from its layout pass (to publish
571 /// its viewport, which is what the rubber-band curve is a fraction of)
572 /// keeps its own handle and passes it to
573 /// [`with_scroller`](Self::with_scroller) instead, so the physics survives
574 /// a rebuild.
575 pub fn new(axes: ScrollableAxes) -> Self {
576 Self {
577 axes,
578 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
579 options: ScrollHandlingOptions::default(),
580 before: None,
581 after: None,
582 }
583 }
584
585 /// Use the caller's scroller rather than the one [`new`](Self::new) made.
586 pub fn with_scroller(mut self, scroller: Rc<RefCell<KineticScroller>>) -> Self {
587 self.scroller = scroller;
588 self
589 }
590
591 /// Which axes a finger may pan.
592 pub fn axes(mut self, axes: PanAxes) -> Self {
593 self.options.axes = axes;
594 self
595 }
596
597 /// Whether a boundary scroll chains outward or is absorbed.
598 pub fn overscroll(mut self, behavior: OverscrollBehavior) -> Self {
599 self.options.overscroll_behavior = behavior;
600 self
601 }
602
603 /// Follow the finger past the end with decreasing gain. See
604 /// [`ScrollHandlingOptions::rubber_band`] for why this is off by default.
605 pub fn rubber_band(mut self, on: bool) -> Self {
606 self.options.rubber_band = on;
607 self
608 }
609
610 /// Which overscroll feel to use when the band is on.
611 pub fn overscroll_style(mut self, style: OverscrollStyle) -> Self {
612 self.options.overscroll_style = style;
613 self
614 }
615
616 /// Whether a wheel notch tweens to its target.
617 pub fn smooth(mut self, on: bool) -> Self {
618 self.options.smooth = on;
619 self
620 }
621
622 /// How long that tween lasts.
623 pub fn smooth_duration(mut self, duration: Duration) -> Self {
624 self.options.smooth_duration = duration;
625 self
626 }
627
628 /// Pixels one line of a [`ScrollDelta::Lines`] notch is worth.
629 pub fn line_height(mut self, pixels: f32) -> Self {
630 self.options.line_height = pixels;
631 self
632 }
633
634 /// Which pointer kinds may pan this surface.
635 pub fn pan_devices(mut self, devices: PointerKindMask) -> Self {
636 self.options.pan_devices = devices;
637 self
638 }
639
640 /// `prefers-reduced-motion`, read from the build context.
641 pub fn reduced_motion(mut self, reduced: bool) -> Self {
642 self.options.reduced_motion = reduced;
643 self
644 }
645
646 /// The theme's scroll-physics constants, for the rubber-band curve.
647 pub fn physics(mut self, physics: ScrollPhysicsTokens) -> Self {
648 self.options.physics = physics;
649 self
650 }
651
652 /// An arm the installed handler runs **first**, for every event.
653 ///
654 /// The answer is an `Option`, and the two halves of it are different
655 /// questions:
656 ///
657 /// * `None` — "not mine". The shared treatment then runs on the **same,
658 /// unmodified** event. This is what an observing arm returns: a surface
659 /// doing per-scroll bookkeeping before the delta lands looks, records,
660 /// and declines.
661 /// * `Some(r)` — "this event is mine, and `r` is the surface's answer to
662 /// it". The shared treatment does not run at all. Both a surface's own
663 /// scroll-adjacent events (`ScrollIntoView` is the usual one) and an arm
664 /// that *rewrote* the event and fed the rewrite to
665 /// [`handle_scroll_event`] itself take this branch — including when the
666 /// rewrite could not move, where the answer is `Some(Ignored)` so the
667 /// whole original event chains outward.
668 ///
669 /// That last case is why this is an `Option` and not an [`EventResponse`].
670 /// A `Handled`-means-short-circuit rule cannot express "I consumed this
671 /// event and the answer is `Ignored`", so a remapping arm — the tables'
672 /// Shift+wheel — would fall through and have the shared handler apply the
673 /// *original* delta on top of the remapped one it just declined.
674 pub fn before(
675 mut self,
676 arm: impl Fn(&WidgetEvent, &mut EventContext) -> Option<EventResponse> + 'static,
677 ) -> Self {
678 self.before = Some(Rc::new(arm));
679 self
680 }
681
682 /// An arm the installed handler runs **last**, once the delta has landed.
683 ///
684 /// It sees the event and the answer the surface is about to give, and can
685 /// change neither: contradicting the boundary answer is how a chain stops
686 /// working. It is for the bookkeeping a surface can only do *after* the
687 /// offset moved — asking for a repaint being the one that matters, on a
688 /// surface whose offset signals are read at paint rather than bound to the
689 /// node.
690 ///
691 /// It runs on every path, including the one where a
692 /// [`before`](Self::before) arm claimed the event — a `before` arm that
693 /// answers `Handled` has moved the offset itself, which is exactly the
694 /// case this arm exists to notice.
695 pub fn after(
696 mut self,
697 arm: impl Fn(&WidgetEvent, EventResponse, &mut EventContext) + 'static,
698 ) -> Self {
699 self.after = Some(Rc::new(arm));
700 self
701 }
702
703 /// The scroller this behaviour will use, for a surface that must publish
704 /// its viewport into it from layout.
705 pub fn scroller(&self) -> Rc<RefCell<KineticScroller>> {
706 self.scroller.clone()
707 }
708
709 /// The options this behaviour resolved to. Exposed for a surface that
710 /// wants to answer the same boundary question from a second call site
711 /// (a keyboard page, an AT scroll action).
712 pub fn options(&self) -> ScrollHandlingOptions {
713 self.options
714 }
715
716 /// Attach the pan claim and the scroll handler to `handlers`.
717 pub fn install(self, handlers: HandlerSet) -> HandlerSet {
718 let Self {
719 axes,
720 scroller,
721 options,
722 before,
723 after,
724 } = self;
725
726 // A scroller is built around its style, so the resolved style is
727 // stamped in here — at build, where the theme and the preference that
728 // decide it are in scope — rather than re-asserted per event. The
729 // handle is the caller's, so a surface that publishes its viewport
730 // from layout keeps writing to the right object; the range and the
731 // offset are re-read from the signals on the next sample either way.
732 {
733 let mut s = scroller.borrow_mut();
734 *s = KineticScroller::with_tokens(options.effective_style(), &options.physics);
735 s.set_reduced_motion(options.reduced_motion);
736 }
737
738 let handlers = if options.axes == PanAxes::NONE {
739 handlers
740 } else {
741 handlers.pan_claim(PanClaim {
742 axes: options.axes,
743 devices: options.pan_devices,
744 kinetic: true,
745 })
746 };
747
748 handlers.on_scroll(move |event, ctx| {
749 // `before` answering `Some` means it OWNS this event: the shared
750 // treatment is skipped whatever the answer is. Skipping only on
751 // `Handled` would run the shared handler on the original event
752 // after a remapping arm had already consumed it — which is how a
753 // Shift+wheel notch a `Chain` table could not absorb sideways
754 // ended up scrolling its rows vertically as well. (`Ignored` is
755 // the boundary answer only under `Chain`; a `Contain` surface
756 // answered `Handled` and short-circuited even before this.)
757 let response = match before.as_ref().and_then(|before| before(event, ctx)) {
758 Some(response) => response,
759 None => handle_scroll_event(event, &axes, &scroller, &options, ctx),
760 };
761 if let Some(after) = &after {
762 after(event, response, ctx);
763 }
764 response
765 })
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use teksilo_canvas::{Size, SizeProposal};
773 use teksilo_core::build_context::BuildContext;
774 use teksilo_core::event::Modifiers;
775 use teksilo_core::pointer::clock::ManualClock;
776 use teksilo_core::pointer::{
777 BackendDeviceKey, PointerId, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
778 };
779 use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget};
780 use teksilo_core::widget_id::WidgetId;
781 use teksilo_core::widget_tree::WidgetTree;
782
783 // -- fixture ---------------------------------------------------------
784
785 /// A minimal scrollable: nothing but a [`ScrollableBehavior`] on a leaf
786 /// that fills whatever it is proposed. Everything a real surface adds —
787 /// content, bars, viewport metrics — is beside the point here.
788 #[derive(Debug)]
789 struct Surface {
790 axes: ScrollableAxes,
791 scroller: Rc<RefCell<KineticScroller>>,
792 options: ScrollHandlingOptions,
793 pan_axes: PanAxes,
794 viewport: f32,
795 /// One already-registered child, for the nested fixture.
796 child: Option<WidgetId>,
797 }
798
799 impl Surface {
800 fn new(axes: ScrollableAxes) -> Self {
801 Self {
802 axes,
803 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
804 options: ScrollHandlingOptions {
805 smooth: false,
806 ..Default::default()
807 },
808 pan_axes: PanAxes::BOTH,
809 viewport: 200.0,
810 child: None,
811 }
812 }
813 }
814
815 impl Widget for Surface {
816 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
817 let behavior = ScrollableBehavior::new(self.axes.clone())
818 .with_scroller(self.scroller.clone())
819 .axes(self.pan_axes)
820 .overscroll(self.options.overscroll_behavior)
821 .rubber_band(self.options.rubber_band)
822 .overscroll_style(self.options.overscroll_style)
823 .smooth(self.options.smooth)
824 .smooth_duration(self.options.smooth_duration)
825 .line_height(self.options.line_height)
826 .pan_devices(self.options.pan_devices)
827 .reduced_motion(self.options.reduced_motion);
828 ctx.apply_self_handlers(behavior.install(HandlerSet::new()));
829 self.scroller
830 .borrow_mut()
831 .set_viewport(Vec2::new(self.viewport, self.viewport));
832 self.child.into_iter().collect()
833 }
834
835 fn children(&self) -> Vec<WidgetId> {
836 self.child.into_iter().collect()
837 }
838
839 fn place_children(
840 &self,
841 bounds: teksilo_canvas::Rect,
842 _proposal: SizeProposal,
843 children: &mut [teksilo_core::widget::WidgetPlacement],
844 _ctx: &LayoutContext,
845 ) {
846 for child in children.iter_mut() {
847 child.origin = bounds.origin();
848 child.size = bounds.size();
849 }
850 }
851
852 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
853 Size::new(
854 proposal.width.unwrap_or(self.viewport),
855 proposal.height.unwrap_or(self.viewport),
856 )
857 .into()
858 }
859 }
860
861 /// A tree holding one `Surface`, laid out 200 × 200 with the pointer parked
862 /// inside it so a positionless wheel event has somewhere to go.
863 struct Fixture {
864 tree: WidgetTree,
865 id: WidgetId,
866 axes: ScrollableAxes,
867 }
868
869 fn fixture(build: impl FnOnce(&mut Surface)) -> Fixture {
870 let axes = ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(1000.0));
871 let mut surface = Surface::new(axes.clone());
872 build(&mut surface);
873 let mut tree = WidgetTree::new();
874 let id = tree.add(surface);
875 tree.layout(SizeProposal::exact(200.0, 200.0));
876 tree.pointer_move(Point::new(100.0, 100.0));
877 Fixture { tree, id, axes }
878 }
879
880 /// Two `Surface`s, one inside the other, so a boundary answer is
881 /// observable as movement on the container rather than as a return value
882 /// no public API hands back.
883 struct Nested {
884 tree: WidgetTree,
885 inner: ScrollableAxes,
886 outer: ScrollableAxes,
887 }
888
889 fn nested(inner_max: f32, outer_max: f32, contain: bool) -> Nested {
890 let inner_axes =
891 ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(inner_max));
892 let outer_axes =
893 ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(outer_max));
894 let mut inner = Surface::new(inner_axes.clone());
895 if contain {
896 inner.options.overscroll_behavior = OverscrollBehavior::Contain;
897 }
898 let mut outer = Surface::new(outer_axes.clone());
899 let mut tree = WidgetTree::new();
900 let inner_id = tree.add(inner);
901 outer.child = Some(inner_id);
902 let outer_id = tree.add(outer);
903 let _ = outer_id;
904 tree.layout(SizeProposal::exact(200.0, 200.0));
905 tree.pointer_move(Point::new(100.0, 100.0));
906 Nested {
907 tree,
908 inner: inner_axes,
909 outer: outer_axes,
910 }
911 }
912
913 fn wheel(f: &mut Fixture, dy: f32) {
914 f.tree.dispatch_event(WidgetEvent::scroll(
915 ScrollDelta::Pixels { x: 0.0, y: dy },
916 Modifiers::NONE,
917 ));
918 }
919
920 fn contact_id(raw: u64) -> PointerId {
921 let alloc = PointerIdAllocator::global();
922 let device = BackendDeviceKey::new(0x5C40);
923 let id = alloc.begin(device, raw);
924 alloc.end(device, raw);
925 id
926 }
927
928 fn contact(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
929 PointerSample {
930 pointer: PointerInfo::touch(id, EventTime::ZERO),
931 phase,
932 position: at,
933 button: None,
934 modifiers: Modifiers::NONE,
935 coalesced: Vec::new(),
936 }
937 }
938
939 fn pan_slop() -> f32 {
940 teksilo_core::gesture::default_profile(teksilo_tokens::PointerKind::Touch)
941 .pan_slop
942 .expect("a touch profile pans")
943 }
944
945 /// Press at `from` and drag the finger by `dy`, crossing the pan slop
946 /// first so the claim is taken. Returns the finger's final position.
947 fn drag(tree: &mut WidgetTree, id: PointerId, from: Point, dy: f32) -> Point {
948 tree.dispatch_pointer(contact(id, PointerPhase::Down, from));
949 let arm = Point::new(from.x, from.y + pan_slop().copysign(dy) + dy.signum());
950 tree.dispatch_pointer(contact(id, PointerPhase::Move, arm));
951 let at = Point::new(from.x, arm.y + (dy - (arm.y - from.y)));
952 tree.dispatch_pointer(contact(id, PointerPhase::Move, at));
953 at
954 }
955
956 // -- the wheel path --------------------------------------------------
957
958 /// A line notch is worth the line height; a pixel notch is itself.
959 #[test]
960 fn a_line_notch_is_worth_the_line_height_and_a_pixel_notch_is_itself() {
961 let mut f = fixture(|s| s.options.line_height = 17.0);
962 f.tree.dispatch_event(WidgetEvent::scroll(
963 ScrollDelta::Lines { x: 0.0, y: 3.0 },
964 Modifiers::NONE,
965 ));
966 assert_eq!(f.axes.y.get(), 51.0, "3 lines × 17 dp");
967 wheel(&mut f, 9.0);
968 assert_eq!(f.axes.y.get(), 60.0, "a pixel delta is not scaled");
969 }
970
971 /// A wheel event at a hard boundary is declined, so it bubbles to the
972 /// container around it; `Contain` absorbs the same event and the container
973 /// never sees it. Nothing moves inside either way.
974 #[test]
975 fn a_clamped_wheel_chains_unless_contained() {
976 let mut n = nested(100.0, 1000.0, false);
977 n.tree.dispatch_event(WidgetEvent::scroll(
978 ScrollDelta::Pixels { x: 0.0, y: 500.0 },
979 Modifiers::NONE,
980 ));
981 assert_eq!(n.inner.y.get(), 100.0, "the inner surface reached its end");
982 n.tree.dispatch_event(WidgetEvent::scroll(
983 ScrollDelta::Pixels { x: 0.0, y: 50.0 },
984 Modifiers::NONE,
985 ));
986 assert_eq!(
987 n.outer.y.get(),
988 50.0,
989 "…and the next notch went to the container"
990 );
991
992 let mut n = nested(100.0, 1000.0, true);
993 n.tree.dispatch_event(WidgetEvent::scroll(
994 ScrollDelta::Pixels { x: 0.0, y: 500.0 },
995 Modifiers::NONE,
996 ));
997 n.tree.dispatch_event(WidgetEvent::scroll(
998 ScrollDelta::Pixels { x: 0.0, y: 50.0 },
999 Modifiers::NONE,
1000 ));
1001 assert_eq!(
1002 n.inner.y.get(),
1003 100.0,
1004 "Contain absorbs, it does not scroll"
1005 );
1006 assert_eq!(n.outer.y.get(), 0.0, "…and nothing reaches the container");
1007 }
1008
1009 /// The same rule for a finger, along the claimant chain: the whole event
1010 /// goes outward at the boundary, with no residual left behind.
1011 #[test]
1012 fn a_boundary_pan_hands_the_whole_event_outward() {
1013 let mut n = nested(0.0, 1000.0, false);
1014 drag(&mut n.tree, contact_id(20), Point::new(100.0, 150.0), -60.0);
1015 assert_eq!(
1016 n.inner.y.get(),
1017 0.0,
1018 "the inner surface had nothing to give"
1019 );
1020 assert!(
1021 n.outer.y.get() > 0.0,
1022 "so the container took the pan: {}",
1023 n.outer.y.get()
1024 );
1025 }
1026
1027 /// A smooth notch aims a tween, and the next notch accumulates onto that
1028 /// target rather than onto the frame the tween has reached — which is what
1029 /// makes a fast series of notches travel the sum of its deltas.
1030 #[test]
1031 fn smooth_notches_accumulate_on_the_animation_target() {
1032 let mut f = fixture(|s| s.options.smooth = true);
1033 wheel(&mut f, 40.0);
1034 assert_eq!(f.axes.y.animation_target(), Some(40.0));
1035 wheel(&mut f, 40.0);
1036 assert_eq!(
1037 f.axes.y.animation_target(),
1038 Some(80.0),
1039 "the second notch aims past the first"
1040 );
1041 }
1042
1043 // -- the pan path ----------------------------------------------------
1044
1045 /// A finger scrolls the surface, and the content moves against the finger.
1046 #[test]
1047 fn a_finger_pans_the_surface() {
1048 let mut f = fixture(|_| {});
1049 drag(&mut f.tree, contact_id(1), Point::new(100.0, 150.0), -60.0);
1050 assert!(
1051 f.axes.y.get() > 0.0,
1052 "dragging the finger up scrolls down: {}",
1053 f.axes.y.get()
1054 );
1055 }
1056
1057 /// A pan never tweens, whatever `smooth` says: the content is under the
1058 /// finger, so it is already the animation.
1059 #[test]
1060 fn a_pan_never_tweens() {
1061 let mut f = fixture(|s| s.options.smooth = true);
1062 drag(&mut f.tree, contact_id(2), Point::new(100.0, 150.0), -60.0);
1063 assert!(f.axes.y.get() > 0.0);
1064 assert_eq!(f.axes.y.animation_target(), None);
1065 }
1066
1067 /// A fast release hands off to the tree's coast, and the coast keeps the
1068 /// surface moving after the finger is gone.
1069 #[test]
1070 fn a_release_flings_and_the_coast_keeps_scrolling() {
1071 let mut f = fixture(|_| {});
1072 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
1073 f.tree.set_input_clock(clock.clone());
1074
1075 let finger = contact_id(3);
1076 let from = Point::new(100.0, 180.0);
1077 f.tree
1078 .dispatch_pointer(contact(finger, PointerPhase::Down, from));
1079 let mut y = from.y;
1080 for step in 1..=5 {
1081 clock.set(EventTime::from_millis(step * 4));
1082 y -= 20.0;
1083 f.tree
1084 .dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
1085 }
1086 clock.set(EventTime::from_millis(24));
1087 f.tree
1088 .dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
1089
1090 assert!(f.tree.is_flinging(f.id), "a fast release starts a coast");
1091 let at_release = f.axes.y.get();
1092 f.tree.advance_time(Duration::from_millis(100));
1093 assert!(
1094 f.axes.y.get() > at_release,
1095 "the coast moved the surface: {at_release} -> {}",
1096 f.axes.y.get()
1097 );
1098 }
1099
1100 /// Reduced motion turns the coast off outright: the surface stops exactly
1101 /// where the finger left it.
1102 #[test]
1103 fn reduced_motion_collapses_the_fling_to_where_the_finger_left_it() {
1104 let mut f = fixture(|s| s.options.reduced_motion = true);
1105 f.tree.set_accessibility_preferences(false, true, 1.0);
1106 let clock = Rc::new(ManualClock::new(EventTime::ZERO));
1107 f.tree.set_input_clock(clock.clone());
1108
1109 let finger = contact_id(4);
1110 let from = Point::new(100.0, 180.0);
1111 f.tree
1112 .dispatch_pointer(contact(finger, PointerPhase::Down, from));
1113 let mut y = from.y;
1114 for step in 1..=5 {
1115 clock.set(EventTime::from_millis(step * 4));
1116 y -= 20.0;
1117 f.tree
1118 .dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
1119 }
1120 clock.set(EventTime::from_millis(24));
1121 f.tree
1122 .dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
1123
1124 assert!(!f.tree.is_flinging(f.id), "reduced motion starts no coast");
1125 let at_release = f.axes.y.get();
1126 f.tree.advance_time(Duration::from_millis(200));
1127 assert_eq!(
1128 f.axes.y.get(),
1129 at_release,
1130 "and nothing moves it afterwards"
1131 );
1132 }
1133
1134 /// With the band on, a pan past the end keeps following the finger with
1135 /// decreasing gain, publishes the excess as overscroll — and the offset
1136 /// itself never leaves the range. The release puts it back.
1137 #[test]
1138 fn the_rubber_band_holds_past_the_end_and_releases_on_the_lift() {
1139 let mut f = fixture(|s| {
1140 s.axes.max_y.set(100.0);
1141 s.options.rubber_band = true;
1142 s.options.overscroll_style = OverscrollStyle::RubberBand;
1143 });
1144 f.axes.y.set(100.0);
1145
1146 let finger = contact_id(5);
1147 let at = drag(&mut f.tree, finger, Point::new(100.0, 180.0), -80.0);
1148 assert_eq!(f.axes.y.get(), 100.0, "the offset stays inside the range");
1149 let held = f.axes.overscroll.get().y;
1150 assert!(held > 0.0, "the band is holding the content past the end");
1151 assert!(
1152 held < 80.0,
1153 "…with decreasing gain, so less far than the finger travelled: {held}"
1154 );
1155
1156 f.tree
1157 .dispatch_pointer(contact(finger, PointerPhase::Up, at));
1158 assert_eq!(
1159 f.axes.overscroll.get(),
1160 Vec2::ZERO,
1161 "the lift releases the band"
1162 );
1163 assert_eq!(f.axes.y.get(), 100.0);
1164 }
1165
1166 /// Reduced motion hard-clamps the band: the pan stops at the end and there
1167 /// is no overscroll to release.
1168 #[test]
1169 fn reduced_motion_hard_clamps_the_band() {
1170 let mut f = fixture(|s| {
1171 s.axes.max_y.set(100.0);
1172 s.options.rubber_band = true;
1173 s.options.overscroll_style = OverscrollStyle::RubberBand;
1174 s.options.reduced_motion = true;
1175 });
1176 f.axes.y.set(100.0);
1177 drag(&mut f.tree, contact_id(6), Point::new(100.0, 180.0), -80.0);
1178 assert_eq!(f.axes.overscroll.get(), Vec2::ZERO);
1179 assert_eq!(f.axes.y.get(), 100.0);
1180 }
1181
1182 /// An axis the surface does not claim takes nothing from a finger.
1183 #[test]
1184 fn a_pan_on_an_unclaimed_axis_takes_nothing() {
1185 let mut f = fixture(|s| s.pan_axes = PanAxes::X);
1186 drag(&mut f.tree, contact_id(7), Point::new(100.0, 150.0), -60.0);
1187 assert_eq!(f.axes.y.get(), 0.0, "the vertical axis is not this one's");
1188 }
1189
1190 /// A mouse is not a panning pointer. It has no `pan_slop` at all, so a
1191 /// press-and-drag with a mouse button scrolls nothing — the wheel is its
1192 /// scroll device.
1193 #[test]
1194 fn a_mouse_drag_does_not_pan() {
1195 let mut f = fixture(|_| {});
1196 f.tree.dispatch_event(WidgetEvent::pointer_down(
1197 Point::new(100.0, 150.0),
1198 teksilo_core::event::PointerButton::Primary,
1199 Modifiers::NONE,
1200 ));
1201 f.tree.pointer_move(Point::new(100.0, 60.0));
1202 assert_eq!(f.axes.y.get(), 0.0);
1203 }
1204
1205 /// An offset moved by somebody else — a scroll bar, an `ensure_visible` —
1206 /// is picked up by the next pan sample rather than being overwritten by
1207 /// the stale position the scroller was still holding.
1208 #[test]
1209 fn an_externally_moved_offset_is_picked_up_by_the_next_pan() {
1210 let mut f = fixture(|_| {});
1211 let finger = contact_id(8);
1212 let at = drag(&mut f.tree, finger, Point::new(100.0, 150.0), -40.0);
1213 let after_pan = f.axes.y.get();
1214 assert!(after_pan > 0.0);
1215
1216 // A scroll bar drag writes the shared signal directly.
1217 f.axes.y.set(500.0);
1218 f.tree.dispatch_pointer(contact(
1219 finger,
1220 PointerPhase::Move,
1221 Point::new(at.x, at.y - 10.0),
1222 ));
1223 assert!(
1224 f.axes.y.get() > 500.0,
1225 "the pan continued from where the bar left it, not from {after_pan}"
1226 );
1227 }
1228
1229 // -- install ---------------------------------------------------------
1230
1231 /// `install` attaches both halves. Without the claim a finger has nothing
1232 /// to catch, so the proof is that a real contact scrolls the surface —
1233 /// which is exactly what the two pan tests above already exercise. What
1234 /// this one pins is the *other* direction: a surface that claims no axis
1235 /// stays wheel-scrollable and catches no finger.
1236 #[test]
1237 fn a_surface_with_no_claim_still_scrolls_on_a_wheel() {
1238 let mut f = fixture(|s| s.pan_axes = PanAxes::NONE);
1239 wheel(&mut f, 40.0);
1240 assert_eq!(f.axes.y.get(), 40.0);
1241 drag(&mut f.tree, contact_id(9), Point::new(100.0, 150.0), -60.0);
1242 assert_eq!(f.axes.y.get(), 40.0, "no claim, no pan");
1243 }
1244
1245 /// The `before` arm runs first for every event: `Some` short-circuits
1246 /// whatever the answer is, `None` observes and falls through.
1247 #[test]
1248 fn the_before_arm_observes_then_falls_through() {
1249 #[derive(Debug)]
1250 struct Observed {
1251 axes: ScrollableAxes,
1252 seen: Rc<Cell<usize>>,
1253 }
1254 impl Widget for Observed {
1255 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1256 let seen = self.seen.clone();
1257 let behavior = ScrollableBehavior::new(self.axes.clone())
1258 .smooth(false)
1259 .before(move |event, _ctx| {
1260 if matches!(event, WidgetEvent::Scroll { .. }) {
1261 seen.set(seen.get() + 1);
1262 }
1263 // Observes and declines, so the shared handler still
1264 // runs on this event.
1265 None
1266 });
1267 ctx.apply_self_handlers(behavior.install(HandlerSet::new()));
1268 Vec::new()
1269 }
1270 fn layout_response(
1271 &self,
1272 proposal: SizeProposal,
1273 _ctx: &LayoutContext,
1274 ) -> LayoutResponse {
1275 Size::new(
1276 proposal.width.unwrap_or(200.0),
1277 proposal.height.unwrap_or(200.0),
1278 )
1279 .into()
1280 }
1281 }
1282
1283 use std::cell::Cell;
1284 let axes = ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(1000.0));
1285 let seen = Rc::new(Cell::new(0));
1286 let mut tree = WidgetTree::new();
1287 tree.add(Observed {
1288 axes: axes.clone(),
1289 seen: seen.clone(),
1290 });
1291 tree.layout(SizeProposal::exact(200.0, 200.0));
1292 tree.pointer_move(Point::new(100.0, 100.0));
1293 tree.dispatch_event(WidgetEvent::scroll(
1294 ScrollDelta::Pixels { x: 0.0, y: 25.0 },
1295 Modifiers::NONE,
1296 ));
1297 assert_eq!(seen.get(), 1, "the arm saw the scroll");
1298 assert_eq!(axes.y.get(), 25.0, "observing does not stop the delta");
1299 }
1300
1301 /// The scroller handle survives `install`: a viewport published from the
1302 /// surface's own layout reaches the object the handler will read.
1303 #[test]
1304 fn the_callers_scroller_handle_survives_install() {
1305 let axes = ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(1000.0));
1306 let scroller = Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp)));
1307 let behavior = ScrollableBehavior::new(axes)
1308 .with_scroller(scroller.clone())
1309 .rubber_band(true)
1310 .overscroll_style(OverscrollStyle::RubberBand);
1311 assert!(Rc::ptr_eq(&behavior.scroller(), &scroller));
1312 let _ = behavior.install(HandlerSet::new());
1313 scroller.borrow_mut().set_range_y(0.0, 100.0);
1314 scroller.borrow_mut().set_offset(Point::new(0.0, 100.0));
1315 // The style `install` stamped in is the one the band needs: a drag past
1316 // the end is followed rather than refused.
1317 let step = scroller
1318 .borrow_mut()
1319 .pan(EventTime::ZERO, Point::ZERO, Vec2::new(0.0, 40.0));
1320 assert!(
1321 step.overscroll.y > 0.0,
1322 "install kept the rubber-band style"
1323 );
1324 }
1325
1326 /// A rebuild with the band turned off leaves the scroller clamping, so the
1327 /// two knobs cannot drift apart across a rebuild.
1328 #[test]
1329 fn install_without_the_band_leaves_the_scroller_clamping() {
1330 let axes = ScrollableAxes::vertical(Signal::new_animated(0.0), Signal::new(1000.0));
1331 let scroller = Rc::new(RefCell::new(KineticScroller::new(
1332 OverscrollStyle::RubberBand,
1333 )));
1334 let _ = ScrollableBehavior::new(axes)
1335 .with_scroller(scroller.clone())
1336 .rubber_band(false)
1337 .install(HandlerSet::new());
1338 scroller.borrow_mut().set_range_y(0.0, 100.0);
1339 scroller.borrow_mut().set_offset(Point::new(0.0, 100.0));
1340 let step = scroller
1341 .borrow_mut()
1342 .pan(EventTime::ZERO, Point::ZERO, Vec2::new(0.0, 40.0));
1343 assert_eq!(step.overscroll, Vec2::ZERO);
1344 }
1345}