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}