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