GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/container/scroll_box.rs (28.8K)
1 //! Embedded scroll-math + scrollbar-chrome helper (Phase 6av: DEMOTED from `WidgetHost` to a
2 //! plain struct). Never registered into the ctx tree by either consumer — TreeList and
3 //! cce-test-interface's panel copy drive it entirely through concrete calls — so the
4 //! `WidgetHost` impl was pure dyn-dispatch ballast. The former WidgetHost-default entry points the
5 //! consumers forward (`cursor_moved`, `tick`, drag hooks, `is_dragging`) are kept as
6 //! inherent methods with the exact default-derived behavior.
7
8 use crate::widget::*;
9
10 /// Shared relief-scrollbar painter: the track a carved groove
11 /// ([`crate::scene::paint::PaintCtx::recess`]), the thumb a raised rounded
12 /// plate riding in it ([`crate::scene::paint::PaintCtx::bevel`], configured
13 /// thumb color) — the DE highlight/shadow bevel treatment. `viewport` is the
14 /// scrolling area's box; the bar hugs its right edge. No-op while the content
15 /// fits.
16 pub fn paint_relief_scrollbar(
17 pc: &mut crate::scene::paint::PaintCtx,
18 viewport: crate::scene::layout::Rect,
19 content_h: f32,
20 scroll_y: f32,
21 ) {
22 if content_h <= viewport.height {
23 return;
24 }
25 let sb_w = crate::layout::scrollbar_width();
26 let sb_x = viewport.x + viewport.width - sb_w - 4.0;
27 let sb_track_h = viewport.height - 8.0;
28 let sb_track_y = viewport.y + 4.0;
29
30 let visible_ratio = viewport.height / content_h;
31 let thumb_h = if sb_track_h <= 20.0 {
32 sb_track_h
33 } else {
34 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
35 };
36 let max_scroll = (content_h - viewport.height).max(0.0);
37 let scroll_ratio = if max_scroll > 0.0 { scroll_y / max_scroll } else { 0.0 };
38 let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
39
40 // Pill radii; the roll width scales to the bar (the DE bevel width would
41 // swallow a 14px-wide thumb whole).
42 let r = sb_w * 0.5;
43 let depth = crate::layout::bevel_width().min(sb_w * 0.35);
44 let radii = (r, r, r, r);
45 use crate::scene::layout::Rect;
46 let (track, track_radii) =
47 crate::layout::carve_inside(Rect { x: sb_x, y: sb_track_y, width: sb_w, height: sb_track_h }, radii, depth);
48 pc.recess(track, track_radii, depth);
49 pc.bevel(
50 Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
51 radii,
52 &crate::scene::material::Material::from_fill(crate::color::scrollbar_thumb_color()),
53 depth,
54 );
55 }
56
57 #[derive(Debug, Clone)]
58 pub struct ScrollBox {
59 pub base: Widget,
60 pub scroll_y: f32,
61 pub content_h: f32,
62 pub viewport_y: f32,
63 pub viewport_h: f32,
64 viewport_offset_y: f32,
65 viewport_offset_h: f32,
66 pub show_border: bool,
67 pub show_background: bool,
68 pub scrollbar_dragging: bool,
69 pub drag_offset_y: f32,
70 /// Opt-in: the bar rides the box's CENTRE line and idles behind the
71 /// host's plate, raised in front by a scroll ([`ScrollbarActivity`]) —
72 /// the DE's one scrollbar design, as a sink-behind `ScrollRegion` has it.
73 /// The host draws the idle copy before its plate and the fore copy after
74 /// its content, both through [`Self::paint_scrollbar_pills`]. Off, the
75 /// bar hugs the right edge and is always grabbable, as it always was.
76 pub sink_behind: bool,
77 activity: crate::widget::ScrollbarActivity,
78 /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
79 motion: crate::widget::scroll_motion::ScrollMotion,
80 }
81
82 impl ScrollBox {
83 pub fn new() -> Self {
84 Self {
85 base: Widget::new(),
86 scroll_y: 0.0,
87 content_h: 0.0,
88 viewport_y: 0.0,
89 viewport_h: 0.0,
90 viewport_offset_y: 0.0,
91 viewport_offset_h: 0.0,
92 show_border: true,
93 show_background: true,
94 scrollbar_dragging: false,
95 drag_offset_y: 0.0,
96 sink_behind: false,
97 activity: crate::widget::ScrollbarActivity::new(),
98 motion: crate::widget::scroll_motion::ScrollMotion::new(),
99 }
100 }
101
102 /// The bar's geometry, `(sb_x, track_y, sb_w, track_h, thumb_y,
103 /// thumb_h)`, or `None` while the content fits — the one source for the
104 /// paint, the hit test, the press and the drag. Centred on the box's
105 /// width at [`crate::layout::centred_scrollbar_width`] for a
106 /// [`Self::sink_behind`] box, at the right edge otherwise.
107 fn bar_geom(&self) -> Option<(f32, f32, f32, f32, f32, f32)> {
108 if self.content_h <= self.viewport_h {
109 return None;
110 }
111 let (sb_x, sb_w) = if self.sink_behind {
112 let sb_w = crate::layout::centred_scrollbar_width();
113 (self.base.x + (self.base.w - sb_w) * 0.5, sb_w)
114 } else {
115 let sb_w = crate::layout::scrollbar_width();
116 (self.base.x + self.base.w - sb_w - 4.0, sb_w)
117 };
118 let track_h = self.viewport_h - 8.0;
119 let track_y = self.viewport_y + 4.0;
120 let visible_ratio = self.viewport_h / self.content_h;
121 let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
122 let max_scroll = (self.content_h - self.viewport_h).max(0.0);
123 let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
124 let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
125 Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h))
126 }
127
128 /// Move the thumb so its grab point is at `py`, returning whether the
129 /// offset moved.
130 fn drag_thumb_to(&mut self, py: f32) -> bool {
131 let Some((_, track_y, _, track_h, _, thumb_h)) = self.bar_geom() else { return false };
132 let max_scroll = (self.content_h - self.viewport_h).max(0.0);
133 let target = py - self.drag_offset_y;
134 let ratio = if track_h - thumb_h > 0.0 { ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0) } else { 0.0 };
135 let old = self.scroll_y;
136 self.scroll_y = ratio * max_scroll;
137 (self.scroll_y - old).abs() > 0.01
138 }
139
140 /// Whether the bar is raised in front of the host's plate — the latch,
141 /// which gates input. Always true for a box that does not sink.
142 pub fn scrollbar_raised(&self) -> bool {
143 !self.sink_behind || self.activity.raised()
144 }
145
146 /// How far the fore copy has faded in, 0..=1. Always 1 for a box that
147 /// does not sink.
148 pub fn scrollbar_fade(&self) -> f32 {
149 if self.sink_behind { self.activity.fade() } else { 1.0 }
150 }
151
152 /// The host moved `scroll_y` itself (a scroll to the selection): raise a
153 /// sink-behind bar as a wheel would.
154 pub fn notify_scrolled(&mut self) {
155 if self.sink_behind {
156 self.activity.bump();
157 self.activity.recompute(self.content_h > self.viewport_h, self.scrollbar_dragging);
158 }
159 }
160
161 /// The bar as flat pills in the track and thumb colours, scaled to
162 /// `alpha` — what a sink-behind host draws twice: the idle copy at 1
163 /// BEFORE its plate, and the fore copy at [`Self::scrollbar_fade`]
164 /// after its content. (The relief bar is not used for a sinking one:
165 /// shader-lit relief does not fade with a vertex alpha.)
166 pub fn paint_scrollbar_pills(&self, pc: &mut crate::scene::paint::PaintCtx, alpha: f32) {
167 let a = alpha.clamp(0.0, 1.0);
168 let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.bar_geom() else { return };
169 if a <= 0.001 {
170 return;
171 }
172 let dim = |mut c: [f32; 4]| {
173 c[3] *= a;
174 c
175 };
176 use crate::scene::layout::Rect;
177 let all = (true, true, true, true);
178 pc.rounded_rect(Rect { x: sb_x, y: track_y, width: sb_w, height: track_h }, sb_w.min(track_h) * 0.5, all, dim(crate::color::scrollbar_track_color()));
179 pc.rounded_rect(Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h }, sb_w.min(thumb_h) * 0.5, all, dim(crate::color::scrollbar_thumb_color()));
180 }
181
182 pub fn update_bounds(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
183 self.content_h = content_h;
184 self.viewport_y = viewport_y;
185 self.viewport_h = viewport_h;
186 self.viewport_offset_y = viewport_y - self.base.y;
187 self.viewport_offset_h = viewport_h - self.base.h;
188 let max_scroll = (content_h - viewport_h).max(0.0);
189 self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
190 self.motion.set_bounds(Bounds::max(0.0), Bounds::max(max_scroll));
191 }
192
193 fn bounds_y(&self) -> Bounds {
194 Bounds::max((self.content_h - self.viewport_h).max(0.0))
195 }
196
197 /// Whether a glide or coast is still moving the offset.
198 pub fn is_animating(&self) -> bool {
199 self.motion.is_animating()
200 }
201
202 /// Whether `(px, py)` is on the bar's strip (±4px slop). A sunk bar is
203 /// behind the host's plate and is never hit: a press on its lane is a
204 /// press on the content under it.
205 pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
206 if !self.scrollbar_raised() {
207 return false;
208 }
209 self.bar_geom().is_some_and(|(sb_x, track_y, sb_w, track_h, _, _)| {
210 px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
211 })
212 }
213
214 /// Screen y for an item at `virtual_y`, or `None` when it doesn't
215 /// intersect the viewport at all. Partially visible items ARE returned —
216 /// callers draw under a clip rect (or clamp per quad), so an edge item
217 /// renders cut, not culled, and hit-testing must accept the same partial
218 /// items the draw shows. (The original full-containment test here is what
219 /// made list rows vanish the moment they touched the viewport edge, in
220 /// every app that copied it.)
221 pub fn get_item_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
222 let draw_y = self.viewport_y + virtual_y - self.scroll_y;
223 if draw_y + item_h >= self.viewport_y - 1.0
224 && draw_y <= self.viewport_y + self.viewport_h + 1.0
225 {
226 Some(draw_y)
227 } else {
228 None
229 }
230 }
231 }
232
233 impl ScrollBox {
234 pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
235 self.base.x = x;
236 self.base.y = y;
237 self.base.w = w;
238 self.base.h = h;
239 self.viewport_y = y + self.viewport_offset_y;
240 self.viewport_h = h + self.viewport_offset_h;
241 }
242
243 /// The legacy `WidgetHost` default hit test over the base rect (ScrollBox never carried a
244 /// label or row expansion, so those branches are folded away).
245 fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
246 if ctx.is_coordinate_covered(self.base.id(), px, py) {
247 return false;
248 }
249 let (x, y, w, h) = (self.base.x, self.base.y, self.base.w, self.base.h);
250 if w <= 0.0 || h <= 0.0 {
251 return false;
252 }
253 px >= x && px <= x + w && py >= y && py <= y + h
254 }
255
256 /// The legacy focus claim on scrollbar/list clicks: its only observable effect was
257 /// unfocusing the previously focused widget (nothing ever queried focus ON the scroll
258 /// box through the thread-local, and its own `unfocus` was a no-op) — so just release
259 /// the current holder instead of storing a pointer to a non-WidgetHost.
260 fn claim_focus(&self, ctx: &mut UiContext) {
261 ctx.clear_focus();
262 }
263
264 pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
265 if button == MouseButton::Left {
266 if state == ElementState::Pressed {
267 if self.hit_test_scrollbar(px, py) {
268 self.claim_focus(ctx);
269 self.scrollbar_dragging = true;
270 let (_, _, _, _, thumb_y, thumb_h) = self.bar_geom().expect("a hit bar has geometry");
271 let click_offset = py - thumb_y;
272 if click_offset >= 0.0 && click_offset <= thumb_h {
273 self.drag_offset_y = click_offset;
274 } else {
275 // Clicked outside the thumb: jump thumb center to py
276 self.drag_offset_y = thumb_h / 2.0;
277 self.drag_thumb_to(py);
278 }
279 return true;
280 } else {
281 self.scrollbar_dragging = false;
282 }
283 if self.hit_test(px, py, ctx) {
284 self.claim_focus(ctx);
285 }
286 } else if state == ElementState::Released {
287 self.end_thumb_drag();
288 }
289 }
290 false
291 }
292
293 pub fn draggable(&self) -> bool {
294 self.scrollbar_dragging
295 }
296
297 /// Legacy `WidgetHost` default parity: ScrollBox never overrode `is_dragging` — TreeList
298 /// forwards it and always got `false`.
299 pub fn is_dragging(&self) -> bool {
300 false
301 }
302
303 pub fn drag_begin(&mut self, _px: f32, _py: f32) {}
304
305 pub fn drag_update(&mut self, _px: f32, py: f32) -> bool {
306 if !self.scrollbar_dragging {
307 return false;
308 }
309 self.drag_thumb_to(py)
310 }
311
312 pub fn drag_end(&mut self) {
313 self.end_thumb_drag();
314 }
315
316 /// A thumb drag let go: a sink-behind bar lingers for the hold window
317 /// rather than sinking the instant the button lifts.
318 fn end_thumb_drag(&mut self) {
319 if std::mem::take(&mut self.scrollbar_dragging) && self.sink_behind {
320 self.activity.bump();
321 }
322 }
323
324 /// The legacy `WidgetHost` default `cursor_moved` entry (cce-test-interface's panel copy
325 /// calls it): cover-check clears hover, otherwise falls into `on_cursor_moved`. The
326 /// MouseLeave dispatch the default performed was a no-op for ScrollBox.
327 pub fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
328 ctx.set_cursor_pos(px, py);
329 if ctx.is_coordinate_covered(self.base.id(), px, py) {
330 let was = self.base.hovered;
331 if was {
332 self.base.hovered = false;
333 }
334 return was;
335 }
336 self.on_cursor_moved(px, py, ctx)
337 }
338
339 pub fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
340 let mut changed = false;
341 if self.scrollbar_dragging && self.drag_thumb_to(py) {
342 changed = true;
343 }
344 if self.sink_behind {
345 // Gated on the latch: a sunk bar reports no hover, so hover only
346 // ever holds up a bar a scroll raised.
347 self.activity.set_hover(self.hit_test_scrollbar(px, py));
348 }
349
350 let was = self.base.hovered;
351 self.base.hovered = self.hit_test(px, py, ctx);
352 if was != self.base.hovered {
353 changed = true;
354 }
355 changed
356 }
357
358 /// Per-frame smooth-scroll upkeep: adopts host writes to `scroll_y`,
359 /// advances a wheel glide or trackpad coast, and returns the repaint
360 /// signal (true while anything is still moving).
361 pub fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
362 self.motion.reconcile(0.0, self.scroll_y);
363 let moved = self.motion.tick(dt, Bounds::max(0.0), self.bounds_y());
364 self.scroll_y = self.motion.y.pos();
365 let animating = self.motion.is_animating();
366 if !self.sink_behind {
367 return moved || animating;
368 }
369 // A glide or coast in motion holds the bar up as the scroll that
370 // began it did; the hold keeps frames coming until the sink renders.
371 if moved {
372 self.activity.bump();
373 }
374 let holding = self.activity.holding();
375 let flipped = self.activity.tick(dt, self.content_h > self.viewport_h, self.scrollbar_dragging);
376 moved || animating || flipped || holding
377 }
378
379 pub fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
380 if self.hit_test(px, py, ctx) {
381 self.motion.reconcile(0.0, self.scroll_y);
382 let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), self.bounds_y());
383 self.scroll_y = self.motion.y.pos();
384 if changed {
385 self.notify_scrolled();
386 }
387 changed
388 } else {
389 false
390 }
391 }
392
393 pub fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
394 let mut quads = Vec::new();
395
396 // Background
397 if self.show_background {
398 quads.push((self.base.x, self.base.y, self.base.w, self.base.h, crate::color::list_bg_color()));
399 }
400
401
402
403 // Scrollbar
404 if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.bar_geom() {
405 quads.push((sb_x, track_y, sb_w, track_h, crate::color::scrollbar_track_color()));
406 quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
407 }
408
409 quads
410 }
411
412 /// The scrollbar in the DE's relief language — see
413 /// [`paint_relief_scrollbar`]. The flat-quad view stays available through
414 /// [`extra_quads`](Self::extra_quads) for legacy paths.
415 pub fn paint_scrollbar_relief(&self, pc: &mut crate::scene::paint::PaintCtx) {
416 paint_relief_scrollbar(
417 pc,
418 crate::scene::layout::Rect {
419 x: self.base.x,
420 y: self.viewport_y,
421 width: self.base.w,
422 height: self.viewport_h,
423 },
424 self.content_h,
425 self.scroll_y,
426 );
427 }
428
429 pub fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
430 let moved = self.keyboard_scroll(event, ctx);
431 if moved {
432 self.notify_scrolled();
433 }
434 moved
435 }
436
437 fn keyboard_scroll(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
438 // Focus never lands on the box itself (post-6av it is not an `WidgetHost`), and its id is
439 // never a tree ancestor of the focused widget — like the legacy address walk, this
440 // gate only ever passes via the hover check below.
441 let self_id = self.base.id();
442 let has_focus = ctx.is_focused_id(self_id) || {
443 let mut current = ctx.focused_widget;
444 let mut found = false;
445 while let Some(id) = current {
446 if id == self_id {
447 found = true;
448 break;
449 }
450 current = ctx.tree.parent_id(id);
451 }
452 found
453 };
454
455 let is_hovered = self.hit_test(ctx.cursor_pos.0, ctx.cursor_pos.1, ctx);
456 if !has_focus && !is_hovered {
457 return false;
458 }
459 if event.state != ElementState::Pressed {
460 return false;
461 }
462 let max_scroll = (self.content_h - self.viewport_h).max(0.0);
463 if event.ctrl {
464 match &event.logical_key {
465 Key::Character(c) if c == "n" || c == "N" => {
466 let old_scroll = self.scroll_y;
467 self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
468 (self.scroll_y - old_scroll).abs() > 0.01
469 }
470 Key::Character(c) if c == "p" || c == "P" => {
471 let old_scroll = self.scroll_y;
472 self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
473 (self.scroll_y - old_scroll).abs() > 0.01
474 }
475 _ => false,
476 }
477 } else {
478 match &event.logical_key {
479 Key::Named(NamedKey::ArrowDown) => {
480 let old_scroll = self.scroll_y;
481 self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
482 (self.scroll_y - old_scroll).abs() > 0.01
483 }
484 Key::Named(NamedKey::ArrowUp) => {
485 let old_scroll = self.scroll_y;
486 self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
487 (self.scroll_y - old_scroll).abs() > 0.01
488 }
489 Key::Named(NamedKey::PageDown) => {
490 let old_scroll = self.scroll_y;
491 self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max_scroll);
492 (self.scroll_y - old_scroll).abs() > 0.01
493 }
494 Key::Named(NamedKey::PageUp) => {
495 let old_scroll = self.scroll_y;
496 self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max_scroll);
497 (self.scroll_y - old_scroll).abs() > 0.01
498 }
499 Key::Named(NamedKey::Home) => {
500 let old_scroll = self.scroll_y;
501 self.scroll_y = 0.0;
502 (self.scroll_y - old_scroll).abs() > 0.01
503 }
504 Key::Named(NamedKey::End) => {
505 let old_scroll = self.scroll_y;
506 self.scroll_y = max_scroll;
507 (self.scroll_y - old_scroll).abs() > 0.01
508 }
509 _ => false,
510 }
511 }
512 }
513
514 }
515
516 unsafe impl Send for ScrollBox {}
517 unsafe impl Sync for ScrollBox {}
518
519 #[cfg(test)]
520 mod tests {
521 use super::*;
522
523 /// A sink-behind box's bar rides the centre line, takes no press while
524 /// sunk (the content under its lane does), is raised by a wheel and held
525 /// by a pointer over it, and sinks once nothing holds it; the fore copy
526 /// fades rather than flips. A plain box keeps its edge bar.
527 #[test]
528 fn a_sink_behind_bar_rides_the_centre_and_sinks_until_scrolled() {
529 let mut ui = UiContext::new();
530 let mut sb = ScrollBox::new();
531 sb.sink_behind = true;
532 sb.set_rect(10.0, 20.0, 200.0, 100.0);
533 sb.update_bounds(400.0, 20.0, 100.0);
534 let (sb_x, _, sb_w, _, _, _) = sb.bar_geom().unwrap();
535 assert!((sb_x + sb_w * 0.5 - 110.0).abs() < 0.01, "on the centre line");
536 assert_eq!(sb_w, crate::layout::centred_scrollbar_width());
537
538 // Sunk: the lane is the content's.
539 assert!(!sb.scrollbar_raised());
540 assert!(!sb.hit_test_scrollbar(110.0, 60.0));
541 assert!(!sb.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 60.0, &mut ui));
542 assert!(!sb.scrollbar_dragging);
543 // Hover never raises a sunk bar.
544 sb.on_cursor_moved(110.0, 60.0, &mut ui);
545 sb.tick(0.016, &mut ui);
546 assert!(!sb.scrollbar_raised());
547
548 // A wheel raises it; the fore copy fades in.
549 assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 60.0, &mut ui));
550 assert!(sb.scrollbar_raised());
551 assert!(sb.tick(0.05, &mut ui));
552 assert!(sb.scrollbar_fade() > 0.0 && sb.scrollbar_fade() < 1.0, "fading in: {}", sb.scrollbar_fade());
553 for _ in 0..1000 {
554 if !sb.is_animating() { break; }
555 sb.tick(1.0 / 60.0, &mut ui);
556 }
557 // Raised, the bar takes the press; a pointer over it holds it up.
558 assert!(sb.hit_test_scrollbar(110.0, 60.0));
559 sb.on_cursor_moved(110.0, 60.0, &mut ui);
560 sb.tick(1.0, &mut ui);
561 assert!(sb.scrollbar_raised(), "held by the pointer");
562 sb.on_cursor_moved(40.0, 60.0, &mut ui);
563 sb.tick(0.016, &mut ui);
564 assert!(!sb.scrollbar_raised(), "unheld, it sinks");
565 for _ in 0..30 {
566 sb.tick(0.016, &mut ui);
567 }
568 assert_eq!(sb.scrollbar_fade(), 0.0);
569
570 let mut plain = ScrollBox::new();
571 plain.set_rect(10.0, 20.0, 200.0, 100.0);
572 plain.update_bounds(400.0, 20.0, 100.0);
573 let (sb_x, _, sb_w, _, _, _) = plain.bar_geom().unwrap();
574 assert_eq!(sb_x + sb_w, 10.0 + 200.0 - 4.0, "a plain box keeps its edge bar");
575 assert!(plain.scrollbar_raised() && plain.hit_test_scrollbar(sb_x + 1.0, 60.0));
576 }
577
578 #[test]
579 fn test_scroll_box_bounds_scrolling() {
580 let mut sb = ScrollBox::new();
581 sb.set_rect(10.0, 20.0, 100.0, 100.0);
582
583 // 1. Initially scroll is 0
584 assert_eq!(sb.scroll_y, 0.0);
585
586 // 2. Update bounds: content_h = 150 (greater than viewport_h = 100)
587 sb.update_bounds(150.0, 20.0, 100.0);
588 assert_eq!(sb.scroll_y, 0.0);
589 assert_eq!(sb.content_h, 150.0);
590 assert_eq!(sb.viewport_h, 100.0);
591
592 // 3. Scroll inside bounds
593 let delta = MouseScrollDelta::LineDelta(0.0, -2.0); // scroll down by 2 lines (48px)
594 let mut dummy = UiContext::new();
595 let changed = sb.mouse_wheel(&delta, 50.0, 50.0, &mut dummy);
596 assert!(changed);
597 // The notch glides: run the motion out before reading the offset.
598 for _ in 0..1000 {
599 if !sb.is_animating() { break; }
600 sb.tick(1.0 / 60.0, &mut dummy);
601 }
602 assert_eq!(sb.scroll_y, 48.0);
603
604 // 4. Clamps at max scroll: 150 - 100 = 50
605 let delta_large = MouseScrollDelta::LineDelta(0.0, -10.0);
606 sb.mouse_wheel(&delta_large, 50.0, 50.0, &mut dummy);
607 for _ in 0..1000 {
608 if !sb.is_animating() { break; }
609 sb.tick(1.0 / 60.0, &mut dummy);
610 }
611 assert_eq!(sb.scroll_y, 50.0);
612
613 // 5. Test item draw coordinates (intersection contract: partially
614 // visible items are returned so callers draw them cut by the clip).
615 // Virtual item at virtual_y = 10, item_h = 24
616 // Screen draw y = 20 + 10 - 50 = -20; bottom = 4 < viewport_y - 1
617 // (19.0): fully above the viewport, culled.
618 assert!(sb.get_item_draw_y(10.0, 24.0).is_none());
619
620 // Virtual item at virtual_y = 40, item_h = 24
621 // Screen draw y = 20 + 40 - 50 = 10: straddles the viewport top
622 // (bottom = 34 >= 19.0) — returned, drawn cut by the clip.
623 assert_eq!(sb.get_item_draw_y(40.0, 24.0), Some(10.0));
624
625 // Virtual item at virtual_y = 60, item_h = 24
626 // Screen draw y = 20 + 60 - 50 = 30: fully inside.
627 assert_eq!(sb.get_item_draw_y(60.0, 24.0), Some(30.0));
628 }
629
630 #[test]
631 fn test_scroll_box_keyboard_input() {
632 let mut sb = ScrollBox::new();
633 sb.set_rect(10.0, 20.0, 100.0, 100.0);
634 sb.update_bounds(300.0, 20.0, 100.0); // max_scroll = 200.0
635
636 let mut ctx = UiContext::new();
637 // Hover the scroll box (the focus path took a ctx-registered WidgetHost; as a plain
638 // struct the hovered branch is the live gate).
639 ctx.set_cursor_pos(50.0, 50.0);
640
641 // 1. ArrowDown key
642 let event_down = KeyEvent {
643 state: ElementState::Pressed,
644 logical_key: Key::Named(NamedKey::ArrowDown),
645 text: None,
646 repeat: false,
647 ctrl: false,
648 shift: false,
649 alt: false,
650 };
651 assert!(sb.keyboard_input(&event_down, &mut ctx));
652 assert_eq!(sb.scroll_y, 24.0);
653
654 // 2. PageDown key
655 let event_pgdown = KeyEvent {
656 state: ElementState::Pressed,
657 logical_key: Key::Named(NamedKey::PageDown),
658 text: None,
659 repeat: false,
660 ctrl: false,
661 shift: false,
662 alt: false,
663 };
664 assert!(sb.keyboard_input(&event_pgdown, &mut ctx));
665 assert_eq!(sb.scroll_y, 124.0);
666
667 // 3. End key
668 let event_end = KeyEvent {
669 state: ElementState::Pressed,
670 logical_key: Key::Named(NamedKey::End),
671 text: None,
672 repeat: false,
673 ctrl: false,
674 shift: false,
675 alt: false,
676 };
677 assert!(sb.keyboard_input(&event_end, &mut ctx));
678 assert_eq!(sb.scroll_y, 200.0); // clamps at max_scroll = 200.0
679
680 // 4. PageUp key
681 let event_pgup = KeyEvent {
682 state: ElementState::Pressed,
683 logical_key: Key::Named(NamedKey::PageUp),
684 text: None,
685 repeat: false,
686 ctrl: false,
687 shift: false,
688 alt: false,
689 };
690 assert!(sb.keyboard_input(&event_pgup, &mut ctx));
691 assert_eq!(sb.scroll_y, 100.0);
692
693 // 5. Home key
694 let event_home = KeyEvent {
695 state: ElementState::Pressed,
696 logical_key: Key::Named(NamedKey::Home),
697 text: None,
698 repeat: false,
699 ctrl: false,
700 shift: false,
701 alt: false,
702 };
703 assert!(sb.keyboard_input(&event_home, &mut ctx));
704 assert_eq!(sb.scroll_y, 0.0);
705 }
706
707 #[test]
708 fn test_scroll_box_keys_gated_on_hover() {
709 let mut sb = ScrollBox::new();
710 sb.set_rect(10.0, 20.0, 100.0, 100.0);
711 sb.update_bounds(300.0, 20.0, 100.0); // max_scroll = 200.0
712
713 let mut ctx = UiContext::new();
714
715 let event_down = KeyEvent {
716 state: ElementState::Pressed,
717 logical_key: Key::Named(NamedKey::ArrowDown),
718 text: None,
719 repeat: false,
720 ctrl: false,
721 shift: false,
722 alt: false,
723 };
724
725 // Cursor away from the box, nothing focused: keys are ignored.
726 ctx.set_cursor_pos(500.0, 500.0);
727 assert!(!sb.keyboard_input(&event_down, &mut ctx));
728 assert_eq!(sb.scroll_y, 0.0);
729
730 // Hovered: keys scroll.
731 ctx.set_cursor_pos(50.0, 50.0);
732 assert!(sb.keyboard_input(&event_down, &mut ctx));
733 assert_eq!(sb.scroll_y, 24.0);
734 }
735 }