blob: 312670d2a46c30e435bef2359dff564e88ff8d8f [file]
//! A scroll area that paints scrollbars over the element it was called on.
//!
//! This lives here rather than being imported because the shell is built on
//! `gpui-base` alone: everything it draws has to be expressible in the base
//! layer, and a dependency on `gpui-component` would put the product component
//! library underneath a runtime that is supposed to sit beside it. The scrollbar
//! itself — [`gpui_base::Scrollbar`] — is a base type; only this wrapper had to
//! come along. `gpui-component` keeps its own copy behind a
//! `ScrollableElement` trait; the shell needs one call site, so it names
//! [`Scrollable`] directly and skips the trait.
use std::panic::Location;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, Overflow, ParentElement,
PointRefinement, RenderOnce, ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder,
};
use gpui_base::{InteractiveElementExt as _, Scrollbar, ScrollbarAxis, StyledExt as _};
/// A scrollable element wrapper that renders the original element as the scroll area and overlays scrollbars.
#[derive(IntoElement)]
pub(crate) struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
id: ElementId,
element: E,
axis: ScrollbarAxis,
}
impl<E> Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
#[track_caller]
pub(crate) fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
Self {
id: caller_id(),
element,
axis: axis.into(),
}
}
/// Set a specific element id, default is the [`std::panic::Location::caller`].
///
/// Only needed when one call site creates several scrollables, which would
/// otherwise share a single scroll position.
pub(crate) fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = id.into();
self
}
}
impl<E> Styled for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.style()
}
}
impl<E> ParentElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.element.extend(elements)
}
}
impl<E> InteractiveElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl<E> RenderOnce for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element + 'static,
{
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let scroll_handle = scroll_handle_for(&self.id, window, cx);
// Preserve the caller-requested size on the wrapper, while keeping the
// caller's element as the actual scroll-tracked layout container.
let root_style = root_style_from(&mut self.element, self.axis);
let root_id = self.id.clone();
let area_id = (self.id.clone(), "area");
let content_id = (self.id.clone(), "content");
let scrollbar_id = (self.id.clone(), "scrollbar");
let mut content = self
.element
.id(content_id)
.flex_none()
.map(|this| match self.axis {
ScrollbarAxis::Vertical => this.h_auto().min_h_full(),
ScrollbarAxis::Horizontal => this.w_auto().min_w_full(),
ScrollbarAxis::Both => this.size_auto().min_size_full(),
});
// The wrapper holds the caller's maximum size. Left on the content
// too, it would cap the content at the area's size on the scrolled
// axis, so nothing would overflow and the area would not scroll.
let max_size = &mut content.style().max_size;
match self.axis {
ScrollbarAxis::Vertical => max_size.height = None,
ScrollbarAxis::Horizontal => max_size.width = None,
ScrollbarAxis::Both => *max_size = Default::default(),
}
// Keep the scroll area in the normal flow: its content size must
// propagate to auto-sized ancestors (e.g. a Dialog that grows with
// its content). An absolutely positioned scroll area would collapse
// such ancestors to zero height.
let scroll_area = div()
.id(area_id)
.size_full()
.flex()
.track_scroll(&scroll_handle)
.map(|this| match self.axis {
ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
ScrollbarAxis::Both => this.overflow_scroll(),
})
// On a single-axis area gpui otherwise remaps the other axis' delta
// onto ours, so a purely horizontal swipe scrolls this vertically.
.lock_scroll_axis()
.child(content);
// The wrapper lays the area out as a flex item along the scrolled
// axis, so the area shrinks to the wrapper's size. Sized by its 100%
// alone, the area is not held to a maximum size on the wrapper: in a
// column whose height follows its content, inside a row, the area
// came out as tall as that column and the wrapper clipped the rows
// past its maximum height instead of scrolling to them.
div()
.id(root_id)
.size_full()
.refine_style(&root_style)
.relative()
.flex()
.map(|this| match self.axis {
ScrollbarAxis::Horizontal => this.flex_row(),
ScrollbarAxis::Vertical | ScrollbarAxis::Both => this.flex_col(),
})
.child(scroll_area)
.child(render_scrollbar(
scrollbar_id,
&scroll_handle,
self.axis,
window,
cx,
))
}
}
#[inline]
#[track_caller]
fn caller_id() -> ElementId {
ElementId::CodeLocation(*Location::caller())
}
#[inline]
fn scroll_handle_for(id: &ElementId, window: &mut Window, cx: &mut App) -> ScrollHandle {
window
.use_keyed_state(id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone()
}
/// Copies the outer layout styles from the element, so the wrapper can
/// participate in the parent's layout the same way the source element would.
///
/// The scrolled axis is marked clipped here for the same reason as in
/// `gpui_component::scroll::scrollable`: a flex item only drops its
/// content-based automatic minimum size when its own overflow is not
/// [`Overflow::Visible`], and the scrolled overflow lives on the inner scroll
/// area. Without it a scroll region used as a flex item pushes its siblings out
/// of the container instead of scrolling.
#[inline]
fn root_style_from<E>(element: &mut E, axis: ScrollbarAxis) -> StyleRefinement
where
E: Styled,
{
let style = element.style();
StyleRefinement {
size: style.size.clone(),
min_size: style.min_size.clone(),
max_size: style.max_size.clone(),
flex_grow: style.flex_grow,
flex_shrink: style.flex_shrink,
flex_basis: style.flex_basis,
align_self: style.align_self,
overflow: PointRefinement {
x: axis.has_horizontal().then_some(Overflow::Hidden),
y: axis.has_vertical().then_some(Overflow::Hidden),
},
..Default::default()
}
}
#[inline]
fn render_scrollbar(
id: impl Into<ElementId>,
scroll_handle: &ScrollHandle,
axis: ScrollbarAxis,
window: &mut Window,
cx: &mut App,
) -> Div {
// Do not render scrollbar when inspector is picking elements,
// to allow us to pick the background elements.
let is_inspector_picking = window.is_inspector_picking(cx);
if is_inspector_picking {
return div();
}
div()
.absolute()
.inset_0()
.debug_selector(|| "scrollbar-overlay".to_string())
.child(
Scrollbar::new(scroll_handle)
.id(id)
.axis(axis)
.viewport_from_layout(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{
Bounds, Context, Pixels, Render, ScrollDelta, ScrollWheelEvent, TestAppContext,
VisualTestContext, canvas, point, px,
};
use std::{cell::Cell, rc::Rc};
struct FlexItemScrollableTest {
header_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
last_row_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
footer_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl Render for FlexItemScrollableTest {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let header_bounds = self.header_bounds.clone();
let footer_bounds = self.footer_bounds.clone();
// A fixed-height column of header, flexible scroll area, footer.
// The content is far taller than the room left for the area, so
// the area must shrink into the remaining 60px and scroll.
gpui_base::v_flex()
.w(px(100.))
.h(px(100.))
.child(
div().h(px(20.)).flex_shrink_0().child(
canvas(
move |bounds, _, _| header_bounds.set(Some(bounds)),
|_, _, _, _| {},
)
.size_full(),
),
)
.child(Scrollable::new(
gpui_base::v_flex()
.flex_1()
.children((0..5).map(|_| div().h(px(50.)).flex_shrink_0()))
.child(tracked_block(px(50.), &self.last_row_bounds)),
ScrollbarAxis::Vertical,
))
.child(
div().h(px(20.)).flex_shrink_0().child(
canvas(
move |bounds, _, _| footer_bounds.set(Some(bounds)),
|_, _, _, _| {},
)
.size_full(),
),
)
}
}
#[gpui::test]
fn scrollable_flex_item_shrinks_below_its_content(cx: &mut TestAppContext) {
// Debug selectors are disabled in release builds; capture laid-out
// bounds directly so the Shell's release test suite checks the layout.
let header_bounds = Rc::new(Cell::new(None));
let last_row_bounds = Rc::new(Cell::new(None));
let footer_bounds = Rc::new(Cell::new(None));
let (_, cx) = cx.add_window_view(|_, _| FlexItemScrollableTest {
header_bounds: header_bounds.clone(),
last_row_bounds: last_row_bounds.clone(),
footer_bounds: footer_bounds.clone(),
});
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
// Header and footer stay inside the 100px column, so the area took the
// 60px left over instead of its content height.
assert_eq!(
header_bounds.get().expect("header was laid out").top(),
px(0.)
);
assert_eq!(
footer_bounds.get().expect("footer was laid out").top(),
px(80.)
);
// Scrolled to the end, the last row ends where the footer starts.
cx.simulate_event(ScrollWheelEvent {
position: point(px(50.), px(50.)),
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
..Default::default()
});
cx.update(|window, cx| {
_ = window.draw(cx);
});
assert_eq!(
last_row_bounds
.get()
.expect("last row was laid out")
.bottom(),
px(80.)
);
}
fn tracked_block(height: Pixels, bounds: &Rc<Cell<Option<Bounds<Pixels>>>>) -> Div {
let bounds = bounds.clone();
div()
.h(height)
.flex_shrink_0()
.child(canvas(move |b, _, _| bounds.set(Some(b)), |_, _, _, _| {}).size_full())
}
struct MaxHeightScrollableTest {
first_row_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
last_row_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
below_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl Render for MaxHeightScrollableTest {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
// Six rows of 50px in an area that may grow to 100px, between two
// blocks in a column as tall as its content, in a row. In such a
// column the area's full height once came out as the column's,
// 140px, rather than the 100px of the wrapper around it.
gpui_base::h_flex().w(px(100.)).child(
gpui_base::v_flex()
.w_full()
.child(div().h(px(20.)).flex_shrink_0())
.child(Scrollable::new(
gpui_base::v_flex()
.max_h(px(100.))
.child(tracked_block(px(50.), &self.first_row_bounds))
.children((0..4).map(|_| div().h(px(50.)).flex_shrink_0()))
.child(tracked_block(px(50.), &self.last_row_bounds)),
ScrollbarAxis::Vertical,
))
.child(tracked_block(px(20.), &self.below_bounds)),
)
}
}
#[gpui::test]
fn scrollable_with_a_max_height_scrolls_what_overflows_it(cx: &mut TestAppContext) {
let first_row_bounds = Rc::new(Cell::new(None));
let last_row_bounds = Rc::new(Cell::new(None));
let below_bounds = Rc::new(Cell::new(None));
let (_, cx) = cx.add_window_view(|_, _| MaxHeightScrollableTest {
first_row_bounds: first_row_bounds.clone(),
last_row_bounds: last_row_bounds.clone(),
below_bounds: below_bounds.clone(),
});
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
let first_row = first_row_bounds.get().expect("first row was laid out");
let top = first_row.top();
// The area stops at its maximum height.
assert_eq!(
below_bounds.get().expect("block was laid out").top() - top,
px(100.)
);
let mut scroll = |dy: f32| {
cx.simulate_event(ScrollWheelEvent {
position: point(first_row.center().x, top + px(10.)),
delta: ScrollDelta::Pixels(point(px(0.), px(dy))),
..Default::default()
});
cx.update(|window, cx| {
_ = window.draw(cx);
});
};
// The rows past it scroll into view, down to the last one, which
// ends at the area's bottom edge.
scroll(-40.);
assert_eq!(
first_row_bounds
.get()
.expect("first row was laid out")
.top()
- top,
px(-40.)
);
scroll(-1000.);
assert_eq!(
last_row_bounds
.get()
.expect("last row was laid out")
.bottom()
- top,
px(100.)
);
}
struct MaxWidthScrollableTest {
first_column_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl Render for MaxWidthScrollableTest {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let first_column_bounds = self.first_column_bounds.clone();
// Six columns of 50px in an area that may grow to 100px wide.
gpui_base::h_flex().h(px(40.)).child(Scrollable::new(
gpui_base::h_flex()
.h_full()
.max_w(px(100.))
.child(
div().w(px(50.)).h_full().flex_shrink_0().child(
canvas(
move |bounds, _, _| first_column_bounds.set(Some(bounds)),
|_, _, _, _| {},
)
.size_full(),
),
)
.children((0..5).map(|_| div().w(px(50.)).h_full().flex_shrink_0())),
ScrollbarAxis::Horizontal,
))
}
}
#[gpui::test]
fn scrollable_with_a_max_width_scrolls_what_overflows_it(cx: &mut TestAppContext) {
let first_column_bounds = Rc::new(Cell::new(None));
let (_, cx) = cx.add_window_view(|_, _| MaxWidthScrollableTest {
first_column_bounds: first_column_bounds.clone(),
});
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
cx.simulate_event(ScrollWheelEvent {
position: point(px(10.), px(10.)),
delta: ScrollDelta::Pixels(point(px(-40.), px(0.))),
..Default::default()
});
cx.update(|window, cx| {
_ = window.draw(cx);
});
assert_eq!(
first_column_bounds
.get()
.expect("first column was laid out")
.left(),
px(-40.)
);
}
}