diff --git a/.changeset/scrollbars.md b/.changeset/scrollbars.md
new file mode 100644
index 00000000..9bd86225
--- /dev/null
+++ b/.changeset/scrollbars.md
@@ -0,0 +1,34 @@
+---
+"@gpuix/native": minor
+"@gpuix/react": minor
+---
+
+Paint scrollbars on scroll boxes, and add `scrollbar-width`, `scrollbar-color` and `scrollbar-gutter`.
+
+A box with `overflow: scroll` or `overflow: auto` now gets a scrollbar
+on each axis it scrolls. The OS picks the kind of bar, as a browser does.
+When the OS auto-hides scrollbars, an overlay bar floats over the content,
+shows for a second after a scroll and fades out, and reserves no space.
+Otherwise a classic bar sits in a 15px gutter that the layout reserves.
+`overflow: scroll` keeps the classic bar at all times and `auto` shows it
+only while the content overflows. A drag of the thumb scrolls, a click in
+the track moves one page, and the thumb widens under the mouse.
+`scrollbar-width: thin` narrows the bar and `none` removes it.
+`scrollbar-color` sets the thumb and the track. `scrollbar-gutter: stable`
+reserves the gutter of a classic bar even while the content fits, and
+`stable both-edges` reserves one at the start of the axis too.
+`overflow: auto` used to do nothing and `clip` now clips like `hidden`.
+`GPUIX_SCROLLBARS=overlay` or `classic` in the environment overrides the
+OS choice, for tests.
+
+A bar paints after the whole frame, above any effect a sibling of the
+content paints, so a blurred sticky header does not cover it. When one
+axis of `overflow` computes to `visible` or `clip` and the other axis
+scrolls, the first becomes `auto` or `hidden`, as in CSS.
+
+`scrollIntoView(elementId, block, inline)` on the renderer scrolls every
+scroll box around an element until the element shows. `block` and
+`inline` take `start`, `center`, `end` or `nearest`, with the web
+defaults. `scroll-margin` on the target keeps space around it, and
+`scroll-padding` on a scroll box keeps space inside the box, each as one
+value or as the one-to-four shorthand.
diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx
index 4d7cf8de..60a97b98 100644
--- a/examples/demo.test.tsx
+++ b/examples/demo.test.tsx
@@ -23,6 +23,7 @@ import { Inheritance } from "./demo/inheritance"
import { Lengths } from "./demo/lengths"
import { motion } from "@gpuix/react"
import { Motion } from "./demo/motion-panel"
+import { IntoView, Scrollbars } from "./demo/scrollbars"
import { Selectors } from "./demo/selectors"
import { Variables } from "./demo/variables"
import { resolveClassName } from "./demo/classes"
@@ -49,6 +50,7 @@ const PANELS = [
["classes", ],
["selectors", ],
["motion", ],
+ ["scrollbars", ],
] as const
describeNative("demo panels", () => {
@@ -285,6 +287,41 @@ describeNative("height: auto", () => {
})
})
+describeNative("the scrollbars panel", () => {
+ it("scrollIntoView honours scroll-padding and scroll-margin", () => {
+ const test = root()
+ test.render(
+
+
+
+ )
+ const box = test.renderer.findByTestId("into-view-box")!
+ expect(test.renderer.getScrollOffset(box.id)![1]).toBe(0)
+
+ const start = test.renderer.findByText("start")!
+ const [x, y] = test.renderer.getElementBounds(start.id)!
+ test.renderer.nativeSimulateClick(x + 4, y + 4)
+
+ expect(test.renderer.getScrollOffset(box.id)![1]).toBeLessThan(0)
+ const [, boxY] = test.renderer.getElementBounds(box.id)!
+ const row = test.renderer.findByTestId("into-view-target")!
+ const [, rowY] = test.renderer.getElementBounds(row.id)!
+ // 12px of scroll-padding plus 16px of scroll-margin, inside the border.
+ expect(rowY - boxY).toBeGreaterThanOrEqual(28)
+ expect(rowY - boxY).toBeLessThanOrEqual(30)
+ test.unmount()
+ })
+})
+
describeNative("the whole application", () => {
/// Walk the sidebar and paint each section, so the whole application is
/// covered rather than the one it opens on. The test renderer has the frame
@@ -294,7 +331,7 @@ describeNative("the whole application", () => {
test.render( )
expect(test.renderer.getPaintedText()).toContain("GPUIX")
- for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Performance", "Colours"]) {
+ for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Performance", "Colours"]) {
const item = test.renderer.findByText(title)
expect(item, `no sidebar item named ${title}`).toBeDefined()
const bounds = test.renderer.getElementBounds(item!.id)
diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx
index 5ae0e36e..d5f7a2e2 100644
--- a/examples/demo/app.tsx
+++ b/examples/demo/app.tsx
@@ -17,6 +17,7 @@ import { Inheritance } from "./inheritance.js"
import { Lengths } from "./lengths.js"
import { Motion } from "./motion-panel.js"
import { frameOverlay, Perf } from "./perf.js"
+import { Scrollbars } from "./scrollbars.js"
import { Selectors } from "./selectors.js"
import { Variables } from "./variables.js"
@@ -78,6 +79,7 @@ const SECTIONS = [
{ id: "classes", title: "className", render: () => },
{ id: "selectors", title: "Selectors", render: () => },
{ id: "motion", title: "Motion", render: () => },
+ { id: "scrollbars", title: "Scrollbars", render: () => },
] as const
type SectionId = (typeof SECTIONS)[number]["id"] | "perf"
diff --git a/examples/demo/scrollbars.tsx b/examples/demo/scrollbars.tsx
new file mode 100644
index 00000000..2cb11f9c
--- /dev/null
+++ b/examples/demo/scrollbars.tsx
@@ -0,0 +1,195 @@
+/// Scroll boxes, the bars they paint, and scrollIntoView.
+///
+/// The OS picks the kind of bar. An overlay bar floats over the content and
+/// fades out after a scroll. A classic bar keeps a track and reserves a
+/// gutter in the layout. Every box here also scrolls with the wheel, with a
+/// drag on the thumb, and with a click in the track, which moves one page.
+
+import React, { useRef } from "react"
+import { useGpuix } from "@gpuix/react"
+import type { StyleDesc } from "@gpuix/react"
+import { Button, Grid, Panel, Row, Sample } from "./ui.js"
+
+/// Rows tall enough to overflow the box, so a bar shows.
+function Rows({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ )
+}
+
+function ScrollBox({ style, count = 14 }: { style: StyleDesc; count?: number }) {
+ return (
+
+
+
+ )
+}
+
+function Bars() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/// The content fits, so only the reserved gutter tells the boxes apart.
+/// The full-width band paints the content area, and the gutter is the strip
+/// the band does not cover.
+function Gutters() {
+ const band: StyleDesc = {
+ height: 100,
+ margin: 8,
+ borderRadius: 6,
+ backgroundColor: "var(--color-track)",
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function BothAxes() {
+ return (
+
+
+
+ 900 x 400 of content in a smaller box.
+
+
+
+ )
+}
+
+export function IntoView() {
+ const { renderer } = useGpuix()
+ const target = useRef<{ id: number } | null>(null)
+ const show = (block: string) => {
+ if (renderer && target.current) {
+ renderer.scrollIntoView?.(target.current.id, block)
+ }
+ }
+ return (
+
+
+ show("start")} />
+ show("center")} />
+ show("end")} />
+ show("nearest")} />
+
+
+
+ {Array.from({ length: 24 }, (_, i) => {
+ const isTarget = i === 9
+ return (
+
+
+ {isTarget ? "row 10, the target" : `row ${i + 1}`}
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+export function Scrollbars() {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts
index 66ad979b..5cf312e4 100644
--- a/packages/native/index.d.ts
+++ b/packages/native/index.d.ts
@@ -66,6 +66,15 @@ export declare class GpuixRenderer {
getWindowInsets(): WindowInsets
/** `"hidden"` | `"minimal"` | `"full"`. Paints into the scene after layout. */
setDebugFrameOverlay(mode: string): string
+ /**
+ * Scroll every ancestor scroll box so the element shows, like the
+ * web `scrollIntoView`. `block` places it on the y axis and
+ * `inline` on the x axis: `start`, `center`, `end` or `nearest`.
+ * The defaults match the web: `start` and `nearest`. The
+ * `scroll-margin` of the element and the `scroll-padding` of each
+ * box apply.
+ */
+ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
/** Hidden → minimal → full → hidden. */
cycleDebugFrameOverlay(): string
getDebugFrameOverlay(): string
@@ -323,6 +332,11 @@ export declare class TestGpuixRenderer {
* Call flush() after to apply the offset and re-render.
*/
scrollTo(elementId: number, x: number, y: number): void
+ /**
+ * Scroll every ancestor scroll box so the element shows, like the
+ * web scrollIntoView. Call flush() after to apply and re-render.
+ */
+ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
/**
* Scroll a child into view by its index in the children list.
* Call flush() after to apply and re-render. For a `` the
diff --git a/packages/native/src/automation.rs b/packages/native/src/automation.rs
index 8a197650..be68169e 100644
--- a/packages/native/src/automation.rs
+++ b/packages/native/src/automation.rs
@@ -84,11 +84,26 @@ pub fn all_bounds() -> HashMap {
BOUNDS.with(|cell| cell.borrow().clone())
}
-pub fn bounds_tracker(id: u64, selection_start: Option) -> impl IntoElement {
+pub fn bounds_tracker(
+ id: u64,
+ selection_start: Option,
+ scroll: Option,
+) -> impl IntoElement {
canvas(
|bounds, _, _| bounds,
move |bounds, _, _, _| {
- record_bounds(id, bounds);
+ // A scroll box paints its children moved by its own offset, and
+ // this tracker is one of them. Take the offset back out, so the
+ // recorded rectangle is the box in the window, not the box in
+ // its own content. The selection region stays at the painted
+ // place, because a selection starts from the glyphs on screen.
+ let mut recorded = bounds;
+ if let Some(handle) = &scroll {
+ let offset = handle.offset();
+ recorded.origin.x -= offset.x;
+ recorded.origin.y -= offset.y;
+ }
+ record_bounds(id, recorded);
if let Some(selectable) = selection_start {
crate::text::record_start_region(bounds, selectable);
}
diff --git a/packages/native/src/custom_elements/input.rs b/packages/native/src/custom_elements/input.rs
index 34f90d7f..437fc570 100644
--- a/packages/native/src/custom_elements/input.rs
+++ b/packages/native/src/custom_elements/input.rs
@@ -367,7 +367,7 @@ impl CustomElement for TextEditorElement {
// `Some(false)` also claims the same box as a non-selectable
// selection-start region: a drag inside an editor must move the caret,
// not start a document selection.
- editor = editor.child(crate::automation::bounds_tracker(ctx.id, Some(false)));
+ editor = editor.child(crate::automation::bounds_tracker(ctx.id, Some(false), None));
if ctx.events.contains("click") {
let callback = ctx.event_callback.clone();
let id = ctx.id;
diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs
index e3c25390..d02c491d 100644
--- a/packages/native/src/custom_elements/mod.rs
+++ b/packages/native/src/custom_elements/mod.rs
@@ -137,7 +137,7 @@ pub(crate) fn custom_surface(
{
el = el.relative();
}
- el = el.child(crate::automation::bounds_tracker(ctx.id, None));
+ el = el.child(crate::automation::bounds_tracker(ctx.id, None, None));
wire_standard_events(el, ctx)
}
diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs
index 97af0b4b..7d32ba4f 100644
--- a/packages/native/src/renderer.rs
+++ b/packages/native/src/renderer.rs
@@ -48,6 +48,8 @@ gpui::actions!(gpuix_focus, [FocusNext, FocusPrevious]);
mod auto_height;
mod batch;
mod frame;
+pub(crate) mod scroll_into_view;
+pub(crate) mod scrollbar;
mod virtual_list;
pub use batch::apply_batch_to_tree;
@@ -93,6 +95,17 @@ fn raw_element_id(id: f64) -> std::result::Result {
Ok(id as u64)
}
+/// A scroll offset as the two JS numbers `[x, y]`. Adding `0.0` turns a
+/// negative zero into a plain zero. An unscrolled axis can carry `-0.0`,
+/// and a JS caller that compares offsets with `Object.is` separates `-0`
+/// from `0`.
+pub(crate) fn offset_to_js(offset: gpui::Point) -> [f64; 2] {
+ [
+ f64::from(f32::from(offset.x)) + 0.0,
+ f64::from(f32::from(offset.y)) + 0.0,
+ ]
+}
+
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
pub(crate) fn to_element_id(id: f64) -> Result {
raw_element_id(id).map_err(Error::from_reason)
@@ -328,6 +341,11 @@ enum UiCommand {
index: usize,
offset: f32,
},
+ ScrollIntoView {
+ id: u64,
+ block: scroll_into_view::Align,
+ inline: scroll_into_view::Align,
+ },
GetScrollOffset {
id: u64,
response: SyncSender>,
@@ -465,25 +483,30 @@ async fn run_ui_commands(
}
refresh_ui_window(window, cx)
}
+ UiCommand::ScrollIntoView { id, block, inline } => {
+ window
+ .update(cx, |view, _window, _cx| {
+ let tree = view.tree.lock().unwrap();
+ scroll_into_view::scroll_into_view(&tree, id, block, inline, |id| {
+ SCROLL_HANDLES.with(|cell| cell.borrow().get(&id).cloned())
+ });
+ })
+ .ok();
+ refresh_ui_window(window, cx)
+ }
UiCommand::GetScrollOffset { id, response } => {
let offset = VIRTUAL_LIST_STATES
.with(|cell| {
cell.borrow().get(&id).map(|state| {
let offset = state.scroll_px_offset_for_scrollbar();
- [
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ]
+ offset_to_js(offset)
})
})
.or_else(|| {
SCROLL_HANDLES.with(|cell| {
cell.borrow().get(&id).map(|handle| {
let offset = handle.offset();
- [
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ]
+ offset_to_js(offset)
})
})
});
@@ -1291,6 +1314,45 @@ impl GpuixRenderer {
Err(Error::from_reason("Unsupported operating system"))
}
+ /// Scroll every ancestor scroll box so the element shows, like the
+ /// web `scrollIntoView`. `block` places it on the y axis and
+ /// `inline` on the x axis: `start`, `center`, `end` or `nearest`.
+ /// The defaults match the web: `start` and `nearest`. The
+ /// `scroll-margin` of the element and the `scroll-padding` of each
+ /// box apply.
+ #[napi]
+ pub fn scroll_into_view(
+ &self,
+ element_id: f64,
+ block: Option,
+ inline: Option,
+ ) -> Result<()> {
+ let id = to_element_id(element_id)?;
+ let block = scroll_into_view::Align::parse(block.as_deref(), scroll_into_view::Align::Start);
+ let inline =
+ scroll_into_view::Align::parse(inline.as_deref(), scroll_into_view::Align::Nearest);
+ #[cfg(target_os = "macos")]
+ {
+ let tree = self.tree.lock().unwrap();
+ scroll_into_view::scroll_into_view(&tree, id, block, inline, |id| {
+ SCROLL_HANDLES.with(|cell| cell.borrow().get(&id).cloned())
+ });
+ drop(tree);
+ return invalidate_window();
+ }
+
+ #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))]
+ return self.send_ui_command(UiCommand::ScrollIntoView { id, block, inline });
+
+ #[cfg(not(any(
+ target_os = "macos",
+ target_os = "windows",
+ target_os = "linux",
+ target_os = "freebsd"
+ )))]
+ Err(Error::from_reason("Unsupported operating system"))
+ }
+
/// Hidden → minimal → full → hidden.
#[napi]
pub fn cycle_debug_frame_overlay(&self) -> Result {
@@ -1640,10 +1702,7 @@ impl GpuixRenderer {
.with(|cell| {
cell.borrow().get(&id).map(|state| {
let offset = state.scroll_px_offset_for_scrollbar();
- vec![
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ]
+ offset_to_js(offset).to_vec()
})
})
.or_else(|| {
@@ -1651,10 +1710,7 @@ impl GpuixRenderer {
let handles = cell.borrow();
handles.get(&id).map(|handle| {
let offset = handle.offset();
- vec![
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ]
+ offset_to_js(offset).to_vec()
})
})
}));
@@ -2826,6 +2882,8 @@ pub(crate) struct GpuixView {
pub(crate) scroll_handles: HashMap,
/// Native animation clocks keyed by retained element ID.
pub(crate) motion_states: HashMap,
+ /// What each scroll box's scrollbar remembers between frames.
+ pub(crate) scrollbars: scrollbar::States,
/// Live text selection, shared with the paint closures and the napi methods.
pub(crate) selection: SharedSelection,
/// Persistent measurement and scroll state for React-backed virtual lists.
@@ -2970,6 +3028,7 @@ impl GpuixView {
custom_registry: CustomElementRegistry::with_defaults(),
scroll_handles: HashMap::new(),
motion_states: HashMap::new(),
+ scrollbars: HashMap::new(),
selection,
virtual_lists: HashMap::new(),
clock: crate::automation::AutomationClock::new(),
@@ -3080,6 +3139,7 @@ impl GpuixView {
custom_registry: &mut self.custom_registry,
virtual_lists: &mut self.virtual_lists,
motion_states: &mut self.motion_states,
+ scrollbars: &mut self.scrollbars,
now,
motion_active: &mut motion_active,
selection: self.selection.clone(),
@@ -3160,10 +3220,7 @@ impl GpuixView {
.get(&id)?
.state
.scroll_px_offset_for_scrollbar();
- Some([
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ])
+ Some(offset_to_js(offset))
}
pub(crate) fn reveal_virtual_list_ancestor(&self, id: u64) -> bool {
@@ -3343,6 +3400,7 @@ impl gpui::Render for GpuixView {
custom_registry: &mut self.custom_registry,
virtual_lists: &mut self.virtual_lists,
motion_states: &mut self.motion_states,
+ scrollbars: &mut self.scrollbars,
now,
motion_active: &mut motion_active,
selection: self.selection.clone(),
diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs
index f12b351b..1903034d 100644
--- a/packages/native/src/renderer/frame.rs
+++ b/packages/native/src/renderer/frame.rs
@@ -27,6 +27,7 @@ pub(super) struct BuildCtx<'a> {
pub custom_registry: &'a mut CustomElementRegistry,
pub virtual_lists: &'a mut HashMap,
pub motion_states: &'a mut HashMap,
+ pub scrollbars: &'a mut super::scrollbar::States,
pub now: std::time::Instant,
pub motion_active: &'a mut bool,
pub selection: SharedSelection,
@@ -492,13 +493,15 @@ pub(crate) fn build_host_container(
// wide child fills the parent instead of overflowing. Zed's code-block path:
// flex + min_w_0 on the scroller, flex_none on the child.
let mut overflow_x_only = false;
+ let mut scrollbar = None;
if let Some(style) = style {
// Resolve each axis: axis-specific overrides shorthand.
let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref());
let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref());
+ let (resolved_x, resolved_y) = super::scrollbar::used_overflow(resolved_x, resolved_y);
- let needs_scroll_x = resolved_x == Some("scroll");
- let needs_scroll_y = resolved_y == Some("scroll");
+ let needs_scroll_x = super::scrollbar::scrolls(resolved_x);
+ let needs_scroll_y = super::scrollbar::scrolls(resolved_y);
if needs_scroll_x && needs_scroll_y {
el = el.overflow_scroll();
@@ -526,13 +529,42 @@ pub(crate) fn build_host_container(
.entry(element.id)
.or_insert_with(gpui::ScrollHandle::new);
el = el.track_scroll(handle);
+
+ // The scrollbar. Classic bars reserve a gutter in the layout,
+ // which taffy takes as one width for both axes.
+ let mode = super::scrollbar::Mode::current(cx);
+ if let Some(spec) = super::scrollbar::Spec::from_style(style, mode) {
+ let state = ctx.scrollbars.entry(element.id).or_default().clone();
+ let reserved = spec.reserved(state.borrow().overflowed);
+ let gutter = reserved.x.max(reserved.y);
+ if gutter > gpui::px(0.0) {
+ el = el.scrollbar_width(gutter);
+ if spec.both_edges() {
+ let padding = &mut el.style().padding;
+ if needs_scroll_y {
+ padding.left = Some(add_pixels(padding.left, gutter));
+ }
+ if needs_scroll_x {
+ padding.top = Some(add_pixels(padding.top, gutter));
+ }
+ }
+ }
+ scrollbar = Some(super::scrollbar::Scrollbar::new(
+ spec,
+ handle.clone(),
+ state,
+ ctx.now,
+ ));
+ }
} else {
// Element is no longer scrollable — remove stale handle.
ctx.scroll_handles.remove(&element.id);
+ ctx.scrollbars.remove(&element.id);
}
} else {
// No style at all — remove stale handle if it existed.
ctx.scroll_handles.remove(&element.id);
+ ctx.scrollbars.remove(&element.id);
}
// If a FocusHandle was pre-created for this element (by sync_focus_handles),
@@ -545,6 +577,7 @@ pub(crate) fn build_host_container(
el = el.child(crate::automation::bounds_tracker(
element.id,
selection_start_flag(style),
+ ctx.scroll_handles.get(&element.id).cloned(),
));
if let Some(handle) = ctx.focus_handles.get(&element.id) {
@@ -794,9 +827,25 @@ pub(crate) fn build_host_container(
}
pop_child_rules(saved_direct, pushed, ctx);
+ // Last, so it paints over the content and takes the mouse first.
+ if let Some(scrollbar) = scrollbar {
+ el = el.child(scrollbar);
+ }
+
el.into_any_element()
}
+/// `length` plus `extra`. A pixel length adds. Any other unit gives way,
+/// because the sum would need the box's size to resolve.
+fn add_pixels(length: Option, extra: gpui::Pixels) -> gpui::DefiniteLength {
+ match length {
+ Some(gpui::DefiniteLength::Absolute(gpui::AbsoluteLength::Pixels(pixels))) => {
+ (pixels + extra).into()
+ }
+ _ => extra.into(),
+ }
+}
+
/// Whether this element is the anonymous node the reconciler makes for a raw
/// string child. The web gives a text node no box of its own: `*` never
/// matches one, and `:nth-child` does not count one.
diff --git a/packages/native/src/renderer/scroll_into_view.rs b/packages/native/src/renderer/scroll_into_view.rs
new file mode 100644
index 00000000..e2d3f747
--- /dev/null
+++ b/packages/native/src/renderer/scroll_into_view.rs
@@ -0,0 +1,213 @@
+//! CSS `scrollIntoView` with `scroll-margin` and `scroll-padding`.
+//!
+//! Each ancestor scroll box scrolls in turn, nearest first, the way a
+//! browser walks the chain. The `scroll-margin` of the element grows
+//! the rectangle a box brings into view. The `scroll-padding` of the
+//! box shrinks the viewport the rectangle lands in. Both take a number
+//! of pixels or `"Npx"` text, alone or as the CSS one-to-four
+//! shorthand. The bounds come from the last painted frame.
+
+use gpui::{point, px, Point, ScrollHandle};
+
+use crate::retained_tree::RetainedTree;
+use crate::style::{Numeric, StyleDesc};
+
+/// Where the element lands in the viewport on one axis.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Align {
+ Start,
+ Center,
+ End,
+ Nearest,
+}
+
+impl Align {
+ /// The `scrollIntoView` words `start`, `center`, `end` and
+ /// `nearest`. An unknown word falls back to `default`.
+ pub(crate) fn parse(word: Option<&str>, default: Align) -> Align {
+ match word.map(str::trim) {
+ Some("start") => Align::Start,
+ Some("center") => Align::Center,
+ Some("end") => Align::End,
+ Some("nearest") => Align::Nearest,
+ _ => default,
+ }
+ }
+}
+
+/// An absolute rectangle, as two corners.
+#[derive(Clone, Copy)]
+struct Rect {
+ start: Point,
+ end: Point,
+}
+
+impl Rect {
+ fn from_bounds(bounds: crate::automation::ElementBounds) -> Self {
+ let start = point(bounds.x as f32, bounds.y as f32);
+ Self {
+ start,
+ end: point(
+ start.x + bounds.width as f32,
+ start.y + bounds.height as f32,
+ ),
+ }
+ }
+}
+
+/// One length: a number of pixels or `"Npx"`. Anything else is zero.
+fn length(value: &str) -> f32 {
+ let value = value.trim();
+ let value = value.strip_suffix("px").unwrap_or(value);
+ value.trim().parse().unwrap_or(0.0)
+}
+
+/// The CSS one-to-four shorthand, as top, right, bottom and left.
+fn shorthand(value: Option<&Numeric>) -> [f32; 4] {
+ let words: Vec = match value {
+ None => return [0.0; 4],
+ Some(Numeric::Number(number)) => return [*number as f32; 4],
+ Some(Numeric::Text(text)) => text.split_whitespace().map(length).collect(),
+ };
+ match words[..] {
+ [all] => [all; 4],
+ [vertical, horizontal] => [vertical, horizontal, vertical, horizontal],
+ [top, horizontal, bottom] => [top, horizontal, bottom, horizontal],
+ [top, right, bottom, left] => [top, right, bottom, left],
+ _ => [0.0; 4],
+ }
+}
+
+/// A per-side value over the shorthand.
+fn side(long: Option<&Numeric>, short: f32) -> f32 {
+ match long {
+ None => short,
+ Some(Numeric::Number(number)) => *number as f32,
+ Some(Numeric::Text(text)) => length(text),
+ }
+}
+
+/// The `scroll-margin` of the element, as top, right, bottom and left.
+fn scroll_margin(style: Option<&StyleDesc>) -> [f32; 4] {
+ let Some(style) = style else { return [0.0; 4] };
+ let base = shorthand(style.scroll_margin.as_ref());
+ [
+ side(style.scroll_margin_top.as_ref(), base[0]),
+ side(style.scroll_margin_right.as_ref(), base[1]),
+ side(style.scroll_margin_bottom.as_ref(), base[2]),
+ side(style.scroll_margin_left.as_ref(), base[3]),
+ ]
+}
+
+/// The `scroll-padding` of a scroll box, in the same order.
+fn scroll_padding(style: Option<&StyleDesc>) -> [f32; 4] {
+ let Some(style) = style else { return [0.0; 4] };
+ let base = shorthand(style.scroll_padding.as_ref());
+ [
+ side(style.scroll_padding_top.as_ref(), base[0]),
+ side(style.scroll_padding_right.as_ref(), base[1]),
+ side(style.scroll_padding_bottom.as_ref(), base[2]),
+ side(style.scroll_padding_left.as_ref(), base[3]),
+ ]
+}
+
+/// How far the content must move on one axis, in pixels.
+fn axis_delta(align: Align, start: f32, end: f32, port_start: f32, port_end: f32) -> f32 {
+ match align {
+ Align::Start => start - port_start,
+ Align::End => end - port_end,
+ Align::Center => (start + end) / 2.0 - (port_start + port_end) / 2.0,
+ Align::Nearest => {
+ if start >= port_start && end <= port_end {
+ 0.0
+ } else if (start - port_start).abs() <= (end - port_end).abs() {
+ start - port_start
+ } else {
+ end - port_end
+ }
+ }
+ }
+}
+
+/// Scroll every ancestor scroll box of `target` so the element shows.
+/// Returns true when an offset changed.
+pub(crate) fn scroll_into_view(
+ tree: &RetainedTree,
+ target: u64,
+ block: Align,
+ inline: Align,
+ handle_for: impl Fn(u64) -> Option,
+) -> bool {
+ let Some(bounds) = crate::automation::get_bounds(target) else {
+ return false;
+ };
+ let mut rect = Rect::from_bounds(bounds);
+ let style = |id: u64| tree.elements.get(&id).and_then(|el| el.style.as_deref());
+ let margin = scroll_margin(style(target));
+ rect.start.x -= margin[3];
+ rect.start.y -= margin[0];
+ rect.end.x += margin[1];
+ rect.end.y += margin[2];
+
+ let mut moved = false;
+ let mut current = tree.elements.get(&target).and_then(|el| el.parent);
+ while let Some(id) = current {
+ if let (Some(handle), Some(bounds)) = (handle_for(id), crate::automation::get_bounds(id)) {
+ let mut port = Rect::from_bounds(bounds);
+ let padding = scroll_padding(style(id));
+ port.start.x += padding[3];
+ port.start.y += padding[0];
+ port.end.x -= padding[1];
+ port.end.y -= padding[2];
+
+ let delta = point(
+ axis_delta(inline, rect.start.x, rect.end.x, port.start.x, port.end.x),
+ axis_delta(block, rect.start.y, rect.end.y, port.start.y, port.end.y),
+ );
+ let old = handle.offset();
+ let max = handle.max_offset();
+ let new = point(
+ (old.x - px(delta.x)).max(-max.x).min(px(0.0)),
+ (old.y - px(delta.y)).max(-max.y).min(px(0.0)),
+ );
+ if new != old {
+ handle.set_offset(new);
+ moved = true;
+ }
+ // The content of the box moves with the offset, and the
+ // rectangle of the element moves with the content.
+ rect.start.x += f32::from(new.x - old.x);
+ rect.end.x += f32::from(new.x - old.x);
+ rect.start.y += f32::from(new.y - old.y);
+ rect.end.y += f32::from(new.y - old.y);
+ }
+ current = tree.elements.get(&id).and_then(|el| el.parent);
+ }
+ moved
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_shorthand_expands_the_css_way() {
+ let sides = |text: &str| shorthand(Some(&Numeric::Text(text.to_string())));
+ assert_eq!(sides("8px"), [8.0; 4]);
+ assert_eq!(sides("8px 12px"), [8.0, 12.0, 8.0, 12.0]);
+ assert_eq!(sides("1px 2px 3px"), [1.0, 2.0, 3.0, 2.0]);
+ assert_eq!(sides("1px 2px 3px 4px"), [1.0, 2.0, 3.0, 4.0]);
+ assert_eq!(shorthand(Some(&Numeric::Number(6.0))), [6.0; 4]);
+ }
+
+ #[test]
+ fn nearest_moves_the_short_way_or_not_at_all() {
+ let near = |start, end| axis_delta(Align::Nearest, start, end, 100.0, 200.0);
+ assert_eq!(near(120.0, 180.0), 0.0);
+ assert_eq!(near(40.0, 80.0), -60.0);
+ assert_eq!(near(240.0, 280.0), 80.0);
+ assert_eq!(axis_delta(Align::Start, 40.0, 80.0, 100.0, 200.0), -60.0);
+ assert_eq!(axis_delta(Align::End, 240.0, 280.0, 100.0, 200.0), 80.0);
+ assert_eq!(axis_delta(Align::Center, 90.0, 110.0, 100.0, 200.0), -50.0);
+ }
+}
diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs
new file mode 100644
index 00000000..00dfdc84
--- /dev/null
+++ b/packages/native/src/renderer/scrollbar.rs
@@ -0,0 +1,855 @@
+//! Scrollbars for scroll boxes.
+//!
+//! GPUI clips and scrolls a box but paints no bar, so this element paints
+//! one. It sits last among the children of the box, takes no layout space,
+//! and reads the box's `ScrollHandle` for the viewport, the content size
+//! and the offset. The OS picks the kind of bar. Overlay bars float over
+//! the content and fade out after a scroll. Classic bars keep a track and
+//! reserve a gutter in the layout. `cx.should_auto_hide_scrollbars()`
+//! tells the two apart, and `GPUIX_SCROLLBARS=overlay|classic` in the
+//! environment overrides it, which keeps tests the same on every machine.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::rc::Rc;
+use std::time::{Duration, Instant};
+
+use gpui::{
+ hsla, point, px, size, Along, App, Axis, BorderStyle, Bounds, Corners, Edges, Element,
+ ElementId, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, MouseButton,
+ MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, Style, Window,
+};
+
+use crate::style::StyleDesc;
+
+/// Overlay bars keep the mouse this long after the last scroll.
+const HIDE_DELAY: Duration = Duration::from_secs(1);
+/// Then they fade over this long.
+const FADE: Duration = Duration::from_millis(400);
+/// A thumb never gets shorter than this.
+const MIN_THUMB: Pixels = px(20.0);
+
+/// Which kind of bar the OS asks for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Mode {
+ /// Floats over the content, fades out after a scroll, reserves nothing.
+ Overlay,
+ /// Always there, with a track, in a gutter reserved in the layout.
+ Classic,
+}
+
+impl Mode {
+ /// The mode for this window, with the environment override on top.
+ pub(crate) fn current(cx: &App) -> Self {
+ match std::env::var("GPUIX_SCROLLBARS").as_deref() {
+ Ok("overlay") => Mode::Overlay,
+ Ok("classic") => Mode::Classic,
+ _ if cx.should_auto_hide_scrollbars() => Mode::Overlay,
+ _ => Mode::Classic,
+ }
+ }
+}
+
+/// CSS `scrollbar-width`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Thickness {
+ Auto,
+ Thin,
+ None,
+}
+
+/// CSS `scrollbar-gutter`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Gutter {
+ Auto,
+ Stable,
+ StableBothEdges,
+}
+
+/// What one scroll box asks of its bars, resolved from its style.
+#[derive(Clone, Debug)]
+pub(crate) struct Spec {
+ mode: Mode,
+ thickness: Thickness,
+ gutter: Gutter,
+ thumb: Option,
+ track: Option,
+ /// Whether each axis scrolls at all.
+ scrolls: Point,
+ /// `overflow: scroll` on the axis, so a classic bar shows even when
+ /// the content fits. `auto` shows one only when it overflows.
+ always: Point,
+}
+
+/// The overflow words that make a scroll box on an axis.
+/// The used per-axis `overflow` words. CSS never mixes `visible` or
+/// `clip` on one axis with a scrolling word on the other: when one axis
+/// is neither `visible` nor `clip`, `visible` on the other computes to
+/// `auto` and `clip` computes to `hidden`.
+pub(crate) fn used_overflow<'a>(
+ x: Option<&'a str>,
+ y: Option<&'a str>,
+) -> (Option<&'a str>, Option<&'a str>) {
+ let keeps_word = |word: Option<&str>| {
+ matches!(word, None | Some("visible") | Some("clip"))
+ };
+ let coerce = |word: Option<&'a str>, other: Option<&'a str>| {
+ if keeps_word(other) {
+ return word;
+ }
+ match word {
+ None | Some("visible") => Some("auto"),
+ Some("clip") => Some("hidden"),
+ word => word,
+ }
+ };
+ (coerce(x, y), coerce(y, x))
+}
+
+pub(crate) fn scrolls(word: Option<&str>) -> bool {
+ matches!(word, Some("scroll") | Some("auto"))
+}
+
+impl Spec {
+ /// The spec for a box, or `None` when no axis scrolls.
+ pub(crate) fn from_style(style: &StyleDesc, mode: Mode) -> Option {
+ let x = style.overflow_x.as_deref().or(style.overflow.as_deref());
+ let y = style.overflow_y.as_deref().or(style.overflow.as_deref());
+ let (x, y) = used_overflow(x, y);
+ let scrolls = point(scrolls(x), scrolls(y));
+ if !scrolls.x && !scrolls.y {
+ return None;
+ }
+ let thickness = match style.scrollbar_width.as_deref().map(str::trim) {
+ Some("thin") => Thickness::Thin,
+ Some("none") => Thickness::None,
+ _ => Thickness::Auto,
+ };
+ let gutter = match style.scrollbar_gutter.as_deref().map(str::trim) {
+ Some("stable") => Gutter::Stable,
+ Some("stable both-edges") | Some("both-edges stable") => Gutter::StableBothEdges,
+ _ => Gutter::Auto,
+ };
+ let (thumb, track) = style
+ .scrollbar_color
+ .as_deref()
+ .map(scrollbar_colors)
+ .unwrap_or((None, None));
+ Some(Self {
+ mode,
+ thickness,
+ gutter,
+ thumb,
+ track,
+ scrolls,
+ always: point(x == Some("scroll"), y == Some("scroll")),
+ })
+ }
+
+ /// The width of a classic gutter, or zero for overlay bars and
+ /// `scrollbar-width: none`.
+ fn gutter_width(&self) -> Pixels {
+ match (self.mode, self.thickness) {
+ (Mode::Overlay, _) | (_, Thickness::None) => px(0.0),
+ (Mode::Classic, Thickness::Auto) => px(15.0),
+ (Mode::Classic, Thickness::Thin) => px(8.0),
+ }
+ }
+
+ /// The gutter to reserve at the end of each axis this frame, given
+ /// which axes overflowed at the last one. `overflow: scroll` and
+ /// `scrollbar-gutter: stable` reserve it at all times, `auto` only
+ /// while a bar shows. Both are zero for overlay bars, as in CSS.
+ pub(crate) fn reserved(&self, overflowed: Point) -> Point {
+ let width = self.gutter_width();
+ let reserve = |axis: Axis| {
+ let scrolls = self.scrolls.along(axis);
+ let stable = self.always.along(axis) || self.gutter != Gutter::Auto;
+ if scrolls && (stable || overflowed.along(axis)) {
+ width
+ } else {
+ px(0.0)
+ }
+ };
+ point(reserve(Axis::Horizontal), reserve(Axis::Vertical))
+ }
+
+ /// Whether `scrollbar-gutter: stable both-edges` asks for a second
+ /// gutter at the start of the axes.
+ pub(crate) fn both_edges(&self) -> bool {
+ self.gutter == Gutter::StableBothEdges && self.gutter_width() > px(0.0)
+ }
+
+ /// The thickness of the thumb, wider while the mouse is on the bar.
+ fn thumb_thickness(&self, hovered: bool) -> Pixels {
+ match (self.mode, self.thickness, hovered) {
+ (_, Thickness::None, _) => px(0.0),
+ (Mode::Overlay, Thickness::Auto, false) => px(7.0),
+ (Mode::Overlay, Thickness::Auto, true) => px(11.0),
+ (Mode::Overlay, Thickness::Thin, false) => px(4.0),
+ (Mode::Overlay, Thickness::Thin, true) => px(6.0),
+ (Mode::Classic, Thickness::Auto, _) => px(9.0),
+ (Mode::Classic, Thickness::Thin, _) => px(6.0),
+ }
+ }
+
+ /// The strip along one edge that a bar lives in. For classic bars it
+ /// is the gutter. For overlay bars it is the widest the thumb gets plus
+ /// its inset from the edge.
+ fn strip_thickness(&self) -> Pixels {
+ match self.mode {
+ Mode::Classic => self.gutter_width(),
+ Mode::Overlay => self.thumb_thickness(true) + px(2.0) * 2.0,
+ }
+ }
+
+ fn thumb_color(&self, state: ThumbLook) -> Hsla {
+ let base = self.thumb.unwrap_or(hsla(0.0, 0.0, 0.5, 0.55));
+ let alpha = match state {
+ ThumbLook::Rest => base.a,
+ ThumbLook::Hovered => (base.a * 1.3).min(1.0),
+ ThumbLook::Dragged => (base.a * 1.5).min(1.0),
+ };
+ Hsla { a: alpha, ..base }
+ }
+
+ fn track_color(&self) -> Hsla {
+ self.track.unwrap_or(hsla(0.0, 0.0, 0.5, 0.12))
+ }
+}
+
+#[derive(Clone, Copy)]
+enum ThumbLook {
+ Rest,
+ Hovered,
+ Dragged,
+}
+
+/// `scrollbar-color: `, or `auto`.
+///
+/// CSS takes `auto` or exactly two colours. One colour, three words or a
+/// word that is not a colour drops the whole declaration, the way a browser
+/// drops a value it cannot parse.
+fn scrollbar_colors(value: &str) -> (Option, Option) {
+ let words = split_top_level(value);
+ if words.len() != 2 {
+ return (None, None);
+ }
+ let color = |index: usize| {
+ words
+ .get(index)
+ .and_then(|word| crate::color::parse_color_rgba(word))
+ .map(Hsla::from)
+ };
+ match (color(0), color(1)) {
+ (Some(thumb), Some(track)) => (Some(thumb), Some(track)),
+ _ => (None, None),
+ }
+}
+
+/// Splits on spaces outside parentheses, so `rgb(0 0 0 / 0.5) white` is
+/// two words.
+fn split_top_level(value: &str) -> Vec<&str> {
+ let mut words = Vec::new();
+ let mut depth = 0usize;
+ let mut start = None;
+ for (index, ch) in value.char_indices() {
+ match ch {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ _ => {}
+ }
+ if ch.is_whitespace() && depth == 0 {
+ if let Some(from) = start.take() {
+ words.push(&value[from..index]);
+ }
+ } else if start.is_none() {
+ start = Some(index);
+ }
+ }
+ if let Some(from) = start {
+ words.push(&value[from..]);
+ }
+ words
+}
+
+/// What a bar remembers between frames.
+#[derive(Default)]
+pub(crate) struct State {
+ /// The offset at the last frame, to notice a scroll.
+ last_offset: Option>,
+ /// When the box last scrolled. Overlay bars show for a while after.
+ last_scroll: Option,
+ /// Which axes had more content than room at the last frame.
+ pub(crate) overflowed: Point,
+ /// The axis whose strip the mouse is on.
+ hovered: Option,
+ /// A drag of the thumb: the axis and where the mouse took hold of
+ /// the thumb, from its start.
+ drag: Option<(Axis, Pixels)>,
+}
+
+/// The shared states, one per scroll box, kept on the view.
+pub(crate) type States = HashMap>>;
+
+/// Where a bar's parts are this frame.
+#[derive(Clone, Copy)]
+struct Geometry {
+ axis: Axis,
+ /// The strip along the edge that takes the mouse.
+ strip: Bounds,
+ /// The part of the strip a thumb can move in.
+ track: Bounds,
+ /// The thumb, or `None` when the content fits.
+ thumb: Option>,
+}
+
+impl Geometry {
+ /// The offset for a thumb start at `along` in the track.
+ fn offset_for_thumb_start(&self, along: Pixels, max_offset: Pixels) -> Pixels {
+ let Some(thumb) = self.thumb else {
+ return px(0.0);
+ };
+ let room = self.track.size.along(self.axis) - thumb.size.along(self.axis);
+ if room <= px(0.0) {
+ return px(0.0);
+ }
+ let fraction = (f32::from(along - self.track.origin.along(self.axis)) / f32::from(room))
+ .clamp(0.0, 1.0);
+ -max_offset * fraction
+ }
+}
+
+/// The element the box adopts as its last child. It takes no layout
+/// space and hands the bar to a deferred draw, which paints after the
+/// whole tree. A sibling that overlaps the box, such as a blurred
+/// header, then cannot cover or blur the bar. The deferred draw keeps
+/// the content mask of the box, so an ancestor still clips the bar.
+pub(crate) struct Scrollbar {
+ bar: Option,
+}
+
+impl Scrollbar {
+ pub(crate) fn new(
+ spec: Spec,
+ handle: ScrollHandle,
+ state: Rc>,
+ now: Instant,
+ ) -> Self {
+ Self {
+ bar: Some(
+ Bar {
+ spec,
+ handle,
+ state,
+ now,
+ }
+ .into_any_element(),
+ ),
+ }
+ }
+}
+
+impl Element for Scrollbar {
+ type RequestLayoutState = ();
+ type PrepaintState = ();
+
+ fn id(&self) -> Option {
+ None
+ }
+
+ fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, ()) {
+ (self.bar.as_mut().unwrap().request_layout(window, cx), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ window: &mut Window,
+ _cx: &mut App,
+ ) {
+ let bar = self.bar.take().unwrap();
+ let mask = window.content_mask();
+ window.defer_draw(bar, window.element_offset(), 0, Some(mask));
+ }
+
+ fn paint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ _prepaint: &mut (),
+ _window: &mut Window,
+ _cx: &mut App,
+ ) {
+ }
+}
+
+/// The bar itself: the strips, the thumbs and the mouse handling.
+struct Bar {
+ spec: Spec,
+ handle: ScrollHandle,
+ state: Rc>,
+ now: Instant,
+}
+
+impl Bar {
+
+ /// How much of an overlay bar shows, from 0 to 1. Classic bars are
+ /// always 1.
+ fn opacity(&self, state: &State) -> f32 {
+ if self.spec.mode == Mode::Classic || state.drag.is_some() || state.hovered.is_some() {
+ return 1.0;
+ }
+ let Some(last) = state.last_scroll else {
+ return 0.0;
+ };
+ let since = self.now.saturating_duration_since(last);
+ if since < HIDE_DELAY {
+ 1.0
+ } else if since < HIDE_DELAY + FADE {
+ 1.0 - (since - HIDE_DELAY).as_secs_f32() / FADE.as_secs_f32()
+ } else {
+ 0.0
+ }
+ }
+
+ /// Whether the bar on `axis` takes any part in this frame.
+ fn shows(&self, axis: Axis, overflowed: Point) -> bool {
+ if !self.spec.scrolls.along(axis) || self.spec.thickness == Thickness::None {
+ return false;
+ }
+ match self.spec.mode {
+ Mode::Classic => overflowed.along(axis) || self.spec.always.along(axis),
+ Mode::Overlay => overflowed.along(axis),
+ }
+ }
+
+ /// The parts of the bar on `axis`, given whether the other axis also
+ /// shows one and takes the corner.
+ fn geometry(&self, axis: Axis, other_shows: bool, hovered: bool) -> Geometry {
+ let bounds = self.handle.bounds();
+ let offset = self.handle.offset();
+ let max_offset = self.handle.max_offset();
+ let strip_thickness = self.spec.strip_thickness();
+ let thumb_thickness = self.spec.thumb_thickness(hovered);
+ // Where the thumb sits across the axis. Classic thumbs centre in
+ // the gutter. Overlay thumbs keep a 2px inset from the edge and
+ // grow inward when hovered.
+ let inset = match self.spec.mode {
+ Mode::Classic => (strip_thickness - thumb_thickness) / 2.0,
+ Mode::Overlay => px(2.0),
+ };
+ let corner = if other_shows {
+ strip_thickness
+ } else {
+ px(0.0)
+ };
+ let end_inset = match self.spec.mode {
+ Mode::Classic => px(0.0),
+ Mode::Overlay => px(2.0),
+ };
+ let (strip, track) = match axis {
+ Axis::Vertical => {
+ let strip = Bounds::new(
+ point(bounds.right() - strip_thickness, bounds.top()),
+ size(strip_thickness, bounds.size.height - corner),
+ );
+ let track = Bounds::new(
+ point(
+ bounds.right() - inset - thumb_thickness,
+ strip.top() + end_inset,
+ ),
+ size(thumb_thickness, strip.size.height - end_inset * 2.0),
+ );
+ (strip, track)
+ }
+ Axis::Horizontal => {
+ let strip = Bounds::new(
+ point(bounds.left(), bounds.bottom() - strip_thickness),
+ size(bounds.size.width - corner, strip_thickness),
+ );
+ let track = Bounds::new(
+ point(
+ strip.left() + end_inset,
+ bounds.bottom() - inset - thumb_thickness,
+ ),
+ size(strip.size.width - end_inset * 2.0, thumb_thickness),
+ );
+ (strip, track)
+ }
+ };
+ let max = max_offset.along(axis);
+ let thumb = (max > px(0.0)).then(|| {
+ let viewport = bounds.size.along(axis);
+ let content = viewport + max;
+ let track_len = track.size.along(axis);
+ let len = (track_len * (f32::from(viewport) / f32::from(content)))
+ .max(MIN_THUMB)
+ .min(track_len);
+ let scrolled = (f32::from(-offset.along(axis)) / f32::from(max)).clamp(0.0, 1.0);
+ let start = (track_len - len) * scrolled;
+ Bounds::new(
+ track.origin.apply_along(axis, |origin| origin + start),
+ track.size.apply_along(axis, |_| len),
+ )
+ });
+ Geometry {
+ axis,
+ strip,
+ track,
+ thumb,
+ }
+ }
+}
+
+/// The mouse listeners for one frame. They hold the frame's geometry, so
+/// a hit test is a `contains` on the bounds. The scroll offset they set
+/// is clamped by the box at its next prepaint.
+fn register_mouse(
+ bars: Vec,
+ handle: ScrollHandle,
+ state: Rc>,
+ window: &mut Window,
+) {
+ let viewport = handle.bounds();
+ let strips: Vec<(Axis, Bounds)> = bars.iter().map(|g| (g.axis, g.strip)).collect();
+
+ // Take hold of a thumb, or jump a page on a click in the track.
+ let down_bars = bars.clone();
+ let down_handle = handle.clone();
+ let down_state = state.clone();
+ window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
+ if !phase.bubble() || event.button != MouseButton::Left {
+ return;
+ }
+ for bar in &down_bars {
+ if !bar.strip.contains(&event.position) {
+ continue;
+ }
+ let along = event.position.along(bar.axis);
+ let offset = down_handle.offset();
+ if let Some(thumb) = bar.thumb.filter(|thumb| thumb.contains(&event.position)) {
+ down_state.borrow_mut().drag =
+ Some((bar.axis, along - thumb.origin.along(bar.axis)));
+ } else if let Some(thumb) = bar.thumb {
+ let page = viewport.size.along(bar.axis) * 0.9;
+ let max = down_handle.max_offset().along(bar.axis);
+ let current = offset.along(bar.axis);
+ let next = if along < thumb.origin.along(bar.axis) {
+ current + page
+ } else {
+ current - page
+ };
+ let offset = offset.apply_along(bar.axis, |_| next.clamp(-max, px(0.0)));
+ down_handle.set_offset(offset);
+ }
+ cx.stop_propagation();
+ window.refresh();
+ return;
+ }
+ });
+
+ // Move the thumb, and notice the mouse coming onto or leaving a strip.
+ let move_bars = bars;
+ let move_strips = strips.clone();
+ let move_handle = handle.clone();
+ let move_state = state.clone();
+ window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, _cx| {
+ if !phase.bubble() {
+ return;
+ }
+ let drag = move_state.borrow().drag;
+ if let Some((axis, grab)) = drag {
+ if let Some(bar) = move_bars.iter().find(|bar| bar.axis == axis) {
+ let start = event.position.along(axis) - grab;
+ let max = move_handle.max_offset().along(axis);
+ let offset = move_handle
+ .offset()
+ .apply_along(axis, |_| bar.offset_for_thumb_start(start, max));
+ move_handle.set_offset(offset);
+ window.refresh();
+ }
+ return;
+ }
+ let hovered = move_strips
+ .iter()
+ .find(|(_, strip)| strip.contains(&event.position))
+ .map(|(axis, _)| *axis);
+ let mut state = move_state.borrow_mut();
+ if state.hovered != hovered {
+ state.hovered = hovered;
+ window.refresh();
+ }
+ });
+
+ window.on_mouse_event(move |event: &MouseUpEvent, phase, window, _cx| {
+ if phase.bubble() && event.button == MouseButton::Left {
+ let mut state = state.borrow_mut();
+ if state.drag.take().is_some() {
+ state.hovered = strips
+ .iter()
+ .find(|(_, strip)| strip.contains(&event.position))
+ .map(|(axis, _)| *axis);
+ window.refresh();
+ }
+ }
+ });
+}
+
+impl Element for Bar {
+ type RequestLayoutState = ();
+ type PrepaintState = ();
+
+ fn id(&self) -> Option {
+ None
+ }
+
+ fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, ()) {
+ // Out of the flow and empty, so the box lays out as if the bar
+ // were not there. The bar paints from the handle's bounds instead.
+ let mut style = Style::default();
+ style.position = gpui::Position::Absolute;
+ style.inset.top = px(0.0).into();
+ style.inset.left = px(0.0).into();
+ style.size = size(px(0.0).into(), px(0.0).into());
+ (window.request_layout(style, [], cx), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ window: &mut Window,
+ _cx: &mut App,
+ ) {
+ // The box set its bounds and max offset on the handle just before
+ // its children prepaint, so they are this frame's.
+ let offset = self.handle.offset();
+ let max_offset = self.handle.max_offset();
+ let mut state = self.state.borrow_mut();
+ if state.last_offset.is_some_and(|last| last != offset) {
+ state.last_scroll = Some(self.now);
+ }
+ state.last_offset = Some(offset);
+ state.overflowed = point(max_offset.x > px(0.0), max_offset.y > px(0.0));
+ let opacity = self.opacity(&state);
+ if self.spec.mode == Mode::Overlay && opacity > 0.0 && opacity < 1.0 {
+ window.request_animation_frame();
+ }
+ }
+
+ fn paint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ _prepaint: &mut (),
+ window: &mut Window,
+ _cx: &mut App,
+ ) {
+ let state = self.state.borrow();
+ let opacity = self.opacity(&state);
+ if opacity <= 0.0 {
+ return;
+ }
+ let overflowed = state.overflowed;
+ let shows = point(
+ self.shows(Axis::Horizontal, overflowed),
+ self.shows(Axis::Vertical, overflowed),
+ );
+ let mut bars = Vec::new();
+ for axis in [Axis::Vertical, Axis::Horizontal] {
+ if !shows.along(axis) {
+ continue;
+ }
+ let other = match axis {
+ Axis::Vertical => Axis::Horizontal,
+ Axis::Horizontal => Axis::Vertical,
+ };
+ let hovered =
+ state.hovered == Some(axis) || matches!(state.drag, Some((a, _)) if a == axis);
+ let bar = self.geometry(axis, shows.along(other), hovered);
+
+ // The track. Classic bars always have one. Overlay bars show
+ // one while the mouse is on the strip, like macOS.
+ let track_shows = self.spec.mode == Mode::Classic || hovered;
+ if track_shows {
+ let mut color = self.spec.track_color();
+ color.a *= opacity;
+ let (radius, border) = match self.spec.mode {
+ Mode::Classic => (px(0.0), px(1.0)),
+ Mode::Overlay => (bar.strip.size.along(other) / 2.0, px(0.0)),
+ };
+ let mut border_widths = Edges::default();
+ match axis {
+ Axis::Vertical => border_widths.left = border,
+ Axis::Horizontal => border_widths.top = border,
+ }
+ let mut border_color = color;
+ border_color.a = (border_color.a * 1.5).min(1.0);
+ window.paint_quad(gpui::quad(
+ bar.strip,
+ Corners::all(radius),
+ color,
+ border_widths,
+ border_color,
+ BorderStyle::default(),
+ ));
+ }
+
+ if let Some(thumb) = bar.thumb {
+ let look = match state.drag {
+ Some((a, _)) if a == axis => ThumbLook::Dragged,
+ _ if hovered => ThumbLook::Hovered,
+ _ => ThumbLook::Rest,
+ };
+ let mut color = self.spec.thumb_color(look);
+ color.a *= opacity;
+ let radius = thumb.size.along(other) / 2.0;
+ window.paint_quad(gpui::fill(thumb, color).corner_radii(Corners::all(radius)));
+ }
+ bars.push(bar);
+ }
+ // The square where two classic bars meet, in the track colour,
+ // as a browser paints it.
+ if self.spec.mode == Mode::Classic && shows.x && shows.y {
+ let bounds = self.handle.bounds();
+ let thickness = self.spec.strip_thickness();
+ let corner = Bounds::new(
+ point(bounds.right() - thickness, bounds.bottom() - thickness),
+ size(thickness, thickness),
+ );
+ window.paint_quad(gpui::fill(corner, self.spec.track_color()));
+ }
+ drop(state);
+ if !bars.is_empty() {
+ register_mouse(bars, self.handle.clone(), self.state.clone(), window);
+ }
+ }
+}
+
+impl IntoElement for Scrollbar {
+ type Element = Self;
+
+ fn into_element(self) -> Self {
+ self
+ }
+}
+
+impl IntoElement for Bar {
+ type Element = Self;
+
+ fn into_element(self) -> Self {
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn colors_split_outside_parentheses() {
+ let words = split_top_level("rgb(0 0 0 / 0.5) white");
+ assert_eq!(words, vec!["rgb(0 0 0 / 0.5)", "white"]);
+ let (thumb, track) = scrollbar_colors("rgb(0 0 0 / 0.5) white");
+ assert!(thumb.is_some_and(|c| (c.a - 0.5).abs() < 0.01));
+ assert!(track.is_some_and(|c| c.l > 0.99));
+ assert_eq!(scrollbar_colors("auto"), (None, None));
+ }
+
+ #[test]
+ fn scrollbar_color_takes_auto_or_exactly_two_colors() {
+ // One colour is not valid CSS, so the declaration drops.
+ assert_eq!(scrollbar_colors("red"), (None, None));
+ assert_eq!(scrollbar_colors("red white blue"), (None, None));
+ // A word that is not a colour drops both, not just itself.
+ assert_eq!(scrollbar_colors("red nonsense"), (None, None));
+ }
+
+ fn spec(overflow: &str, extra: impl FnOnce(&mut StyleDesc), mode: Mode) -> Spec {
+ let mut style = StyleDesc::default();
+ style.overflow = Some(overflow.to_string());
+ extra(&mut style);
+ Spec::from_style(&style, mode).expect("a scroll box")
+ }
+
+ #[test]
+ fn classic_gutter_follows_overflow_and_gutter_words() {
+ let no = point(false, false);
+ let yes = point(true, true);
+ assert_eq!(
+ spec("scroll", |_| {}, Mode::Classic).reserved(no),
+ point(px(15.0), px(15.0))
+ );
+ assert_eq!(
+ spec("auto", |_| {}, Mode::Classic).reserved(no),
+ point(px(0.0), px(0.0))
+ );
+ assert_eq!(
+ spec("auto", |_| {}, Mode::Classic).reserved(yes),
+ point(px(15.0), px(15.0))
+ );
+ let stable = spec(
+ "auto",
+ |s| s.scrollbar_gutter = Some("stable".into()),
+ Mode::Classic,
+ );
+ assert_eq!(stable.reserved(no), point(px(15.0), px(15.0)));
+ let thin = spec(
+ "scroll",
+ |s| s.scrollbar_width = Some("thin".into()),
+ Mode::Classic,
+ );
+ assert_eq!(thin.reserved(no), point(px(8.0), px(8.0)));
+ let none = spec(
+ "scroll",
+ |s| s.scrollbar_width = Some("none".into()),
+ Mode::Classic,
+ );
+ assert_eq!(none.reserved(yes), point(px(0.0), px(0.0)));
+ assert_eq!(
+ spec("scroll", |_| {}, Mode::Overlay).reserved(yes),
+ point(px(0.0), px(0.0))
+ );
+ }
+
+ #[test]
+ fn a_hidden_axis_has_no_bar() {
+ let mut style = StyleDesc::default();
+ style.overflow_y = Some("scroll".into());
+ style.overflow_x = Some("hidden".into());
+ let spec = Spec::from_style(&style, Mode::Classic).unwrap();
+ assert_eq!(spec.reserved(point(true, true)), point(px(0.0), px(15.0)));
+ style.overflow_y = Some("clip".into());
+ assert!(Spec::from_style(&style, Mode::Classic).is_none());
+ }
+}
diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs
index 9ee989b5..69eecd0b 100644
--- a/packages/native/src/style.rs
+++ b/packages/native/src/style.rs
@@ -487,6 +487,25 @@ style_desc! {
overscroll_behavior: Option = "overscrollBehavior",
overscroll_behavior_x: Option = "overscrollBehaviorX",
overscroll_behavior_y: Option = "overscrollBehaviorY",
+ /// `auto`, `thin` or `none`.
+ scrollbar_width: Option = "scrollbarWidth",
+ /// `auto`, or a thumb colour and a track colour.
+ scrollbar_color: Option = "scrollbarColor",
+ /// `auto`, `stable` or `stable both-edges`.
+ scrollbar_gutter: Option = "scrollbarGutter",
+ /// Space `scrollIntoView` keeps around the element, a number of
+ /// pixels or `"Npx"`, alone or as the one-to-four shorthand.
+ scroll_margin: Option = "scrollMargin",
+ scroll_margin_top: Option = "scrollMarginTop",
+ scroll_margin_right: Option = "scrollMarginRight",
+ scroll_margin_bottom: Option = "scrollMarginBottom",
+ scroll_margin_left: Option = "scrollMarginLeft",
+ /// Space `scrollIntoView` keeps inside this scroll box.
+ scroll_padding: Option = "scrollPadding",
+ scroll_padding_top: Option = "scrollPaddingTop",
+ scroll_padding_right: Option = "scrollPaddingRight",
+ scroll_padding_bottom: Option = "scrollPaddingBottom",
+ scroll_padding_left: Option = "scrollPaddingLeft",
// Cursor
cursor: Option = "cursor",
diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs
index 5dd69bcf..4e6fb0f3 100644
--- a/packages/native/src/style/resolve.rs
+++ b/packages/native/src/style/resolve.rs
@@ -771,17 +771,22 @@ pub(crate) fn apply_styles(mut el: E, style: &StyleDesc, scope:
el = el.cursor(cursor);
}
// Overflow: hidden is on the Styled trait, so we handle it here.
- // overflow: "scroll" requires StatefulInteractiveElement — handled in build_host_container().
+ // overflow: "scroll" and "auto" need StatefulInteractiveElement, so build_host_container() handles them.
// CSS precedence: axis-specific (overflowX/Y) overrides the shorthand (overflow).
{
let resolved_x = style.overflow_x.as_deref().or(style.overflow.as_deref());
let resolved_y = style.overflow_y.as_deref().or(style.overflow.as_deref());
+ let (resolved_x, resolved_y) =
+ crate::renderer::scrollbar::used_overflow(resolved_x, resolved_y);
// Only apply hidden here — scroll is handled in build_host_container.
- if resolved_x == Some("hidden") && resolved_y == Some("hidden") {
+ // `clip` clips like `hidden`. The difference in CSS, that `clip`
+ // is not a scroll container for `scrollTo`, has no meaning here.
+ let hidden = |word: Option<&str>| matches!(word, Some("hidden") | Some("clip"));
+ if hidden(resolved_x) && hidden(resolved_y) {
el = el.overflow_hidden();
- } else if resolved_x == Some("hidden") {
+ } else if hidden(resolved_x) {
el = el.overflow_x_hidden();
- } else if resolved_y == Some("hidden") {
+ } else if hidden(resolved_y) {
el = el.overflow_y_hidden();
}
}
diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs
index 71d6a0bc..6d176e76 100644
--- a/packages/native/src/test_renderer.rs
+++ b/packages/native/src/test_renderer.rs
@@ -21,8 +21,8 @@ use gpui::AppContext as _;
use crate::element_tree::EventPayload;
use crate::renderer::{
apply_batch_to_tree, debug_frame_overlay_mode_name, debug_frame_overlay_stats_js,
- parse_debug_frame_overlay_mode, to_element_id, DebugFrameOverlayStats, EventCallback,
- GpuixView,
+ offset_to_js, parse_debug_frame_overlay_mode, to_element_id, DebugFrameOverlayStats,
+ EventCallback, GpuixView,
};
use crate::retained_tree::RetainedTree;
@@ -719,6 +719,34 @@ impl TestGpuixRenderer {
})
}
+ /// Scroll every ancestor scroll box so the element shows, like the
+ /// web scrollIntoView. Call flush() after to apply and re-render.
+ #[napi]
+ pub fn scroll_into_view(
+ &self,
+ element_id: f64,
+ block: Option,
+ inline: Option,
+ ) -> Result<()> {
+ use crate::renderer::scroll_into_view::{scroll_into_view, Align};
+ let id = to_element_id(element_id)?;
+ let block = Align::parse(block.as_deref(), Align::Start);
+ let inline = Align::parse(inline.as_deref(), Align::Nearest);
+ with_test_state(|cx, window, view| {
+ let view = view.clone();
+ cx.update_window(window, |_, _window, app| {
+ view.update(app, |view, _cx| {
+ let tree = view.tree.lock().unwrap();
+ scroll_into_view(&tree, id, block, inline, |id| {
+ view.scroll_handles.get(&id).cloned()
+ });
+ });
+ })
+ .map_err(|e| Error::from_reason(e.to_string()))?;
+ Ok(())
+ })
+ }
+
/// Scroll a child into view by its index in the children list.
/// Call flush() after to apply and re-render. For a `` the
/// scroll is queued and applied on that flush, after the child splice.
@@ -843,10 +871,7 @@ impl TestGpuixRenderer {
}
view.scroll_handles.get(&id).map(|handle| {
let offset = handle.offset();
- vec![
- f64::from(f32::from(offset.x)),
- f64::from(f32::from(offset.y)),
- ]
+ offset_to_js(offset).to_vec()
})
})
})
diff --git a/packages/react/src/__tests__/scrollbars.test.tsx b/packages/react/src/__tests__/scrollbars.test.tsx
new file mode 100644
index 00000000..74fe5fc6
--- /dev/null
+++ b/packages/react/src/__tests__/scrollbars.test.tsx
@@ -0,0 +1,169 @@
+/**
+ * Scrollbars on scroll boxes. `GPUIX_SCROLLBARS` picks the kind of bar,
+ * so the tests do not depend on the machine's setting.
+ */
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import React from "react"
+import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing"
+
+const describeNative = hasNativeTestRenderer ? describe : describe.skip
+
+type Style = React.CSSProperties & Record
+
+function Page({ box, content = 400 }: { box: Style; content?: number }) {
+ return (
+
+ )
+}
+
+describeNative("scrollbars", () => {
+ let root: TestRoot
+ beforeEach(() => {
+ root = createTestRoot({})
+ })
+ afterEach(() => {
+ root.unmount()
+ delete process.env.GPUIX_SCROLLBARS
+ })
+
+ const box = () => root.renderer.findByType("div")[0]!
+ const inner = () => root.renderer.findByType("div")[1]!
+ /** The inner box's rectangle after a settled frame. */
+ const innerBounds = () => {
+ root.renderer.getElementBounds(inner().id)
+ return root.renderer.getElementBounds(inner().id)!
+ }
+
+ describe("classic bars", () => {
+ beforeEach(() => {
+ process.env.GPUIX_SCROLLBARS = "classic"
+ })
+
+ it("reserve a 15px gutter for overflow: scroll", () => {
+ root.render( )
+ expect(innerBounds()[2]).toBe(185)
+ })
+
+ it("reserve the gutter for overflow: auto only when the content overflows", () => {
+ root.render( )
+ expect(innerBounds()[2]).toBe(200)
+ root.render( )
+ expect(innerBounds()[2]).toBe(185)
+ })
+
+ it("follow scrollbar-width and scrollbar-gutter", () => {
+ root.render( )
+ expect(innerBounds()[2]).toBe(192)
+ root.render( )
+ expect(innerBounds()[2]).toBe(200)
+ root.render( )
+ expect(innerBounds()[2]).toBe(185)
+ root.render(
+
+ )
+ const [x, , width] = innerBounds()
+ expect(x).toBe(15)
+ expect(width).toBe(170)
+ })
+
+ it("scroll with a thumb drag and a track click", () => {
+ root.render( )
+ const id = box().id
+ // The thumb is a quarter of the track, at the top. Take it 40px down.
+ root.renderer.nativeSimulateMouseDown(192, 10)
+ root.renderer.nativeSimulateMouseMove(192, 50, 0)
+ root.renderer.nativeSimulateMouseUp(192, 50)
+ const dragged = root.renderer.getScrollOffset(id)![1]
+ expect(dragged).toBeCloseTo(-160, 0)
+
+ // A click in the track below the thumb pages down.
+ root.renderer.nativeSimulateMouseDown(192, 98)
+ root.renderer.nativeSimulateMouseUp(192, 98)
+ expect(root.renderer.getScrollOffset(id)![1]).toBeCloseTo(-250, 0)
+ })
+ })
+
+ describe("overlay bars", () => {
+ beforeEach(() => {
+ process.env.GPUIX_SCROLLBARS = "overlay"
+ })
+
+ it("reserve nothing, even with scrollbar-gutter: stable", () => {
+ root.render( )
+ expect(innerBounds()[2]).toBe(200)
+ })
+
+ it("take a thumb drag after a scroll", () => {
+ root.render( )
+ const id = box().id
+ root.renderer.nativeSimulateScrollWheel(100, 50, 0, -10)
+ const before = root.renderer.getScrollOffset(id)![1]
+ expect(before).toBeLessThan(0)
+ root.renderer.nativeSimulateMouseDown(195, 12)
+ root.renderer.nativeSimulateMouseMove(195, 60, 0)
+ root.renderer.nativeSimulateMouseUp(195, 60)
+ expect(root.renderer.getScrollOffset(id)![1]).toBeLessThan(before - 100)
+ })
+ })
+
+ it("overflow: auto scrolls like overflow: scroll", () => {
+ root.render( )
+ root.renderer.nativeSimulateScrollWheel(100, 50, 0, -30)
+ expect(root.renderer.getScrollOffset(box().id)![1]).toBeLessThan(0)
+ })
+})
+
+describeNative("scrollIntoView", () => {
+ let root: TestRoot
+ beforeEach(() => {
+ root = createTestRoot({})
+ })
+ afterEach(() => root.unmount())
+
+ function List({ margin, padding }: { margin?: number; padding?: number }) {
+ return (
+
+ {Array.from({ length: 10 }, (_, i) => (
+
+ {`row ${i}`}
+
+ ))}
+
+ )
+ }
+
+ it("brings the target to the start and honours the margins", () => {
+ root.render(
)
+ const divs = root.renderer.findByType("div")
+ root.renderer.scrollIntoView(divs[7]!.id)
+ // The target's top is at 240. scroll-padding keeps 10 inside the
+ // box and scroll-margin keeps 4 around the target: 240 - 10 - 4.
+ expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(-226)
+ })
+
+ it("a repeated call moves nothing", () => {
+ // The box's own recorded bounds must not move with its own offset.
+ // When they do, the second call applies the same delta again.
+ root.render(
)
+ const divs = root.renderer.findByType("div")
+ root.renderer.scrollIntoView(divs[7]!.id)
+ const once = root.renderer.getScrollOffset(divs[0]!.id)![1]
+ expect(once).toBeLessThan(0)
+ root.renderer.scrollIntoView(divs[7]!.id)
+ expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(once)
+ })
+
+ it("nearest leaves a visible target alone", () => {
+ root.render(
)
+ const divs = root.renderer.findByType("div")
+ root.renderer.scrollIntoView(divs[2]!.id, "nearest")
+ expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(0)
+ root.renderer.scrollIntoView(divs[7]!.id, "end")
+ // The target's bottom is at 280 and the viewport is 100 tall.
+ expect(root.renderer.getScrollOffset(divs[0]!.id)![1]).toBe(-180)
+ })
+})
diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts
index 52e39d3a..e9382e7a 100644
--- a/packages/react/src/testing.ts
+++ b/packages/react/src/testing.ts
@@ -66,6 +66,7 @@ interface NativeTestRendererApi extends NativeRenderer {
getAllText(): string[]
scrollTo(elementId: number, x: number, y: number): void
scrollToItem(elementId: number, index: number, offsetInItem?: number): void
+ scrollIntoView(elementId: number, block?: string, inline?: string): void
getScrollOffset(elementId: number): number[] | null
getListScrollTop(elementId: number): number[] | null
setDebugFrameOverlay(mode: DebugFrameOverlayMode): string
@@ -519,6 +520,14 @@ export class TestRenderer implements NativeRenderer {
this.native.flush()
}
+ /** Scroll ancestors until the element is in view, as web scrollIntoView. */
+ scrollIntoView(elementId: number, block?: string, inline?: string): void {
+ this.native.flush()
+ this.native.scrollIntoView(elementId, block, inline)
+ this.dispatchNativeEvents()
+ this.native.flush()
+ }
+
/** Get the current scroll offset [x, y] or null if element is not scrollable. */
getScrollOffset(elementId: number): [number, number] | null {
this.native.flush()
diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts
index 3956f470..d662d0dd 100644
--- a/packages/react/src/types/host.ts
+++ b/packages/react/src/types/host.ts
@@ -275,11 +275,40 @@ export interface StyleDesc {
textOverflow?: "ellipsis" | "ellipsis-start"
lineClamp?: Numeric
+ /** `visible`, `hidden`, `clip`, `scroll` or `auto`. `scroll` and `auto`
+ * make a scroll box with a scrollbar. The OS picks the kind of bar: an
+ * overlay bar that fades out after a scroll, or a classic bar in a
+ * gutter. A classic bar shows at all times for `scroll` and only while
+ * the content overflows for `auto`. */
overflow?: string
overflowX?: string
overflowY?: string
- /** `"contain"` and `"none"` stop the wheel from reaching the parent
- * scroll box when this one hits its edge. `"auto"` lets it through. */
+ /** `auto`, `thin` or `none`. `none` paints no bar and reserves no gutter. */
+ scrollbarWidth?: string
+ /** `auto`, or the thumb colour then the track colour, as in CSS. */
+ scrollbarColor?: string
+ /** `auto`, `stable` or `stable both-edges`. `stable` reserves the gutter
+ * of a classic bar even while the content fits, and `both-edges` adds
+ * the same gutter at the start of the axis. Overlay bars reserve
+ * nothing, as in CSS. */
+ scrollbarGutter?: string
+ /** Space scrollIntoView keeps around this element, a number of pixels
+ * or "Npx", alone or as the CSS one-to-four shorthand. */
+ scrollMargin?: Numeric
+ scrollMarginTop?: Numeric
+ scrollMarginRight?: Numeric
+ scrollMarginBottom?: Numeric
+ scrollMarginLeft?: Numeric
+ /** Space scrollIntoView keeps inside this scroll box. */
+ scrollPadding?: Numeric
+ scrollPaddingTop?: Numeric
+ scrollPaddingRight?: Numeric
+ scrollPaddingBottom?: Numeric
+ scrollPaddingLeft?: Numeric
+ /** `auto`, `contain` or `none`, one word for both axes or two with the x
+ * axis first. A scroll box keeps a wheel event it can scroll with. At its
+ * end, `auto` hands the event to the nearest scroll box around it and
+ * `contain` or `none` keeps it. */
overscrollBehavior?: string
overscrollBehaviorX?: string
overscrollBehaviorY?: string
@@ -769,6 +798,11 @@ export interface NativeRenderer {
* `offsetInItem` is in pixels; a negative value anchors the viewport top
* above the item, resolved against measured row heights at layout time. */
scrollToItem?(elementId: number, index: number, offsetInItem?: number): void
+ /** Scroll every ancestor scroll box so the element shows, like the web
+ * scrollIntoView. block places it on the y axis and inline on the x
+ * axis: "start", "center", "end" or "nearest". The defaults match the
+ * web: "start" and "nearest". */
+ scrollIntoView?(elementId: number, block?: string, inline?: string): void
/** Get the current scroll offset [x, y] or null if element is not scrollable. */
getScrollOffset?(elementId: number): Array | null
/** The logical scroll anchor of a ``: