1use bevy_ecs::message::MessageReader;
22use bevy_ecs::prelude::*;
23use glam::Vec2;
24use lumen_core::prelude::*;
25
26#[allow(clippy::too_many_arguments, clippy::type_complexity)]
37pub fn update_scrollbars(
38 mut commands: Commands,
39 tick: Res<Tick>,
40 anim: Res<AnimationsActive>,
41 pointer: Res<PointerState>,
42 mut presses: MessageReader<PointerPressed>,
43 mut releases: MessageReader<PointerReleased>,
44 mut keys: MessageReader<KeyPressed>,
45 mut interaction: ResMut<ScrollbarInteraction>,
46 mut frame_dirty: ResMut<FrameDirty>,
47 parents: Query<&ChildOf>,
48 children_q: Query<(&ChildOf, &Transform)>,
49 bare: Query<Entity, (With<Scroll>, Without<ScrollbarState>)>,
50 styles: Query<&ScrollbarStyle>,
51 mut scrolls: Query<(
52 Entity,
53 &Transform,
54 &mut Scroll,
55 &mut ScrollOffset,
56 &mut ScrollbarState,
57 )>,
58) {
59 for e in &bare {
61 commands.entity(e).insert(ScrollbarState::default());
62 }
63
64 let dt = tick.dt.as_secs_f32().min(0.1);
65
66 let offsets: std::collections::HashMap<Entity, Vec2> =
70 scrolls.iter().map(|(e, _, _, off, _)| (e, off.0)).collect();
71
72 let mut extents: std::collections::HashMap<Entity, Vec2> = std::collections::HashMap::new();
75 for (child_of, kid) in &children_q {
76 let parent = child_of.parent();
77 if !scrolls.contains(parent) {
78 continue;
79 }
80 let e = extents.entry(parent).or_insert(Vec2::ZERO);
81 e.x = e.x.max(kid.absolute.x + kid.size.x);
84 e.y = e.y.max(kid.absolute.y + kid.size.y);
85 }
86
87 let geometry = |entity: Entity,
90 tf: &Transform,
91 scroll: &Scroll,
92 offset: Vec2|
93 -> (Option<ScrollbarGeometry>, Option<ScrollbarGeometry>) {
94 let style = styles.get(entity).copied().unwrap_or_default();
95 let Some(metrics) = style.metrics() else {
97 return (None, None);
98 };
99 let Some(max_corner) = extents.get(&entity) else {
100 return (None, None);
101 };
102 let content_w = max_corner.x - tf.absolute.x;
103 let content_h = max_corner.y - tf.absolute.y;
104 let allow_y = scroll.axis.allows_y();
105 let allow_x = scroll.axis.allows_x();
106 let v_overflow = allow_y && content_h - tf.size.y > 0.5;
107 let h_overflow = allow_x && content_w - tf.size.x > 0.5;
108 let mut anc_off = Vec2::ZERO;
110 let mut cur = entity;
111 while let Ok(p) = parents.get(cur) {
112 let parent = p.parent();
113 if let Some(o) = offsets.get(&parent) {
114 anc_off += *o;
115 }
116 cur = parent;
117 }
118 let origin = tf.absolute - anc_off;
119 let v = if v_overflow {
120 vertical_scrollbar(origin, tf.size, content_h, offset.y, h_overflow, metrics)
121 } else {
122 None
123 };
124 let h = if h_overflow {
125 horizontal_scrollbar(origin, tf.size, content_w, offset.x, v_overflow, metrics)
126 } else {
127 None
128 };
129 (v, h)
130 };
131
132 let pressed = presses
133 .read()
134 .any(|p| matches!(p.button, PointerButton::Primary));
135 let released = releases
136 .read()
137 .any(|r| matches!(r.button, PointerButton::Primary));
138 let escape = keys
139 .read()
140 .any(|k| matches!(k.key, Key::Named(NamedKey::Escape)));
141
142 if escape && let Some(drag) = interaction.drag.take() {
146 if let Ok((_, _, mut scroll, mut offset, _)) = scrolls.get_mut(drag.entity) {
147 offset.0 = drag.start_offset;
148 scroll.velocity = Vec2::ZERO;
149 }
150 frame_dirty.dirty = true;
151 }
152
153 if let Some(drag) = interaction.drag {
155 let end = released || !pointer.primary_down;
156 if end {
157 interaction.drag = None;
158 frame_dirty.dirty = true;
159 } else if let Some(pos) = pointer.position
160 && let Ok((entity, tf, mut scroll, mut offset, _state)) = scrolls.get_mut(drag.entity)
161 {
162 let (v, h) = geometry(entity, tf, &scroll, offset.0);
163 let (geo, vertical) = match drag.axis {
164 ScrollbarAxisPick::Vertical => (v, true),
165 ScrollbarAxisPick::Horizontal => (h, false),
166 };
167 if let Some(geo) = geo {
168 let along = if vertical { pos.y } else { pos.x };
169 let new = geo.offset_for_thumb_pos(along, drag.grab, vertical);
170 if vertical {
171 if (offset.0.y - new).abs() > f32::EPSILON {
172 offset.0.y = new;
173 scroll.velocity.y = 0.0;
174 frame_dirty.dirty = true;
175 }
176 } else if (offset.0.x - new).abs() > f32::EPSILON {
177 offset.0.x = new;
178 scroll.velocity.x = 0.0;
179 frame_dirty.dirty = true;
180 }
181 }
182 }
183 }
184
185 let prev_hover = interaction.hover;
187 let mut new_hover: Option<(Entity, ScrollbarAxisPick, ScrollbarPart)> = None;
188 if interaction.drag.is_none()
189 && let Some(pos) = pointer.position
190 {
191 for (entity, tf, scroll, offset, state) in scrolls.iter() {
192 if state.alpha <= 0.05 {
195 continue;
196 }
197 let (v, h) = geometry(entity, tf, scroll, offset.0);
198 let pick = |geo: Option<ScrollbarGeometry>,
199 axis: ScrollbarAxisPick|
200 -> Option<(Entity, ScrollbarAxisPick, ScrollbarPart)> {
201 let geo = geo?;
202 if geo.point_in_thumb(pos) {
203 Some((entity, axis, ScrollbarPart::Thumb))
204 } else if geo.point_in_track(pos) {
205 Some((entity, axis, ScrollbarPart::Track))
206 } else {
207 None
208 }
209 };
210 if let Some(hit) = pick(v, ScrollbarAxisPick::Vertical)
211 .or_else(|| pick(h, ScrollbarAxisPick::Horizontal))
212 {
213 let depth = |mut e: Entity| -> u32 {
216 let mut d = 0;
217 while let Ok(p) = parents.get(e) {
218 d += 1;
219 e = p.parent();
220 }
221 d
222 };
223 new_hover = match new_hover {
224 Some(prev) if depth(prev.0) >= depth(hit.0) => Some(prev),
225 _ => Some(hit),
226 };
227 }
228 }
229 }
230 if interaction.drag.is_none() {
231 interaction.hover = new_hover;
232 if prev_hover.map(|(e, a, _)| (e, a)) != new_hover.map(|(e, a, _)| (e, a)) {
233 frame_dirty.dirty = true;
235 }
236 }
237
238 if pressed
240 && interaction.drag.is_none()
241 && let Some((entity, axis, part)) = interaction.hover
242 && let Some(pos) = pointer.position
243 && let Ok((e, tf, mut scroll, mut offset, _state)) = scrolls.get_mut(entity)
244 {
245 let (v, h) = geometry(e, tf, &scroll, offset.0);
246 let (geo, vertical) = match axis {
247 ScrollbarAxisPick::Vertical => (v, true),
248 ScrollbarAxisPick::Horizontal => (h, false),
249 };
250 if let Some(geo) = geo {
251 let start_offset = offset.0;
254 let along = if vertical { pos.y } else { pos.x };
255 let thumb_start = if vertical {
256 geo.thumb_origin.y
257 } else {
258 geo.thumb_origin.x
259 };
260 let thumb_len = if vertical {
261 geo.thumb_size.y
262 } else {
263 geo.thumb_size.x
264 };
265 let grab = match part {
266 ScrollbarPart::Thumb => along - thumb_start,
269 ScrollbarPart::Track => thumb_len / 2.0,
273 };
274 let new = geo.offset_for_thumb_pos(along, grab, vertical);
275 if vertical {
276 offset.0.y = new;
277 scroll.velocity.y = 0.0;
278 } else {
279 offset.0.x = new;
280 scroll.velocity.x = 0.0;
281 }
282 interaction.drag = Some(ScrollbarDrag {
283 entity,
284 axis,
285 grab,
286 start_offset,
287 });
288 frame_dirty.dirty = true;
289 }
290 }
291
292 let hover_entity = interaction.hover.map(|(e, ..)| e);
294 let drag_entity = interaction.drag.map(|d| d.entity);
295 for (entity, _tf, _scroll, offset, mut state) in &mut scrolls {
296 let style = styles.get(entity).copied().unwrap_or_default();
297 let fade_delay = style.fade_delay_secs.max(0.0);
298 let fade_len = style.fade_secs.max(0.01);
299 let scrolled = (offset.0 - state.last_offset).length_squared() > 0.001;
300 let active = scrolled || hover_entity == Some(entity) || drag_entity == Some(entity);
301 let mut next = *state;
305 next.last_offset = offset.0;
306 if active {
307 next.idle_secs = 0.0;
308 next.alpha = 1.0;
309 } else {
310 next.idle_secs = (next.idle_secs + dt).min(fade_delay + fade_len + 1.0);
311 if next.idle_secs > fade_delay {
312 next.alpha = (next.alpha - dt / fade_len).max(0.0);
313 }
314 }
315 if next != *state {
316 let visual_change = next.alpha != state.alpha;
325 if visual_change {
326 *state = next;
327 } else {
328 *state.bypass_change_detection() = next;
329 }
330 if state.alpha > 0.0 {
334 anim.request();
335 }
336 }
337 }
338}
339
340#[cfg(test)]
341mod geometry_tests {
342 use lumen_core::prelude::*;
346
347 #[test]
348 fn no_bar_when_content_fits() {
349 assert!(
350 vertical_scrollbar(
351 glam::Vec2::ZERO,
352 glam::Vec2::new(200.0, 400.0),
353 400.0,
354 0.0,
355 false,
356 ScrollbarMetrics::default(),
357 )
358 .is_none(),
359 "content == viewport must not show a bar (as-needed)"
360 );
361 assert!(
362 vertical_scrollbar(
363 glam::Vec2::ZERO,
364 glam::Vec2::new(200.0, 400.0),
365 300.0,
366 0.0,
367 false,
368 ScrollbarMetrics::default(),
369 )
370 .is_none()
371 );
372 }
373
374 #[test]
375 fn thumb_length_is_proportional() {
376 let geo = vertical_scrollbar(
378 glam::Vec2::ZERO,
379 glam::Vec2::new(200.0, 400.0),
380 800.0,
381 0.0,
382 false,
383 ScrollbarMetrics::default(),
384 )
385 .unwrap();
386 let track_len = 400.0 - 2.0 * SCROLLBAR_MARGIN;
387 assert!((geo.track_size.y - track_len).abs() < 0.01);
388 assert!((geo.thumb_size.y - track_len / 2.0).abs() < 0.01);
389 assert!(
391 (geo.track_origin.x - (200.0 - SCROLLBAR_THICKNESS - SCROLLBAR_MARGIN)).abs() < 0.01
392 );
393 }
394
395 #[test]
396 fn thumb_respects_theme_minimum() {
397 let geo = vertical_scrollbar(
400 glam::Vec2::ZERO,
401 glam::Vec2::new(200.0, 400.0),
402 100_000.0,
403 0.0,
404 false,
405 ScrollbarMetrics::default(),
406 )
407 .unwrap();
408 assert!((geo.thumb_size.y - SCROLLBAR_MIN_THUMB).abs() < 0.01);
409 }
410
411 #[test]
412 fn thumb_position_tracks_offset() {
413 let viewport = glam::Vec2::new(200.0, 400.0);
414 let content = 800.0;
415 let max_off = content - viewport.y; let m = ScrollbarMetrics::default();
417 let geo_top =
418 vertical_scrollbar(glam::Vec2::ZERO, viewport, content, 0.0, false, m).unwrap();
419 let geo_bottom =
420 vertical_scrollbar(glam::Vec2::ZERO, viewport, content, max_off, false, m).unwrap();
421 assert!((geo_top.thumb_origin.y - geo_top.track_origin.y).abs() < 0.01);
422 let track_end = geo_bottom.track_origin.y + geo_bottom.track_size.y;
423 assert!(
424 (geo_bottom.thumb_origin.y + geo_bottom.thumb_size.y - track_end).abs() < 0.01,
425 "at max offset the thumb's trailing edge sits at the track end"
426 );
427 let geo_mid =
429 vertical_scrollbar(glam::Vec2::ZERO, viewport, content, max_off / 2.0, false, m)
430 .unwrap();
431 let expected = geo_mid.track_origin.y + 0.5 * (geo_mid.track_size.y - geo_mid.thumb_size.y);
432 assert!((geo_mid.thumb_origin.y - expected).abs() < 0.01);
433 }
434
435 #[test]
436 fn drag_mapping_is_exact_inverse() {
437 let viewport = glam::Vec2::new(200.0, 400.0);
438 let content = 4000.0;
439 for off in [0.0_f32, 123.0, 1800.0, 3600.0] {
440 let geo = vertical_scrollbar(
441 glam::Vec2::ZERO,
442 viewport,
443 content,
444 off,
445 false,
446 ScrollbarMetrics::default(),
447 )
448 .unwrap();
449 let grab = geo.thumb_size.y / 2.0;
452 let pointer = geo.thumb_origin.y + grab;
453 let recovered = geo.offset_for_thumb_pos(pointer, grab, true);
454 assert!(
455 (recovered - off).abs() < 0.5,
456 "offset {off} -> recovered {recovered}"
457 );
458 }
459 }
460
461 #[test]
462 fn horizontal_bar_sits_on_bottom_edge() {
463 let geo = horizontal_scrollbar(
464 glam::Vec2::new(10.0, 20.0),
465 glam::Vec2::new(300.0, 150.0),
466 900.0,
467 0.0,
468 false,
469 ScrollbarMetrics::default(),
470 )
471 .unwrap();
472 assert!(
473 (geo.track_origin.y - (20.0 + 150.0 - SCROLLBAR_THICKNESS - SCROLLBAR_MARGIN)).abs()
474 < 0.01
475 );
476 assert!((geo.track_size.x - (300.0 - 2.0 * SCROLLBAR_MARGIN)).abs() < 0.01);
477 }
478
479 #[test]
480 fn corner_reservation_shortens_track() {
481 let plain = vertical_scrollbar(
482 glam::Vec2::ZERO,
483 glam::Vec2::new(200.0, 400.0),
484 800.0,
485 0.0,
486 false,
487 ScrollbarMetrics::default(),
488 )
489 .unwrap();
490 let reserved = vertical_scrollbar(
491 glam::Vec2::ZERO,
492 glam::Vec2::new(200.0, 400.0),
493 800.0,
494 0.0,
495 true,
496 ScrollbarMetrics::default(),
497 )
498 .unwrap();
499 assert!(
500 reserved.track_size.y < plain.track_size.y,
501 "corner reservation must shorten the track so bars don't overlap"
502 );
503 }
504}
505
506#[cfg(test)]
507mod fsm_tests {
508 use super::*;
511 use bevy_ecs::message::Messages;
512 use bevy_ecs::system::RunSystemOnce;
513
514 fn world_with_scroller() -> (World, Entity) {
516 let mut world = World::new();
517 world.init_resource::<Messages<PointerPressed>>();
518 world.init_resource::<Messages<PointerReleased>>();
519 world.init_resource::<Messages<KeyPressed>>();
520 world.insert_resource(Tick::default());
521 world.insert_resource(AnimationsActive::default());
522 world.insert_resource(PointerState::default());
523 world.insert_resource(ScrollbarInteraction::default());
524 world.insert_resource(FrameDirty { dirty: false });
525 let scroller = world
526 .spawn((
527 Transform::new(glam::Vec2::ZERO, glam::Vec2::new(200.0, 400.0)),
528 Scroll::vertical().with_inertia(0.0),
529 ScrollOffset::default(),
530 ScrollbarState::default(),
531 ))
532 .id();
533 world.spawn((
534 Transform::new(glam::Vec2::ZERO, glam::Vec2::new(200.0, 800.0)),
535 ChildOf(scroller),
536 ));
537 (world, scroller)
538 }
539
540 fn run(world: &mut World) {
541 world.run_system_once(update_scrollbars).unwrap();
542 world.resource_mut::<Messages<PointerPressed>>().clear();
543 world.resource_mut::<Messages<PointerReleased>>().clear();
544 world.resource_mut::<Messages<KeyPressed>>().clear();
545 }
546
547 fn set_pointer(world: &mut World, pos: Option<glam::Vec2>, down: bool) {
548 *world.resource_mut::<PointerState>() = PointerState {
549 position: pos,
550 primary_down: down,
551 };
552 }
553
554 fn press(world: &mut World, pos: glam::Vec2) {
555 set_pointer(world, Some(pos), true);
556 world
557 .resource_mut::<Messages<PointerPressed>>()
558 .write(PointerPressed {
559 position: pos,
560 button: PointerButton::Primary,
561 local: None,
562 });
563 }
564
565 fn release(world: &mut World, pos: glam::Vec2) {
566 set_pointer(world, Some(pos), false);
567 world
568 .resource_mut::<Messages<PointerReleased>>()
569 .write(PointerReleased {
570 position: pos,
571 button: PointerButton::Primary,
572 local: None,
573 });
574 }
575
576 fn offset_y(world: &World, e: Entity) -> f32 {
577 world.get::<ScrollOffset>(e).unwrap().0.y
578 }
579
580 fn bar_x() -> f32 {
582 200.0 - SCROLLBAR_THICKNESS / 2.0 - SCROLLBAR_MARGIN
583 }
584
585 #[test]
586 fn hovering_the_thumb_is_tracked() {
587 let (mut world, scroller) = world_with_scroller();
588 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 50.0)), false);
590 run(&mut world);
591 let ix = world.resource::<ScrollbarInteraction>();
592 assert_eq!(
593 ix.hover.map(|(e, _, p)| (e, p)),
594 Some((scroller, ScrollbarPart::Thumb)),
595 );
596 }
597
598 #[test]
599 fn thumb_drag_maps_one_to_one_and_captures_pointer() {
600 let (mut world, scroller) = world_with_scroller();
601 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 50.0)), false);
603 run(&mut world);
604 press(&mut world, glam::Vec2::new(bar_x(), 50.0));
605 run(&mut world);
606 assert!(
607 world.resource::<ScrollbarInteraction>().drag.is_some(),
608 "press on thumb starts a drag"
609 );
610 set_pointer(&mut world, Some(glam::Vec2::new(500.0, 149.0)), true);
614 run(&mut world);
615 let off = offset_y(&world, scroller);
616 assert!(
617 (off - 200.0).abs() < 6.0,
618 "absolute 1:1 drag mapping (got {off})"
619 );
620 release(&mut world, glam::Vec2::new(500.0, 149.0));
622 run(&mut world);
623 assert!(world.resource::<ScrollbarInteraction>().drag.is_none());
624 }
625
626 #[test]
627 fn escape_cancels_thumb_drag_and_restores_offset() {
628 let (mut world, scroller) = world_with_scroller();
629 world.get_mut::<ScrollOffset>(scroller).unwrap().0.y = 60.0;
631 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 80.0)), false);
633 run(&mut world);
634 press(&mut world, glam::Vec2::new(bar_x(), 80.0));
635 run(&mut world);
636 assert!(world.resource::<ScrollbarInteraction>().drag.is_some());
637 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 180.0)), true);
639 run(&mut world);
640 assert!((offset_y(&world, scroller) - 60.0).abs() > 50.0);
641 world
643 .resource_mut::<Messages<KeyPressed>>()
644 .write(KeyPressed {
645 key: Key::Named(NamedKey::Escape),
646 modifiers: Modifiers::default(),
647 repeat: false,
648 });
649 run(&mut world);
650 assert_eq!(
651 offset_y(&world, scroller),
652 60.0,
653 "Escape restores the pre-drag scroll offset"
654 );
655 assert!(
656 world.resource::<ScrollbarInteraction>().drag.is_none(),
657 "Escape ends the drag"
658 );
659 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 300.0)), true);
662 run(&mut world);
663 assert_eq!(offset_y(&world, scroller), 60.0);
664 }
665
666 #[test]
667 fn track_click_jumps_to_position() {
668 let (mut world, scroller) = world_with_scroller();
669 let click_y = 390.0;
671 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), click_y)), false);
672 run(&mut world);
673 assert_eq!(
674 world
675 .resource::<ScrollbarInteraction>()
676 .hover
677 .map(|(_, _, p)| p),
678 Some(ScrollbarPart::Track),
679 );
680 press(&mut world, glam::Vec2::new(bar_x(), click_y));
681 run(&mut world);
682 let off = offset_y(&world, scroller);
683 assert!(
684 off > 350.0,
685 "track click near the bottom jumps close to max offset (got {off})"
686 );
687 }
688
689 #[test]
690 fn bars_fade_after_idle_and_reappear_on_scroll() {
691 let (mut world, scroller) = world_with_scroller();
692 for _ in 0..20 {
695 world.resource_mut::<Tick>().dt = std::time::Duration::from_millis(100);
696 run(&mut world);
697 }
698 let faded = world.get::<ScrollbarState>(scroller).unwrap().alpha;
699 assert!(
700 faded <= 0.001,
701 "bars fade out after ~1s idle (alpha {faded})"
702 );
703 world.get_mut::<ScrollOffset>(scroller).unwrap().0.y = 40.0;
705 run(&mut world);
706 let alpha = world.get::<ScrollbarState>(scroller).unwrap().alpha;
707 assert!(
708 (alpha - 1.0).abs() < f32::EPSILON,
709 "scroll reactivates the bars (alpha {alpha})"
710 );
711 }
712
713 #[test]
714 fn invisible_bars_do_not_capture_the_pointer() {
715 let (mut world, _scroller) = world_with_scroller();
716 for _ in 0..20 {
717 world.resource_mut::<Tick>().dt = std::time::Duration::from_millis(100);
718 run(&mut world);
719 }
720 set_pointer(&mut world, Some(glam::Vec2::new(bar_x(), 50.0)), false);
722 run(&mut world);
723 assert!(
724 world.resource::<ScrollbarInteraction>().hover.is_none(),
725 "fully faded bars must not steal pointer input"
726 );
727 }
728}