bezel-ui 0.1.12

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
//! What gpui's own scroll handles leave to the app: a bar to show the position,
//! and a rule for which pane a wheel belongs to.
//!
//! gpui scrolls a `div` perfectly well and draws nothing while it does, so a
//! bezel app has no way to show how far down it is. This is that bar: the
//! caller keeps its own `overflow_y_scroll` container, because a wrapper that
//! swallowed the content would have to re-implement layout for it. Nesting two
//! of those containers is [`claim_wheel`]'s business.
//!
//! ```ignore
//! div().relative()                                  // the bar is absolute in here
//!     .child(
//!         div()
//!             .id("pane")
//!             .size_full()
//!             .overflow_y_scroll()
//!             .track_scroll(&self.scroll)           // gpui's handle, the app's field
//!             .child(content),
//!     )
//!     .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar))
//! ```
//!
//! The bar must span the container it reports on — its track *is* the viewport,
//! in the coordinates [`thumb`] answers in.
//!
//! The geometry is transcribed from zed's own scrollbar (`thumb_ranges` in
//! `crates/ui/src/components/scrollbar.rs`), which is 1722 lines of settings
//! system around the fifteen that matter. Two of gpui's conventions are easy to
//! get backwards and both are load-bearing here: `max_offset` is the *overflow*
//! (content minus viewport, not content), and `offset` is **negative** as you
//! scroll down.
//!
//! [`transient`] is the same bar, shown only while its content moves.

use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};

use gpui::{
    Animation, AnimationExt, App, Axis, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
    ScrollHandle, SharedString, Window, canvas, div, point, prelude::*, px,
};

use motion::Painter;
use theme::ink;

/// Shortest a thumb may get, however long the document — below this it stops
/// being something a pointer can catch.
pub const MIN_THUMB: Pixels = px(25.0);
/// Width of the strip the thumb sits in.
const TRACK: f32 = 10.0;
/// Width of the thumb itself, centred in the track.
const THUMB: f32 = 6.0;
/// Length of one [`rail`] mark, and its thickness.
const MARK: f32 = 16.0;
const MARK_THICK: f32 = 2.0;
/// Between two marks. The track's width, so a rail and a bar on the same pane
/// are cut to one rhythm.
const MARK_GAP: f32 = TRACK;
/// How far the rail stands off the edge it is pinned to.
const RAIL_INSET: f32 = 12.0;
/// What a rail needs beside the content before it will paint at all.
pub const RAIL_ROOM: f32 = RAIL_INSET + MARK;

/// Where the thumb sits in a track of `viewport` length, as a range from the
/// track's start — or `None` when there is nothing to scroll.
///
/// `None` also covers a viewport of zero (the frame before layout has run) and
/// a thumb that would not fit, which is zed's third guard: with a viewport
/// shorter than [`MIN_THUMB`] a bar would be all thumb and no travel.
pub fn thumb(
    viewport: Pixels,
    max_offset: Pixels,
    offset: Pixels,
    min: Pixels,
) -> Option<Range<Pixels>> {
    if viewport <= px(0.0) || max_offset <= px(0.0) {
        return None;
    }
    let content = viewport + max_offset;
    let size = min.max(viewport * (viewport / content));
    if size > viewport {
        return None;
    }
    // Negative going down, and never past either end — a wheel can overshoot.
    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
    let start = (travelled / max_offset) * (viewport - size);
    Some(start..start + size)
}

/// The inverse: the scroll offset that puts the thumb's top at `top`.
///
/// Negative, because that is the direction gpui counts in, and clamped to the
/// scrollable range so a drag past either end simply stops.
pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
    let travel = viewport - size;
    if travel <= px(0.0) || max_offset <= px(0.0) {
        return px(0.0);
    }
    -(max_offset * (top / travel).clamp(0.0, 1.0))
}

