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 }