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 }