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}