feat: add scrollbar for virtualized lists
This commit is contained in:
@@ -31,6 +31,7 @@ full = [
|
|||||||
"input",
|
"input",
|
||||||
"popover",
|
"popover",
|
||||||
"scroll_area",
|
"scroll_area",
|
||||||
|
"scrollbar",
|
||||||
"segmented",
|
"segmented",
|
||||||
"spinner",
|
"spinner",
|
||||||
"field",
|
"field",
|
||||||
@@ -59,6 +60,7 @@ badge = []
|
|||||||
input = ["button"]
|
input = ["button"]
|
||||||
popover = []
|
popover = []
|
||||||
scroll_area = []
|
scroll_area = []
|
||||||
|
scrollbar = ["scroll_area"]
|
||||||
segmented = []
|
segmented = []
|
||||||
spinner = []
|
spinner = []
|
||||||
field = []
|
field = []
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ pub mod popover;
|
|||||||
#[cfg(feature = "scroll_area")]
|
#[cfg(feature = "scroll_area")]
|
||||||
pub mod scroll_area;
|
pub mod scroll_area;
|
||||||
|
|
||||||
|
#[cfg(feature = "scrollbar")]
|
||||||
|
pub mod scrollbar;
|
||||||
|
|
||||||
#[cfg(feature = "segmented")]
|
#[cfg(feature = "segmented")]
|
||||||
pub mod segmented;
|
pub mod segmented;
|
||||||
|
|
||||||
|
|||||||
+193
-12
@@ -1,8 +1,12 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, MouseDownEvent,
|
App, Bounds, DispatchPhase, ElementId, Entity, EntityId, InteractiveElement as _, IntoElement,
|
||||||
MouseMoveEvent, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, ScrollEvent,
|
||||||
StatefulInteractiveElement as _, Styled, Window, canvas, div, fill, point,
|
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Task, Window, canvas, div,
|
||||||
|
fill, point,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
@@ -20,12 +24,115 @@ pub enum ScrollAxes {
|
|||||||
Both,
|
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<AutohideState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutohideState {
|
||||||
|
last_activity: Option<Instant>,
|
||||||
|
hide_task: Option<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<gpui::Hsla> {
|
||||||
|
(alpha > 0.0).then_some(gpui::Hsla {
|
||||||
|
a: color.a * alpha,
|
||||||
|
..color
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A scrollable container with an overlay scrollbar.
|
/// A scrollable container with an overlay scrollbar.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct ScrollArea {
|
pub struct ScrollArea {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
handle: Option<ScrollHandle>,
|
handle: Option<ScrollHandle>,
|
||||||
axes: ScrollAxes,
|
axes: ScrollAxes,
|
||||||
|
visibility: ScrollbarVisibility,
|
||||||
a11y_label: Option<SharedString>,
|
a11y_label: Option<SharedString>,
|
||||||
style: gpui::StyleRefinement,
|
style: gpui::StyleRefinement,
|
||||||
children: Vec<gpui::AnyElement>,
|
children: Vec<gpui::AnyElement>,
|
||||||
@@ -53,6 +160,7 @@ impl ScrollArea {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
handle: None,
|
handle: None,
|
||||||
axes: ScrollAxes::Vertical,
|
axes: ScrollAxes::Vertical,
|
||||||
|
visibility: ScrollbarVisibility::default(),
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
style: gpui::StyleRefinement::default(),
|
style: gpui::StyleRefinement::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
@@ -65,6 +173,12 @@ impl ScrollArea {
|
|||||||
self
|
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.
|
/// Scrolls with a handle held on the owning view.
|
||||||
pub fn handle(mut self, handle: ScrollHandle) -> Self {
|
pub fn handle(mut self, handle: ScrollHandle) -> Self {
|
||||||
self.handle = Some(handle);
|
self.handle = Some(handle);
|
||||||
@@ -101,7 +215,7 @@ pub(crate) fn thumb_geometry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for ScrollArea {
|
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()
|
let mut pane = div()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
@@ -128,11 +242,16 @@ impl RenderOnce for ScrollArea {
|
|||||||
wrapper.extend(std::iter::once(pane.into_any_element()));
|
wrapper.extend(std::iter::once(pane.into_any_element()));
|
||||||
|
|
||||||
if let Some(handle) = self.handle {
|
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(
|
wrapper.extend(std::iter::once(
|
||||||
canvas(|_, _, _| {}, {
|
canvas(|_, _, _| {}, {
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
move |_, _, window, cx| {
|
move |_, _, window, cx| {
|
||||||
paint_scrollbar(&handle, window, cx);
|
paint_scrollbar(&handle, autohide, window, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
@@ -146,8 +265,10 @@ impl RenderOnce for ScrollArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Paints the overlay scrollbar thumb.
|
/// 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 theme = cx.theme();
|
||||||
|
let view = window.current_view();
|
||||||
|
|
||||||
let color = theme.colors.scrollbar_thumb;
|
let color = theme.colors.scrollbar_thumb;
|
||||||
let width = theme.scrollbar.width;
|
let width = theme.scrollbar.width;
|
||||||
let inset = theme.scrollbar.inset;
|
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 max_offset = handle.max_offset().y;
|
||||||
let bounds = handle.bounds();
|
let bounds = handle.bounds();
|
||||||
|
|
||||||
if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO {
|
if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO {
|
||||||
return;
|
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 track = bounds.size.height - inset * 2.0;
|
||||||
let (thumb_length, thumb_top) = thumb_geometry(
|
let (thumb_length, thumb_top) = thumb_geometry(
|
||||||
bounds.size.height,
|
bounds.size.height,
|
||||||
@@ -177,16 +310,27 @@ fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if let Some(color) = thumb_style(color, alpha) {
|
||||||
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| {
|
if let Some(state) = &autohide_state {
|
||||||
Option::<(Pixels, Pixels)>::None
|
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({
|
window.on_mouse_event({
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
let drag = drag.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
|
if phase != DispatchPhase::Bubble
|
||||||
|| event.button != gpui::MouseButton::Left
|
|| event.button != gpui::MouseButton::Left
|
||||||
|| !thumb_bounds.contains(&event.position)
|
|| !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.update(cx, |drag, _| {
|
||||||
*drag = Some((event.position.y, handle.offset().y));
|
*drag = Some((event.position.y, handle.offset().y));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(state) = &autohide_state {
|
||||||
|
state.touch(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
cx.stop_propagation();
|
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({
|
window.on_mouse_event({
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
let drag = drag.clone();
|
let drag = drag.clone();
|
||||||
move |event: &MouseMoveEvent, phase, _window, cx| {
|
let autohide_state = autohide_state.clone();
|
||||||
if phase != DispatchPhase::Bubble
|
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
|| event.pressed_button != Some(gpui::MouseButton::Left)
|
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;
|
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)
|
let offset = (start_offset - (event.position.y - start_y) * scale)
|
||||||
.clamp(-max_offset, Pixels::ZERO);
|
.clamp(-max_offset, Pixels::ZERO);
|
||||||
handle.set_offset(point(handle.offset().x, offset));
|
handle.set_offset(point(handle.offset().x, offset));
|
||||||
|
cx.notify(view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<Pixels>;
|
||||||
|
/// The current offset, non-positive along each axis.
|
||||||
|
fn offset(&self) -> Point<Pixels>;
|
||||||
|
/// Applies the offset along one axis, keeping the other.
|
||||||
|
fn set_offset(&self, offset: Point<Pixels>);
|
||||||
|
/// The bounds of the scrolled surface.
|
||||||
|
fn viewport(&self) -> Bounds<Pixels>;
|
||||||
|
/// 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<Pixels> {
|
||||||
|
ScrollHandle::max_offset(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(&self) -> Point<Pixels> {
|
||||||
|
ScrollHandle::offset(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_offset(&self, offset: Point<Pixels>) {
|
||||||
|
ScrollHandle::set_offset(self, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport(&self) -> Bounds<Pixels> {
|
||||||
|
self.bounds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollbarHandle for UniformListScrollHandle {
|
||||||
|
fn max_offset(&self) -> Point<Pixels> {
|
||||||
|
self.0.borrow().base_handle.max_offset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(&self) -> Point<Pixels> {
|
||||||
|
self.0.borrow().base_handle.offset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_offset(&self, offset: Point<Pixels>) {
|
||||||
|
self.0.borrow().base_handle.set_offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport(&self) -> Bounds<Pixels> {
|
||||||
|
self.0.borrow().base_handle.bounds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollbarHandle for ListState {
|
||||||
|
fn max_offset(&self) -> Point<Pixels> {
|
||||||
|
ListState::max_offset_for_scrollbar(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(&self) -> Point<Pixels> {
|
||||||
|
ListState::scroll_px_offset_for_scrollbar(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_offset(&self, offset: Point<Pixels>) {
|
||||||
|
ListState::set_offset_from_scrollbar(self, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport(&self) -> Bounds<Pixels> {
|
||||||
|
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>) -> Pixels {
|
||||||
|
match self {
|
||||||
|
Axis::Vertical => position.y,
|
||||||
|
Axis::Horizontal => position.x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_along(self, position: Point<Pixels>, along: Pixels) -> Point<Pixels> {
|
||||||
|
match self {
|
||||||
|
Axis::Vertical => point(position.x, along),
|
||||||
|
Axis::Horizontal => point(along, position.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport_along(self, viewport: &Bounds<Pixels>) -> 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<dyn ScrollbarHandle>,
|
||||||
|
visibility: ScrollbarVisibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scrollbar {
|
||||||
|
/// A vertical scrollbar driven by `handle`.
|
||||||
|
pub fn vertical<H>(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<H>(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<dyn ScrollbarHandle>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<RefCell<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for UniformShell {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<Self>) -> 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<RefCell<usize>>,
|
||||||
|
) {
|
||||||
|
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<Pixels>,
|
||||||
|
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<Pixels> {
|
||||||
|
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<Pixels> {
|
||||||
|
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<Pixels> {
|
||||||
|
point(
|
||||||
|
self.thumb_center().x,
|
||||||
|
self.thumb_center().y + (self.track_length() - self.thumb_length()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||||
|
visual.simulate_event(MouseDownEvent {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
position,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drag_to(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position,
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(visual: &mut VisualTestContext, position: Point<Pixels>) {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Scrollbar {
|
pub struct Scrollbar {
|
||||||
/// The painted thumb's thickness.
|
/// The painted thumb's thickness.
|
||||||
@@ -234,6 +235,8 @@ pub struct Scrollbar {
|
|||||||
pub inset: Pixels,
|
pub inset: Pixels,
|
||||||
/// The thumb never shrinks below this length, so it stays grabbable.
|
/// The thumb never shrinks below this length, so it stays grabbable.
|
||||||
pub min_thumb: Pixels,
|
pub min_thumb: Pixels,
|
||||||
|
/// Whether the platform's scrollbars hide while idle.
|
||||||
|
pub autohide: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Scrollbar {
|
impl Default for Scrollbar {
|
||||||
@@ -242,6 +245,7 @@ impl Default for Scrollbar {
|
|||||||
width: px(8.),
|
width: px(8.),
|
||||||
inset: px(2.),
|
inset: px(2.),
|
||||||
min_thumb: px(24.),
|
min_thumb: px(24.),
|
||||||
|
autohide: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ pub(crate) fn resolve(
|
|||||||
radius,
|
radius,
|
||||||
elevations: Elevations::for_platform(system.source, variant),
|
elevations: Elevations::for_platform(system.source, variant),
|
||||||
motion: Motion::default(),
|
motion: Motion::default(),
|
||||||
scrollbar: Scrollbar::default(),
|
scrollbar: Scrollbar {
|
||||||
|
autohide: system.source.scrollbar_autohide(),
|
||||||
|
..Scrollbar::default()
|
||||||
|
},
|
||||||
titlebar_height,
|
titlebar_height,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
sizes,
|
sizes,
|
||||||
@@ -339,6 +342,28 @@ mod tests {
|
|||||||
assert_eq!(resolved.sizes.small.text_size, px(11.05));
|
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]
|
#[test]
|
||||||
fn a_stated_input_height_overrides_the_derived_one_and_scales() {
|
fn a_stated_input_height_overrides_the_derived_one_and_scales() {
|
||||||
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ impl SourceKind {
|
|||||||
Self::Windows => "Windows 11 (UISettings)",
|
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.
|
/// A partial palette with optional colors.
|
||||||
|
|||||||
+4
-5
@@ -9,18 +9,16 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Command palette** (M12) — filtered actions; the matcher stays
|
- [ ] **Scrollbar** (M13) — a simple way to show a scrollbar on gpui's
|
||||||
caller-owned.
|
`list` and `uniform_list`.
|
||||||
- [x] API sketch
|
- [x] API sketch
|
||||||
- [x] Implementation (theme-baked, no entry motion per AGENTS.md)
|
- [x] Implementation (theme-baked, overlays virtualized lists)
|
||||||
- [x] Tests
|
- [x] Tests
|
||||||
- [x] Gallery panel
|
- [x] Gallery panel
|
||||||
- [ ] Review
|
- [ ] Review
|
||||||
|
|
||||||
## Queue — to follow
|
## 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.
|
- [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting.
|
||||||
- [ ] **Diff view** (M15, `editor`).
|
- [ ] **Diff view** (M15, `editor`).
|
||||||
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
- [ ] **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
|
- [x] Status dot — colored state bead, Badge's variant vocabulary, optional
|
||||||
screen-reader label
|
screen-reader label
|
||||||
- [x] Combobox — type-to-filter picker, create / keep / revert blur modes
|
- [x] Combobox — type-to-filter picker, create / keep / revert blur modes
|
||||||
|
- [x] Command palette — filtered actions; the matcher stays caller-owned
|
||||||
|
|
||||||
### data (`crates/data`)
|
### data (`crates/data`)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::time::Duration;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _,
|
App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _,
|
||||||
IntoElement, KeyBinding, ParentElement as _, Pixels, Render, SharedString, Styled as _,
|
IntoElement, KeyBinding, ListAlignment, ListState, ParentElement as _, Pixels, Render,
|
||||||
WeakEntity, Window, WindowBounds, WindowOptions, actions, div, px, size,
|
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::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
||||||
use gpui_native_core::avatar::Avatar;
|
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::progress::Progress;
|
||||||
use gpui_native_core::radio::{Radio, RadioGroup};
|
use gpui_native_core::radio::{Radio, RadioGroup};
|
||||||
use gpui_native_core::scroll_area::{ScrollArea, ScrollAxes};
|
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::segmented::{Segmented, SegmentedOption};
|
||||||
use gpui_native_core::select::{Select, SelectOption};
|
use gpui_native_core::select::{Select, SelectOption};
|
||||||
use gpui_native_core::slider::Slider;
|
use gpui_native_core::slider::Slider;
|
||||||
@@ -93,6 +95,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("tooltip", "Tooltip"),
|
("tooltip", "Tooltip"),
|
||||||
("empty-state", "Empty state"),
|
("empty-state", "Empty state"),
|
||||||
("scroll-area", "Scroll area"),
|
("scroll-area", "Scroll area"),
|
||||||
|
("scrollbar", "Scrollbar"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -614,6 +617,12 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("scrollbar", "Scrollbar", |_, cx| {
|
||||||
|
cx.new(|_| ScrollbarDemo::new()).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
||||||
cx.new(|_| SidebarDemo { selected: None }).into()
|
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<Self>) -> 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 {
|
struct SidebarDemo {
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user