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