Skip to main content

ui/scroll/
bar.rs

1//! The bar: track, thumb, drag state and rail.
2
3use super::*;
4
5/// The strip a thumb sits in, placed along `axis` and named for `id`.
6///
7/// A press on the bar belongs to the bar. Hitboxes in gpui are paint-order
8/// only, so without `block_mouse_except_scroll` the content under the strip
9/// takes the press as well; the wheel still passes, which is what a bar laid
10/// over a pane has to let through.
11/// Whether the pointer rests on a bar's track, which holds a transient bar up.
12///
13/// gpui keeps the pointer's last position when it leaves the window, and a
14/// track under that position reads as hovered again on the next frame. `out`
15/// is set when the pointer leaves the window across the track and cleared by
16/// the next move over it.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub(crate) struct Hover {
19    over: bool,
20    out: bool,
21}
22
23impl Hover {
24    pub(crate) fn held(self) -> bool {
25        self.over && !self.out
26    }
27}
28
29pub(super) type SetHover = Rc<dyn Fn(Hover, &mut Window, &mut App)>;
30
31/// Keep `hover` for `track`, calling `changed` when it changes.
32pub(crate) fn hover_track<E: StatefulInteractiveElement>(
33    track: E,
34    hover: Rc<Cell<Hover>>,
35    changed: impl Fn(&mut Window, &mut App) + 'static,
36) -> E {
37    let set: SetHover = {
38        let hover = hover.clone();
39        Rc::new(move |next, window, cx| {
40            if next != hover.get() {
41                hover.set(next);
42                changed(window, cx);
43            }
44        })
45    };
46    let (over, exit, moved) = (hover.clone(), hover.clone(), hover);
47    let (set_exit, set_moved) = (set.clone(), set.clone());
48    track
49        .on_hover(move |hovered, window, cx| {
50            let over = Hover {
51                over: *hovered,
52                ..over.get()
53            };
54            set(over, window, cx)
55        })
56        .on_mouse_exit(move |_, window, cx| {
57            let out = Hover {
58                out: true,
59                ..exit.get()
60            };
61            set_exit(out, window, cx)
62        })
63        .on_mouse_move(move |_, window, cx| {
64            let back = Hover {
65                out: false,
66                ..moved.get()
67            };
68            set_moved(back, window, cx)
69        })
70}
71
72pub(super) fn track(id: &SharedString, place: Place, axis: Axis) -> Stateful<Div> {
73    let debug_id = id.clone();
74    let el = div()
75        .debug_selector(move || format!("{debug_id}-track"))
76        .id(SharedString::from(format!("{id}-track")))
77        .block_mouse_except_scroll()
78        .absolute()
79        .flex();
80    match axis {
81        Axis::Vertical => el
82            .top(BAR_INSET)
83            .right(place.near())
84            .bottom(BAR_INSET + place.end)
85            .w(px(TRACK)),
86        Axis::Horizontal => el
87            .left(BAR_INSET)
88            .right(BAR_INSET + place.end)
89            .bottom(place.near())
90            .h(px(TRACK)),
91    }
92}
93
94/// Where the thumb sits in a track of `viewport` length, as a range from the
95/// track's start — or `None` when there is nothing to scroll.
96///
97/// `None` also covers a viewport of zero (the frame before layout has run) and
98/// a thumb that would not fit, which is zed's third guard: with a viewport
99/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
100pub fn thumb(
101    viewport: Pixels,
102    max_offset: Pixels,
103    offset: Pixels,
104    min: Pixels,
105) -> Option<Range<Pixels>> {
106    if viewport <= px(0.0) || max_offset <= px(0.0) {
107        return None;
108    }
109    let content = viewport + max_offset;
110    let size = min.max(viewport * (viewport / content));
111    if size > viewport {
112        return None;
113    }
114    // Negative going down, and never past either end — a wheel can overshoot.
115    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
116    let start = (travelled / max_offset) * (viewport - size);
117    Some(start..start + size)
118}
119
120pub(crate) fn thumb_in_track(
121    viewport: Pixels,
122    max_offset: Pixels,
123    offset: Pixels,
124    track: Pixels,
125) -> Option<Range<Pixels>> {
126    if track <= px(0.) {
127        return None;
128    }
129    let scale = track / viewport;
130    let range = thumb(viewport, max_offset, offset, MIN_THUMB / scale)?;
131    Some(range.start * scale..range.end * scale)
132}
133
134/// The inverse: the scroll offset that puts the thumb's top at `top`.
135///
136/// Negative, because that is the direction gpui counts in, and clamped to the
137/// scrollable range so a drag past either end simply stops.
138pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
139    let travel = viewport - size;
140    if travel <= px(0.0) || max_offset <= px(0.0) {
141        return px(0.0);
142    }
143    -(max_offset * (top / travel).clamp(0.0, 1.0))
144}
145
146/// The drag payload. Carries the bar's id because, unlike a split, an app has
147/// several of these on screen at once and `on_drag_move` filters by type alone
148/// — without the id every bar in the window would answer one thumb's gesture.
149#[derive(Clone)]
150pub struct ScrollbarDrag(pub SharedString);
151
152/// Where in the thumb a drag was grabbed.
153///
154/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
155/// and the bar clones — for the same reason: both mutate through `&self`, so
156/// the bar carries its whole gesture without the view wiring a single listener.
157/// Without it the thumb would jump its middle to the pointer on every press.
158#[derive(Clone)]
159pub struct ScrollbarState {
160    grab: Rc<Cell<Option<Pixels>>>,
161    /// A drag runs in event-dispatch context, where the window cannot resolve
162    /// which view is asking — so the bar carries its own.
163    painter: Painter,
164}
165
166impl ScrollbarState {
167    pub fn new(painter: Painter) -> Self {
168        Self {
169            grab: Rc::new(Cell::new(None)),
170            painter,
171        }
172    }
173
174    fn begin(&self, handle: &ScrollHandle, event: &gpui::MouseDownEvent, end_inset: Pixels) {
175        let viewport = handle.bounds().size.height;
176        if let Some(range) = thumb_in_track(
177            viewport,
178            handle.max_offset().y,
179            handle.offset().y,
180            viewport - 2. * BAR_INSET - end_inset,
181        ) {
182            self.grab.set(Some(
183                (event.position.y - handle.bounds().top() - BAR_INSET - range.start)
184                    .clamp(px(0.), range.end - range.start),
185            ));
186        }
187    }
188
189    /// Whether a thumb drag is in flight.
190    pub fn dragging(&self) -> bool {
191        self.grab.get().is_some()
192    }
193
194    /// One drag move of the thumb: filter the gesture to this bar's track, then
195    /// translate the pointer into a scroll offset.
196    fn drag(
197        &self,
198        track_id: &SharedString,
199        handle: &ScrollHandle,
200        event: &DragMoveEvent<ScrollbarDrag>,
201        end_inset: Pixels,
202        cx: &mut App,
203    ) {
204        // Another bar's thumb: `on_drag_move` filters by payload type, and
205        // every bar in the window shares this one.
206        if event.drag(cx).0 != *track_id {
207            return;
208        }
209        let viewport = handle.bounds().size.height;
210        let max_offset = handle.max_offset().y;
211        let Some(range) = thumb_in_track(
212            viewport,
213            max_offset,
214            handle.offset().y,
215            viewport - 2. * BAR_INSET - end_inset,
216        ) else {
217            return;
218        };
219        let size = range.end - range.start;
220        let pointer = event.event.position.y - event.bounds.top();
221        // First move of this drag: the offset has not shifted yet, so the
222        // thumb is still where the press landed on it and the grab is
223        // simply the difference. Held for the rest of the gesture — read it
224        // again later and it would answer "wherever the pointer is now",
225        // which is a thumb that never moves.
226        let grab = self.grab.get().unwrap_or_else(|| {
227            let grab = (pointer - range.start).clamp(px(0.0), size);
228            self.grab.set(Some(grab));
229            grab
230        });
231        let offset = offset_for_thumb(
232            pointer - grab,
233            viewport - 2. * BAR_INSET - end_inset,
234            max_offset,
235            size,
236        );
237        handle.set_offset(point(handle.offset().x, offset));
238        self.painter.notify(cx);
239    }
240}
241
242/// Where a bar sits in the pane it reports on. [`Overlay`] builds one; the free
243/// bars take the default.
244#[derive(Clone, Copy)]
245pub(super) struct Place {
246    /// Shortens the track at its far end.
247    pub(super) end: Pixels,
248    /// Gap between the pane's edge and the thumb, across the axis.
249    pub(super) margin: Pixels,
250}
251
252impl Default for Place {
253    fn default() -> Self {
254        Self {
255            end: px(0.),
256            margin: px(MARGIN),
257        }
258    }
259}
260
261impl Place {
262    /// Gap between the pane's edge and the track. The track is centred on the
263    /// thumb where the margin leaves room, and starts at the edge otherwise.
264    pub(super) fn near(self) -> Pixels {
265        (self.margin - px(TRACK - THUMB) * 0.5).max(px(0.))
266    }
267
268    /// Gap between the track's outer side and the thumb.
269    pub(super) fn inner(self) -> Pixels {
270        self.margin - self.near()
271    }
272}
273
274/// The bar: an overlay strip along the right edge of whatever it is laid over,
275/// showing nothing at all when the content fits.
276///
277/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
278/// content beneath it.
279///
280/// Its geometry comes from the handle as the *last* frame left it, which is all
281/// a render pass can see; the canvas at the end asks for one more frame when
282/// layout disagrees, so the bar is right on the frame after it first appears
283/// rather than whenever something else happens to repaint.
284///
285/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
286/// rather than a toned surface, so the thumb is [`ink`], which already follows
287/// the appearance on its own. A parameter it ignored would be worse than none.
288pub fn scrollbar(
289    id: impl Into<SharedString>,
290    handle: &ScrollHandle,
291    state: &ScrollbarState,
292) -> gpui::AnyElement {
293    scrollbar_placed(id.into(), handle, state, Place::default())
294}
295
296pub(super) fn scrollbar_placed(
297    id: SharedString,
298    handle: &ScrollHandle,
299    state: &ScrollbarState,
300    place: Place,
301) -> gpui::AnyElement {
302    let end_inset = place.end;
303    let viewport = handle.bounds().size.height;
304    let max_offset = handle.max_offset().y;
305    let Some(range) = thumb_in_track(
306        viewport,
307        max_offset,
308        handle.offset().y,
309        viewport - 2. * BAR_INSET - end_inset,
310    ) else {
311        return Empty.into_any_element();
312    };
313    let size = range.end - range.start;
314    let dragging = state.dragging();
315
316    let track_id = id.clone();
317    let drag_handle = handle.clone();
318    let drag_state = state.clone();
319    let release_state = state.clone();
320    let press_state = state.clone();
321    let press_handle = handle.clone();
322    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
323        release_state.grab.set(None);
324    };
325
326    let thumb_debug_id = id.clone();
327    track(&id, place, Axis::Vertical)
328        .on_drag_move(move |event, _, cx| {
329            drag_state.drag(&track_id, &drag_handle, event, end_inset, cx);
330        })
331        // Both, because a release can land anywhere on screen; a grab left set
332        // would make the next press continue the last gesture.
333        .on_mouse_up(MouseButton::Left, released.clone())
334        .on_mouse_up_out(MouseButton::Left, released)
335        .child(
336            div()
337                .debug_selector(move || format!("{thumb_debug_id}-thumb"))
338                .id(SharedString::from(format!("{id}-thumb")))
339                .absolute()
340                .top(range.start)
341                .h(size)
342                .right(place.inner())
343                .w(px(THUMB))
344                .rounded_full()
345                .bg(if dragging { ink(0.38) } else { ink(0.2) })
346                .hover(|s| s.bg(ink(0.32)))
347                .on_mouse_down(MouseButton::Left, move |event, _, cx| {
348                    press_state.begin(&press_handle, event, end_inset);
349                    press_state.painter.notify(cx);
350                })
351                .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
352        )
353        .child(
354            canvas(
355                move |bounds, window, _| {
356                    // Laid out taller or shorter than the geometry above was
357                    // computed from: that geometry came from last frame's
358                    // handle. Ask for the frame that will paint it right.
359                    // Self-limiting — once they agree, nothing is requested.
360                    if (bounds.size.height + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
361                    {
362                        window.request_animation_frame();
363                    }
364                },
365                |_, _, _, _| {},
366            )
367            .absolute()
368            .size_full(),
369        )
370        .into_any_element()
371}
372
373/// Whether `room` beside the content is enough for a rail to paint in. A
374/// hand-rolled rail asks this to land on the same floor as [`rail`].
375pub fn rail_fits(room: Pixels) -> bool {
376    room >= px(RAIL_ROOM)
377}
378
379/// A mark per item, the one at the top of the viewport lit — for a pane whose
380/// content comes in countable pieces (a transcript's turns) rather than as one
381/// continuous document, where how far down you are matters less than which
382/// piece you are on. A press jumps to that piece.
383///
384/// `count` addresses the **direct children** of the `track_scroll` element,
385/// which is what gpui indexes: a pane whose pieces sit nested inside a wrapper
386/// reports one child, and every mark would scroll to the same place.
387///
388/// Absolute, so the caller's container holds the position: pin it with
389/// `.relative()` on whichever box the rail belongs to the edge of.
390///
391/// `room` is the clear space beside the content, which only the caller can
392/// measure; under what [`rail_fits`] accepts the rail paints nothing.
393pub fn rail(
394    id: impl Into<SharedString>,
395    handle: &ScrollHandle,
396    count: usize,
397    room: Pixels,
398) -> gpui::AnyElement {
399    if count == 0 || !rail_fits(room) {
400        return Empty.into_any_element();
401    }
402    let id = id.into();
403    let at = handle.top_item();
404    div()
405        .absolute()
406        .top_0()
407        .bottom_0()
408        .left(px(RAIL_INSET))
409        .flex()
410        .flex_col()
411        .items_center()
412        .justify_center()
413        .gap(px(MARK_GAP))
414        .overflow_hidden()
415        .children((0..count).map(|ix| {
416            let handle = handle.clone();
417            div()
418                .id(SharedString::from(format!("{id}-{ix}")))
419                .w(px(MARK))
420                .h(px(MARK_THICK))
421                .rounded_full()
422                .bg(if ix == at { ink(0.6) } else { ink(0.2) })
423                .cursor_pointer()
424                .hover(|mark| mark.bg(ink(0.32)))
425                .on_click(move |_, window, _| {
426                    handle.scroll_to_item(ix);
427                    window.refresh();
428                })
429        }))
430        .into_any_element()
431}
432
433// ---------------------------------------------------------------------------
434// Transient — the same bar, only while the content moves
435// ---------------------------------------------------------------------------
436
437/// How long the thumb stays after the last scroll before fading — the idle
438/// window *is* the fade, because the fork's `Animation` has no delay.
439pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
440
441/// The show-and-fade state behind [`transient`]: the last frame's scroll
442/// state (a change is activity), a generation counter (a fresh animation id
443/// restarts the fade — `AnimationElement` pins its clock to the id it first
444/// laid out with), and the hover flag that holds the thumb up while the
445/// pointer is on the strip.
446#[derive(Clone)]
447pub struct TransientState {
448    cell: Rc<Cell<(Pixels, Pixels, u64)>>,
449    hover: Rc<Cell<Hover>>,
450    bar: ScrollbarState,
451}
452
453impl TransientState {
454    pub fn new(painter: Painter) -> Self {
455        Self {
456            cell: Rc::new(Cell::new(Default::default())),
457            hover: Rc::default(),
458            bar: ScrollbarState::new(painter),
459        }
460    }
461}
462
463/// The same bar as [`scrollbar`], but it only earns its place while the
464/// content moves: activity raises the thumb, and it fades out over
465/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
466/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
467/// animate, so it renders as the always-on bar.
468pub fn transient(
469    id: impl Into<SharedString>,
470    handle: &ScrollHandle,
471    state: &TransientState,
472    reduce_motion: bool,
473) -> gpui::AnyElement {
474    transient_placed(id.into(), handle, state, reduce_motion, Place::default())
475}
476
477pub(super) fn transient_placed(
478    id: SharedString,
479    handle: &ScrollHandle,
480    state: &TransientState,
481    reduce_motion: bool,
482    place: Place,
483) -> gpui::AnyElement {
484    let end_inset = place.end;
485    let viewport = handle.bounds().size.height;
486    let max_offset = handle.max_offset().y;
487    let Some(range) = thumb_in_track(
488        viewport,
489        max_offset,
490        handle.offset().y,
491        viewport - 2. * BAR_INSET - end_inset,
492    ) else {
493        return Empty.into_any_element();
494    };
495    let size = range.end - range.start;
496
497    // Any change in the scroll state is activity: bump the generation so the
498    // fade restarts under a fresh animation id. The half-pixel slack keeps a
499    // sub-pixel layout jitter from re-showing a settled bar.
500    let mut cell = state.cell.get();
501    if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
502        cell.0 = handle.offset().y;
503        cell.1 = max_offset;
504        cell.2 += 1;
505        state.cell.set(cell);
506    }
507    let generation = cell.2;
508    let dragging = state.bar.dragging();
509
510    let track_id = id.clone();
511    let drag_handle = handle.clone();
512    let drag_state = state.clone();
513    let release_state = state.clone();
514    let press_state = state.clone();
515    let press_handle = handle.clone();
516    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
517        release_state.bar.grab.set(None);
518    };
519
520    let thumb_debug_id = id.clone();
521    let track = track(&id, place, Axis::Vertical)
522        .on_drag_move(move |event, _, cx| {
523            drag_state
524                .bar
525                .drag(&track_id, &drag_handle, event, end_inset, cx);
526        })
527        // Both, because a release can land anywhere on screen; a grab left set
528        // would make the next press continue the last gesture.
529        .on_mouse_up(MouseButton::Left, released.clone())
530        .on_mouse_up_out(MouseButton::Left, released)
531        .map(|track| {
532            if reduce_motion {
533                track
534            } else {
535                // The thumb's stand-in at rest: hovering the strip raises it,
536                // and leaving starts its fade.
537                let hover_state = state.clone();
538                let hover_painter = state.bar.painter;
539                hover_track(track, state.hover.clone(), move |_, cx| {
540                    let mut cell = hover_state.cell.get();
541                    cell.2 += 1;
542                    hover_state.cell.set(cell);
543                    hover_painter.notify(cx);
544                })
545            }
546        });
547
548    let thumb = div()
549        .debug_selector(move || format!("{thumb_debug_id}-thumb"))
550        .id(SharedString::from(format!("{id}-thumb")))
551        .absolute()
552        .top(range.start)
553        .h(size)
554        .right(place.inner())
555        .w(px(THUMB))
556        .rounded_full()
557        .bg(if dragging { ink(0.38) } else { ink(0.2) })
558        .hover(|s| s.bg(ink(0.32)))
559        .on_mouse_down(MouseButton::Left, move |event, _, cx| {
560            press_state.bar.begin(&press_handle, event, end_inset);
561            press_state.bar.painter.notify(cx);
562        })
563        .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
564
565    let thumb: gpui::AnyElement = if reduce_motion {
566        thumb.into_any_element()
567    } else {
568        // The thumb's presence is the animation: progress 0 is fully up, and
569        // progress 1 — a full idle window later — is hidden, so no frame is
570        // requested once the fade completes.
571        let anim = state.clone();
572        thumb
573            .with_animation(
574                ElementId::from(format!("{id}-fade-{generation}")),
575                Animation::new(TRANSIENT_IDLE),
576                move |el, p| {
577                    if anim.hover.get().held() || anim.bar.dragging() {
578                        el
579                    } else if p < 1.0 {
580                        el.opacity(1.0 - p)
581                    } else {
582                        el.hidden()
583                    }
584                },
585            )
586            .into_any_element()
587    };
588
589    track
590        .child(thumb)
591        .child(
592            canvas(
593                move |bounds, window, _| {
594                    // Laid out taller or shorter than the geometry above was
595                    // computed from: that geometry came from last frame's
596                    // handle. Ask for the frame that will paint it right.
597                    // Self-limiting — once they agree, nothing is requested.
598                    if (bounds.size.height + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
599                    {
600                        window.request_animation_frame();
601                    }
602                },
603                |_, _, _, _| {},
604            )
605            .absolute()
606            .size_full(),
607        )
608        .into_any_element()
609}