/// The drag payload. Carries the bar's id because, unlike a split, an app has
/// several of these on screen at once and `on_drag_move` filters by type alone
/// — without the id every bar in the window would answer one thumb's gesture.
#[derive(Clone)]
pub struct ScrollbarDrag(pub SharedString);

/// Where in the thumb a drag was grabbed.
///
/// Shaped like gpui's `ScrollHandle` — an `Rc` cell the view holds one field of
/// and the bar clones — for the same reason: both mutate through `&self`, so
/// the bar carries its whole gesture without the view wiring a single listener.
/// Without it the thumb would jump its middle to the pointer on every press.
#[derive(Clone)]
pub struct ScrollbarState {
    grab: Rc<Cell<Option<Pixels>>>,
    /// A drag runs in event-dispatch context, where the window cannot resolve
    /// which view is asking — so the bar carries its own.
    painter: Painter,
}

impl ScrollbarState {
    pub fn new(painter: Painter) -> Self {
        Self {
            grab: Rc::new(Cell::new(None)),
            painter,
        }
    }

    /// Whether a thumb drag is in flight.
    pub fn dragging(&self) -> bool {
        self.grab.get().is_some()
    }

    /// One drag move of the thumb: filter the gesture to this bar's track, then
    /// translate the pointer into a scroll offset.
    fn drag(
        &self,
        track_id: &SharedString,
        handle: &ScrollHandle,
        event: &DragMoveEvent<ScrollbarDrag>,
        cx: &mut App,
    ) {
        // Another bar's thumb: `on_drag_move` filters by payload type, and
        // every bar in the window shares this one.
        if event.drag(cx).0 != *track_id {
            return;
        }
        let viewport = handle.bounds().size.height;
        let max_offset = handle.max_offset().y;
        let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
            return;
        };
        let size = range.end - range.start;
        let pointer = event.event.position.y - event.bounds.top();
        // First move of this drag: the offset has not shifted yet, so the
        // thumb is still where the press landed on it and the grab is
        // simply the difference. Held for the rest of the gesture — read it
        // again later and it would answer "wherever the pointer is now",
        // which is a thumb that never moves.
        let grab = self.grab.get().unwrap_or_else(|| {
            let grab = (pointer - range.start).clamp(px(0.0), size);
            self.grab.set(Some(grab));
            grab
        });
        let offset = offset_for_thumb(pointer - grab, viewport, max_offset, size);
        handle.set_offset(point(handle.offset().x, offset));
        self.painter.notify(cx);
    }
}

/// The bar: an overlay strip along the right edge of whatever it is laid over,
/// showing nothing at all when the content fits.
///
/// Overlay rather than a gutter, so a bar arriving or leaving never reflows the
/// content beneath it.
///
/// Its geometry comes from the handle as the *last* frame left it, which is all
/// a render pass can see; the canvas at the end asks for one more frame when
/// layout disagrees, so the bar is right on the frame after it first appears
/// rather than whenever something else happens to repaint.
///
/// No `&Theme`, unlike most of this crate — a scrollbar is a neutral overlay
/// rather than a toned surface, so the thumb is [`ink`], which already follows
/// the appearance on its own. A parameter it ignored would be worse than none.
pub fn scrollbar(
    id: impl Into<SharedString>,
    handle: &ScrollHandle,
    state: &ScrollbarState,
) -> gpui::AnyElement {
    let id = id.into();
    let viewport = handle.bounds().size.height;
    let max_offset = handle.max_offset().y;
    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
        return Empty.into_any_element();
    };
    let size = range.end - range.start;
    let dragging = state.dragging();

    let track_id = id.clone();
    let drag_handle = handle.clone();
    let drag_state = state.clone();
    let release_state = state.clone();
    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
        release_state.grab.set(None);
    };

    div()
        .id(SharedString::from(format!("{id}-track")))
        .absolute()
        .top_0()
        .right_0()
        .bottom_0()
        .w(px(TRACK))
        .flex()
        .justify_center()
        .on_drag_move(move |event, _, cx| {
            drag_state.drag(&track_id, &drag_handle, event, cx);
        })
        // Both, because a release can land anywhere on screen; a grab left set
        // would make the next press continue the last gesture.
        .on_mouse_up(MouseButton::Left, released.clone())
        .on_mouse_up_out(MouseButton::Left, released)
        .child(
            div()
                .id(SharedString::from(format!("{id}-thumb")))
                .absolute()
                .top(range.start)
                .h(size)
                .w(px(THUMB))
                .rounded_full()
                .bg(if dragging { ink(0.38) } else { ink(0.2) })
                .hover(|s| s.bg(ink(0.32)))
                .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
        )
        .child(
            canvas(
                move |bounds, window, _| {
                    // Laid out taller or shorter than the geometry above was
                    // computed from: that geometry came from last frame's
                    // handle. Ask for the frame that will paint it right.
                    // Self-limiting — once they agree, nothing is requested.
                    if (bounds.size.height - viewport).abs() > px(0.5) {
                        window.request_animation_frame();
                    }
                },
                |_, _, _, _| {},
            )
            .absolute()
            .size_full(),
        )
        .into_any_element()
}

