ui/scroll.rs
1//! What gpui's own scroll handles leave to the app: a container that scrolls
2//! the way it was asked to, a bar to show the position, and a rule for which
3//! pane a wheel belongs to.
4//!
5//! [`pane`] is the container — an axis given at construction, the way
6//! SwiftUI's `ScrollView` takes one, because gpui's is a style field that
7//! defaults to unset and gets guessed at. Read its docs before reaching for
8//! `div().overflow_y_scroll()`; the guess is a real bug and not a small one.
9//!
10//! gpui scrolls that pane perfectly well and draws nothing while it does, so a
11//! bezel app has no way to show how far down it is. That is [`scrollbar`]: an
12//! overlay the caller lays over its own pane, because a wrapper that swallowed
13//! the content would have to re-implement layout for it. Nesting two panes is
14//! [`claim_wheel`]'s business.
15//!
16//! ```ignore
17//! div().relative() // the bar is absolute in here
18//! .child(
19//! scroll::pane("pane", Axes::Vertical)
20//! .size_full()
21//! .track_scroll(&self.scroll) // gpui's handle, the app's field
22//! .child(content),
23//! )
24//! .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
25//! ```
26//!
27//! The bar must span the container it reports on — its track *is* the viewport,
28//! in the coordinates [`thumb`] answers in.
29//!
30//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
31//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
32//! system around the fifteen that matter. Two of gpui's conventions are easy to
33//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
34//! (content minus viewport, not content), and `offset` is **negative** as you
35//! scroll down.
36//!
37//! [`transient`] is the same bar, shown only while its content moves.
38//! [`Overlay`] manages its own state and supports either axis. Its default is
39//! [`Visibility::Scrolling`]; [`set_visibility`] updates all default overlays,
40//! including Markdown code blocks and tables. [`Viewport`] also owns the handle.
41
42mod overlay;
43pub use overlay::{Overlay, Viewport, Visibility, set_visibility, visibility};
44
45use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
46
47use gpui::{
48 Animation, AnimationExt, App, Axis, Bounds, Div, DragMoveEvent, ElementId, Empty, MouseButton,
49 Pixels, Point, ScrollHandle, SharedString, Stateful, Window, canvas, div, point, prelude::*,
50 px,
51};
52
53use motion::Painter;
54use theme::ink;
55use web_time::Instant;
56
57/// Shortest a thumb may get, however long the document — below this it stops
58/// being something a pointer can catch.
59pub const MIN_THUMB: Pixels = px(25.0);
60/// Space between the overlay track and the viewport edges, along the axis the
61/// bar runs.
62const INSET: f32 = 4.0;
63const BAR_INSET: Pixels = px(INSET);
64/// Width of the strip the thumb sits in.
65const TRACK: f32 = 10.0;
66/// Room a bar is centred in across its axis when the caller reserves none.
67const CHANNEL: f32 = 2.0 * INSET + TRACK;
68/// Width of the thumb itself, centred in the track.
69pub(crate) const THUMB: f32 = 4.0;
70/// Length of one [`rail`] mark, and its thickness.
71const MARK: f32 = 16.0;
72const MARK_THICK: f32 = 2.0;
73/// Between two marks. The track's width, so a rail and a bar on the same pane
74/// are cut to one rhythm.
75const MARK_GAP: f32 = TRACK;
76/// How far the rail stands off the edge it is pinned to.
77const RAIL_INSET: f32 = 12.0;
78/// What a rail needs beside the content before it will paint at all.
79const RAIL_ROOM: f32 = RAIL_INSET + MARK;
80
81// ---------------------------------------------------------------------------
82// Pane — a scroll container whose axis is an argument, not a modifier
83// ---------------------------------------------------------------------------
84
85/// Which way a [`pane`] scrolls. SwiftUI's `Axis.Set`, which gpui's [`Axis`]
86/// has no spelling for: a pane that scrolls both ways is not one of two
87/// directions.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum Axes {
90 Vertical,
91 Horizontal,
92 Both,
93}
94
95impl Axes {
96 pub fn vertical(self) -> bool {
97 matches!(self, Axes::Vertical | Axes::Both)
98 }
99
100 pub fn horizontal(self) -> bool {
101 matches!(self, Axes::Horizontal | Axes::Both)
102 }
103
104 /// The gpui axis this is, where it is only one.
105 pub fn axis(self) -> Option<Axis> {
106 match self {
107 Axes::Vertical => Some(Axis::Vertical),
108 Axes::Horizontal => Some(Axis::Horizontal),
109 Axes::Both => None,
110 }
111 }
112}
113
114/// A scroll container, with the axis as an argument rather than a modifier you
115/// can forget.
116///
117/// ```ignore
118/// scroll::pane("log", Axes::Vertical)
119/// .size_full()
120/// .track_scroll(&self.scroll)
121/// .child(content)
122/// ```
123///
124/// Returns an element for the caller to fill, the way [`crate::stack::row`]
125/// does — it takes no children and lays nothing out, so the pane stays the
126/// app's and only its scroll behaviour is decided here. The id is gpui's
127/// requirement, not ours: a scroll container has state to track.
128///
129/// # Why this exists rather than `div().overflow_y_scroll()`
130///
131/// gpui makes scrollability a late-bound style field with no default, and then
132/// has to guess what to do when a gesture's axis is not one the container
133/// scrolls: it **remaps the delta onto whichever axis the container can
134/// scroll**. A sideways swipe over a vertical list scrolls it down; a downward
135/// swipe over a wide table pans it sideways. `restrict_scroll_to_axis` turns
136/// that off, but it is opt-in per element, so every pane that forgets it is
137/// wrong and nothing says so.
138///
139/// SwiftUI has no such case to guess at — `ScrollView(.vertical)` takes its
140/// axis at construction, so there is no container whose axis is unstated. This
141/// is that: ask for an axis, get a pane that answers only to it.
142///
143/// A horizontal pane also contains a sideways gesture ([`contain_sideways`]),
144/// because the pane it is nested in usually belongs to a consumer and is not
145/// ours to restrict.
146///
147/// `Axes::Both` inherits gpui's dominant-axis lock — a diagonal gesture moves
148/// one axis, not two. gpui exposes no builder for `allow_concurrent_scroll`.
149pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
150 scrolls(div().id(id), axes)
151}
152
153/// [`pane`], keeping the wheel it can act on: the pane a consumer nests inside
154/// another and never wires a handle to.
155///
156/// [`claim_wheel`] asks the caller for a [`ScrollHandle`] and a [`ClaimState`],
157/// because the caller usually has the handle already — it is scrolling the pane
158/// from elsewhere. A bounded box inside someone else's page has neither, and a
159/// pane that is only ever read by the wheel that moves it should not make a
160/// consumer hold two fields to stop it dragging the page behind it. Both live
161/// in keyed element state here, so the pane is still one call.
162///
163/// ```ignore
164/// scroll::claiming_pane("output", Axes::Vertical, window, cx).child(text)
165/// ```
166///
167/// The chaining is [`claim_wheel`]'s: at its ends the pane hands the wheel back
168/// to the page.
169pub fn claiming_pane(
170 id: impl Into<ElementId>,
171 axes: Axes,
172 window: &mut Window,
173 cx: &mut App,
174) -> Stateful<Div> {
175 let id = id.into();
176 let held = window.use_keyed_state(id.clone(), cx, |_, _| Claiming::default());
177 let (handle, state) = {
178 let held = held.read(cx);
179 (held.handle.clone(), held.state.clone())
180 };
181 claim_wheel(pane(id, axes).track_scroll(&handle), &handle, axes, &state)
182}
183
184/// What [`claiming_pane`] keeps between frames: the handle it reads its own
185/// travel off, and where that travel stood before the wheel being dispatched.
186#[derive(Default)]
187struct Claiming {
188 handle: ScrollHandle,
189 state: ClaimState,
190}
191
192/// [`pane`]'s answer applied to an element that already exists — a container
193/// that scrolls only at some widths, or one another builder handed back.
194///
195/// ```ignore
196/// strip.when(compact, |strip| scroll::scrolls(strip, Axes::Horizontal))
197/// ```
198pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
199 let el = match axes {
200 Axes::Vertical => el.overflow_y_scroll(),
201 Axes::Horizontal => el.overflow_x_scroll(),
202 Axes::Both => el.overflow_scroll(),
203 }
204 .restrict_scroll_to_axis();
205 match axes.horizontal() {
206 true => contain_sideways(el),
207 false => el,
208 }
209}
210
211/// Keep a sideways gesture inside the pane it started in.
212///
213/// The other half of [`pane`], and the half [`Axes::Vertical`] does not want:
214/// a vertical pane at its end should hand the wheel to the page behind it
215/// ([`claim_wheel`] is that chaining), but a sideways gesture reaching a
216/// vertical ancestor is never right — unless that ancestor is restricted too,
217/// it will remap the delta and scroll down.
218///
219/// Applied by [`pane`] for the axes that need it. Public because a consumer
220/// wrapping bezel's content in a scroller of its own has the same problem and
221/// the same fix.
222///
223/// Registered before the element's own handler and so run after it — gpui
224/// bubbles the list backwards — which is why the pane has already moved by the
225/// time the event stops here.
226pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
227 contain_wheel(el, Axes::Horizontal)
228}
229
230/// Keep every wheel inside the pane it landed on — `overscroll-behavior:
231/// contain`, where [`claim_wheel`] is the chaining kind.
232///
233/// For a box with a cap on it, where the content is a program's output rather
234/// than a document: it is a window onto something, and a wheel over a window
235/// belongs to what is inside it. Chaining asks the pane to prove it moved,
236/// which it reads off a handle carrying the previous frame's layout — under a
237/// pane whose content is still arriving that reads as "did not move", and the
238/// page takes the wheel while the box is still scrolling (user report,
239/// DEV-13).
240///
241/// The page is still reachable: move the pointer off the box.
242pub fn contain_wheel<E: gpui::InteractiveElement>(el: E, axes: Axes) -> E {
243 el.on_scroll_wheel(move |event, window, cx| {
244 let delta = event.delta.pixel_delta(window.line_height());
245 // The dominant axis, not "any horizontal component": a trackpad puts a
246 // little of both into every gesture, and a mostly-vertical one still
247 // belongs to the page.
248 let sideways = delta.x.abs() > delta.y.abs();
249 if (sideways && axes.horizontal()) || (!sideways && axes.vertical()) {
250 cx.stop_propagation();
251 }
252 })
253}
254
255/// The strip a thumb sits in, placed along `axis` and named for `id`.
256///
257/// A press on the bar belongs to the bar. Hitboxes in gpui are paint-order
258/// only, so without `block_mouse_except_scroll` the content under the strip
259/// takes the press as well; the wheel still passes, which is what a bar laid
260/// over a pane has to let through.
261/// Whether the pointer rests on a bar's track, which holds a transient bar up.
262///
263/// gpui keeps the pointer's last position when it leaves the window, and a
264/// track under that position reads as hovered again on the next frame. `out`
265/// is set when the pointer leaves the window across the track and cleared by
266/// the next move over it.
267#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
268pub(crate) struct Hover {
269 over: bool,
270 out: bool,
271}
272
273impl Hover {
274 pub(crate) fn held(self) -> bool {
275 self.over && !self.out
276 }
277}
278
279type SetHover = Rc<dyn Fn(Hover, &mut Window, &mut App)>;
280
281/// Keep `hover` for `track`, calling `changed` when it changes.
282pub(crate) fn hover_track<E: StatefulInteractiveElement>(
283 track: E,
284 hover: Rc<Cell<Hover>>,
285 changed: impl Fn(&mut Window, &mut App) + 'static,
286) -> E {
287 let set: SetHover = {
288 let hover = hover.clone();
289 Rc::new(move |next, window, cx| {
290 if next != hover.get() {
291 hover.set(next);
292 changed(window, cx);
293 }
294 })
295 };
296 let (over, exit, moved) = (hover.clone(), hover.clone(), hover);
297 let (set_exit, set_moved) = (set.clone(), set.clone());
298 track
299 .on_hover(move |hovered, window, cx| {
300 let over = Hover {
301 over: *hovered,
302 ..over.get()
303 };
304 set(over, window, cx)
305 })
306 .on_mouse_exit(move |_, window, cx| {
307 let out = Hover {
308 out: true,
309 ..exit.get()
310 };
311 set_exit(out, window, cx)
312 })
313 .on_mouse_move(move |_, window, cx| {
314 let back = Hover {
315 out: false,
316 ..moved.get()
317 };
318 set_moved(back, window, cx)
319 })
320}
321
322fn track(id: &SharedString, place: Place, axis: Axis) -> Stateful<Div> {
323 let debug_id = id.clone();
324 let el = div()
325 .debug_selector(move || format!("{debug_id}-track"))
326 .id(SharedString::from(format!("{id}-track")))
327 .block_mouse_except_scroll()
328 .absolute()
329 .flex();
330 match axis {
331 Axis::Vertical => el
332 .top(BAR_INSET)
333 .right(place.near())
334 .bottom(BAR_INSET + place.end)
335 .w(px(TRACK))
336 .justify_center(),
337 Axis::Horizontal => el
338 .left(BAR_INSET)
339 .right(BAR_INSET + place.end)
340 .bottom(place.near())
341 .h(px(TRACK))
342 .items_center(),
343 }
344}
345
346/// Where the thumb sits in a track of `viewport` length, as a range from the
347/// track's start — or `None` when there is nothing to scroll.
348///
349/// `None` also covers a viewport of zero (the frame before layout has run) and
350/// a thumb that would not fit, which is zed's third guard: with a viewport
351/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
352pub fn thumb(
353 viewport: Pixels,
354 max_offset: Pixels,
355 offset: Pixels,
356 min: Pixels,
357) -> Option<Range<Pixels>> {
358 if viewport <= px(0.0) || max_offset <= px(0.0) {
359 return None;
360 }
361 let content = viewport + max_offset;
362 let size = min.max(viewport * (viewport / content));
363 if size > viewport {
364 return None;
365 }
366 // Negative going down, and never past either end — a wheel can overshoot.
367 let travelled = offset.clamp(-max_offset, px(0.0)).abs();
368 let start = (travelled / max_offset) * (viewport - size);
369 Some(start..start + size)
370}
371
372pub(crate) fn thumb_in_track(
373 viewport: Pixels,
374 max_offset: Pixels,
375 offset: Pixels,
376 track: Pixels,
377) -> Option<Range<Pixels>> {
378 if track <= px(0.) {
379 return None;
380 }
381 let scale = track / viewport;
382 let range = thumb(viewport, max_offset, offset, MIN_THUMB / scale)?;
383 Some(range.start * scale..range.end * scale)
384}
385
386/// The inverse: the scroll offset that puts the thumb's top at `top`.
387///
388/// Negative, because that is the direction gpui counts in, and clamped to the
389/// scrollable range so a drag past either end simply stops.
390pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
391 let travel = viewport - size;
392 if travel <= px(0.0) || max_offset <= px(0.0) {
393 return px(0.0);
394 }
395 -(max_offset * (top / travel).clamp(0.0, 1.0))
396}
397
398/// The drag payload. Carries the bar's id because, unlike a split, an app has
399/// several of these on screen at once and `on_drag_move` filters by type alone
400/// — without the id every bar in the window would answer one thumb's gesture.
401#[derive(Clone)]
402pub struct ScrollbarDrag(pub SharedString);
403
404/// Where in the thumb a drag was grabbed.
405///
406/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
407/// and the bar clones — for the same reason: both mutate through `&self`, so
408/// the bar carries its whole gesture without the view wiring a single listener.
409/// Without it the thumb would jump its middle to the pointer on every press.
410#[derive(Clone)]
411pub struct ScrollbarState {
412 grab: Rc<Cell<Option<Pixels>>>,
413 /// A drag runs in event-dispatch context, where the window cannot resolve
414 /// which view is asking — so the bar carries its own.
415 painter: Painter,
416}
417
418impl ScrollbarState {
419 pub fn new(painter: Painter) -> Self {
420 Self {
421 grab: Rc::new(Cell::new(None)),
422 painter,
423 }
424 }
425
426 fn begin(&self, handle: &ScrollHandle, event: &gpui::MouseDownEvent, end_inset: Pixels) {
427 let viewport = handle.bounds().size.height;
428 if let Some(range) = thumb_in_track(
429 viewport,
430 handle.max_offset().y,
431 handle.offset().y,
432 viewport - 2. * BAR_INSET - end_inset,
433 ) {
434 self.grab.set(Some(
435 (event.position.y - handle.bounds().top() - BAR_INSET - range.start)
436 .clamp(px(0.), range.end - range.start),
437 ));
438 }
439 }
440
441 /// Whether a thumb drag is in flight.
442 pub fn dragging(&self) -> bool {
443 self.grab.get().is_some()
444 }
445
446 /// One drag move of the thumb: filter the gesture to this bar's track, then
447 /// translate the pointer into a scroll offset.
448 fn drag(
449 &self,
450 track_id: &SharedString,
451 handle: &ScrollHandle,
452 event: &DragMoveEvent<ScrollbarDrag>,
453 end_inset: Pixels,
454 cx: &mut App,
455 ) {
456 // Another bar's thumb: `on_drag_move` filters by payload type, and
457 // every bar in the window shares this one.
458 if event.drag(cx).0 != *track_id {
459 return;
460 }
461 let viewport = handle.bounds().size.height;
462 let max_offset = handle.max_offset().y;
463 let Some(range) = thumb_in_track(
464 viewport,
465 max_offset,
466 handle.offset().y,
467 viewport - 2. * BAR_INSET - end_inset,
468 ) else {
469 return;
470 };
471 let size = range.end - range.start;
472 let pointer = event.event.position.y - event.bounds.top();
473 // First move of this drag: the offset has not shifted yet, so the
474 // thumb is still where the press landed on it and the grab is
475 // simply the difference. Held for the rest of the gesture — read it
476 // again later and it would answer "wherever the pointer is now",
477 // which is a thumb that never moves.
478 let grab = self.grab.get().unwrap_or_else(|| {
479 let grab = (pointer - range.start).clamp(px(0.0), size);
480 self.grab.set(Some(grab));
481 grab
482 });
483 let offset = offset_for_thumb(
484 pointer - grab,
485 viewport - 2. * BAR_INSET - end_inset,
486 max_offset,
487 size,
488 );
489 handle.set_offset(point(handle.offset().x, offset));
490 self.painter.notify(cx);
491 }
492}
493
494/// Where a bar sits in the pane it reports on. [`Overlay`] builds one; the free
495/// bars take the default.
496#[derive(Clone, Copy)]
497struct Place {
498 /// Shortens the track at its far end.
499 end: Pixels,
500 /// Room reserved across the axis, which the track is centred in.
501 channel: Pixels,
502}
503
504impl Default for Place {
505 fn default() -> Self {
506 Self {
507 end: px(0.),
508 channel: px(CHANNEL),
509 }
510 }
511}
512
513impl Place {
514 /// Gap between the near edge of the pane and the near side of the track.
515 fn near(self) -> Pixels {
516 ((self.channel - px(TRACK)) * 0.5).max(px(0.))
517 }
518}
519
520/// The bar: an overlay strip along the right edge of whatever it is laid over,
521/// showing nothing at all when the content fits.
522///
523/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
524/// content beneath it.
525///
526/// Its geometry comes from the handle as the *last* frame left it, which is all
527/// a render pass can see; the canvas at the end asks for one more frame when
528/// layout disagrees, so the bar is right on the frame after it first appears
529/// rather than whenever something else happens to repaint.
530///
531/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
532/// rather than a toned surface, so the thumb is [`ink`], which already follows
533/// the appearance on its own. A parameter it ignored would be worse than none.
534pub fn scrollbar(
535 id: impl Into<SharedString>,
536 handle: &ScrollHandle,
537 state: &ScrollbarState,
538) -> gpui::AnyElement {
539 scrollbar_placed(id.into(), handle, state, Place::default())
540}
541
542fn scrollbar_placed(
543 id: SharedString,
544 handle: &ScrollHandle,
545 state: &ScrollbarState,
546 place: Place,
547) -> gpui::AnyElement {
548 let end_inset = place.end;
549 let viewport = handle.bounds().size.height;
550 let max_offset = handle.max_offset().y;
551 let Some(range) = thumb_in_track(
552 viewport,
553 max_offset,
554 handle.offset().y,
555 viewport - 2. * BAR_INSET - end_inset,
556 ) else {
557 return Empty.into_any_element();
558 };
559 let size = range.end - range.start;
560 let dragging = state.dragging();
561
562 let track_id = id.clone();
563 let drag_handle = handle.clone();
564 let drag_state = state.clone();
565 let release_state = state.clone();
566 let press_state = state.clone();
567 let press_handle = handle.clone();
568 let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
569 release_state.grab.set(None);
570 };
571
572 let thumb_debug_id = id.clone();
573 track(&id, place, Axis::Vertical)
574 .on_drag_move(move |event, _, cx| {
575 drag_state.drag(&track_id, &drag_handle, event, end_inset, cx);
576 })
577 // Both, because a release can land anywhere on screen; a grab left set
578 // would make the next press continue the last gesture.
579 .on_mouse_up(MouseButton::Left, released.clone())
580 .on_mouse_up_out(MouseButton::Left, released)
581 .child(
582 div()
583 .debug_selector(move || format!("{thumb_debug_id}-thumb"))
584 .id(SharedString::from(format!("{id}-thumb")))
585 .absolute()
586 .top(range.start)
587 .h(size)
588 .w(px(THUMB))
589 .rounded_full()
590 .bg(if dragging { ink(0.38) } else { ink(0.2) })
591 .hover(|s| s.bg(ink(0.32)))
592 .on_mouse_down(MouseButton::Left, move |event, _, cx| {
593 press_state.begin(&press_handle, event, end_inset);
594 press_state.painter.notify(cx);
595 })
596 .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
597 )
598 .child(
599 canvas(
600 move |bounds, window, _| {
601 // Laid out taller or shorter than the geometry above was
602 // computed from: that geometry came from last frame's
603 // handle. Ask for the frame that will paint it right.
604 // Self-limiting — once they agree, nothing is requested.
605 if (bounds.size.height + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
606 {
607 window.request_animation_frame();
608 }
609 },
610 |_, _, _, _| {},
611 )
612 .absolute()
613 .size_full(),
614 )
615 .into_any_element()
616}
617
618/// Whether `room` beside the content is enough for a rail to paint in. A
619/// hand-rolled rail asks this to land on the same floor as [`rail`].
620pub fn rail_fits(room: Pixels) -> bool {
621 room >= px(RAIL_ROOM)
622}
623
624/// A mark per item, the one at the top of the viewport lit — for a pane whose
625/// content comes in countable pieces (a transcript's turns) rather than as one
626/// continuous document, where how far down you are matters less than which
627/// piece you are on. A press jumps to that piece.
628///
629/// `count` addresses the **direct children** of the `track_scroll` element,
630/// which is what gpui indexes: a pane whose pieces sit nested inside a wrapper
631/// reports one child, and every mark would scroll to the same place.
632///
633/// Absolute, so the caller's container holds the position: pin it with
634/// `.relative()` on whichever box the rail belongs to the edge of.
635///
636/// `room` is the clear space beside the content, which only the caller can
637/// measure; under what [`rail_fits`] accepts the rail paints nothing.
638pub fn rail(
639 id: impl Into<SharedString>,
640 handle: &ScrollHandle,
641 count: usize,
642 room: Pixels,
643) -> gpui::AnyElement {
644 if count == 0 || !rail_fits(room) {
645 return Empty.into_any_element();
646 }
647 let id = id.into();
648 let at = handle.top_item();
649 div()
650 .absolute()
651 .top_0()
652 .bottom_0()
653 .left(px(RAIL_INSET))
654 .flex()
655 .flex_col()
656 .items_center()
657 .justify_center()
658 .gap(px(MARK_GAP))
659 .overflow_hidden()
660 .children((0..count).map(|ix| {
661 let handle = handle.clone();
662 div()
663 .id(SharedString::from(format!("{id}-{ix}")))
664 .w(px(MARK))
665 .h(px(MARK_THICK))
666 .rounded_full()
667 .bg(if ix == at { ink(0.6) } else { ink(0.2) })
668 .cursor_pointer()
669 .hover(|mark| mark.bg(ink(0.32)))
670 .on_click(move |_, window, _| {
671 handle.scroll_to_item(ix);
672 window.refresh();
673 })
674 }))
675 .into_any_element()
676}
677
678// ---------------------------------------------------------------------------
679// Transient — the same bar, only while the content moves
680// ---------------------------------------------------------------------------
681
682/// How long the thumb stays after the last scroll before fading — the idle
683/// window *is* the fade, because the fork's `Animation` has no delay.
684pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
685
686/// The show-and-fade state behind [`transient`]: the last frame's scroll
687/// state (a change is activity), a generation counter (a fresh animation id
688/// restarts the fade — `AnimationElement` pins its clock to the id it first
689/// laid out with), and the hover flag that holds the thumb up while the
690/// pointer is on the strip.
691#[derive(Clone)]
692pub struct TransientState {
693 cell: Rc<Cell<(Pixels, Pixels, u64)>>,
694 hover: Rc<Cell<Hover>>,
695 bar: ScrollbarState,
696}
697
698impl TransientState {
699 pub fn new(painter: Painter) -> Self {
700 Self {
701 cell: Rc::new(Cell::new(Default::default())),
702 hover: Rc::default(),
703 bar: ScrollbarState::new(painter),
704 }
705 }
706}
707
708/// The same bar as [`scrollbar`], but it only earns its place while the
709/// content moves: activity raises the thumb, and it fades out over
710/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
711/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
712/// animate, so it renders as the always-on bar.
713pub fn transient(
714 id: impl Into<SharedString>,
715 handle: &ScrollHandle,
716 state: &TransientState,
717 reduce_motion: bool,
718) -> gpui::AnyElement {
719 transient_placed(id.into(), handle, state, reduce_motion, Place::default())
720}
721
722fn transient_placed(
723 id: SharedString,
724 handle: &ScrollHandle,
725 state: &TransientState,
726 reduce_motion: bool,
727 place: Place,
728) -> gpui::AnyElement {
729 let end_inset = place.end;
730 let viewport = handle.bounds().size.height;
731 let max_offset = handle.max_offset().y;
732 let Some(range) = thumb_in_track(
733 viewport,
734 max_offset,
735 handle.offset().y,
736 viewport - 2. * BAR_INSET - end_inset,
737 ) else {
738 return Empty.into_any_element();
739 };
740 let size = range.end - range.start;
741
742 // Any change in the scroll state is activity: bump the generation so the
743 // fade restarts under a fresh animation id. The half-pixel slack keeps a
744 // sub-pixel layout jitter from re-showing a settled bar.
745 let mut cell = state.cell.get();
746 if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
747 cell.0 = handle.offset().y;
748 cell.1 = max_offset;
749 cell.2 += 1;
750 state.cell.set(cell);
751 }
752 let generation = cell.2;
753 let dragging = state.bar.dragging();
754
755 let track_id = id.clone();
756 let drag_handle = handle.clone();
757 let drag_state = state.clone();
758 let release_state = state.clone();
759 let press_state = state.clone();
760 let press_handle = handle.clone();
761 let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
762 release_state.bar.grab.set(None);
763 };
764
765 let thumb_debug_id = id.clone();
766 let track = track(&id, place, Axis::Vertical)
767 .on_drag_move(move |event, _, cx| {
768 drag_state
769 .bar
770 .drag(&track_id, &drag_handle, event, end_inset, cx);
771 })
772 // Both, because a release can land anywhere on screen; a grab left set
773 // would make the next press continue the last gesture.
774 .on_mouse_up(MouseButton::Left, released.clone())
775 .on_mouse_up_out(MouseButton::Left, released)
776 .map(|track| {
777 if reduce_motion {
778 track
779 } else {
780 // The thumb's stand-in at rest: hovering the strip raises it,
781 // and leaving starts its fade.
782 let hover_state = state.clone();
783 let hover_painter = state.bar.painter;
784 hover_track(track, state.hover.clone(), move |_, cx| {
785 let mut cell = hover_state.cell.get();
786 cell.2 += 1;
787 hover_state.cell.set(cell);
788 hover_painter.notify(cx);
789 })
790 }
791 });
792
793 let thumb = div()
794 .debug_selector(move || format!("{thumb_debug_id}-thumb"))
795 .id(SharedString::from(format!("{id}-thumb")))
796 .absolute()
797 .top(range.start)
798 .h(size)
799 .w(px(THUMB))
800 .rounded_full()
801 .bg(if dragging { ink(0.38) } else { ink(0.2) })
802 .hover(|s| s.bg(ink(0.32)))
803 .on_mouse_down(MouseButton::Left, move |event, _, cx| {
804 press_state.bar.begin(&press_handle, event, end_inset);
805 press_state.bar.painter.notify(cx);
806 })
807 .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
808
809 let thumb: gpui::AnyElement = if reduce_motion {
810 thumb.into_any_element()
811 } else {
812 // The thumb's presence is the animation: progress 0 is fully up, and
813 // progress 1 — a full idle window later — is hidden, so no frame is
814 // requested once the fade completes.
815 let anim = state.clone();
816 thumb
817 .with_animation(
818 ElementId::from(format!("{id}-fade-{generation}")),
819 Animation::new(TRANSIENT_IDLE),
820 move |el, p| {
821 if anim.hover.get().held() || anim.bar.dragging() {
822 el
823 } else if p < 1.0 {
824 el.opacity(1.0 - p)
825 } else {
826 el.hidden()
827 }
828 },
829 )
830 .into_any_element()
831 };
832
833 track
834 .child(thumb)
835 .child(
836 canvas(
837 move |bounds, window, _| {
838 // Laid out taller or shorter than the geometry above was
839 // computed from: that geometry came from last frame's
840 // handle. Ask for the frame that will paint it right.
841 // Self-limiting — once they agree, nothing is requested.
842 if (bounds.size.height + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
843 {
844 window.request_animation_frame();
845 }
846 },
847 |_, _, _, _| {},
848 )
849 .absolute()
850 .size_full(),
851 )
852 .into_any_element()
853}
854
855// ---------------------------------------------------------------------------
856// Nesting — which pane a wheel belongs to
857// ---------------------------------------------------------------------------
858
859/// Where a [`claim_wheel`] pane was before the wheel that is being dispatched.
860///
861/// Shaped like [`ScrollbarState`] and [`FollowState`], and owned by the view
862/// for the same reason: an element rebuilt every render cannot remember
863/// anything, and this has to outlive the frame it was written in.
864#[derive(Clone)]
865pub struct ClaimState(Rc<Cell<Point<Pixels>>>);
866
867impl Default for ClaimState {
868 fn default() -> Self {
869 Self::new()
870 }
871}
872
873impl ClaimState {
874 pub fn new() -> Self {
875 Self(Rc::new(Cell::new(point(px(0.0), px(0.0)))))
876 }
877}
878
879/// Let a pane keep the wheel it can act on, instead of passing it to the pane
880/// behind as well.
881///
882/// gpui's scroll listener neither stops propagation nor asks whether an
883/// ancestor scrolls too, so a wheel over a nested pane moves *both* — an output
884/// box inside a transcript scrolls itself and drags the transcript with it
885/// (user report). Every scrolling ancestor under the pointer does this, so the
886/// deeper the nesting the further the page jumps.
887///
888/// The question it asks is whether the pane *moved*, not whether it had room
889/// to. This listener runs after the element's own — gpui registers that one
890/// later and the bubble phase runs the list backwards — so `handle` already
891/// holds the post-scroll offset, and `state` is where it stood before. "Had
892/// room" is the same answer one notch too late, and gets the notch that lands
893/// exactly on the end wrong: the pane finishes its travel *and* the page jumps
894/// a full notch behind it.
895///
896/// Chained at the ends, not sealed: a pane with nowhere left to go hands the
897/// wheel to the page, so reaching the end of a short inner list does not strand
898/// it there and make the pointer move. A pane whose content fits never claims
899/// anything, for the same reason. For `overscroll-behavior: contain` — a pane
900/// that never lets a wheel past — stop unconditionally instead.
901///
902/// ```ignore
903/// scroll::claim_wheel(
904/// scroll::pane("output", Axes::Vertical).track_scroll(&self.scroll),
905/// &self.scroll,
906/// Axes::Vertical,
907/// &self.claim,
908/// )
909/// ```
910///
911/// `axes` is what the pane scrolls, and must be what [`pane`] was given: an
912/// axis left out here is one whose movement goes unnoticed, so the wheel is
913/// handed on and the ancestor moves too.
914pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
915 el: E,
916 handle: &ScrollHandle,
917 axes: Axes,
918 state: &ClaimState,
919) -> E {
920 let handle = handle.clone();
921 let state = state.0.clone();
922 // Where the pane stands as the frame is built, which is where it stands
923 // before anything this frame's listeners are handed. The listener writes it
924 // too: a wheel the pane could not act on produces no `notify` and so no
925 // render, and the reading below has to stay true across that gap.
926 state.set(travel(&handle, axes));
927 el.on_scroll_wheel(move |_, _, cx| {
928 let now = travel(&handle, axes);
929 if now != state.get() {
930 state.set(now);
931 cx.stop_propagation();
932 }
933 })
934}
935
936/// How far `handle` has visibly travelled along each of `axes`. Negative, as
937/// gpui counts it; an axis the pane does not scroll reads zero forever, so it
938/// can never be mistaken for movement.
939///
940/// Clamped here because gpui's scroll listener is not: it adds the raw delta
941/// and leaves the clamp to the next `paint`, so a pane held at its end keeps
942/// accumulating offset it will never show. Compared raw, every notch past the
943/// end reads as movement and the pane never lets go of the wheel.
944fn travel(handle: &ScrollHandle, axes: Axes) -> Point<Pixels> {
945 let (offset, max) = (handle.offset(), handle.max_offset());
946 let seen = |offset: Pixels, max: Pixels| offset.clamp(-max.max(px(0.0)), px(0.0));
947 point(
948 match axes.horizontal() {
949 true => seen(offset.x, max.x),
950 false => px(0.0),
951 },
952 match axes.vertical() {
953 true => seen(offset.y, max.y),
954 false => px(0.0),
955 },
956 )
957}
958
959// ---------------------------------------------------------------------------
960// Follow — a view pinned to the bottom of content that grows under it
961// ---------------------------------------------------------------------------
962
963/// How close to the bottom still counts as following. A wheel lands on
964/// fractional offsets and a re-layout can move the end by a hair; without slack
965/// a view would unpin itself for a rounding error nobody asked for.
966pub const FOLLOW_SLACK: Pixels = px(4.0);
967
968/// Whether `offset` is at the end of the scrollable range, within `slack`.
969///
970/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
971/// `offset` is **negative** going down, which makes the distance still to go
972/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
973/// is nowhere else to be, and answering `false` would unpin an empty log.
974pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
975 if max_offset <= px(0.0) {
976 return true;
977 }
978 let travelled = offset.clamp(-max_offset, px(0.0)).abs();
979 max_offset - travelled <= slack
980}
981
982/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
983///
984/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
985/// `&self`, so the element carries the whole behaviour without the view wiring
986/// a listener. Starts pinned — a transcript or a log opens on its newest line.
987#[derive(Clone)]
988pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
989
990impl Default for FollowState {
991 fn default() -> Self {
992 Self(Rc::new(Cell::new((true, px(0.0)))))
993 }
994}
995
996impl FollowState {
997 pub fn new() -> Self {
998 Self::default()
999 }
1000
1001 /// Whether the view is following. An app shows its "jump to latest" affordance
1002 /// on `!following()`, which is the only reason this is public.
1003 pub fn following(&self) -> bool {
1004 self.0.get().0
1005 }
1006
1007 /// Re-pin. What that "jump to latest" button calls; the next frame does the
1008 /// scrolling.
1009 pub fn follow(&self) {
1010 let (_, last) = self.0.get();
1011 self.0.set((true, last));
1012 }
1013}
1014
1015/// Keep `handle` pinned to the bottom of its content while the user leaves it
1016/// there, and get out of the way the moment they scroll up.
1017///
1018/// Drop it in beside [`scrollbar`], over the same container:
1019///
1020/// ```ignore
1021/// div().relative()
1022/// .child(scroll::pane("log", Axes::Vertical).size_full().track_scroll(&self.scroll).child(rows))
1023/// .child(scroll::follow(&self.scroll, &self.follow))
1024/// .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
1025/// ```
1026///
1027/// **Telling appended content from a user scroll is the whole problem**, and
1028/// neither is an event this can subscribe to — both surface as the same handle
1029/// reading differently than last frame. The overflow is what separates them: if
1030/// it changed, the content grew and the pin is left as the user last set it; if
1031/// it did not, the offset moved because the *user* moved it, and being at the
1032/// end is what re-pins. So scrolling up releases, and scrolling back down
1033/// re-attaches, with no gesture to hook.
1034///
1035/// The correction lands a frame late — the scrolling div was laid out with the
1036/// old offset before this runs — which is why it asks for that frame. At a
1037/// streaming cadence it is invisible, and it converges rather than spinning:
1038/// once pinned and at the end, nothing is requested.
1039pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
1040 let handle = handle.clone();
1041 let state = state.clone();
1042 canvas(
1043 move |_, window, _| {
1044 let max_offset = handle.max_offset().y;
1045 let offset = handle.offset().y;
1046 let (was_pinned, last_max) = state.0.get();
1047
1048 let pinned = if (max_offset - last_max).abs() > px(0.5) {
1049 was_pinned
1050 } else {
1051 at_bottom(max_offset, offset, FOLLOW_SLACK)
1052 };
1053
1054 if pinned && (offset + max_offset).abs() > px(0.5) {
1055 handle.set_offset(point(handle.offset().x, -max_offset));
1056 window.request_animation_frame();
1057 }
1058 state.0.set((pinned, max_offset));
1059 },
1060 |_, _, _, _| {},
1061 )
1062 .absolute()
1063 .size_full()
1064 .into_any_element()
1065}
1066
1067// ---------------------------------------------------------------------------
1068// Drift — a pane that keeps moving while a drag is held at its edge
1069// ---------------------------------------------------------------------------
1070
1071/// How close to an edge a held drag starts the pane moving. Read off
1072/// `../desktop`'s board (2026-05): a third of a column, wide enough to reach
1073/// while aiming at a card and narrow enough to leave the middle still.
1074pub const DRIFT_EDGE: Pixels = px(96.0);
1075
1076/// How fast a pane travels with the pointer at the very edge, in pixels a
1077/// second. The same board's 22px per frame, said in a unit that does not
1078/// double on a 120Hz display.
1079pub const DRIFT_SPEED: f32 = 1320.0;
1080
1081/// The most one frame may travel, however late it ran. A frame dropped while
1082/// the pointer rests at an edge costs a pause, not a jump to the far end.
1083const DRIFT_STEP: Duration = Duration::from_millis(50);
1084
1085/// How far a pane should travel in a second, for a pointer at `pointer`
1086/// between edges `start` and `end`.
1087///
1088/// Signed the way gpui's offset is: positive travels back toward the start,
1089/// because the offset goes negative as a pane scrolls on. Zero everywhere but
1090/// within [`DRIFT_EDGE`] of an edge, where it ramps with proximity — easing
1091/// toward the edge eases the scroll — and holds at full speed once the pointer
1092/// is past it, so a card carried off the side of a board keeps it coming
1093/// instead of stopping dead at the boundary.
1094///
1095/// The ramp is capped at half the pane, so one narrower than two edges has a
1096/// still middle rather than a midpoint where the direction flips at half speed.
1097pub fn drift_velocity(pointer: Pixels, start: Pixels, end: Pixels) -> f32 {
1098 let edge = DRIFT_EDGE.as_f32().min((end - start).as_f32() / 2.0);
1099 if edge <= 0.0 {
1100 return 0.0;
1101 }
1102 let (from_start, from_end) = ((pointer - start).as_f32(), (end - pointer).as_f32());
1103 // The nearer edge, which is what decides the direction — and what keeps
1104 // the two ramps from summing where they overlap.
1105 let near = from_start.min(from_end);
1106 if near >= edge {
1107 return 0.0;
1108 }
1109 let ramp = 1.0 - near.max(0.0) / edge;
1110 match from_start < from_end {
1111 true => DRIFT_SPEED * ramp,
1112 false => -DRIFT_SPEED * ramp,
1113 }
1114}
1115
1116/// What lies past a drifting pane's edge, and so whether a pointer that has
1117/// crossed it is still aiming at the pane.
1118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1119pub enum Beyond {
1120 /// Nothing the drag could be meant for — a board that fills the window.
1121 /// The pane holds at full speed for as long as the pointer is out there.
1122 Nothing,
1123 /// Another surface. The pane stops the moment the pointer leaves it,
1124 /// whichever edge it left by.
1125 Neighbour,
1126}
1127
1128/// How fast a pane of `bounds` drifts with the pointer at `pointer`, one
1129/// [`drift_velocity`] per axis [`Axes`] names.
1130///
1131/// Across an axis the pointer must be within the pane: without that, every
1132/// lane of a board would drift together on a drag that is only near one of
1133/// them. Along it, `beyond` decides.
1134pub fn pane_velocity(
1135 bounds: Bounds<Pixels>,
1136 pointer: Point<Pixels>,
1137 axes: Axes,
1138 beyond: Beyond,
1139) -> Point<f32> {
1140 if beyond == Beyond::Neighbour && !bounds.contains(&pointer) {
1141 return point(0.0, 0.0);
1142 }
1143 let mut velocity = point(0.0, 0.0);
1144 if axes.horizontal() && (bounds.top()..=bounds.bottom()).contains(&pointer.y) {
1145 velocity.x = drift_velocity(pointer.x, bounds.left(), bounds.right());
1146 }
1147 if axes.vertical() && (bounds.left()..=bounds.right()).contains(&pointer.x) {
1148 velocity.y = drift_velocity(pointer.y, bounds.top(), bounds.bottom());
1149 }
1150 velocity
1151}
1152
1153/// What one drifting pane remembers between frames.
1154#[derive(Clone, Copy, Default)]
1155struct Drift {
1156 /// Where the pointer was last seen carrying something this pane follows.
1157 aim: Option<Point<Pixels>>,
1158 /// When it last moved, and `None` whenever it is not moving — so a pane
1159 /// picking the gesture back up starts its clock rather than travelling the
1160 /// gap it stood still for.
1161 since: Option<Instant>,
1162}
1163
1164/// Where a drift was last aimed, and when it last moved.
1165///
1166/// Shaped like [`ScrollbarState`] and owned by the view for the same reason:
1167/// the element is rebuilt every frame and cannot remember where the pointer
1168/// was. One per pane — two panes sharing one would take turns reading each
1169/// other's clock.
1170#[derive(Clone, Default)]
1171pub struct DriftState(Rc<Cell<Drift>>);
1172
1173impl DriftState {
1174 pub fn new() -> Self {
1175 Self::default()
1176 }
1177
1178 /// Where the pointer is. Call it from the drag-move listener of the
1179 /// payload this pane should follow — that listener is typed, which is what
1180 /// keeps a board from drifting under somebody dragging a scrollbar thumb:
1181 ///
1182 /// ```ignore
1183 /// .on_drag_move(cx.listener(|this, event: &DragMoveEvent<CardDrag>, _, _| {
1184 /// this.drift.aim(event.event.position);
1185 /// }))
1186 /// ```
1187 ///
1188 /// Aimed rather than continuous: gpui reports a drag only while it moves,
1189 /// and a pointer parked at an edge is the case the whole thing exists for.
1190 pub fn aim(&self, pointer: Point<Pixels>) {
1191 let drift = self.0.get();
1192 self.0.set(Drift {
1193 aim: Some(pointer),
1194 ..drift
1195 });
1196 }
1197}
1198
1199/// Move `handle` while a drag is held near its edge, for as long as it is held
1200/// there.
1201///
1202/// Drop it in beside [`scrollbar`], over the same container, and feed it from
1203/// the drag-move listener — see [`DriftState::aim`]:
1204///
1205/// ```ignore
1206/// div().relative()
1207/// .child(scroll::pane("board", Axes::Horizontal).size_full().track_scroll(&self.scroll).child(lanes))
1208/// .child(scroll::drift(&self.scroll, &self.drift, Axes::Horizontal, Beyond::Nothing))
1209/// ```
1210///
1211/// Without it a board is only as wide as the window: a card cannot be carried
1212/// to a lane that is off screen, because reaching for one means letting go.
1213///
1214/// **The gesture ending is not an event this subscribes to.** gpui hands a
1215/// drop to whatever was under the pointer, and a release over nothing is not
1216/// delivered at all, so either would leave a pane drifting on a gesture that
1217/// is over. The drag going away is the signal instead, and it arrives however
1218/// the drag ended.
1219///
1220/// `beyond` is what the pane's edge gives onto, and decides whether a pointer
1221/// carried past it still drives the pane — see [`Beyond`] and
1222/// [`pane_velocity`].
1223///
1224/// Not motion in the [`motion`] sense and not reduced with it: nothing here
1225/// animates a property, the pane is being scrolled by a gesture the same way a
1226/// wheel scrolls it, and a reader who cannot reach the far lane has no gesture
1227/// left to make.
1228pub fn drift(
1229 handle: &ScrollHandle,
1230 state: &DriftState,
1231 axes: Axes,
1232 beyond: Beyond,
1233) -> gpui::AnyElement {
1234 let handle = handle.clone();
1235 let state = state.clone();
1236 canvas(
1237 move |_, window, cx: &mut App| {
1238 let drift = state.0.get();
1239 // Nothing aimed here, or the gesture that aimed it has ended.
1240 let Some(pointer) = drift.aim.filter(|_| cx.has_active_drag()) else {
1241 state.0.set(Drift::default());
1242 return;
1243 };
1244
1245 let velocity = pane_velocity(handle.bounds(), pointer, axes, beyond);
1246 // Aimed here but nowhere near an edge, or gone off to a
1247 // neighbour. The clock stops with it, so a drag wandering back to
1248 // the edge a second later starts a fresh drift rather than
1249 // travelling the second it stood still.
1250 if velocity.x == 0.0 && velocity.y == 0.0 {
1251 state.0.set(Drift {
1252 aim: Some(pointer),
1253 since: None,
1254 });
1255 return;
1256 }
1257
1258 let now = cx.background_executor().now();
1259 state.0.set(Drift {
1260 aim: Some(pointer),
1261 since: Some(now),
1262 });
1263 // The first frame of a drift is the one that starts the clock;
1264 // there is no interval yet to travel over.
1265 let Some(last) = drift.since else {
1266 window.request_animation_frame();
1267 return;
1268 };
1269
1270 let step = (now - last).min(DRIFT_STEP).as_secs_f32();
1271 let (offset, max) = (handle.offset(), handle.max_offset());
1272 let moved = point(
1273 (offset.x + px(velocity.x * step)).clamp(-max.x, px(0.0)),
1274 (offset.y + px(velocity.y * step)).clamp(-max.y, px(0.0)),
1275 );
1276 if moved == offset {
1277 // Held at an end that has nowhere left to go. Asking for
1278 // another frame here would spin at the display's rate for as
1279 // long as the drag is held; the next pointer move draws one
1280 // anyway, and that is the soonest this could have anything to
1281 // do.
1282 return;
1283 }
1284 handle.set_offset(moved);
1285 window.request_animation_frame();
1286 },
1287 |_, _, _, _| {},
1288 )
1289 .absolute()
1290 .size_full()
1291 .into_any_element()
1292}