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