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 (23.7K)

  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     /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
 71     motion: crate::widget::scroll_motion::ScrollMotion,
 72 }
 73 
 74 impl ScrollBox {
 75     pub fn new() -> Self {
 76         Self {
 77             base: Widget::new(),
 78             scroll_y: 0.0,
 79             content_h: 0.0,
 80             viewport_y: 0.0,
 81             viewport_h: 0.0,
 82             viewport_offset_y: 0.0,
 83             viewport_offset_h: 0.0,
 84             show_border: true,
 85             show_background: true,
 86             scrollbar_dragging: false,
 87             drag_offset_y: 0.0,
 88             motion: crate::widget::scroll_motion::ScrollMotion::new(),
 89         }
 90     }
 91 
 92     pub fn update_bounds(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
 93         self.content_h = content_h;
 94         self.viewport_y = viewport_y;
 95         self.viewport_h = viewport_h;
 96         self.viewport_offset_y = viewport_y - self.base.y;
 97         self.viewport_offset_h = viewport_h - self.base.h;
 98         let max_scroll = (content_h - viewport_h).max(0.0);
 99         self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
100         self.motion.set_bounds(Bounds::max(0.0), Bounds::max(max_scroll));
101     }
102 
103     fn bounds_y(&self) -> Bounds {
104         Bounds::max((self.content_h - self.viewport_h).max(0.0))
105     }
106 
107     /// Whether a glide or coast is still moving the offset.
108     pub fn is_animating(&self) -> bool {
109         self.motion.is_animating()
110     }
111 
112     pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
113         if self.content_h <= self.viewport_h {
114             return false;
115         }
116         let sb_w = crate::layout::scrollbar_width();
117         let sb_x = self.base.x + self.base.w - sb_w - 4.0;
118         let sb_track_h = self.viewport_h - 8.0;
119         let sb_track_y = self.viewport_y + 4.0;
120 
121         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
122             && py >= sb_track_y && py <= sb_track_y + sb_track_h
123     }
124 
125     /// Screen y for an item at `virtual_y`, or `None` when it doesn't
126     /// intersect the viewport at all. Partially visible items ARE returned —
127     /// callers draw under a clip rect (or clamp per quad), so an edge item
128     /// renders cut, not culled, and hit-testing must accept the same partial
129     /// items the draw shows. (The original full-containment test here is what
130     /// made list rows vanish the moment they touched the viewport edge, in
131     /// every app that copied it.)
132     pub fn get_item_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
133         let draw_y = self.viewport_y + virtual_y - self.scroll_y;
134         if draw_y + item_h >= self.viewport_y - 1.0
135             && draw_y <= self.viewport_y + self.viewport_h + 1.0
136         {
137             Some(draw_y)
138         } else {
139             None
140         }
141     }
142 }
143 
144 impl ScrollBox {
145     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
146         self.base.x = x;
147         self.base.y = y;
148         self.base.w = w;
149         self.base.h = h;
150         self.viewport_y = y + self.viewport_offset_y;
151         self.viewport_h = h + self.viewport_offset_h;
152     }
153 
154     /// The legacy `WidgetHost` default hit test over the base rect (ScrollBox never carried a
155     /// label or row expansion, so those branches are folded away).
156     fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
157         if ctx.is_coordinate_covered(self.base.id(), px, py) {
158             return false;
159         }
160         let (x, y, w, h) = (self.base.x, self.base.y, self.base.w, self.base.h);
161         if w <= 0.0 || h <= 0.0 {
162             return false;
163         }
164         px >= x && px <= x + w && py >= y && py <= y + h
165     }
166 
167     /// The legacy focus claim on scrollbar/list clicks: its only observable effect was
168     /// unfocusing the previously focused widget (nothing ever queried focus ON the scroll
169     /// box through the thread-local, and its own `unfocus` was a no-op) — so just release
170     /// the current holder instead of storing a pointer to a non-WidgetHost.
171     fn claim_focus(&self, ctx: &mut UiContext) {
172         focus::clear_focus(Some(ctx));
173     }
174 
175     pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
176         if button == MouseButton::Left {
177             if state == ElementState::Pressed {
178                 if self.hit_test_scrollbar(px, py) {
179                     self.claim_focus(ctx);
180                     self.scrollbar_dragging = true;
181                     
182                     let sb_track_h = self.viewport_h - 8.0;
183                     let sb_track_y = self.viewport_y + 4.0;
184                     let visible_ratio = self.viewport_h / self.content_h;
185                     let thumb_h = if sb_track_h <= 20.0 {
186                         sb_track_h
187                     } else {
188                         (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
189                     };
190                     let max_scroll = (self.content_h - self.viewport_h).max(0.0);
191                     let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
192                     let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
193                     
194                     let click_offset = py - thumb_y;
195                     if click_offset >= 0.0 && click_offset <= thumb_h {
196                         self.drag_offset_y = click_offset;
197                     } else {
198                         // Clicked outside the thumb: jump thumb center to py
199                         self.drag_offset_y = thumb_h / 2.0;
200                         let target_thumb_y = py - self.drag_offset_y;
201                         let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
202                             ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
203                         } else {
204                             0.0
205                         };
206                         self.scroll_y = new_scroll_ratio * max_scroll;
207                     }
208                     return true;
209                 } else {
210                     self.scrollbar_dragging = false;
211                 }
212                 if self.hit_test(px, py, ctx) {
213                     self.claim_focus(ctx);
214                 }
215             } else if state == ElementState::Released {
216                 self.scrollbar_dragging = false;
217             }
218         }
219         false
220     }
221 
222     pub fn draggable(&self) -> bool {
223         self.scrollbar_dragging
224     }
225 
226     /// Legacy `WidgetHost` default parity: ScrollBox never overrode `is_dragging` — TreeList
227     /// forwards it and always got `false`.
228     pub fn is_dragging(&self) -> bool {
229         false
230     }
231 
232     pub fn drag_begin(&mut self, _px: f32, _py: f32) {}
233 
234     pub fn drag_update(&mut self, _px: f32, py: f32) -> bool {
235         if !self.scrollbar_dragging {
236             return false;
237         }
238         let sb_track_h = self.viewport_h - 8.0;
239         let sb_track_y = self.viewport_y + 4.0;
240         let visible_ratio = self.viewport_h / self.content_h;
241         let thumb_h = if sb_track_h <= 20.0 {
242             sb_track_h
243         } else {
244             (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
245         };
246         let max_scroll = (self.content_h - self.viewport_h).max(0.0);
247         
248         let target_thumb_y = py - self.drag_offset_y;
249         let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
250             ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
251         } else {
252             0.0
253         };
254         
255         let old_scroll = self.scroll_y;
256         self.scroll_y = new_scroll_ratio * max_scroll;
257         (self.scroll_y - old_scroll).abs() > 0.01
258     }
259 
260     pub fn drag_end(&mut self) {
261         self.scrollbar_dragging = false;
262     }
263 
264     /// The legacy `WidgetHost` default `cursor_moved` entry (cce-test-interface's panel copy
265     /// calls it): cover-check clears hover, otherwise falls into `on_cursor_moved`. The
266     /// MouseLeave dispatch the default performed was a no-op for ScrollBox.
267     pub fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
268         ctx.set_cursor_pos(px, py);
269         if ctx.is_coordinate_covered(self.base.id(), px, py) {
270             let was = self.base.hovered;
271             if was {
272                 self.base.hovered = false;
273             }
274             return was;
275         }
276         self.on_cursor_moved(px, py, ctx)
277     }
278 
279     pub fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
280         let mut changed = false;
281         if self.scrollbar_dragging {
282             let sb_track_h = self.viewport_h - 8.0;
283             let sb_track_y = self.viewport_y + 4.0;
284             let visible_ratio = self.viewport_h / self.content_h;
285             let thumb_h = if sb_track_h <= 20.0 {
286                 sb_track_h
287             } else {
288                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
289             };
290             let max_scroll = (self.content_h - self.viewport_h).max(0.0);
291             
292             let target_thumb_y = py - self.drag_offset_y;
293             let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
294                 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
295             } else {
296                 0.0
297             };
298             
299             let old_scroll = self.scroll_y;
300             self.scroll_y = new_scroll_ratio * max_scroll;
301             if (self.scroll_y - old_scroll).abs() > 0.01 {
302                 changed = true;
303             }
304         }
305 
306         let was = self.base.hovered;
307         self.base.hovered = self.hit_test(px, py, ctx);
308         if was != self.base.hovered {
309             changed = true;
310         }
311         changed
312     }
313 
314     /// Per-frame smooth-scroll upkeep: adopts host writes to `scroll_y`,
315     /// advances a wheel glide or trackpad coast, and returns the repaint
316     /// signal (true while anything is still moving).
317     pub fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
318         self.motion.reconcile(0.0, self.scroll_y);
319         let moved = self.motion.tick(dt, Bounds::max(0.0), self.bounds_y());
320         self.scroll_y = self.motion.y.pos();
321         moved || self.motion.is_animating()
322     }
323 
324     pub fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
325         if self.hit_test(px, py, ctx) {
326             self.motion.reconcile(0.0, self.scroll_y);
327             let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), self.bounds_y());
328             self.scroll_y = self.motion.y.pos();
329             changed
330         } else {
331             false
332         }
333     }
334 
335     pub fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
336         let mut quads = Vec::new();
337 
338         // Background
339         if self.show_background {
340             quads.push((self.base.x, self.base.y, self.base.w, self.base.h, crate::color::list_bg_color()));
341         }
342 
343 
344 
345         // Scrollbar
346         if self.content_h > self.viewport_h {
347             let sb_w = crate::layout::scrollbar_width();
348             let sb_x = self.base.x + self.base.w - sb_w - 4.0;
349             let sb_track_h = self.viewport_h - 8.0;
350             let sb_track_y = self.viewport_y + 4.0;
351 
352             // Track
353             quads.push((sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()));
354 
355             // Thumb
356             let visible_ratio = self.viewport_h / self.content_h;
357             let thumb_h = if sb_track_h <= 20.0 {
358                 sb_track_h
359             } else {
360                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
361             };
362             let max_scroll = (self.content_h - self.viewport_h).max(0.0);
363             let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
364             let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
365 
366             quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
367         }
368 
369         quads
370     }
371 
372     /// The scrollbar in the DE's relief language — see
373     /// [`paint_relief_scrollbar`]. The flat-quad view stays available through
374     /// [`extra_quads`](Self::extra_quads) for legacy paths.
375     pub fn paint_scrollbar_relief(&self, pc: &mut crate::scene::paint::PaintCtx) {
376         paint_relief_scrollbar(
377             pc,
378             crate::scene::layout::Rect {
379                 x: self.base.x,
380                 y: self.viewport_y,
381                 width: self.base.w,
382                 height: self.viewport_h,
383             },
384             self.content_h,
385             self.scroll_y,
386         );
387     }
388 
389     pub fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
390         // Focus never lands on the box itself (post-6av it is not an `WidgetHost`), and its id is
391         // never a tree ancestor of the focused widget — like the legacy address walk, this
392         // gate only ever passes via the hover check below.
393         let self_id = self.base.id();
394         let has_focus = ctx.is_focused_id(self_id) || {
395             let mut current = ctx.focused_widget;
396             let mut found = false;
397             while let Some(id) = current {
398                 if id == self_id {
399                     found = true;
400                     break;
401                 }
402                 current = ctx.tree.parent_id(id);
403             }
404             found
405         };
406 
407         let is_hovered = self.hit_test(ctx.cursor_pos.0, ctx.cursor_pos.1, ctx);
408         if !has_focus && !is_hovered {
409             return false;
410         }
411         if event.state != ElementState::Pressed {
412             return false;
413         }
414         let max_scroll = (self.content_h - self.viewport_h).max(0.0);
415         if event.ctrl {
416             match &event.logical_key {
417                 Key::Character(c) if c == "n" || c == "N" => {
418                     let old_scroll = self.scroll_y;
419                     self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
420                     (self.scroll_y - old_scroll).abs() > 0.01
421                 }
422                 Key::Character(c) if c == "p" || c == "P" => {
423                     let old_scroll = self.scroll_y;
424                     self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
425                     (self.scroll_y - old_scroll).abs() > 0.01
426                 }
427                 _ => false,
428             }
429         } else {
430             match &event.logical_key {
431                 Key::Named(NamedKey::ArrowDown) => {
432                     let old_scroll = self.scroll_y;
433                     self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
434                     (self.scroll_y - old_scroll).abs() > 0.01
435                 }
436                 Key::Named(NamedKey::ArrowUp) => {
437                     let old_scroll = self.scroll_y;
438                     self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
439                     (self.scroll_y - old_scroll).abs() > 0.01
440                 }
441                 Key::Named(NamedKey::PageDown) => {
442                     let old_scroll = self.scroll_y;
443                     self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max_scroll);
444                     (self.scroll_y - old_scroll).abs() > 0.01
445                 }
446                 Key::Named(NamedKey::PageUp) => {
447                     let old_scroll = self.scroll_y;
448                     self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max_scroll);
449                     (self.scroll_y - old_scroll).abs() > 0.01
450                 }
451                 Key::Named(NamedKey::Home) => {
452                     let old_scroll = self.scroll_y;
453                     self.scroll_y = 0.0;
454                     (self.scroll_y - old_scroll).abs() > 0.01
455                 }
456                 Key::Named(NamedKey::End) => {
457                     let old_scroll = self.scroll_y;
458                     self.scroll_y = max_scroll;
459                     (self.scroll_y - old_scroll).abs() > 0.01
460                 }
461                 _ => false,
462             }
463         }
464     }
465 
466 }
467 
468 unsafe impl Send for ScrollBox {}
469 unsafe impl Sync for ScrollBox {}
470 
471 #[cfg(test)]
472 mod tests {
473     use super::*;
474 
475     #[test]
476     fn test_scroll_box_bounds_scrolling() {
477         let mut sb = ScrollBox::new();
478         sb.set_rect(10.0, 20.0, 100.0, 100.0);
479         
480         // 1. Initially scroll is 0
481         assert_eq!(sb.scroll_y, 0.0);
482 
483         // 2. Update bounds: content_h = 150 (greater than viewport_h = 100)
484         sb.update_bounds(150.0, 20.0, 100.0);
485         assert_eq!(sb.scroll_y, 0.0);
486         assert_eq!(sb.content_h, 150.0);
487         assert_eq!(sb.viewport_h, 100.0);
488 
489         // 3. Scroll inside bounds
490         let delta = MouseScrollDelta::LineDelta(0.0, -2.0); // scroll down by 2 lines (48px)
491         let mut dummy = UiContext::new();
492         let changed = sb.mouse_wheel(&delta, 50.0, 50.0, &mut dummy);
493         assert!(changed);
494         // The notch glides: run the motion out before reading the offset.
495         for _ in 0..1000 {
496             if !sb.is_animating() { break; }
497             sb.tick(1.0 / 60.0, &mut dummy);
498         }
499         assert_eq!(sb.scroll_y, 48.0);
500 
501         // 4. Clamps at max scroll: 150 - 100 = 50
502         let delta_large = MouseScrollDelta::LineDelta(0.0, -10.0);
503         sb.mouse_wheel(&delta_large, 50.0, 50.0, &mut dummy);
504         for _ in 0..1000 {
505             if !sb.is_animating() { break; }
506             sb.tick(1.0 / 60.0, &mut dummy);
507         }
508         assert_eq!(sb.scroll_y, 50.0);
509 
510         // 5. Test item draw coordinates (intersection contract: partially
511         // visible items are returned so callers draw them cut by the clip).
512         // Virtual item at virtual_y = 10, item_h = 24
513         // Screen draw y = 20 + 10 - 50 = -20; bottom = 4 < viewport_y - 1
514         // (19.0): fully above the viewport, culled.
515         assert!(sb.get_item_draw_y(10.0, 24.0).is_none());
516 
517         // Virtual item at virtual_y = 40, item_h = 24
518         // Screen draw y = 20 + 40 - 50 = 10: straddles the viewport top
519         // (bottom = 34 >= 19.0) — returned, drawn cut by the clip.
520         assert_eq!(sb.get_item_draw_y(40.0, 24.0), Some(10.0));
521 
522         // Virtual item at virtual_y = 60, item_h = 24
523         // Screen draw y = 20 + 60 - 50 = 30: fully inside.
524         assert_eq!(sb.get_item_draw_y(60.0, 24.0), Some(30.0));
525     }
526 
527     #[test]
528     fn test_scroll_box_keyboard_input() {
529         let mut sb = ScrollBox::new();
530         sb.set_rect(10.0, 20.0, 100.0, 100.0);
531         sb.update_bounds(300.0, 20.0, 100.0); // max_scroll = 200.0
532 
533         let mut ctx = UiContext::new();
534         // Hover the scroll box (the focus path took a ctx-registered WidgetHost; as a plain
535         // struct the hovered branch is the live gate).
536         ctx.set_cursor_pos(50.0, 50.0);
537 
538         // 1. ArrowDown key
539         let event_down = KeyEvent {
540             state: ElementState::Pressed,
541             logical_key: Key::Named(NamedKey::ArrowDown),
542             text: None,
543             repeat: false,
544             ctrl: false,
545             shift: false,
546             alt: false,
547         };
548         assert!(sb.keyboard_input(&event_down, &mut ctx));
549         assert_eq!(sb.scroll_y, 24.0);
550 
551         // 2. PageDown key
552         let event_pgdown = KeyEvent {
553             state: ElementState::Pressed,
554             logical_key: Key::Named(NamedKey::PageDown),
555             text: None,
556             repeat: false,
557             ctrl: false,
558             shift: false,
559             alt: false,
560         };
561         assert!(sb.keyboard_input(&event_pgdown, &mut ctx));
562         assert_eq!(sb.scroll_y, 124.0);
563 
564         // 3. End key
565         let event_end = KeyEvent {
566             state: ElementState::Pressed,
567             logical_key: Key::Named(NamedKey::End),
568             text: None,
569             repeat: false,
570             ctrl: false,
571             shift: false,
572             alt: false,
573         };
574         assert!(sb.keyboard_input(&event_end, &mut ctx));
575         assert_eq!(sb.scroll_y, 200.0); // clamps at max_scroll = 200.0
576 
577         // 4. PageUp key
578         let event_pgup = KeyEvent {
579             state: ElementState::Pressed,
580             logical_key: Key::Named(NamedKey::PageUp),
581             text: None,
582             repeat: false,
583             ctrl: false,
584             shift: false,
585             alt: false,
586         };
587         assert!(sb.keyboard_input(&event_pgup, &mut ctx));
588         assert_eq!(sb.scroll_y, 100.0);
589 
590         // 5. Home key
591         let event_home = KeyEvent {
592             state: ElementState::Pressed,
593             logical_key: Key::Named(NamedKey::Home),
594             text: None,
595             repeat: false,
596             ctrl: false,
597             shift: false,
598             alt: false,
599         };
600         assert!(sb.keyboard_input(&event_home, &mut ctx));
601         assert_eq!(sb.scroll_y, 0.0);
602     }
603 
604     #[test]
605     fn test_scroll_box_keys_gated_on_hover() {
606         let mut sb = ScrollBox::new();
607         sb.set_rect(10.0, 20.0, 100.0, 100.0);
608         sb.update_bounds(300.0, 20.0, 100.0); // max_scroll = 200.0
609 
610         let mut ctx = UiContext::new();
611 
612         let event_down = KeyEvent {
613             state: ElementState::Pressed,
614             logical_key: Key::Named(NamedKey::ArrowDown),
615             text: None,
616             repeat: false,
617             ctrl: false,
618             shift: false,
619             alt: false,
620         };
621 
622         // Cursor away from the box, nothing focused: keys are ignored.
623         ctx.set_cursor_pos(500.0, 500.0);
624         assert!(!sb.keyboard_input(&event_down, &mut ctx));
625         assert_eq!(sb.scroll_y, 0.0);
626 
627         // Hovered: keys scroll.
628         ctx.set_cursor_pos(50.0, 50.0);
629         assert!(sb.keyboard_input(&event_down, &mut ctx));
630         assert_eq!(sb.scroll_y, 24.0);
631     }
632 }