From 84e8ad29bc9d0f3ee271c6cace04661e1e4a5095 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 16:55:57 +0700 Subject: [PATCH] migrate scroll onto gpui-base --- crates/ui/src/scroll/mod.rs | 5 +- crates/ui/src/scroll/scrollable.rs | 127 ++-- crates/ui/src/scroll/scrollable_mask.rs | 169 ----- crates/ui/src/scroll/scrollbar.rs | 945 ------------------------ 4 files changed, 73 insertions(+), 1173 deletions(-) delete mode 100644 crates/ui/src/scroll/scrollable_mask.rs delete mode 100644 crates/ui/src/scroll/scrollbar.rs diff --git a/crates/ui/src/scroll/mod.rs b/crates/ui/src/scroll/mod.rs index ac47fb5b..3553f838 100644 --- a/crates/ui/src/scroll/mod.rs +++ b/crates/ui/src/scroll/mod.rs @@ -1,7 +1,4 @@ mod scrollable; -mod scrollable_mask; -mod scrollbar; +pub use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle}; pub use scrollable::*; -pub use scrollable_mask::*; -pub use scrollbar::*; diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index 4333890e..6fac9324 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -1,15 +1,14 @@ use std::panic::Location; -use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, - ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, }; +use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle}; +use theme::ActiveTheme as _; -use super::{Scrollbar, ScrollbarAxis}; -use crate::StyledExt; -use crate::scroll::ScrollbarHandle; +use crate::StyledExt as _; /// A trait for elements that can be made scrollable with scrollbars. pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element { @@ -23,7 +22,7 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme self.child(ScrollbarLayer { id: "scrollbar_layer".into(), axis: axis.into(), - scroll_handle: Rc::new(scroll_handle.clone()), + scroll_handle: scroll_handle.clone(), }) } @@ -57,6 +56,72 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme } } +/// The scrollbar the application's theme describes: a 10px rail holding a 6px +/// rounded thumb that grows to 8px under the pointer. +fn scrollbar( + scroll_handle: &H, + axis: ScrollbarAxis, + cx: &App, +) -> Scrollbar { + let theme = cx.theme(); + let (thumb_width, thumb_radius) = if theme.scrollbar_mode.is_scrolling() { + (px(6.), px(3.)) + } else { + (px(8.), px(4.)) + }; + + Scrollbar::new(scroll_handle).axis(axis).styles(|styles| { + styles + .track(|track| { + track + .bg(theme.scrollbar_track_background) + .border_color(theme.scrollbar_thumb_border) + .width(px(10.)) + }) + .track_hover(|track| track.bg(theme.scrollbar_thumb_background).width(px(10.))) + .thumb(|thumb| { + thumb + .bg(theme.scrollbar_thumb_background) + .width(thumb_width) + .inset(px(1.)) + .radius(thumb_radius) + .min_length(px(48.)) + }) + .thumb_hover(|thumb| { + thumb + .bg(theme.scrollbar_thumb_hover_background) + .width(px(8.)) + .inset(px(1.)) + .radius(px(4.)) + }) + .thumb_active(|thumb| { + thumb + .bg(theme.scrollbar_thumb_hover_background) + .width(px(8.)) + .inset(px(1.)) + .radius(px(4.)) + }) + }) +} + +/// A scrollbar child that resolves the theme while rendering, which the +/// [`ScrollableElement`] helpers cannot do at the call site. +#[derive(IntoElement)] +struct ScrollbarLayer { + id: ElementId, + axis: ScrollbarAxis, + scroll_handle: H, +} + +impl RenderOnce for ScrollbarLayer +where + H: ScrollbarHandle + Clone + 'static, +{ + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + scrollbar(&self.scroll_handle, self.axis, cx).id(self.id) + } +} + /// A scrollable element wrapper that adds scrollbars to an interactive element. #[derive(IntoElement)] pub struct Scrollable { @@ -149,13 +214,7 @@ where .flex_1(), ), ) - .child(render_scrollbar( - "scrollbar", - &scroll_handle, - self.axis, - window, - cx, - )) + .child(scrollbar(&scroll_handle, self.axis, cx).id("scrollbar")) } } @@ -167,45 +226,3 @@ where Self: InteractiveElement, { } - -#[derive(IntoElement)] -struct ScrollbarLayer { - id: ElementId, - axis: ScrollbarAxis, - scroll_handle: Rc, -} - -impl RenderOnce for ScrollbarLayer -where - H: ScrollbarHandle + Clone + 'static, -{ - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx) - } -} - -#[inline] -#[track_caller] -fn render_scrollbar( - id: impl Into, - scroll_handle: &H, - 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() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::new(scroll_handle).id(id).axis(axis)) -} diff --git a/crates/ui/src/scroll/scrollable_mask.rs b/crates/ui/src/scroll/scrollable_mask.rs deleted file mode 100644 index 170f309d..00000000 --- a/crates/ui/src/scroll/scrollable_mask.rs +++ /dev/null @@ -1,169 +0,0 @@ -use gpui::{ - App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId, - GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative, -}; -use theme::AxisExt; - -/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening. -/// -/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction. -/// You can use this `scroll_handle` to control what you want to scroll. -/// This is only can handle once axis scrolling. -pub struct ScrollableMask { - view_id: EntityId, - axis: Axis, - scroll_handle: ScrollHandle, - debug: Option, -} - -impl ScrollableMask { - /// Create a new scrollable mask element. - pub fn new(view_id: EntityId, axis: Axis, scroll_handle: &ScrollHandle) -> Self { - Self { - view_id, - scroll_handle: scroll_handle.clone(), - axis, - debug: None, - } - } - - /// Enable the debug border, to show the mask bounds. - #[allow(dead_code)] - pub fn debug(mut self) -> Self { - self.debug = Some(gpui::yellow()); - self - } -} - -impl IntoElement for ScrollableMask { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for ScrollableMask { - type PrepaintState = Hitbox; - type RequestLayoutState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Absolute, - flex_grow: 1.0, - flex_shrink: 1.0, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, - ..Default::default() - }; - - (window.request_layout(style, None, cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - _: &mut App, - ) -> Self::PrepaintState { - // Move y to bounds height to cover the parent view. - let cover_bounds = Bounds { - origin: Point { - x: bounds.origin.x, - y: bounds.origin.y - bounds.size.height, - }, - size: bounds.size, - }; - - window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal) - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - hitbox: &mut Self::PrepaintState, - window: &mut Window, - _: &mut App, - ) { - let line_height = window.line_height(); - let bounds = hitbox.bounds; - - window.with_content_mask(Some(ContentMask { bounds }), |window| { - if let Some(color) = self.debug { - window.paint_quad(PaintQuad { - bounds, - border_widths: Edges::all(px(1.0)), - border_color: color, - background: gpui::transparent_white().into(), - corner_radii: Corners::all(px(0.)), - border_style: BorderStyle::default(), - }); - } - - window.on_mouse_event({ - let view_id = self.view_id; - let is_horizontal = self.axis.is_horizontal(); - let scroll_handle = self.scroll_handle.clone(); - let hitbox = hitbox.clone(); - let mouse_position = window.mouse_position(); - let last_offset = scroll_handle.offset(); - - move |event: &ScrollWheelEvent, phase, window, cx| { - if bounds.contains(&mouse_position) - && phase.bubble() - && hitbox.is_hovered(window) - { - let mut offset = scroll_handle.offset(); - let mut delta = event.delta.pixel_delta(line_height); - - // Limit for only one way scrolling at same time. - // When use MacBook touchpad we may get both x and y delta, - // only allows the one that more to scroll. - if !delta.x.is_zero() && !delta.y.is_zero() { - if delta.x.abs() > delta.y.abs() { - delta.y = px(0.); - } else { - delta.x = px(0.); - } - } - - if is_horizontal { - offset.x += delta.x; - } else { - offset.y += delta.y; - } - - if last_offset != offset { - scroll_handle.set_offset(offset); - cx.notify(view_id); - cx.stop_propagation(); - } - } - } - }); - }); - } -} diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs deleted file mode 100644 index bce26e9a..00000000 --- a/crates/ui/src/scroll/scrollbar.rs +++ /dev/null @@ -1,945 +0,0 @@ -use std::cell::Cell; -use std::ops::Deref; -use std::panic::Location; -use std::rc::Rc; - -use gpui::{ - Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero, - LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill, - point, px, relative, size, -}; -use instant::{Duration, Instant}; -use theme::{ActiveTheme, AxisExt, ScrollbarMode}; - -/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) -const WIDTH: Pixels = px(1. * 2. + 8.); -const MIN_THUMB_SIZE: f32 = 48.; - -const THUMB_WIDTH: Pixels = px(6.); -const THUMB_RADIUS: Pixels = px(6. / 2.); -const THUMB_INSET: Pixels = px(1.); - -const THUMB_ACTIVE_WIDTH: Pixels = px(8.); -const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); -const THUMB_ACTIVE_INSET: Pixels = px(1.); - -const FADE_OUT_DURATION: f32 = 3.0; -const FADE_OUT_DELAY: f32 = 2.0; - -/// A trait for scroll handles that can get and set offset. -pub trait ScrollbarHandle: 'static { - /// Get the current offset of the scroll handle. - fn offset(&self) -> Point; - /// Set the offset of the scroll handle. - fn set_offset(&self, offset: Point); - /// The full size of the content, including padding. - fn content_size(&self) -> Size; - /// Called when start dragging the scrollbar thumb. - fn start_drag(&self) {} - /// Called when end dragging the scrollbar thumb. - fn end_drag(&self) {} -} - -impl ScrollbarHandle for ScrollHandle { - fn offset(&self) -> Point { - self.offset() - } - - fn set_offset(&self, offset: Point) { - self.set_offset(offset); - } - - fn content_size(&self) -> Size { - Size::from(self.max_offset()) + self.bounds().size - } -} - -impl ScrollbarHandle 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 content_size(&self) -> Size { - let base_handle = &self.0.borrow().base_handle; - Size::from(base_handle.max_offset()) + base_handle.bounds().size - } -} - -impl ScrollbarHandle for ListState { - fn offset(&self) -> Point { - self.scroll_px_offset_for_scrollbar() - } - - fn set_offset(&self, offset: Point) { - self.set_offset_from_scrollbar(offset); - } - - fn content_size(&self) -> Size { - Size::from(self.max_offset_for_scrollbar()) + self.viewport_bounds().size - } - - fn start_drag(&self) { - self.scrollbar_drag_started(); - } - - fn end_drag(&self) { - self.scrollbar_drag_ended(); - } -} - -#[doc(hidden)] -#[derive(Debug, Clone)] -struct ScrollbarState(Rc>); - -#[doc(hidden)] -#[derive(Debug, Clone, Copy)] -struct ScrollbarStateInner { - hovered_axis: Option, - hovered_on_thumb: Option, - dragged_axis: Option, - drag_pos: Point, - last_scroll_offset: Point, - last_scroll_time: Option, - // Last update offset - last_update: Instant, - idle_timer_scheduled: bool, -} - -impl Default for ScrollbarState { - fn default() -> Self { - Self(Rc::new(Cell::new(ScrollbarStateInner { - 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, - last_update: Instant::now(), - idle_timer_scheduled: false, - }))) - } -} - -impl Deref for ScrollbarState { - type Target = Rc>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl ScrollbarStateInner { - fn with_drag_pos(&self, axis: Axis, 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; - if axis.is_some() { - state.last_scroll_time = Some(instant::Instant::now()); - } - state - } - - fn with_hovered_on_thumb(&self, axis: Option) -> Self { - let mut state = *self; - state.hovered_on_thumb = axis; - if self.is_scrollbar_visible() && axis.is_some() { - state.last_scroll_time = Some(instant::Instant::now()); - } - 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 - } - - fn with_last_scroll_time(&self, t: Option) -> Self { - let mut state = *self; - state.last_scroll_time = t; - state - } - - fn with_last_update(&self, t: Instant) -> Self { - let mut state = *self; - state.last_update = t; - state - } - - fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { - let mut state = *self; - state.idle_timer_scheduled = scheduled; - state - } - - fn is_scrollbar_visible(&self) -> bool { - // On drag - if self.dragged_axis.is_some() { - return true; - } - - if let Some(last_time) = self.last_scroll_time { - let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - elapsed < FADE_OUT_DURATION - } else { - false - } - } -} - -/// Scrollbar axis. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollbarAxis { - /// Vertical scrollbar. - Vertical, - /// Horizontal scrollbar. - Horizontal, - /// Show both vertical and horizontal scrollbars. - Both, -} - -impl From for ScrollbarAxis { - fn from(axis: Axis) -> Self { - match axis { - Axis::Vertical => Self::Vertical, - Axis::Horizontal => Self::Horizontal, - } - } -} - -impl ScrollbarAxis { - /// Return true if the scrollbar axis is vertical. - #[inline] - pub fn is_vertical(&self) -> bool { - matches!(self, Self::Vertical) - } - - /// Return true if the scrollbar axis is horizontal. - #[inline] - pub fn is_horizontal(&self) -> bool { - matches!(self, Self::Horizontal) - } - - /// Return true if the scrollbar axis is both vertical and horizontal. - #[inline] - pub fn is_both(&self) -> bool { - matches!(self, Self::Both) - } - - /// Return true if the scrollbar has vertical axis. - #[inline] - pub fn has_vertical(&self) -> bool { - matches!(self, Self::Vertical | Self::Both) - } - - /// Return true if the scrollbar has horizontal axis. - #[inline] - pub fn has_horizontal(&self) -> bool { - matches!(self, Self::Horizontal | Self::Both) - } - - #[inline] - fn all(&self) -> Vec { - match self { - Self::Vertical => vec![Axis::Vertical], - Self::Horizontal => vec![Axis::Horizontal], - // This should keep Horizontal first, Vertical is the primary axis - // if Vertical not need display, then Horizontal will not keep right margin. - Self::Both => vec![Axis::Horizontal, Axis::Vertical], - } - } -} - -/// Scrollbar control for scroll-area or a uniform-list. -pub struct Scrollbar { - pub(crate) id: ElementId, - axis: ScrollbarAxis, - scrollbar_mode: Option, - scroll_handle: Rc, - scroll_size: Option>, - /// Maximum frames per second for scrolling by drag. Default is 120 FPS. - /// - /// This is used to limit the update rate of the scrollbar when it is - /// being dragged for some complex interactions for reducing CPU usage. - max_fps: usize, -} - -impl Scrollbar { - /// Create a new scrollbar. - /// - /// This will have both vertical and horizontal scrollbars. - #[track_caller] - pub fn new(scroll_handle: &H) -> Self { - let caller = Location::caller(); - Self { - id: ElementId::CodeLocation(*caller), - axis: ScrollbarAxis::Both, - scrollbar_mode: None, - scroll_handle: Rc::new(scroll_handle.clone()), - max_fps: 120, - scroll_size: None, - } - } - - /// Create with horizontal scrollbar. - #[track_caller] - pub fn horizontal(scroll_handle: &H) -> Self { - Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) - } - - /// Create with vertical scrollbar. - #[track_caller] - pub fn vertical(scroll_handle: &H) -> Self { - Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) - } - - /// Set a specific element id, default is the [`Location::caller`]. - /// - /// NOTE: In most cases, you don't need to set a specific id for scrollbar. - pub fn id(mut self, id: impl Into) -> Self { - self.id = id.into(); - self - } - - /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`. - pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self { - self.scrollbar_mode = Some(mode); - self - } - - /// Set a special scroll size of the content area, default is None. - /// - /// Default will sync the `content_size` from `scroll_handle`. - pub fn scroll_size(mut self, scroll_size: Size) -> Self { - self.scroll_size = Some(scroll_size); - self - } - - /// Set scrollbar axis. - pub fn axis(mut self, axis: impl Into) -> Self { - self.axis = axis.into(); - self - } - - /// Set maximum frames per second for scrolling by drag. Default is 120 FPS. - /// - /// If you have very high CPU usage, consider reducing this value to improve performance. - /// - /// Available values: 30..120 - #[allow(dead_code)] - pub(crate) fn max_fps(mut self, max_fps: usize) -> Self { - self.max_fps = max_fps.clamp(30, 120); - self - } - - // Get the width of the scrollbar. - #[allow(dead_code)] - pub(crate) const fn width() -> Pixels { - WIDTH - } - - fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - ( - cx.theme().scrollbar_thumb_hover_background, - cx.theme().scrollbar_thumb_background, - cx.theme().scrollbar_thumb_border, - THUMB_ACTIVE_WIDTH, - THUMB_ACTIVE_INSET, - THUMB_ACTIVE_RADIUS, - ) - } - - fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - ( - cx.theme().scrollbar_thumb_hover_background, - cx.theme().scrollbar_thumb_background, - cx.theme().scrollbar_thumb_border, - THUMB_ACTIVE_WIDTH, - THUMB_ACTIVE_INSET, - THUMB_ACTIVE_RADIUS, - ) - } - - fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - ( - cx.theme().scrollbar_thumb_background, - cx.theme().scrollbar_thumb_border, - gpui::transparent_black(), - THUMB_ACTIVE_WIDTH, - THUMB_ACTIVE_INSET, - THUMB_ACTIVE_RADIUS, - ) - } - - fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); - let (width, inset, radius) = match scrollbar_mode { - ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), - _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), - }; - ( - cx.theme().scrollbar_thumb_background, - cx.theme().scrollbar_track_background, - gpui::transparent_black(), - width, - inset, - radius, - ) - } - - fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always); - let (width, inset, radius) = match scrollbar_mode { - ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), - _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), - }; - - ( - gpui::transparent_black(), - gpui::transparent_black(), - gpui::transparent_black(), - width, - inset, - radius, - ) - } -} - -impl IntoElement for Scrollbar { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -#[doc(hidden)] -pub struct PrepaintState { - hitbox: Hitbox, - scrollbar_state: ScrollbarState, - states: Vec, -} - -#[doc(hidden)] -pub struct AxisPrepaintState { - axis: Axis, - bar_hitbox: Hitbox, - bounds: Bounds, - radius: Pixels, - bg: Hsla, - border: Hsla, - thumb_bounds: Bounds, - // Bounds of thumb to be rendered. - thumb_fill_bounds: Bounds, - thumb_bg: Hsla, - scroll_size: Pixels, - container_size: Pixels, - thumb_size: Pixels, - margin_end: Pixels, -} - -impl Element for Scrollbar { - type PrepaintState = PrepaintState; - type RequestLayoutState = (); - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Absolute, - flex_grow: 1.0, - flex_shrink: 1.0, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, - ..Default::default() - }; - - (window.request_layout(style, None, cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { - window.insert_hitbox(bounds, HitboxBehavior::Normal) - }); - - let state = window - .use_state(cx, |_, _| ScrollbarState::default()) - .read(cx) - .clone(); - - let mut states = vec![]; - let mut has_both = self.axis.is_both(); - - let scroll_size = self - .scroll_size - .unwrap_or(self.scroll_handle.content_size()); - - 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 { - ( - scroll_size.height, - hitbox.size.height, - self.scroll_handle.offset().y, - ) - } else { - ( - scroll_size.width, - hitbox.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 { - 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; - } - - 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.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y) - } else { - point( - hitbox.origin.x, - hitbox.origin.y + hitbox.size.height - WIDTH, - ) - }, - size: gpui::Size { - width: if is_vertical { - WIDTH - } else { - hitbox.size.width - }, - height: if is_vertical { - hitbox.size.height - } else { - WIDTH - }, - }, - }; - - let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); - let is_always_to_show = scrollbar_show.is_always(); - let is_hover_to_show = scrollbar_show.is_hover(); - let is_hovered_on_bar = state.get().hovered_axis == Some(axis); - let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); - let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset(); - - let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = - if state.get().dragged_axis == Some(axis) { - Self::style_for_active(cx) - } else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) { - if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - } - } else if is_offset_changed { - self.style_for_normal(cx) - } else if is_always_to_show { - if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - } - } else { - let mut idle_state = self.style_for_idle(cx); - // Delay 2s to fade out the scrollbar thumb (in 1s) - if let Some(last_time) = state.get().last_scroll_time { - let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - if is_hovered_on_bar { - state.set(state.get().with_last_scroll_time(Some(Instant::now()))); - idle_state = if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - }; - } else if elapsed < FADE_OUT_DELAY { - idle_state.0 = cx.theme().scrollbar_thumb_background; - - if !state.get().idle_timer_scheduled { - let state = state.clone(); - state.set(state.get().with_idle_timer_scheduled(true)); - let current_view = window.current_view(); - let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed); - window - .spawn(cx, async move |cx| { - cx.background_executor().timer(next_delay).await; - state.set(state.get().with_idle_timer_scheduled(false)); - cx.update(|_, cx| cx.notify(current_view)).ok(); - }) - .detach(); - } - } else if elapsed < FADE_OUT_DURATION { - let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); - idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity); - - window.request_animation_frame(); - } - } - - idle_state - }; - - // The clickable area of the thumb - let thumb_length = thumb_end - thumb_start - inset * 2; - let thumb_bounds = if is_vertical { - Bounds::from_anchor_and_size( - Anchor::TopRight, - bounds.top_right() + point(-inset, inset + thumb_start), - size(WIDTH, thumb_length), - ) - } else { - Bounds::from_anchor_and_size( - Anchor::BottomLeft, - bounds.bottom_left() + point(inset + thumb_start, -inset), - size(thumb_length, WIDTH), - ) - }; - - // The actual render area of the thumb - let thumb_fill_bounds = if is_vertical { - Bounds::from_anchor_and_size( - Anchor::TopRight, - bounds.top_right() + point(-inset, inset + thumb_start), - size(thumb_width, thumb_length), - ) - } else { - Bounds::from_anchor_and_size( - Anchor::BottomLeft, - bounds.bottom_left() + point(inset + thumb_start, -inset), - size(thumb_length, thumb_width), - ) - }; - - let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { - window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) - }); - - states.push(AxisPrepaintState { - axis, - bar_hitbox, - bounds, - radius, - bg: bar_bg, - border: bar_border, - thumb_bounds, - thumb_fill_bounds, - thumb_bg, - scroll_size: scroll_area_size, - container_size, - thumb_size: thumb_length, - margin_end, - }) - } - - PrepaintState { - hitbox, - states, - scrollbar_state: state, - } - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let scrollbar_state = &prepaint.scrollbar_state; - let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); - let view_id = window.current_view(); - let hitbox_bounds = prepaint.hitbox.bounds; - let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always(); - let is_hover_to_show = scrollbar_show.is_hover(); - - // Update last_scroll_time when offset is changed. - if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset { - scrollbar_state.set( - scrollbar_state - .get() - .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), - ); - cx.notify(view_id); - } - - window.with_content_mask( - Some(ContentMask { - bounds: hitbox_bounds, - }), - |window| { - for state in prepaint.states.iter() { - let axis = state.axis; - let mut radius = state.radius; - if cx.theme().radius.is_zero() { - radius = px(0.); - } - let bounds = state.bounds; - let thumb_bounds = state.thumb_bounds; - let scroll_area_size = state.scroll_size; - let container_size = state.container_size; - let thumb_size = state.thumb_size; - let margin_end = state.margin_end; - let is_vertical = axis.is_vertical(); - - window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox); - - window.paint_layer(hitbox_bounds, |cx| { - cx.paint_quad(fill(state.bounds, state.bg)); - - cx.paint_quad(PaintQuad { - bounds, - corner_radii: (0.).into(), - background: gpui::transparent_black().into(), - border_widths: Edges { - top: px(0.), - right: px(0.), - bottom: px(0.), - left: px(0.), - }, - border_color: state.border, - border_style: BorderStyle::default(), - }); - - cx.paint_quad( - fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius), - ); - }); - - window.on_mouse_event({ - let state = scrollbar_state.clone(); - let scroll_handle = self.scroll_handle.clone(); - - move |event: &ScrollWheelEvent, phase, _, cx| { - if phase.bubble() - && hitbox_bounds.contains(&event.position) - && scroll_handle.offset() != state.get().last_scroll_offset - { - state.set(state.get().with_last_scroll( - scroll_handle.offset(), - Some(Instant::now()), - )); - cx.notify(view_id); - } - } - }); - - let safe_range = (-scroll_area_size + container_size)..px(0.); - - if is_hover_to_show || is_visible { - window.on_mouse_event({ - let state = scrollbar_state.clone(); - 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; - - scroll_handle.start_drag(); - state.set(state.get().with_drag_pos(axis, pos)); - - cx.notify(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_size / 2. - bounds.origin.y) - / (bounds.size.height - thumb_size) - } else { - (event.position.x - thumb_size / 2. - bounds.origin.x) - / (bounds.size.width - thumb_size) - } - .min(1.); - - if is_vertical { - scroll_handle.set_offset(point( - offset.x, - (-scroll_area_size * percentage) - .clamp(safe_range.start, safe_range.end), - )); - } else { - scroll_handle.set_offset(point( - (-scroll_area_size * percentage) - .clamp(safe_range.start, safe_range.end), - offset.y, - )); - } - } - } - } - }); - } - - window.on_mouse_event({ - let scroll_handle = self.scroll_handle.clone(); - let state = scrollbar_state.clone(); - let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); - - move |event: &MouseMoveEvent, _, _, cx| { - let mut notify = false; - // When is hover to show mode or it was visible, - // we need to update the hovered state and increase the last_scroll_time. - let need_hover_to_update = is_hover_to_show || is_visible; - // Update hovered state for scrollbar - if bounds.contains(&event.position) && need_hover_to_update { - state.set(state.get().with_hovered(Some(axis))); - - if state.get().hovered_axis != Some(axis) { - notify = true; - } - } else if state.get().hovered_axis == Some(axis) { - state.set(state.get().with_hovered(None)); - notify = true; - } - - // 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))); - notify = true; - } - } else if state.get().hovered_on_thumb == Some(axis) { - state.set(state.get().with_hovered_on_thumb(None)); - notify = true; - } - - // Move thumb position on dragging - if state.get().dragged_axis == Some(axis) && event.dragging() { - // Stop the event propagation to avoid selecting text or other side effects. - cx.stop_propagation(); - - // 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_size) - } else { - (event.position.x - drag_pos.x - bounds.origin.x) - / (bounds.size.width - thumb_size - margin_end) - }) - .clamp(0., 1.); - - let offset = if is_vertical { - point( - scroll_handle.offset().x, - (-(scroll_area_size - container_size) * percentage) - .clamp(safe_range.start, safe_range.end), - ) - } else { - point( - (-(scroll_area_size - container_size) * percentage) - .clamp(safe_range.start, safe_range.end), - scroll_handle.offset().y, - ) - }; - - if (scroll_handle.offset().y - offset.y).abs() > px(1.) - || (scroll_handle.offset().x - offset.x).abs() > px(1.) - { - // Limit update rate - if state.get().last_update.elapsed() > max_fps_duration { - scroll_handle.set_offset(offset); - state.set(state.get().with_last_update(Instant::now())); - notify = true; - } - } - } - - if notify { - cx.notify(view_id); - } - } - }); - - window.on_mouse_event({ - let state = scrollbar_state.clone(); - let scroll_handle = self.scroll_handle.clone(); - - move |_event: &MouseUpEvent, phase, _, cx| { - if phase.bubble() { - scroll_handle.end_drag(); - state.set(state.get().with_unset_drag_pos()); - cx.notify(view_id); - } - } - }); - } - }, - ); - } -}