Skip to main content

ui/scroll/
drift.rs

1//! Drift: a pane that keeps moving while a drag is held at its edge.
2
3use super::*;
4
5/// How close to an edge a held drag starts the pane moving. Read off
6/// `../desktop`'s board (2026-05): a third of a column, wide enough to reach
7/// while aiming at a card and narrow enough to leave the middle still.
8pub const DRIFT_EDGE: Pixels = px(96.0);
9
10/// How fast a pane travels with the pointer at the very edge, in pixels a
11/// second. The same board's 22px per frame, said in a unit that does not
12/// double on a 120Hz display.
13pub const DRIFT_SPEED: f32 = 1320.0;
14
15/// The most one frame may travel, however late it ran. A frame dropped while
16/// the pointer rests at an edge costs a pause, not a jump to the far end.
17pub(super) const DRIFT_STEP: Duration = Duration::from_millis(50);
18
19/// How far a pane should travel in a second, for a pointer at `pointer`
20/// between edges `start` and `end`.
21///
22/// Signed the way gpui's offset is: positive travels back toward the start,
23/// because the offset goes negative as a pane scrolls on. Zero everywhere but
24/// within [`DRIFT_EDGE`] of an edge, where it ramps with proximity — easing
25/// toward the edge eases the scroll — and holds at full speed once the pointer
26/// is past it, so a card carried off the side of a board keeps it coming
27/// instead of stopping dead at the boundary.
28///
29/// The ramp is capped at half the pane, so one narrower than two edges has a
30/// still middle rather than a midpoint where the direction flips at half speed.
31pub fn drift_velocity(pointer: Pixels, start: Pixels, end: Pixels) -> f32 {
32    let edge = DRIFT_EDGE.as_f32().min((end - start).as_f32() / 2.0);
33    if edge <= 0.0 {
34        return 0.0;
35    }
36    let (from_start, from_end) = ((pointer - start).as_f32(), (end - pointer).as_f32());
37    // The nearer edge, which is what decides the direction — and what keeps
38    // the two ramps from summing where they overlap.
39    let near = from_start.min(from_end);
40    if near >= edge {
41        return 0.0;
42    }
43    let ramp = 1.0 - near.max(0.0) / edge;
44    match from_start < from_end {
45        true => DRIFT_SPEED * ramp,
46        false => -DRIFT_SPEED * ramp,
47    }
48}
49
50/// What lies past a drifting pane's edge, and so whether a pointer that has
51/// crossed it is still aiming at the pane.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum Beyond {
54    /// Nothing the drag could be meant for — a board that fills the window.
55    /// The pane holds at full speed for as long as the pointer is out there.
56    Nothing,
57    /// Another surface. The pane stops the moment the pointer leaves it,
58    /// whichever edge it left by.
59    Neighbour,
60}
61
62/// How fast a pane of `bounds` drifts with the pointer at `pointer`, one
63/// [`drift_velocity`] per axis [`Axes`] names.
64///
65/// Across an axis the pointer must be within the pane: without that, every
66/// lane of a board would drift together on a drag that is only near one of
67/// them. Along it, `beyond` decides.
68pub fn pane_velocity(
69    bounds: Bounds<Pixels>,
70    pointer: Point<Pixels>,
71    axes: Axes,
72    beyond: Beyond,
73) -> Point<f32> {
74    if beyond == Beyond::Neighbour && !bounds.contains(&pointer) {
75        return point(0.0, 0.0);
76    }
77    let mut velocity = point(0.0, 0.0);
78    if axes.horizontal() && (bounds.top()..=bounds.bottom()).contains(&pointer.y) {
79        velocity.x = drift_velocity(pointer.x, bounds.left(), bounds.right());
80    }
81    if axes.vertical() && (bounds.left()..=bounds.right()).contains(&pointer.x) {
82        velocity.y = drift_velocity(pointer.y, bounds.top(), bounds.bottom());
83    }
84    velocity
85}
86
87/// What one drifting pane remembers between frames.
88#[derive(Clone, Copy, Default)]
89pub(super) struct Drift {
90    /// Where the pointer was last seen carrying something this pane follows.
91    aim: Option<Point<Pixels>>,
92    /// When it last moved, and `None` whenever it is not moving — so a pane
93    /// picking the gesture back up starts its clock rather than travelling the
94    /// gap it stood still for.
95    since: Option<Instant>,
96}
97
98/// Where a drift was last aimed, and when it last moved.
99///
100/// Shaped like [`ScrollbarState`] and owned by the view for the same reason:
101/// the element is rebuilt every frame and cannot remember where the pointer
102/// was. One per pane — two panes sharing one would take turns reading each
103/// other's clock.
104#[derive(Clone, Default)]
105pub struct DriftState(Rc<Cell<Drift>>);
106
107impl DriftState {
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Where the pointer is. Call it from the drag-move listener of the
113    /// payload this pane should follow — that listener is typed, which is what
114    /// keeps a board from drifting under somebody dragging a scrollbar thumb:
115    ///
116    /// ```ignore
117    /// .on_drag_move(cx.listener(|this, event: &DragMoveEvent<CardDrag>, _, _| {
118    ///     this.drift.aim(event.event.position);
119    /// }))
120    /// ```
121    ///
122    /// Aimed rather than continuous: gpui reports a drag only while it moves,
123    /// and a pointer parked at an edge is the case the whole thing exists for.
124    pub fn aim(&self, pointer: Point<Pixels>) {
125        let drift = self.0.get();
126        self.0.set(Drift {
127            aim: Some(pointer),
128            ..drift
129        });
130    }
131}
132
133/// Move `handle` while a drag is held near its edge, for as long as it is held
134/// there.
135///
136/// Drop it in beside [`scrollbar`], over the same container, and feed it from
137/// the drag-move listener — see [`DriftState::aim`]:
138///
139/// ```ignore
140/// div().relative()
141///     .child(scroll::pane("board", Axes::Horizontal).size_full().track_scroll(&self.scroll).child(lanes))
142///     .child(scroll::drift(&self.scroll, &self.drift, Axes::Horizontal, Beyond::Nothing))
143/// ```
144///
145/// Without it a board is only as wide as the window: a card cannot be carried
146/// to a lane that is off screen, because reaching for one means letting go.
147///
148/// **The gesture ending is not an event this subscribes to.** gpui hands a
149/// drop to whatever was under the pointer, and a release over nothing is not
150/// delivered at all, so either would leave a pane drifting on a gesture that
151/// is over. The drag going away is the signal instead, and it arrives however
152/// the drag ended.
153///
154/// `beyond` is what the pane's edge gives onto, and decides whether a pointer
155/// carried past it still drives the pane — see [`Beyond`] and
156/// [`pane_velocity`].
157///
158/// Not motion in the [`motion`] sense and not reduced with it: nothing here
159/// animates a property, the pane is being scrolled by a gesture the same way a
160/// wheel scrolls it, and a reader who cannot reach the far lane has no gesture
161/// left to make.
162pub fn drift(
163    handle: &ScrollHandle,
164    state: &DriftState,
165    axes: Axes,
166    beyond: Beyond,
167) -> gpui::AnyElement {
168    let handle = handle.clone();
169    let state = state.clone();
170    canvas(
171        move |_, window, cx: &mut App| {
172            let drift = state.0.get();
173            // Nothing aimed here, or the gesture that aimed it has ended.
174            let Some(pointer) = drift.aim.filter(|_| cx.has_active_drag()) else {
175                state.0.set(Drift::default());
176                return;
177            };
178
179            let velocity = pane_velocity(handle.bounds(), pointer, axes, beyond);
180            // Aimed here but nowhere near an edge, or gone off to a
181            // neighbour. The clock stops with it, so a drag wandering back to
182            // the edge a second later starts a fresh drift rather than
183            // travelling the second it stood still.
184            if velocity.x == 0.0 && velocity.y == 0.0 {
185                state.0.set(Drift {
186                    aim: Some(pointer),
187                    since: None,
188                });
189                return;
190            }
191
192            let now = cx.background_executor().now();
193            state.0.set(Drift {
194                aim: Some(pointer),
195                since: Some(now),
196            });
197            // The first frame of a drift is the one that starts the clock;
198            // there is no interval yet to travel over.
199            let Some(last) = drift.since else {
200                window.request_animation_frame();
201                return;
202            };
203
204            let step = (now - last).min(DRIFT_STEP).as_secs_f32();
205            let (offset, max) = (handle.offset(), handle.max_offset());
206            let moved = point(
207                (offset.x + px(velocity.x * step)).clamp(-max.x, px(0.0)),
208                (offset.y + px(velocity.y * step)).clamp(-max.y, px(0.0)),
209            );
210            if moved == offset {
211                // Held at an end that has nowhere left to go. Asking for
212                // another frame here would spin at the display's rate for as
213                // long as the drag is held; the next pointer move draws one
214                // anyway, and that is the soonest this could have anything to
215                // do.
216                return;
217            }
218            handle.set_offset(moved);
219            window.request_animation_frame();
220        },
221        |_, _, _, _| {},
222    )
223    .absolute()
224    .size_full()
225    .into_any_element()
226}