/// A mark per item, the one at the top of the viewport lit — for a pane whose
/// content comes in countable pieces (a transcript's turns) rather than as one
/// continuous document, where how far down you are matters less than which
/// piece you are on. A press jumps to that piece.
///
/// `count` addresses the **direct children** of the `track_scroll` element,
/// which is what gpui indexes: a pane whose pieces sit nested inside a wrapper
/// reports one child, and every mark would scroll to the same place.
///
/// Absolute, so the caller's container holds the position: pin it with
/// `.relative()` on whichever box the rail belongs to the edge of.
///
/// `room` is the clear space beside the content, which only the caller can
/// measure — the rail paints nothing under [`RAIL_ROOM`], because marks over
/// the text would be worse than no marks at all.
pub fn rail(
    id: impl Into<SharedString>,
    handle: &ScrollHandle,
    count: usize,
    room: Pixels,
) -> gpui::AnyElement {
    if count == 0 || room < px(RAIL_ROOM) {
        return Empty.into_any_element();
    }
    let id = id.into();
    let at = handle.top_item();
    div()
        .absolute()
        .top_0()
        .bottom_0()
        .left(px(RAIL_INSET))
        .flex()
        .flex_col()
        .items_center()
        .justify_center()
        .gap(px(MARK_GAP))
        .overflow_hidden()
        .children((0..count).map(|ix| {
            let handle = handle.clone();
            div()
                .id(SharedString::from(format!("{id}-{ix}")))
                .w(px(MARK))
                .h(px(MARK_THICK))
                .rounded_full()
                .bg(if ix == at { ink(0.6) } else { ink(0.2) })
                .cursor_pointer()
                .hover(|mark| mark.bg(ink(0.32)))
                .on_click(move |_, window, _| {
                    handle.scroll_to_item(ix);
                    window.refresh();
                })
        }))
        .into_any_element()
}

// ---------------------------------------------------------------------------
// Transient — the same bar, only while the content moves
// ---------------------------------------------------------------------------

/// How long the thumb stays after the last scroll before fading — the idle
/// window *is* the fade, because the fork's `Animation` has no delay.
pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);

/// The show-and-fade state behind [`transient`]: the last frame's scroll
/// state (a change is activity), a generation counter (a fresh animation id
/// restarts the fade — `AnimationElement` pins its clock to the id it first
/// laid out with), and the hover flag that holds the thumb up while the
/// pointer is on the strip.
#[derive(Clone)]
pub struct TransientState {
    cell: Rc<Cell<(Pixels, Pixels, u64, bool)>>,
    bar: ScrollbarState,
}

