From 3209c99d1c20cc0c817e271ca40268cb9549f08f Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:24:08 +0200 Subject: [PATCH 1/4] feat(native): paint scrollbars and scroll an element into view --- .changeset/scrollbars.md | 34 + packages/native/index.d.ts | 14 + packages/native/src/renderer.rs | 118 ++- packages/native/src/renderer/frame.rs | 52 +- .../native/src/renderer/scroll_into_view.rs | 213 +++++ packages/native/src/renderer/scrollbar.rs | 836 ++++++++++++++++++ packages/native/src/style.rs | 19 + packages/native/src/style/resolve.rs | 13 +- packages/native/src/test_renderer.rs | 35 +- .../react/src/__tests__/scrollbars.test.tsx | 157 ++++ packages/react/src/types/host.ts | 39 + 11 files changed, 1484 insertions(+), 46 deletions(-) create mode 100644 .changeset/scrollbars.md create mode 100644 packages/native/src/renderer/scroll_into_view.rs create mode 100644 packages/native/src/renderer/scrollbar.rs create mode 100644 packages/react/src/__tests__/scrollbars.test.tsx 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/packages/native/index.d.ts b/packages/native/index.d.ts index e2bd9e4d..55bdc712 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -59,6 +59,15 @@ export declare class GpuixRenderer { getWindowSize(): WindowSize /** `"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 @@ -260,6 +269,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. diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index d5389e32..af78ea53 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -43,6 +43,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(crate) use batch::apply_batch_to_tree; @@ -70,6 +72,17 @@ pub(crate) fn to_element_id(id: f64) -> 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, + ] +} + thread_local! { #[cfg(target_os = "macos")] static MAC_PLATFORM: RefCell>> = const { RefCell::new(None) }; @@ -122,10 +135,7 @@ pub(crate) fn debug_frame_overlay_stats_js( } #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] -fn recv_ui_response( - receiver: std::sync::mpsc::Receiver, - operation: &str, -) -> Result { +fn recv_ui_response(receiver: std::sync::mpsc::Receiver, operation: &str) -> Result { match receiver.recv_timeout(Duration::from_secs(2)) { Ok(response) => Ok(response), Err(RecvTimeoutError::Timeout) => Err(Error::from_reason(format!( @@ -222,6 +232,11 @@ enum UiCommand { id: u64, index: usize, }, + ScrollIntoView { + id: u64, + block: scroll_into_view::Align, + inline: scroll_into_view::Align, + }, GetScrollOffset { id: u64, response: SyncSender>, @@ -343,25 +358,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) }) }) }); @@ -496,13 +516,10 @@ impl GpuixRenderer { input, response: response_sender, })?; - recv_ui_response(response_receiver, "the GPUI UI command")? - .map_err(Error::from_reason) + recv_ui_response(response_receiver, "the GPUI UI command")?.map_err(Error::from_reason) } - fn automation_bounds( - &self, - ) -> Result> { + fn automation_bounds(&self) -> Result> { #[cfg(target_os = "macos")] return Ok(crate::automation::all_bounds()); @@ -522,10 +539,7 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } - fn element_bounds( - &self, - id: u64, - ) -> Result> { + fn element_bounds(&self, id: u64) -> Result> { #[cfg(target_os = "macos")] return Ok(crate::automation::get_bounds(id)); @@ -1023,6 +1037,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 { @@ -1299,10 +1352,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(|| { @@ -1310,10 +1360,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() }) }) })); @@ -1323,8 +1370,7 @@ impl GpuixRenderer { let (response, receiver) = sync_channel(1); self.send_ui_command(UiCommand::GetScrollOffset { id, response })?; return Ok( - recv_ui_response(receiver, "the GPUI scroll query")? - .map(|[x, y]| vec![x, y]), + recv_ui_response(receiver, "the GPUI scroll query")?.map(|[x, y]| vec![x, y]) ); } @@ -1631,6 +1677,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, /// Keeps the cmd-c observer alive for as long as the view. @@ -1668,6 +1716,7 @@ impl GpuixView { custom_registry: CustomElementRegistry::with_defaults(), scroll_handles: HashMap::new(), motion_states: HashMap::new(), + scrollbars: HashMap::new(), selection, _copy_subscription: copy_subscription, virtual_lists: HashMap::new(), @@ -1746,6 +1795,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(), @@ -1800,10 +1850,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 { @@ -1974,6 +2021,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 8fbc9659..966a53ba 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, @@ -361,13 +362,15 @@ pub(crate) fn build_div( // 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(); @@ -391,13 +394,42 @@ pub(crate) fn build_div( .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), @@ -629,9 +661,25 @@ pub(crate) fn build_div( }; } + // 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(), + } +} + /// A selectable text run owned by `element_id`. Runs are left to gpui so the /// text keeps inheriting colour, weight and family from ancestor styles. fn text_content(element_id: u64, content: &str, ctx: &BuildCtx) -> gpui::AnyElement { 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..d136456f --- /dev/null +++ b/packages/native/src/renderer/scrollbar.rs @@ -0,0 +1,836 @@ +//! 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`. +fn scrollbar_colors(value: &str) -> (Option, Option) { + let words = split_top_level(value); + let color = |index: usize| { + words + .get(index) + .and_then(|word| crate::color::parse_color_rgba(word)) + .map(Hsla::from) + }; + (color(0), color(1)) +} + +/// 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)); + } + + 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 988759a1..efc54553 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 938df240..70a7cb75 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -637,17 +637,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_div(). + // overflow: "scroll" and "auto" need StatefulInteractiveElement, so build_div() 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_div. - 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 630749d6..e19f9eb4 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -23,7 +23,7 @@ use crate::events::EventPayload; use crate::renderer::{ apply_batch_to_tree, debug_frame_overlay_mode_name, debug_frame_overlay_stats_js, parse_debug_frame_overlay_mode, DebugFrameOverlayStats, - to_element_id, EventCallback, GpuixView, + offset_to_js, to_element_id, EventCallback, GpuixView, }; use crate::retained_tree::RetainedTree; use crate::style::StyleDesc; @@ -578,6 +578,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. #[napi] @@ -674,10 +702,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..ff50e2a9 --- /dev/null +++ b/packages/react/src/__tests__/scrollbars.test.tsx @@ -0,0 +1,157 @@ +/** + * 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 ( +
+
+ inner +
+
+ ) +} + +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("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/types/host.ts b/packages/react/src/types/host.ts index 2365c5ab..234c33fb 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -240,9 +240,43 @@ 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 + /** `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?: number | string + scrollMarginTop?: number | string + scrollMarginRight?: number | string + scrollMarginBottom?: number | string + scrollMarginLeft?: number | string + /** Space scrollIntoView keeps inside this scroll box. */ + scrollPadding?: number | string + scrollPaddingTop?: number | string + scrollPaddingRight?: number | string + scrollPaddingBottom?: number | string + scrollPaddingLeft?: number | string + /** `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 /** * A CSS cursor keyword: `default`, `pointer`, `text`, `vertical-text`, @@ -606,6 +640,11 @@ export interface NativeRenderer { scrollTo?(elementId: number, x: number, y: number): void /** Scroll a child into view by its index in the children list. */ scrollToItem?(elementId: number, index: 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 From 855038e457301e26b9f719e3ea521d2cb7bc5257 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:08:18 +0200 Subject: [PATCH 2/4] fix(scrollbars): take auto or exactly two colors for scrollbar-color --- packages/native/src/renderer/scrollbar.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/native/src/renderer/scrollbar.rs b/packages/native/src/renderer/scrollbar.rs index d136456f..00dfdc84 100644 --- a/packages/native/src/renderer/scrollbar.rs +++ b/packages/native/src/renderer/scrollbar.rs @@ -226,15 +226,25 @@ enum ThumbLook { } /// `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) }; - (color(0), color(1)) + 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 @@ -776,6 +786,15 @@ mod tests { 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()); From db6c5088d29a79dbfc8befd5e3d95f1d55121c18 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 15:59:41 +0200 Subject: [PATCH 3/4] feat(demo): add a scrollbars panel --- examples/demo.test.tsx | 39 ++++++- examples/demo/app.tsx | 2 + examples/demo/scrollbars.tsx | 195 +++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 examples/demo/scrollbars.tsx diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 3b358808..60249e6c 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -20,6 +20,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 { Variables } from "./demo/variables" import { resolveClassName } from "./demo/classes" @@ -40,6 +41,7 @@ const PANELS = [ ["inheritance", ], ["classes", ], ["motion", ], + ["scrollbars", ], ] as const describeNative("demo panels", () => { @@ -205,6 +207,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 @@ -214,7 +251,7 @@ describeNative("the whole application", () => { test.render() expect(test.renderer.getPaintedText()).toContain("GPUIX") - for (const title of ["Lengths", "Variables", "Inheritance", "className", "Motion", "Performance", "Colours"]) { + for (const title of ["Lengths", "Variables", "Inheritance", "className", "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 f16934d8..42ca5f08 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 { Variables } from "./variables.js" /// The palette every panel reads. Exported so a test can mount one panel @@ -76,6 +77,7 @@ const SECTIONS = [ { id: "inheritance", title: "Inheritance", render: () => }, { id: "classes", title: "className", 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) => ( +
+
+ {`row ${i + 1}`} +
+ ))} +
+ ) +} + +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 ( + + +