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}