impl TransientState {
    pub fn new(painter: Painter) -> Self {
        Self {
            cell: Rc::new(Cell::new(Default::default())),
            bar: ScrollbarState::new(painter),
        }
    }
}

/// The same bar as [`scrollbar`], but it only earns its place while the
/// content moves: activity raises the thumb, and it fades out over
/// [`TRANSIENT_IDLE`] once the scrolling stops. Hovering the strip or
/// dragging the thumb holds it up. With `reduce_motion` there is nothing to
/// animate, so it renders as the always-on bar.
pub fn transient(
    id: impl Into<SharedString>,
    handle: &ScrollHandle,
    state: &TransientState,
    reduce_motion: bool,
) -> gpui::AnyElement {
    let id = id.into();
    let viewport = handle.bounds().size.height;
    let max_offset = handle.max_offset().y;
    let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
        return Empty.into_any_element();
    };
    let size = range.end - range.start;

    // Any change in the scroll state is activity: bump the generation so the
    // fade restarts under a fresh animation id. The half-pixel slack keeps a
    // sub-pixel layout jitter from re-showing a settled bar.
    let mut cell = state.cell.get();
    if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
        cell.0 = handle.offset().y;
        cell.1 = max_offset;
        cell.2 += 1;
        state.cell.set(cell);
    }
    let generation = cell.2;
    let dragging = state.bar.dragging();

    let track_id = id.clone();
    let drag_handle = handle.clone();
    let drag_state = state.clone();
    let release_state = state.clone();
    let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
        release_state.bar.grab.set(None);
    };

    let track = div()
        .id(SharedString::from(format!("{id}-track")))
        .absolute()
        .top_0()
        .right_0()
        .bottom_0()
        .w(px(TRACK))
        .flex()
        .justify_center()
        .on_drag_move(move |event, _, cx| {
            drag_state.bar.drag(&track_id, &drag_handle, event, cx);
        })
        // Both, because a release can land anywhere on screen; a grab left set
        // would make the next press continue the last gesture.
        .on_mouse_up(MouseButton::Left, released.clone())
        .on_mouse_up_out(MouseButton::Left, released)
        .map(|track| {
            if reduce_motion {
                track
            } else {
                // The thumb's stand-in at rest: hovering the strip raises it,
                // and leaving starts its fade.
                let hover_state = state.clone();
                let hover_painter = state.bar.painter;
                track.on_hover(move |hovered: &bool, _, cx: &mut App| {
                    let mut cell = hover_state.cell.get();
                    if cell.3 == *hovered {
                        return;
                    }
                    cell.3 = *hovered;
                    cell.2 += 1;
                    hover_state.cell.set(cell);
                    hover_painter.notify(cx);
                })
            }
        });

    let thumb = div()
        .id(SharedString::from(format!("{id}-thumb")))
        .absolute()
        .top(range.start)
        .h(size)
        .w(px(THUMB))
        .rounded_full()
        .bg(if dragging { ink(0.38) } else { ink(0.2) })
        .hover(|s| s.bg(ink(0.32)))
        .on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));

    let thumb: gpui::AnyElement = if reduce_motion {
        thumb.into_any_element()
    } else {
        // The thumb's presence is the animation: progress 0 is fully up, and
        // progress 1 — a full idle window later — is hidden, so no frame is
        // requested once the fade completes.
        let anim = state.clone();
        thumb
            .with_animation(
                ElementId::from(format!("{id}-fade-{generation}")),
                Animation::new(TRANSIENT_IDLE),
                move |el, p| {
                    let cell = anim.cell.get();
                    if cell.3 || anim.bar.dragging() {
                        el
                    } else if p < 1.0 {
                        el.opacity(1.0 - p)
                    } else {
                        el.hidden()
                    }
                },
            )
            .into_any_element()
    };

    track
        .child(thumb)
        .child(
            canvas(
                move |bounds, window, _| {
                    // Laid out taller or shorter than the geometry above was
                    // computed from: that geometry came from last frame's
                    // handle. Ask for the frame that will paint it right.
                    // Self-limiting — once they agree, nothing is requested.
                    if (bounds.size.height - viewport).abs() > px(0.5) {
                        window.request_animation_frame();
                    }
                },
                |_, _, _, _| {},
            )
            .absolute()
            .size_full(),
        )
        .into_any_element()
}

