GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
scroll-region: shared raise/sink scrollbar activity + edge inset
Extract the designer parameter-pane scrollbar treatment into the toolkit so
every app's bar can behave the same way:
- ScrollbarActivity: the raise/sink hysteresis lifted out of ParametersBg
(wheel/drag raises, hover only sustains, 0.7s hold decays in tick; sunk =
behind the host's translucent plate and non-interactive). ParametersBg now
uses it — behavior unchanged.
- ScrollRegion: opt-in with_sink_behind() gates the bar's hit tests on
raised, raises on wheel/keyboard scrolls and drag releases, ticks the
hold (returning the host's repaint signal), and push_scrollbar_prims
lets hosts emit the pill bars under their plate while sunk / over the
content while raised. with_edge_inset() stands the bar off the region's
right edge; the default 4.0 keeps framed lists exactly as they were.
- layout::scrollbar_inset(): config style.control.scrollbar.inset
(default 16), for page-level bars floating over a window plate.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/layout.rs | 37 +++++
src/widget/container/parameters_bg.rs | 56 +++-----
src/widget/mod.rs | 2 +-
src/widget/scroll_region.rs | 256 +++++++++++++++++++++++++++++++++-
4 files changed, 308 insertions(+), 43 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index fd739d0..e3ddbdf 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -97,6 +97,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
"style.control.rangeslider.height" => "rangeslider_height",
"style.control.rangeslider.corner_radius" | "style.rangeslider.corner_radius" => "rangeslider_corner_radius",
"style.control.scrollbar.width" => "scrollbar_width",
+ "style.control.scrollbar.inset" => "scrollbar_inset",
"style.control.spinbox.height" => "spinbox_height",
"style.control.spinbox.button_padding" => "spinbox_button_padding",
"style.control.spinbox.corner_radius" => "spinbox_corner_radius",
@@ -324,6 +325,7 @@ static BUTTON_HEIGHT: RwLock<f32> = RwLock::new(40.0);
static RAMP_HEIGHT: RwLock<f32> = RwLock::new(32.0);
static BUTTON_STRIP_SPACING: RwLock<f32> = RwLock::new(8.0);
static SCROLLBAR_WIDTH: RwLock<f32> = RwLock::new(4.0);
+static SCROLLBAR_INSET: RwLock<f32> = RwLock::new(16.0);
static COLUMN_GAP: RwLock<f32> = RwLock::new(16.0);
static CONTROL_PANEL_PADDING: RwLock<f32> = RwLock::new(16.0);
static CONTROL_PANEL_GAP: RwLock<f32> = RwLock::new(12.0);
@@ -537,6 +539,15 @@ pub fn reload_config() {
}
}
}
+ if let Some(rest) = trimmed.strip_prefix("scrollbar_inset") {
+ let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+ let val_str = rest.trim_end_matches('"').trim();
+ if let Ok(val) = val_str.parse::<f32>() {
+ if let Ok(mut lock) = SCROLLBAR_INSET.write() {
+ *lock = val;
+ }
+ }
+ }
if let Some(rest) = trimmed.strip_prefix("tree_opacity") {
let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
let val_str = rest.trim_end_matches('"').trim();
@@ -2854,6 +2865,32 @@ pub fn set_scrollbar_width(width: f32) {
}
}
+/// How far a page-level scrollbar stands off its window/plate right edge — the
+/// designer parameter-pane look (config `style.control.scrollbar.inset`).
+/// Framed inner lists keep their own tight 4px hug; this is for bars floating
+/// over a plate.
+pub fn scrollbar_inset() -> f32 {
+ use std::sync::Once;
+ static INIT: Once = Once::new();
+ INIT.call_once(|| {
+ if let Some(content) = read_config() {
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if let Some(rest) = trimmed.strip_prefix("scrollbar_inset") {
+ let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+ let val_str = rest.trim_end_matches('"').trim();
+ if let Ok(val) = val_str.parse::<f32>() {
+ if let Ok(mut lock) = SCROLLBAR_INSET.write() {
+ *lock = val;
+ }
+ }
+ }
+ }
+ }
+ });
+ *SCROLLBAR_INSET.read().unwrap()
+}
+
pub fn tree_opacity() -> f32 {
use std::sync::Once;
static INIT: Once = Once::new();
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 1cb70e0..512bab7 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -70,21 +70,12 @@ pub struct ParametersBg {
pub content_h: f32,
scrollbar_dragging: bool,
drag_offset_y: f32,
- /// Seconds left in the "recently scrolled" window that keeps the scrollbar raised in
- /// front of the pane plate; decays in `tick`. See [`ParametersBg::scrollbar_active`].
- scroll_activity: f32,
- /// Whether the pointer currently sits over the scrollbar track (updated on pointer move).
- scrollbar_hover: bool,
- /// Latched "raised in front of the plate" state, with hysteresis: a wheel scroll (or an
- /// active drag) raises it; hover only *sustains* an already-raised bar; nothing else
- /// raises it. While sunk it is behind the plate, so hover and clicks can't reach it —
- /// the plate occludes it. Recomputed via [`ParametersBg::recompute_scrollbar_raised`].
- scrollbar_raised: bool,
+ /// The raise/sink hysteresis (wheel/drag raises, hover sustains, the hold decays in
+ /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
+ /// this widget so every app's plate-straddling scrollbar behaves the same way.
+ activity: crate::widget::ScrollbarActivity,
}
-/// How long (seconds) the scrollbar stays raised after the last wheel scroll or drag release.
-const SCROLL_ACTIVE_HOLD: f32 = 0.7;
-
/// The channel: the ONLY gap a control keeps from whatever its edge meets — the
/// neighboring control, or its section's wall. Controls pack edge-to-edge; the
/// reliefs on either side (control bevel, section wall) shade the channel into a
@@ -168,9 +159,7 @@ impl ParametersBg {
content_h: 0.0,
scrollbar_dragging: false,
drag_offset_y: 0.0,
- scroll_activity: 0.0,
- scrollbar_raised: false,
- scrollbar_hover: false,
+ activity: crate::widget::ScrollbarActivity::new(),
})
}
@@ -526,23 +515,13 @@ impl ParametersBg {
/// Whether the scrollbar is currently raised in front of the pane plate (the latched
/// state). While this is false the bar sits behind the plate and is non-interactive.
pub fn scrollbar_active(&self) -> bool {
- self.scrollbar_raised
+ self.activity.raised()
}
- /// Recompute the latched "raised" state with hysteresis, returning whether it changed.
- ///
- /// A wheel scroll (`scroll_activity`) or an active drag raises the bar. Hover only
- /// *sustains* a bar that is already raised — it can never raise a sunk one, because a
- /// sunk bar is behind the plate and the plate is what the pointer is actually over. Once
- /// nothing holds it up it sinks, and can only rise again by scrolling.
+ /// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
fn recompute_scrollbar_raised(&mut self) -> bool {
- let raised = self.scrollbar_visible()
- && (self.scrollbar_dragging
- || self.scroll_activity > 0.0
- || (self.scrollbar_raised && self.scrollbar_hover));
- let changed = raised != self.scrollbar_raised;
- self.scrollbar_raised = raised;
- changed
+ let visible = self.scrollbar_visible();
+ self.activity.recompute(visible, self.scrollbar_dragging)
}
/// The scrollbar's track + thumb quads (empty when no scrollbar is needed). The host draws
@@ -1709,7 +1688,7 @@ impl Input for ParametersBg {
fn drag_end(&mut self) {
if self.scrollbar_dragging {
self.scrollbar_dragging = false;
- self.scroll_activity = SCROLL_ACTIVE_HOLD;
+ self.activity.bump();
return;
}
if let Some(i) = self.dragging_param.take() {
@@ -1793,12 +1772,12 @@ impl Input for ParametersBg {
}
// Decay the "recently scrolled" window; keep frames coming until it expires so the
// scrollbar's sink behind the plate actually renders.
- if self.scroll_activity > 0.0 {
- self.scroll_activity = (self.scroll_activity - dt).max(0.0);
+ if self.activity.holding() {
changed = true;
}
// Re-latch the raised state (e.g. sink once the scroll window lapses).
- if self.recompute_scrollbar_raised() {
+ let visible = self.scrollbar_visible();
+ if self.activity.tick(dt, visible, self.scrollbar_dragging) {
changed = true;
}
changed
@@ -1826,7 +1805,8 @@ impl Input for ParametersBg {
// Track scrollbar hover, then re-latch: hover only sustains an already-raised
// bar (a sunk one is behind the plate, so the pointer never reaches it), so
// the only visible change here is the raised state — redraw on that transition.
- self.scrollbar_hover = self.hit_test_scrollbar(px, py);
+ let hover = self.hit_test_scrollbar(px, py);
+ self.activity.set_hover(hover);
let raised_changed = self.recompute_scrollbar_raised();
let Some(ui) = ectx.ui.as_deref_mut() else {
return raised_changed;
@@ -1934,7 +1914,7 @@ impl Input for ParametersBg {
if state == ElementState::Pressed {
// Only a raised bar can be grabbed — a sunk one is behind the plate,
// so the press falls through to the pane content underneath it.
- if self.scrollbar_raised && self.hit_test_scrollbar(px, py) {
+ if self.activity.raised() && self.hit_test_scrollbar(px, py) {
// Legacy called `self.focus()` here — base flag only, which
// nothing reads (see module docs).
self.scrollbar_dragging = true;
@@ -1971,7 +1951,7 @@ impl Input for ParametersBg {
} else if state == ElementState::Released {
if self.scrollbar_dragging {
self.scrollbar_dragging = false;
- self.scroll_activity = SCROLL_ACTIVE_HOLD;
+ self.activity.bump();
return true;
}
}
@@ -2641,7 +2621,7 @@ impl Input for ParametersBg {
self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
if (self.scroll_y - old_scroll).abs() > 0.01 {
self.update_slider_rects();
- self.scroll_activity = SCROLL_ACTIVE_HOLD;
+ self.activity.bump();
self.recompute_scrollbar_raised();
}
}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 7b39a39..d5dcbee 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -537,7 +537,7 @@ pub mod scroll_region;
// Re-exports
pub use self::editor::TextEditorState;
pub use self::layout_helper::{ColumnLayout, RowLayout};
-pub use self::scroll_region::ScrollRegion;
+pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
pub use self::model::{Adapted, EventCtx, Input, Layout, Paint};
pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
pub use self::core::focus::link_parent_child;
diff --git a/src/widget/scroll_region.rs b/src/widget/scroll_region.rs
index 38cadd3..5dc8a0c 100644
--- a/src/widget/scroll_region.rs
+++ b/src/widget/scroll_region.rs
@@ -24,6 +24,84 @@
use crate::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
+/// How long (seconds) a raise/sink scrollbar stays raised after the last wheel
+/// scroll or drag release.
+pub const SCROLL_ACTIVE_HOLD: f32 = 0.7;
+
+/// The raise/sink hysteresis for scrollbars that idle BEHIND their host's
+/// translucent plate — the designer parameter-pane treatment, shared so every
+/// app's bar behaves the same way. The bar has two depths: *raised* it draws in
+/// front of the content and takes input; *sunk* it draws under the host's plate
+/// (dimly visible through a translucent one) and is non-interactive, because
+/// the plate occludes it.
+///
+/// The rules: a scroll (wheel, keyboard) or an active thumb drag raises the
+/// bar, and a drag release refreshes the hold. Hover only *sustains* a bar
+/// that is already raised — it can never raise a sunk one, since the pointer
+/// is really over the plate, not the bar. Once nothing holds it up for
+/// [`SCROLL_ACTIVE_HOLD`] seconds it sinks, and only scrolling brings it back.
+///
+/// The owner drives it: [`Self::bump`] on scrolls and drag releases,
+/// [`Self::set_hover`] from pointer moves, [`Self::tick`] once per frame
+/// (which decays the hold and recomputes — a `true` return is the repaint
+/// signal for the raise/sink flip).
+#[derive(Debug, Clone, Default)]
+pub struct ScrollbarActivity {
+ /// Seconds left in the "recently scrolled" window that keeps the bar raised.
+ activity: f32,
+ hover: bool,
+ raised: bool,
+}
+
+impl ScrollbarActivity {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Whether the bar is currently raised in front of the plate. While false
+ /// it sits behind the plate and must not take input.
+ pub fn raised(&self) -> bool {
+ self.raised
+ }
+
+ /// Refresh the hold window: call on a wheel/keyboard scroll and on a drag
+ /// release.
+ pub fn bump(&mut self) {
+ self.activity = SCROLL_ACTIVE_HOLD;
+ }
+
+ /// Track whether the pointer sits over the bar (raw geometry — the caller
+ /// does not gate this on raised; the hysteresis is what limits hover to
+ /// sustaining).
+ pub fn set_hover(&mut self, over: bool) {
+ self.hover = over;
+ }
+
+ /// Whether the post-scroll hold window is still running — owners whose tick
+ /// chain only runs while frames are being drawn use this to keep frames
+ /// coming until the sink actually renders.
+ pub fn holding(&self) -> bool {
+ self.activity > 0.0
+ }
+
+ /// Recompute the latched raised state, returning whether it changed.
+ pub fn recompute(&mut self, visible: bool, dragging: bool) -> bool {
+ let raised = visible && (dragging || self.activity > 0.0 || (self.raised && self.hover));
+ let changed = raised != self.raised;
+ self.raised = raised;
+ changed
+ }
+
+ /// Per-frame decay + recompute. Returns whether the raised state flipped —
+ /// the owner's repaint signal.
+ pub fn tick(&mut self, dt: f32, visible: bool, dragging: bool) -> bool {
+ if self.activity > 0.0 {
+ self.activity = (self.activity - dt).max(0.0);
+ }
+ self.recompute(visible, dragging)
+ }
+}
+
#[derive(Debug, Clone)]
pub struct ScrollRegion {
pub x: f32,
@@ -53,6 +131,17 @@ pub struct ScrollRegion {
/// Draw the border + background plate in `push_prims`. Off = frameless: rows
/// 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.
+ 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.
+ pub sink_behind: bool,
+ activity: ScrollbarActivity,
}
impl Default for ScrollRegion {
@@ -88,6 +177,9 @@ impl ScrollRegion {
hovered: false,
focused: false,
draw_frame: true,
+ edge_inset: 4.0,
+ sink_behind: false,
+ activity: ScrollbarActivity::new(),
}
}
@@ -96,6 +188,16 @@ impl ScrollRegion {
self
}
+ pub fn with_edge_inset(mut self, inset: f32) -> Self {
+ self.edge_inset = inset;
+ self
+ }
+
+ pub fn with_sink_behind(mut self, sink: bool) -> Self {
+ self.sink_behind = sink;
+ self
+ }
+
pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.x = x;
self.y = y;
@@ -172,7 +274,7 @@ impl ScrollRegion {
/// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
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 - 4.0;
+ let sb_x = self.x + self.w - sb_w - self.edge_inset;
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);
@@ -190,6 +292,11 @@ impl ScrollRegion {
if self.content_h <= self.viewport_h {
return false;
}
+ // A sunk bar is behind the host's plate: the plate occludes it, so the
+ // pointer can neither grab nor jump-scroll it.
+ if self.sink_behind && !self.activity.raised() {
+ return false;
+ }
let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
}
@@ -219,6 +326,9 @@ impl ScrollRegion {
if !self.h_scroll_active() {
return false;
}
+ if self.sink_behind && !self.activity.raised() {
+ return false;
+ }
let (track_x, sb_y, track_w, sb_h, _, _) = self.h_scrollbar_geom();
py >= sb_y - 4.0 && py <= sb_y + sb_h + 4.0 && px >= track_x && px <= track_x + track_w
}
@@ -274,7 +384,13 @@ impl ScrollRegion {
pub fn release(&mut self) -> bool {
// Bitwise on purpose: both drags must reset even when the first
// operand is already true (|| would short-circuit the take).
- std::mem::take(&mut self.dragging) | std::mem::take(&mut self.dragging_h)
+ let was_dragging = std::mem::take(&mut self.dragging) | std::mem::take(&mut self.dragging_h);
+ if was_dragging && self.sink_behind {
+ // A drag release starts the hold window, so the bar lingers
+ // briefly instead of sinking the instant the button lifts.
+ self.activity.bump();
+ }
+ was_dragging
}
fn drag_move(&mut self, py: f32) -> bool {
@@ -295,6 +411,12 @@ impl ScrollRegion {
/// tint and the keyboard scope.
pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
self.hovered = self.hit(px, py);
+ if self.sink_behind {
+ // Gated hit tests: a sunk bar reports no hover, so hover can only
+ // sustain a raised bar (the hysteresis contract).
+ self.activity
+ .set_hover(self.hit_scrollbar(px, py) || self.hit_h_scrollbar(px, py));
+ }
if self.dragging {
self.drag_move(py);
return true;
@@ -327,7 +449,47 @@ impl ScrollRegion {
// vertical-only list ignores them (max_scroll_x = 0 clamps to 0).
let old_x = self.scroll_x;
self.scroll_x = (self.scroll_x + dx).clamp(0.0, self.max_scroll_x());
- (self.scroll_y - old_y).abs() > 0.01 || (self.scroll_x - old_x).abs() > 0.01
+ let changed = (self.scroll_y - old_y).abs() > 0.01 || (self.scroll_x - old_x).abs() > 0.01;
+ if changed {
+ self.raise();
+ }
+ changed
+ }
+
+ /// Whether the bar overflows in either axis — the raise/sink "visible" input.
+ fn overflowing(&self) -> bool {
+ self.content_h > self.viewport_h || self.h_scroll_active()
+ }
+
+ /// Refresh the raise hold and recompute immediately, so a scroll shows the
+ /// bar in the same frame's redraw rather than one tick later.
+ fn raise(&mut self) {
+ if self.sink_behind {
+ self.activity.bump();
+ self.activity.recompute(self.overflowing(), self.dragging || self.dragging_h);
+ }
+ }
+
+ /// Whether the scrollbar currently draws in front of the content and takes
+ /// input. Always true for a region without [`Self::sink_behind`].
+ pub fn scrollbar_raised(&self) -> bool {
+ !self.sink_behind || self.activity.raised()
+ }
+
+ /// Per-frame raise/sink upkeep for sink-behind regions; a `true` return is
+ /// the host's repaint signal. True while the post-scroll hold is running,
+ /// not just on the flip: the demand-driven frame loop only keeps ticking
+ /// while frames flow, so the hold must keep them coming or the sink would
+ /// stall until the next input event. No-op (false) without `sink_behind`.
+ pub fn tick(&mut self, dt: f32) -> bool {
+ if !self.sink_behind {
+ return false;
+ }
+ let holding = self.activity.holding();
+ let flipped = self
+ .activity
+ .tick(dt, self.overflowing(), self.dragging || self.dragging_h);
+ flipped || holding
}
/// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
@@ -365,10 +527,15 @@ impl ScrollRegion {
_ => return false,
}
if (self.scroll_x - old_x).abs() > 0.01 {
+ self.raise();
return true;
}
}
- (self.scroll_y - old).abs() > 0.01
+ let changed = (self.scroll_y - old).abs() > 0.01;
+ if changed {
+ self.raise();
+ }
+ changed
}
/// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
@@ -395,6 +562,17 @@ impl ScrollRegion {
all,
);
}
+ // A sunk sink-behind bar is NOT drawn here: the host paints it under
+ // its plate via `push_scrollbar_prims` so it shows through dimly.
+ if self.scrollbar_raised() {
+ self.push_scrollbar_prims(pc);
+ }
+ }
+
+ /// 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.
+ pub fn push_scrollbar_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
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();
@@ -545,6 +723,76 @@ mod tests {
assert!(!r.press(50.0, 50.0));
}
+ #[test]
+ fn edge_inset_moves_the_bar_off_the_edge() {
+ let mut r = region().with_edge_inset(20.0);
+ r.update_bounds(10, 20.0, 100.0);
+ // Bar right edge sits edge_inset in from the region's right edge; the
+ // old 4px position no longer hits.
+ let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 20.0;
+ assert!(r.press(sb_x + 1.0, 50.0));
+ assert!(r.release());
+ let old_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0 + 1.0;
+ assert!(!r.press(old_x + 4.1, 50.0)); // past the ±4 slop of the inset bar
+ }
+
+ #[test]
+ fn sink_behind_gates_input_until_a_scroll_raises() {
+ let mut r = region().with_sink_behind(true);
+ 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;
+ assert!(!r.press(sb_x + 1.0, 50.0));
+ assert!(!r.dragging);
+ // A wheel scroll raises it in the same frame…
+ assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+ assert!(r.scrollbar_raised());
+ // …and now the bar takes the grab.
+ assert!(r.press(sb_x + 1.0, 50.0));
+ assert!(r.dragging);
+ assert!(r.release());
+ // The release refreshed the hold: still raised, and the hold keeps the
+ // repaint signal up so the frame loop keeps ticking toward the sink.
+ assert!(r.scrollbar_raised());
+ assert!(r.tick(0.3)); // holding → keep frames coming
+ assert!(r.scrollbar_raised());
+ assert!(r.tick(SCROLL_ACTIVE_HOLD)); // hold lapses → sink flip reported
+ assert!(!r.scrollbar_raised());
+ assert!(!r.tick(0.016)); // settled sunk: quiet again
+ }
+
+ #[test]
+ 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;
+ // Hovering the sunk bar's strip does not raise it.
+ r.cursor_moved(sb_x + 1.0, 50.0);
+ assert!(!r.tick(0.016));
+ assert!(!r.scrollbar_raised());
+ // Raise by scrolling, hover it, and let the hold lapse: hover sustains.
+ r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
+ r.cursor_moved(sb_x + 1.0, 50.0);
+ r.tick(SCROLL_ACTIVE_HOLD + 0.1); // hold lapses, hover keeps it raised
+ assert!(r.scrollbar_raised());
+ assert!(!r.tick(0.016)); // sustained by hover alone: no repaint churn
+ // Pointer leaves: the next tick sinks it.
+ r.cursor_moved(50.0, 50.0);
+ assert!(r.tick(0.016));
+ assert!(!r.scrollbar_raised());
+ }
+
+ #[test]
+ fn non_sink_regions_are_unchanged() {
+ let mut r = region();
+ r.update_bounds(10, 20.0, 100.0);
+ assert!(r.scrollbar_raised()); // always interactive
+ assert!(!r.tick(1.0)); // tick is a no-op
+ let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
+ assert!(r.press(sb_x + 1.0, 50.0));
+ }
+
#[test]
fn keyboard_is_hover_or_focus_scoped() {
let mut r = region();