add scrollarea
This commit is contained in:
@@ -27,7 +27,7 @@ full = [
|
||||
"avatar",
|
||||
"input",
|
||||
"popover",
|
||||
"tooltip",
|
||||
"scroll_area",
|
||||
"menu",
|
||||
"dialog",
|
||||
"sheet",
|
||||
@@ -44,6 +44,7 @@ progress = []
|
||||
avatar = []
|
||||
input = []
|
||||
popover = []
|
||||
scroll_area = []
|
||||
tooltip = []
|
||||
menu = []
|
||||
dialog = []
|
||||
|
||||
@@ -39,6 +39,9 @@ pub mod input;
|
||||
#[cfg(feature = "popover")]
|
||||
pub mod popover;
|
||||
|
||||
#[cfg(feature = "scroll_area")]
|
||||
pub mod scroll_area;
|
||||
|
||||
/// Initializes everything the components need at startup.
|
||||
pub fn init(cx: &mut App) {
|
||||
gpui_native_theme::Theme::init(cx);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Bounds, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, MouseDownEvent,
|
||||
MouseMoveEvent, ParentElement, Pixels, Refineable as _, RenderOnce, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement as _, Styled, Window, canvas, div, fill, point,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
/// Which directions the content scrolls in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ScrollAxes {
|
||||
/// Vertical only; the default.
|
||||
#[default]
|
||||
Vertical,
|
||||
/// Horizontal only.
|
||||
Horizontal,
|
||||
/// Both directions.
|
||||
Both,
|
||||
}
|
||||
|
||||
/// A scrollable container with an overlay scrollbar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ScrollArea {
|
||||
id: ElementId,
|
||||
handle: Option<ScrollHandle>,
|
||||
axes: ScrollAxes,
|
||||
a11y_label: Option<SharedString>,
|
||||
style: gpui::StyleRefinement,
|
||||
children: Vec<gpui::AnyElement>,
|
||||
}
|
||||
|
||||
impl ParentElement for ScrollArea {
|
||||
fn extend(&mut self, children: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.children.extend(children);
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ScrollArea {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollArea {
|
||||
/// Creates a vertically scrolling area.
|
||||
pub fn new<I>(id: I) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
handle: None,
|
||||
axes: ScrollAxes::Vertical,
|
||||
a11y_label: None,
|
||||
style: gpui::StyleRefinement::default(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets which directions the content scrolls in.
|
||||
pub fn axes(mut self, axes: ScrollAxes) -> Self {
|
||||
self.axes = axes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Scrolls with a handle held on the owning view.
|
||||
pub fn handle(mut self, handle: ScrollHandle) -> Self {
|
||||
self.handle = Some(handle);
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the accessibility label of the container.
|
||||
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.a11y_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure thumb geometry for an overlay scrollbar.
|
||||
pub(crate) fn thumb_geometry(
|
||||
viewport: Pixels,
|
||||
content: Pixels,
|
||||
offset: Pixels,
|
||||
track: Pixels,
|
||||
min_thumb: Pixels,
|
||||
) -> (Pixels, Pixels) {
|
||||
if content <= viewport || content <= Pixels::ZERO {
|
||||
return (track, Pixels::ZERO);
|
||||
}
|
||||
|
||||
let length = (viewport / content * track).max(min_thumb).min(track);
|
||||
let overflow = content - viewport;
|
||||
let progress = (-offset / overflow).clamp(0., 1.);
|
||||
let travel = track - length;
|
||||
(length, travel * progress)
|
||||
}
|
||||
|
||||
impl RenderOnce for ScrollArea {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let mut pane = div()
|
||||
.id(self.id.clone())
|
||||
.overflow_hidden()
|
||||
.when(self.axes != ScrollAxes::Horizontal, |area| {
|
||||
area.overflow_y_scroll()
|
||||
})
|
||||
.when(self.axes != ScrollAxes::Vertical, |area| {
|
||||
area.overflow_x_scroll()
|
||||
})
|
||||
.when_some(self.handle.clone(), |area, handle| {
|
||||
area.track_scroll(&handle)
|
||||
})
|
||||
.when_some(self.a11y_label, |area, label| area.aria_label(label))
|
||||
.on_scroll(|_, _, cx| {
|
||||
// Keep wheel events from scrolling an outer scroll area too.
|
||||
cx.stop_propagation();
|
||||
});
|
||||
|
||||
pane.style().refine(&self.style);
|
||||
pane.extend(self.children);
|
||||
|
||||
let mut wrapper = div().relative();
|
||||
wrapper.extend(std::iter::once(pane.into_any_element()));
|
||||
|
||||
if let Some(handle) = self.handle {
|
||||
wrapper.extend(std::iter::once(
|
||||
canvas(|_, _, _| {}, {
|
||||
let handle = handle.clone();
|
||||
move |_, _, window, cx| {
|
||||
paint_scrollbar(&handle, window, cx);
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.into_any_element(),
|
||||
));
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the overlay scrollbar thumb.
|
||||
fn paint_scrollbar(handle: &ScrollHandle, window: &mut Window, cx: &mut App) {
|
||||
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 max_offset = handle.max_offset().y;
|
||||
let bounds = handle.bounds();
|
||||
if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
let track = bounds.size.height - inset * 2.0;
|
||||
let (thumb_length, thumb_top) = thumb_geometry(
|
||||
bounds.size.height,
|
||||
bounds.size.height + max_offset,
|
||||
handle.offset().y,
|
||||
track,
|
||||
min_thumb,
|
||||
);
|
||||
|
||||
let thumb_x = bounds.right() - width - inset;
|
||||
let thumb_bounds = Bounds::from_corners(
|
||||
point(thumb_x, bounds.top() + inset + thumb_top),
|
||||
point(
|
||||
thumb_x + width,
|
||||
bounds.top() + inset + thumb_top + thumb_length,
|
||||
),
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let handle = handle.clone();
|
||||
let drag = drag.clone();
|
||||
move |event: &MouseDownEvent, phase, _window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| event.button != gpui::MouseButton::Left
|
||||
|| !thumb_bounds.contains(&event.position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
drag.update(cx, |drag, _| {
|
||||
*drag = Some((event.position.y, handle.offset().y));
|
||||
});
|
||||
cx.stop_propagation();
|
||||
}
|
||||
});
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((start_y, start_offset)) = *drag.read(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if max_offset > Pixels::ZERO && track > thumb_length {
|
||||
let scale = max_offset / (track - thumb_length);
|
||||
let offset = (start_offset - (event.position.y - start_y) * scale)
|
||||
.clamp(-max_offset, Pixels::ZERO);
|
||||
handle.set_offset(point(handle.offset().x, offset));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use gpui::{
|
||||
Context, IntoElement, Modifiers, ParentElement as _, Render, ScrollDelta, ScrollEvent,
|
||||
ScrollHandle, SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||
px,
|
||||
};
|
||||
use gpui_native_core::scroll_area::ScrollArea;
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
struct Harness {
|
||||
scroll: ScrollHandle,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl Render for Harness {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(
|
||||
ScrollArea::new("test-scroll")
|
||||
.handle(self.scroll.clone())
|
||||
.h(px(200.))
|
||||
.w(px(300.))
|
||||
.child(div().flex().flex_col().children((0..self.rows).map(|row| {
|
||||
div()
|
||||
.h(px(20.))
|
||||
.child(SharedString::from(format!("row {row}")))
|
||||
}))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 300×200 scroll area over `rows` 20px rows, with the handle the test
|
||||
/// can inspect directly.
|
||||
fn scroll_area_window(rows: usize) -> (TestAppContext, VisualTestContext, ScrollHandle) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let handle = ScrollHandle::new();
|
||||
let handle_for_view = handle.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
scroll: handle_for_view,
|
||||
rows,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_scrolling_moves_the_offset_and_stays_inside_the_content() {
|
||||
let (_cx, mut visual, handle) = scroll_area_window(30);
|
||||
|
||||
// 30 rows × 20px = 600px of content in a 200px viewport.
|
||||
visual.simulate_event(ScrollEvent {
|
||||
position: point(px(100.), px(100.)),
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-60.))),
|
||||
modifiers: Modifiers::default(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let offset = handle.offset().y;
|
||||
assert_eq!(offset, px(-60.));
|
||||
|
||||
// Wheeling far past the end clamps to the content.
|
||||
for _ in 0..10 {
|
||||
visual.simulate_event(ScrollEvent {
|
||||
position: point(px(100.), px(100.)),
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-60.))),
|
||||
modifiers: Modifiers::default(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// max_offset = 600 - 200 = 400.
|
||||
assert_eq!(handle.offset().y, px(-400.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_starts_unscrolled_and_horizontal_axes_scroll_x() {
|
||||
let (_cx, mut visual, handle) = scroll_area_window(30);
|
||||
assert_eq!(handle.offset(), gpui::point(px(0.), px(0.)));
|
||||
|
||||
// A vertical-only area ignores horizontal wheel deltas.
|
||||
visual.simulate_event(ScrollEvent {
|
||||
position: point(px(100.), px(100.)),
|
||||
delta: ScrollDelta::Pixels(point(px(-60.), px(0.))),
|
||||
modifiers: Modifiers::default(),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(handle.offset().x, px(0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumb_paints_when_the_content_overflows() {
|
||||
let (_cx, _visual, handle) = scroll_area_window(30);
|
||||
// 30 rows × 20px in a 200px viewport: the thumb has something to report.
|
||||
assert!(handle.max_offset().y > px(0.));
|
||||
}
|
||||
@@ -33,8 +33,10 @@ pub struct ColorTokens {
|
||||
pub input: Hsla,
|
||||
pub ring: Hsla,
|
||||
pub selection: Hsla,
|
||||
/// The scrim behind modal layers; translucent in every theme.
|
||||
/// The scrim behind modal layers, translucent in every theme.
|
||||
pub overlay: Hsla,
|
||||
/// The overlay scrollbar thumb, translucent over scrolling content.
|
||||
pub scrollbar_thumb: Hsla,
|
||||
}
|
||||
|
||||
impl ColorTokens {
|
||||
@@ -60,6 +62,8 @@ impl ColorTokens {
|
||||
ring: hex(0x93c5fd),
|
||||
selection: hex(0xdbeafe),
|
||||
overlay: scrim(0.25),
|
||||
// macOS light overlay thumbs are translucent black.
|
||||
scrollbar_thumb: hsla(0., 0., 0., 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +89,8 @@ impl ColorTokens {
|
||||
ring: hex(0x1d4ed8),
|
||||
selection: hex(0x1e3a8a),
|
||||
overlay: scrim(0.45),
|
||||
// macOS dark overlay thumbs are translucent light gray.
|
||||
scrollbar_thumb: hsla(0., 0., 1., 0.3),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct ThemeDefinition {
|
||||
pub ring: Option<String>,
|
||||
pub selection: Option<String>,
|
||||
pub overlay: Option<String>,
|
||||
pub scrollbar_thumb: Option<String>,
|
||||
}
|
||||
|
||||
impl ThemeDefinition {
|
||||
@@ -112,6 +113,10 @@ impl ThemeDefinition {
|
||||
pub fn overlay(&self) -> Option<Hsla> {
|
||||
parse_hex(self.overlay.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn scrollbar_thumb(&self) -> Option<Hsla> {
|
||||
parse_hex(self.scrollbar_thumb.as_deref()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `#rgb` or `#rrggbb` into an opaque color.
|
||||
|
||||
+27
-4
@@ -51,6 +51,8 @@ pub struct Theme {
|
||||
pub elevations: Elevations,
|
||||
/// Overlay transition timing; gpui's animation primitives do the rest.
|
||||
pub motion: Motion,
|
||||
/// Overlay scrollbar geometry.
|
||||
pub scrollbar: Scrollbar,
|
||||
/// The platform's native root font size; rem-based sizing scales with it.
|
||||
pub root_font_size: Pixels,
|
||||
pub sizes: SizeTable,
|
||||
@@ -82,8 +84,8 @@ impl Default for Radius {
|
||||
}
|
||||
}
|
||||
|
||||
/// One drop shadow of a floating layer, transcribed from the platform's
|
||||
/// native overlay shadows.
|
||||
/// One drop shadow of a floating layer,
|
||||
/// transcribed from the platform's native overlay shadows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Elevation {
|
||||
pub offset_y: Pixels,
|
||||
@@ -149,6 +151,27 @@ impl Default for Motion {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay scrollbar geometry, transcribed from the platform's overlay crollbars.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Scrollbar {
|
||||
/// The painted thumb's thickness.
|
||||
pub width: Pixels,
|
||||
/// Edge padding between the thumb and the viewport it floats over.
|
||||
pub inset: Pixels,
|
||||
/// The thumb never shrinks below this length, so it stays grabbable.
|
||||
pub min_thumb: Pixels,
|
||||
}
|
||||
|
||||
impl Default for Scrollbar {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: px(8.),
|
||||
inset: px(2.),
|
||||
min_thumb: px(24.),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named control size class.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Size {
|
||||
@@ -176,8 +199,7 @@ pub struct ControlMetrics {
|
||||
pub avatar_size: Pixels,
|
||||
}
|
||||
|
||||
/// The control metrics for every size class, derived from the theme's root
|
||||
/// font size so controls scale with the platform's native type.
|
||||
/// The control metrics for every size class.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SizeTable {
|
||||
pub small: ControlMetrics,
|
||||
@@ -246,6 +268,7 @@ impl Theme {
|
||||
radius: resolved.radius,
|
||||
elevations: resolved.elevations,
|
||||
motion: resolved.motion,
|
||||
scrollbar: resolved.scrollbar,
|
||||
root_font_size: resolved.root_font_size,
|
||||
sizes: resolved.sizes,
|
||||
accessibility: resolved.accessibility,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::definition::ThemeDefinition;
|
||||
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
|
||||
use crate::system::SystemTheme;
|
||||
use crate::{ColorTokens, Elevations, Motion, Radius, SizeTable, ThemeVariant};
|
||||
use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant};
|
||||
use gpui::px;
|
||||
|
||||
/// Derived state shades for the colors that get pressed and hovered.
|
||||
@@ -21,6 +21,7 @@ pub struct ResolvedTheme {
|
||||
pub radius: Radius,
|
||||
pub elevations: Elevations,
|
||||
pub motion: Motion,
|
||||
pub scrollbar: Scrollbar,
|
||||
/// The platform's native root font size; the fallback theme uses 16.
|
||||
pub root_font_size: gpui::Pixels,
|
||||
pub sizes: SizeTable,
|
||||
@@ -130,6 +131,11 @@ pub(crate) fn resolve(
|
||||
system_palette.overlay,
|
||||
base.overlay,
|
||||
),
|
||||
scrollbar_thumb: pick(
|
||||
def_color(|d| d.scrollbar_thumb()),
|
||||
system_palette.scrollbar_thumb,
|
||||
base.scrollbar_thumb,
|
||||
),
|
||||
};
|
||||
|
||||
let accessibility = system.accessibility;
|
||||
@@ -181,6 +187,7 @@ pub(crate) fn resolve(
|
||||
radius,
|
||||
elevations: Elevations::default(),
|
||||
motion: Motion::default(),
|
||||
scrollbar: Scrollbar::default(),
|
||||
root_font_size,
|
||||
sizes,
|
||||
accessibility,
|
||||
|
||||
@@ -86,6 +86,7 @@ impl MacosSource {
|
||||
ring: Self::color_to_hsla(&NSColor::keyboardFocusIndicatorColor()),
|
||||
selection: Self::color_to_hsla(&NSColor::selectedTextBackgroundColor()),
|
||||
overlay: None,
|
||||
scrollbar_thumb: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct PartialPalette {
|
||||
pub selection: Option<Hsla>,
|
||||
/// No platform states a scrim color; preset and user files override it.
|
||||
pub overlay: Option<Hsla>,
|
||||
/// No platform states a scrollbar thumb color; presets override it.
|
||||
pub scrollbar_thumb: Option<Hsla>,
|
||||
}
|
||||
|
||||
/// A normalized snapshot of the running system's theme.
|
||||
|
||||
Reference in New Issue
Block a user