Skip to main content

gpui_cn/
scroll_area.rs

1//! A vertical scroll region that stretches past its ends and springs
2//! back, on every platform, with an overlay scrollbar.
3
4use std::{cell::Cell, rc::Rc};
5
6use gpui_kit::{
7    AnyElement, App, Bounds, ElementId, InteractiveElement as _, IntoElement, ListSizingBehavior,
8    ListState, ParentElement, Pixels, RenderOnce, ScrollHandle, StatefulInteractiveElement as _,
9    StyleRefinement, Styled, Window,
10    base::{
11        self, ElementExt as _, ScrollBounce, ScrollbarHandle, StyledExt as _, TestSupportExt as _,
12    },
13    div, list, point,
14    prelude::FluentBuilder as _,
15    px,
16};
17
18type RenderItem = Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement>;
19type OnScroll = Box<dyn Fn(&mut Window, &mut App)>;
20
21/// What the region scrolls: children laid out in full, or a virtual list
22/// that lays out only the rows in view.
23enum Content {
24    Children(Vec<AnyElement>),
25    List {
26        state: ListState,
27        row_hint: Pixels,
28        render_item: RenderItem,
29    },
30}
31
32/// A vertical scroll region with the overscroll bounce of a `UIScrollView`
33/// and an overlay scrollbar.
34///
35/// `gpui_base::ScrollBounce` plays the stretch and the return, and turns
36/// itself on for iOS and Android alone. gpui-cn turns it on everywhere,
37/// so a trackpad on a desktop feels the same end of the page as a finger
38/// on a phone. Reduced motion turns the bounce off; the region still
39/// scrolls. The scroll position lives in window state under `id`, so it
40/// survives a re-render.
41///
42/// `gpui_base::Scrollbar` draws the bar over the region's right edge: it
43/// shows while the region scrolls, fades when it rests, and drags. Its
44/// look comes from the theme, which projects it onto base for every
45/// scrolling element.
46///
47/// A focused field inside the region asks GPUI to reveal it, and the
48/// region scrolls until the field is in view, as GPUI's `list` does.
49///
50/// The region fills its parent. Give it a sized parent, and put fixed
51/// chrome such as a toolbar outside it.
52///
53/// ```
54/// use gpui_cn::ScrollArea;
55/// use gpui_kit::{IntoElement, ParentElement as _, div};
56///
57/// fn page() -> impl IntoElement {
58///     ScrollArea::new("page").child(div().child("A long page"))
59/// }
60/// ```
61///
62/// A long collection uses [`list`](Self::list) instead of children: GPUI's
63/// `list` element lays out only the rows in view, and the caller's
64/// `ListState` owns the scroll position and the row measurements.
65#[derive(IntoElement)]
66pub struct ScrollArea {
67    id: ElementId,
68    content: Content,
69    handle: Option<ScrollHandle>,
70    on_scroll: Option<OnScroll>,
71    style: StyleRefinement,
72}
73
74impl ScrollArea {
75    /// An empty scroll region. The id owns the scroll position and the
76    /// bounce state.
77    pub fn new(id: impl Into<ElementId>) -> Self {
78        Self {
79            id: id.into(),
80            content: Content::Children(Vec::new()),
81            handle: None,
82            on_scroll: None,
83            style: StyleRefinement::default(),
84        }
85    }
86
87    /// A scroll region over a virtual list: `render_item` builds the row
88    /// at an index when it comes into view, and `state` (the caller's, so
89    /// it can scroll to a row and reset the count) owns the scroll
90    /// position. The region sizes itself to its rows up to the height its
91    /// style allows, so a short list is short and a long one scrolls.
92    ///
93    /// `row_hint` is the height a row is taken to have before it is laid
94    /// out, so the scroll range and a scroll to a far row are right from
95    /// the first frame. GPUI drops every unmeasured row's hint whenever
96    /// the list's width changes; the region puts it back.
97    pub fn list(
98        id: impl Into<ElementId>,
99        state: &ListState,
100        row_hint: Pixels,
101        render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
102    ) -> Self {
103        Self {
104            id: id.into(),
105            content: Content::List {
106                state: state.clone(),
107                row_hint,
108                render_item: Box::new(render_item),
109            },
110            handle: None,
111            on_scroll: None,
112            style: StyleRefinement::default(),
113        }
114    }
115}
116
117impl ScrollArea {
118    /// Scrolls with `handle`, the caller's, so the caller can read how far
119    /// the region has moved. A region over a list has the list state for
120    /// that. Without one the region keeps its own, keyed by its id.
121    pub fn track(mut self, handle: &ScrollHandle) -> Self {
122        self.handle = Some(handle.clone());
123        self
124    }
125
126    /// Runs after a wheel step or a pan moves the region. The region
127    /// moves its content itself, so a list's own scroll handler does not
128    /// run for these steps; read the new position from the list state.
129    pub fn on_scroll(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
130        self.on_scroll = Some(Box::new(handler));
131        self
132    }
133}
134
135impl Styled for ScrollArea {
136    fn style(&mut self) -> &mut StyleRefinement {
137        &mut self.style
138    }
139}
140
141impl ParentElement for ScrollArea {
142    /// Children go to a region made with [`new`](Self::new). A region over a
143    /// list has no children of its own; the list's rows are the content.
144    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
145        if let Content::Children(children) = &mut self.content {
146            children.extend(elements);
147        }
148    }
149}
150
151/// The bounce and the scrollbar around a viewport, and whether the
152/// region has room to scroll, for any handle base can drive.
153fn chrome<H: ScrollbarHandle + Clone>(
154    id: &ElementId,
155    handle: &H,
156    viewport: impl IntoElement,
157    on_scroll: Option<OnScroll>,
158) -> (AnyElement, AnyElement) {
159    let mut bounce = ScrollBounce::new(
160        ElementId::NamedChild(id.clone().into(), "bounce".into()),
161        handle,
162        viewport,
163    )
164    .enabled(true);
165    if let Some(handler) = on_scroll {
166        bounce = bounce.on_scroll(handler);
167    }
168    let scrollbar = base::Scrollbar::vertical(handle)
169        .id(ElementId::NamedChild(id.clone().into(), "scrollbar".into()));
170    (bounce.into_any_element(), scrollbar.into_any_element())
171}
172
173impl RenderOnce for ScrollArea {
174    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
175        // The caller styles one element, but there are two: an outer box
176        // that sits in the caller's layout, and the viewport inside it
177        // that lays out the content. The box takes what places it (size,
178        // flex, margin, position); the viewport takes the rest (padding,
179        // direction, gap, colors).
180        let (frame_style, viewport_style) = split_style(self.style);
181        let on_scroll = self.on_scroll;
182        let mut revealer = None;
183        let (bounce, scrollbar) = match self.content {
184            Content::Children(children) => {
185                let handle = match self.handle {
186                    Some(handle) => handle,
187                    None => window
188                        .use_keyed_state(
189                            ElementId::NamedChild(self.id.clone().into(), "scroll".into()),
190                            cx,
191                            |_, _| ScrollHandle::new(),
192                        )
193                        .read(cx)
194                        .clone(),
195                };
196                let viewport = div()
197                    .id(self.id.clone())
198                    .test_support()
199                    .size_full()
200                    .overflow_y_scroll()
201                    .track_scroll(&handle)
202                    .refine_style(&viewport_style)
203                    .children(children);
204                revealer = Some(handle.clone());
205                chrome(&self.id, &handle, viewport, on_scroll)
206            }
207            Content::List {
208                state,
209                row_hint,
210                render_item,
211            } => {
212                // GPUI drops the unmeasured rows' hints when the list's
213                // width changes, and with them the scroll range. The
214                // width the rows were last hinted at is kept here, and the
215                // hint goes back when the width is seen to change. A
216                // measured row keeps its measured height.
217                let hinted_width = window.use_keyed_state(
218                    ElementId::NamedChild(self.id.clone().into(), "hinted".into()),
219                    cx,
220                    |_, _| None::<Pixels>,
221                );
222                let width = state.viewport_bounds().size.width;
223                if *hinted_width.read(cx) != Some(width) {
224                    state.clone().with_uniform_item_height(row_hint);
225                    hinted_width.update(cx, |hinted, _| *hinted = Some(width));
226                }
227                // `Infer` sizes the list to its rows, so the region is as
228                // tall as its content until the style's height caps it;
229                // `Auto` would fill a parent that has no height of its own.
230                let viewport = div()
231                    .id(self.id.clone())
232                    .test_support()
233                    .size_full()
234                    .refine_style(&viewport_style)
235                    .child(
236                        list(state.clone(), render_item)
237                            .with_sizing_behavior(ListSizingBehavior::Infer)
238                            .size_full(),
239                    );
240                chrome(&self.id, &state, viewport, on_scroll)
241            }
242        };
243        // GPUI's `list` takes a reveal request itself. For children, the
244        // request made before the content prepaints belongs to something
245        // outside, so it is set aside and put back unless the content made
246        // its own.
247        let outside = Rc::new(Cell::new(None));
248        // The bounce owns the wheel: the innermost region under the
249        // pointer with room to scroll takes the step in the capture phase
250        // and stops it there, and a region whose content fits passes it on.
251        div()
252            .id(ElementId::NamedChild(self.id.into(), "frame".into()))
253            .relative()
254            .refine_style(&frame_style)
255            .when(revealer.is_some(), |this| {
256                let outside = outside.clone();
257                this.on_prepaint(move |_, window, _| outside.set(window.take_autoscroll()))
258            })
259            .child(bounce)
260            .child(scrollbar)
261            .when_some(revealer, |this, handle| {
262                this.on_prepaint(move |visible, window, _| match window.take_autoscroll() {
263                    Some(request) => reveal(&handle, request, visible, window),
264                    None => {
265                        if let Some(request) = outside.take() {
266                            window.request_autoscroll(request);
267                        }
268                    }
269                })
270            })
271    }
272}
273
274/// Scrolls `handle` so `request`, an area a focused element inside the
275/// region asked to see, lies inside `visible`, the region's bounds. An
276/// area taller than the region shows from its top. The part that shows
277/// is asked of the region outside, so nested regions reveal it in turn.
278fn reveal(
279    handle: &ScrollHandle,
280    request: Bounds<Pixels>,
281    visible: Bounds<Pixels>,
282    window: &mut Window,
283) {
284    let offset = handle.offset();
285    let shift = if request.top() < visible.top() || request.size.height > visible.size.height {
286        visible.top() - request.top()
287    } else if request.bottom() > visible.bottom() {
288        visible.bottom() - request.bottom()
289    } else {
290        px(0.)
291    };
292    let y = (offset.y + shift).clamp(-handle.max_offset().y, px(0.));
293    let moved = y - offset.y;
294    if moved != px(0.) {
295        handle.set_offset(point(offset.x, y));
296        window.request_animation_frame();
297    }
298    let shown =
299        Bounds::new(request.origin + point(px(0.), moved), request.size).intersect(&visible);
300    if shown.size.height > px(0.) {
301        window.request_autoscroll(shown);
302    }
303}
304
305/// Splits a refinement into what places an element in its parent and what
306/// lays out its content.
307fn split_style(style: StyleRefinement) -> (StyleRefinement, StyleRefinement) {
308    let mut frame = StyleRefinement::default();
309    let mut viewport = style;
310    frame.size = std::mem::take(&mut viewport.size);
311    frame.min_size = std::mem::take(&mut viewport.min_size);
312    frame.max_size = std::mem::take(&mut viewport.max_size);
313    frame.flex_grow = viewport.flex_grow.take();
314    frame.flex_shrink = viewport.flex_shrink.take();
315    frame.flex_basis = viewport.flex_basis.take();
316    frame.align_self = viewport.align_self.take();
317    frame.margin = std::mem::take(&mut viewport.margin);
318    frame.position = viewport.position.take();
319    frame.inset = std::mem::take(&mut viewport.inset);
320    (frame, viewport)
321}