Skip to main content

lumen_primitives/
scroll.rs

1//! Scroll primitive.
2//!
3//! - Tag a container with [`Scroll`] to declare its axes.
4//! - [`accumulate_wheel`] reads [`MouseWheel`] messages and updates [`ScrollOffset`] on the directly-hovered scrollable entity.
5//! - The default extract fns consume [`ScrollOffset`] via [`lumen_core::render_world::parent_scroll_offsets`].
6//! - Clipping (`overflow: hidden`) is handled in `lumen-layout-taffy`.
7
8use bevy_ecs::message::MessageReader;
9use bevy_ecs::prelude::*;
10use glam::Vec2;
11use lumen_core::prelude::*;
12
13/// Plugin: registers [`accumulate_wheel`] and [`integrate_scroll`]. The
14/// scroll-aware extract path is supplied by lumen-core's default
15/// extracts - they query [`ScrollOffset`] directly and translate
16/// descendants accordingly.
17pub struct ScrollPlugin;
18
19impl Plugin for ScrollPlugin {
20    fn build(self, app: &mut App) {
21        // W6 T3: every `ScrollOffset` mutator is ordered strictly BEFORE
22        // `hit_test`. The two conflict on `ScrollOffset` (writer vs
23        // reader), so without an explicit edge the executor serialized
24        // them in an arbitrary, per-tick-flippable order - when the last
25        // offset mutation of a fling landed AFTER that tick's hit-test,
26        // the `Hovered` marker kept reflecting pre-scroll positions under
27        // a stationary cursor until some unrelated tick re-ran it (the
28        // "stale hover after simulated scroll" bug). With the edge,
29        // content moving under the cursor re-resolves hover on the same
30        // tick, matching Qt's behaviour (scroll under a stationary
31        // cursor re-evaluates the hovered widget).
32        //
33        // Wheel ROUTING (`accumulate_wheel` walks up from the hovered
34        // entity) now deterministically uses the previous tick's hover -
35        // the pointer position it was resolved from is unchanged unless
36        // a `PointerMoved` arrived this very tick, which the winit
37        // backend delivers on its own tick anyway.
38        app.add_systems(
39            TickStage::Systems,
40            accumulate_wheel.before(lumen_input::hit_test),
41        );
42        app.add_systems(
43            TickStage::Systems,
44            integrate_scroll
45                .after(accumulate_wheel)
46                .before(lumen_input::hit_test),
47        );
48        // Keyboard scrolling for the focused scrollable (or its nearest
49        // scrollable ancestor). Same `.before(hit_test)` edge as the
50        // wheel path - it mutates `ScrollOffset` too (W6 T3).
51        app.add_systems(
52            TickStage::Systems,
53            scroll_on_keys.before(lumen_input::hit_test),
54        );
55        // Overlay scrollbars (spec section 16.2/section 16.6): pointer FSM + fade
56        // driver, ordered strictly before the hit-test so the
57        // `ScrollbarInteraction` resource it consults reflects THIS
58        // tick's pointer position (bars sit above content).
59        app.add_systems(
60            TickStage::Systems,
61            crate::scrollbar::update_scrollbars.before(lumen_input::hit_test),
62        );
63        // W5.3: drain [`A11yScrollIntoViewRequests`] (written by
64        // `handle_a11y_action` in `lumen-window-winit`) so screen-reader
65        // initiated `Action::ScrollIntoView` calls actually translate to
66        // a scroll. Runs in `LayoutSync` so the read of `Transform.size`
67        // sees freshly resolved layout; the offset write lands before
68        // `clamp_scroll_offsets` in `A11ySync` clips it to the valid
69        // range.
70        app.add_systems(TickStage::LayoutSync, apply_a11y_scroll_into_view);
71        // Clamp runs after layout so child Transforms reflect this
72        // tick's content extent. Without this the user can scroll
73        // past either edge into empty space.
74        app.add_systems(TickStage::A11ySync, clamp_scroll_offsets);
75    }
76}
77
78/// Upper bound on per-tick integration step. After a suspend / hot
79/// reload / debugger pause, `Tick.dt` can balloon into seconds; without
80/// a cap the inertial fling launches the offset off the page in a
81/// single tick. 100 ms matches the wall-clock budget most browsers use
82/// for their `requestAnimationFrame` rate limiters after backgrounding.
83pub const MAX_INTEGRATION_DT_MS: u32 = 100;
84
85/// Velocity below which the integrator snaps to zero, in px/s. At
86/// 60 Hz a velocity of 3 px/s produces ~0.05 px of motion per frame -
87/// imperceptible, but heavy enough to avoid eternal sub-pixel ticks.
88pub const VELOCITY_SLEEP_PX_PER_S: f32 = 3.0;
89
90/// Slack (px) when deciding whether a scroller is already pinned at a
91/// limit for wheel-routing purposes. Sub-pixel residue from inertia /
92/// rubber-band settling must not make an at-limit scroller "consume"
93/// the event and stall the bubble to its ancestor.
94const WHEEL_LIMIT_EPSILON: f32 = 0.5;
95
96/// Accumulate wheel deltas into the [`ScrollOffset`] of the innermost
97/// scrollable ancestor of whatever entity the pointer is hovering
98/// **that can still scroll in the event's direction** (spec section 16.5).
99///
100/// Why bubbling at all: `hit_test` picks the *deepest* candidate under
101/// the cursor (so a tile beats its column container), but the `Scroll`
102/// component lives on the container, not on the leaf - so we walk the
103/// [`ChildOf`] chain upward. Nested-scroll routing (Qt propagation
104/// model): the innermost hovered scroll area handles the wheel first;
105/// when it is already at its limit on the event's direction (or the
106/// event's axes don't intersect its allowed [`ScrollAxis`]), it does
107/// **not** consume the event and the next scrollable ancestor gets it.
108/// An inner list pinned at its bottom + wheel-down therefore scrolls
109/// the outer page, while wheel-up still scrolls the inner list.
110///
111/// If no scroll ancestor on the chain can consume the event (every
112/// scroller is pinned at its limit for this direction), the event is
113/// routed to the innermost scroller anyway so overshoot / rubber-band
114/// handling still observes the gesture - `clamp_scroll_offsets` keeps
115/// the offset in bounds, so this is a visual no-op on hard-clamp
116/// platforms. The cursor being over chrome with no `Scroll` ancestor
117/// at all keeps the old fallback: the world's first `Scroll` entity,
118/// so wheel still does something sensible in single-scroller apps.
119///
120/// Drains the [`MouseWheel`] buffer into a local vec first to avoid losing
121/// events during archetype churn (the previous `single_mut` version did).
122pub fn accumulate_wheel(
123    mut wheels: MessageReader<MouseWheel>,
124    hovered: Query<Entity, With<Hovered>>,
125    parents: Query<&ChildOf>,
126    children_q: Query<(&ChildOf, &Transform)>,
127    transforms: Query<&Transform>,
128    sliders: Query<(), With<SliderValue>>,
129    mut scrolls: Query<(Entity, &mut Scroll, &mut ScrollOffset)>,
130) {
131    let pending: Vec<MouseWheel> = wheels.read().copied().collect();
132    if pending.is_empty() {
133        return;
134    }
135    // A hovered `<slider>` (or its thumb child) consumes the wheel -
136    // `lumen_primitives::controls::adjust_slider_on_wheel` turns it into
137    // a value step - so it must never ALSO scroll an ancestor scroll
138    // container (Qt: an accepted wheel event does not propagate).
139    {
140        let mut cur = hovered.iter().next();
141        while let Some(e) = cur {
142            if sliders.contains(e) {
143                return;
144            }
145            cur = parents.get(e).ok().map(|c| c.parent());
146        }
147    }
148    let total: Vec2 = pending.iter().map(|ev| ev.delta).sum();
149
150    // Max scroll offset per axis for one container: bbox of its direct
151    // children relative to its own rect - the same content-extent rule
152    // `clamp_scroll_offsets` applies.
153    let max_offset_of = |container: Entity| -> Vec2 {
154        let Ok(self_tf) = transforms.get(container) else {
155            // No layout yet - treat as freely scrollable so the first
156            // tick doesn't drop events (matches pre-routing behavior).
157            return Vec2::splat(f32::INFINITY);
158        };
159        let mut max_x = 0.0_f32;
160        let mut max_y = 0.0_f32;
161        for (child_of, kid) in &children_q {
162            if child_of.parent() != container {
163                continue;
164            }
165            max_x = max_x.max((kid.absolute.x - self_tf.absolute.x) + kid.size.x);
166            max_y = max_y.max((kid.absolute.y - self_tf.absolute.y) + kid.size.y);
167        }
168        Vec2::new(
169            (max_x - self_tf.size.x).max(0.0),
170            (max_y - self_tf.size.y).max(0.0),
171        )
172    };
173
174    // section 16.5 consumption test: `offset += delta` (the DOM wheel
175    // convention), so a negative delta moves toward 0 (needs offset > 0)
176    // and a positive delta moves toward max (needs offset < max) - per
177    // axis, masked to the container's allowed axes.
178    let can_consume = |scroll: &Scroll, offset: Vec2, max_off: Vec2| -> bool {
179        let masked = mask_delta(total, scroll.axis);
180        let axis_ok = |d: f32, off: f32, max: f32| -> bool {
181            if d < 0.0 {
182                off > WHEEL_LIMIT_EPSILON
183            } else if d > 0.0 {
184                off < max - WHEEL_LIMIT_EPSILON
185            } else {
186                false
187            }
188        };
189        axis_ok(masked.x, offset.x, max_off.x) || axis_ok(masked.y, offset.y, max_off.y)
190    };
191
192    // Walk the hover chain innermost-first, collecting scrollable
193    // ancestors; pick the first that can consume the event.
194    let mut chain: Vec<Entity> = Vec::new();
195    let mut cur = hovered.iter().next();
196    while let Some(e) = cur {
197        if scrolls.contains(e) {
198            chain.push(e);
199        }
200        cur = parents.get(e).ok().map(|c| c.parent());
201    }
202    let target = if chain.is_empty() {
203        // Cursor over chrome with no scrollable ancestor: legacy
204        // fallback to the world's first scroller. First-SPAWNED (minimum
205        // entity row), not first in query order: iteration order is
206        // archetype order, which reshuffles with unrelated world changes
207        // (`Entity`'s own `Ord` is not spawn order either), and the
208        // devtools overlay contributes a hidden scroller of its own -
209        // spawn order is the only stable "first" that lands on the app's.
210        scrolls.iter().map(|(e, ..)| e).min_by_key(|e| e.index())
211    } else {
212        chain
213            .iter()
214            .copied()
215            .find(|&e| {
216                scrolls
217                    .get(e)
218                    .map(|(_, s, off)| can_consume(s, off.0, max_offset_of(e)))
219                    .unwrap_or(false)
220            })
221            // Every scroller in the chain is at its limit: route to the
222            // innermost anyway so rubber-band / overshoot handling (and
223            // sensitivity-scaled velocity) still observe the gesture -
224            // `clamp_scroll_offsets` keeps it in bounds.
225            .or_else(|| chain.first().copied())
226    };
227    let Some(target) = target else {
228        return;
229    };
230
231    // Apply.
232    if let Ok((_entity, mut scroll, mut offset)) = scrolls.get_mut(target) {
233        let mut delta = Vec2::ZERO;
234        for ev in &pending {
235            delta += mask_delta(ev.delta, scroll.axis);
236        }
237        delta *= scroll.sensitivity;
238        let inertia = scroll.inertia.clamp(0.0, 1.0);
239        // Immediate portion: 1 - inertia. Velocity portion: inertia.
240        offset.0 += delta * (1.0 - inertia);
241        // Velocity is stored in px/s. A wheel detent normally takes
242        // ~16 ms (one 60 Hz tick) for the OS to fire, so the px/frame
243        // equivalent multiplies by ~60 to land in px/s - preserves the
244        // pre-delta-time-fix glide distance at 60 Hz while letting the
245        // integrator scale correctly across refresh rates.
246        const WHEEL_VELOCITY_HZ: f32 = 60.0;
247        scroll.velocity += delta * inertia * WHEEL_VELOCITY_HZ;
248    }
249}
250
251/// Mask a wheel delta to a container's allowed [`ScrollAxis`].
252fn mask_delta(delta: Vec2, axis: ScrollAxis) -> Vec2 {
253    match axis {
254        ScrollAxis::X => Vec2::new(delta.x, 0.0),
255        ScrollAxis::Y => Vec2::new(0.0, delta.y),
256        ScrollAxis::Both => delta,
257    }
258}
259
260/// Keyboard scrolling for the focused entity, or - when the focused
261/// entity isn't itself scrollable - its nearest [`Scroll`]-bearing
262/// ancestor. Walks the [`ChildOf`] chain the same way
263/// [`apply_a11y_scroll_into_view`] does for AT-driven `ScrollIntoView`.
264///
265/// - `ArrowUp` / `ArrowDown` / `ArrowLeft` / `ArrowRight`: one line
266///   ([`crate::physics::LINE_HEIGHT_PX`]) along the arrow's direction,
267///   masked to the container's allowed [`ScrollAxis`] (a horizontal-only
268///   scroller ignores Up/Down and vice versa).
269/// - `PageUp` / `PageDown` (forwarded by the winit backend as
270///   `Key::Character("PageUp"/"PageDown")` - no dedicated `NamedKey`
271///   variant exists yet): one viewport height.
272/// - `Home` / `End`: jump to the top / bottom of the content.
273///
274/// Every target value - including `Home`/`End` - is computed against
275/// the container's own content-extent bbox (the same bounding-box scan
276/// [`clamp_scroll_offsets`] does) and clamped inline, so this system is
277/// self-contained: it doesn't depend on `clamp_scroll_offsets` running
278/// afterward to land in bounds, and (unlike a naive "seek to
279/// `f32::MAX`") `End` lands exactly at the max offset even under
280/// macOS's rubber-band overshoot curve rather than asymptotically
281/// approaching it.
282///
283/// A focused [`TextInput`] owns arrows/Home/End for caret movement
284/// (`type_into_focused` in lumen-input runs on the same raw key bus);
285/// this system no-ops whenever the *focused* entity is itself a text
286/// input, so a `<textarea>` sitting inside a scroll container doesn't
287/// have its arrow keys double-handled - moving the caret AND scrolling
288/// the container out from under it.
289///
290/// The same double-handling guard covers the widgets that own their
291/// arrow / Home / End keys while focused (Qt: a focused control that
292/// accepts a key consumes it - the view never also scrolls):
293/// dropdown headers ([`crate::tabs::DropdownButton`] - closed-state
294/// value stepping), popup rows ([`crate::tabs::DropdownOptionButton`]
295/// / [`crate::tabs::MenuItemButton`] - highlight movement), and tab
296/// strip buttons ([`crate::tabs::TabStripButton`] - tab switching).
297#[allow(clippy::too_many_arguments, clippy::type_complexity)]
298pub fn scroll_on_keys(
299    mut keys: MessageReader<KeyPressed>,
300    tracker: Res<FocusTracker>,
301    parents: Query<&ChildOf>,
302    text_inputs: Query<(), With<TextInput>>,
303    key_owning_widgets: Query<
304        (),
305        Or<(
306            With<crate::tabs::DropdownButton>,
307            With<crate::tabs::DropdownOptionButton>,
308            With<crate::tabs::MenuItemButton>,
309            With<crate::tabs::TabStripButton>,
310            // A freshly-opened menu parks focus on the panel itself
311            // until the first arrow press picks a row.
312            With<crate::popup::PopupPanel>,
313        )>,
314    >,
315    children: Query<(&ChildOf, &Transform)>,
316    mut scrolls: Query<(&Transform, &mut Scroll, &mut ScrollOffset)>,
317) {
318    let Some(focused) = tracker.0 else {
319        keys.read().for_each(drop);
320        return;
321    };
322    if text_inputs.contains(focused) || key_owning_widgets.contains(focused) {
323        keys.read().for_each(drop);
324        return;
325    }
326    // Nearest Scroll-bearing entity at/above `focused`.
327    let mut current = Some(focused);
328    let mut target: Option<Entity> = None;
329    while let Some(e) = current {
330        if scrolls.contains(e) {
331            target = Some(e);
332            break;
333        }
334        current = parents.get(e).ok().map(|c| c.parent());
335    }
336    let Some(target) = target else {
337        keys.read().for_each(drop);
338        return;
339    };
340    let Ok((tf, mut scroll, mut offset)) = scrolls.get_mut(target) else {
341        keys.read().for_each(drop);
342        return;
343    };
344    // Content extent, same bbox-of-direct-children approach
345    // `clamp_scroll_offsets` uses (that one groups by parent across all
346    // scrollers per tick; here we only need one target so a direct scan
347    // is simpler and just as cheap).
348    let mut max_x = 0.0f32;
349    let mut max_y = 0.0f32;
350    for (child_of, kid) in &children {
351        if child_of.parent() != target {
352            continue;
353        }
354        max_x = max_x.max((kid.absolute.x - tf.absolute.x) + kid.size.x);
355        max_y = max_y.max((kid.absolute.y - tf.absolute.y) + kid.size.y);
356    }
357    let max_off = Vec2::new((max_x - tf.size.x).max(0.0), (max_y - tf.size.y).max(0.0));
358    let touches_x = scroll.axis.allows_x();
359    let touches_y = scroll.axis.allows_y();
360
361    for ev in keys.read() {
362        let new = match &ev.key {
363            Key::Named(NamedKey::ArrowUp) => {
364                if !touches_y {
365                    continue;
366                }
367                offset.0 + Vec2::new(0.0, -crate::physics::LINE_HEIGHT_PX)
368            }
369            Key::Named(NamedKey::ArrowDown) => {
370                if !touches_y {
371                    continue;
372                }
373                offset.0 + Vec2::new(0.0, crate::physics::LINE_HEIGHT_PX)
374            }
375            Key::Named(NamedKey::ArrowLeft) => {
376                if !touches_x {
377                    continue;
378                }
379                offset.0 + Vec2::new(-crate::physics::LINE_HEIGHT_PX, 0.0)
380            }
381            Key::Named(NamedKey::ArrowRight) => {
382                if !touches_x {
383                    continue;
384                }
385                offset.0 + Vec2::new(crate::physics::LINE_HEIGHT_PX, 0.0)
386            }
387            Key::Character(s) if s.as_str() == "PageUp" => {
388                if !touches_y {
389                    continue;
390                }
391                offset.0 + Vec2::new(0.0, -tf.size.y)
392            }
393            Key::Character(s) if s.as_str() == "PageDown" => {
394                if !touches_y {
395                    continue;
396                }
397                offset.0 + Vec2::new(0.0, tf.size.y)
398            }
399            Key::Named(NamedKey::Home) => Vec2::new(
400                if touches_x { 0.0 } else { offset.0.x },
401                if touches_y { 0.0 } else { offset.0.y },
402            ),
403            Key::Named(NamedKey::End) => Vec2::new(
404                if touches_x { max_off.x } else { offset.0.x },
405                if touches_y { max_off.y } else { offset.0.y },
406            ),
407            _ => continue,
408        }
409        .clamp(Vec2::ZERO, max_off);
410
411        if new != offset.0 {
412            if (new.x - offset.0.x).abs() > f32::EPSILON {
413                scroll.velocity.x = 0.0;
414            }
415            if (new.y - offset.0.y).abs() > f32::EPSILON {
416                scroll.velocity.y = 0.0;
417            }
418            offset.0 = new;
419        }
420    }
421}
422
423/// Clamp every [`ScrollOffset`] to `[0, content - viewport]` per axis,
424/// with macOS-style rubber-band overshoot driven by
425/// [`crate::physics::RUBBER_BAND_STIFFNESS`]. Runs after layout so child
426/// `Transform`s reflect the current content extent.
427///
428/// Content extent is computed as the bounding box of the scroll
429/// container's direct children - virtualized `<for>` blocks set their
430/// own size to `rows x row_h`, so the bbox correctly reflects the
431/// scrollable region.
432///
433/// Rubber-band model: when stiffness > 0, an offset past the bound is
434/// pulled back via `bound + overflow / (1 + stiffness * |overflow|)`
435/// (a Hooke spring approximation). When stiffness is 0 (Windows /
436/// Linux defaults), the offset hard-clamps and velocity is zeroed on
437/// the clamped axis so inertial flings don't keep pumping into the
438/// wall - matching the pre-rubber-band behaviour.
439pub fn clamp_scroll_offsets(
440    children_q: Query<(&ChildOf, &Transform)>,
441    mut scrolls: Query<(Entity, &Transform, &mut Scroll, &mut ScrollOffset)>,
442) {
443    use std::collections::HashMap;
444    // Build child-by-parent lookup once.
445    let mut by_parent: HashMap<Entity, Vec<&Transform>> = HashMap::new();
446    for (parent, tf) in &children_q {
447        by_parent.entry(parent.parent()).or_default().push(tf);
448    }
449    let stiffness = crate::physics::RUBBER_BAND_STIFFNESS;
450    for (entity, self_tf, mut scroll, mut offset) in &mut scrolls {
451        let Some(kids) = by_parent.get(&entity) else {
452            // No children = no content; nothing to scroll.
453            if offset.0 != Vec2::ZERO {
454                offset.0 = Vec2::ZERO;
455            }
456            scroll.velocity = Vec2::ZERO;
457            continue;
458        };
459        // Children's `absolute` is layout-space (pre-scroll), so the
460        // local extent under the scroll container is the bbox of
461        // child positions relative to `self_tf.absolute`.
462        let mut max_x = 0.0_f32;
463        let mut max_y = 0.0_f32;
464        for kid in kids {
465            let rx = (kid.absolute.x - self_tf.absolute.x) + kid.size.x;
466            let ry = (kid.absolute.y - self_tf.absolute.y) + kid.size.y;
467            if rx > max_x {
468                max_x = rx;
469            }
470            if ry > max_y {
471                max_y = ry;
472            }
473        }
474        let max_off_x = (max_x - self_tf.size.x).max(0.0);
475        let max_off_y = (max_y - self_tf.size.y).max(0.0);
476
477        let new = Vec2::new(
478            apply_bound(offset.0.x, 0.0, max_off_x, stiffness),
479            apply_bound(offset.0.y, 0.0, max_off_y, stiffness),
480        );
481        if new != offset.0 {
482            // Hit a bound - drop velocity along the clamped axis so
483            // inertial flings don't keep pumping into the wall. With
484            // stiffness > 0 (rubber-band), we still zero the velocity
485            // beyond the bound: the next tick will pull `offset` back
486            // toward the bound via `apply_bound` even with no inertial
487            // assist, mirroring the AppKit spring-snap feel.
488            if (new.x - offset.0.x).abs() > f32::EPSILON {
489                scroll.velocity.x = 0.0;
490            }
491            if (new.y - offset.0.y).abs() > f32::EPSILON {
492                scroll.velocity.y = 0.0;
493            }
494            offset.0 = new;
495        }
496    }
497}
498
499/// Overshoot (px) below which a rubber-banded offset comes to rest on its
500/// bound. The pullback alone only approaches the bound, so without a rest
501/// point an offset left past the edge keeps moving by ever smaller amounts
502/// on every tick, repainting forever and resting wherever the last tick
503/// happened to leave it.
504const RUBBER_BAND_REST_PX: f32 = 0.5;
505
506/// Apply a soft (rubber-band) or hard clamp to a single axis. With
507/// `stiffness == 0` the function degenerates to `value.clamp(lo, hi)`.
508/// With `stiffness > 0`, overflow past a bound is pulled back via the
509/// Hooke approximation `bound + overflow / (1 + stiffness * |overflow|)`;
510/// this is the same shape Cocoa's `NSScrollView` uses for its
511/// rubber-band overshoot. A 100 px overshoot at stiffness 0.55 lands
512/// ~98% of the way back to the bound on the same tick, the residual
513/// shrinks on the ticks after, and once it is under
514/// [`RUBBER_BAND_REST_PX`] the offset lands on the bound.
515fn apply_bound(value: f32, lo: f32, hi: f32, stiffness: f32) -> f32 {
516    if stiffness <= 0.0 {
517        return value.clamp(lo, hi);
518    }
519    let pull = |overflow: f32| {
520        let rest = overflow / (1.0 + stiffness * overflow);
521        if rest < RUBBER_BAND_REST_PX {
522            0.0
523        } else {
524            rest
525        }
526    };
527    if value < lo {
528        return lo - pull(lo - value);
529    }
530    if value > hi {
531        return hi + pull(value - hi);
532    }
533    value
534}
535
536/// W5.3 consumer for inbound `Action::ScrollIntoView` AccessKit actions.
537///
538/// - Drains [`lumen_core::components::A11yScrollIntoViewRequests`] each
539///   tick. For every requested entity, walks the [`ChildOf`] chain up
540///   to the nearest ancestor that carries [`Scroll`] + [`ScrollOffset`]
541///   and updates the offset so the target's bounding box sits inside
542///   the container's viewport rect.
543/// - Vertical-axis containers move the offset along Y, horizontal along
544///   X, `Both` along both axes simultaneously.
545/// - When the target is already fully visible, the offset is left
546///   untouched (idempotent - repeated requests don't drift).
547/// - Velocity on the target axis is zeroed so a previously-fired
548///   inertial fling does not immediately scroll back away from the
549///   focused element.
550/// - The actual clamp to `[0, content - viewport]` is left to
551///   [`clamp_scroll_offsets`] which runs in `TickStage::A11ySync` after
552///   this system.
553pub fn apply_a11y_scroll_into_view(
554    mut requests: ResMut<lumen_core::components::A11yScrollIntoViewRequests>,
555    parents: Query<&ChildOf>,
556    transforms: Query<&Transform>,
557    mut scrolls: Query<(&Transform, &mut Scroll, &mut ScrollOffset)>,
558) {
559    if requests.targets.is_empty() {
560        return;
561    }
562    for target in requests.targets.drain(..) {
563        // Find the nearest scroll-bearing ancestor.
564        let mut current = Some(target);
565        let mut container: Option<Entity> = None;
566        while let Some(e) = current {
567            if scrolls.contains(e) {
568                container = Some(e);
569                break;
570            }
571            current = parents.get(e).ok().map(|c| c.parent());
572        }
573        let Some(container_entity) = container else {
574            continue;
575        };
576        let Ok(target_tf) = transforms.get(target) else {
577            continue;
578        };
579        let target_min = target_tf.absolute;
580        let target_max = target_tf.absolute + target_tf.size;
581        let Ok((container_tf, mut scroll, mut offset)) = scrolls.get_mut(container_entity) else {
582            continue;
583        };
584        let viewport_min = container_tf.absolute;
585        let viewport_max = container_tf.absolute + container_tf.size;
586
587        // Per-axis delta: positive shifts the visible area down/right
588        // (i.e. the offset *increases*), revealing content further into
589        // the scrollable region.
590        let mut delta = Vec2::ZERO;
591        let touches_x = scroll.axis.allows_x();
592        let touches_y = scroll.axis.allows_y();
593        if touches_x {
594            if target_min.x < viewport_min.x {
595                delta.x = target_min.x - viewport_min.x;
596            } else if target_max.x > viewport_max.x {
597                delta.x = target_max.x - viewport_max.x;
598            }
599        }
600        if touches_y {
601            if target_min.y < viewport_min.y {
602                delta.y = target_min.y - viewport_min.y;
603            } else if target_max.y > viewport_max.y {
604                delta.y = target_max.y - viewport_max.y;
605            }
606        }
607        if delta == Vec2::ZERO {
608            continue;
609        }
610        offset.0 += delta;
611        if delta.x != 0.0 {
612            scroll.velocity.x = 0.0;
613        }
614        if delta.y != 0.0 {
615            scroll.velocity.y = 0.0;
616        }
617    }
618}
619
620/// Per-tick: apply [`Scroll::velocity`] (px/s) to [`ScrollOffset`] and
621/// decay velocity per the OS-specific [`crate::physics::INERTIA_DECAY`]
622/// constant. Reads wall-clock `dt` from the shared [`Tick`] resource so
623/// every scroller integrates against the same frame clock, clamped to
624/// [`MAX_INTEGRATION_DT_MS`] so a post-suspend frame doesn't fling the
625/// offset off the page.
626///
627/// Stops integrating when |velocity| falls below
628/// [`VELOCITY_SLEEP_PX_PER_S`] to avoid eternal tiny redraws.
629pub fn integrate_scroll(
630    tick: Res<Tick>,
631    anim: Res<lumen_core::render_world::AnimationsActive>,
632    mut q: Query<(&mut ScrollOffset, &mut Scroll)>,
633) {
634    let max_dt = std::time::Duration::from_millis(u64::from(MAX_INTEGRATION_DT_MS));
635    let dt = tick.dt.min(max_dt).as_secs_f32();
636    for (mut offset, mut scroll) in &mut q {
637        if scroll.velocity.length_squared() < VELOCITY_SLEEP_PX_PER_S * VELOCITY_SLEEP_PX_PER_S {
638            scroll.velocity = Vec2::ZERO;
639            continue;
640        }
641        if dt <= 0.0 {
642            continue;
643        }
644        // Inertial glide still moving - keep the loop awake so momentum
645        // scrolling coasts to a stop without needing further input events.
646        anim.request();
647        offset.0 += scroll.velocity * dt;
648        // Exponential decay: v(t+dt) = v(t) * exp(-k * dt). Framerate-
649        // independent - at 60 Hz with k = 8.0 this matches the legacy
650        // glide envelope; at 120 Hz the per-tick decay halves and the
651        // glide duration stays constant.
652        let decay = (-crate::physics::INERTIA_DECAY * dt).exp();
653        scroll.velocity *= decay;
654    }
655}
656
657#[cfg(test)]
658mod a11y_scroll_into_view_tests {
659    //! W5.3: `Action::ScrollIntoView` actually moves the right
660    //! ancestor's [`ScrollOffset`] toward the target entity. ECS-side
661    //! roundtrip executed via `World::run_system_once` so we observe
662    //! only the consumer system, not the full schedule.
663    use super::*;
664    use bevy_ecs::system::RunSystemOnce;
665    use lumen_core::components::A11yScrollIntoViewRequests;
666
667    #[test]
668    fn scrolls_target_into_visible_range_vertical() {
669        let mut world = World::new();
670        world.init_resource::<A11yScrollIntoViewRequests>();
671
672        let container = world
673            .spawn((
674                Transform {
675                    absolute: Vec2::ZERO,
676                    size: Vec2::new(200.0, 100.0),
677                    baseline_y: None,
678                },
679                Scroll::vertical(),
680                ScrollOffset(Vec2::ZERO),
681            ))
682            .id();
683        // Target is below the container's viewport.
684        let target = world
685            .spawn((
686                Transform {
687                    absolute: Vec2::new(0.0, 150.0),
688                    size: Vec2::new(50.0, 30.0),
689                    baseline_y: None,
690                },
691                ChildOf(container),
692            ))
693            .id();
694        world
695            .resource_mut::<A11yScrollIntoViewRequests>()
696            .targets
697            .push(target);
698
699        world.run_system_once(apply_a11y_scroll_into_view).unwrap();
700
701        // target_max.y = 180, viewport_max.y = 100 -> +80 px delta.
702        let off = world.get::<ScrollOffset>(container).unwrap();
703        assert!(
704            (off.0.y - 80.0).abs() < f32::EPSILON,
705            "expected +80px scroll, got {:?}",
706            off.0,
707        );
708        assert!(
709            world
710                .resource::<A11yScrollIntoViewRequests>()
711                .targets
712                .is_empty(),
713            "consumer must drain the request queue",
714        );
715    }
716
717    #[test]
718    fn idempotent_when_target_already_visible() {
719        let mut world = World::new();
720        world.init_resource::<A11yScrollIntoViewRequests>();
721        let container = world
722            .spawn((
723                Transform {
724                    absolute: Vec2::ZERO,
725                    size: Vec2::new(200.0, 100.0),
726                    baseline_y: None,
727                },
728                Scroll::vertical(),
729                ScrollOffset(Vec2::new(7.0, 13.0)),
730            ))
731            .id();
732        let target = world
733            .spawn((
734                Transform {
735                    absolute: Vec2::new(20.0, 20.0),
736                    size: Vec2::new(30.0, 30.0),
737                    baseline_y: None,
738                },
739                ChildOf(container),
740            ))
741            .id();
742        world
743            .resource_mut::<A11yScrollIntoViewRequests>()
744            .targets
745            .push(target);
746
747        world.run_system_once(apply_a11y_scroll_into_view).unwrap();
748
749        let off = world.get::<ScrollOffset>(container).unwrap();
750        assert_eq!(off.0, Vec2::new(7.0, 13.0), "offset must stay untouched");
751    }
752
753    #[test]
754    fn walks_child_of_chain_to_nearest_scroll_ancestor() {
755        let mut world = World::new();
756        world.init_resource::<A11yScrollIntoViewRequests>();
757        let outer = world
758            .spawn((
759                Transform {
760                    absolute: Vec2::ZERO,
761                    size: Vec2::new(400.0, 400.0),
762                    baseline_y: None,
763                },
764                Scroll::vertical(),
765                ScrollOffset(Vec2::ZERO),
766            ))
767            .id();
768        let middle = world
769            .spawn((
770                Transform {
771                    absolute: Vec2::ZERO,
772                    size: Vec2::new(300.0, 300.0),
773                    baseline_y: None,
774                },
775                ChildOf(outer),
776            ))
777            .id();
778        let target = world
779            .spawn((
780                Transform {
781                    absolute: Vec2::new(0.0, 500.0),
782                    size: Vec2::new(20.0, 20.0),
783                    baseline_y: None,
784                },
785                ChildOf(middle),
786            ))
787            .id();
788        world
789            .resource_mut::<A11yScrollIntoViewRequests>()
790            .targets
791            .push(target);
792        world.run_system_once(apply_a11y_scroll_into_view).unwrap();
793        let off = world.get::<ScrollOffset>(outer).unwrap();
794        // target_max.y = 520, viewport_max.y = 400 -> +120 px.
795        assert!((off.0.y - 120.0).abs() < f32::EPSILON);
796    }
797}
798
799#[cfg(test)]
800mod wheel_routing_tests {
801    //! Spec section 16.5 - nested scroll areas: the innermost hovered
802    //! scrollable handles the wheel first; when it can't scroll further
803    //! in the event's direction it doesn't consume, and the ancestor
804    //! scroll area scrolls instead.
805    use super::*;
806    use bevy_ecs::system::RunSystemOnce;
807
808    /// Outer 400x400 scroller (content 400x2000) containing an inner
809    /// 300x200 scroller (content 300x600) whose leaf tile carries
810    /// `Hovered`. Returns `(world, outer, inner)`.
811    fn nested_scrollers(inner_offset_y: f32) -> (World, Entity, Entity) {
812        let mut world = World::new();
813        world.init_resource::<Messages<MouseWheel>>();
814        let outer = world
815            .spawn((
816                Transform::new(Vec2::ZERO, Vec2::new(400.0, 400.0)),
817                // inertia 0 -> whole delta applies to the offset at once,
818                // so assertions can compare offsets directly.
819                Scroll::vertical().with_inertia(0.0),
820                ScrollOffset(Vec2::ZERO),
821            ))
822            .id();
823        let outer_content = world
824            .spawn((
825                Transform::new(Vec2::ZERO, Vec2::new(400.0, 2000.0)),
826                ChildOf(outer),
827            ))
828            .id();
829        let inner = world
830            .spawn((
831                Transform::new(Vec2::new(0.0, 50.0), Vec2::new(300.0, 200.0)),
832                Scroll::vertical().with_inertia(0.0),
833                ScrollOffset(Vec2::new(0.0, inner_offset_y)),
834                ChildOf(outer_content),
835            ))
836            .id();
837        // Inner content: 600 tall -> inner max offset = 400.
838        world.spawn((
839            Transform::new(Vec2::new(0.0, 50.0), Vec2::new(300.0, 600.0)),
840            ChildOf(inner),
841        ));
842        // Hovered leaf inside the inner scroller.
843        world.spawn((
844            Transform::new(Vec2::new(0.0, 50.0), Vec2::new(300.0, 20.0)),
845            ChildOf(inner),
846            Hovered,
847        ));
848        (world, outer, inner)
849    }
850
851    fn wheel(world: &mut World, dy: f32) {
852        world
853            .resource_mut::<Messages<MouseWheel>>()
854            .write(MouseWheel {
855                delta: Vec2::new(0.0, dy),
856                position: Vec2::new(10.0, 60.0),
857                local: None,
858            });
859    }
860
861    fn offset_y(world: &World, e: Entity) -> f32 {
862        world.get::<ScrollOffset>(e).unwrap().0.y
863    }
864
865    #[test]
866    fn inner_scrolls_while_it_can() {
867        let (mut world, outer, inner) = nested_scrollers(0.0);
868        // Wheel-down (positive delta -> offset increases).
869        wheel(&mut world, 30.0);
870        world.run_system_once(accumulate_wheel).unwrap();
871        assert_eq!(offset_y(&world, inner), 30.0, "inner consumes the wheel");
872        assert_eq!(offset_y(&world, outer), 0.0, "outer untouched");
873    }
874
875    #[test]
876    fn wheel_down_at_inner_bottom_bubbles_to_outer() {
877        // Inner pinned at its max offset (600 content - 200 viewport).
878        let (mut world, outer, inner) = nested_scrollers(400.0);
879        wheel(&mut world, 30.0);
880        world.run_system_once(accumulate_wheel).unwrap();
881        assert_eq!(
882            offset_y(&world, inner),
883            400.0,
884            "inner at limit must not consume"
885        );
886        assert_eq!(offset_y(&world, outer), 30.0, "outer scrolls instead");
887    }
888
889    #[test]
890    fn wheel_up_at_inner_bottom_still_scrolls_inner() {
891        let (mut world, outer, inner) = nested_scrollers(400.0);
892        // Wheel-up (negative delta -> offset decreases): inner CAN
893        // consume this direction, so it must not bubble.
894        wheel(&mut world, -30.0);
895        world.run_system_once(accumulate_wheel).unwrap();
896        assert_eq!(offset_y(&world, inner), 370.0, "inner scrolls back up");
897        assert_eq!(offset_y(&world, outer), 0.0, "outer untouched");
898    }
899
900    #[test]
901    fn wheel_up_at_top_of_both_goes_to_inner_and_clamps_later() {
902        // Both at 0 and wheel-up: nobody can consume; the innermost
903        // gets the (overshooting) delta and clamp handles it later.
904        let (mut world, outer, inner) = nested_scrollers(0.0);
905        wheel(&mut world, -30.0);
906        world.run_system_once(accumulate_wheel).unwrap();
907        assert_eq!(offset_y(&world, outer), 0.0, "outer untouched");
908        assert_eq!(
909            offset_y(&world, inner),
910            -30.0,
911            "innermost receives the unconsumable delta (clamped next stage)"
912        );
913    }
914
915    #[test]
916    fn no_scroll_ancestor_falls_back_to_first_world_scroller() {
917        let mut world = World::new();
918        world.init_resource::<Messages<MouseWheel>>();
919        let scroller = world
920            .spawn((
921                Transform::new(Vec2::ZERO, Vec2::new(400.0, 400.0)),
922                Scroll::vertical().with_inertia(0.0),
923                ScrollOffset(Vec2::ZERO),
924            ))
925            .id();
926        world.spawn((
927            Transform::new(Vec2::ZERO, Vec2::new(400.0, 1000.0)),
928            ChildOf(scroller),
929        ));
930        // Hovered chrome entity entirely outside the scroller's tree.
931        world.spawn((Transform::new(Vec2::ZERO, Vec2::new(50.0, 50.0)), Hovered));
932        wheel(&mut world, 20.0);
933        world.run_system_once(accumulate_wheel).unwrap();
934        assert_eq!(offset_y(&world, scroller), 20.0);
935    }
936}
937
938#[cfg(test)]
939mod scroll_key_tests {
940    //! `scroll_on_keys` - arrow / PageUp / PageDown / Home / End on the
941    //! focused scrollable, or its nearest scrollable ancestor. Driven
942    //! directly via `run_system_once` against a bare `World`.
943    use super::*;
944    use bevy_ecs::system::RunSystemOnce;
945
946    /// A 400x400 vertical scroller containing one 400x1000 child (so
947    /// `max_off.y = 1000 - 400 = 600`).
948    fn setup_vertical_scroller() -> (World, Entity) {
949        let mut world = World::new();
950        world.init_resource::<Messages<KeyPressed>>();
951        let container = world
952            .spawn((
953                Transform {
954                    absolute: Vec2::ZERO,
955                    size: Vec2::new(400.0, 400.0),
956                    baseline_y: None,
957                },
958                Scroll::vertical(),
959                ScrollOffset(Vec2::ZERO),
960            ))
961            .id();
962        world.spawn((
963            Transform {
964                absolute: Vec2::ZERO,
965                size: Vec2::new(400.0, 1000.0),
966                baseline_y: None,
967            },
968            ChildOf(container),
969        ));
970        world.insert_resource(FocusTracker(Some(container)));
971        (world, container)
972    }
973
974    fn press(world: &mut World, key: Key) {
975        world
976            .resource_mut::<Messages<KeyPressed>>()
977            .write(KeyPressed {
978                key,
979                modifiers: Modifiers::default(),
980                repeat: false,
981            });
982    }
983
984    /// `run_system_once` builds a fresh system (and thus a fresh
985    /// `MessageReader` cursor) on every call, so a message written
986    /// before one call is still visible to the *next* call's brand-new
987    /// reader unless the buffer is cleared in between. Tests that press
988    /// more than one key in sequence must clear after each
989    /// `run_system_once` to avoid re-processing stale key presses.
990    fn run_and_clear(world: &mut World) {
991        world.run_system_once(scroll_on_keys).unwrap();
992        world.resource_mut::<Messages<KeyPressed>>().clear();
993    }
994
995    #[test]
996    fn arrow_down_scrolls_by_one_line() {
997        let (mut world, container) = setup_vertical_scroller();
998        press(&mut world, Key::Named(NamedKey::ArrowDown));
999        run_and_clear(&mut world);
1000        let off = world.get::<ScrollOffset>(container).unwrap();
1001        assert_eq!(off.0.y, crate::physics::LINE_HEIGHT_PX);
1002    }
1003
1004    #[test]
1005    fn arrow_left_right_are_ignored_on_a_vertical_only_scroller() {
1006        let (mut world, container) = setup_vertical_scroller();
1007        press(&mut world, Key::Named(NamedKey::ArrowRight));
1008        run_and_clear(&mut world);
1009        let off = world.get::<ScrollOffset>(container).unwrap();
1010        assert_eq!(
1011            off.0,
1012            Vec2::ZERO,
1013            "horizontal arrows no-op on a Y-only scroller"
1014        );
1015    }
1016
1017    #[test]
1018    fn page_down_clamps_at_max_offset() {
1019        let (mut world, container) = setup_vertical_scroller();
1020        // Viewport height 400, content 1000 -> max_off.y = 600. Two
1021        // PageDowns (400 px each) would overshoot to 800 without a clamp.
1022        press(&mut world, Key::Named(NamedKey::ArrowDown)); // no-op filler to exercise the loop
1023        run_and_clear(&mut world);
1024        press(&mut world, Key::Character("PageDown".into()));
1025        press(&mut world, Key::Character("PageDown".into()));
1026        run_and_clear(&mut world);
1027        let off = world.get::<ScrollOffset>(container).unwrap();
1028        assert_eq!(
1029            off.0.y, 600.0,
1030            "PageDown must clamp at content max, not overshoot"
1031        );
1032    }
1033
1034    #[test]
1035    fn end_jumps_to_exact_max_offset() {
1036        let (mut world, container) = setup_vertical_scroller();
1037        press(&mut world, Key::Named(NamedKey::End));
1038        run_and_clear(&mut world);
1039        let off = world.get::<ScrollOffset>(container).unwrap();
1040        assert_eq!(off.0.y, 600.0);
1041    }
1042
1043    #[test]
1044    fn home_returns_to_zero() {
1045        let (mut world, container) = setup_vertical_scroller();
1046        press(&mut world, Key::Named(NamedKey::End));
1047        run_and_clear(&mut world);
1048        press(&mut world, Key::Named(NamedKey::Home));
1049        run_and_clear(&mut world);
1050        let off = world.get::<ScrollOffset>(container).unwrap();
1051        assert_eq!(off.0.y, 0.0);
1052    }
1053
1054    #[test]
1055    fn nearest_scrollable_ancestor_of_focused_child_receives_scroll() {
1056        let mut world = World::new();
1057        world.init_resource::<Messages<KeyPressed>>();
1058        let container = world
1059            .spawn((
1060                Transform {
1061                    absolute: Vec2::ZERO,
1062                    size: Vec2::new(400.0, 400.0),
1063                    baseline_y: None,
1064                },
1065                Scroll::vertical(),
1066                ScrollOffset(Vec2::ZERO),
1067            ))
1068            .id();
1069        let row = world
1070            .spawn((
1071                Transform {
1072                    absolute: Vec2::ZERO,
1073                    size: Vec2::new(400.0, 1000.0),
1074                    baseline_y: None,
1075                },
1076                ChildOf(container),
1077            ))
1078            .id();
1079        // Focus lands on a plain child of the scroll container (e.g. a
1080        // focusable row), not the scroller itself.
1081        world.insert_resource(FocusTracker(Some(row)));
1082        press(&mut world, Key::Named(NamedKey::ArrowDown));
1083        run_and_clear(&mut world);
1084        let off = world.get::<ScrollOffset>(container).unwrap();
1085        assert_eq!(off.0.y, crate::physics::LINE_HEIGHT_PX);
1086    }
1087
1088    #[test]
1089    fn focused_text_input_is_not_double_handled() {
1090        let (mut world, container) = setup_vertical_scroller();
1091        // Re-point focus at a TextInput entity that also happens to be
1092        // a child of the scroll container.
1093        let input = world
1094            .spawn((
1095                ChildOf(container),
1096                TextInput {
1097                    placeholder: String::new(),
1098                    cursor: 0,
1099                    selection_anchor: None,
1100                    multiline: false,
1101                },
1102            ))
1103            .id();
1104        world.insert_resource(FocusTracker(Some(input)));
1105        press(&mut world, Key::Named(NamedKey::ArrowDown));
1106        run_and_clear(&mut world);
1107        let off = world.get::<ScrollOffset>(container).unwrap();
1108        assert_eq!(
1109            off.0,
1110            Vec2::ZERO,
1111            "a focused TextInput's arrows must not also scroll its scroll-container ancestor"
1112        );
1113    }
1114}
1115
1116#[cfg(test)]
1117mod rubber_band_tests {
1118    use super::apply_bound;
1119
1120    const STIFFNESS: f32 = 0.55;
1121
1122    /// Ticks until an offset released `overshoot` px past the upper bound
1123    /// stops moving, or `None` when it still moves after 50 ticks.
1124    fn ticks_to_rest(overshoot: f32) -> Option<usize> {
1125        let hi = 100.0;
1126        let mut value = hi + overshoot;
1127        for tick in 0..50 {
1128            let next = apply_bound(value, 0.0, hi, STIFFNESS);
1129            if next == value {
1130                return Some(tick);
1131            }
1132            value = next;
1133        }
1134        None
1135    }
1136
1137    #[test]
1138    fn a_released_overshoot_comes_to_rest_on_the_bound() {
1139        for overshoot in [0.1, 1.0, 5.0, 100.0, 10_000.0] {
1140            let ticks = ticks_to_rest(overshoot)
1141                .unwrap_or_else(|| panic!("a {overshoot} px overshoot never came to rest"));
1142            assert!(ticks <= 8, "a {overshoot} px overshoot took {ticks} ticks");
1143        }
1144        assert_eq!(apply_bound(100.0, 0.0, 100.0, STIFFNESS), 100.0);
1145    }
1146
1147    #[test]
1148    fn an_overshoot_past_the_start_rests_on_zero() {
1149        let mut value = -5.0;
1150        for _ in 0..8 {
1151            value = apply_bound(value, 0.0, 100.0, STIFFNESS);
1152        }
1153        assert_eq!(value, 0.0);
1154    }
1155
1156    #[test]
1157    fn a_large_overshoot_still_stretches_past_the_bound() {
1158        let pulled = apply_bound(200.0, 0.0, 100.0, STIFFNESS);
1159        assert!(pulled > 100.0 && pulled < 102.0, "pulled to {pulled}");
1160    }
1161
1162    #[test]
1163    fn zero_stiffness_clamps_at_the_edge() {
1164        assert_eq!(apply_bound(105.0, 0.0, 100.0, 0.0), 100.0);
1165        assert_eq!(apply_bound(-3.0, 0.0, 100.0, 0.0), 0.0);
1166        assert_eq!(apply_bound(42.0, 0.0, 100.0, 0.0), 42.0);
1167    }
1168}