git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit267529e293fbf6cf282a92219116688be53d0291
parent105af35156
authorLucas Galante <[email protected]>
date2026-09-01 12:36
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();