Skip to main content

ui/scroll/
claim.rs

1//! Nesting: which pane a wheel belongs to.
2
3use super::*;
4
5/// Where a [`claim_wheel`] pane was before the wheel that is being dispatched.
6///
7/// Shaped like [`ScrollbarState`] and [`FollowState`], and owned by the view
8/// for the same reason: an element rebuilt every render cannot remember
9/// anything, and this has to outlive the frame it was written in.
10#[derive(Clone)]
11pub struct ClaimState(Rc<Cell<Point<Pixels>>>);
12
13impl Default for ClaimState {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl ClaimState {
20    pub fn new() -> Self {
21        Self(Rc::new(Cell::new(point(px(0.0), px(0.0)))))
22    }
23}
24
25/// Let a pane keep the wheel it can act on, instead of passing it to the pane
26/// behind as well.
27///
28/// gpui's scroll listener neither stops propagation nor asks whether an
29/// ancestor scrolls too, so a wheel over a nested pane moves *both* — an output
30/// box inside a transcript scrolls itself and drags the transcript with it
31/// (user report). Every scrolling ancestor under the pointer does this, so the
32/// deeper the nesting the further the page jumps.
33///
34/// The question it asks is whether the pane *moved*, not whether it had room
35/// to. This listener runs after the element's own — gpui registers that one
36/// later and the bubble phase runs the list backwards — so `handle` already
37/// holds the post-scroll offset, and `state` is where it stood before. "Had
38/// room" is the same answer one notch too late, and gets the notch that lands
39/// exactly on the end wrong: the pane finishes its travel *and* the page jumps
40/// a full notch behind it.
41///
42/// Chained at the ends, not sealed: a pane with nowhere left to go hands the
43/// wheel to the page, so reaching the end of a short inner list does not strand
44/// it there and make the pointer move. A pane whose content fits never claims
45/// anything, for the same reason. For `overscroll-behavior: contain` — a pane
46/// that never lets a wheel past — stop unconditionally instead.
47///
48/// ```ignore
49/// scroll::claim_wheel(
50///     scroll::pane("output", Axes::Vertical).track_scroll(&self.scroll),
51///     &self.scroll,
52///     Axes::Vertical,
53///     &self.claim,
54/// )
55/// ```
56///
57/// `axes` is what the pane scrolls, and must be what [`pane`] was given: an
58/// axis left out here is one whose movement goes unnoticed, so the wheel is
59/// handed on and the ancestor moves too.
60pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
61    el: E,
62    handle: &ScrollHandle,
63    axes: Axes,
64    state: &ClaimState,
65) -> E {
66    let handle = handle.clone();
67    let state = state.0.clone();
68    // Where the pane stands as the frame is built, which is where it stands
69    // before anything this frame's listeners are handed. The listener writes it
70    // too: a wheel the pane could not act on produces no `notify` and so no
71    // render, and the reading below has to stay true across that gap.
72    state.set(travel(&handle, axes));
73    el.on_scroll_wheel(move |_, _, cx| {
74        let now = travel(&handle, axes);
75        if now != state.get() {
76            state.set(now);
77            cx.stop_propagation();
78        }
79    })
80}
81
82/// How far `handle` has visibly travelled along each of `axes`. Negative, as
83/// gpui counts it; an axis the pane does not scroll reads zero forever, so it
84/// can never be mistaken for movement.
85///
86/// Clamped here because gpui's scroll listener is not: it adds the raw delta
87/// and leaves the clamp to the next `paint`, so a pane held at its end keeps
88/// accumulating offset it will never show. Compared raw, every notch past the
89/// end reads as movement and the pane never lets go of the wheel.
90pub(super) fn travel(handle: &ScrollHandle, axes: Axes) -> Point<Pixels> {
91    let (offset, max) = (handle.offset(), handle.max_offset());
92    let seen = |offset: Pixels, max: Pixels| offset.clamp(-max.max(px(0.0)), px(0.0));
93    point(
94        match axes.horizontal() {
95            true => seen(offset.x, max.x),
96            false => px(0.0),
97        },
98        match axes.vertical() {
99            true => seen(offset.y, max.y),
100            false => px(0.0),
101        },
102    )
103}