bezel-ui 0.2.20

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
//! Follow: a view pinned to the bottom of content that grows under it.

use super::*;

/// 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(scroll::pane("log", Axes::Vertical).size_full().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: impl Into<Scroller>, state: &FollowState) -> gpui::AnyElement {
    let handle = handle.into();
    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.scroll_to_end();
                window.request_animation_frame();
            }
            state.0.set((pinned, max_offset));
        },
        |_, _, _, _| {},
    )
    .absolute()
    .size_full()
    .into_any_element()
}