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

src/widget/scroll_region.rs (49.5K)

   1 //! The shared app-side scroll region — scroll state, virtualization math,
   2 //! scrollbar geometry/input, and frame/scrollbar emission for a list whose ROWS
   3 //! the app draws itself.
   4 //!
   5 //! Lifted from the per-app copies (cce-system-interface's Phase 6q dissolution
   6 //! original, re-copied into cce-fonts, cce-mail, cce-cloud, and
   7 //! cce-layout-interface) so the virtualization CONTRACT lives in one place:
   8 //!
   9 //! **`get_item_draw_y`/`get_draw_y` return every row that INTERSECTS the
  10 //! viewport, partially visible rows included. Callers draw those rows under a
  11 //! clip (the `PaintCtx` clip stack, or per-quad clamping), so an edge row
  12 //! renders cut — never culled.** The copies' original full-containment test
  13 //! made cards vanish the moment they touched the viewport edge, separately in
  14 //! every app; hit-testing must accept the same partial rows the draw shows
  15 //! (a visible sliver that ignores clicks is the same bug mirrored).
  16 //!
  17 //! The frame/scrollbar can be emitted two ways, matching the two app styles:
  18 //! [`ScrollRegion::push_prims`] draws the bordered frame + pill scrollbars onto
  19 //! a [`crate::layout::RenderTarget`]; [`ScrollRegion::push_quads`] /
  20 //! [`ScrollRegion::push_scrollbar_quads`] emit flat quads for hosts on the
  21 //! tuple pipeline (split so the scrollbar can draw AFTER the rows — drawn
  22 //! together, rows paint over the thumb and it peeks through the inter-row gaps
  23 //! as dotted segments).
  24 
  25 use crate::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion, LINE_PX};
  26 use crate::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
  27 
  28 /// How long (seconds) a raise/sink scrollbar stays raised after the last wheel
  29 /// scroll or drag release.
  30 pub const SCROLL_ACTIVE_HOLD: f32 = 0.7;
  31 
  32 /// How long (seconds) the raise and the sink take to cross-fade. Coming to the
  33 /// fore is a fade, not a flip: the bar reads as rising through the host's
  34 /// frosted plate rather than being swapped for a copy of itself.
  35 pub const SCROLL_FADE_SECS: f32 = 0.18;
  36 
  37 /// The raise/sink hysteresis for scrollbars that idle BEHIND their host's
  38 /// translucent plate — the designer parameter-pane treatment, shared so every
  39 /// app's bar behaves the same way. The bar has two depths: *raised* it draws in
  40 /// front of the content and takes input; *sunk* it draws under the host's plate
  41 /// (dimly visible through a translucent one) and is non-interactive, because
  42 /// the plate occludes it.
  43 ///
  44 /// The rules: a scroll (wheel, keyboard) or an active thumb drag raises the
  45 /// bar, and a drag release refreshes the hold. Hover only *sustains* a bar
  46 /// that is already raised — it can never raise a sunk one, since the pointer
  47 /// is really over the plate, not the bar. Once nothing holds it up for
  48 /// [`SCROLL_ACTIVE_HOLD`] seconds it sinks, and only scrolling brings it back.
  49 ///
  50 /// The owner drives it: [`Self::bump`] on scrolls and drag releases,
  51 /// [`Self::set_hover`] from pointer moves, [`Self::tick`] once per frame
  52 /// (which decays the hold and recomputes — a `true` return is the repaint
  53 /// signal for the raise/sink flip).
  54 #[derive(Debug, Clone, Default)]
  55 pub struct ScrollbarActivity {
  56     /// Seconds left in the "recently scrolled" window that keeps the bar raised.
  57     activity: f32,
  58     hover: bool,
  59     raised: bool,
  60     /// How far the FORE copy has faded in, 0..=1. Chases `raised` over
  61     /// [`SCROLL_FADE_SECS`]; only [`Self::tick`] advances it, so a host that
  62     /// drives the latch through `recompute` alone keeps the old hard flip.
  63     fade: f32,
  64 }
  65 
  66 impl ScrollbarActivity {
  67     pub fn new() -> Self {
  68         Self::default()
  69     }
  70 
  71     /// Whether the bar is currently raised in front of the plate. While false
  72     /// it sits behind the plate and must not take input. This is the LATCH —
  73     /// it flips at once, so input never waits on the fade.
  74     pub fn raised(&self) -> bool {
  75         self.raised
  76     }
  77 
  78     /// Opacity of the fore copy, 0..=1: 0 while fully sunk (only the copy
  79     /// behind the plate shows), 1 once risen. Paint with this; gate input on
  80     /// [`Self::raised`].
  81     pub fn fade(&self) -> f32 {
  82         self.fade
  83     }
  84 
  85     /// Refresh the hold window: call on a wheel/keyboard scroll and on a drag
  86     /// release.
  87     pub fn bump(&mut self) {
  88         self.activity = SCROLL_ACTIVE_HOLD;
  89     }
  90 
  91     /// Track whether the pointer sits over the bar (raw geometry — the caller
  92     /// does not gate this on raised; the hysteresis is what limits hover to
  93     /// sustaining).
  94     pub fn set_hover(&mut self, over: bool) {
  95         self.hover = over;
  96     }
  97 
  98     /// Whether the post-scroll hold window is still running — owners whose tick
  99     /// chain only runs while frames are being drawn use this to keep frames
 100     /// coming until the sink actually renders.
 101     pub fn holding(&self) -> bool {
 102         self.activity > 0.0
 103     }
 104 
 105     /// Recompute the latched raised state, returning whether it changed.
 106     pub fn recompute(&mut self, visible: bool, dragging: bool) -> bool {
 107         let raised = visible && (dragging || self.activity > 0.0 || (self.raised && self.hover));
 108         let changed = raised != self.raised;
 109         self.raised = raised;
 110         changed
 111     }
 112 
 113     /// Per-frame decay + recompute. Returns whether the raised state flipped —
 114     /// the owner's repaint signal.
 115     pub fn tick(&mut self, dt: f32, visible: bool, dragging: bool) -> bool {
 116         if self.activity > 0.0 {
 117             self.activity = (self.activity - dt).max(0.0);
 118         }
 119         let flipped = self.recompute(visible, dragging);
 120         // Chase the latch. The step is over the WHOLE range, so a fade
 121         // reversed halfway takes proportionally less time rather than
 122         // restarting — a flick-scroll-flick does not stutter.
 123         let target = if self.raised { 1.0 } else { 0.0 };
 124         let step = if SCROLL_FADE_SECS > 0.0 && crate::motion::enabled() { dt / SCROLL_FADE_SECS } else { 1.0 };
 125         let moved = if (self.fade - target).abs() <= step {
 126             let done = self.fade != target;
 127             self.fade = target;
 128             done
 129         } else {
 130             self.fade += step * (target - self.fade).signum();
 131             true
 132         };
 133         flipped || moved
 134     }
 135 }
 136 
 137 #[derive(Debug, Clone)]
 138 pub struct ScrollRegion {
 139     pub x: f32,
 140     pub y: f32,
 141     pub w: f32,
 142     pub h: f32,
 143     /// Row height, with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
 144     pub item_height: f32,
 145     pub item_gap: f32,
 146     pub scroll_y: f32,
 147     pub content_h: f32,
 148     pub viewport_y: f32,
 149     pub viewport_h: f32,
 150     /// Horizontal scrolling is opt-in per list: it activates only when a page
 151     /// declares a content width wider than the box (`set_content_w`). The
 152     /// default 0 keeps every vertical-only list exactly as it was.
 153     pub scroll_x: f32,
 154     pub content_w: f32,
 155     pub dragging: bool,
 156     dragging_h: bool,
 157     drag_offset_y: f32,
 158     drag_offset_x: f32,
 159     pub hovered: bool,
 160     /// Local stand-in for the legacy global focus flag (`ScrollBox::focus()` on any press
 161     /// inside the frame): set on a press that hits the region, cleared on one that misses.
 162     pub focused: bool,
 163     /// Draw the border + background plate in `push_prims`. Off = frameless: rows
 164     /// sit directly on the window plate (the scrollbar still draws).
 165     pub draw_frame: bool,
 166     /// Gap between the vertical bar's right edge and the region's right edge.
 167     /// The default 4.0 hugs a framed list's border. Edge bars only: a
 168     /// [`Self::sink_behind`] region's bars ride the centre lines and ignore it.
 169     pub edge_inset: f32,
 170     /// Opt-in raise/sink behavior ([`ScrollbarActivity`]): the bar idles sunk
 171     /// (host draws it behind its plate via [`Self::push_scrollbar_prims`]) and
 172     /// is non-interactive until a scroll raises it. Off (the default), the bar
 173     /// is always drawn and always grabbable at the right/bottom edge.
 174     ///
 175     /// **A sink-behind bar rides the CENTRE line of what it scrolls** (since
 176     /// 2026-10-06): the vertical bar down the region's middle, the horizontal
 177     /// one across the viewport's, crossing there when both scroll — over the
 178     /// rows, reserving no lane, [`crate::layout::centred_scrollbar_width`]
 179     /// thick. It is the DE's one scrollbar design (the params pane, the
 180     /// spreadsheet, the designer's dialog): sunk it is behind the plate and a
 181     /// press on its lane is a press on the row under it, so the middle of
 182     /// the list costs nothing until a scroll raises the bar there.
 183     pub sink_behind: bool,
 184     activity: ScrollbarActivity,
 185     /// The smooth-scroll driver behind `scroll_x`/`scroll_y`: wheel notches
 186     /// glide, trackpad flicks coast. The pub offsets stay the DRAWN values —
 187     /// hosts keep reading them — and any host write to them is adopted on the
 188     /// next `wheel`/`tick` via `reconcile`.
 189     motion: ScrollMotion,
 190 }
 191 
 192 impl Default for ScrollRegion {
 193     /// A region with no geometry yet — `set_rect`/`update_bounds` supply that on
 194     /// the first view pass. `new(0.0, ..)` floors `item_height` at the list
 195     /// font's line box, so a host that forgets to size its rows still gets a
 196     /// legible one rather than a zero-height row that never draws.
 197     fn default() -> Self {
 198         Self::new(0.0, 4.0)
 199     }
 200 }
 201 
 202 impl ScrollRegion {
 203     pub fn new(item_height: f32, item_gap: f32) -> Self {
 204         let (_, font_size) = crate::layout::list_font_parsed();
 205         Self {
 206             x: 0.0,
 207             y: 0.0,
 208             w: 0.0,
 209             h: 0.0,
 210             item_height: item_height.max(font_size + 14.0),
 211             item_gap,
 212             scroll_y: 0.0,
 213             content_h: 0.0,
 214             viewport_y: 0.0,
 215             viewport_h: 0.0,
 216             scroll_x: 0.0,
 217             content_w: 0.0,
 218             dragging: false,
 219             dragging_h: false,
 220             drag_offset_y: 0.0,
 221             drag_offset_x: 0.0,
 222             hovered: false,
 223             focused: false,
 224             draw_frame: true,
 225             edge_inset: 4.0,
 226             sink_behind: false,
 227             activity: ScrollbarActivity::new(),
 228             motion: ScrollMotion::new(),
 229         }
 230     }
 231 
 232     pub fn with_frame(mut self, draw_frame: bool) -> Self {
 233         self.draw_frame = draw_frame;
 234         self
 235     }
 236 
 237     pub fn with_edge_inset(mut self, inset: f32) -> Self {
 238         self.edge_inset = inset;
 239         self
 240     }
 241 
 242     pub fn with_sink_behind(mut self, sink: bool) -> Self {
 243         self.sink_behind = sink;
 244         self
 245     }
 246 
 247     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
 248         self.x = x;
 249         self.y = y;
 250         self.w = w;
 251         self.h = h;
 252     }
 253 
 254     /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
 255     pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
 256         self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
 257         self.viewport_y = viewport_y;
 258         self.viewport_h = viewport_h;
 259         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
 260         self.motion.set_bounds(self.bounds_x(), self.bounds_y());
 261     }
 262 
 263     /// `ScrollBox::update_bounds` shape: raw content height, not a row count.
 264     pub fn update_bounds_raw(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
 265         self.content_h = content_h;
 266         self.viewport_y = viewport_y;
 267         self.viewport_h = viewport_h;
 268         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
 269         self.motion.set_bounds(self.bounds_x(), self.bounds_y());
 270     }
 271 
 272     /// Jump the offset (no glide) — cancels any motion in flight.
 273     pub fn set_scroll_y(&mut self, val: f32) {
 274         self.scroll_y = val;
 275         self.motion.y.jump_to(val);
 276     }
 277 
 278     /// Glide the offset to `val` (a "scroll to selection" that should read as
 279     /// motion, not a cut). Falls back to a jump with smoothing off.
 280     pub fn scroll_to_y(&mut self, val: f32) -> bool {
 281         self.motion.reconcile(self.scroll_x, self.scroll_y);
 282         let moved = self.motion.y.scroll_to(val, self.bounds_y(), &scroll_settings());
 283         self.sync_from_motion();
 284         if moved {
 285             self.raise();
 286         }
 287         moved
 288     }
 289 
 290     /// Whether a glide or coast is still moving the offset — hosts whose tick
 291     /// chain is conditional use this to keep frames coming.
 292     pub fn is_animating(&self) -> bool {
 293         self.motion.is_animating()
 294     }
 295 
 296     fn bounds_y(&self) -> Bounds {
 297         Bounds::max(self.max_scroll())
 298     }
 299 
 300     fn bounds_x(&self) -> Bounds {
 301         Bounds::max(self.max_scroll_x())
 302     }
 303 
 304     fn sync_from_motion(&mut self) {
 305         self.scroll_x = self.motion.x.pos();
 306         self.scroll_y = self.motion.y.pos();
 307     }
 308 
 309     /// Declare how wide the content really is. Wider than the box = the list
 310     /// scrolls horizontally (bottom scrollbar, x wheel deltas, arrow keys).
 311     pub fn set_content_w(&mut self, w: f32) {
 312         self.content_w = w;
 313         self.scroll_x = self.scroll_x.clamp(0.0, self.max_scroll_x());
 314         self.motion.x.set_bounds(self.bounds_x());
 315     }
 316 
 317     /// Whether horizontal scrolling is live (content declared wider than the
 318     /// box). Pages use this to reserve bottom room for the h-bar.
 319     pub fn h_scroll_active(&self) -> bool {
 320         self.content_w > self.w
 321     }
 322 
 323     pub fn max_scroll(&self) -> f32 {
 324         (self.content_h - self.viewport_h).max(0.0)
 325     }
 326 
 327     pub fn max_scroll_x(&self) -> f32 {
 328         (self.content_w - self.w).max(0.0)
 329     }
 330 
 331     pub fn hit(&self, px: f32, py: f32) -> bool {
 332         px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
 333     }
 334 
 335     /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
 336     /// when the row doesn't intersect the viewport at all. Partially visible rows
 337     /// ARE returned — callers draw under a clip rect, so they render cut, not culled.
 338     pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
 339         let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
 340         self.get_draw_y(virtual_y, self.item_height)
 341     }
 342 
 343     /// `ScrollBox::get_item_draw_y` shape: a precomputed virtual y + item
 344     /// height. Same intersection contract as [`Self::get_item_draw_y`].
 345     pub fn get_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
 346         let draw_y = self.viewport_y + virtual_y - self.scroll_y;
 347         if draw_y + item_h >= self.viewport_y - 1.0
 348             && draw_y <= self.viewport_y + self.viewport_h + 1.0
 349         {
 350             Some(draw_y)
 351         } else {
 352             None
 353         }
 354     }
 355 
 356     /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
 357     /// Centred on the region's width for a [`Self::sink_behind`] region, at the
 358     /// right edge (less [`Self::edge_inset`]) otherwise.
 359     fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
 360         let (sb_x, sb_w) = if self.sink_behind {
 361             let sb_w = crate::layout::centred_scrollbar_width();
 362             (self.x + (self.w - sb_w) * 0.5, sb_w)
 363         } else {
 364             let sb_w = crate::layout::scrollbar_width();
 365             (self.x + self.w - sb_w - self.edge_inset, sb_w)
 366         };
 367         let track_h = self.viewport_h - 8.0;
 368         let track_y = self.viewport_y + 4.0;
 369         let visible_ratio = self.viewport_h / self.content_h.max(1.0);
 370         let thumb_h = if track_h <= 20.0 {
 371             track_h
 372         } else {
 373             (track_h * visible_ratio).clamp(20.0, track_h)
 374         };
 375         let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
 376         let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
 377         (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
 378     }
 379 
 380     pub fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
 381         if self.content_h <= self.viewport_h {
 382             return false;
 383         }
 384         // A sunk bar is behind the host's plate: the plate occludes it, so the
 385         // pointer can neither grab nor jump-scroll it.
 386         if self.sink_behind && !self.activity.raised() {
 387             return false;
 388         }
 389         let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
 390         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
 391     }
 392 
 393     /// Bottom scrollbar geometry, mirroring [`Self::scrollbar_geom`] with the
 394     /// axes swapped: (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w). An edge
 395     /// track stops short of the vertical bar's strip so the pills never
 396     /// overlap in the corner; a centred one runs the full width across the
 397     /// viewport's middle and crosses the vertical bar there.
 398     fn h_scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
 399         let (sb_y, sb_h, right_reserve) = if self.sink_behind {
 400             let sb_h = crate::layout::centred_scrollbar_width();
 401             (self.viewport_y + (self.viewport_h - sb_h) * 0.5, sb_h, 0.0)
 402         } else {
 403             let sb_h = crate::layout::scrollbar_width();
 404             let reserve = if self.content_h > self.viewport_h { sb_h + 8.0 } else { 0.0 };
 405             (self.y + self.h - sb_h - 4.0, sb_h, reserve)
 406         };
 407         let track_x = self.x + 4.0;
 408         let track_w = self.w - 8.0 - right_reserve;
 409         let visible_ratio = self.w / self.content_w.max(1.0);
 410         let thumb_w = if track_w <= 20.0 {
 411             track_w
 412         } else {
 413             (track_w * visible_ratio).clamp(20.0, track_w)
 414         };
 415         let ratio = if self.max_scroll_x() > 0.0 { self.scroll_x / self.max_scroll_x() } else { 0.0 };
 416         let thumb_x = track_x + ratio * (track_w - thumb_w);
 417         (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w)
 418     }
 419 
 420     fn hit_h_scrollbar(&self, px: f32, py: f32) -> bool {
 421         if !self.h_scroll_active() {
 422             return false;
 423         }
 424         if self.sink_behind && !self.activity.raised() {
 425             return false;
 426         }
 427         let (track_x, sb_y, track_w, sb_h, _, _) = self.h_scrollbar_geom();
 428         py >= sb_y - 4.0 && py <= sb_y + sb_h + 4.0 && px >= track_x && px <= track_x + track_w
 429     }
 430 
 431     /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
 432     /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
 433     /// scrollbar consumed the press — a press on the rows falls through to them.
 434     pub fn press(&mut self, px: f32, py: f32) -> bool {
 435         self.focused = self.hit(px, py);
 436         // The bottom bar first: its ±4 slop strip sits inside the box, where
 437         // the vertical hit test can never claim it.
 438         if self.hit_h_scrollbar(px, py) {
 439             self.dragging_h = true;
 440             let (track_x, _, track_w, _, thumb_x, thumb_w) = self.h_scrollbar_geom();
 441             let click_offset = px - thumb_x;
 442             if click_offset >= 0.0 && click_offset <= thumb_w {
 443                 self.drag_offset_x = click_offset;
 444             } else {
 445                 self.drag_offset_x = thumb_w / 2.0;
 446                 let target = px - self.drag_offset_x;
 447                 let ratio = if track_w - thumb_w > 0.0 {
 448                     ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
 449                 } else {
 450                     0.0
 451                 };
 452                 self.scroll_x = ratio * self.max_scroll_x();
 453             }
 454             return true;
 455         }
 456         if !self.hit_scrollbar(px, py) {
 457             self.dragging = false;
 458             return false;
 459         }
 460         self.dragging = true;
 461         let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
 462         let click_offset = py - thumb_y;
 463         if click_offset >= 0.0 && click_offset <= thumb_h {
 464             self.drag_offset_y = click_offset;
 465         } else {
 466             self.drag_offset_y = thumb_h / 2.0;
 467             let target = py - self.drag_offset_y;
 468             let ratio = if track_h - thumb_h > 0.0 {
 469                 ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
 470             } else {
 471                 0.0
 472             };
 473             self.scroll_y = ratio * self.max_scroll();
 474         }
 475         true
 476     }
 477 
 478     /// Returns whether a thumb drag was in progress (the caller's redraw signal).
 479     pub fn release(&mut self) -> bool {
 480         // Bitwise on purpose: both drags must reset even when the first
 481         // operand is already true (|| would short-circuit the take).
 482         let was_dragging = std::mem::take(&mut self.dragging) | std::mem::take(&mut self.dragging_h);
 483         if was_dragging && self.sink_behind {
 484             // A drag release starts the hold window, so the bar lingers
 485             // briefly instead of sinking the instant the button lifts.
 486             self.activity.bump();
 487         }
 488         was_dragging
 489     }
 490 
 491     fn drag_move(&mut self, py: f32) -> bool {
 492         let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
 493         let target = py - self.drag_offset_y;
 494         let ratio = if track_h - thumb_h > 0.0 {
 495             ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
 496         } else {
 497             0.0
 498         };
 499         let old = self.scroll_y;
 500         self.scroll_y = ratio * self.max_scroll();
 501         (self.scroll_y - old).abs() > 0.01
 502     }
 503 
 504     /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
 505     /// treats it as a high-priority drag override), else just tracks hover for the border
 506     /// tint and the keyboard scope.
 507     pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
 508         self.hovered = self.hit(px, py);
 509         if self.sink_behind {
 510             // Gated hit tests: a sunk bar reports no hover, so hover can only
 511             // sustain a raised bar (the hysteresis contract).
 512             self.activity
 513                 .set_hover(self.hit_scrollbar(px, py) || self.hit_h_scrollbar(px, py));
 514         }
 515         if self.dragging {
 516             self.drag_move(py);
 517             return true;
 518         }
 519         if self.dragging_h {
 520             let (track_x, _, track_w, _, _, thumb_w) = self.h_scrollbar_geom();
 521             let target = px - self.drag_offset_x;
 522             let ratio = if track_w - thumb_w > 0.0 {
 523                 ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
 524             } else {
 525                 0.0
 526             };
 527             self.scroll_x = ratio * self.max_scroll_x();
 528             return true;
 529         }
 530         false
 531     }
 532 
 533     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
 534         if !self.hit(px, py) {
 535             return false;
 536         }
 537         // Sideways wheel/trackpad deltas pan an h-scrollable list; a
 538         // vertical-only list ignores them (max_scroll_x = 0 clamps to 0).
 539         self.motion.reconcile(self.scroll_x, self.scroll_y);
 540         let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), self.bounds_x(), self.bounds_y());
 541         self.sync_from_motion();
 542         if changed {
 543             self.raise();
 544         }
 545         changed
 546     }
 547 
 548     /// Whether the bar overflows in either axis — the raise/sink "visible" input.
 549     fn overflowing(&self) -> bool {
 550         self.content_h > self.viewport_h || self.h_scroll_active()
 551     }
 552 
 553     /// Refresh the raise hold and recompute immediately, so a scroll shows the
 554     /// bar in the same frame's redraw rather than one tick later.
 555     fn raise(&mut self) {
 556         if self.sink_behind {
 557             self.activity.bump();
 558             self.activity.recompute(self.overflowing(), self.dragging || self.dragging_h);
 559         }
 560     }
 561 
 562     /// A host scrolled the region programmatically (selection auto-snap, jump
 563     /// to a search hit): raise a sink-behind bar the same way a wheel scroll
 564     /// does. No-op for regions without [`Self::sink_behind`].
 565     pub fn notify_scrolled(&mut self) {
 566         self.raise();
 567     }
 568 
 569     /// Whether the scrollbar currently draws in front of the content and takes
 570     /// input. Always true for a region without [`Self::sink_behind`].
 571     pub fn scrollbar_raised(&self) -> bool {
 572         !self.sink_behind || self.activity.raised()
 573     }
 574 
 575     /// Opacity of the fore copy of the bar, 0..=1 — see
 576     /// [`ScrollbarActivity::fade`]. Always 1 for a region that never sinks.
 577     pub fn scrollbar_fade(&self) -> f32 {
 578         if self.sink_behind {
 579             self.activity.fade()
 580         } else {
 581             1.0
 582         }
 583     }
 584 
 585     /// Per-frame raise/sink upkeep for sink-behind regions; a `true` return is
 586     /// the host's repaint signal. True while the post-scroll hold is running,
 587     /// not just on the flip: the demand-driven frame loop only keeps ticking
 588     /// while frames flow, so the hold must keep them coming or the sink would
 589     /// stall until the next input event. No-op (false) without `sink_behind`.
 590     pub fn tick(&mut self, dt: f32) -> bool {
 591         // The glide/coast first: a host write to the pub offsets since the
 592         // last frame (thumb drag, auto-snap) is adopted, then the motion
 593         // advances and the drawn offsets follow it.
 594         self.motion.reconcile(self.scroll_x, self.scroll_y);
 595         let moved = self.motion.tick(dt, self.bounds_x(), self.bounds_y());
 596         self.sync_from_motion();
 597         let animating = self.motion.is_animating();
 598         if !self.sink_behind {
 599             return moved || animating;
 600         }
 601         let holding = self.activity.holding();
 602         let flipped = self
 603             .activity
 604             .tick(dt, self.overflowing(), self.dragging || self.dragging_h);
 605         moved || animating || flipped || holding
 606     }
 607 
 608     /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
 609     /// when focused or hovered; the dissolved region keeps both via its local flags).
 610     pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
 611         if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
 612             return false;
 613         }
 614         // Keyboard steps ride the same glide as wheel notches (a held arrow
 615         // accumulates into one motion); pages and Home/End glide to their
 616         // absolute target.
 617         let s = scroll_settings();
 618         self.motion.reconcile(self.scroll_x, self.scroll_y);
 619         let by = self.bounds_y();
 620         let bx = self.bounds_x();
 621         let max_x = self.max_scroll_x();
 622         let changed = if event.ctrl {
 623             match &event.logical_key {
 624                 Key::Character(c) if c == "n" || c == "N" => self.motion.y.wheel(LINE_PX, by, &s),
 625                 Key::Character(c) if c == "p" || c == "P" => self.motion.y.wheel(-LINE_PX, by, &s),
 626                 _ => return false,
 627             }
 628         } else {
 629             match &event.logical_key {
 630                 Key::Named(NamedKey::ArrowDown) => self.motion.y.wheel(LINE_PX, by, &s),
 631                 Key::Named(NamedKey::ArrowUp) => self.motion.y.wheel(-LINE_PX, by, &s),
 632                 Key::Named(NamedKey::PageDown) => {
 633                     let t = self.motion.y.target() + self.viewport_h;
 634                     self.motion.y.scroll_to(t, by, &s)
 635                 }
 636                 Key::Named(NamedKey::PageUp) => {
 637                     let t = self.motion.y.target() - self.viewport_h;
 638                     self.motion.y.scroll_to(t, by, &s)
 639                 }
 640                 Key::Named(NamedKey::Home) => self.motion.y.scroll_to(0.0, by, &s),
 641                 Key::Named(NamedKey::End) => self.motion.y.scroll_to(by.hi, by, &s),
 642                 // Only an h-scrollable list claims the horizontal arrows —
 643                 // elsewhere they keep falling through to other handlers.
 644                 Key::Named(NamedKey::ArrowRight) if max_x > 0.0 => self.motion.x.wheel(LINE_PX, bx, &s),
 645                 Key::Named(NamedKey::ArrowLeft) if max_x > 0.0 => self.motion.x.wheel(-LINE_PX, bx, &s),
 646                 _ => return false,
 647             }
 648         };
 649         self.sync_from_motion();
 650         if changed {
 651             self.raise();
 652         }
 653         changed
 654     }
 655 
 656     /// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
 657     /// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
 658     ///
 659     /// A [`Self::sink_behind`] region draws no FORE copy here: the host draws
 660     /// its rows after this call, and a fore bar drawn before them would sit
 661     /// under them. Call [`Self::push_scrollbar_fore`] once the rows are down.
 662     /// A framed sink region draws its behind copy here, under the bg fill; a
 663     /// frameless one leaves it to the host, under the host's plate.
 664     pub fn push_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
 665         if self.draw_frame {
 666             let radius = crate::layout::list_corner_radius();
 667             let border_color = if self.focused {
 668                 [0.30, 0.50, 0.32, 1.0]
 669             } else if self.hovered {
 670                 [0.25, 0.25, 0.35, 1.0]
 671             } else {
 672                 [0.18, 0.18, 0.24, 1.0]
 673             };
 674             let all = (true, true, true, true);
 675             pc.rect_with_radius_corners(border_color, self.x, self.y, self.w, self.h, radius, all);
 676             // A sink-behind bar's idle copy draws here, UNDER the translucent
 677             // bg fill (list_bg_color's alpha is 0.3): it shows through dimly,
 678             // sunk into the list plate — the designer parameter-pane look,
 679             // self-contained for framed regions. Always, raised or not: the
 680             // fore copy fades in OVER it, so dropping it at the latch would
 681             // blink the bar out under a fore copy still at a low alpha.
 682             if self.sink_behind {
 683                 self.push_scrollbar_prims(pc);
 684             }
 685             pc.rect_with_radius_corners(
 686                 crate::color::list_bg_color(),
 687                 self.x + 1.0,
 688                 self.y + 1.0,
 689                 self.w - 2.0,
 690                 self.h - 2.0,
 691                 (radius - 1.0).max(0.0),
 692                 all,
 693             );
 694         }
 695         // A plain region's bar rides on top, always. A sink-behind region's
 696         // fore copy is the host's to draw after its rows
 697         // (`push_scrollbar_fore`); a FRAMELESS one draws no idle copy here
 698         // either — its rows sit directly on the host's plate, so the host
 699         // owns the under-plate emission via `push_scrollbar_prims`.
 700         if !self.sink_behind {
 701             self.push_scrollbar_prims(pc);
 702         }
 703     }
 704 
 705     /// A sink-behind region's FORE copy, at the activity's fade: draw it
 706     /// after the rows. It fades rather than flips and keeps drawing all the
 707     /// way out — gating it on `scrollbar_raised` would cut the fade off at
 708     /// the latch. Nothing for a plain region, whose bar `push_prims` drew.
 709     pub fn push_scrollbar_fore(&self, pc: &mut dyn crate::layout::RenderTarget) {
 710         if self.sink_behind {
 711             self.push_scrollbar_prims_alpha(pc, self.activity.fade());
 712         }
 713     }
 714 
 715     /// The pill scrollbars alone (track + thumb, both axes), drawn wherever the
 716     /// host calls it — a frameless sink-behind host draws its idle copy with
 717     /// this, every frame, BEFORE its plate.
 718     pub fn push_scrollbar_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
 719         self.push_scrollbar_prims_alpha(pc, 1.0);
 720     }
 721 
 722     /// [`Self::push_scrollbar_prims`] with the track and thumb scaled to
 723     /// `alpha` — what a host draws the FORE copy with while it fades in and
 724     /// out. The copy that idles behind the plate is drawn at full alpha; the
 725     /// plate over it is what dims and frosts it.
 726     pub fn push_scrollbar_prims_alpha(&self, pc: &mut dyn crate::layout::RenderTarget, alpha: f32) {
 727         let a = alpha.clamp(0.0, 1.0);
 728         if a <= 0.001 {
 729             return;
 730         }
 731         let dim = |mut c: [f32; 4]| {
 732             c[3] *= a;
 733             c
 734         };
 735         // Track and thumb are pills — half-width radius (the designer look).
 736         // Both tracks before either thumb, so where centred bars cross the
 737         // horizontal track does not cover the vertical thumb.
 738         let all = (true, true, true, true);
 739         let track = dim(crate::color::scrollbar_track_color());
 740         let thumb = dim(crate::color::scrollbar_thumb_color());
 741         let v = (self.content_h > self.viewport_h).then(|| self.scrollbar_geom());
 742         let h = self.h_scroll_active().then(|| self.h_scrollbar_geom());
 743         if let Some((sb_x, track_y, sb_w, track_h, _, _)) = v {
 744             pc.rect_with_radius_corners(track, sb_x, track_y, sb_w, track_h, sb_w.min(track_h) * 0.5, all);
 745         }
 746         if let Some((track_x, sb_y, track_w, sb_h, _, _)) = h {
 747             pc.rect_with_radius_corners(track, track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
 748         }
 749         if let Some((sb_x, _, sb_w, _, thumb_y, thumb_h)) = v {
 750             pc.rect_with_radius_corners(thumb, sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
 751         }
 752         if let Some((_, sb_y, _, sb_h, thumb_x, thumb_w)) = h {
 753             pc.rect_with_radius_corners(thumb, thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
 754         }
 755     }
 756 
 757     /// Flat background fill for hosts on the tuple pipeline. Flat squares on a
 758     /// hard flip, the vertical bar only: a sink-behind host wants the prim
 759     /// path (pills, both axes, the fade), and every one in the DE is on it. The scrollbar is
 760     /// split into [`Self::push_scrollbar_quads`] so the host can emit it AFTER
 761     /// the rows — drawn together, the rows paint over the thumb and it peeks
 762     /// through the inter-row gaps as dotted segments.
 763     ///
 764     /// For a sink-behind region, a sunk bar is emitted here FIRST, under the
 765     /// translucent bg fill (alpha 0.3), so it shows through dimly — and
 766     /// [`Self::push_scrollbar_quads`] goes quiet. The host's existing
 767     /// bg → rows → scrollbar order needs no change to adopt the treatment.
 768     pub fn push_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 769         if self.sink_behind && !self.activity.raised() {
 770             self.push_scrollbar_quads_always(quads);
 771         }
 772         quads.push((self.x, self.y, self.w, self.h, crate::color::list_bg_color()));
 773     }
 774 
 775     /// Scrollbar track + thumb when the content overflows; emit after the rows.
 776     /// For a sink-behind region this is the RAISED layer only — while sunk the
 777     /// bar was already emitted under the bg by [`Self::push_quads`].
 778     pub fn push_scrollbar_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 779         if self.scrollbar_raised() {
 780             self.push_scrollbar_quads_always(quads);
 781         }
 782     }
 783 
 784     fn push_scrollbar_quads_always(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 785         if self.content_h > self.viewport_h {
 786             let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
 787             quads.push((sb_x, track_y, sb_w, track_h, crate::color::scrollbar_track_color()));
 788             quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
 789         }
 790     }
 791 }
 792 
 793 #[cfg(test)]
 794 mod tests {
 795     use super::*;
 796 
 797     /// Coming to the fore is a fade, not a flip: the latch moves at once (so
 798     /// input never waits) while the drawn opacity ramps, in and back out.
 799     #[test]
 800     fn scrollbar_fade_ramps_instead_of_flipping() {
 801         let mut a = ScrollbarActivity::new();
 802         a.bump();
 803         // dt = SCROLL_FADE_SECS / 3, so one tick is a third of the way in.
 804         let dt = SCROLL_FADE_SECS / 3.0;
 805         a.tick(dt, true, false);
 806         assert!(a.raised(), "the latch flips immediately");
 807         assert!(a.fade() > 0.0 && a.fade() < 1.0, "part-way faded in, got {}", a.fade());
 808         for _ in 0..3 {
 809             a.tick(dt, true, false);
 810         }
 811         assert_eq!(a.fade(), 1.0, "fully in after the fade duration");
 812 
 813         // Let the post-scroll hold expire: the latch drops, then the fade
 814         // runs back out rather than vanishing with it.
 815         let ticks = (SCROLL_ACTIVE_HOLD / dt).ceil() as i32 + 1;
 816         for _ in 0..ticks {
 817             a.tick(dt, true, false);
 818         }
 819         assert!(!a.raised(), "hold expired");
 820         assert!(a.fade() < 1.0 && a.fade() >= 0.0, "fading out, got {}", a.fade());
 821         for _ in 0..4 {
 822             a.tick(dt, true, false);
 823         }
 824         assert_eq!(a.fade(), 0.0, "fully out");
 825     }
 826 
 827     fn region() -> ScrollRegion {
 828         // item_height clamps to list_font + 14, so pick one comfortably above any config.
 829         let mut r = ScrollRegion::new(40.0, 4.0);
 830         r.set_rect(10.0, 20.0, 200.0, 100.0);
 831         r
 832     }
 833 
 834     /// The left edge of a sink-behind [`region`]'s centred vertical bar.
 835     fn centred_bar_x() -> f32 {
 836         10.0 + (200.0 - crate::layout::centred_scrollbar_width()) * 0.5
 837     }
 838 
 839     /// Run the glide out (a no-op with smoothing off in the test host's config).
 840     fn settle(r: &mut ScrollRegion) {
 841         let mut n = 0;
 842         while r.is_animating() && n < 1000 {
 843             r.tick(1.0 / 60.0);
 844             n += 1;
 845         }
 846     }
 847 
 848     #[test]
 849     fn wheel_scrolls_and_clamps() {
 850         let mut r = region();
 851         r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
 852         assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
 853         settle(&mut r);
 854         assert_eq!(r.scroll_y, 48.0);
 855         assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
 856         r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
 857         settle(&mut r);
 858         assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
 859     }
 860 
 861     #[test]
 862     fn virtualization_matches_list_math() {
 863         let mut r = region();
 864         r.update_bounds(10, 20.0, 100.0);
 865         r.set_scroll_y(0.0);
 866         // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
 867         assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
 868         // Row 2 at 20 + 92 - 0 = 112: extends past the viewport bottom (121) but
 869         // still intersects it — returned so the caller draws it cut by the clip.
 870         assert_eq!(r.get_item_draw_y(2, 4.0), Some(112.0));
 871         // Row 3 at 20 + 136 = 156: fully below the viewport → culled.
 872         assert!(r.get_item_draw_y(3, 4.0).is_none());
 873     }
 874 
 875     #[test]
 876     fn virtualization_keeps_partial_row_at_top() {
 877         let mut r = region();
 878         r.update_bounds(10, 20.0, 100.0);
 879         // Scrolled so row 0 (virtual 4..44) is half above the viewport top:
 880         // draw_y = 20 + 4 - 24 = 0 < viewport_y, but its bottom (40) intersects.
 881         r.set_scroll_y(24.0);
 882         assert_eq!(r.get_item_draw_y(0, 4.0), Some(0.0));
 883         // A row whose bottom ends above the viewport top would be culled; with
 884         // this geometry row 0 always intersects, so scroll far and check row 0.
 885         r.set_scroll_y(80.0);
 886         assert!(r.get_item_draw_y(0, 4.0).is_none());
 887     }
 888 
 889     #[test]
 890     fn raw_shim_shares_the_intersection_contract() {
 891         let mut r = region();
 892         r.update_bounds_raw(444.0, 20.0, 100.0);
 893         r.set_scroll_y(50.0);
 894         // draw_y = 20 + 10 - 50 = -20; bottom = 4 < 19 → fully above, culled.
 895         assert!(r.get_draw_y(10.0, 24.0).is_none());
 896         // draw_y = 20 + 40 - 50 = 10: straddles the top edge → returned.
 897         assert_eq!(r.get_draw_y(40.0, 24.0), Some(10.0));
 898         // draw_y = 20 + 60 - 50 = 30: fully inside.
 899         assert_eq!(r.get_draw_y(60.0, 24.0), Some(30.0));
 900     }
 901 
 902     #[test]
 903     fn press_focuses_and_grabs_only_scrollbar() {
 904         let mut r = region();
 905         r.update_bounds(10, 20.0, 100.0);
 906         // Press in the rows area: focused, not dragging, falls through.
 907         assert!(!r.press(50.0, 50.0));
 908         assert!(r.focused && !r.dragging);
 909         // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
 910         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
 911         assert!(r.press(sb_x + 1.0, 50.0));
 912         assert!(r.dragging);
 913         assert!(r.release());
 914         // Press outside: unfocuses.
 915         assert!(!r.press(500.0, 500.0));
 916         assert!(!r.focused);
 917     }
 918 
 919     #[test]
 920     fn horizontal_scroll_is_opt_in_and_clamps() {
 921         let mut r = region();
 922         r.update_bounds(10, 20.0, 100.0);
 923         // No content width declared: x wheel deltas change nothing and the
 924         // vertical-only behavior (including the y component) is untouched.
 925         assert!(!r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
 926         assert_eq!(r.scroll_x, 0.0);
 927         assert!(!r.h_scroll_active());
 928 
 929         // Content wider than the 200px box: x deltas pan and clamp.
 930         r.set_content_w(500.0);
 931         assert!(r.h_scroll_active());
 932         assert!(r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
 933         settle(&mut r);
 934         assert_eq!(r.scroll_x, 48.0);
 935         r.wheel(&MouseScrollDelta::LineDelta(-100.0, 0.0), 50.0, 50.0);
 936         settle(&mut r);
 937         assert_eq!(r.scroll_x, 300.0); // max = 500 - 200
 938         r.wheel(&MouseScrollDelta::LineDelta(100.0, 0.0), 50.0, 50.0);
 939         settle(&mut r);
 940         assert_eq!(r.scroll_x, 0.0);
 941     }
 942 
 943     #[test]
 944     fn h_thumb_press_grabs_and_releases() {
 945         let mut r = region();
 946         r.update_bounds(2, 20.0, 100.0); // no vertical overflow
 947         r.set_content_w(500.0);
 948         // The bottom strip: y + h - sb_w - 4, thumb starts at track_x.
 949         let sb_y = 20.0 + 100.0 - crate::layout::scrollbar_width() - 4.0;
 950         assert!(r.press(20.0, sb_y + 1.0));
 951         // Drag right: scroll_x follows.
 952         assert!(r.cursor_moved(120.0, sb_y + 1.0));
 953         assert!(r.scroll_x > 0.0);
 954         assert!(r.release());
 955         // A rows-area press still falls through (no h-bar hit).
 956         assert!(!r.press(50.0, 50.0));
 957     }
 958 
 959     #[test]
 960     fn edge_inset_moves_the_bar_off_the_edge() {
 961         let mut r = region().with_edge_inset(20.0);
 962         r.update_bounds(10, 20.0, 100.0);
 963         // Bar right edge sits edge_inset in from the region's right edge; the
 964         // old 4px position no longer hits.
 965         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 20.0;
 966         assert!(r.press(sb_x + 1.0, 50.0));
 967         assert!(r.release());
 968         let old_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0 + 1.0;
 969         assert!(!r.press(old_x + 4.1, 50.0)); // past the ±4 slop of the inset bar
 970     }
 971 
 972     #[test]
 973     fn sink_behind_gates_input_until_a_scroll_raises() {
 974         let mut r = region().with_sink_behind(true);
 975         r.update_bounds(10, 20.0, 100.0);
 976         assert!(!r.scrollbar_raised());
 977         // Sunk: a press on the bar strip falls through (the plate occludes it).
 978         let sb_x = centred_bar_x();
 979         assert!(!r.press(sb_x + 1.0, 50.0));
 980         assert!(!r.dragging);
 981         // A wheel scroll raises it in the same frame…
 982         assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
 983         assert!(r.scrollbar_raised());
 984         // …and now the bar takes the grab.
 985         assert!(r.press(sb_x + 1.0, 50.0));
 986         assert!(r.dragging);
 987         assert!(r.release());
 988         // The release refreshed the hold: still raised, and the hold keeps the
 989         // repaint signal up so the frame loop keeps ticking toward the sink.
 990         assert!(r.scrollbar_raised());
 991         assert!(r.tick(0.3)); // holding → keep frames coming
 992         assert!(r.scrollbar_raised());
 993         assert!(r.tick(SCROLL_ACTIVE_HOLD)); // hold lapses → sink flip reported
 994         assert!(!r.scrollbar_raised());
 995         assert!(!r.tick(0.016)); // settled sunk: quiet again
 996     }
 997 
 998     #[test]
 999     fn hover_sustains_but_never_raises() {
1000         let mut r = region().with_sink_behind(true);
1001         r.update_bounds(10, 20.0, 100.0);
1002         let sb_x = centred_bar_x();
1003         // Hovering the sunk bar's strip does not raise it.
1004         r.cursor_moved(sb_x + 1.0, 50.0);
1005         assert!(!r.tick(0.016));
1006         assert!(!r.scrollbar_raised());
1007         // Raise by scrolling (and let the glide land, so the ticks below
1008         // measure only the raise/sink state), hover it, and let the hold
1009         // lapse: hover sustains.
1010         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
1011         settle(&mut r);
1012         r.cursor_moved(sb_x + 1.0, 50.0);
1013         r.tick(SCROLL_ACTIVE_HOLD + 0.1); // hold lapses, hover keeps it raised
1014         assert!(r.scrollbar_raised());
1015         assert!(!r.tick(0.016)); // sustained by hover alone: no repaint churn
1016         // Pointer leaves: the next tick sinks it.
1017         r.cursor_moved(50.0, 50.0);
1018         assert!(r.tick(0.016));
1019         assert!(!r.scrollbar_raised());
1020     }
1021 
1022     #[test]
1023     fn tuple_emission_layers_by_raised_state() {
1024         let mut r = region().with_sink_behind(true);
1025         r.update_bounds(10, 20.0, 100.0);
1026         // Sunk: bar quads come UNDER the bg (push_quads emits bar then bg, the
1027         // raised-layer call is quiet).
1028         let mut under = Vec::new();
1029         r.push_quads(&mut under);
1030         assert_eq!(under.len(), 3); // track + thumb + bg
1031         assert_eq!(under[2].2, 200.0); // last quad is the full-width bg fill
1032         let mut over = Vec::new();
1033         r.push_scrollbar_quads(&mut over);
1034         assert!(over.is_empty());
1035         // Raised: bg alone below, bar above.
1036         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
1037         let mut under = Vec::new();
1038         r.push_quads(&mut under);
1039         assert_eq!(under.len(), 1);
1040         let mut over = Vec::new();
1041         r.push_scrollbar_quads(&mut over);
1042         assert_eq!(over.len(), 2);
1043         // A non-sink region keeps the legacy shape: bg alone, bar always.
1044         let mut plain = region();
1045         plain.update_bounds(10, 20.0, 100.0);
1046         let (mut under, mut over) = (Vec::new(), Vec::new());
1047         plain.push_quads(&mut under);
1048         plain.push_scrollbar_quads(&mut over);
1049         assert_eq!((under.len(), over.len()), (1, 2));
1050     }
1051 
1052     /// A sink-behind region's bars ride the centre lines and cross there:
1053     /// the vertical bar down the region's middle, the horizontal one across
1054     /// the viewport's, the horizontal track running the full width rather
1055     /// than stopping short of a corner. Sunk, a press on either lane is a
1056     /// press on the rows; raised, it is the bar's.
1057     #[test]
1058     fn sink_behind_bars_cross_at_the_centre() {
1059         let mut r = region().with_sink_behind(true).with_edge_inset(30.0);
1060         r.update_bounds(10, 20.0, 100.0);
1061         r.set_content_w(500.0);
1062         let (sb_x, _, sb_w, _, _, _) = r.scrollbar_geom();
1063         assert!((sb_x + sb_w * 0.5 - (10.0 + 100.0)).abs() < 0.01, "vertical bar on the centre line, edge_inset ignored");
1064         assert_eq!(sb_w, crate::layout::centred_scrollbar_width());
1065         let (track_x, sb_y, track_w, sb_h, _, _) = r.h_scrollbar_geom();
1066         assert!((sb_y + sb_h * 0.5 - (20.0 + 50.0)).abs() < 0.01, "horizontal bar on the viewport's centre line");
1067         assert_eq!((track_x, track_w), (14.0, 192.0), "the horizontal track crosses, reserving no corner");
1068 
1069         // Sunk: the middle of the list is the rows'.
1070         assert!(!r.press(110.0, 50.0));
1071         assert!(!r.press(40.0, 70.0));
1072         assert!(!r.dragging);
1073         // Raised by a scroll, the cross takes the press.
1074         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
1075         assert!(r.press(110.0, 40.0));
1076         assert!(r.release());
1077         assert!(r.press(40.0, 70.0));
1078         assert!(r.release());
1079 
1080         // A plain region keeps its edge bars.
1081         let mut plain = region();
1082         plain.update_bounds(10, 20.0, 100.0);
1083         let (sb_x, _, sb_w, _, _, _) = plain.scrollbar_geom();
1084         assert_eq!(sb_x + sb_w, 10.0 + 200.0 - 4.0);
1085     }
1086 
1087     /// Records the alpha of every rect a paint emits.
1088     #[derive(Default)]
1089     struct Alphas(Vec<f32>);
1090     impl crate::layout::RenderTarget for Alphas {
1091         fn rect(&mut self, color: [f32; 4], _x: f32, _y: f32, _w: f32, _h: f32) {
1092             self.0.push(color[3]);
1093         }
1094         fn text(&mut self, _: &str, _: f32, _: f32, _: f32, _: [f32; 4]) {}
1095     }
1096 
1097     /// A sink-behind region's `push_prims` draws the frame and the idle copy
1098     /// under its bg, never the fore copy, which would land under the rows the
1099     /// host draws next; `push_scrollbar_fore` draws that, at the fade, and
1100     /// nothing while the bar is sunk. A plain region's bar is still in
1101     /// `push_prims`, and its fore call is quiet.
1102     #[test]
1103     fn the_fore_copy_is_drawn_after_the_rows() {
1104         let mut r = region().with_sink_behind(true);
1105         r.update_bounds(10, 20.0, 100.0);
1106         let mut prims = Alphas::default();
1107         r.push_prims(&mut prims);
1108         // border, idle track + thumb, bg — and no fore copy.
1109         assert_eq!(prims.0.len(), 4);
1110         let mut fore = Alphas::default();
1111         r.push_scrollbar_fore(&mut fore);
1112         assert!(fore.0.is_empty(), "sunk: no fore copy");
1113         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
1114         r.tick(SCROLL_FADE_SECS * 0.5);
1115         let mut prims = Alphas::default();
1116         r.push_prims(&mut prims);
1117         assert_eq!(prims.0.len(), 4, "raised, the idle copy still lies under the bg");
1118         let mut fore = Alphas::default();
1119         r.push_scrollbar_fore(&mut fore);
1120         assert_eq!(fore.0.len(), 2);
1121         let track = crate::color::scrollbar_track_color()[3];
1122         assert!(fore.0[0] < track, "half faded in: {} of {}", fore.0[0], track);
1123 
1124         let mut plain = region();
1125         plain.update_bounds(10, 20.0, 100.0);
1126         let mut prims = Alphas::default();
1127         plain.push_prims(&mut prims);
1128         assert_eq!(prims.0.len(), 4, "border, bg, track, thumb");
1129         let mut fore = Alphas::default();
1130         plain.push_scrollbar_fore(&mut fore);
1131         assert!(fore.0.is_empty());
1132     }
1133 
1134     #[test]
1135     fn non_sink_regions_are_unchanged() {
1136         let mut r = region();
1137         r.update_bounds(10, 20.0, 100.0);
1138         assert!(r.scrollbar_raised()); // always interactive
1139         assert!(!r.tick(1.0)); // tick is a no-op
1140         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
1141         assert!(r.press(sb_x + 1.0, 50.0));
1142     }
1143 
1144     #[test]
1145     fn keyboard_is_hover_or_focus_scoped() {
1146         let mut r = region();
1147         r.update_bounds(10, 20.0, 100.0);
1148         let down = KeyEvent {
1149             state: ElementState::Pressed,
1150             logical_key: Key::Named(NamedKey::ArrowDown),
1151             text: None,
1152             repeat: false,
1153             ctrl: false,
1154             shift: false,
1155             alt: false,
1156         };
1157         assert!(!r.keyboard(&down)); // neither hovered nor focused
1158         r.cursor_moved(50.0, 50.0);
1159         assert!(r.hovered);
1160         assert!(r.keyboard(&down));
1161         settle(&mut r);
1162         assert_eq!(r.scroll_y, 24.0);
1163     }
1164 }