// ---------------------------------------------------------------------------
// Nesting — which pane a wheel belongs to
// ---------------------------------------------------------------------------

/// Where a [`claim_wheel`] pane was before the wheel that is being dispatched.
///
/// Shaped like [`ScrollbarState`] and [`FollowState`], and owned by the view
/// for the same reason: an element rebuilt every render cannot remember
/// anything, and this has to outlive the frame it was written in.
#[derive(Clone)]
pub struct ClaimState(Rc<Cell<Pixels>>);

impl Default for ClaimState {
    fn default() -> Self {
        Self::new()
    }
}

impl ClaimState {
    pub fn new() -> Self {
        Self(Rc::new(Cell::new(px(0.0))))
    }
}

/// Let a pane keep the wheel it can act on, instead of passing it to the pane
/// behind as well.
///
/// gpui's scroll listener neither stops propagation nor asks whether an
/// ancestor scrolls too, so a wheel over a nested pane moves *both* — an output
/// box inside a transcript scrolls itself and drags the transcript with it
/// (user report). Every scrolling ancestor under the pointer does this, so the
/// deeper the nesting the further the page jumps.
///
/// The question it asks is whether the pane *moved*, not whether it had room
/// to. This listener runs after the element's own — gpui registers that one
/// later and the bubble phase runs the list backwards — so `handle` already
/// holds the post-scroll offset, and `state` is where it stood before. "Had
/// room" is the same answer one notch too late, and gets the notch that lands
/// exactly on the end wrong: the pane finishes its travel *and* the page jumps
/// a full notch behind it.
///
/// Chained at the ends, not sealed: a pane with nowhere left to go hands the
/// wheel to the page, so reaching the end of a short inner list does not strand
/// it there and make the pointer move. A pane whose content fits never claims
/// anything, for the same reason. For `overscroll-behavior: contain` — a pane
/// that never lets a wheel past — stop unconditionally instead.
///
/// ```ignore
/// scroll::claim_wheel(
///     div().id("output").overflow_y_scroll().track_scroll(&self.scroll),
///     &self.scroll,
///     Axis::Vertical,
///     &self.claim,
/// )
/// ```
pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
    el: E,
    handle: &ScrollHandle,
    axis: Axis,
    state: &ClaimState,
) -> E {
    let handle = handle.clone();
    let state = state.0.clone();
    // Where the pane stands as the frame is built, which is where it stands
    // before anything this frame's listeners are handed. The listener writes it
    // too: a wheel the pane could not act on produces no `notify` and so no
    // render, and the reading below has to stay true across that gap.
    state.set(travel(&handle, axis));
    el.on_scroll_wheel(move |_, _, cx| {
        let now = travel(&handle, axis);
        if now != state.get() {
            state.set(now);
            cx.stop_propagation();
        }
    })
}

/// How far `handle` has visibly travelled along `axis`. Negative, as gpui
/// counts it.
///
/// Clamped here because gpui's scroll listener is not: it adds the raw delta
/// and leaves the clamp to the next `paint`, so a pane held at its end keeps
/// accumulating offset it will never show. Compared raw, every notch past the
/// end reads as movement and the pane never lets go of the wheel.
fn travel(handle: &ScrollHandle, axis: Axis) -> Pixels {
    let (offset, max) = match axis {
        Axis::Vertical => (handle.offset().y, handle.max_offset().y),
        Axis::Horizontal => (handle.offset().x, handle.max_offset().x),
    };
    offset.clamp(-max.max(px(0.0)), px(0.0))
}

