From 50da1d4a9d51d1561a41328a63263db028b2c0a3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 4 Nov 2026 13:05:39 +0700 Subject: [PATCH] feat: add scrollbar for virtualized lists --- crates/core/Cargo.toml | 2 + crates/core/src/lib.rs | 3 + crates/core/src/scroll_area.rs | 209 +++++++++++++++-- crates/core/src/scrollbar.rs | 394 +++++++++++++++++++++++++++++++++ crates/core/tests/scrollbar.rs | 273 +++++++++++++++++++++++ crates/theme/src/lib.rs | 6 +- crates/theme/src/resolve.rs | 27 ++- crates/theme/src/system.rs | 7 + docs/CHECKLIST.md | 9 +- examples/gallery/src/main.rs | 126 ++++++++++- 10 files changed, 1033 insertions(+), 23 deletions(-) create mode 100644 crates/core/src/scrollbar.rs create mode 100644 crates/core/tests/scrollbar.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 495bfe0..1ec4a4e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -31,6 +31,7 @@ full = [ "input", "popover", "scroll_area", + "scrollbar", "segmented", "spinner", "field", @@ -59,6 +60,7 @@ badge = [] input = ["button"] popover = [] scroll_area = [] +scrollbar = ["scroll_area"] segmented = [] spinner = [] field = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4db9e0b..80a860f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -63,6 +63,9 @@ pub mod popover; #[cfg(feature = "scroll_area")] pub mod scroll_area; +#[cfg(feature = "scrollbar")] +pub mod scrollbar; + #[cfg(feature = "segmented")] pub mod segmented; diff --git a/crates/core/src/scroll_area.rs b/crates/core/src/scroll_area.rs index f50b733..fee3574 100644 --- a/crates/core/src/scroll_area.rs +++ b/crates/core/src/scroll_area.rs @@ -1,8 +1,12 @@ +use std::time::Duration; +use std::time::Instant; + 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, + App, Bounds, DispatchPhase, ElementId, Entity, EntityId, InteractiveElement as _, IntoElement, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, ScrollEvent, + ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Task, Window, canvas, div, + fill, point, }; use gpui_native_theme::ActiveTheme as _; @@ -20,12 +24,115 @@ pub enum ScrollAxes { Both, } +/// When an overlay scrollbar shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ScrollbarVisibility { + /// The running platform's behavior, from the theme. + #[default] + Platform, + Always, + Autohide, +} + +/// How long an autohide thumb stays fully visible before its fade begins. +const AUTOHIDE_DELAY: Duration = Duration::from_millis(700); +/// How long the autohide fade runs. +const AUTOHIDE_FADE: Duration = Duration::from_millis(250); +/// How often the fade repaints while it runs. +const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50); + +/// The shared autohide timeline for an overlay scrollbar. +#[derive(Clone)] +pub(crate) struct Autohide { + view: EntityId, + state: Entity, +} + +struct AutohideState { + last_activity: Option, + hide_task: Option>, +} + +impl Autohide { + pub(crate) fn new(window: &mut Window, cx: &mut App) -> Self { + let view = window.current_view(); + let state = window.use_keyed_state("scrollbar-autohide", cx, |_, _| AutohideState { + last_activity: None, + hide_task: None, + }); + Self { view, state } + } + + /// Marks interaction. + pub(crate) fn touch(&self, window: &mut Window, cx: &mut App) { + let view = self.view; + let reduce_motion = cx.theme().accessibility.reduce_motion; + + let task = window.spawn(cx, async move |cx| { + if reduce_motion { + cx.background_executor() + .timer(AUTOHIDE_DELAY + AUTOHIDE_FADE) + .await; + cx.update(|_, cx| cx.notify(view)).ok(); + return; + } + + let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1); + cx.background_executor().timer(AUTOHIDE_DELAY).await; + + for _ in 0..steps { + cx.update(|_, cx| cx.notify(view)).ok(); + cx.background_executor().timer(AUTOHIDE_FADE_STEP).await; + } + + cx.update(|_, cx| cx.notify(view)).ok(); + }); + + self.state.update(cx, |state, _| { + state.last_activity = Some(Instant::now()); + state.hide_task = Some(task); + }); + } + + /// The thumb's opacity. + pub(crate) fn alpha(&self, cx: &App, dragging: bool) -> f32 { + if dragging { + return 1.0; + } + + let Some(activity) = self.state.read(cx).last_activity else { + return 0.0; + }; + + let idle = activity.elapsed(); + if idle <= AUTOHIDE_DELAY { + return 1.0; + } + + let fade = idle.saturating_sub(AUTOHIDE_DELAY); + if fade >= AUTOHIDE_FADE { + return 0.0; + } + + 1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32() + } +} + +/// An overlay scrollbar thumb's style for one paint: `None` hides it. +pub(crate) fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option { + (alpha > 0.0).then_some(gpui::Hsla { + a: color.a * alpha, + ..color + }) +} + /// A scrollable container with an overlay scrollbar. #[derive(IntoElement)] pub struct ScrollArea { id: ElementId, handle: Option, axes: ScrollAxes, + visibility: ScrollbarVisibility, a11y_label: Option, style: gpui::StyleRefinement, children: Vec, @@ -53,6 +160,7 @@ impl ScrollArea { id: id.into(), handle: None, axes: ScrollAxes::Vertical, + visibility: ScrollbarVisibility::default(), a11y_label: None, style: gpui::StyleRefinement::default(), children: Vec::new(), @@ -65,6 +173,12 @@ impl ScrollArea { self } + /// Overrides when the scrollbar shows, instead of the platform default. + pub fn visibility(mut self, visibility: ScrollbarVisibility) -> Self { + self.visibility = visibility; + self + } + /// Scrolls with a handle held on the owning view. pub fn handle(mut self, handle: ScrollHandle) -> Self { self.handle = Some(handle); @@ -101,7 +215,7 @@ pub(crate) fn thumb_geometry( } impl RenderOnce for ScrollArea { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let mut pane = div() .id(self.id.clone()) .overflow_hidden() @@ -128,11 +242,16 @@ impl RenderOnce for ScrollArea { wrapper.extend(std::iter::once(pane.into_any_element())); if let Some(handle) = self.handle { + let autohide = match self.visibility { + ScrollbarVisibility::Platform => cx.theme().scrollbar.autohide, + ScrollbarVisibility::Always => false, + ScrollbarVisibility::Autohide => true, + }; wrapper.extend(std::iter::once( canvas(|_, _, _| {}, { let handle = handle.clone(); move |_, _, window, cx| { - paint_scrollbar(&handle, window, cx); + paint_scrollbar(&handle, autohide, window, cx); } }) .absolute() @@ -146,8 +265,10 @@ impl RenderOnce for ScrollArea { } /// Paints the overlay scrollbar thumb. -fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { +fn paint_scrollbar(handle: &ScrollHandle, autohide: bool, window: &mut Window, cx: &mut App) { let theme = cx.theme(); + let view = window.current_view(); + let color = theme.colors.scrollbar_thumb; let width = theme.scrollbar.width; let inset = theme.scrollbar.inset; @@ -155,10 +276,22 @@ fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { let max_offset = handle.max_offset().y; let bounds = handle.bounds(); + if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO { return; } + let autohide_state = autohide.then(|| Autohide::new(window, cx)); + let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| { + Option::<(Pixels, Pixels)>::None + }); + let dragging = drag.read(cx).is_some(); + + let alpha = match (&autohide_state, autohide) { + (Some(state), true) => state.alpha(cx, dragging), + _ => 1.0, + }; + let track = bounds.size.height - inset * 2.0; let (thumb_length, thumb_top) = thumb_geometry( bounds.size.height, @@ -177,16 +310,27 @@ fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { ), ); - window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0)); + if let Some(color) = thumb_style(color, alpha) { + 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 - }); + if let Some(state) = &autohide_state { + window.on_mouse_event({ + let state = state.clone(); + move |event: &ScrollEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble || !bounds.contains(&event.position) { + return; + } + state.touch(window, cx); + } + }); + } window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); - move |event: &MouseDownEvent, phase, _window, cx| { + let autohide_state = autohide_state.clone(); + move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left || !thumb_bounds.contains(&event.position) @@ -197,17 +341,53 @@ fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { drag.update(cx, |drag, _| { *drag = Some((event.position.y, handle.offset().y)); }); + + if let Some(state) = &autohide_state { + state.touch(window, cx); + } + cx.stop_propagation(); } }); + window.on_mouse_event({ + let drag = drag.clone(); + let autohide_state = autohide_state.clone(); + move |event: &MouseUpEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left { + return; + } + + if drag.read(cx).is_none() { + return; + } + + drag.update(cx, |drag, _| { + *drag = None; + }); + + if let Some(state) = &autohide_state { + state.touch(window, cx); + } + } + }); + 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) + let autohide_state = autohide_state.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + if let Some(state) = &autohide_state + && drag.read(cx).is_some() { + state.touch(window, cx); + } + + if event.pressed_button != Some(gpui::MouseButton::Left) { return; } @@ -220,6 +400,7 @@ fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) { let offset = (start_offset - (event.position.y - start_y) * scale) .clamp(-max_offset, Pixels::ZERO); handle.set_offset(point(handle.offset().x, offset)); + cx.notify(view); } } }); diff --git a/crates/core/src/scrollbar.rs b/crates/core/src/scrollbar.rs new file mode 100644 index 0000000..c5a2415 --- /dev/null +++ b/crates/core/src/scrollbar.rs @@ -0,0 +1,394 @@ +//! Overlay scrollbars for scroll surfaces, including gpui's virtualized +//! `uniform_list` and `list`. Render the scrollbar as a later child of a +//! `.relative()` container around the scrolled surface: +//! +//! ``` +//! div().relative() +//! .child(uniform_list(...).track_scroll(&handle)) +//! .child(Scrollbar::vertical(handle)) +//! ``` + +use std::rc::Rc; + +use gpui::{ + App, Bounds, DispatchPhase, 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, 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, +} + +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(), + } + } + + /// 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(), + } + } + + /// Overrides when the scrollbar shows, instead of the platform default. + pub fn visibility(mut self, visibility: ScrollbarVisibility) -> Self { + self.visibility = visibility; + 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; + move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, 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, + 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(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-thumb-drag", 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); + } + } + }); +} diff --git a/crates/core/tests/scrollbar.rs b/crates/core/tests/scrollbar.rs new file mode 100644 index 0000000..65fa880 --- /dev/null +++ b/crates/core/tests/scrollbar.rs @@ -0,0 +1,273 @@ +use gpui::{ + Bounds, Context, IntoElement, ListAlignment, ListState, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, Styled as _, + TestAppContext, UniformListScrollHandle, VisualTestContext, Window, div, list, point, px, + uniform_list, +}; +use gpui_native_core::init; +use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle}; +use gpui_native_theme::ActiveTheme as _; +use std::cell::RefCell; +use std::rc::Rc; + +const ROWS: usize = 50; +const ROW_HEIGHT: f32 = 24.; +const VIEWPORT_HEIGHT: f32 = 300.; + +struct UniformShell { + rows: usize, + handle: UniformListScrollHandle, + renders: Rc>, +} + +impl Render for UniformShell { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + *self.renders.borrow_mut() += 1; + div().size_full().child( + div() + .relative() + .h(px(VIEWPORT_HEIGHT)) + .w(px(400.)) + .child( + uniform_list("rows", self.rows, |range, _, _| { + range + .map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}"))) + .collect() + }) + .track_scroll(&self.handle) + .h_full() + .w_full(), + ) + .child(Scrollbar::vertical(self.handle.clone())), + ) + } +} + +struct ListShell { + state: ListState, +} + +impl Render for ListShell { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child( + div() + .relative() + .h(px(VIEWPORT_HEIGHT)) + .w(px(400.)) + .child( + list(self.state.clone(), |ix, _, _| { + div() + .h(px(ROW_HEIGHT)) + .child(format!("row {ix}")) + .into_any_element() + }) + .h_full() + .w_full(), + ) + .child(Scrollbar::vertical(self.state.clone())), + ) + } +} + +fn uniform_shell( + rows: usize, +) -> ( + TestAppContext, + VisualTestContext, + UniformListScrollHandle, + Rc>, +) { + let mut cx = TestAppContext::single(); + cx.update(init); + let handle = UniformListScrollHandle::new(); + let handle_for_view = handle.clone(); + let renders = Rc::new(RefCell::new(0)); + let renders_for_view = renders.clone(); + let window = cx.add_window(move |_, _| UniformShell { + rows, + handle: handle_for_view, + renders: renders_for_view, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, handle, renders) +} + +fn list_shell() -> (TestAppContext, VisualTestContext, ListState) { + let mut cx = TestAppContext::single(); + cx.update(init); + let state = + ListState::new(ROWS, ListAlignment::Top, px(400.)).with_uniform_item_height(px(ROW_HEIGHT)); + let state_for_view = state.clone(); + let window = cx.add_window(move |_, _| ListShell { + state: state_for_view, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, state) +} + +/// The scrollbar geometry the tests need, straight from the handle and the +/// theme the component reads. +struct Metrics { + viewport: Bounds, + max_offset: Pixels, + width: Pixels, + inset: Pixels, + min_thumb: Pixels, +} + +impl Metrics { + fn measure(cx: &TestAppContext, handle: &impl ScrollbarHandle) -> Self { + cx.update(|cx| { + let theme = cx.theme(); + Self { + viewport: handle.viewport(), + max_offset: handle.max_offset().y, + width: theme.scrollbar.width, + inset: theme.scrollbar.inset, + min_thumb: theme.scrollbar.min_thumb, + } + }) + } + + fn track_length(&self) -> Pixels { + self.viewport.size.height - self.inset * 2.0 + } + + fn thumb_length(&self) -> Pixels { + let content = self.viewport.size.height + self.max_offset; + (self.viewport.size.height / content * self.track_length()) + .max(self.min_thumb) + .min(self.track_length()) + } + + fn thumb_center(&self) -> Point { + point( + self.viewport.right() - self.inset - self.width / 2.0, + self.viewport.top() + self.inset + self.thumb_length() / 2.0, + ) + } + + fn track_near_bottom(&self) -> Point { + point( + self.viewport.right() - self.inset - self.width / 2.0, + self.viewport.top() + self.inset + self.track_length() - px(1.), + ) + } + + fn full_drag_target(&self) -> Point { + point( + self.thumb_center().x, + self.thumb_center().y + (self.track_length() - self.thumb_length()), + ) + } +} + +fn press(visual: &mut VisualTestContext, position: Point) { + visual.simulate_event(MouseDownEvent { + button: MouseButton::Left, + position, + ..Default::default() + }); +} + +fn drag_to(visual: &mut VisualTestContext, position: Point) { + visual.simulate_event(MouseMoveEvent { + position, + pressed_button: Some(MouseButton::Left), + ..Default::default() + }); +} + +fn release(visual: &mut VisualTestContext, position: Point) { + visual.simulate_event(MouseUpEvent { + button: MouseButton::Left, + position, + ..Default::default() + }); +} + +#[test] +fn dragging_the_thumb_scrolls_the_uniform_list() { + let (cx, mut visual, handle, renders) = uniform_shell(ROWS); + let metrics = Metrics::measure(&cx, &handle); + assert!( + metrics.max_offset > px(0.), + "50 24px rows overflow a 300px viewport" + ); + + press(&mut visual, metrics.thumb_center()); + let renders_after_press = *renders.borrow(); + drag_to(&mut visual, metrics.full_drag_target()); + let renders_after_move = *renders.borrow(); + release(&mut visual, metrics.full_drag_target()); + + let offset = handle.offset().y; + assert!( + (offset.abs() - metrics.max_offset).abs() < px(0.5), + "a full drag reaches the end, got {offset}" + ); + assert!( + renders_after_move > renders_after_press, + "dragging must repaint the owning view, or the scroll never shows" + ); +} + +#[test] +fn clicking_the_track_jumps_to_the_position() { + let (cx, mut visual, handle, _) = uniform_shell(ROWS); + let metrics = Metrics::measure(&cx, &handle); + + press(&mut visual, metrics.track_near_bottom()); + release(&mut visual, metrics.track_near_bottom()); + + let offset = handle.offset().y; + assert!( + (offset.abs() - metrics.max_offset).abs() < px(0.5), + "a track click near the bottom jumps to the end, got {offset}" + ); +} + +#[test] +fn a_fitted_list_ignores_the_scrollbar() { + let (cx, mut visual, handle, _) = uniform_shell(5); + let metrics = Metrics::measure(&cx, &handle); + assert_eq!(metrics.max_offset, px(0.), "5 rows fit the viewport"); + + press(&mut visual, metrics.track_near_bottom()); + drag_to(&mut visual, metrics.full_drag_target()); + release(&mut visual, metrics.full_drag_target()); + + assert_eq!(handle.offset().y, px(0.), "nothing scrolled"); +} + +#[test] +fn dragging_the_thumb_drives_a_list_state_and_reports_the_drag() { + let (cx, mut visual, state) = list_shell(); + let metrics = Metrics::measure(&cx, &state); + assert!(metrics.max_offset > px(0.)); + + press(&mut visual, metrics.thumb_center()); + assert!( + state.is_scrollbar_dragging(), + "the drag freezes the list's measured height" + ); + + drag_to(&mut visual, metrics.full_drag_target()); + let offset = state.offset().y; + assert!( + (offset.abs() - metrics.max_offset).abs() < px(0.5), + "a full drag reaches the end, got {offset}" + ); + + release(&mut visual, metrics.full_drag_target()); + assert!( + !state.is_scrollbar_dragging(), + "the drag ends with the mouse" + ); +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 9103b66..87f933f 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -225,7 +225,8 @@ impl Default for Motion { } } -/// Overlay scrollbar geometry, transcribed from the platform's overlay crollbars. +/// Overlay scrollbar presentation, transcribed from the platform's overlay +/// crollbars. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Scrollbar { /// The painted thumb's thickness. @@ -234,6 +235,8 @@ pub struct Scrollbar { pub inset: Pixels, /// The thumb never shrinks below this length, so it stays grabbable. pub min_thumb: Pixels, + /// Whether the platform's scrollbars hide while idle. + pub autohide: bool, } impl Default for Scrollbar { @@ -242,6 +245,7 @@ impl Default for Scrollbar { width: px(8.), inset: px(2.), min_thumb: px(24.), + autohide: false, } } } diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index f0af057..3a66fbe 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -238,7 +238,10 @@ pub(crate) fn resolve( radius, elevations: Elevations::for_platform(system.source, variant), motion: Motion::default(), - scrollbar: Scrollbar::default(), + scrollbar: Scrollbar { + autohide: system.source.scrollbar_autohide(), + ..Scrollbar::default() + }, titlebar_height, root_font_size, sizes, @@ -339,6 +342,28 @@ mod tests { assert_eq!(resolved.sizes.small.text_size, px(11.05)); } + #[test] + fn scrollbar_autohide_follows_the_platform_source() { + let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None); + stated.source = SourceKind::Macos; + assert!(resolve(&stated, None, None).scrollbar.autohide); + + stated.source = SourceKind::Gnome; + assert!(resolve(&stated, None, None).scrollbar.autohide); + + stated.source = SourceKind::Omarchy; + assert!(resolve(&stated, None, None).scrollbar.autohide); + + stated.source = SourceKind::Windows; + assert!(!resolve(&stated, None, None).scrollbar.autohide); + + stated.source = SourceKind::Kde; + assert!(!resolve(&stated, None, None).scrollbar.autohide); + + stated.source = SourceKind::Fallback; + assert!(!resolve(&stated, None, None).scrollbar.autohide); + } + #[test] fn a_stated_input_height_overrides_the_derived_one_and_scales() { let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 4af7ab8..671e170 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -36,6 +36,13 @@ impl SourceKind { Self::Windows => "Windows 11 (UISettings)", } } + + /// Overlay scrollbars hide while idle on macOS, GNOME, and Omarchy. + /// + /// Windows and KDE keep theirs drawn. + pub fn scrollbar_autohide(self) -> bool { + matches!(self, Self::Macos | Self::Omarchy | Self::Gnome) + } } /// A partial palette with optional colors. diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 0c591df..bc2ba82 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,18 +9,16 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Command palette** (M12) — filtered actions; the matcher stays - caller-owned. +- [ ] **Scrollbar** (M13) — a simple way to show a scrollbar on gpui's + `list` and `uniform_list`. - [x] API sketch - - [x] Implementation (theme-baked, no entry motion per AGENTS.md) + - [x] Implementation (theme-baked, overlays virtualized lists) - [x] Tests - [x] Gallery panel - [ ] Review ## Queue — to follow -- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit - histories and the inbox. - [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting. - [ ] **Diff view** (M15, `editor`). - [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms. @@ -62,6 +60,7 @@ is the first downstream consumer; its screens drive the order. - [x] Status dot — colored state bead, Badge's variant vocabulary, optional screen-reader label - [x] Combobox — type-to-filter picker, create / keep / revert blur modes +- [x] Command palette — filtered actions; the matcher stays caller-owned ### data (`crates/data`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 01f6947..d86411a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -3,8 +3,9 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, KeyBinding, ParentElement as _, Pixels, Render, SharedString, Styled as _, - WeakEntity, Window, WindowBounds, WindowOptions, actions, div, px, size, + IntoElement, KeyBinding, ListAlignment, ListState, ParentElement as _, Pixels, Render, + SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds, + WindowOptions, actions, div, list, px, size, uniform_list, }; use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::avatar::Avatar; @@ -25,6 +26,7 @@ use gpui_native_core::popover::Popover; use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::scroll_area::{ScrollArea, ScrollAxes}; +use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; use gpui_native_core::segmented::{Segmented, SegmentedOption}; use gpui_native_core::select::{Select, SelectOption}; use gpui_native_core::slider::Slider; @@ -93,6 +95,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("tooltip", "Tooltip"), ("empty-state", "Empty state"), ("scroll-area", "Scroll area"), + ("scrollbar", "Scrollbar"), ], ), ( @@ -614,6 +617,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("scrollbar", "Scrollbar", |_, cx| { + cx.new(|_| ScrollbarDemo::new()).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("sidebar", "Sidebar", |_, cx| { cx.new(|_| SidebarDemo { selected: None }).into() @@ -2614,6 +2623,119 @@ impl Render for ScrollAreaDemo { } } +/// The virtualized lists a [Scrollbar] drives: gpui's `uniform_list` and +/// `list`, whose handles the overlay reads and writes. +struct ScrollbarDemo { + uniform: UniformListScrollHandle, + list: ListState, + visibility: ScrollbarVisibility, +} + +impl ScrollbarDemo { + fn new() -> Self { + Self { + uniform: UniformListScrollHandle::new(), + list: ListState::new(64, ListAlignment::Top, px(400.)), + visibility: ScrollbarVisibility::Platform, + } + } +} + +impl Render for ScrollbarDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let radius = theme.radius; + let list_state = self.list.clone(); + let visibility = self.visibility; + let view = cx.entity(); + + ScrollArea::new("scrollbar-demo").size_full().p_4().child( + v_flex() + .gap_3() + .child(demo_section( + "visibility", + colors, + Segmented::new("scrollbar-demo-visibility", visibility) + .options(vec![ + (ScrollbarVisibility::Platform, "Platform"), + (ScrollbarVisibility::Always, "Always"), + (ScrollbarVisibility::Autohide, "Autohide"), + ]) + .on_change(move |next, _, cx| { + view.update(cx, |demo, cx| { + demo.visibility = next; + cx.notify(); + }); + }), + )) + .child(demo_section( + "uniform_list", + colors, + div() + .relative() + .h(px(240.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + uniform_list("scrollbar-demo-uniform", 120, move |range, _, _| { + range + .map(|row| { + div() + .h(px(24.)) + .px_3() + .flex() + .items_center() + .border_b_1() + .border_color(colors.border) + .child(format!("Row {row} of 120")) + }) + .collect() + }) + .track_scroll(&self.uniform) + .h_full() + .w_full(), + ) + .child(Scrollbar::vertical(self.uniform.clone()).visibility(visibility)), + )) + .child(demo_section( + "list", + colors, + div() + .relative() + .h(px(240.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + list(list_state, move |row, _, _| { + div() + .px_3() + .py_1() + .border_b_1() + .border_color(colors.border) + .child(format!("Item {row} — {} lines tall", 1 + row % 3)) + .h(px(28. + (row % 3) as f32 * 12.)) + .into_any_element() + }) + .h_full() + .w_full(), + ) + .child(Scrollbar::vertical(self.list.clone()).visibility(visibility)), + )) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child("Platform follows the running OS; scroll or grab the rail to reveal an autohidden thumb."), + ), + ) + } +} + struct SidebarDemo { selected: Option, }