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}