use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Window, canvas, div, fill, point, }; use gpui_native_theme::ActiveTheme as _; use crate::common::StyledExt; /// Which directions the content scrolls in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum ScrollAxes { /// Vertical only; the default. #[default] Vertical, /// Horizontal only. Horizontal, /// Both directions. Both, } /// A scrollable container with an overlay scrollbar. #[derive(IntoElement)] pub struct ScrollArea { id: ElementId, handle: Option, axes: ScrollAxes, a11y_label: Option, style: gpui::StyleRefinement, children: Vec, } impl ParentElement for ScrollArea { fn extend(&mut self, children: impl IntoIterator) { self.children.extend(children); } } impl Styled for ScrollArea { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style } } impl ScrollArea { /// Creates a vertically scrolling area. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), handle: None, axes: ScrollAxes::Vertical, a11y_label: None, style: gpui::StyleRefinement::default(), children: Vec::new(), } } /// Sets which directions the content scrolls in. pub fn axes(mut self, axes: ScrollAxes) -> Self { self.axes = axes; self } /// Scrolls with a handle held on the owning view. pub fn handle(mut self, handle: ScrollHandle) -> Self { self.handle = Some(handle); self } /// Overrides the accessibility label of the container. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } /// Pure thumb geometry for an overlay scrollbar. pub(crate) fn thumb_geometry( viewport: Pixels, content: Pixels, offset: Pixels, track: Pixels, min_thumb: Pixels, ) -> (Pixels, Pixels) { if content <= viewport || content <= Pixels::ZERO { return (track, Pixels::ZERO); } let length = (viewport / content * track).max(min_thumb).min(track); let overflow = content - viewport; let progress = (-offset / overflow).clamp(0., 1.); let travel = track - length; (length, travel * progress) } impl RenderOnce for ScrollArea { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let mut pane = div() .id(self.id.clone()) .overflow_hidden() .when(self.axes != ScrollAxes::Horizontal, |area| { area.overflow_y_scroll() }) .when(self.axes != ScrollAxes::Vertical, |area| { area.overflow_x_scroll() }) .when_some(self.handle.clone(), |area, handle| { area.track_scroll(&handle) }) .when_some(self.a11y_label, |area, label| area.aria_label(label)) .on_scroll(|_, _, cx| { // Keep wheel events from scrolling an outer scroll area too. cx.stop_propagation(); }) .refine_style(&self.style); // Extend with children pane.extend(self.children); let mut wrapper = div().relative().refine_style(&self.style); wrapper.extend(std::iter::once(pane.into_any_element())); if let Some(handle) = self.handle { wrapper.extend(std::iter::once( canvas(|_, _, _| {}, { let handle = handle.clone(); move |_, _, window, cx| { paint_scrollbar(&handle, window, cx); } }) .absolute() .inset_0() .into_any_element(), )); } wrapper } } /// Paints the overlay scrollbar thumb. fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { let theme = cx.theme(); let color = theme.colors.scrollbar_thumb; let width = theme.scrollbar.width; let inset = theme.scrollbar.inset; let min_thumb = theme.scrollbar.min_thumb; let max_offset = handle.max_offset().y; let bounds = handle.bounds(); if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO { return; } let track = bounds.size.height - inset * 2.0; let (thumb_length, thumb_top) = thumb_geometry( bounds.size.height, bounds.size.height + max_offset, handle.offset().y, track, min_thumb, ); let thumb_x = bounds.right() - width - inset; let thumb_bounds = Bounds::from_corners( point(thumb_x, bounds.top() + inset + thumb_top), point( thumb_x + width, bounds.top() + inset + thumb_top + thumb_length, ), ); window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0)); let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| { Option::<(Pixels, Pixels)>::None }); window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); move |event: &MouseDownEvent, phase, _window, cx| { if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left || !thumb_bounds.contains(&event.position) { return; } drag.update(cx, |drag, _| { *drag = Some((event.position.y, handle.offset().y)); }); cx.stop_propagation(); } }); window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); move |event: &MouseMoveEvent, phase, _window, cx| { if phase != DispatchPhase::Bubble || event.pressed_button != Some(gpui::MouseButton::Left) { return; } let Some((start_y, start_offset)) = *drag.read(cx) else { return; }; if max_offset > Pixels::ZERO && track > thumb_length { let scale = max_offset / (track - thumb_length); let offset = (start_offset - (event.position.y - start_y) * scale) .clamp(-max_offset, Pixels::ZERO); handle.set_offset(point(handle.offset().x, offset)); } } }); }