git.lucas.co / cce-ui
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 }