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: impl Into<Scroller>, state: &FollowState) -> gpui::AnyElement {
82 let handle = handle.into();
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.scroll_to_end();
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}