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 }