Skip to main content

gpui_kit/layout/
scroll.rs

1//! A bounded region with a themed scrollbar.
2//!
3//! The scroll area is honest about two different things a viewport can mean.
4//! There is *nothing more* — the content fits, and no scrollbar is drawn or
5//! published — and there is *more, off screen* — a scrollbar node carries how
6//! far the content reaches and how far it has been scrolled, so a test can
7//! tell the two apart instead of guessing from what happens to be visible.
8//!
9//! The scrollbar never covers what it hides. Its track is a sibling of the
10//! viewport rather than a layer over it, and the gutter is reserved for every
11//! axis the caller enabled, so turning a scrollbar on cannot reflow the
12//! content that decided whether it was needed.
13//!
14//! This is the same rule [`List`](crate::data::List) follows: scroll position
15//! is transient view state, so it is held in an application global keyed by
16//! semantic identity rather than by the caller.
17
18use std::cell::RefCell;
19use std::collections::HashMap;
20use std::rc::Rc;
21
22use gpui::{
23    AnyElement, App, Global, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
24    Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div,
25    prelude::FluentBuilder, px, relative,
26};
27use gpui_kit_semantics::{NodeSpec, Role, Semantic};
28use gpui_kit_theme::{ActiveTheme, Theme};
29
30use crate::foundation::Ident;
31use crate::layout::measure;
32use crate::motion::ScrollLink;
33use crate::strings::{ActiveStrings, StringKey};
34
35/// How wide the reserved gutter is and how thick the thumb inside it is drawn.
36/// Neither value repeats anywhere else.
37const TRACK: f32 = 10.0;
38const THUMB: f32 = 6.0;
39
40/// The shortest a thumb gets, so a very long document still leaves something
41/// to grab.
42const MIN_THUMB: f32 = 24.0;
43
44/// Which way the content may be scrolled.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum ScrollAxis {
47    #[default]
48    Vertical,
49    Horizontal,
50    Both,
51}
52
53impl ScrollAxis {
54    pub fn has_vertical(self) -> bool {
55        matches!(self, Self::Vertical | Self::Both)
56    }
57
58    pub fn has_horizontal(self) -> bool {
59        matches!(self, Self::Horizontal | Self::Both)
60    }
61}
62
63/// A scrollable region.
64#[derive(IntoElement)]
65pub struct ScrollArea {
66    ident: Ident,
67    axis: ScrollAxis,
68    label: Option<SharedString>,
69    width: Option<f32>,
70    height: Option<f32>,
71    /// Whether the area is as tall as what it holds instead of as tall as it
72    /// is offered.
73    fit_height: bool,
74    content: Option<AnyElement>,
75}
76
77impl std::fmt::Debug for ScrollArea {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter
80            .debug_struct("ScrollArea")
81            .field("ident", &self.ident)
82            .field("axis", &self.axis)
83            .field("label", &self.label)
84            .field("size", &(self.width, self.height))
85            .finish()
86    }
87}
88
89impl ScrollArea {
90    pub fn new(ident: impl Into<Ident>) -> Self {
91        Self {
92            ident: ident.into(),
93            axis: ScrollAxis::Vertical,
94            label: None,
95            width: None,
96            height: None,
97            fit_height: false,
98            content: None,
99        }
100    }
101
102    pub fn axis(mut self, axis: ScrollAxis) -> Self {
103        self.axis = axis;
104        self
105    }
106
107    pub fn vertical(self) -> Self {
108        self.axis(ScrollAxis::Vertical)
109    }
110
111    pub fn horizontal(self) -> Self {
112        self.axis(ScrollAxis::Horizontal)
113    }
114
115    pub fn both(self) -> Self {
116        self.axis(ScrollAxis::Both)
117    }
118
119    /// What the region is called, for a reader that has only the tree.
120    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
121        self.label = Some(label.into());
122        self
123    }
124
125    /// Bounds the viewport. Without a bound on the scrolled axis the region
126    /// grows to its content and never scrolls at all.
127    pub fn width(mut self, width: f32) -> Self {
128        self.width = Some(width);
129        self
130    }
131
132    /// Makes the area as tall as its content rather than as tall as the space
133    /// around it.
134    ///
135    /// By default the area fills the height it is offered, which is what a
136    /// pane wants. A parent that states no height offers none, and the area
137    /// then has nowhere to draw: it disappears, taking its content with it.
138    /// A caller placing one in a column that grows with its children says so
139    /// here.
140    pub fn fit_height(mut self) -> Self {
141        self.fit_height = true;
142        self
143    }
144
145    pub fn height(mut self, height: f32) -> Self {
146        self.height = Some(height);
147        self
148    }
149
150    pub fn child(mut self, content: impl IntoElement) -> Self {
151        self.content = Some(content.into_any_element());
152        self
153    }
154}
155
156impl RenderOnce for ScrollArea {
157    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
158        let theme = cx.theme().clone();
159        let handle = scroll_handle(&self.ident, cx);
160        let offset = handle.offset();
161        let max = handle.max_offset();
162        // Whether a scrollbar is needed depends on a size only layout knows,
163        // so the viewport is measured during prepaint and the frame that
164        // learns a new size asks for one more.
165        let measured = measure::cell(&self.ident.child("viewport").semantic_id(), cx);
166        let viewport = measured.get().size;
167
168        let content = div()
169            .id(self.ident.child("content").element_id())
170            .when(self.axis.has_vertical(), |element| element.min_h(px(0.0)))
171            .when(!self.axis.has_horizontal(), |element| element.w_full())
172            .semantic_in(
173                cx,
174                NodeSpec::new(self.ident.child("content").semantic_id(), Role::Group)
175                    .parent(self.ident.semantic_id()),
176            )
177            .children(self.content);
178
179        let fit_height = self.fit_height;
180        let viewport_element = div()
181            .id(self.ident.child("viewport").element_id())
182            .w_full()
183            .when(!fit_height, |element| element.h_full())
184            .when(self.axis.has_vertical(), |element| {
185                element.overflow_y_scroll()
186            })
187            .when(self.axis.has_horizontal(), |element| {
188                element.overflow_x_scroll()
189            })
190            .track_scroll(&handle)
191            .child(content);
192
193        // The shadow is a function of the offset and nothing else: it does not
194        // animate, it does not ask for a frame, and scrolling back up takes it
195        // away again because the offset went back. It is also information —
196        // there is content above the fold — so it is not suppressed under
197        // reduced motion.
198        let shade = self.axis.has_vertical().then(|| {
199            ScrollLink::over(px(theme.effects.edge_fade_band)).progress(px(-f32::from(offset.y)))
200        });
201        let top_shadow = shade.filter(|shade| *shade > 0.0).map(|shade| {
202            div()
203                .absolute()
204                .top_0()
205                .left_0()
206                .right_0()
207                .h(px(theme.borders.hairline))
208                .bg(theme.colors.hairline_strong.opacity(shade))
209        });
210
211        let viewport_frame = div()
212            .relative()
213            .on_children_prepainted({
214                let measured = Rc::clone(&measured);
215                move |bounds, window, _| {
216                    if let Some(first) = bounds.first() {
217                        measure::record(&measured, *first, window);
218                    }
219                }
220            })
221            .when(!self.fit_height, |element| element.flex_1())
222            .min_w(px(0.0))
223            .min_h(px(0.0))
224            // The viewport stays the first child: the prepaint above measures
225            // whichever child comes first, and it is the viewport that decides
226            // whether a scrollbar is needed.
227            .child(viewport_element)
228            .children(top_shadow);
229
230        let vertical = self.axis.has_vertical().then(|| {
231            bar(
232                &self.ident,
233                "vertical",
234                true,
235                f32::from(viewport.height),
236                f32::from(max.y),
237                -f32::from(offset.y),
238                &handle,
239                offset,
240                &theme,
241                cx,
242            )
243        });
244        let horizontal = self.axis.has_horizontal().then(|| {
245            bar(
246                &self.ident,
247                "horizontal",
248                false,
249                f32::from(viewport.width),
250                f32::from(max.x),
251                -f32::from(offset.x),
252                &handle,
253                offset,
254                &theme,
255                cx,
256            )
257        });
258
259        let body = div()
260            .flex()
261            .flex_row()
262            .items_stretch()
263            .flex_1()
264            .min_h(px(0.0))
265            .child(viewport_frame)
266            .children(vertical);
267
268        div()
269            .id(self.ident.element_id())
270            .flex()
271            .flex_col()
272            .when_some(self.width, |element, width| element.w(px(width)))
273            .when_some(self.height, |element, height| element.h(px(height)))
274            .when(
275                self.width.is_none() && self.height.is_none() && !self.fit_height,
276                |element| element.size_full(),
277            )
278            .when(self.fit_height && self.width.is_none(), |element| {
279                element.w_full()
280            })
281            .child(body)
282            .children(horizontal)
283            .semantic_in(cx, {
284                let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Region);
285                if let Some(label) = self.label.clone() {
286                    spec = spec.text(label);
287                }
288                spec
289            })
290    }
291}
292
293/// One scrollbar gutter, with a thumb only while there is something to scroll.
294///
295/// The gutter is reserved whether or not the thumb is drawn, so showing a
296/// scrollbar never takes space away from the content that decided it was
297/// needed.
298#[allow(clippy::too_many_arguments)]
299fn bar(
300    ident: &Ident,
301    axis: &str,
302    vertical: bool,
303    viewport: f32,
304    max: f32,
305    scrolled: f32,
306    handle: &ScrollHandle,
307    offset: Point<Pixels>,
308    theme: &Theme,
309    cx: &mut App,
310) -> AnyElement {
311    let bar_ident = ident.child("scrollbar").child(axis);
312    let content = viewport + max;
313    let overflowing = max > 0.5 && viewport > 0.0;
314    let track = measure::cell(&bar_ident.semantic_id(), cx);
315
316    let fraction = if content > 0.0 {
317        (viewport / content).clamp(0.0, 1.0)
318    } else {
319        1.0
320    };
321    let position = if max > 0.0 {
322        (scrolled / max).clamp(0.0, 1.0)
323    } else {
324        0.0
325    };
326
327    let thumb = overflowing.then(|| {
328        div()
329            .absolute()
330            .rounded_full()
331            .bg(theme.colors.hairline_strong)
332            .when(vertical, |element| {
333                element
334                    .w(px(THUMB))
335                    .left(px((TRACK - THUMB) / 2.0))
336                    .min_h(px(MIN_THUMB))
337                    .h(relative(fraction))
338                    .top(relative(position * (1.0 - fraction)))
339            })
340            .when(!vertical, |element| {
341                element
342                    .h(px(THUMB))
343                    .top(px((TRACK - THUMB) / 2.0))
344                    .min_w(px(MIN_THUMB))
345                    .w(relative(fraction))
346                    .left(relative(position * (1.0 - fraction)))
347            })
348    });
349
350    let mut gutter = div()
351        .id(bar_ident.element_id())
352        .relative()
353        .size_full()
354        .bg(theme.colors.panel)
355        .children(thumb);
356
357    if overflowing {
358        let handle = handle.clone();
359        let track = Rc::clone(&track);
360        gutter = gutter.on_mouse_move(move |event, window, _| {
361            if event.pressed_button != Some(MouseButton::Left) {
362                return;
363            }
364            let bounds = track.get();
365            let (origin, extent, pointer) = if vertical {
366                (
367                    f32::from(bounds.top()),
368                    f32::from(bounds.size.height),
369                    f32::from(event.position.y),
370                )
371            } else {
372                (
373                    f32::from(bounds.left()),
374                    f32::from(bounds.size.width),
375                    f32::from(event.position.x),
376                )
377            };
378            if extent <= 0.0 {
379                return;
380            }
381            let travel = (extent * (1.0 - fraction)).max(f32::EPSILON);
382            let next = (((pointer - origin) - travel * fraction / 2.0) / travel).clamp(0.0, 1.0);
383            let scrolled = -next * max;
384            handle.set_offset(if vertical {
385                gpui::point(offset.x, px(scrolled))
386            } else {
387                gpui::point(px(scrolled), offset.y)
388            });
389            window.refresh();
390        });
391    }
392
393    // A scrollbar exists only where there is something to scroll: an absent
394    // node is how a test reads "there is nothing more".
395    if overflowing {
396        gutter = gutter.semantic_in(
397            cx,
398            NodeSpec::new(bar_ident.semantic_id(), Role::Scrollbar)
399                .parent(ident.semantic_id())
400                .text(cx.strings().text(if vertical {
401                    StringKey::ScrollbarVertical
402                } else {
403                    StringKey::ScrollbarHorizontal
404                }))
405                .value(format!("{scrolled:.0} of {max:.0}"))
406                .range(0.0, max, scrolled.clamp(0.0, max)),
407        );
408    }
409
410    div()
411        .on_children_prepainted({
412            let track = Rc::clone(&track);
413            move |bounds, window, _| {
414                if let Some(first) = bounds.first() {
415                    measure::record(&track, *first, window);
416                }
417            }
418        })
419        .flex_none()
420        .when(vertical, |element| element.w(px(TRACK)).h_full())
421        .when(!vertical, |element| element.h(px(TRACK)).w_full())
422        .child(gutter)
423        .into_any_element()
424}
425
426#[derive(Default)]
427struct ScrollHandles(RefCell<HashMap<SharedString, ScrollHandle>>);
428
429impl Global for ScrollHandles {}
430
431/// How far the region with this identity has been scrolled, in pixels down
432/// and across from the start of its content.
433///
434/// This is what a scroll-linked value reads: pair it with
435/// [`ScrollLink`] to collapse a header or fade a
436/// heading as the content moves under it. A region that has never rendered
437/// reports zero, which is where it will be when it does.
438pub fn scroll_offset(ident: impl Into<Ident>, cx: &mut App) -> Point<Pixels> {
439    let offset = scroll_handle(&ident.into(), cx).offset();
440    gpui::point(-offset.x, -offset.y)
441}
442
443/// Puts the region with this identity at `offset`, measured the same way
444/// [`scroll_offset`] reports it.
445///
446/// The counterpart to reading, for a host restoring where the user was: a
447/// reopened document belongs where they left it, not at the top. It takes
448/// effect on the next frame, and a region that has never rendered remembers
449/// the position until it does.
450pub fn scroll_to(ident: impl Into<Ident>, offset: Point<Pixels>, cx: &mut App) {
451    scroll_handle(&ident.into(), cx).set_offset(gpui::point(-offset.x, -offset.y));
452}
453
454/// The scroll position of the region with this identity.
455fn scroll_handle(ident: &Ident, cx: &mut App) -> ScrollHandle {
456    if !cx.has_global::<ScrollHandles>() {
457        cx.set_global(ScrollHandles::default());
458    }
459    let mut handles = cx.global::<ScrollHandles>().0.borrow_mut();
460    handles.entry(ident.semantic_id()).or_default().clone()
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn an_axis_knows_which_gutters_it_reserves() {
469        assert!(ScrollAxis::Vertical.has_vertical());
470        assert!(!ScrollAxis::Vertical.has_horizontal());
471        assert!(ScrollAxis::Both.has_vertical() && ScrollAxis::Both.has_horizontal());
472    }
473}