Skip to main content

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}