Skip to main content

ui/
scroll.rs

1//! What gpui's own scroll handles leave to the app: a bar to show the position,
2//! and a rule for which pane a wheel belongs to.
3//!
4//! gpui scrolls a `div` perfectly well and draws nothing while it does, so a
5//! bezel app has no way to show how far down it is. This is that bar: the
6//! caller keeps its own `overflow_y_scroll` container, because a wrapper that
7//! swallowed the content would have to re-implement layout for it. Nesting two
8//! of those containers is [`claim_wheel`]'s business.
9//!
10//! ```ignore
11//! div().relative()                                  // the bar is absolute in here
12//!     .child(
13//!         div()
14//!             .id("pane")
15//!             .size_full()
16//!             .overflow_y_scroll()
17//!             .track_scroll(&self.scroll)           // gpui's handle, the app's field
18//!             .child(content),
19//!     )
20//!     .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
21//! ```
22//!
23//! The bar must span the container it reports on — its track *is* the viewport,
24//! in the coordinates [`thumb`] answers in.
25//!
26//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
27//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
28//! system around the fifteen that matter. Two of gpui's conventions are easy to
29//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
30//! (content minus viewport, not content), and `offset` is **negative** as you
31//! scroll down.
32//!
33//! [`transient`] is the same bar, shown only while its content moves.
34
35use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
36
37use gpui::{
38    Animation, AnimationExt, App, Axis, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
39    ScrollHandle, SharedString, Window, canvas, div, point, prelude::*, px,
40};
41
42use motion::Painter;
43use theme::ink;
44
45/// Shortest a thumb may get, however long the document — below this it stops
46/// being something a pointer can catch.
47pub const MIN_THUMB: Pixels = px(25.0);
48/// Width of the strip the thumb sits in.
49const TRACK: f32 = 10.0;
50/// Width of the thumb itself, centred in the track.
51const THUMB: f32 = 6.0;
52/// Length of one [`rail`] mark, and its thickness.
53const MARK: f32 = 16.0;
54const MARK_THICK: f32 = 2.0;
55/// Between two marks. The track's width, so a rail and a bar on the same pane
56/// are cut to one rhythm.
57const MARK_GAP: f32 = TRACK;
58/// How far the rail stands off the edge it is pinned to.
59const RAIL_INSET: f32 = 12.0;
60/// What a rail needs beside the content before it will paint at all.
61pub const RAIL_ROOM: f32 = RAIL_INSET + MARK;
62
63/// Where the thumb sits in a track of `viewport` length, as a range from the
64/// track's start — or `None` when there is nothing to scroll.
65///
66/// `None` also covers a viewport of zero (the frame before layout has run) and
67/// a thumb that would not fit, which is zed's third guard: with a viewport
68/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
69pub fn thumb(
70    viewport: Pixels,
71    max_offset: Pixels,
72    offset: Pixels,
73    min: Pixels,
74) -> Option<Range<Pixels>> {
75    if viewport <= px(0.0) || max_offset <= px(0.0) {
76        return None;
77    }
78    let content = viewport + max_offset;
79    let size = min.max(viewport * (viewport / content));
80    if size > viewport {
81        return None;
82    }
83    // Negative going down, and never past either end — a wheel can overshoot.
84    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
85    let start = (travelled / max_offset) * (viewport - size);
86    Some(start..start + size)
87}
88
89/// The inverse: the scroll offset that puts the thumb's top at `top`.
90///
91/// Negative, because that is the direction gpui counts in, and clamped to the
92/// scrollable range so a drag past either end simply stops.
93pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
94    let travel = viewport - size;
95    if travel <= px(0.0) || max_offset <= px(0.0) {
96        return px(0.0);
97    }
98    -(max_offset * (top / travel).clamp(0.0, 1.0))
99}
100
101/// The drag payload. Carries the bar's id because, unlike a split, an app has
102/// several of these on screen at once and `on_drag_move` filters by type alone
103/// — without the id every bar in the window would answer one thumb's gesture.
104#[derive(Clone)]
105pub struct ScrollbarDrag(pub SharedString);
106
107/// Where in the thumb a drag was grabbed.
108///
109/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
110/// and the bar clones — for the same reason: both mutate through `&self`, so
111/// the bar carries its whole gesture without the view wiring a single listener.
112/// Without it the thumb would jump its middle to the pointer on every press.
113#[derive(Clone)]
114pub struct ScrollbarState {
115    grab: Rc<Cell<Option<Pixels>>>,
116    /// A drag runs in event-dispatch context, where the window cannot resolve
117    /// which view is asking — so the bar carries its own.
118    painter: Painter,
119}
120
121impl ScrollbarState {
122    pub fn new(painter: Painter) -> Self {
123        Self {
124            grab: Rc::new(Cell::new(None)),
125            painter,
126        }
127    }
128
129    /// Whether a thumb drag is in flight.
130    pub fn dragging(&self) -> bool {
131        self.grab.get().is_some()
132    }
133
134    /// One drag move of the thumb: filter the gesture to this bar's track, then
135    /// translate the pointer into a scroll offset.
136    fn drag(
137        &self,
138        track_id: &SharedString,
139        handle: &ScrollHandle,
140        event: &DragMoveEvent<ScrollbarDrag>,
141        cx: &mut App,
142    ) {
143        // Another bar's thumb: `on_drag_move` filters by payload type, and
144        // every bar in the window shares this one.
145        if event.drag(cx).0 != *track_id {
146            return;
147        }
148        let viewport = handle.bounds().size.height;
149        let max_offset = handle.max_offset().y;
150        let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
151            return;
152        };
153        let size = range.end - range.start;
154        let pointer = event.event.position.y - event.bounds.top();
155        // First move of this drag: the offset has not shifted yet, so the
156        // thumb is still where the press landed on it and the grab is
157        // simply the difference. Held for the rest of the gesture — read it
158        // again later and it would answer "wherever the pointer is now",
159        // which is a thumb that never moves.
160        let grab = self.grab.get().unwrap_or_else(|| {
161            let grab = (pointer - range.start).clamp(px(0.0), size);
162            self.grab.set(Some(grab));
163            grab
164        });
165        let offset = offset_for_thumb(pointer - grab, viewport, max_offset, size);
166        handle.set_offset(point(handle.offset().x, offset));
167        self.painter.notify(cx);
168    }
169}
170
171/// The bar: an overlay strip along the right edge of whatever it is laid over,
172/// showing nothing at all when the content fits.
173///
174/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
175/// content beneath it.
176///
177/// Its geometry comes from the handle as the *last* frame left it, which is all
178/// a render pass can see; the canvas at the end asks for one more frame when
179/// layout disagrees, so the bar is right on the frame after it first appears
180/// rather than whenever something else happens to repaint.
181///
182/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
183/// rather than a toned surface, so the thumb is [`ink`], which already follows
184/// the appearance on its own. A parameter it ignored would be worse than none.
185pub fn scrollbar(
186    id: impl Into<SharedString>,
187    handle: &ScrollHandle,
188    state: &ScrollbarState,
189) -> gpui::AnyElement {
190    let id = id.into();
191    let viewport = handle.bounds().size.height;
192    let max_offset = handle.max_offset().y;
193    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
194        return Empty.into_any_element();
195    };
196    let size = range.end - range.start;
197    let dragging = state.dragging();
198
199    let track_id = id.clone();
200    let drag_handle = handle.clone();
201    let drag_state = state.clone();
202    let release_state = state.clone();
203    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
204        release_state.grab.set(None);
205    };
206
207    div()
208        .id(SharedString::from(format!("{id}-track")))
209        .absolute()
210        .top_0()
211        .right_0()
212        .bottom_0()
213        .w(px(TRACK))
214        .flex()
215        .justify_center()
216        .on_drag_move(move |event, _, cx| {
217            drag_state.drag(&track_id, &drag_handle, event, cx);
218        })
219        // Both, because a release can land anywhere on screen; a grab left set
220        // would make the next press continue the last gesture.
221        .on_mouse_up(MouseButton::Left, released.clone())
222        .on_mouse_up_out(MouseButton::Left, released)
223        .child(
224            div()
225                .id(SharedString::from(format!("{id}-thumb")))
226                .absolute()
227                .top(range.start)
228                .h(size)
229                .w(px(THUMB))
230                .rounded_full()
231                .bg(if dragging { ink(0.38) } else { ink(0.2) })
232                .hover(|s| s.bg(ink(0.32)))
233                .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
234        )
235        .child(
236            canvas(
237                move |bounds, window, _| {
238                    // Laid out taller or shorter than the geometry above was
239                    // computed from: that geometry came from last frame's
240                    // handle. Ask for the frame that will paint it right.
241                    // Self-limiting — once they agree, nothing is requested.
242                    if (bounds.size.height - viewport).abs() > px(0.5) {
243                        window.request_animation_frame();
244                    }
245                },
246                |_, _, _, _| {},
247            )
248            .absolute()
249            .size_full(),
250        )
251        .into_any_element()
252}
253
254/// A mark per item, the one at the top of the viewport lit — for a pane whose
255/// content comes in countable pieces (a transcript's turns) rather than as one
256/// continuous document, where how far down you are matters less than which
257/// piece you are on. A press jumps to that piece.
258///
259/// `count` addresses the **direct children** of the `track_scroll` element,
260/// which is what gpui indexes: a pane whose pieces sit nested inside a wrapper
261/// reports one child, and every mark would scroll to the same place.
262///
263/// Absolute, so the caller's container holds the position: pin it with
264/// `.relative()` on whichever box the rail belongs to the edge of.
265///
266/// `room` is the clear space beside the content, which only the caller can
267/// measure — the rail paints nothing under [`RAIL_ROOM`], because marks over
268/// the text would be worse than no marks at all.
269pub fn rail(
270    id: impl Into<SharedString>,
271    handle: &ScrollHandle,
272    count: usize,
273    room: Pixels,
274) -> gpui::AnyElement {
275    if count == 0 || room < px(RAIL_ROOM) {
276        return Empty.into_any_element();
277    }
278    let id = id.into();
279    let at = handle.top_item();
280    div()
281        .absolute()
282        .top_0()
283        .bottom_0()
284        .left(px(RAIL_INSET))
285        .flex()
286        .flex_col()
287        .items_center()
288        .justify_center()
289        .gap(px(MARK_GAP))
290        .overflow_hidden()
291        .children((0..count).map(|ix| {
292            let handle = handle.clone();
293            div()
294                .id(SharedString::from(format!("{id}-{ix}")))
295                .w(px(MARK))
296                .h(px(MARK_THICK))
297                .rounded_full()
298                .bg(if ix == at { ink(0.6) } else { ink(0.2) })
299                .cursor_pointer()
300                .hover(|mark| mark.bg(ink(0.32)))
301                .on_click(move |_, window, _| {
302                    handle.scroll_to_item(ix);
303                    window.refresh();
304                })
305        }))
306        .into_any_element()
307}
308
309// ---------------------------------------------------------------------------
310// Transient — the same bar, only while the content moves
311// ---------------------------------------------------------------------------
312
313/// How long the thumb stays after the last scroll before fading — the idle
314/// window *is* the fade, because the fork's `Animation` has no delay.
315pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
316
317/// The show-and-fade state behind [`transient`]: the last frame's scroll
318/// state (a change is activity), a generation counter (a fresh animation id
319/// restarts the fade — `AnimationElement` pins its clock to the id it first
320/// laid out with), and the hover flag that holds the thumb up while the
321/// pointer is on the strip.
322#[derive(Clone)]
323pub struct TransientState {
324    cell: Rc<Cell<(Pixels, Pixels, u64, bool)>>,
325    bar: ScrollbarState,
326}
327
328impl TransientState {
329    pub fn new(painter: Painter) -> Self {
330        Self {
331            cell: Rc::new(Cell::new(Default::default())),
332            bar: ScrollbarState::new(painter),
333        }
334    }
335}
336
337/// The same bar as [`scrollbar`], but it only earns its place while the
338/// content moves: activity raises the thumb, and it fades out over
339/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
340/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
341/// animate, so it renders as the always-on bar.
342pub fn transient(
343    id: impl Into<SharedString>,
344    handle: &ScrollHandle,
345    state: &TransientState,
346    reduce_motion: bool,
347) -> gpui::AnyElement {
348    let id = id.into();
349    let viewport = handle.bounds().size.height;
350    let max_offset = handle.max_offset().y;
351    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
352        return Empty.into_any_element();
353    };
354    let size = range.end - range.start;
355
356    // Any change in the scroll state is activity: bump the generation so the
357    // fade restarts under a fresh animation id. The half-pixel slack keeps a
358    // sub-pixel layout jitter from re-showing a settled bar.
359    let mut cell = state.cell.get();
360    if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
361        cell.0 = handle.offset().y;
362        cell.1 = max_offset;
363        cell.2 += 1;
364        state.cell.set(cell);
365    }
366    let generation = cell.2;
367    let dragging = state.bar.dragging();
368
369    let track_id = id.clone();
370    let drag_handle = handle.clone();
371    let drag_state = state.clone();
372    let release_state = state.clone();
373    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
374        release_state.bar.grab.set(None);
375    };
376
377    let track = div()
378        .id(SharedString::from(format!("{id}-track")))
379        .absolute()
380        .top_0()
381        .right_0()
382        .bottom_0()
383        .w(px(TRACK))
384        .flex()
385        .justify_center()
386        .on_drag_move(move |event, _, cx| {
387            drag_state.bar.drag(&track_id, &drag_handle, event, cx);
388        })
389        // Both, because a release can land anywhere on screen; a grab left set
390        // would make the next press continue the last gesture.
391        .on_mouse_up(MouseButton::Left, released.clone())
392        .on_mouse_up_out(MouseButton::Left, released)
393        .map(|track| {
394            if reduce_motion {
395                track
396            } else {
397                // The thumb's stand-in at rest: hovering the strip raises it,
398                // and leaving starts its fade.
399                let hover_state = state.clone();
400                let hover_painter = state.bar.painter;
401                track.on_hover(move |hovered: &bool, _, cx: &mut App| {
402                    let mut cell = hover_state.cell.get();
403                    if cell.3 == *hovered {
404                        return;
405                    }
406                    cell.3 = *hovered;
407                    cell.2 += 1;
408                    hover_state.cell.set(cell);
409                    hover_painter.notify(cx);
410                })
411            }
412        });
413
414    let thumb = div()
415        .id(SharedString::from(format!("{id}-thumb")))
416        .absolute()
417        .top(range.start)
418        .h(size)
419        .w(px(THUMB))
420        .rounded_full()
421        .bg(if dragging { ink(0.38) } else { ink(0.2) })
422        .hover(|s| s.bg(ink(0.32)))
423        .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
424
425    let thumb: gpui::AnyElement = if reduce_motion {
426        thumb.into_any_element()
427    } else {
428        // The thumb's presence is the animation: progress 0 is fully up, and
429        // progress 1 — a full idle window later — is hidden, so no frame is
430        // requested once the fade completes.
431        let anim = state.clone();
432        thumb
433            .with_animation(
434                ElementId::from(format!("{id}-fade-{generation}")),
435                Animation::new(TRANSIENT_IDLE),
436                move |el, p| {
437                    let cell = anim.cell.get();
438                    if cell.3 || anim.bar.dragging() {
439                        el
440                    } else if p < 1.0 {
441                        el.opacity(1.0 - p)
442                    } else {
443                        el.hidden()
444                    }
445                },
446            )
447            .into_any_element()
448    };
449
450    track
451        .child(thumb)
452        .child(
453            canvas(
454                move |bounds, window, _| {
455                    // Laid out taller or shorter than the geometry above was
456                    // computed from: that geometry came from last frame's
457                    // handle. Ask for the frame that will paint it right.
458                    // Self-limiting — once they agree, nothing is requested.
459                    if (bounds.size.height - viewport).abs() > px(0.5) {
460                        window.request_animation_frame();
461                    }
462                },
463                |_, _, _, _| {},
464            )
465            .absolute()
466            .size_full(),
467        )
468        .into_any_element()
469}
470
471// ---------------------------------------------------------------------------
472// Nesting — which pane a wheel belongs to
473// ---------------------------------------------------------------------------
474
475/// Where a [`claim_wheel`] pane was before the wheel that is being dispatched.
476///
477/// Shaped like [`ScrollbarState`] and [`FollowState`], and owned by the view
478/// for the same reason: an element rebuilt every render cannot remember
479/// anything, and this has to outlive the frame it was written in.
480#[derive(Clone)]
481pub struct ClaimState(Rc<Cell<Pixels>>);
482
483impl Default for ClaimState {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl ClaimState {
490    pub fn new() -> Self {
491        Self(Rc::new(Cell::new(px(0.0))))
492    }
493}
494
495/// Let a pane keep the wheel it can act on, instead of passing it to the pane
496/// behind as well.
497///
498/// gpui's scroll listener neither stops propagation nor asks whether an
499/// ancestor scrolls too, so a wheel over a nested pane moves *both* — an output
500/// box inside a transcript scrolls itself and drags the transcript with it
501/// (user report). Every scrolling ancestor under the pointer does this, so the
502/// deeper the nesting the further the page jumps.
503///
504/// The question it asks is whether the pane *moved*, not whether it had room
505/// to. This listener runs after the element's own — gpui registers that one
506/// later and the bubble phase runs the list backwards — so `handle` already
507/// holds the post-scroll offset, and `state` is where it stood before. "Had
508/// room" is the same answer one notch too late, and gets the notch that lands
509/// exactly on the end wrong: the pane finishes its travel *and* the page jumps
510/// a full notch behind it.
511///
512/// Chained at the ends, not sealed: a pane with nowhere left to go hands the
513/// wheel to the page, so reaching the end of a short inner list does not strand
514/// it there and make the pointer move. A pane whose content fits never claims
515/// anything, for the same reason. For `overscroll-behavior: contain` — a pane
516/// that never lets a wheel past — stop unconditionally instead.
517///
518/// ```ignore
519/// scroll::claim_wheel(
520///     div().id("output").overflow_y_scroll().track_scroll(&self.scroll),
521///     &self.scroll,
522///     Axis::Vertical,
523///     &self.claim,
524/// )
525/// ```
526pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
527    el: E,
528    handle: &ScrollHandle,
529    axis: Axis,
530    state: &ClaimState,
531) -> E {
532    let handle = handle.clone();
533    let state = state.0.clone();
534    // Where the pane stands as the frame is built, which is where it stands
535    // before anything this frame's listeners are handed. The listener writes it
536    // too: a wheel the pane could not act on produces no `notify` and so no
537    // render, and the reading below has to stay true across that gap.
538    state.set(travel(&handle, axis));
539    el.on_scroll_wheel(move |_, _, cx| {
540        let now = travel(&handle, axis);
541        if now != state.get() {
542            state.set(now);
543            cx.stop_propagation();
544        }
545    })
546}
547
548/// How far `handle` has visibly travelled along `axis`. Negative, as gpui
549/// counts it.
550///
551/// Clamped here because gpui's scroll listener is not: it adds the raw delta
552/// and leaves the clamp to the next `paint`, so a pane held at its end keeps
553/// accumulating offset it will never show. Compared raw, every notch past the
554/// end reads as movement and the pane never lets go of the wheel.
555fn travel(handle: &ScrollHandle, axis: Axis) -> Pixels {
556    let (offset, max) = match axis {
557        Axis::Vertical => (handle.offset().y, handle.max_offset().y),
558        Axis::Horizontal => (handle.offset().x, handle.max_offset().x),
559    };
560    offset.clamp(-max.max(px(0.0)), px(0.0))
561}
562
563// ---------------------------------------------------------------------------
564// Follow — a view pinned to the bottom of content that grows under it
565// ---------------------------------------------------------------------------
566
567/// How close to the bottom still counts as following. A wheel lands on
568/// fractional offsets and a re-layout can move the end by a hair; without slack
569/// a view would unpin itself for a rounding error nobody asked for.
570pub const FOLLOW_SLACK: Pixels = px(4.0);
571
572/// Whether `offset` is at the end of the scrollable range, within `slack`.
573///
574/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
575/// `offset` is **negative** going down, which makes the distance still to go
576/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
577/// is nowhere else to be, and answering `false` would unpin an empty log.
578pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
579    if max_offset <= px(0.0) {
580        return true;
581    }
582    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
583    max_offset - travelled <= slack
584}
585
586/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
587///
588/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
589/// `&self`, so the element carries the whole behaviour without the view wiring
590/// a listener. Starts pinned — a transcript or a log opens on its newest line.
591#[derive(Clone)]
592pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
593
594impl Default for FollowState {
595    fn default() -> Self {
596        Self(Rc::new(Cell::new((true, px(0.0)))))
597    }
598}
599
600impl FollowState {
601    pub fn new() -> Self {
602        Self::default()
603    }
604
605    /// Whether the view is following. An app shows its "jump to latest" affordance
606    /// on `!following()`, which is the only reason this is public.
607    pub fn following(&self) -> bool {
608        self.0.get().0
609    }
610
611    /// Re-pin. What that "jump to latest" button calls; the next frame does the
612    /// scrolling.
613    pub fn follow(&self) {
614        let (_, last) = self.0.get();
615        self.0.set((true, last));
616    }
617}
618
619/// Keep `handle` pinned to the bottom of its content while the user leaves it
620/// there, and get out of the way the moment they scroll up.
621///
622/// Drop it in beside [`scrollbar`], over the same container:
623///
624/// ```ignore
625/// div().relative()
626///     .child(div().id("log").size_full().overflow_y_scroll().track_scroll(&self.scroll).child(rows))
627///     .child(scroll::follow(&self.scroll, &self.follow))
628///     .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
629/// ```
630///
631/// **Telling appended content from a user scroll is the whole problem**, and
632/// neither is an event this can subscribe to — both surface as the same handle
633/// reading differently than last frame. The overflow is what separates them: if
634/// it changed, the content grew and the pin is left as the user last set it; if
635/// it did not, the offset moved because the *user* moved it, and being at the
636/// end is what re-pins. So scrolling up releases, and scrolling back down
637/// re-attaches, with no gesture to hook.
638///
639/// The correction lands a frame late — the scrolling div was laid out with the
640/// old offset before this runs — which is why it asks for that frame. At a
641/// streaming cadence it is invisible, and it converges rather than spinning:
642/// once pinned and at the end, nothing is requested.
643pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
644    let handle = handle.clone();
645    let state = state.clone();
646    canvas(
647        move |_, window, _| {
648            let max_offset = handle.max_offset().y;
649            let offset = handle.offset().y;
650            let (was_pinned, last_max) = state.0.get();
651
652            let pinned = if (max_offset - last_max).abs() > px(0.5) {
653                was_pinned
654            } else {
655                at_bottom(max_offset, offset, FOLLOW_SLACK)
656            };
657
658            if pinned && (offset + max_offset).abs() > px(0.5) {
659                handle.set_offset(point(handle.offset().x, -max_offset));
660                window.request_animation_frame();
661            }
662            state.0.set((pinned, max_offset));
663        },
664        |_, _, _, _| {},
665    )
666    .absolute()
667    .size_full()
668    .into_any_element()
669}