Skip to main content

lumen_primitives/
scrollbar.rs

1//! Overlay-scrollbar interaction FSM + fade driver (spec section 16.2 / section 16.6).
2//!
3//! Painting lives in `lumen_core::render_world::extract_scrollbars`;
4//! this module owns everything main-world:
5//!
6//! - auto-attaching [`ScrollbarState`] to every [`Scroll`] container,
7//! - the pointer FSM (thumb hover highlight, thumb press + drag with
8//!   absolute 1:1 mapping and pointer capture, track click =
9//!   jump-to-position - consistent with the `<slider>` track-click
10//!   decision in [`crate::controls`]),
11//! - the overlay fade: bars stay fully visible while scrolling /
12//!   hovered, then fade out after `ScrollbarStyle::fade_delay_secs` of
13//!   inactivity. The fade frames self-schedule through
14//!   [`lumen_core::render_world::AnimationsActive`] and go quiescent the
15//!   moment alpha reaches its resting value.
16//!
17//! Wheel events are never consumed here - hovering a bar resolves the
18//! hit-test to the scroll container itself (see `lumen_input::hit_test`),
19//! so the wheel keeps routing through the normal nested-scroll chain.
20
21use bevy_ecs::message::MessageReader;
22use bevy_ecs::prelude::*;
23use glam::Vec2;
24use lumen_core::prelude::*;
25
26/// Pointer FSM + fade driver for overlay scrollbars. Runs in
27/// `TickStage::Systems` strictly before `lumen_input::hit_test` so the
28/// [`ScrollbarInteraction`] resource the hit-test consults reflects this
29/// tick's pointer position.
30///
31/// All visual + timing knobs (thickness, minimum thumb, fade delay /
32/// ramp) resolve through [`ScrollbarStyle`] - CSS `scrollbar-color` /
33/// `scrollbar-width` per container, with the component [`Default`] as
34/// the no-stylesheet fallback - so the FSM's hit regions always match
35/// the styled paint.
36#[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    // Auto-attach fade state to freshly-spawned scroll containers.
60    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    // Scroll offsets snapshot for ancestor-chain translation (a nested
67    // scroller's viewport box moves with its ANCESTORS' offsets). Taken
68    // up front because `scrolls` is borrowed mutably below.
69    let offsets: std::collections::HashMap<Entity, Vec2> =
70        scrolls.iter().map(|(e, _, _, off, _)| (e, off.0)).collect();
71
72    // Content extents per scroll container (bbox of direct children -
73    // the same rule `clamp_scroll_offsets` applies).
74    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        // Child extents are made relative below (needs the parent tf);
82        // store absolute max corner for now.
83        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    // Resolve both bars' geometry for one container, honouring its
88    // CSS-resolved [`ScrollbarStyle`] metrics.
89    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        // `scrollbar-width: none` - no bars, no interaction.
96        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        // The viewport box translates with ANCESTOR scrollers only.
109        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    // -- Escape cancels an in-flight thumb drag (Qt drag-cancel):
143    // restore the pre-drag scroll offset and end the drag without a
144    // release commit. -------------------------------------------------
145    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    // -- Drag continuation / termination (pointer capture) ------------
154    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    // -- Hover pick (visible bars only) -------------------------------
186    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            // Invisible (fully faded) bars are not interactive - an
193            // invisible strip must not swallow clicks meant for rows.
194            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                // Deepest container wins when bars overlap (nested
214                // scrollers): prefer the one with more ancestors.
215                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            // Hover highlight / track visibility changed - repaint.
234            frame_dirty.dirty = true;
235        }
236    }
237
238    // -- Press: thumb grab or track jump-to-position ------------------
239    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            // Snapshot for Escape-cancel BEFORE the press applies its
252            // jump: cancelling restores the pre-press position.
253            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                // Grab keeps the pointer glued to the same spot on the
267                // thumb for the whole drag.
268                ScrollbarPart::Thumb => along - thumb_start,
269                // Track click: jump so the thumb centers under the
270                // pointer (the slider track-click decision), then keep
271                // dragging from the thumb's center.
272                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    // -- Fade FSM -----------------------------------------------------
293    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        // Compute the next fade values without touching the component
302        // yet - bevy change detection is write-triggered, and an
303        // unconditional write would keep FrameDirty hot forever.
304        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            // Only an actual alpha step is render-relevant.
317            // `roll_up_frame_dirty` watches `Changed<ScrollbarState>`, so
318            // clock-only updates (idle-delay countdown, `last_offset`
319            // refresh, post-fade accumulator ticks) must go through
320            // `bypass_change_detection` - marking them Changed turned
321            // every countdown tick into a full repaint and kept
322            // otherwise-idle apps rendering for seconds after the last
323            // interaction.
324            let visual_change = next.alpha != state.alpha;
325            if visual_change {
326                *state = next;
327            } else {
328                *state.bypass_change_detection() = next;
329            }
330            // Keep ticking while the fade clock still has somewhere to
331            // go (idle countdown or alpha ramp) so the fade completes
332            // without external events, then go quiescent.
333            if state.alpha > 0.0 {
334                anim.request();
335            }
336        }
337    }
338}
339
340#[cfg(test)]
341mod geometry_tests {
342    //! Spec section 16.2 - thumb geometry: proportional length with theme
343    //! minimum, position from offset / content extent, as-needed
344    //! visibility, and the absolute 1:1 drag inverse.
345    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        // Viewport 400, content 800 -> thumb = half the track.
377        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        // Bar hugs the right edge.
390        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        // Content 100 000 px -> proportional thumb would be ~1.6 px;
398        // the theme minimum must win.
399        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; // 400
416        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        // Midpoint.
428        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            // Grab the thumb dead center, don't move the pointer: the
450            // recovered offset must equal the input offset.
451            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    //! Interaction FSM: thumb press + drag (1:1, pointer capture), track
509    //! click jump, fade after idle, reappear on scroll.
510    use super::*;
511    use bevy_ecs::message::Messages;
512    use bevy_ecs::system::RunSystemOnce;
513
514    /// 200x400 vertical scroller with 800-px content => max offset 400.
515    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    /// The x coordinate inside the vertical bar strip.
581    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        // Thumb occupies the top half of the track at offset 0.
589        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        // Grab the thumb at y=50 (inside the thumb at offset 0)...
602        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        // ...move down 99 px: track_len - thumb_len ~ 198, max 400 =>
611        // offset ~ 99/198x400 ~ 200. Pointer is far OUTSIDE the bar
612        // horizontally - capture keeps the drag alive.
613        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 ends the drag.
621        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        // Start from a non-zero offset so the restore is observable.
630        world.get_mut::<ScrollOffset>(scroller).unwrap().0.y = 60.0;
631        // Hover + grab the thumb (offset 60 -> thumb ~ y 30..129).
632        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        // Drag down - offset moves away from 60.
638        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        // Escape mid-drag: offset restored, drag ended.
642        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        // Further pointer motion while the button is still held must
660        // not resume the cancelled drag.
661        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        // Click near the bottom of the track (thumb is at the top).
670        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        // Simulate a long idle stretch: step the fade clock in 100 ms
693        // slices (dt is clamped to 100 ms per tick).
694        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        // Any scroll-offset change brings them straight back.
704        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        // Pointer over the (now invisible) bar strip: no hover pick.
721        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}