// ---------------------------------------------------------------------------
// Follow — a view pinned to the bottom of content that grows under it
// ---------------------------------------------------------------------------

/// How close to the bottom still counts as following. A wheel lands on
/// fractional offsets and a re-layout can move the end by a hair; without slack
/// a view would unpin itself for a rounding error nobody asked for.
pub const FOLLOW_SLACK: Pixels = px(4.0);

/// Whether `offset` is at the end of the scrollable range, within `slack`.
///
/// Both of gpui's conventions bite here, so: `max_offset` is the *overflow* and
/// `offset` is **negative** going down, which makes the distance still to go
/// `max_offset - |offset|`. Content that fits is always "at the bottom" — there
/// is nowhere else to be, and answering `false` would unpin an empty log.
pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
    if max_offset <= px(0.0) {
        return true;
    }
    let travelled = offset.clamp(-max_offset, px(0.0)).abs();
    max_offset - travelled <= slack
}

/// Whether a [`follow`] view is still pinned, and the overflow it last saw.
///
/// Shaped like [`ScrollbarState`] and for the same reason: it mutates through
/// `&self`, so the element carries the whole behaviour without the view wiring
/// a listener. Starts pinned — a transcript or a log opens on its newest line.
#[derive(Clone)]
pub struct FollowState(Rc<Cell<(bool, Pixels)>>);

impl Default for FollowState {
    fn default() -> Self {
        Self(Rc::new(Cell::new((true, px(0.0)))))
    }
}

impl FollowState {
    pub fn new() -> Self {
        Self::default()
    }

    /// Whether the view is following. An app shows its "jump to latest" affordance
    /// on `!following()`, which is the only reason this is public.
    pub fn following(&self) -> bool {
        self.0.get().0
    }

    /// Re-pin. What that "jump to latest" button calls; the next frame does the
    /// scrolling.
    pub fn follow(&self) {
        let (_, last) = self.0.get();
        self.0.set((true, last));
    }
}

/// Keep `handle` pinned to the bottom of its content while the user leaves it
/// there, and get out of the way the moment they scroll up.
///
/// Drop it in beside [`scrollbar`], over the same container:
///
/// ```ignore
/// div().relative()
///     .child(div().id("log").size_full().overflow_y_scroll().track_scroll(&self.scroll).child(rows))
///     .child(scroll::follow(&self.scroll, &self.follow))
///     .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar))
/// ```
///
/// **Telling appended content from a user scroll is the whole problem**, and
/// neither is an event this can subscribe to — both surface as the same handle
/// reading differently than last frame. The overflow is what separates them: if
/// it changed, the content grew and the pin is left as the user last set it; if
/// it did not, the offset moved because the *user* moved it, and being at the
/// end is what re-pins. So scrolling up releases, and scrolling back down
/// re-attaches, with no gesture to hook.
///
/// The correction lands a frame late — the scrolling div was laid out with the
/// old offset before this runs — which is why it asks for that frame. At a
/// streaming cadence it is invisible, and it converges rather than spinning:
/// once pinned and at the end, nothing is requested.
pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
    let handle = handle.clone();
    let state = state.clone();
    canvas(
        move |_, window, _| {
            let max_offset = handle.max_offset().y;
            let offset = handle.offset().y;
            let (was_pinned, last_max) = state.0.get();

            let pinned = if (max_offset - last_max).abs() > px(0.5) {
                was_pinned
            } else {
                at_bottom(max_offset, offset, FOLLOW_SLACK)
            };

            if pinned && (offset + max_offset).abs() > px(0.5) {
                handle.set_offset(point(handle.offset().x, -max_offset));
                window.request_animation_frame();
            }
            state.0.set((pinned, max_offset));
        },
        |_, _, _, _| {},
    )
    .absolute()
    .size_full()
    .into_any_element()
}