From cc22baca6e11dd6cd788553ad595190c9ed918e6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 19:23:06 +0700 Subject: [PATCH] add scrollarea --- crates/core/Cargo.toml | 3 +- crates/core/src/lib.rs | 3 + crates/core/src/scroll_area.rs | 223 +++++++++++++++++++++++++++++++ crates/core/tests/scroll_area.rs | 96 +++++++++++++ crates/theme/src/colors.rs | 8 +- crates/theme/src/definition.rs | 5 + crates/theme/src/lib.rs | 31 ++++- crates/theme/src/resolve.rs | 9 +- crates/theme/src/source/macos.rs | 1 + crates/theme/src/system.rs | 2 + docs/PLAN.md | 2 + examples/gallery/src/main.rs | 15 ++- 12 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 crates/core/src/scroll_area.rs create mode 100644 crates/core/tests/scroll_area.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index c045562..7c73c9f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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 = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 87fa5bb..1ccefd6 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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); diff --git a/crates/core/src/scroll_area.rs b/crates/core/src/scroll_area.rs new file mode 100644 index 0000000..2d7894f --- /dev/null +++ b/crates/core/src/scroll_area.rs @@ -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, + axes: ScrollAxes, + a11y_label: Option, + style: gpui::StyleRefinement, + children: Vec, +} + +impl ParentElement for ScrollArea { + fn extend(&mut self, children: impl IntoIterator) { + 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(id: I) -> Self + where + I: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + 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)); + } + } + }); +} diff --git a/crates/core/tests/scroll_area.rs b/crates/core/tests/scroll_area.rs new file mode 100644 index 0000000..6eb8d3b --- /dev/null +++ b/crates/core/tests/scroll_area.rs @@ -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) -> 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.)); +} diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index cb4e592..af186cf 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -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), } } } diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs index 9471d45..bddb5f3 100644 --- a/crates/theme/src/definition.rs +++ b/crates/theme/src/definition.rs @@ -30,6 +30,7 @@ pub struct ThemeDefinition { pub ring: Option, pub selection: Option, pub overlay: Option, + pub scrollbar_thumb: Option, } impl ThemeDefinition { @@ -112,6 +113,10 @@ impl ThemeDefinition { pub fn overlay(&self) -> Option { parse_hex(self.overlay.as_deref()?) } + + pub fn scrollbar_thumb(&self) -> Option { + parse_hex(self.scrollbar_thumb.as_deref()?) + } } /// Parses `#rgb` or `#rrggbb` into an opaque color. diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index e062eef..05b9de0 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -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, diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 3a07090..5bb31e4 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -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, diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index 2c670b5..e3ac797 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -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, } } } diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index df9aaa1..54dd85c 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -49,6 +49,8 @@ pub struct PartialPalette { pub selection: Option, /// No platform states a scrim color; preset and user files override it. pub overlay: Option, + /// No platform states a scrollbar thumb color; presets override it. + pub scrollbar_thumb: Option, } /// A normalized snapshot of the running system's theme. diff --git a/docs/PLAN.md b/docs/PLAN.md index b7b80a6..6c1337b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -151,6 +151,7 @@ Omarchy `colors.toml` maps almost 1:1: `lighter_background` → `surface`, `dark The set stays role-minimal and grows one concept at a time, driven by the unit that needs it. Known planned additions: - ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. +- ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). - **`success` / `warning` roles** — with the first severity-bearing component (Toast, badges, validation); the Omarchy mapping above already anticipates them. - **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion. @@ -208,6 +209,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 8 | core | Progress | Determinate + indeterminate. | | 9 | core | Avatar | Initials; image slot later. | | 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. Single-line finishing pass: §6.2. | +| 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | | 11+ | core | Tooltip, Menu, Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. Menu reuses the popover shell + `Radius.overlay`/`Elevations.popover`; Dialog consumes `colors.overlay` (scrim) + `Elevations.dialog` | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index c8eaa88..29be9a2 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -13,6 +13,7 @@ use gpui_native_core::link::Link; 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; use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; use gpui_native_core::toggle::Toggle; @@ -29,6 +30,7 @@ struct Gallery { volume: f32, input_state: Entity, popover_open: bool, + scroll: gpui::ScrollHandle, } impl Render for Gallery { @@ -98,8 +100,7 @@ impl Render for Gallery { ("destructive", ButtonVariant::Destructive), ]; - div() - .size_full() + let content = div() .font_family(".SystemUIFont") .bg(colors.background) .text_color(colors.foreground) @@ -582,7 +583,14 @@ impl Render for Gallery { .map(|role| icon(*role).size(20.0).color(colors.foreground)), ), ), - ) + ); + + // The whole gallery scrolls, demonstrating the scroll area at the + // page level: the thumb floats over the window's content. + ScrollArea::new("gallery-scroll") + .handle(self.scroll.clone()) + .size_full() + .child(content) } } @@ -671,6 +679,7 @@ fn main() { volume: 40.0, input_state, popover_open: false, + scroll: gpui::ScrollHandle::new(), }) }, ) {