use std::{cell::Cell, rc::Rc, time::Instant}; use crate::theme::ActiveTheme; use gpui::{ fill, point, px, relative, Bounds, ContentMask, Edges, Element, EntityId, Hitbox, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Style, UniformListScrollHandle, }; const MIN_THUMB_SIZE: f32 = 80.; const THUMB_RADIUS: Pixels = Pixels(3.0); const THUMB_INSET: Pixels = Pixels(4.); pub trait ScrollHandleOffsetable { fn offset(&self) -> Point; fn set_offset(&self, offset: Point); fn is_uniform_list(&self) -> bool { false } } impl ScrollHandleOffsetable for ScrollHandle { fn offset(&self) -> Point { self.offset() } fn set_offset(&self, offset: Point) { self.set_offset(offset); } } impl ScrollHandleOffsetable for UniformListScrollHandle { fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } fn set_offset(&self, offset: Point) { self.0.borrow_mut().base_handle.set_offset(offset) } fn is_uniform_list(&self) -> bool { true } } #[derive(Debug, Clone, Copy)] pub struct ScrollbarState { hovered_axis: Option, hovered_on_thumb: Option, dragged_axis: Option, drag_pos: Point, last_scroll_offset: Point, last_scroll_time: Option, } impl Default for ScrollbarState { fn default() -> Self { Self { hovered_axis: None, hovered_on_thumb: None, dragged_axis: None, drag_pos: point(px(0.), px(0.)), last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, } } } impl ScrollbarState { pub fn new() -> Self { Self::default() } fn with_drag_pos(&self, axis: ScrollbarAxis, pos: Point) -> Self { let mut state = *self; if axis.is_vertical() { state.drag_pos.y = pos.y; } else { state.drag_pos.x = pos.x; } state.dragged_axis = Some(axis); state } fn with_unset_drag_pos(&self) -> Self { let mut state = *self; state.dragged_axis = None; state } fn with_hovered(&self, axis: Option) -> Self { let mut state = *self; state.hovered_axis = axis; state } fn with_hovered_on_thumb(&self, axis: Option) -> Self { let mut state = *self; state.hovered_on_thumb = axis; state } fn with_last_scroll( &self, last_scroll_offset: Point, last_scroll_time: Option, ) -> Self { let mut state = *self; state.last_scroll_offset = last_scroll_offset; state.last_scroll_time = last_scroll_time; state } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrollbarAxis { Vertical, Horizontal, Both, } impl ScrollbarAxis { #[inline] fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } #[inline] fn is_both(&self) -> bool { matches!(self, Self::Both) } #[inline] pub fn has_vertical(&self) -> bool { matches!(self, Self::Vertical | Self::Both) } #[inline] pub fn has_horizontal(&self) -> bool { matches!(self, Self::Horizontal | Self::Both) } #[inline] fn all(&self) -> Vec { match self { Self::Vertical => vec![Self::Vertical], Self::Horizontal => vec![Self::Horizontal], // This should keep vertical first, vertical is the primary axis // if vertical not need display, then horizontal will not keep right margin. Self::Both => vec![Self::Vertical, Self::Horizontal], } } } /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { view_id: EntityId, axis: ScrollbarAxis, /// When is vertical, this is the height of the scrollbar. width: Pixels, scroll_handle: Rc>, scroll_size: gpui::Size, state: Rc>, } impl Scrollbar { fn new( view_id: EntityId, state: Rc>, axis: ScrollbarAxis, scroll_handle: impl ScrollHandleOffsetable + 'static, scroll_size: gpui::Size, ) -> Self { Self { view_id, state, axis, scroll_size, width: px(12.), scroll_handle: Rc::new(Box::new(scroll_handle)), } } /// Create with vertical and horizontal scrollbar. pub fn both( view_id: EntityId, state: Rc>, scroll_handle: impl ScrollHandleOffsetable + 'static, scroll_size: gpui::Size, ) -> Self { Self::new( view_id, state, ScrollbarAxis::Both, scroll_handle, scroll_size, ) } /// Create with horizontal scrollbar. pub fn horizontal( view_id: EntityId, state: Rc>, scroll_handle: impl ScrollHandleOffsetable + 'static, scroll_size: gpui::Size, ) -> Self { Self::new( view_id, state, ScrollbarAxis::Horizontal, scroll_handle, scroll_size, ) } /// Create with vertical scrollbar. pub fn vertical( view_id: EntityId, state: Rc>, scroll_handle: impl ScrollHandleOffsetable + 'static, scroll_size: gpui::Size, ) -> Self { Self::new( view_id, state, ScrollbarAxis::Vertical, scroll_handle, scroll_size, ) } /// Create vertical scrollbar for uniform list. pub fn uniform_scroll( view_id: EntityId, state: Rc>, scroll_handle: UniformListScrollHandle, ) -> Self { let scroll_size = scroll_handle .0 .borrow() .last_item_size .map(|size| size.contents) .unwrap_or_default(); Self::new( view_id, state, ScrollbarAxis::Vertical, scroll_handle, scroll_size, ) } /// Set scrollbar axis. pub fn axis(mut self, axis: ScrollbarAxis) -> Self { self.axis = axis; self } } impl IntoElement for Scrollbar { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for Scrollbar { type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { None } fn request_layout( &mut self, _: Option<&gpui::GlobalElementId>, cx: &mut gpui::WindowContext, ) -> (gpui::LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.position = Position::Absolute; style.flex_grow = 1.0; style.flex_shrink = 1.0; style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (cx.request_layout(style, None), ()) } fn prepaint( &mut self, _: Option<&gpui::GlobalElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, cx: &mut gpui::WindowContext, ) -> Self::PrepaintState { cx.with_content_mask(Some(ContentMask { bounds }), |cx| { cx.insert_hitbox(bounds, false) }) } fn paint( &mut self, _: Option<&gpui::GlobalElementId>, _: Bounds, _: &mut Self::RequestLayoutState, hitbox: &mut Self::PrepaintState, cx: &mut gpui::WindowContext, ) { let hitbox_bounds = hitbox.bounds; let mut has_both = self.axis.is_both(); cx.with_content_mask( Some(ContentMask { bounds: hitbox_bounds, }), |cx| { for axis in self.axis.all().into_iter() { let is_vertical = axis.is_vertical(); let (scroll_area_size, container_size, scroll_position) = if is_vertical { ( self.scroll_size.height, hitbox_bounds.size.height, self.scroll_handle.offset().y, ) } else { ( self.scroll_size.width, hitbox_bounds.size.width, self.scroll_handle.offset().x, ) }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. let margin_end = if has_both && !is_vertical { self.width } else { px(0.) }; // Hide scrollbar, if the scroll area is smaller than the container. if scroll_area_size <= container_size { has_both = false; continue; } const NORMAL_OPACITY: f32 = 0.35; let thumb_length = (container_size / scroll_area_size * container_size) .max(px(MIN_THUMB_SIZE)); let thumb_start = -(scroll_position / (scroll_area_size - container_size) * (container_size - margin_end - thumb_length)); let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end); let bounds = Bounds { origin: if is_vertical { point( hitbox_bounds.origin.x + hitbox_bounds.size.width - self.width, hitbox_bounds.origin.y, ) } else { point( hitbox_bounds.origin.x, hitbox_bounds.origin.y + hitbox_bounds.size.height - self.width, ) }, size: gpui::Size { width: if is_vertical { self.width } else { hitbox_bounds.size.width }, height: if is_vertical { hitbox_bounds.size.height } else { self.width }, }, }; let state = self.state.clone(); let (thumb_bg, bar_bg, bar_border, inset, radius) = if state.get().dragged_axis == Some(axis) { ( cx.theme().scrollbar_thumb, cx.theme().scrollbar, cx.theme().border, THUMB_INSET - px(1.), THUMB_RADIUS, ) } else if state.get().hovered_axis == Some(axis) { if state.get().hovered_on_thumb == Some(axis) { ( cx.theme().scrollbar_thumb, cx.theme().scrollbar, cx.theme().border, THUMB_INSET - px(1.), THUMB_RADIUS, ) } else { ( cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY), gpui::transparent_black(), gpui::transparent_black(), THUMB_INSET, THUMB_RADIUS, ) } } else { let mut idle_state = ( gpui::transparent_black(), gpui::transparent_black(), gpui::transparent_black(), THUMB_INSET, THUMB_RADIUS - px(1.), ); if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); if elapsed < 1.0 { let y_value = NORMAL_OPACITY - elapsed.powi(10); // y = 1 - x^10 idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value); cx.request_animation_frame(); } } idle_state }; let border_width = px(0.); let thumb_bounds = if is_vertical { Bounds::from_corners( point( bounds.origin.x + inset + border_width, bounds.origin.y + thumb_start + inset, ), point( bounds.origin.x + self.width - inset, bounds.origin.y + thumb_end - inset, ), ) } else { Bounds::from_corners( point( bounds.origin.x + thumb_start + inset, bounds.origin.y + inset + border_width, ), point( bounds.origin.x + thumb_end - inset, bounds.origin.y + self.width - inset, ), ) }; cx.paint_quad(fill(bounds, bar_bg)); cx.paint_quad(PaintQuad { bounds, corner_radii: (0.).into(), background: gpui::transparent_black(), border_widths: if is_vertical { Edges { top: px(0.), right: px(0.), bottom: px(0.), left: border_width, } } else { Edges { top: border_width, right: px(0.), bottom: px(0.), left: px(0.), } }, border_color: bar_border, }); cx.paint_quad(fill(thumb_bounds, thumb_bg).corner_radii(radius)); cx.on_mouse_event({ let state = self.state.clone(); let view_id = self.view_id; let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, cx| { if phase.bubble() && hitbox_bounds.contains(&event.position) { if scroll_handle.offset() != state.get().last_scroll_offset { state.set(state.get().with_last_scroll( scroll_handle.offset(), Some(Instant::now()), )); cx.notify(Some(view_id)); } } } }); cx.on_mouse_event({ let state = self.state.clone(); let view_id = self.view_id; let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, cx| { if phase.bubble() && bounds.contains(&event.position) { cx.stop_propagation(); if thumb_bounds.contains(&event.position) { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; state.set(state.get().with_drag_pos(axis, pos)); cx.notify(Some(view_id)); } else { // click on the scrollbar, jump to the position // Set the thumb bar center to the click position let offset = scroll_handle.offset(); let percentage = if is_vertical { (event.position.y - thumb_length / 2. - bounds.origin.y) / (bounds.size.height - thumb_length) } else { (event.position.x - thumb_length / 2. - bounds.origin.x) / (bounds.size.width - thumb_length) } .min(1.); if is_vertical { scroll_handle.set_offset(point( offset.x, -scroll_area_size * percentage, )); } else { scroll_handle.set_offset(point( -scroll_area_size * percentage, offset.y, )); } } } } }); cx.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); let state = self.state.clone(); let view_id = self.view_id; move |event: &MouseMoveEvent, _, cx| { // Update hovered state for scrollbar if bounds.contains(&event.position) { if state.get().hovered_axis != Some(axis) { state.set(state.get().with_hovered(Some(axis))); cx.notify(Some(view_id)); } } else { if state.get().hovered_axis == Some(axis) { if state.get().hovered_axis.is_some() { state.set(state.get().with_hovered(None)); cx.notify(Some(view_id)); } } } // Update hovered state for scrollbar thumb if thumb_bounds.contains(&event.position) { if state.get().hovered_on_thumb != Some(axis) { state.set(state.get().with_hovered_on_thumb(Some(axis))); cx.notify(Some(view_id)); } } else { if state.get().hovered_on_thumb == Some(axis) { state.set(state.get().with_hovered_on_thumb(None)); cx.notify(Some(view_id)); } } // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; let percentage = (if is_vertical { (event.position.y - drag_pos.y - bounds.origin.y) / (bounds.size.height - thumb_length) } else { (event.position.x - drag_pos.x - bounds.origin.x) / (bounds.size.width - thumb_length - margin_end) }) .clamp(0., 1.); let offset = if is_vertical { point( scroll_handle.offset().x, -(scroll_area_size - container_size) * percentage, ) } else { point( -(scroll_area_size - container_size) * percentage, scroll_handle.offset().y, ) }; scroll_handle.set_offset(offset); cx.notify(Some(view_id)); } } }); cx.on_mouse_event({ let view_id = self.view_id; let state = self.state.clone(); move |_event: &MouseUpEvent, phase, cx| { if phase.bubble() { state.set(state.get().with_unset_drag_pos()); cx.notify(Some(view_id)); } } }); } }, ); } }