//! Overlay scrollbars for scroll surfaces. use std::rc::Rc; use gpui::{ App, Bounds, DispatchPhase, ElementId, IntoElement, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle, Styled as _, UniformListScrollHandle, Window, canvas, fill, point, }; use gpui_native_theme::ActiveTheme as _; use crate::scroll_area::{Autohide, scrollbar_state_key, thumb_geometry, thumb_style}; /// Re-exported so callers only need the scrollbar module. pub use crate::scroll_area::ScrollbarVisibility; /// The scroll state a scrollbar drives, implemented for gpui's scroll handles. pub trait ScrollbarHandle: 'static { /// How far the content can scroll, positive along each axis. fn max_offset(&self) -> Point; /// The current offset, non-positive along each axis. fn offset(&self) -> Point; /// Applies the offset along one axis, keeping the other. fn set_offset(&self, offset: Point); /// The bounds of the scrolled surface. fn viewport(&self) -> Bounds; /// A thumb drag began. fn drag_started(&self) {} /// A thumb drag ended. fn drag_ended(&self) {} } impl ScrollbarHandle for ScrollHandle { fn max_offset(&self) -> Point { ScrollHandle::max_offset(self) } fn offset(&self) -> Point { ScrollHandle::offset(self) } fn set_offset(&self, offset: Point) { ScrollHandle::set_offset(self, offset); } fn viewport(&self) -> Bounds { self.bounds() } } impl ScrollbarHandle for UniformListScrollHandle { fn max_offset(&self) -> Point { self.0.borrow().base_handle.max_offset() } fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } fn set_offset(&self, offset: Point) { self.0.borrow().base_handle.set_offset(offset); } fn viewport(&self) -> Bounds { self.0.borrow().base_handle.bounds() } } impl ScrollbarHandle for ListState { fn max_offset(&self) -> Point { ListState::max_offset_for_scrollbar(self) } fn offset(&self) -> Point { ListState::scroll_px_offset_for_scrollbar(self) } fn set_offset(&self, offset: Point) { ListState::set_offset_from_scrollbar(self, offset); } fn viewport(&self) -> Bounds { self.viewport_bounds() } fn drag_started(&self) { ListState::scrollbar_drag_started(self); } fn drag_ended(&self) { ListState::scrollbar_drag_ended(self); } } /// Which direction the scrollbar lies along. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Axis { Vertical, Horizontal, } impl Axis { fn along(self, position: Point) -> Pixels { match self { Axis::Vertical => position.y, Axis::Horizontal => position.x, } } fn with_along(self, position: Point, along: Pixels) -> Point { match self { Axis::Vertical => point(position.x, along), Axis::Horizontal => point(along, position.y), } } fn viewport_along(self, viewport: &Bounds) -> Pixels { match self { Axis::Vertical => viewport.size.height, Axis::Horizontal => viewport.size.width, } } } /// An overlay scrollbar, the caller renders it over the scrolled surface. #[derive(IntoElement)] pub struct Scrollbar { axis: Axis, handle: Rc, visibility: ScrollbarVisibility, key: ElementId, } impl Scrollbar { /// A vertical scrollbar driven by `handle`. pub fn vertical(handle: H) -> Self where H: ScrollbarHandle, { Self { axis: Axis::Vertical, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), key: ElementId::Name("scrollbar".into()), } } /// A horizontal scrollbar driven by `handle`. pub fn horizontal(handle: H) -> Self where H: ScrollbarHandle, { Self { axis: Axis::Horizontal, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), key: ElementId::Name("scrollbar".into()), } } /// Overrides when the scrollbar shows, instead of the platform default. pub fn visibility(mut self, visibility: ScrollbarVisibility) -> Self { self.visibility = visibility; self } /// Names this scrollbar's state. pub fn key(mut self, key: impl Into) -> Self { self.key = key.into(); self } } impl RenderOnce for Scrollbar { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { canvas(|_, _, _| {}, { let handle = self.handle; let axis = self.axis; let visibility = self.visibility; let key = self.key; move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &key, window, cx) }) .absolute() .inset_0() } } /// The drag state shared between the scrollbar's mouse handlers. type Drag = Option<(Pixels, Pixels)>; fn paint_scrollbar( handle: &Rc, axis: Axis, visibility: ScrollbarVisibility, key: &ElementId, window: &mut Window, cx: &mut App, ) { let view = window.current_view(); 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 viewport = handle.viewport(); let viewport_along = axis.viewport_along(&viewport); let max_offset = axis.along(handle.max_offset()); if max_offset <= Pixels::ZERO || viewport_along <= Pixels::ZERO { return; } let autohide = match visibility { ScrollbarVisibility::Platform => theme.scrollbar.autohide, ScrollbarVisibility::Always => false, ScrollbarVisibility::Autohide => true, }; let autohide_state = autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx)); let track_length = viewport_along - inset * 2.0; let (thumb_length, thumb_travel) = thumb_geometry( viewport_along, viewport_along + max_offset, axis.along(handle.offset()), track_length, min_thumb, ); // The track strip is the thumb's rail plus the padding the thumb rides inside. let (thumb_bounds, track_bounds) = match axis { Axis::Vertical => { let thumb_x = viewport.right() - width - inset; ( Bounds::from_corners( point(thumb_x, viewport.top() + inset + thumb_travel), point( thumb_x + width, viewport.top() + inset + thumb_travel + thumb_length, ), ), Bounds::from_corners( point(viewport.right() - width, viewport.top() + inset), point(viewport.right(), viewport.top() + inset + track_length), ), ) } Axis::Horizontal => { let thumb_y = viewport.bottom() - width - inset; ( Bounds::from_corners( point(viewport.left() + inset + thumb_travel, thumb_y), point( viewport.left() + inset + thumb_travel + thumb_length, thumb_y + width, ), ), Bounds::from_corners( point(viewport.left() + inset, viewport.bottom() - width), point(viewport.left() + inset + track_length, viewport.bottom()), ), ) } }; let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| { Drag::None }); let dragging = drag.read(cx).is_some(); let alpha = match &autohide_state { Some(state) => state.alpha(cx, dragging), None => 1.0, }; if let Some(color) = thumb_style(color, alpha) { window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0)); } if let Some(state) = &autohide_state { window.on_mouse_event({ let state = state.clone(); move |event: &ScrollEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || !viewport.contains(&event.position) { return; } state.touch(window, cx); } }); } window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != MouseButton::Left { return; } let in_thumb = thumb_bounds.contains(&event.position); let in_track = !in_thumb && track_bounds.contains(&event.position); if !in_thumb && !in_track { return; } let start_offset = if in_thumb { axis.along(handle.offset()) } else if track_length > thumb_length { let thumb_start = ((axis.along(event.position) - axis.along(track_bounds.origin)) - thumb_length / 2.0) .clamp(Pixels::ZERO, track_length - thumb_length); let offset = -(thumb_start / (track_length - thumb_length)) * max_offset; handle.set_offset(axis.with_along(handle.offset(), offset)); cx.notify(view); offset } else { return; }; drag.update(cx, |drag, _| { *drag = Some((axis.along(event.position), start_offset)); }); handle.drag_started(); if let Some(state) = &autohide_state { state.touch(window, cx); } cx.stop_propagation(); } }); window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseMoveEvent, phase, window, cx| { if phase != DispatchPhase::Bubble { return; } if let Some(state) = &autohide_state && track_bounds.contains(&event.position) && drag.read(cx).is_none() { state.touch(window, cx); } if event.pressed_button != Some(MouseButton::Left) { return; } let Some((start_position, start_offset)) = *drag.read(cx) else { return; }; if track_length <= thumb_length { return; } let scale = max_offset / (track_length - thumb_length); let offset = (start_offset - (axis.along(event.position) - start_position) * scale) .clamp(-max_offset, Pixels::ZERO); handle.set_offset(axis.with_along(handle.offset(), offset)); cx.notify(view); } }); window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseUpEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != MouseButton::Left { return; } if drag.read(cx).is_none() { return; } drag.update(cx, |drag, _| { *drag = None; }); handle.drag_ended(); // Unfreezing a ListState's height can change the thumb's geometry. cx.notify(view); if let Some(state) = &autohide_state { state.touch(window, cx); } } }); }