Skip to main content

ui/scroll/
follow.rs

1//! Follow: a view pinned to the bottom of content that grows under it.
2
3use super::*;
4
5/// How close to the bottom still counts as following. A wheel lands on
6/// fractional offsets and a re-layout can move the end by a hair; without slack
7/// a view would unpin itself for a rounding error nobody asked for.
8pub const FOLLOW_SLACK: Pixels = px(4.0);
9
10/// Whether `offset` is at the end of the scrollable range, within `slack`.
11///
12/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
13/// `offset` is **negative** going down, which makes the distance still to go
14/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
15/// is nowhere else to be, and answering `false` would unpin an empty log.
16pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
17    if max_offset <= px(0.0) {
18        return true;
19    }
20    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
21    max_offset - travelled <= slack
22}
23
24/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
25///
26/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
27/// `&self`, so the element carries the whole behaviour without the view wiring
28/// a listener. Starts pinned — a transcript or a log opens on its newest line.
29#[derive(Clone)]
30pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
31
32impl Default for FollowState {
33    fn default() -> Self {
34        Self(Rc::new(Cell::new((true, px(0.0)))))
35    }
36}
37
38impl FollowState {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Whether the view is following. An app shows its "jump to latest" affordance
44    /// on `!following()`, which is the only reason this is public.
45    pub fn following(&self) -> bool {
46        self.0.get().0
47    }
48
49    /// Re-pin. What that "jump to latest" button calls; the next frame does the
50    /// scrolling.
51    pub fn follow(&self) {
52        let (_, last) = self.0.get();
53        self.0.set((true, last));
54    }
55}
56
57/// Keep `handle` pinned to the bottom of its content while the user leaves it
58/// there, and get out of the way the moment they scroll up.
59///
60/// Drop it in beside [`scrollbar`], over the same container:
61///
62/// ```ignore
63/// div().relative()
64///     .child(scroll::pane("log", Axes::Vertical).size_full().track_scroll(&self.scroll).child(rows))
65///     .child(scroll::follow(&self.scroll, &self.follow))
66///     .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
67/// ```
68///
69/// **Telling appended content from a user scroll is the whole problem**, and
70/// neither is an event this can subscribe to — both surface as the same handle
71/// reading differently than last frame. The overflow is what separates them: if
72/// it changed, the content grew and the pin is left as the user last set it; if
73/// it did not, the offset moved because the *user* moved it, and being at the
74/// end is what re-pins. So scrolling up releases, and scrolling back down
75/// re-attaches, with no gesture to hook.
76///
77/// The correction lands a frame late — the scrolling div was laid out with the
78/// old offset before this runs — which is why it asks for that frame. At a
79/// streaming cadence it is invisible, and it converges rather than spinning:
80/// once pinned and at the end, nothing is requested.
81pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
82    let handle = handle.clone();
83    let state = state.clone();
84    canvas(
85        move |_, window, _| {
86            let max_offset = handle.max_offset().y;
87            let offset = handle.offset().y;
88            let (was_pinned, last_max) = state.0.get();
89
90            let pinned = if (max_offset - last_max).abs() > px(0.5) {
91                was_pinned
92            } else {
93                at_bottom(max_offset, offset, FOLLOW_SLACK)
94            };
95
96            if pinned && (offset + max_offset).abs() > px(0.5) {
97                handle.set_offset(point(handle.offset().x, -max_offset));
98                window.request_animation_frame();
99            }
100            state.0.set((pinned, max_offset));
101        },
102        |_, _, _, _| {},
103    )
104    .absolute()
105    .size_full()
106    .into_any_element()
107}