Skip to main content

ui/
scroll.rs

1//! What gpui's own scroll handles leave to the app: a container that scrolls
2//! the way it was asked to, a bar to show the position, and a rule for which
3//! pane a wheel belongs to.
4//!
5//! [`pane`] is the container — an axis given at construction, the way
6//! SwiftUI's `ScrollView` takes one, because gpui's is a style field that
7//! defaults to unset and gets guessed at. Read its docs before reaching for
8//! `div().overflow_y_scroll()`; the guess is a real bug and not a small one.
9//!
10//! gpui scrolls that pane perfectly well and draws nothing while it does, so a
11//! bezel app has no way to show how far down it is. That is [`scrollbar`]: an
12//! overlay the caller lays over its own pane, because a wrapper that swallowed
13//! the content would have to re-implement layout for it. Nesting two panes is
14//! [`claim_wheel`]'s business.
15//!
16//! ```ignore
17//! div().relative()                                  // the bar is absolute in here
18//!     .child(
19//!         scroll::pane("pane", Axes::Vertical)
20//!             .size_full()
21//!             .track_scroll(&self.scroll)           // gpui's handle, the app's field
22//!             .child(content),
23//!     )
24//!     .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
25//! ```
26//!
27//! The bar must span the container it reports on — its track *is* the viewport,
28//! in the coordinates [`thumb`] answers in.
29//!
30//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
31//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
32//! system around the fifteen that matter. Two of gpui's conventions are easy to
33//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
34//! (content minus viewport, not content), and `offset` is **negative** as you
35//! scroll down.
36//!
37//! [`transient`] is the same bar, shown only while its content moves.
38
39use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
40
41use gpui::{
42    Animation, AnimationExt, App, Axis, Div, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
43    Point, ScrollHandle, SharedString, Stateful, Window, canvas, div, point, prelude::*, px,
44};
45
46use motion::Painter;
47use theme::ink;
48use web_time::Instant;
49
50/// Shortest a thumb may get, however long the document — below this it stops
51/// being something a pointer can catch.
52pub const MIN_THUMB: Pixels = px(25.0);
53/// Width of the strip the thumb sits in.
54const TRACK: f32 = 10.0;
55/// Width of the thumb itself, centred in the track.
56const THUMB: f32 = 6.0;
57/// Length of one [`rail`] mark, and its thickness.
58const MARK: f32 = 16.0;
59const MARK_THICK: f32 = 2.0;
60/// Between two marks. The track's width, so a rail and a bar on the same pane
61/// are cut to one rhythm.
62const MARK_GAP: f32 = TRACK;
63/// How far the rail stands off the edge it is pinned to.
64const RAIL_INSET: f32 = 12.0;
65/// What a rail needs beside the content before it will paint at all.
66pub const RAIL_ROOM: f32 = RAIL_INSET + MARK;
67
68// ---------------------------------------------------------------------------
69// Pane — a scroll container whose axis is an argument, not a modifier
70// ---------------------------------------------------------------------------
71
72/// Which way a [`pane`] scrolls. SwiftUI's `Axis.Set`, which gpui's [`Axis`]
73/// has no spelling for: a pane that scrolls both ways is not one of two
74/// directions.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum Axes {
77    Vertical,
78    Horizontal,
79    Both,
80}
81
82impl Axes {
83    pub fn vertical(self) -> bool {
84        matches!(self, Axes::Vertical | Axes::Both)
85    }
86
87    pub fn horizontal(self) -> bool {
88        matches!(self, Axes::Horizontal | Axes::Both)
89    }
90
91    /// The gpui axis this is, where it is only one.
92    pub fn axis(self) -> Option<Axis> {
93        match self {
94            Axes::Vertical => Some(Axis::Vertical),
95            Axes::Horizontal => Some(Axis::Horizontal),
96            Axes::Both => None,
97        }
98    }
99}
100
101/// A scroll container, with the axis as an argument rather than a modifier you
102/// can forget.
103///
104/// ```ignore
105/// scroll::pane("log", Axes::Vertical)
106///     .size_full()
107///     .track_scroll(&self.scroll)
108///     .child(content)
109/// ```
110///
111/// Returns an element for the caller to fill, the way [`crate::stack::row`]
112/// does — it takes no children and lays nothing out, so the pane stays the
113/// app's and only its scroll behaviour is decided here. The id is gpui's
114/// requirement, not ours: a scroll container has state to track.
115///
116/// # Why this exists rather than `div().overflow_y_scroll()`
117///
118/// gpui makes scrollability a late-bound style field with no default, and then
119/// has to guess what to do when a gesture's axis is not one the container
120/// scrolls: it **remaps the delta onto whichever axis the container can
121/// scroll**. A sideways swipe over a vertical list scrolls it down; a downward
122/// swipe over a wide table pans it sideways. `restrict_scroll_to_axis` turns
123/// that off, but it is opt-in per element, so every pane that forgets it is
124/// wrong and nothing says so.
125///
126/// SwiftUI has no such case to guess at — `ScrollView(.vertical)` takes its
127/// axis at construction, so there is no container whose axis is unstated. This
128/// is that: ask for an axis, get a pane that answers only to it.
129///
130/// A horizontal pane also contains a sideways gesture ([`contain_sideways`]),
131/// because the pane it is nested in usually belongs to a consumer and is not
132/// ours to restrict.
133///
134/// `Axes::Both` inherits gpui's dominant-axis lock — a diagonal gesture moves
135/// one axis, not two. gpui exposes no builder for `allow_concurrent_scroll`.
136pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
137    scrolls(div().id(id), axes)
138}
139
140/// [`pane`]'s answer applied to an element that already exists — a container
141/// that scrolls only at some widths, or one another builder handed back.
142///
143/// ```ignore
144/// strip.when(compact, |strip| scroll::scrolls(strip, Axes::Horizontal))
145/// ```
146pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
147    let el = match axes {
148        Axes::Vertical => el.overflow_y_scroll(),
149        Axes::Horizontal => el.overflow_x_scroll(),
150        Axes::Both => el.overflow_scroll(),
151    }
152    .restrict_scroll_to_axis();
153    match axes.horizontal() {
154        true => contain_sideways(el),
155        false => el,
156    }
157}
158
159/// Keep a sideways gesture inside the pane it started in.
160///
161/// The other half of [`pane`], and the half [`Axes::Vertical`] does not want:
162/// a vertical pane at its end should hand the wheel to the page behind it
163/// ([`claim_wheel`] is that chaining), but a sideways gesture reaching a
164/// vertical ancestor is never right — unless that ancestor is restricted too,
165/// it will remap the delta and scroll down.
166///
167/// Applied by [`pane`] for the axes that need it. Public because a consumer
168/// wrapping bezel's content in a scroller of its own has the same problem and
169/// the same fix.
170///
171/// Registered before the element's own handler and so run after it — gpui
172/// bubbles the list backwards — which is why the pane has already moved by the
173/// time the event stops here.
174pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
175    el.on_scroll_wheel(|event, window, cx| {
176        let delta = event.delta.pixel_delta(window.line_height());
177        // The dominant axis, not "any horizontal component": a trackpad puts a
178        // little of both into every gesture, and a mostly-vertical one still
179        // belongs to the page.
180        if delta.x.abs() > delta.y.abs() {
181            cx.stop_propagation();
182        }
183    })
184}
185
186/// Where the thumb sits in a track of `viewport` length, as a range from the
187/// track's start — or `None` when there is nothing to scroll.
188///
189/// `None` also covers a viewport of zero (the frame before layout has run) and
190/// a thumb that would not fit, which is zed's third guard: with a viewport
191/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
192pub fn thumb(
193    viewport: Pixels,
194    max_offset: Pixels,
195    offset: Pixels,
196    min: Pixels,
197) -> Option<Range<Pixels>> {
198    if viewport <= px(0.0) || max_offset <= px(0.0) {
199        return None;
200    }
201    let content = viewport + max_offset;
202    let size = min.max(viewport * (viewport / content));
203    if size > viewport {
204        return None;
205    }
206    // Negative going down, and never past either end — a wheel can overshoot.
207    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
208    let start = (travelled / max_offset) * (viewport - size);
209    Some(start..start + size)
210}
211
212/// The inverse: the scroll offset that puts the thumb's top at `top`.
213///
214/// Negative, because that is the direction gpui counts in, and clamped to the
215/// scrollable range so a drag past either end simply stops.
216pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
217    let travel = viewport - size;
218    if travel <= px(0.0) || max_offset <= px(0.0) {
219        return px(0.0);
220    }
221    -(max_offset * (top / travel).clamp(0.0, 1.0))
222}
223
224/// The drag payload. Carries the bar's id because, unlike a split, an app has
225/// several of these on screen at once and `on_drag_move` filters by type alone
226/// — without the id every bar in the window would answer one thumb's gesture.
227#[derive(Clone)]
228pub struct ScrollbarDrag(pub SharedString);
229
230/// Where in the thumb a drag was grabbed.
231///
232/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
233/// and the bar clones — for the same reason: both mutate through `&self`, so
234/// the bar carries its whole gesture without the view wiring a single listener.
235/// Without it the thumb would jump its middle to the pointer on every press.
236#[derive(Clone)]
237pub struct ScrollbarState {
238    grab: Rc<Cell<Option<Pixels>>>,
239    /// A drag runs in event-dispatch context, where the window cannot resolve
240    /// which view is asking — so the bar carries its own.
241    painter: Painter,
242}
243
244impl ScrollbarState {
245    pub fn new(painter: Painter) -> Self {
246        Self {
247            grab: Rc::new(Cell::new(None)),
248            painter,
249        }
250    }
251
252    /// Whether a thumb drag is in flight.
253    pub fn dragging(&self) -> bool {
254        self.grab.get().is_some()
255    }
256
257    /// One drag move of the thumb: filter the gesture to this bar's track, then
258    /// translate the pointer into a scroll offset.
259    fn drag(
260        &self,
261        track_id: &SharedString,
262        handle: &ScrollHandle,
263        event: &DragMoveEvent<ScrollbarDrag>,
264        cx: &mut App,
265    ) {
266        // Another bar's thumb: `on_drag_move` filters by payload type, and
267        // every bar in the window shares this one.
268        if event.drag(cx).0 != *track_id {
269            return;
270        }
271        let viewport = handle.bounds().size.height;
272        let max_offset = handle.max_offset().y;
273        let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
274            return;
275        };
276        let size = range.end - range.start;
277        let pointer = event.event.position.y - event.bounds.top();
278        // First move of this drag: the offset has not shifted yet, so the
279        // thumb is still where the press landed on it and the grab is
280        // simply the difference. Held for the rest of the gesture — read it
281        // again later and it would answer "wherever the pointer is now",
282        // which is a thumb that never moves.
283        let grab = self.grab.get().unwrap_or_else(|| {
284            let grab = (pointer - range.start).clamp(px(0.0), size);
285            self.grab.set(Some(grab));
286            grab
287        });
288        let offset = offset_for_thumb(pointer - grab, viewport, max_offset, size);
289        handle.set_offset(point(handle.offset().x, offset));
290        self.painter.notify(cx);
291    }
292}
293
294/// The bar: an overlay strip along the right edge of whatever it is laid over,
295/// showing nothing at all when the content fits.
296///
297/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
298/// content beneath it.
299///
300/// Its geometry comes from the handle as the *last* frame left it, which is all
301/// a render pass can see; the canvas at the end asks for one more frame when
302/// layout disagrees, so the bar is right on the frame after it first appears
303/// rather than whenever something else happens to repaint.
304///
305/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
306/// rather than a toned surface, so the thumb is [`ink`], which already follows
307/// the appearance on its own. A parameter it ignored would be worse than none.
308pub fn scrollbar(
309    id: impl Into<SharedString>,
310    handle: &ScrollHandle,
311    state: &ScrollbarState,
312) -> gpui::AnyElement {
313    let id = id.into();
314    let viewport = handle.bounds().size.height;
315    let max_offset = handle.max_offset().y;
316    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
317        return Empty.into_any_element();
318    };
319    let size = range.end - range.start;
320    let dragging = state.dragging();
321
322    let track_id = id.clone();
323    let drag_handle = handle.clone();
324    let drag_state = state.clone();
325    let release_state = state.clone();
326    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
327        release_state.grab.set(None);
328    };
329
330    div()
331        .id(SharedString::from(format!("{id}-track")))
332        .absolute()
333        .top_0()
334        .right_0()
335        .bottom_0()
336        .w(px(TRACK))
337        .flex()
338        .justify_center()
339        .on_drag_move(move |event, _, cx| {
340            drag_state.drag(&track_id, &drag_handle, event, cx);
341        })
342        // Both, because a release can land anywhere on screen; a grab left set
343        // would make the next press continue the last gesture.
344        .on_mouse_up(MouseButton::Left, released.clone())
345        .on_mouse_up_out(MouseButton::Left, released)
346        .child(
347            div()
348                .id(SharedString::from(format!("{id}-thumb")))
349                .absolute()
350                .top(range.start)
351                .h(size)
352                .w(px(THUMB))
353                .rounded_full()
354                .bg(if dragging { ink(0.38) } else { ink(0.2) })
355                .hover(|s| s.bg(ink(0.32)))
356                .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
357        )
358        .child(
359            canvas(
360                move |bounds, window, _| {
361                    // Laid out taller or shorter than the geometry above was
362                    // computed from: that geometry came from last frame's
363                    // handle. Ask for the frame that will paint it right.
364                    // Self-limiting — once they agree, nothing is requested.
365                    if (bounds.size.height - viewport).abs() > px(0.5) {
366                        window.request_animation_frame();
367                    }
368                },
369                |_, _, _, _| {},
370            )
371            .absolute()
372            .size_full(),
373        )
374        .into_any_element()
375}
376
377/// A mark per item, the one at the top of the viewport lit — for a pane whose
378/// content comes in countable pieces (a transcript's turns) rather than as one
379/// continuous document, where how far down you are matters less than which
380/// piece you are on. A press jumps to that piece.
381///
382/// `count` addresses the **direct children** of the `track_scroll` element,
383/// which is what gpui indexes: a pane whose pieces sit nested inside a wrapper
384/// reports one child, and every mark would scroll to the same place.
385///
386/// Absolute, so the caller's container holds the position: pin it with
387/// `.relative()` on whichever box the rail belongs to the edge of.
388///
389/// `room` is the clear space beside the content, which only the caller can
390/// measure — the rail paints nothing under [`RAIL_ROOM`], because marks over
391/// the text would be worse than no marks at all.
392pub fn rail(
393    id: impl Into<SharedString>,
394    handle: &ScrollHandle,
395    count: usize,
396    room: Pixels,
397) -> gpui::AnyElement {
398    if count == 0 || room < px(RAIL_ROOM) {
399        return Empty.into_any_element();
400    }
401    let id = id.into();
402    let at = handle.top_item();
403    div()
404        .absolute()
405        .top_0()
406        .bottom_0()
407        .left(px(RAIL_INSET))
408        .flex()
409        .flex_col()
410        .items_center()
411        .justify_center()
412        .gap(px(MARK_GAP))
413        .overflow_hidden()
414        .children((0..count).map(|ix| {
415            let handle = handle.clone();
416            div()
417                .id(SharedString::from(format!("{id}-{ix}")))
418                .w(px(MARK))
419                .h(px(MARK_THICK))
420                .rounded_full()
421                .bg(if ix == at { ink(0.6) } else { ink(0.2) })
422                .cursor_pointer()
423                .hover(|mark| mark.bg(ink(0.32)))
424                .on_click(move |_, window, _| {
425                    handle.scroll_to_item(ix);
426                    window.refresh();
427                })
428        }))
429        .into_any_element()
430}
431
432// ---------------------------------------------------------------------------
433// Transient — the same bar, only while the content moves
434// ---------------------------------------------------------------------------
435
436/// How long the thumb stays after the last scroll before fading — the idle
437/// window *is* the fade, because the fork's `Animation` has no delay.
438pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
439
440/// The show-and-fade state behind [`transient`]: the last frame's scroll
441/// state (a change is activity), a generation counter (a fresh animation id
442/// restarts the fade — `AnimationElement` pins its clock to the id it first
443/// laid out with), and the hover flag that holds the thumb up while the
444/// pointer is on the strip.
445#[derive(Clone)]
446pub struct TransientState {
447    cell: Rc<Cell<(Pixels, Pixels, u64, bool)>>,
448    bar: ScrollbarState,
449}
450
451impl TransientState {
452    pub fn new(painter: Painter) -> Self {
453        Self {
454            cell: Rc::new(Cell::new(Default::default())),
455            bar: ScrollbarState::new(painter),
456        }
457    }
458}
459
460/// The same bar as [`scrollbar`], but it only earns its place while the
461/// content moves: activity raises the thumb, and it fades out over
462/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
463/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
464/// animate, so it renders as the always-on bar.
465pub fn transient(
466    id: impl Into<SharedString>,
467    handle: &ScrollHandle,
468    state: &TransientState,
469    reduce_motion: bool,
470) -> gpui::AnyElement {
471    let id = id.into();
472    let viewport = handle.bounds().size.height;
473    let max_offset = handle.max_offset().y;
474    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
475        return Empty.into_any_element();
476    };
477    let size = range.end - range.start;
478
479    // Any change in the scroll state is activity: bump the generation so the
480    // fade restarts under a fresh animation id. The half-pixel slack keeps a
481    // sub-pixel layout jitter from re-showing a settled bar.
482    let mut cell = state.cell.get();
483    if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
484        cell.0 = handle.offset().y;
485        cell.1 = max_offset;
486        cell.2 += 1;
487        state.cell.set(cell);
488    }
489    let generation = cell.2;
490    let dragging = state.bar.dragging();
491
492    let track_id = id.clone();
493    let drag_handle = handle.clone();
494    let drag_state = state.clone();
495    let release_state = state.clone();
496    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
497        release_state.bar.grab.set(None);
498    };
499
500    let track = div()
501        .id(SharedString::from(format!("{id}-track")))
502        .absolute()
503        .top_0()
504        .right_0()
505        .bottom_0()
506        .w(px(TRACK))
507        .flex()
508        .justify_center()
509        .on_drag_move(move |event, _, cx| {
510            drag_state.bar.drag(&track_id, &drag_handle, event, cx);
511        })
512        // Both, because a release can land anywhere on screen; a grab left set
513        // would make the next press continue the last gesture.
514        .on_mouse_up(MouseButton::Left, released.clone())
515        .on_mouse_up_out(MouseButton::Left, released)
516        .map(|track| {
517            if reduce_motion {
518                track
519            } else {
520                // The thumb's stand-in at rest: hovering the strip raises it,
521                // and leaving starts its fade.
522                let hover_state = state.clone();
523                let hover_painter = state.bar.painter;
524                track.on_hover(move |hovered: &bool, _, cx: &mut App| {
525                    let mut cell = hover_state.cell.get();
526                    if cell.3 == *hovered {
527                        return;
528                    }
529                    cell.3 = *hovered;
530                    cell.2 += 1;
531                    hover_state.cell.set(cell);
532                    hover_painter.notify(cx);
533                })
534            }
535        });
536
537    let thumb = div()
538        .id(SharedString::from(format!("{id}-thumb")))
539        .absolute()
540        .top(range.start)
541        .h(size)
542        .w(px(THUMB))
543        .rounded_full()
544        .bg(if dragging { ink(0.38) } else { ink(0.2) })
545        .hover(|s| s.bg(ink(0.32)))
546        .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
547
548    let thumb: gpui::AnyElement = if reduce_motion {
549        thumb.into_any_element()
550    } else {
551        // The thumb's presence is the animation: progress 0 is fully up, and
552        // progress 1 — a full idle window later — is hidden, so no frame is
553        // requested once the fade completes.
554        let anim = state.clone();
555        thumb
556            .with_animation(
557                ElementId::from(format!("{id}-fade-{generation}")),
558                Animation::new(TRANSIENT_IDLE),
559                move |el, p| {
560                    let cell = anim.cell.get();
561                    if cell.3 || anim.bar.dragging() {
562                        el
563                    } else if p < 1.0 {
564                        el.opacity(1.0 - p)
565                    } else {
566                        el.hidden()
567                    }
568                },
569            )
570            .into_any_element()
571    };
572
573    track
574        .child(thumb)
575        .child(
576            canvas(
577                move |bounds, window, _| {
578                    // Laid out taller or shorter than the geometry above was
579                    // computed from: that geometry came from last frame's
580                    // handle. Ask for the frame that will paint it right.
581                    // Self-limiting — once they agree, nothing is requested.
582                    if (bounds.size.height - viewport).abs() > px(0.5) {
583                        window.request_animation_frame();
584                    }
585                },
586                |_, _, _, _| {},
587            )
588            .absolute()
589            .size_full(),
590        )
591        .into_any_element()
592}
593
594// ---------------------------------------------------------------------------
595// Nesting — which pane a wheel belongs to
596// ---------------------------------------------------------------------------
597
598/// Where a [`claim_wheel`] pane was before the wheel that is being dispatched.
599///
600/// Shaped like [`ScrollbarState`] and [`FollowState`], and owned by the view
601/// for the same reason: an element rebuilt every render cannot remember
602/// anything, and this has to outlive the frame it was written in.
603#[derive(Clone)]
604pub struct ClaimState(Rc<Cell<Point<Pixels>>>);
605
606impl Default for ClaimState {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612impl ClaimState {
613    pub fn new() -> Self {
614        Self(Rc::new(Cell::new(point(px(0.0), px(0.0)))))
615    }
616}
617
618/// Let a pane keep the wheel it can act on, instead of passing it to the pane
619/// behind as well.
620///
621/// gpui's scroll listener neither stops propagation nor asks whether an
622/// ancestor scrolls too, so a wheel over a nested pane moves *both* — an output
623/// box inside a transcript scrolls itself and drags the transcript with it
624/// (user report). Every scrolling ancestor under the pointer does this, so the
625/// deeper the nesting the further the page jumps.
626///
627/// The question it asks is whether the pane *moved*, not whether it had room
628/// to. This listener runs after the element's own — gpui registers that one
629/// later and the bubble phase runs the list backwards — so `handle` already
630/// holds the post-scroll offset, and `state` is where it stood before. "Had
631/// room" is the same answer one notch too late, and gets the notch that lands
632/// exactly on the end wrong: the pane finishes its travel *and* the page jumps
633/// a full notch behind it.
634///
635/// Chained at the ends, not sealed: a pane with nowhere left to go hands the
636/// wheel to the page, so reaching the end of a short inner list does not strand
637/// it there and make the pointer move. A pane whose content fits never claims
638/// anything, for the same reason. For `overscroll-behavior: contain` — a pane
639/// that never lets a wheel past — stop unconditionally instead.
640///
641/// ```ignore
642/// scroll::claim_wheel(
643///     scroll::pane("output", Axes::Vertical).track_scroll(&self.scroll),
644///     &self.scroll,
645///     Axes::Vertical,
646///     &self.claim,
647/// )
648/// ```
649///
650/// `axes` is what the pane scrolls, and must be what [`pane`] was given: an
651/// axis left out here is one whose movement goes unnoticed, so the wheel is
652/// handed on and the ancestor moves too.
653pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
654    el: E,
655    handle: &ScrollHandle,
656    axes: Axes,
657    state: &ClaimState,
658) -> E {
659    let handle = handle.clone();
660    let state = state.0.clone();
661    // Where the pane stands as the frame is built, which is where it stands
662    // before anything this frame's listeners are handed. The listener writes it
663    // too: a wheel the pane could not act on produces no `notify` and so no
664    // render, and the reading below has to stay true across that gap.
665    state.set(travel(&handle, axes));
666    el.on_scroll_wheel(move |_, _, cx| {
667        let now = travel(&handle, axes);
668        if now != state.get() {
669            state.set(now);
670            cx.stop_propagation();
671        }
672    })
673}
674
675/// How far `handle` has visibly travelled along each of `axes`. Negative, as
676/// gpui counts it; an axis the pane does not scroll reads zero forever, so it
677/// can never be mistaken for movement.
678///
679/// Clamped here because gpui's scroll listener is not: it adds the raw delta
680/// and leaves the clamp to the next `paint`, so a pane held at its end keeps
681/// accumulating offset it will never show. Compared raw, every notch past the
682/// end reads as movement and the pane never lets go of the wheel.
683fn travel(handle: &ScrollHandle, axes: Axes) -> Point<Pixels> {
684    let (offset, max) = (handle.offset(), handle.max_offset());
685    let seen = |offset: Pixels, max: Pixels| offset.clamp(-max.max(px(0.0)), px(0.0));
686    point(
687        match axes.horizontal() {
688            true => seen(offset.x, max.x),
689            false => px(0.0),
690        },
691        match axes.vertical() {
692            true => seen(offset.y, max.y),
693            false => px(0.0),
694        },
695    )
696}
697
698// ---------------------------------------------------------------------------
699// Follow — a view pinned to the bottom of content that grows under it
700// ---------------------------------------------------------------------------
701
702/// How close to the bottom still counts as following. A wheel lands on
703/// fractional offsets and a re-layout can move the end by a hair; without slack
704/// a view would unpin itself for a rounding error nobody asked for.
705pub const FOLLOW_SLACK: Pixels = px(4.0);
706
707/// Whether `offset` is at the end of the scrollable range, within `slack`.
708///
709/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
710/// `offset` is **negative** going down, which makes the distance still to go
711/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
712/// is nowhere else to be, and answering `false` would unpin an empty log.
713pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
714    if max_offset <= px(0.0) {
715        return true;
716    }
717    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
718    max_offset - travelled <= slack
719}
720
721/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
722///
723/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
724/// `&self`, so the element carries the whole behaviour without the view wiring
725/// a listener. Starts pinned — a transcript or a log opens on its newest line.
726#[derive(Clone)]
727pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
728
729impl Default for FollowState {
730    fn default() -> Self {
731        Self(Rc::new(Cell::new((true, px(0.0)))))
732    }
733}
734
735impl FollowState {
736    pub fn new() -> Self {
737        Self::default()
738    }
739
740    /// Whether the view is following. An app shows its "jump to latest" affordance
741    /// on `!following()`, which is the only reason this is public.
742    pub fn following(&self) -> bool {
743        self.0.get().0
744    }
745
746    /// Re-pin. What that "jump to latest" button calls; the next frame does the
747    /// scrolling.
748    pub fn follow(&self) {
749        let (_, last) = self.0.get();
750        self.0.set((true, last));
751    }
752}
753
754/// Keep `handle` pinned to the bottom of its content while the user leaves it
755/// there, and get out of the way the moment they scroll up.
756///
757/// Drop it in beside [`scrollbar`], over the same container:
758///
759/// ```ignore
760/// div().relative()
761///     .child(scroll::pane("log", Axes::Vertical).size_full().track_scroll(&self.scroll).child(rows))
762///     .child(scroll::follow(&self.scroll, &self.follow))
763///     .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
764/// ```
765///
766/// **Telling appended content from a user scroll is the whole problem**, and
767/// neither is an event this can subscribe to — both surface as the same handle
768/// reading differently than last frame. The overflow is what separates them: if
769/// it changed, the content grew and the pin is left as the user last set it; if
770/// it did not, the offset moved because the *user* moved it, and being at the
771/// end is what re-pins. So scrolling up releases, and scrolling back down
772/// re-attaches, with no gesture to hook.
773///
774/// The correction lands a frame late — the scrolling div was laid out with the
775/// old offset before this runs — which is why it asks for that frame. At a
776/// streaming cadence it is invisible, and it converges rather than spinning:
777/// once pinned and at the end, nothing is requested.
778pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
779    let handle = handle.clone();
780    let state = state.clone();
781    canvas(
782        move |_, window, _| {
783            let max_offset = handle.max_offset().y;
784            let offset = handle.offset().y;
785            let (was_pinned, last_max) = state.0.get();
786
787            let pinned = if (max_offset - last_max).abs() > px(0.5) {
788                was_pinned
789            } else {
790                at_bottom(max_offset, offset, FOLLOW_SLACK)
791            };
792
793            if pinned && (offset + max_offset).abs() > px(0.5) {
794                handle.set_offset(point(handle.offset().x, -max_offset));
795                window.request_animation_frame();
796            }
797            state.0.set((pinned, max_offset));
798        },
799        |_, _, _, _| {},
800    )
801    .absolute()
802    .size_full()
803    .into_any_element()
804}
805
806// ---------------------------------------------------------------------------
807// Drift — a pane that keeps moving while a drag is held at its edge
808// ---------------------------------------------------------------------------
809
810/// How close to an edge a held drag starts the pane moving. Read off
811/// `../desktop`'s board (2026-05): a third of a column, wide enough to reach
812/// while aiming at a card and narrow enough to leave the middle still.
813pub const DRIFT_EDGE: Pixels = px(96.0);
814
815/// How fast a pane travels with the pointer at the very edge, in pixels a
816/// second. The same board's 22px per frame, said in a unit that does not
817/// double on a 120Hz display.
818pub const DRIFT_SPEED: f32 = 1320.0;
819
820/// The most one frame may travel, however late it ran. A frame dropped while
821/// the pointer rests at an edge costs a pause, not a jump to the far end.
822const DRIFT_STEP: Duration = Duration::from_millis(50);
823
824/// How far a pane should travel in a second, for a pointer at `pointer`
825/// between edges `start` and `end`.
826///
827/// Signed the way gpui's offset is: positive travels back toward the start,
828/// because the offset goes negative as a pane scrolls on. Zero everywhere but
829/// within [`DRIFT_EDGE`] of an edge, where it ramps with proximity — easing
830/// toward the edge eases the scroll — and holds at full speed once the pointer
831/// is past it, so a card carried off the side of a board keeps it coming
832/// instead of stopping dead at the boundary.
833///
834/// The ramp is capped at half the pane, so one narrower than two edges has a
835/// still middle rather than a midpoint where the direction flips at half speed.
836pub fn drift_velocity(pointer: Pixels, start: Pixels, end: Pixels) -> f32 {
837    let edge = DRIFT_EDGE.as_f32().min((end - start).as_f32() / 2.0);
838    if edge <= 0.0 {
839        return 0.0;
840    }
841    let (from_start, from_end) = ((pointer - start).as_f32(), (end - pointer).as_f32());
842    // The nearer edge, which is what decides the direction — and what keeps
843    // the two ramps from summing where they overlap.
844    let near = from_start.min(from_end);
845    if near >= edge {
846        return 0.0;
847    }
848    let ramp = 1.0 - near.max(0.0) / edge;
849    match from_start < from_end {
850        true => DRIFT_SPEED * ramp,
851        false => -DRIFT_SPEED * ramp,
852    }
853}
854
855/// What one drifting pane remembers between frames.
856#[derive(Clone, Copy, Default)]
857struct Drift {
858    /// Where the pointer was last seen carrying something this pane follows.
859    aim: Option<Point<Pixels>>,
860    /// When it last moved, and `None` whenever it is not moving — so a pane
861    /// picking the gesture back up starts its clock rather than travelling the
862    /// gap it stood still for.
863    since: Option<Instant>,
864}
865
866/// Where a drift was last aimed, and when it last moved.
867///
868/// Shaped like [`ScrollbarState`] and owned by the view for the same reason:
869/// the element is rebuilt every frame and cannot remember where the pointer
870/// was. One per pane — two panes sharing one would take turns reading each
871/// other's clock.
872#[derive(Clone, Default)]
873pub struct DriftState(Rc<Cell<Drift>>);
874
875impl DriftState {
876    pub fn new() -> Self {
877        Self::default()
878    }
879
880    /// Where the pointer is. Call it from the drag-move listener of the
881    /// payload this pane should follow — that listener is typed, which is what
882    /// keeps a board from drifting under somebody dragging a scrollbar thumb:
883    ///
884    /// ```ignore
885    /// .on_drag_move(cx.listener(|this, event: &DragMoveEvent<CardDrag>, _, _| {
886    ///     this.drift.aim(event.event.position);
887    /// }))
888    /// ```
889    ///
890    /// Aimed rather than continuous: gpui reports a drag only while it moves,
891    /// and a pointer parked at an edge is the case the whole thing exists for.
892    pub fn aim(&self, pointer: Point<Pixels>) {
893        let drift = self.0.get();
894        self.0.set(Drift {
895            aim: Some(pointer),
896            ..drift
897        });
898    }
899}
900
901/// Move `handle` while a drag is held near its edge, for as long as it is held
902/// there.
903///
904/// Drop it in beside [`scrollbar`], over the same container, and feed it from
905/// the drag-move listener — see [`DriftState::aim`]:
906///
907/// ```ignore
908/// div().relative()
909///     .child(scroll::pane("board", Axes::Horizontal).size_full().track_scroll(&self.scroll).child(lanes))
910///     .child(scroll::drift(&self.scroll, &self.drift, Axes::Horizontal))
911/// ```
912///
913/// Without it a board is only as wide as the window: a card cannot be carried
914/// to a lane that is off screen, because reaching for one means letting go.
915///
916/// **The gesture ending is not an event this subscribes to.** gpui hands a
917/// drop to whatever was under the pointer, and a release over nothing is not
918/// delivered at all, so either would leave a pane drifting on a gesture that
919/// is over. The drag going away is the signal instead, and it arrives however
920/// the drag ended.
921///
922/// Across the axis the pointer must be *within* the pane, or every lane on a
923/// board would drift together on one that is only near one of them. Along it,
924/// past the edge still counts — see [`drift_velocity`].
925///
926/// Not motion in the [`motion`] sense and not reduced with it: nothing here
927/// animates a property, the pane is being scrolled by a gesture the same way a
928/// wheel scrolls it, and a reader who cannot reach the far lane has no gesture
929/// left to make.
930pub fn drift(handle: &ScrollHandle, state: &DriftState, axes: Axes) -> gpui::AnyElement {
931    let handle = handle.clone();
932    let state = state.clone();
933    canvas(
934        move |_, window, cx: &mut App| {
935            let drift = state.0.get();
936            // Nothing aimed here, or the gesture that aimed it has ended.
937            let Some(pointer) = drift.aim.filter(|_| cx.has_active_drag()) else {
938                state.0.set(Drift::default());
939                return;
940            };
941
942            let bounds = handle.bounds();
943            let mut velocity = point(0.0, 0.0);
944            if axes.horizontal() && (bounds.top()..=bounds.bottom()).contains(&pointer.y) {
945                velocity.x = drift_velocity(pointer.x, bounds.left(), bounds.right());
946            }
947            if axes.vertical() && (bounds.left()..=bounds.right()).contains(&pointer.x) {
948                velocity.y = drift_velocity(pointer.y, bounds.top(), bounds.bottom());
949            }
950            // Aimed here but nowhere near an edge. The clock stops with it, so
951            // a drag wandering back to the edge a second later starts a fresh
952            // drift rather than travelling the second it stood still.
953            if velocity.x == 0.0 && velocity.y == 0.0 {
954                state.0.set(Drift {
955                    aim: Some(pointer),
956                    since: None,
957                });
958                return;
959            }
960
961            let now = cx.background_executor().now();
962            state.0.set(Drift {
963                aim: Some(pointer),
964                since: Some(now),
965            });
966            // The first frame of a drift is the one that starts the clock;
967            // there is no interval yet to travel over.
968            let Some(last) = drift.since else {
969                window.request_animation_frame();
970                return;
971            };
972
973            let step = (now - last).min(DRIFT_STEP).as_secs_f32();
974            let (offset, max) = (handle.offset(), handle.max_offset());
975            let moved = point(
976                (offset.x + px(velocity.x * step)).clamp(-max.x, px(0.0)),
977                (offset.y + px(velocity.y * step)).clamp(-max.y, px(0.0)),
978            );
979            if moved == offset {
980                // Held at an end that has nowhere left to go. Asking for
981                // another frame here would spin at the display's rate for as
982                // long as the drag is held; the next pointer move draws one
983                // anyway, and that is the soonest this could have anything to
984                // do.
985                return;
986            }
987            handle.set_offset(moved);
988            window.request_animation_frame();
989        },
990        |_, _, _, _| {},
991    )
992    .absolute()
993    .size_full()
994    .into_any_element()
995}