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

src/widget/scroll_region.rs (43.2K)

   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; page-level bars floating
 168     /// over a window plate use [`crate::layout::scrollbar_inset`] for the
 169     /// designer's stood-off look.
 170     pub edge_inset: f32,
 171     /// Opt-in raise/sink behavior ([`ScrollbarActivity`]): the bar idles sunk
 172     /// (host draws it behind its plate via [`Self::push_scrollbar_prims`]) and
 173     /// is non-interactive until a scroll raises it. Off (the default), the bar
 174     /// is always drawn and always grabbable — existing hosts unchanged.
 175     pub sink_behind: bool,
 176     activity: ScrollbarActivity,
 177     /// The smooth-scroll driver behind `scroll_x`/`scroll_y`: wheel notches
 178     /// glide, trackpad flicks coast. The pub offsets stay the DRAWN values —
 179     /// hosts keep reading them — and any host write to them is adopted on the
 180     /// next `wheel`/`tick` via `reconcile`.
 181     motion: ScrollMotion,
 182 }
 183 
 184 impl Default for ScrollRegion {
 185     /// A region with no geometry yet — `set_rect`/`update_bounds` supply that on
 186     /// the first view pass. `new(0.0, ..)` floors `item_height` at the list
 187     /// font's line box, so a host that forgets to size its rows still gets a
 188     /// legible one rather than a zero-height row that never draws.
 189     fn default() -> Self {
 190         Self::new(0.0, 4.0)
 191     }
 192 }
 193 
 194 impl ScrollRegion {
 195     pub fn new(item_height: f32, item_gap: f32) -> Self {
 196         let (_, font_size) = crate::layout::list_font_parsed();
 197         Self {
 198             x: 0.0,
 199             y: 0.0,
 200             w: 0.0,
 201             h: 0.0,
 202             item_height: item_height.max(font_size + 14.0),
 203             item_gap,
 204             scroll_y: 0.0,
 205             content_h: 0.0,
 206             viewport_y: 0.0,
 207             viewport_h: 0.0,
 208             scroll_x: 0.0,
 209             content_w: 0.0,
 210             dragging: false,
 211             dragging_h: false,
 212             drag_offset_y: 0.0,
 213             drag_offset_x: 0.0,
 214             hovered: false,
 215             focused: false,
 216             draw_frame: true,
 217             edge_inset: 4.0,
 218             sink_behind: false,
 219             activity: ScrollbarActivity::new(),
 220             motion: ScrollMotion::new(),
 221         }
 222     }
 223 
 224     pub fn with_frame(mut self, draw_frame: bool) -> Self {
 225         self.draw_frame = draw_frame;
 226         self
 227     }
 228 
 229     pub fn with_edge_inset(mut self, inset: f32) -> Self {
 230         self.edge_inset = inset;
 231         self
 232     }
 233 
 234     pub fn with_sink_behind(mut self, sink: bool) -> Self {
 235         self.sink_behind = sink;
 236         self
 237     }
 238 
 239     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
 240         self.x = x;
 241         self.y = y;
 242         self.w = w;
 243         self.h = h;
 244     }
 245 
 246     /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
 247     pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
 248         self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
 249         self.viewport_y = viewport_y;
 250         self.viewport_h = viewport_h;
 251         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
 252         self.motion.set_bounds(self.bounds_x(), self.bounds_y());
 253     }
 254 
 255     /// `ScrollBox::update_bounds` shape: raw content height, not a row count.
 256     pub fn update_bounds_raw(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
 257         self.content_h = content_h;
 258         self.viewport_y = viewport_y;
 259         self.viewport_h = viewport_h;
 260         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
 261         self.motion.set_bounds(self.bounds_x(), self.bounds_y());
 262     }
 263 
 264     /// Jump the offset (no glide) — cancels any motion in flight.
 265     pub fn set_scroll_y(&mut self, val: f32) {
 266         self.scroll_y = val;
 267         self.motion.y.jump_to(val);
 268     }
 269 
 270     /// Glide the offset to `val` (a "scroll to selection" that should read as
 271     /// motion, not a cut). Falls back to a jump with smoothing off.
 272     pub fn scroll_to_y(&mut self, val: f32) -> bool {
 273         self.motion.reconcile(self.scroll_x, self.scroll_y);
 274         let moved = self.motion.y.scroll_to(val, self.bounds_y(), &scroll_settings());
 275         self.sync_from_motion();
 276         if moved {
 277             self.raise();
 278         }
 279         moved
 280     }
 281 
 282     /// Whether a glide or coast is still moving the offset — hosts whose tick
 283     /// chain is conditional use this to keep frames coming.
 284     pub fn is_animating(&self) -> bool {
 285         self.motion.is_animating()
 286     }
 287 
 288     fn bounds_y(&self) -> Bounds {
 289         Bounds::max(self.max_scroll())
 290     }
 291 
 292     fn bounds_x(&self) -> Bounds {
 293         Bounds::max(self.max_scroll_x())
 294     }
 295 
 296     fn sync_from_motion(&mut self) {
 297         self.scroll_x = self.motion.x.pos();
 298         self.scroll_y = self.motion.y.pos();
 299     }
 300 
 301     /// Declare how wide the content really is. Wider than the box = the list
 302     /// scrolls horizontally (bottom scrollbar, x wheel deltas, arrow keys).
 303     pub fn set_content_w(&mut self, w: f32) {
 304         self.content_w = w;
 305         self.scroll_x = self.scroll_x.clamp(0.0, self.max_scroll_x());
 306         self.motion.x.set_bounds(self.bounds_x());
 307     }
 308 
 309     /// Whether horizontal scrolling is live (content declared wider than the
 310     /// box). Pages use this to reserve bottom room for the h-bar.
 311     pub fn h_scroll_active(&self) -> bool {
 312         self.content_w > self.w
 313     }
 314 
 315     pub fn max_scroll(&self) -> f32 {
 316         (self.content_h - self.viewport_h).max(0.0)
 317     }
 318 
 319     pub fn max_scroll_x(&self) -> f32 {
 320         (self.content_w - self.w).max(0.0)
 321     }
 322 
 323     pub fn hit(&self, px: f32, py: f32) -> bool {
 324         px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
 325     }
 326 
 327     /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
 328     /// when the row doesn't intersect the viewport at all. Partially visible rows
 329     /// ARE returned — callers draw under a clip rect, so they render cut, not culled.
 330     pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
 331         let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
 332         self.get_draw_y(virtual_y, self.item_height)
 333     }
 334 
 335     /// `ScrollBox::get_item_draw_y` shape: a precomputed virtual y + item
 336     /// height. Same intersection contract as [`Self::get_item_draw_y`].
 337     pub fn get_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
 338         let draw_y = self.viewport_y + virtual_y - self.scroll_y;
 339         if draw_y + item_h >= self.viewport_y - 1.0
 340             && draw_y <= self.viewport_y + self.viewport_h + 1.0
 341         {
 342             Some(draw_y)
 343         } else {
 344             None
 345         }
 346     }
 347 
 348     /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
 349     fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
 350         let sb_w = crate::layout::scrollbar_width();
 351         let sb_x = self.x + self.w - sb_w - self.edge_inset;
 352         let track_h = self.viewport_h - 8.0;
 353         let track_y = self.viewport_y + 4.0;
 354         let visible_ratio = self.viewport_h / self.content_h.max(1.0);
 355         let thumb_h = if track_h <= 20.0 {
 356             track_h
 357         } else {
 358             (track_h * visible_ratio).clamp(20.0, track_h)
 359         };
 360         let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
 361         let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
 362         (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
 363     }
 364 
 365     pub fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
 366         if self.content_h <= self.viewport_h {
 367             return false;
 368         }
 369         // A sunk bar is behind the host's plate: the plate occludes it, so the
 370         // pointer can neither grab nor jump-scroll it.
 371         if self.sink_behind && !self.activity.raised() {
 372             return false;
 373         }
 374         let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
 375         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
 376     }
 377 
 378     /// Bottom scrollbar geometry, mirroring [`Self::scrollbar_geom`] with the
 379     /// axes swapped: (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w). The
 380     /// track stops short of the vertical bar's strip so the pills never
 381     /// overlap in the corner.
 382     fn h_scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
 383         let sb_h = crate::layout::scrollbar_width();
 384         let sb_y = self.y + self.h - sb_h - 4.0;
 385         let right_reserve = if self.content_h > self.viewport_h { sb_h + 8.0 } else { 0.0 };
 386         let track_x = self.x + 4.0;
 387         let track_w = self.w - 8.0 - right_reserve;
 388         let visible_ratio = self.w / self.content_w.max(1.0);
 389         let thumb_w = if track_w <= 20.0 {
 390             track_w
 391         } else {
 392             (track_w * visible_ratio).clamp(20.0, track_w)
 393         };
 394         let ratio = if self.max_scroll_x() > 0.0 { self.scroll_x / self.max_scroll_x() } else { 0.0 };
 395         let thumb_x = track_x + ratio * (track_w - thumb_w);
 396         (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w)
 397     }
 398 
 399     fn hit_h_scrollbar(&self, px: f32, py: f32) -> bool {
 400         if !self.h_scroll_active() {
 401             return false;
 402         }
 403         if self.sink_behind && !self.activity.raised() {
 404             return false;
 405         }
 406         let (track_x, sb_y, track_w, sb_h, _, _) = self.h_scrollbar_geom();
 407         py >= sb_y - 4.0 && py <= sb_y + sb_h + 4.0 && px >= track_x && px <= track_x + track_w
 408     }
 409 
 410     /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
 411     /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
 412     /// scrollbar consumed the press — a press on the rows falls through to them.
 413     pub fn press(&mut self, px: f32, py: f32) -> bool {
 414         self.focused = self.hit(px, py);
 415         // The bottom bar first: its ±4 slop strip sits inside the box, where
 416         // the vertical hit test can never claim it.
 417         if self.hit_h_scrollbar(px, py) {
 418             self.dragging_h = true;
 419             let (track_x, _, track_w, _, thumb_x, thumb_w) = self.h_scrollbar_geom();
 420             let click_offset = px - thumb_x;
 421             if click_offset >= 0.0 && click_offset <= thumb_w {
 422                 self.drag_offset_x = click_offset;
 423             } else {
 424                 self.drag_offset_x = thumb_w / 2.0;
 425                 let target = px - self.drag_offset_x;
 426                 let ratio = if track_w - thumb_w > 0.0 {
 427                     ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
 428                 } else {
 429                     0.0
 430                 };
 431                 self.scroll_x = ratio * self.max_scroll_x();
 432             }
 433             return true;
 434         }
 435         if !self.hit_scrollbar(px, py) {
 436             self.dragging = false;
 437             return false;
 438         }
 439         self.dragging = true;
 440         let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
 441         let click_offset = py - thumb_y;
 442         if click_offset >= 0.0 && click_offset <= thumb_h {
 443             self.drag_offset_y = click_offset;
 444         } else {
 445             self.drag_offset_y = thumb_h / 2.0;
 446             let target = py - self.drag_offset_y;
 447             let ratio = if track_h - thumb_h > 0.0 {
 448                 ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
 449             } else {
 450                 0.0
 451             };
 452             self.scroll_y = ratio * self.max_scroll();
 453         }
 454         true
 455     }
 456 
 457     /// Returns whether a thumb drag was in progress (the caller's redraw signal).
 458     pub fn release(&mut self) -> bool {
 459         // Bitwise on purpose: both drags must reset even when the first
 460         // operand is already true (|| would short-circuit the take).
 461         let was_dragging = std::mem::take(&mut self.dragging) | std::mem::take(&mut self.dragging_h);
 462         if was_dragging && self.sink_behind {
 463             // A drag release starts the hold window, so the bar lingers
 464             // briefly instead of sinking the instant the button lifts.
 465             self.activity.bump();
 466         }
 467         was_dragging
 468     }
 469 
 470     fn drag_move(&mut self, py: f32) -> bool {
 471         let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
 472         let target = py - self.drag_offset_y;
 473         let ratio = if track_h - thumb_h > 0.0 {
 474             ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
 475         } else {
 476             0.0
 477         };
 478         let old = self.scroll_y;
 479         self.scroll_y = ratio * self.max_scroll();
 480         (self.scroll_y - old).abs() > 0.01
 481     }
 482 
 483     /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
 484     /// treats it as a high-priority drag override), else just tracks hover for the border
 485     /// tint and the keyboard scope.
 486     pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
 487         self.hovered = self.hit(px, py);
 488         if self.sink_behind {
 489             // Gated hit tests: a sunk bar reports no hover, so hover can only
 490             // sustain a raised bar (the hysteresis contract).
 491             self.activity
 492                 .set_hover(self.hit_scrollbar(px, py) || self.hit_h_scrollbar(px, py));
 493         }
 494         if self.dragging {
 495             self.drag_move(py);
 496             return true;
 497         }
 498         if self.dragging_h {
 499             let (track_x, _, track_w, _, _, thumb_w) = self.h_scrollbar_geom();
 500             let target = px - self.drag_offset_x;
 501             let ratio = if track_w - thumb_w > 0.0 {
 502                 ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
 503             } else {
 504                 0.0
 505             };
 506             self.scroll_x = ratio * self.max_scroll_x();
 507             return true;
 508         }
 509         false
 510     }
 511 
 512     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
 513         if !self.hit(px, py) {
 514             return false;
 515         }
 516         // Sideways wheel/trackpad deltas pan an h-scrollable list; a
 517         // vertical-only list ignores them (max_scroll_x = 0 clamps to 0).
 518         self.motion.reconcile(self.scroll_x, self.scroll_y);
 519         let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), self.bounds_x(), self.bounds_y());
 520         self.sync_from_motion();
 521         if changed {
 522             self.raise();
 523         }
 524         changed
 525     }
 526 
 527     /// Whether the bar overflows in either axis — the raise/sink "visible" input.
 528     fn overflowing(&self) -> bool {
 529         self.content_h > self.viewport_h || self.h_scroll_active()
 530     }
 531 
 532     /// Refresh the raise hold and recompute immediately, so a scroll shows the
 533     /// bar in the same frame's redraw rather than one tick later.
 534     fn raise(&mut self) {
 535         if self.sink_behind {
 536             self.activity.bump();
 537             self.activity.recompute(self.overflowing(), self.dragging || self.dragging_h);
 538         }
 539     }
 540 
 541     /// A host scrolled the region programmatically (selection auto-snap, jump
 542     /// to a search hit): raise a sink-behind bar the same way a wheel scroll
 543     /// does. No-op for regions without [`Self::sink_behind`].
 544     pub fn notify_scrolled(&mut self) {
 545         self.raise();
 546     }
 547 
 548     /// Whether the scrollbar currently draws in front of the content and takes
 549     /// input. Always true for a region without [`Self::sink_behind`].
 550     pub fn scrollbar_raised(&self) -> bool {
 551         !self.sink_behind || self.activity.raised()
 552     }
 553 
 554     /// Opacity of the fore copy of the bar, 0..=1 — see
 555     /// [`ScrollbarActivity::fade`]. Always 1 for a region that never sinks.
 556     pub fn scrollbar_fade(&self) -> f32 {
 557         if self.sink_behind {
 558             self.activity.fade()
 559         } else {
 560             1.0
 561         }
 562     }
 563 
 564     /// Per-frame raise/sink upkeep for sink-behind regions; a `true` return is
 565     /// the host's repaint signal. True while the post-scroll hold is running,
 566     /// not just on the flip: the demand-driven frame loop only keeps ticking
 567     /// while frames flow, so the hold must keep them coming or the sink would
 568     /// stall until the next input event. No-op (false) without `sink_behind`.
 569     pub fn tick(&mut self, dt: f32) -> bool {
 570         // The glide/coast first: a host write to the pub offsets since the
 571         // last frame (thumb drag, auto-snap) is adopted, then the motion
 572         // advances and the drawn offsets follow it.
 573         self.motion.reconcile(self.scroll_x, self.scroll_y);
 574         let moved = self.motion.tick(dt, self.bounds_x(), self.bounds_y());
 575         self.sync_from_motion();
 576         let animating = self.motion.is_animating();
 577         if !self.sink_behind {
 578             return moved || animating;
 579         }
 580         let holding = self.activity.holding();
 581         let flipped = self
 582             .activity
 583             .tick(dt, self.overflowing(), self.dragging || self.dragging_h);
 584         moved || animating || flipped || holding
 585     }
 586 
 587     /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
 588     /// when focused or hovered; the dissolved region keeps both via its local flags).
 589     pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
 590         if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
 591             return false;
 592         }
 593         // Keyboard steps ride the same glide as wheel notches (a held arrow
 594         // accumulates into one motion); pages and Home/End glide to their
 595         // absolute target.
 596         let s = scroll_settings();
 597         self.motion.reconcile(self.scroll_x, self.scroll_y);
 598         let by = self.bounds_y();
 599         let bx = self.bounds_x();
 600         let max_x = self.max_scroll_x();
 601         let changed = if event.ctrl {
 602             match &event.logical_key {
 603                 Key::Character(c) if c == "n" || c == "N" => self.motion.y.wheel(LINE_PX, by, &s),
 604                 Key::Character(c) if c == "p" || c == "P" => self.motion.y.wheel(-LINE_PX, by, &s),
 605                 _ => return false,
 606             }
 607         } else {
 608             match &event.logical_key {
 609                 Key::Named(NamedKey::ArrowDown) => self.motion.y.wheel(LINE_PX, by, &s),
 610                 Key::Named(NamedKey::ArrowUp) => self.motion.y.wheel(-LINE_PX, by, &s),
 611                 Key::Named(NamedKey::PageDown) => {
 612                     let t = self.motion.y.target() + self.viewport_h;
 613                     self.motion.y.scroll_to(t, by, &s)
 614                 }
 615                 Key::Named(NamedKey::PageUp) => {
 616                     let t = self.motion.y.target() - self.viewport_h;
 617                     self.motion.y.scroll_to(t, by, &s)
 618                 }
 619                 Key::Named(NamedKey::Home) => self.motion.y.scroll_to(0.0, by, &s),
 620                 Key::Named(NamedKey::End) => self.motion.y.scroll_to(by.hi, by, &s),
 621                 // Only an h-scrollable list claims the horizontal arrows —
 622                 // elsewhere they keep falling through to other handlers.
 623                 Key::Named(NamedKey::ArrowRight) if max_x > 0.0 => self.motion.x.wheel(LINE_PX, bx, &s),
 624                 Key::Named(NamedKey::ArrowLeft) if max_x > 0.0 => self.motion.x.wheel(-LINE_PX, bx, &s),
 625                 _ => return false,
 626             }
 627         };
 628         self.sync_from_motion();
 629         if changed {
 630             self.raise();
 631         }
 632         changed
 633     }
 634 
 635     /// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
 636     /// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
 637     pub fn push_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
 638         if self.draw_frame {
 639             let radius = crate::layout::list_corner_radius();
 640             let border_color = if self.focused {
 641                 [0.30, 0.50, 0.32, 1.0]
 642             } else if self.hovered {
 643                 [0.25, 0.25, 0.35, 1.0]
 644             } else {
 645                 [0.18, 0.18, 0.24, 1.0]
 646             };
 647             let all = (true, true, true, true);
 648             pc.rect_with_radius_corners(border_color, self.x, self.y, self.w, self.h, radius, all);
 649             // A sunk sink-behind bar draws here, UNDER the translucent bg fill
 650             // (list_bg_color's alpha is 0.3): it shows through dimly, sunk into
 651             // the list plate — the designer parameter-pane look, self-contained
 652             // for framed regions.
 653             if self.sink_behind && !self.activity.raised() {
 654                 self.push_scrollbar_prims(pc);
 655             }
 656             pc.rect_with_radius_corners(
 657                 crate::color::list_bg_color(),
 658                 self.x + 1.0,
 659                 self.y + 1.0,
 660                 self.w - 2.0,
 661                 self.h - 2.0,
 662                 (radius - 1.0).max(0.0),
 663                 all,
 664             );
 665         }
 666         // Raised (or plain always-on): the bar rides on top. A FRAMELESS
 667         // sink-behind region draws no sunk layer here — its rows sit directly
 668         // on the host's plate, so the host owns the under-plate emission via
 669         // `push_scrollbar_prims`.
 670         // The fore copy fades rather than flips, and keeps drawing all the way
 671         // out — gating this on `scrollbar_raised` would cut the fade off at the
 672         // latch. The tuple path below is deliberately left on the hard flip:
 673         // its hosts have no frosted plate for a sunk bar to show through.
 674         let fade = self.scrollbar_fade();
 675         if fade > 0.001 {
 676             self.push_scrollbar_prims_alpha(pc, fade);
 677         }
 678     }
 679 
 680     /// The pill scrollbars alone (track + thumb, both axes), drawn wherever the
 681     /// host calls it. A sink-behind host emits this twice a frame at most:
 682     /// under its plate while the bar is sunk, over the content while raised.
 683     pub fn push_scrollbar_prims(&self, pc: &mut dyn crate::layout::RenderTarget) {
 684         self.push_scrollbar_prims_alpha(pc, 1.0);
 685     }
 686 
 687     /// [`Self::push_scrollbar_prims`] with the track and thumb scaled to
 688     /// `alpha` — what a host draws the FORE copy with while it fades in and
 689     /// out. The copy that idles behind the plate is drawn at full alpha; the
 690     /// plate over it is what dims and frosts it.
 691     pub fn push_scrollbar_prims_alpha(&self, pc: &mut dyn crate::layout::RenderTarget, alpha: f32) {
 692         let a = alpha.clamp(0.0, 1.0);
 693         if a <= 0.001 {
 694             return;
 695         }
 696         let dim = |mut c: [f32; 4]| {
 697             c[3] *= a;
 698             c
 699         };
 700         if self.content_h > self.viewport_h {
 701             // Track and thumb are pills — half-width radius (the designer look).
 702             let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
 703             let all = (true, true, true, true);
 704             pc.rect_with_radius_corners(dim(crate::color::scrollbar_track_color()), sb_x, track_y, sb_w, track_h, sb_w.min(track_h) * 0.5, all);
 705             pc.rect_with_radius_corners(dim(crate::color::scrollbar_thumb_color()), sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
 706         }
 707         if self.h_scroll_active() {
 708             let (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w) = self.h_scrollbar_geom();
 709             let all = (true, true, true, true);
 710             pc.rect_with_radius_corners(dim(crate::color::scrollbar_track_color()), track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
 711             pc.rect_with_radius_corners(dim(crate::color::scrollbar_thumb_color()), thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
 712         }
 713     }
 714 
 715     /// Flat background fill for hosts on the tuple pipeline. The scrollbar is
 716     /// split into [`Self::push_scrollbar_quads`] so the host can emit it AFTER
 717     /// the rows — drawn together, the rows paint over the thumb and it peeks
 718     /// through the inter-row gaps as dotted segments.
 719     ///
 720     /// For a sink-behind region, a sunk bar is emitted here FIRST, under the
 721     /// translucent bg fill (alpha 0.3), so it shows through dimly — and
 722     /// [`Self::push_scrollbar_quads`] goes quiet. The host's existing
 723     /// bg → rows → scrollbar order needs no change to adopt the treatment.
 724     pub fn push_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 725         if self.sink_behind && !self.activity.raised() {
 726             self.push_scrollbar_quads_always(quads);
 727         }
 728         quads.push((self.x, self.y, self.w, self.h, crate::color::list_bg_color()));
 729     }
 730 
 731     /// Scrollbar track + thumb when the content overflows; emit after the rows.
 732     /// For a sink-behind region this is the RAISED layer only — while sunk the
 733     /// bar was already emitted under the bg by [`Self::push_quads`].
 734     pub fn push_scrollbar_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 735         if self.scrollbar_raised() {
 736             self.push_scrollbar_quads_always(quads);
 737         }
 738     }
 739 
 740     fn push_scrollbar_quads_always(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
 741         if self.content_h > self.viewport_h {
 742             let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
 743             quads.push((sb_x, track_y, sb_w, track_h, crate::color::scrollbar_track_color()));
 744             quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
 745         }
 746     }
 747 }
 748 
 749 #[cfg(test)]
 750 mod tests {
 751     use super::*;
 752 
 753     /// Coming to the fore is a fade, not a flip: the latch moves at once (so
 754     /// input never waits) while the drawn opacity ramps, in and back out.
 755     #[test]
 756     fn scrollbar_fade_ramps_instead_of_flipping() {
 757         let mut a = ScrollbarActivity::new();
 758         a.bump();
 759         // dt = SCROLL_FADE_SECS / 3, so one tick is a third of the way in.
 760         let dt = SCROLL_FADE_SECS / 3.0;
 761         a.tick(dt, true, false);
 762         assert!(a.raised(), "the latch flips immediately");
 763         assert!(a.fade() > 0.0 && a.fade() < 1.0, "part-way faded in, got {}", a.fade());
 764         for _ in 0..3 {
 765             a.tick(dt, true, false);
 766         }
 767         assert_eq!(a.fade(), 1.0, "fully in after the fade duration");
 768 
 769         // Let the post-scroll hold expire: the latch drops, then the fade
 770         // runs back out rather than vanishing with it.
 771         let ticks = (SCROLL_ACTIVE_HOLD / dt).ceil() as i32 + 1;
 772         for _ in 0..ticks {
 773             a.tick(dt, true, false);
 774         }
 775         assert!(!a.raised(), "hold expired");
 776         assert!(a.fade() < 1.0 && a.fade() >= 0.0, "fading out, got {}", a.fade());
 777         for _ in 0..4 {
 778             a.tick(dt, true, false);
 779         }
 780         assert_eq!(a.fade(), 0.0, "fully out");
 781     }
 782 
 783     fn region() -> ScrollRegion {
 784         // item_height clamps to list_font + 14, so pick one comfortably above any config.
 785         let mut r = ScrollRegion::new(40.0, 4.0);
 786         r.set_rect(10.0, 20.0, 200.0, 100.0);
 787         r
 788     }
 789 
 790     /// Run the glide out (a no-op with smoothing off in the test host's config).
 791     fn settle(r: &mut ScrollRegion) {
 792         let mut n = 0;
 793         while r.is_animating() && n < 1000 {
 794             r.tick(1.0 / 60.0);
 795             n += 1;
 796         }
 797     }
 798 
 799     #[test]
 800     fn wheel_scrolls_and_clamps() {
 801         let mut r = region();
 802         r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
 803         assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
 804         settle(&mut r);
 805         assert_eq!(r.scroll_y, 48.0);
 806         assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
 807         r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
 808         settle(&mut r);
 809         assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
 810     }
 811 
 812     #[test]
 813     fn virtualization_matches_list_math() {
 814         let mut r = region();
 815         r.update_bounds(10, 20.0, 100.0);
 816         r.set_scroll_y(0.0);
 817         // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
 818         assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
 819         // Row 2 at 20 + 92 - 0 = 112: extends past the viewport bottom (121) but
 820         // still intersects it — returned so the caller draws it cut by the clip.
 821         assert_eq!(r.get_item_draw_y(2, 4.0), Some(112.0));
 822         // Row 3 at 20 + 136 = 156: fully below the viewport → culled.
 823         assert!(r.get_item_draw_y(3, 4.0).is_none());
 824     }
 825 
 826     #[test]
 827     fn virtualization_keeps_partial_row_at_top() {
 828         let mut r = region();
 829         r.update_bounds(10, 20.0, 100.0);
 830         // Scrolled so row 0 (virtual 4..44) is half above the viewport top:
 831         // draw_y = 20 + 4 - 24 = 0 < viewport_y, but its bottom (40) intersects.
 832         r.set_scroll_y(24.0);
 833         assert_eq!(r.get_item_draw_y(0, 4.0), Some(0.0));
 834         // A row whose bottom ends above the viewport top would be culled; with
 835         // this geometry row 0 always intersects, so scroll far and check row 0.
 836         r.set_scroll_y(80.0);
 837         assert!(r.get_item_draw_y(0, 4.0).is_none());
 838     }
 839 
 840     #[test]
 841     fn raw_shim_shares_the_intersection_contract() {
 842         let mut r = region();
 843         r.update_bounds_raw(444.0, 20.0, 100.0);
 844         r.set_scroll_y(50.0);
 845         // draw_y = 20 + 10 - 50 = -20; bottom = 4 < 19 → fully above, culled.
 846         assert!(r.get_draw_y(10.0, 24.0).is_none());
 847         // draw_y = 20 + 40 - 50 = 10: straddles the top edge → returned.
 848         assert_eq!(r.get_draw_y(40.0, 24.0), Some(10.0));
 849         // draw_y = 20 + 60 - 50 = 30: fully inside.
 850         assert_eq!(r.get_draw_y(60.0, 24.0), Some(30.0));
 851     }
 852 
 853     #[test]
 854     fn press_focuses_and_grabs_only_scrollbar() {
 855         let mut r = region();
 856         r.update_bounds(10, 20.0, 100.0);
 857         // Press in the rows area: focused, not dragging, falls through.
 858         assert!(!r.press(50.0, 50.0));
 859         assert!(r.focused && !r.dragging);
 860         // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
 861         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
 862         assert!(r.press(sb_x + 1.0, 50.0));
 863         assert!(r.dragging);
 864         assert!(r.release());
 865         // Press outside: unfocuses.
 866         assert!(!r.press(500.0, 500.0));
 867         assert!(!r.focused);
 868     }
 869 
 870     #[test]
 871     fn horizontal_scroll_is_opt_in_and_clamps() {
 872         let mut r = region();
 873         r.update_bounds(10, 20.0, 100.0);
 874         // No content width declared: x wheel deltas change nothing and the
 875         // vertical-only behavior (including the y component) is untouched.
 876         assert!(!r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
 877         assert_eq!(r.scroll_x, 0.0);
 878         assert!(!r.h_scroll_active());
 879 
 880         // Content wider than the 200px box: x deltas pan and clamp.
 881         r.set_content_w(500.0);
 882         assert!(r.h_scroll_active());
 883         assert!(r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
 884         settle(&mut r);
 885         assert_eq!(r.scroll_x, 48.0);
 886         r.wheel(&MouseScrollDelta::LineDelta(-100.0, 0.0), 50.0, 50.0);
 887         settle(&mut r);
 888         assert_eq!(r.scroll_x, 300.0); // max = 500 - 200
 889         r.wheel(&MouseScrollDelta::LineDelta(100.0, 0.0), 50.0, 50.0);
 890         settle(&mut r);
 891         assert_eq!(r.scroll_x, 0.0);
 892     }
 893 
 894     #[test]
 895     fn h_thumb_press_grabs_and_releases() {
 896         let mut r = region();
 897         r.update_bounds(2, 20.0, 100.0); // no vertical overflow
 898         r.set_content_w(500.0);
 899         // The bottom strip: y + h - sb_w - 4, thumb starts at track_x.
 900         let sb_y = 20.0 + 100.0 - crate::layout::scrollbar_width() - 4.0;
 901         assert!(r.press(20.0, sb_y + 1.0));
 902         // Drag right: scroll_x follows.
 903         assert!(r.cursor_moved(120.0, sb_y + 1.0));
 904         assert!(r.scroll_x > 0.0);
 905         assert!(r.release());
 906         // A rows-area press still falls through (no h-bar hit).
 907         assert!(!r.press(50.0, 50.0));
 908     }
 909 
 910     #[test]
 911     fn edge_inset_moves_the_bar_off_the_edge() {
 912         let mut r = region().with_edge_inset(20.0);
 913         r.update_bounds(10, 20.0, 100.0);
 914         // Bar right edge sits edge_inset in from the region's right edge; the
 915         // old 4px position no longer hits.
 916         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 20.0;
 917         assert!(r.press(sb_x + 1.0, 50.0));
 918         assert!(r.release());
 919         let old_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0 + 1.0;
 920         assert!(!r.press(old_x + 4.1, 50.0)); // past the ±4 slop of the inset bar
 921     }
 922 
 923     #[test]
 924     fn sink_behind_gates_input_until_a_scroll_raises() {
 925         let mut r = region().with_sink_behind(true);
 926         r.update_bounds(10, 20.0, 100.0);
 927         assert!(!r.scrollbar_raised());
 928         // Sunk: a press on the bar strip falls through (the plate occludes it).
 929         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
 930         assert!(!r.press(sb_x + 1.0, 50.0));
 931         assert!(!r.dragging);
 932         // A wheel scroll raises it in the same frame…
 933         assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
 934         assert!(r.scrollbar_raised());
 935         // …and now the bar takes the grab.
 936         assert!(r.press(sb_x + 1.0, 50.0));
 937         assert!(r.dragging);
 938         assert!(r.release());
 939         // The release refreshed the hold: still raised, and the hold keeps the
 940         // repaint signal up so the frame loop keeps ticking toward the sink.
 941         assert!(r.scrollbar_raised());
 942         assert!(r.tick(0.3)); // holding → keep frames coming
 943         assert!(r.scrollbar_raised());
 944         assert!(r.tick(SCROLL_ACTIVE_HOLD)); // hold lapses → sink flip reported
 945         assert!(!r.scrollbar_raised());
 946         assert!(!r.tick(0.016)); // settled sunk: quiet again
 947     }
 948 
 949     #[test]
 950     fn hover_sustains_but_never_raises() {
 951         let mut r = region().with_sink_behind(true);
 952         r.update_bounds(10, 20.0, 100.0);
 953         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
 954         // Hovering the sunk bar's strip does not raise it.
 955         r.cursor_moved(sb_x + 1.0, 50.0);
 956         assert!(!r.tick(0.016));
 957         assert!(!r.scrollbar_raised());
 958         // Raise by scrolling (and let the glide land, so the ticks below
 959         // measure only the raise/sink state), hover it, and let the hold
 960         // lapse: hover sustains.
 961         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
 962         settle(&mut r);
 963         r.cursor_moved(sb_x + 1.0, 50.0);
 964         r.tick(SCROLL_ACTIVE_HOLD + 0.1); // hold lapses, hover keeps it raised
 965         assert!(r.scrollbar_raised());
 966         assert!(!r.tick(0.016)); // sustained by hover alone: no repaint churn
 967         // Pointer leaves: the next tick sinks it.
 968         r.cursor_moved(50.0, 50.0);
 969         assert!(r.tick(0.016));
 970         assert!(!r.scrollbar_raised());
 971     }
 972 
 973     #[test]
 974     fn tuple_emission_layers_by_raised_state() {
 975         let mut r = region().with_sink_behind(true);
 976         r.update_bounds(10, 20.0, 100.0);
 977         // Sunk: bar quads come UNDER the bg (push_quads emits bar then bg, the
 978         // raised-layer call is quiet).
 979         let mut under = Vec::new();
 980         r.push_quads(&mut under);
 981         assert_eq!(under.len(), 3); // track + thumb + bg
 982         assert_eq!(under[2].2, 200.0); // last quad is the full-width bg fill
 983         let mut over = Vec::new();
 984         r.push_scrollbar_quads(&mut over);
 985         assert!(over.is_empty());
 986         // Raised: bg alone below, bar above.
 987         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
 988         let mut under = Vec::new();
 989         r.push_quads(&mut under);
 990         assert_eq!(under.len(), 1);
 991         let mut over = Vec::new();
 992         r.push_scrollbar_quads(&mut over);
 993         assert_eq!(over.len(), 2);
 994         // A non-sink region keeps the legacy shape: bg alone, bar always.
 995         let mut plain = region();
 996         plain.update_bounds(10, 20.0, 100.0);
 997         let (mut under, mut over) = (Vec::new(), Vec::new());
 998         plain.push_quads(&mut under);
 999         plain.push_scrollbar_quads(&mut over);
1000         assert_eq!((under.len(), over.len()), (1, 2));
1001     }
1002 
1003     #[test]
1004     fn non_sink_regions_are_unchanged() {
1005         let mut r = region();
1006         r.update_bounds(10, 20.0, 100.0);
1007         assert!(r.scrollbar_raised()); // always interactive
1008         assert!(!r.tick(1.0)); // tick is a no-op
1009         let sb_x = 10.0 + 200.0 - crate::layout::scrollbar_width() - 4.0;
1010         assert!(r.press(sb_x + 1.0, 50.0));
1011     }
1012 
1013     #[test]
1014     fn keyboard_is_hover_or_focus_scoped() {
1015         let mut r = region();
1016         r.update_bounds(10, 20.0, 100.0);
1017         let down = KeyEvent {
1018             state: ElementState::Pressed,
1019             logical_key: Key::Named(NamedKey::ArrowDown),
1020             text: None,
1021             repeat: false,
1022             ctrl: false,
1023             shift: false,
1024             alt: false,
1025         };
1026         assert!(!r.keyboard(&down)); // neither hovered nor focused
1027         r.cursor_moved(50.0, 50.0);
1028         assert!(r.hovered);
1029         assert!(r.keyboard(&down));
1030         settle(&mut r);
1031         assert_eq!(r.scroll_y, 24.0);
1032     }
1033 }