GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(scrollbars): every list's bar rides a centre line, behind the plate
A sink-behind ScrollRegion's bars now ride the centre lines of what they
scroll and cross there, centred_scrollbar_width() thick, over the rows
with no lane; edge_inset is for edge bars only. push_prims no longer
draws the fore copy of a sink-behind region (it landed under the rows a
host draws next): push_scrollbar_fore does, at the fade, and a framed
region's idle copy lies under its bg every frame rather than only while
sunk, so the fore copy fades over it instead of blinking at the latch.
ScrollBox gains the same opt-in (sink_behind, a ScrollbarActivity,
paint_scrollbar_pills for the two copies), its bar maths folded into one
bar_geom. TreeList turns it on: the idle copy under its plate, the fore
copy over the rows, replacing its binary on-top timer. ParametersBg
exposes scrollbar_fade so its host can fade rather than flip, and
layout::centred_scrollbar_width is the one width the centred bars share.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 50 ++++++
src/layout.rs | 10 ++
src/widget/container/parameters_bg.rs | 7 +
src/widget/container/scroll_box.rs | 291 +++++++++++++++++++++++-----------
src/widget/container/spreadsheet.rs | 6 +-
src/widget/container/treelist.rs | 55 ++-----
src/widget/scroll_region.rs | 217 ++++++++++++++++++++-----
7 files changed, 456 insertions(+), 180 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index dd8d6fe..f276044 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2218,3 +2218,53 @@ plate is the host's, before the plate, through `paint_scrollbars(rect,
ctx, 1.0)` (`scrollbars_shown` says whether there is one) — the plate is
the host's too. The vertical bar owns the middle of the cross for a drag.
`the_scrollbars_cross_at_the_body_and_sink_until_scrolled` is the test.
+
+### Every scrollbar rides a centre line, behind the plate
+
+The rule the spreadsheet's cross follows is the DE's one scrollbar design
+(2026-10-06), and every scrolling list and pane in the toolkit offers it:
+
+- **It rides the CENTRE line of what it scrolls** — the vertical bar down
+ the middle of the width, the horizontal one across the middle of the
+ viewport — so with both they cross there. Over the content, reserving no
+ lane. `layout::centred_scrollbar_width()` thick (`scrollbar_width` × 1.6:
+ over rows the stock width reads too slim), pills in the shared track and
+ thumb colours.
+- **It idles BEHIND the host's translucent plate** and takes no press
+ there: a press on its lane is a press on the row under it.
+- **A scroll raises it in front with a fade** (`ScrollbarActivity`): a
+ wheel, a key, a glide or coast in motion, a host's own scroll
+ (`notify_scrolled`), a thumb drag. A pointer over a RAISED bar holds it
+ up; hover never raises a sunk one. With nothing holding it for
+ `SCROLL_ACTIVE_HOLD` it sinks, fading out over `SCROLL_FADE_SECS`.
+- **Two copies, the host's and the widget's.** The idle copy is drawn at
+ full alpha BEFORE the plate, every frame — raised or not, since the fore
+ copy fades in over it and dropping it at the latch would blink the bar.
+ The fore copy is drawn after the content at the activity's `fade()`.
+
+Who draws what:
+
+| Widget | Opt in | Idle copy (before the plate) | Fore copy (after the content) |
+|---|---|---|---|
+| `Spreadsheet` | always | host: `paint_scrollbars(rect, ctx, 1.0)` | the widget's own paint |
+| `ParametersBg` | always | host: `scrollbar_quads()` as pills | host: the same at `scrollbar_fade()` |
+| `ScrollRegion` | `with_sink_behind(true)` | framed: `push_prims`, under its bg; frameless: host, `push_scrollbar_prims` | host: `push_scrollbar_fore` |
+| `ScrollBox` | `sink_behind = true` | host: `paint_scrollbar_pills(pc, 1.0)` | host: `paint_scrollbar_pills(pc, scrollbar_fade())` |
+| `TreeList` | always (its `ScrollBox`) | the widget, under its own plate | the widget, over the rows and the well's wall |
+
+For `ScrollRegion` and `ScrollBox`, sinking IS centring: a region or box
+that does not opt in keeps its always-on bar at the right/bottom edge
+(`edge_inset` applies to those only), and a sink-behind one ignores it.
+A sink-behind `ScrollRegion`'s `push_prims` no longer draws the fore copy,
+which would land under the rows a host draws after it. The tuple path
+(`push_quads` / `push_scrollbar_quads`) is flat squares on a hard flip,
+vertical only; every sink-behind host is on the prim path. The relief
+scrollbar (`paint_relief_scrollbar`, carved groove and bevelled thumb)
+is for edge bars only: shader-lit relief does not fade with a vertex
+alpha. Tests: `sink_behind_bars_cross_at_the_centre`,
+`the_fore_copy_is_drawn_after_the_rows`,
+`a_sink_behind_bar_rides_the_centre_and_sinks_until_scrolled`.
+
+**Not on it, deliberately:** a multi-line `TextBox`'s position indicator
+stays at the right edge, always shown and non-interactive — an editor
+keeps its place marker (the user's call, 2026-10-06).
diff --git a/src/layout.rs b/src/layout.rs
index abf0096..cb81482 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3324,6 +3324,16 @@ pub fn scrollbar_width() -> f32 {
*SCROLLBAR_WIDTH.read().unwrap()
}
+/// The thickness of a CENTRED scrollbar — one that rides the centre line of
+/// what it scrolls, over the content and behind the host's plate until a
+/// scroll raises it (the params pane, the spreadsheet, a sink-behind
+/// `ScrollRegion`, the designer's dialog). [`scrollbar_width`] widened by
+/// 1.6: over rows rather than in a lane of its own, the stock width reads
+/// too slim.
+pub fn centred_scrollbar_width() -> f32 {
+ scrollbar_width() * 1.6
+}
+
pub fn set_scrollbar_width(width: f32) {
if let Ok(mut lock) = SCROLLBAR_WIDTH.write() {
*lock = width;
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 3bf99aa..c7cd242 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -914,6 +914,13 @@ impl ParametersBg {
self.activity.raised()
}
+ /// How far the bar's FORE copy has faded in, 0..=1: the host draws the
+ /// idle copy before the plate every frame and the fore copy after the
+ /// rows at this alpha, so the raise and the sink are a fade, not a flip.
+ pub fn scrollbar_fade(&self) -> f32 {
+ self.activity.fade()
+ }
+
/// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
fn recompute_scrollbar_raised(&mut self) -> bool {
let visible = self.scrollbar_visible();
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 124df75..0504ae2 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -67,6 +67,14 @@ pub struct ScrollBox {
pub show_background: bool,
pub scrollbar_dragging: bool,
pub drag_offset_y: f32,
+ /// Opt-in: the bar rides the box's CENTRE line and idles behind the
+ /// host's plate, raised in front by a scroll ([`ScrollbarActivity`]) —
+ /// the DE's one scrollbar design, as a sink-behind `ScrollRegion` has it.
+ /// The host draws the idle copy before its plate and the fore copy after
+ /// its content, both through [`Self::paint_scrollbar_pills`]. Off, the
+ /// bar hugs the right edge and is always grabbable, as it always was.
+ pub sink_behind: bool,
+ activity: crate::widget::ScrollbarActivity,
/// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
motion: crate::widget::scroll_motion::ScrollMotion,
}
@@ -85,10 +93,92 @@ impl ScrollBox {
show_background: true,
scrollbar_dragging: false,
drag_offset_y: 0.0,
+ sink_behind: false,
+ activity: crate::widget::ScrollbarActivity::new(),
motion: crate::widget::scroll_motion::ScrollMotion::new(),
}
}
+ /// The bar's geometry, `(sb_x, track_y, sb_w, track_h, thumb_y,
+ /// thumb_h)`, or `None` while the content fits — the one source for the
+ /// paint, the hit test, the press and the drag. Centred on the box's
+ /// width at [`crate::layout::centred_scrollbar_width`] for a
+ /// [`Self::sink_behind`] box, at the right edge otherwise.
+ fn bar_geom(&self) -> Option<(f32, f32, f32, f32, f32, f32)> {
+ if self.content_h <= self.viewport_h {
+ return None;
+ }
+ let (sb_x, sb_w) = if self.sink_behind {
+ let sb_w = crate::layout::centred_scrollbar_width();
+ (self.base.x + (self.base.w - sb_w) * 0.5, sb_w)
+ } else {
+ let sb_w = crate::layout::scrollbar_width();
+ (self.base.x + self.base.w - sb_w - 4.0, sb_w)
+ };
+ let track_h = self.viewport_h - 8.0;
+ let track_y = self.viewport_y + 4.0;
+ let visible_ratio = self.viewport_h / self.content_h;
+ let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
+ let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
+ Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h))
+ }
+
+ /// Move the thumb so its grab point is at `py`, returning whether the
+ /// offset moved.
+ fn drag_thumb_to(&mut self, py: f32) -> bool {
+ let Some((_, track_y, _, track_h, _, thumb_h)) = self.bar_geom() else { return false };
+ let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+ let target = py - self.drag_offset_y;
+ let ratio = if track_h - thumb_h > 0.0 { ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0) } else { 0.0 };
+ let old = self.scroll_y;
+ self.scroll_y = ratio * max_scroll;
+ (self.scroll_y - old).abs() > 0.01
+ }
+
+ /// Whether the bar is raised in front of the host's plate — the latch,
+ /// which gates input. Always true for a box that does not sink.
+ pub fn scrollbar_raised(&self) -> bool {
+ !self.sink_behind || self.activity.raised()
+ }
+
+ /// How far the fore copy has faded in, 0..=1. Always 1 for a box that
+ /// does not sink.
+ pub fn scrollbar_fade(&self) -> f32 {
+ if self.sink_behind { self.activity.fade() } else { 1.0 }
+ }
+
+ /// The host moved `scroll_y` itself (a scroll to the selection): raise a
+ /// sink-behind bar as a wheel would.
+ pub fn notify_scrolled(&mut self) {
+ if self.sink_behind {
+ self.activity.bump();
+ self.activity.recompute(self.content_h > self.viewport_h, self.scrollbar_dragging);
+ }
+ }
+
+ /// The bar as flat pills in the track and thumb colours, scaled to
+ /// `alpha` — what a sink-behind host draws twice: the idle copy at 1
+ /// BEFORE its plate, and the fore copy at [`Self::scrollbar_fade`]
+ /// after its content. (The relief bar is not used for a sinking one:
+ /// shader-lit relief does not fade with a vertex alpha.)
+ pub fn paint_scrollbar_pills(&self, pc: &mut crate::scene::paint::PaintCtx, alpha: f32) {
+ let a = alpha.clamp(0.0, 1.0);
+ let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.bar_geom() else { return };
+ if a <= 0.001 {
+ return;
+ }
+ let dim = |mut c: [f32; 4]| {
+ c[3] *= a;
+ c
+ };
+ use crate::scene::layout::Rect;
+ let all = (true, true, true, true);
+ pc.rounded_rect(Rect { x: sb_x, y: track_y, width: sb_w, height: track_h }, sb_w.min(track_h) * 0.5, all, dim(crate::color::scrollbar_track_color()));
+ pc.rounded_rect(Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h }, sb_w.min(thumb_h) * 0.5, all, dim(crate::color::scrollbar_thumb_color()));
+ }
+
pub fn update_bounds(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
self.content_h = content_h;
self.viewport_y = viewport_y;
@@ -109,17 +199,16 @@ impl ScrollBox {
self.motion.is_animating()
}
+ /// Whether `(px, py)` is on the bar's strip (±4px slop). A sunk bar is
+ /// behind the host's plate and is never hit: a press on its lane is a
+ /// press on the content under it.
pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
- if self.content_h <= self.viewport_h {
+ if !self.scrollbar_raised() {
return false;
}
- let sb_w = crate::layout::scrollbar_width();
- let sb_x = self.base.x + self.base.w - sb_w - 4.0;
- let sb_track_h = self.viewport_h - 8.0;
- let sb_track_y = self.viewport_y + 4.0;
-
- px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
- && py >= sb_track_y && py <= sb_track_y + sb_track_h
+ self.bar_geom().is_some_and(|(sb_x, track_y, sb_w, track_h, _, _)| {
+ px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
+ })
}
/// Screen y for an item at `virtual_y`, or `None` when it doesn't
@@ -178,32 +267,14 @@ impl ScrollBox {
if self.hit_test_scrollbar(px, py) {
self.claim_focus(ctx);
self.scrollbar_dragging = true;
-
- let sb_track_h = self.viewport_h - 8.0;
- let sb_track_y = self.viewport_y + 4.0;
- let visible_ratio = self.viewport_h / self.content_h;
- let thumb_h = if sb_track_h <= 20.0 {
- sb_track_h
- } else {
- (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
- };
- let max_scroll = (self.content_h - self.viewport_h).max(0.0);
- let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
- let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
-
+ let (_, _, _, _, thumb_y, thumb_h) = self.bar_geom().expect("a hit bar has geometry");
let click_offset = py - thumb_y;
if click_offset >= 0.0 && click_offset <= thumb_h {
self.drag_offset_y = click_offset;
} else {
// Clicked outside the thumb: jump thumb center to py
self.drag_offset_y = thumb_h / 2.0;
- let target_thumb_y = py - self.drag_offset_y;
- let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
- ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
- } else {
- 0.0
- };
- self.scroll_y = new_scroll_ratio * max_scroll;
+ self.drag_thumb_to(py);
}
return true;
} else {
@@ -213,7 +284,7 @@ impl ScrollBox {
self.claim_focus(ctx);
}
} else if state == ElementState::Released {
- self.scrollbar_dragging = false;
+ self.end_thumb_drag();
}
}
false
@@ -235,30 +306,19 @@ impl ScrollBox {
if !self.scrollbar_dragging {
return false;
}
- let sb_track_h = self.viewport_h - 8.0;
- let sb_track_y = self.viewport_y + 4.0;
- let visible_ratio = self.viewport_h / self.content_h;
- let thumb_h = if sb_track_h <= 20.0 {
- sb_track_h
- } else {
- (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
- };
- let max_scroll = (self.content_h - self.viewport_h).max(0.0);
-
- let target_thumb_y = py - self.drag_offset_y;
- let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
- ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
- } else {
- 0.0
- };
-
- let old_scroll = self.scroll_y;
- self.scroll_y = new_scroll_ratio * max_scroll;
- (self.scroll_y - old_scroll).abs() > 0.01
+ self.drag_thumb_to(py)
}
pub fn drag_end(&mut self) {
- self.scrollbar_dragging = false;
+ self.end_thumb_drag();
+ }
+
+ /// A thumb drag let go: a sink-behind bar lingers for the hold window
+ /// rather than sinking the instant the button lifts.
+ fn end_thumb_drag(&mut self) {
+ if std::mem::take(&mut self.scrollbar_dragging) && self.sink_behind {
+ self.activity.bump();
+ }
}
/// The legacy `WidgetHost` default `cursor_moved` entry (cce-test-interface's panel copy
@@ -278,29 +338,13 @@ impl ScrollBox {
pub fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
let mut changed = false;
- if self.scrollbar_dragging {
- let sb_track_h = self.viewport_h - 8.0;
- let sb_track_y = self.viewport_y + 4.0;
- let visible_ratio = self.viewport_h / self.content_h;
- let thumb_h = if sb_track_h <= 20.0 {
- sb_track_h
- } else {
- (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
- };
- let max_scroll = (self.content_h - self.viewport_h).max(0.0);
-
- let target_thumb_y = py - self.drag_offset_y;
- let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
- ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
- } else {
- 0.0
- };
-
- let old_scroll = self.scroll_y;
- self.scroll_y = new_scroll_ratio * max_scroll;
- if (self.scroll_y - old_scroll).abs() > 0.01 {
- changed = true;
- }
+ if self.scrollbar_dragging && self.drag_thumb_to(py) {
+ changed = true;
+ }
+ if self.sink_behind {
+ // Gated on the latch: a sunk bar reports no hover, so hover only
+ // ever holds up a bar a scroll raised.
+ self.activity.set_hover(self.hit_test_scrollbar(px, py));
}
let was = self.base.hovered;
@@ -318,7 +362,18 @@ impl ScrollBox {
self.motion.reconcile(0.0, self.scroll_y);
let moved = self.motion.tick(dt, Bounds::max(0.0), self.bounds_y());
self.scroll_y = self.motion.y.pos();
- moved || self.motion.is_animating()
+ let animating = self.motion.is_animating();
+ if !self.sink_behind {
+ return moved || animating;
+ }
+ // A glide or coast in motion holds the bar up as the scroll that
+ // began it did; the hold keeps frames coming until the sink renders.
+ if moved {
+ self.activity.bump();
+ }
+ let holding = self.activity.holding();
+ let flipped = self.activity.tick(dt, self.content_h > self.viewport_h, self.scrollbar_dragging);
+ moved || animating || flipped || holding
}
pub fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
@@ -326,6 +381,9 @@ impl ScrollBox {
self.motion.reconcile(0.0, self.scroll_y);
let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), self.bounds_y());
self.scroll_y = self.motion.y.pos();
+ if changed {
+ self.notify_scrolled();
+ }
changed
} else {
false
@@ -343,26 +401,8 @@ impl ScrollBox {
// Scrollbar
- if self.content_h > self.viewport_h {
- let sb_w = crate::layout::scrollbar_width();
- let sb_x = self.base.x + self.base.w - sb_w - 4.0;
- let sb_track_h = self.viewport_h - 8.0;
- let sb_track_y = self.viewport_y + 4.0;
-
- // Track
- quads.push((sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()));
-
- // Thumb
- let visible_ratio = self.viewport_h / self.content_h;
- let thumb_h = if sb_track_h <= 20.0 {
- sb_track_h
- } else {
- (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
- };
- let max_scroll = (self.content_h - self.viewport_h).max(0.0);
- let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
- let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
-
+ if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.bar_geom() {
+ quads.push((sb_x, track_y, sb_w, track_h, crate::color::scrollbar_track_color()));
quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
}
@@ -387,6 +427,14 @@ impl ScrollBox {
}
pub fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+ let moved = self.keyboard_scroll(event, ctx);
+ if moved {
+ self.notify_scrolled();
+ }
+ moved
+ }
+
+ fn keyboard_scroll(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
// Focus never lands on the box itself (post-6av it is not an `WidgetHost`), and its id is
// never a tree ancestor of the focused widget — like the legacy address walk, this
// gate only ever passes via the hover check below.
@@ -472,6 +520,61 @@ unsafe impl Sync for ScrollBox {}
mod tests {
use super::*;
+ /// A sink-behind box's bar rides the centre line, takes no press while
+ /// sunk (the content under its lane does), is raised by a wheel and held
+ /// by a pointer over it, and sinks once nothing holds it; the fore copy
+ /// fades rather than flips. A plain box keeps its edge bar.
+ #[test]
+ fn a_sink_behind_bar_rides_the_centre_and_sinks_until_scrolled() {
+ let mut ui = UiContext::new();
+ let mut sb = ScrollBox::new();
+ sb.sink_behind = true;
+ sb.set_rect(10.0, 20.0, 200.0, 100.0);
+ sb.update_bounds(400.0, 20.0, 100.0);
+ let (sb_x, _, sb_w, _, _, _) = sb.bar_geom().unwrap();
+ assert!((sb_x + sb_w * 0.5 - 110.0).abs() < 0.01, "on the centre line");
+ assert_eq!(sb_w, crate::layout::centred_scrollbar_width());
+
+ // Sunk: the lane is the content's.
+ assert!(!sb.scrollbar_raised());
+ assert!(!sb.hit_test_scrollbar(110.0, 60.0));
+ assert!(!sb.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 60.0, &mut ui));
+ assert!(!sb.scrollbar_dragging);
+ // Hover never raises a sunk bar.
+ sb.on_cursor_moved(110.0, 60.0, &mut ui);
+ sb.tick(0.016, &mut ui);
+ assert!(!sb.scrollbar_raised());
+
+ // A wheel raises it; the fore copy fades in.
+ assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 60.0, &mut ui));
+ assert!(sb.scrollbar_raised());
+ assert!(sb.tick(0.05, &mut ui));
+ assert!(sb.scrollbar_fade() > 0.0 && sb.scrollbar_fade() < 1.0, "fading in: {}", sb.scrollbar_fade());
+ for _ in 0..1000 {
+ if !sb.is_animating() { break; }
+ sb.tick(1.0 / 60.0, &mut ui);
+ }
+ // Raised, the bar takes the press; a pointer over it holds it up.
+ assert!(sb.hit_test_scrollbar(110.0, 60.0));
+ sb.on_cursor_moved(110.0, 60.0, &mut ui);
+ sb.tick(1.0, &mut ui);
+ assert!(sb.scrollbar_raised(), "held by the pointer");
+ sb.on_cursor_moved(40.0, 60.0, &mut ui);
+ sb.tick(0.016, &mut ui);
+ assert!(!sb.scrollbar_raised(), "unheld, it sinks");
+ for _ in 0..30 {
+ sb.tick(0.016, &mut ui);
+ }
+ assert_eq!(sb.scrollbar_fade(), 0.0);
+
+ let mut plain = ScrollBox::new();
+ plain.set_rect(10.0, 20.0, 200.0, 100.0);
+ plain.update_bounds(400.0, 20.0, 100.0);
+ let (sb_x, _, sb_w, _, _, _) = plain.bar_geom().unwrap();
+ assert_eq!(sb_x + sb_w, 10.0 + 200.0 - 4.0, "a plain box keeps its edge bar");
+ assert!(plain.scrollbar_raised() && plain.hit_test_scrollbar(sb_x + 1.0, 60.0));
+ }
+
#[test]
fn test_scroll_box_bounds_scrolling() {
let mut sb = ScrollBox::new();
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 77f5d97..56785dc 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -206,10 +206,10 @@ impl Spreadsheet {
})
}
- /// A bar's thickness — the DE `scrollbar_width` widened, as the params
- /// pane's bar is, since it sits over cells rather than in a lane.
+ /// A bar's thickness — the DE's centred width, as the params pane's bar
+ /// is, since it sits over cells rather than in a lane.
fn bar_w() -> f32 {
- crate::layout::scrollbar_width() * 1.6
+ crate::layout::centred_scrollbar_width()
}
/// A thumb's length on a track `track` long showing `ratio` of the content.
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 1b365d6..1ff19d0 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -178,7 +178,6 @@ pub struct TreeList {
pub clicked_item: Option<TreeElement>,
pub right_clicked_section: Option<String>,
pub last_scroll_y: f32,
- pub scrollbar_activity_timer: f32,
/// Lights the recess rim (`paint` has no UiContext, so this mirrors focus).
/// Driven by FocusIn/FocusOut by default; an app whose inline editors float
/// over the tree (cce-data-editor) overrides it per input event with its own
@@ -196,6 +195,9 @@ impl TreeList {
pub fn new() -> Adapted<TreeList> {
let mut scroll_box = ScrollBox::new();
scroll_box.show_background = false;
+ // The DE's scrollbar: down the tree's centre line, behind its plate
+ // until a scroll raises it (`paint`).
+ scroll_box.sink_behind = true;
Adapted::new(TreeList {
base: Widget::new(),
scroll_box,
@@ -214,7 +216,6 @@ impl TreeList {
clicked_item: None,
right_clicked_section: None,
last_scroll_y: 0.0,
- scrollbar_activity_timer: 0.0,
focused: false,
deleted_key_path: None,
edit_box: TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true),
@@ -758,6 +759,11 @@ impl Paint for TreeList {
c
};
+ // 0. The scrollbar's idle copy, BEFORE the plate: sunk behind it, it
+ // shows dimly through the translucent fill and takes no press. The
+ // fore copy fades in over the rows (below) while a scroll holds it.
+ self.scroll_box.paint_scrollbar_pills(pc, 1.0);
+
// 1. Draw container border and background. A true fill + border ring
// (not the legacy full-rect border punched out by the background quad,
// which read as a whole-pane border_color wash once the background
@@ -794,31 +800,6 @@ impl Paint for TreeList {
quads.push((list_left + 180.0, y + offset_y + 1.0, 1.0, header_h - 2.0, 0.0, apply_opacity(header_border_color), (false, false, false, false)));
quads.push((list_left + 235.0, y + offset_y + 1.0, 1.0, header_h - 2.0, 0.0, apply_opacity(header_border_color), (false, false, false, false)));
- // Helper to collect scrollbar quads
- let get_scrollbar_quads = || {
- let mut sb_quads = Vec::new();
- let scroll_quads = self.scroll_box.extra_quads();
- if scroll_quads.len() > 1 {
- for q in &scroll_quads[1..] {
- sb_quads.push((q.0, q.1, q.2, q.3, 0.0, q.4, (false, false, false, false)));
- }
- }
- sb_quads
- };
-
- let show_on_top = self.scrollbar_activity_timer > 0.0;
- let relief_scrollbar = crate::layout::control_relief();
-
- // If NOT on top, draw scrollbar first (behind items). Relief style
- // emits prims directly — they land before the quads flush below, so
- // the ordering matches the flat path.
- if !show_on_top {
- if relief_scrollbar {
- self.scroll_box.paint_scrollbar_relief(pc);
- } else {
- quads.extend(get_scrollbar_quads());
- }
- }
// 2. Draw items (row backgrounds, separator lines, color previews)
let list_left = self.scroll_box.base.x;
@@ -940,11 +921,6 @@ impl Paint for TreeList {
}
}
- // If on top (scroll activity), the scrollbar draws after the rows —
- // the relief prims are emitted after the quads flush below.
- if show_on_top && !relief_scrollbar {
- quads.extend(get_scrollbar_quads());
- }
for (qx, qy, qw, qh, qr, qc, qcorners) in quads {
if qr > 0.1 {
pc.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, qcorners, qc);
@@ -952,9 +928,6 @@ impl Paint for TreeList {
pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}
}
- if show_on_top && relief_scrollbar {
- self.scroll_box.paint_scrollbar_relief(pc);
- }
// Recessed well like a text box or list: the tree floor sits below the
// pane surface, its wall carved over the bg and row quads above. Focus
@@ -971,6 +944,10 @@ impl Paint for TreeList {
}
}
+ // The scrollbar's fore copy, over the rows and the well's wall, at
+ // the activity's fade.
+ self.scroll_box.paint_scrollbar_pills(pc, self.scroll_box.scrollbar_fade());
+
// Row/header labels with the legacy header/list viewport bounds.
let font = Some(crate::layout::tree_font());
let (x, y, w, _h) = (rect.x, rect.y, rect.width, rect.height);
@@ -1209,13 +1186,11 @@ impl Input for TreeList {
if self.scroll_box.tick(dt, ui) {
changed = true;
}
+ // A scroll the tree made itself (following the selection) raises the
+ // bar as a wheel would.
if (self.scroll_box.scroll_y - self.last_scroll_y).abs() > 0.01 {
self.last_scroll_y = self.scroll_box.scroll_y;
- self.scrollbar_activity_timer = 1.0;
- changed = true;
- }
- if self.scrollbar_activity_timer > 0.0 {
- self.scrollbar_activity_timer = (self.scrollbar_activity_timer - dt).max(0.0);
+ self.scroll_box.notify_scrolled();
changed = true;
}
changed
diff --git a/src/widget/scroll_region.rs b/src/widget/scroll_region.rs
index 03ff4b9..979ff6b 100644
--- a/src/widget/scroll_region.rs
+++ b/src/widget/scroll_region.rs
@@ -164,14 +164,22 @@ pub struct ScrollRegion {
/// sit directly on the window plate (the scrollbar still draws).
pub draw_frame: bool,
/// Gap between the vertical bar's right edge and the region's right edge.
- /// The default 4.0 hugs a framed list's border; page-level bars floating
- /// over a window plate use [`crate::layout::scrollbar_inset`] for the
- /// designer's stood-off look.
+ /// The default 4.0 hugs a framed list's border. Edge bars only: a
+ /// [`Self::sink_behind`] region's bars ride the centre lines and ignore it.
pub edge_inset: f32,
/// Opt-in raise/sink behavior ([`ScrollbarActivity`]): the bar idles sunk
/// (host draws it behind its plate via [`Self::push_scrollbar_prims`]) and
/// is non-interactive until a scroll raises it. Off (the default), the bar
- /// is always drawn and always grabbable — existing hosts unchanged.
+ /// is always drawn and always grabbable at the right/bottom edge.
+ ///
+ /// **A sink-behind bar rides the CENTRE line of what it scrolls** (since
+ /// 2026-10-06): the vertical bar down the region's middle, the horizontal
+ /// one across the viewport's, crossing there when both scroll — over the
+ /// rows, reserving no lane, [`crate::layout::centred_scrollbar_width`]
+ /// thick. It is the DE's one scrollbar design (the params pane, the
+ /// spreadsheet, the designer's dialog): sunk it is behind the plate and a
+ /// press on its lane is a press on the row under it, so the middle of
+ /// the list costs nothing until a scroll raises the bar there.
pub sink_behind: bool,
activity: ScrollbarActivity,
/// The smooth-scroll driver behind `scroll_x`/`scroll_y`: wheel notches
@@ -346,9 +354,16 @@ impl ScrollRegion {
}
/// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
+ /// Centred on the region's width for a [`Self::sink_behind`] region, at the
+ /// right edge (less [`Self::edge_inset`]) otherwise.
fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
- let sb_w = crate::layout::scrollbar_width();
- let sb_x = self.x + self.w - sb_w - self.edge_inset;
+ let (sb_x, sb_w) = if self.sink_behind {
+ let sb_w = crate::layout::centred_scrollbar_width();
+ (self.x + (self.w - sb_w) * 0.5, sb_w)
+ } else {
+ let sb_w = crate::layout::scrollbar_width();
+ (self.x + self.w - sb_w - self.edge_inset, sb_w)
+ };
let track_h = self.viewport_h - 8.0;
let track_y = self.viewport_y + 4.0;
let visible_ratio = self.viewport_h / self.content_h.max(1.0);
@@ -376,13 +391,19 @@ impl ScrollRegion {
}
/// Bottom scrollbar geometry, mirroring [`Self::scrollbar_geom`] with the
- /// axes swapped: (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w). The
+ /// axes swapped: (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w). An edge
/// track stops short of the vertical bar's strip so the pills never
- /// overlap in the corner.
+ /// overlap in the corner; a centred one runs the full width across the
+ /// viewport's middle and crosses the vertical bar there.
fn h_scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
- let sb_h = crate::layout::scrollbar_width();
- let sb_y = self.y + self.h - sb_h - 4.0;
- let right_reserve = if self.content_h > self.viewport_h { sb_h + 8.0 } else { 0.0 };
+ let (sb_y, sb_h, right_reserve) = if self.sink_behind {
+ let sb_h = crate::layout::centred_scrollbar_width();
+ (self.viewport_y + (self.viewport_h - sb_h) * 0.5, sb_h, 0.0)
+ } else {
+ let sb_h = crate::layout::scrollbar_width();
+ let reserve = if self.content_h > self.viewport_h { sb_h + 8.0 } else { 0.0 };
+ (self.y + self.h - sb_h - 4.0, sb_h, reserve)
+ };
let track_x = self.x + 4.0;
let track_w = self.w - 8.0 - right_reserve;
let visible_ratio = self.w / self.content_w.max(1.0);
@@ -634,6 +655,12 @@ impl ScrollRegion {
/// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
/// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
+ ///
+ /// A [`Self::sink_behind`] region draws no FORE copy here: the host draws
+ /// its rows after this call, and a fore bar drawn before them would sit
+ /// under them. Call [`Self::push_scrollbar_fore`] once the rows are down.
+ /// A framed sink region draws its behind copy here, under the bg fill; a
+ /// frameless one leaves it to the host, under the host's plate.
pub fn push_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
if self.draw_frame {
let radius = crate::layout::list_corner_radius();
@@ -646,11 +673,13 @@ impl ScrollRegion {
};
let all = (true, true, true, true);
pc.rect_with_radius_corners(border_color, self.x, self.y, self.w, self.h, radius, all);
- // A sunk sink-behind bar draws here, UNDER the translucent bg fill
- // (list_bg_color's alpha is 0.3): it shows through dimly, sunk into
- // the list plate — the designer parameter-pane look, self-contained
- // for framed regions.
- if self.sink_behind && !self.activity.raised() {
+ // A sink-behind bar's idle copy draws here, UNDER the translucent
+ // bg fill (list_bg_color's alpha is 0.3): it shows through dimly,
+ // sunk into the list plate — the designer parameter-pane look,
+ // self-contained for framed regions. Always, raised or not: the
+ // fore copy fades in OVER it, so dropping it at the latch would
+ // blink the bar out under a fore copy still at a low alpha.
+ if self.sink_behind {
self.push_scrollbar_prims(pc);
}
pc.rect_with_radius_corners(
@@ -663,23 +692,29 @@ impl ScrollRegion {
all,
);
}
- // Raised (or plain always-on): the bar rides on top. A FRAMELESS
- // sink-behind region draws no sunk layer here — its rows sit directly
- // on the host's plate, so the host owns the under-plate emission via
- // `push_scrollbar_prims`.
- // The fore copy fades rather than flips, and keeps drawing all the way
- // out — gating this on `scrollbar_raised` would cut the fade off at the
- // latch. The tuple path below is deliberately left on the hard flip:
- // its hosts have no frosted plate for a sunk bar to show through.
- let fade = self.scrollbar_fade();
- if fade > 0.001 {
- self.push_scrollbar_prims_alpha(pc, fade);
+ // A plain region's bar rides on top, always. A sink-behind region's
+ // fore copy is the host's to draw after its rows
+ // (`push_scrollbar_fore`); a FRAMELESS one draws no idle copy here
+ // either — its rows sit directly on the host's plate, so the host
+ // owns the under-plate emission via `push_scrollbar_prims`.
+ if !self.sink_behind {
+ self.push_scrollbar_prims(pc);
+ }
+ }
+
+ /// A sink-behind region's FORE copy, at the activity's fade: draw it
+ /// after the rows. It fades rather than flips and keeps drawing all the
+ /// way out — gating it on `scrollbar_raised` would cut the fade off at
+ /// the latch. Nothing for a plain region, whose bar `push_prims` drew.
+ pub fn push_scrollbar_fore(&self, pc: &mut dyn crate::layout::RenderTarget) {
+ if self.sink_behind {
+ self.push_scrollbar_prims_alpha(pc, self.activity.fade());
}
}
/// The pill scrollbars alone (track + thumb, both axes), drawn wherever the
- /// host calls it. A sink-behind host emits this twice a frame at most:
- /// under its plate while the bar is sunk, over the content while raised.
+ /// host calls it — a frameless sink-behind host draws its idle copy with
+ /// this, every frame, BEFORE its plate.
pub fn push_scrollbar_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
self.push_scrollbar_prims_alpha(pc, 1.0);
}
@@ -697,22 +732,31 @@ impl ScrollRegion {
c[3] *= a;
c
};
- if self.content_h > self.viewport_h {
- // Track and thumb are pills — half-width radius (the designer look).
- let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
- let all = (true, true, true, true);
- pc.rect_with_radius_corners(dim(crate::color::scrollbar_track_color()), sb_x, track_y, sb_w, track_h, sb_w.min(track_h) * 0.5, all);
- pc.rect_with_radius_corners(dim(crate::color::scrollbar_thumb_color()), sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
+ // Track and thumb are pills — half-width radius (the designer look).
+ // Both tracks before either thumb, so where centred bars cross the
+ // horizontal track does not cover the vertical thumb.
+ let all = (true, true, true, true);
+ let track = dim(crate::color::scrollbar_track_color());
+ let thumb = dim(crate::color::scrollbar_thumb_color());
+ let v = (self.content_h > self.viewport_h).then(|| self.scrollbar_geom());
+ let h = self.h_scroll_active().then(|| self.h_scrollbar_geom());
+ if let Some((sb_x, track_y, sb_w, track_h, _, _)) = v {
+ pc.rect_with_radius_corners(track, sb_x, track_y, sb_w, track_h, sb_w.min(track_h) * 0.5, all);
}
- if self.h_scroll_active() {
- let (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w) = self.h_scrollbar_geom();
- let all = (true, true, true, true);
- pc.rect_with_radius_corners(dim(crate::color::scrollbar_track_color()), track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
- pc.rect_with_radius_corners(dim(crate::color::scrollbar_thumb_color()), thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
+ if let Some((track_x, sb_y, track_w, sb_h, _, _)) = h {
+ pc.rect_with_radius_corners(track, track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
+ }
+ if let Some((sb_x, _, sb_w, _, thumb_y, thumb_h)) = v {
+ pc.rect_with_radius_corners(thumb, sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
+ }
+ if let Some((_, sb_y, _, sb_h, thumb_x, thumb_w)) = h {
+ pc.rect_with_radius_corners(thumb, thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
}
}
- /// Flat background fill for hosts on the tuple pipeline. The scrollbar is
+ /// Flat background fill for hosts on the tuple pipeline. Flat squares on a
+ /// hard flip, the vertical bar only: a sink-behind host wants the prim
+ /// path (pills, both axes, the fade), and every one in the DE is on it. The scrollbar is
/// split into [`Self::push_scrollbar_quads`] so the host can emit it AFTER
/// the rows — drawn together, the rows paint over the thumb and it peeks
/// through the inter-row gaps as dotted segments.
@@ -787,6 +831,11 @@ mod tests {
r
}
+ /// The left edge of a sink-behind [`region`]'s centred vertical bar.
+ fn centred_bar_x() -> f32 {
+ 10.0 + (200.0 - crate::layout::centred_scrollbar_width()) * 0.5
+ }
+
/// Run the glide out (a no-op with smoothing off in the test host's config).
fn settle(r: &mut ScrollRegion) {
let mut n = 0;
@@ -926,7 +975,7 @@ mod tests {
r.update_bounds(10, 20.0, 100.0);
assert!(!r.scrollbar_raised());
// Sunk: a press on the bar strip falls through (the plate occludes it).
- let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
+ let sb_x = centred_bar_x();
assert!(!r.press(sb_x + 1.0, 50.0));
assert!(!r.dragging);
// A wheel scroll raises it in the same frame…
@@ -950,7 +999,7 @@ mod tests {
fn hover_sustains_but_never_raises() {
let mut r = region().with_sink_behind(true);
r.update_bounds(10, 20.0, 100.0);
- let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
+ let sb_x = centred_bar_x();
// Hovering the sunk bar's strip does not raise it.
r.cursor_moved(sb_x + 1.0, 50.0);
assert!(!r.tick(0.016));
@@ -1000,6 +1049,88 @@ mod tests {
assert_eq!((under.len(), over.len()), (1, 2));
}
+ /// A sink-behind region's bars ride the centre lines and cross there:
+ /// the vertical bar down the region's middle, the horizontal one across
+ /// the viewport's, the horizontal track running the full width rather
+ /// than stopping short of a corner. Sunk, a press on either lane is a
+ /// press on the rows; raised, it is the bar's.
+ #[test]
+ fn sink_behind_bars_cross_at_the_centre() {
+ let mut r = region().with_sink_behind(true).with_edge_inset(30.0);
+ r.update_bounds(10, 20.0, 100.0);
+ r.set_content_w(500.0);
+ let (sb_x, _, sb_w, _, _, _) = r.scrollbar_geom();
+ assert!((sb_x + sb_w * 0.5 - (10.0 + 100.0)).abs() < 0.01, "vertical bar on the centre line, edge_inset ignored");
+ assert_eq!(sb_w, crate::layout::centred_scrollbar_width());
+ let (track_x, sb_y, track_w, sb_h, _, _) = r.h_scrollbar_geom();
+ assert!((sb_y + sb_h * 0.5 - (20.0 + 50.0)).abs() < 0.01, "horizontal bar on the viewport's centre line");
+ assert_eq!((track_x, track_w), (14.0, 192.0), "the horizontal track crosses, reserving no corner");
+
+ // Sunk: the middle of the list is the rows'.
+ assert!(!r.press(110.0, 50.0));
+ assert!(!r.press(40.0, 70.0));
+ assert!(!r.dragging);
+ // Raised by a scroll, the cross takes the press.
+ r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
+ assert!(r.press(110.0, 40.0));
+ assert!(r.release());
+ assert!(r.press(40.0, 70.0));
+ assert!(r.release());
+
+ // A plain region keeps its edge bars.
+ let mut plain = region();
+ plain.update_bounds(10, 20.0, 100.0);
+ let (sb_x, _, sb_w, _, _, _) = plain.scrollbar_geom();
+ assert_eq!(sb_x + sb_w, 10.0 + 200.0 - 4.0);
+ }
+
+ /// Records the alpha of every rect a paint emits.
+ #[derive(Default)]
+ struct Alphas(Vec<f32>);
+ impl crate::layout::RenderTarget for Alphas {
+ fn rect(&mut self, color: [f32; 4], _x: f32, _y: f32, _w: f32, _h: f32) {
+ self.0.push(color[3]);
+ }
+ fn text(&mut self, _: &str, _: f32, _: f32, _: f32, _: [f32; 4]) {}
+ }
+
+ /// A sink-behind region's `push_prims` draws the frame and the idle copy
+ /// under its bg, never the fore copy, which would land under the rows the
+ /// host draws next; `push_scrollbar_fore` draws that, at the fade, and
+ /// nothing while the bar is sunk. A plain region's bar is still in
+ /// `push_prims`, and its fore call is quiet.
+ #[test]
+ fn the_fore_copy_is_drawn_after_the_rows() {
+ let mut r = region().with_sink_behind(true);
+ r.update_bounds(10, 20.0, 100.0);
+ let mut prims = Alphas::default();
+ r.push_prims(&mut prims);
+ // border, idle track + thumb, bg — and no fore copy.
+ assert_eq!(prims.0.len(), 4);
+ let mut fore = Alphas::default();
+ r.push_scrollbar_fore(&mut fore);
+ assert!(fore.0.is_empty(), "sunk: no fore copy");
+ r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
+ r.tick(SCROLL_FADE_SECS * 0.5);
+ let mut prims = Alphas::default();
+ r.push_prims(&mut prims);
+ assert_eq!(prims.0.len(), 4, "raised, the idle copy still lies under the bg");
+ let mut fore = Alphas::default();
+ r.push_scrollbar_fore(&mut fore);
+ assert_eq!(fore.0.len(), 2);
+ let track = crate::color::scrollbar_track_color()[3];
+ assert!(fore.0[0] < track, "half faded in: {} of {}", fore.0[0], track);
+
+ let mut plain = region();
+ plain.update_bounds(10, 20.0, 100.0);
+ let mut prims = Alphas::default();
+ plain.push_prims(&mut prims);
+ assert_eq!(prims.0.len(), 4, "border, bg, track, thumb");
+ let mut fore = Alphas::default();
+ plain.push_scrollbar_fore(&mut fore);
+ assert!(fore.0.is_empty());
+ }
+
#[test]
fn non_sink_regions_are_unchanged() {
let mut r = region();