Skip to main content

tessera_components/
scrollable.rs

1//! Scrollable viewports driven by a [`ScrollableController`].
2//!
3//! ## Usage
4//!
5//! Turn a layout node into a scroll viewport with `Modifier::vertical_scroll`
6//! or `Modifier::horizontal_scroll` to display content that overflows its
7//! bounds.
8pub mod scrollbar;
9use std::time::Duration;
10
11use tessera_foundation::{
12    gesture::{ScrollRecognizer, TapRecognizer},
13    scroll_physics::{ExponentialInertia, ScrollVelocityTracker as FoundationVelocityTracker},
14};
15use tessera_ui::{
16    AxisConstraint, BuildModifierNode, CallbackWith, Color, ComputedData, Constraint, Dp,
17    LayoutCoordinates, LayoutModifierChild, LayoutModifierInput, LayoutModifierNode, LayoutResult,
18    MeasurementError, Modifier, ModifierBuildContext, PointerInput, PointerInputModifierNode, Px,
19    PxPosition, RenderSlot, ScrollEventSource, State, current_frame_nanos, current_frame_time,
20    focus::FocusRevealRequest,
21    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
22    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
23    receive_frame_nanos, remember, tessera,
24    time::Instant,
25};
26
27use crate::{
28    alignment::Alignment,
29    boxed::boxed,
30    modifier::ModifierExt,
31    nested_scroll::{NestedScrollConnection, ScrollDelta, ScrollVelocity},
32    pos_misc::is_position_inside_bounds,
33};
34
35pub use crate::scrollable::scrollbar::{
36    ScrollBarState, ScrollbarDefaults, scrollbar_h, scrollbar_v,
37};
38
39const SCROLL_INERTIA_DECAY_CONSTANT: f32 = 5.0;
40const SCROLL_INERTIA_MIN_VELOCITY_DP: f32 = 10.0;
41const SCROLL_INERTIA_START_THRESHOLD_DP: f32 = 50.0;
42const SCROLL_INERTIA_MAX_VELOCITY_DP: f32 = 6000.0;
43const SCROLL_VELOCITY_SAMPLE_WINDOW: Duration = Duration::from_millis(90);
44const SCROLL_VELOCITY_IDLE_CUTOFF: Duration = Duration::from_millis(65);
45
46/// Fraction of the remaining distance an animated scroll keeps after one 60Hz
47/// frame.
48///
49/// `1 - DEFAULT_SCROLL_SMOOTHING` of the remaining distance is covered per
50/// frame, so `0.75` reaches 95% of a step in roughly 175ms and 99% in roughly
51/// 270ms. That reads as an eased movement instead of a jump while staying
52/// responsive to rapid wheel input.
53pub(crate) const DEFAULT_SCROLL_SMOOTHING: f32 = 0.75;
54
55fn clamp_scroll_coordinate(value: f32, boundary: f32, enabled: bool) -> f32 {
56    if !enabled {
57        0.0
58    } else if value < boundary {
59        boundary
60    } else if value > 0.0 {
61        0.0
62    } else {
63        value
64    }
65}
66
67/// Defines the behavior of the scrollbar visibility.
68#[derive(Debug, Clone, PartialEq, Default)]
69pub enum ScrollBarBehavior {
70    /// The scrollbar is always visible.
71    #[default]
72    AlwaysVisible,
73    /// The scrollbar is only visible when scrolling.
74    AutoHide,
75    /// No scrollbar at all.
76    Hidden,
77}
78
79/// Defines the layout of the scrollbar relative to the scrollable content.
80#[derive(Debug, Clone, PartialEq, Default)]
81pub enum ScrollBarLayout {
82    /// The scrollbar is placed alongside the content (takes up space in the
83    /// layout).
84    #[default]
85    Alongside,
86    /// The scrollbar is overlaid on top of the content (doesn't take up space).
87    Overlay,
88}
89
90/// Holds the state for a `scrollable` component, managing scroll position and
91/// interaction.
92///
93/// It tracks the current and target scroll positions, the size of the
94/// scrollable content, and focus state.
95///
96/// The scroll position is smoothly interpolated over time to create a fluid
97/// scrolling effect.
98#[derive(Clone, PartialEq)]
99pub struct ScrollableController {
100    density: f64,
101    viewport_scale: f32,
102    viewport_axes: Option<(bool, bool)>,
103    /// The current position of the child component (for rendering)
104    child_position: PxPosition,
105    /// The target position of the child component (scrolling destination)
106    target_position: PxPosition,
107    /// Floating-point target retained between rendered pixel positions.
108    target_position_f32: (f32, f32),
109    /// Floating-point current position retained between rendered pixel
110    /// positions.
111    child_position_f32: (f32, f32),
112    // Preserve interpolation steps smaller than f32 precision in long
113    // virtualized lists.
114    animation_position: (f64, f64),
115    /// The child component size
116    child_size: ComputedData,
117    /// The visible area size
118    visible_size: ComputedData,
119    /// Optional override for the child size used to clamp scroll extents.
120    override_child_size: Option<ComputedData>,
121    /// Last frame time for delta time calculation
122    last_frame_nanos: Option<u64>,
123    /// The state for vertical scrollbar
124    scrollbar_state_v: scrollbar::ScrollBarStateInner,
125    /// The state for horizontal scrollbar
126    scrollbar_state_h: scrollbar::ScrollBarStateInner,
127    /// Velocity tracking for touch-driven inertia.
128    velocity_tracker: Option<FoundationVelocityTracker>,
129    /// Active inertia state after a touch release.
130    active_inertia: Option<ExponentialInertia>,
131    inertia_last_tick: Option<Instant>,
132    /// Whether anything other than construction has positioned this viewport.
133    ///
134    /// Virtualized containers need to tell "this viewport has not been set up
135    /// yet" apart from "the viewport sits at the top", because a fresh
136    /// viewport and a viewport scrolled back to the top both report a zero
137    /// offset.
138    positioned: bool,
139}
140
141impl Default for ScrollableController {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl ScrollableController {
148    /// Creates a new `ScrollableController` with default values.
149    pub fn new() -> Self {
150        Self {
151            density: tessera_ui::dp::scale_factor(),
152            viewport_scale: 1.0,
153            viewport_axes: None,
154            child_position: PxPosition::ZERO,
155            target_position: PxPosition::ZERO,
156            target_position_f32: (0.0, 0.0),
157            child_position_f32: (0.0, 0.0),
158            animation_position: (0.0, 0.0),
159            child_size: ComputedData::ZERO,
160            visible_size: ComputedData::ZERO,
161            override_child_size: None,
162            last_frame_nanos: None,
163            scrollbar_state_v: scrollbar::ScrollBarStateInner::default(),
164            scrollbar_state_h: scrollbar::ScrollBarStateInner::default(),
165            velocity_tracker: None,
166            active_inertia: None,
167            inertia_last_tick: None,
168            positioned: false,
169        }
170    }
171
172    /// Returns the current child position relative to the scrollable container.
173    ///
174    /// This is primarily useful for components that need to implement custom
175    /// virtualization strategies (e.g. lazy lists) and must know the current
176    /// scroll offset. Values are clamped by the scroll logic, so consumers
177    /// can safely derive their offset from the returned position.
178    pub fn child_position(&self) -> PxPosition {
179        self.child_position
180    }
181
182    /// Returns whether this viewport has been positioned by scrolling yet.
183    ///
184    /// A freshly created controller reports `false`. Any scroll input,
185    /// programmatic position or animation marks it as positioned, so
186    /// consumers can distinguish an untouched viewport from one that
187    /// currently sits at the top.
188    pub fn is_positioned(&self) -> bool {
189        self.positioned
190    }
191
192    /// Returns the currently visible viewport size of the scrollable container.
193    pub fn visible_size(&self) -> ComputedData {
194        self.visible_size
195    }
196
197    #[cfg(test)]
198    pub(crate) fn set_visible_size_for_test(&mut self, size: ComputedData) {
199        self.visible_size = size;
200    }
201
202    pub(crate) fn child_size(&self) -> ComputedData {
203        self.child_size
204    }
205
206    /// Overrides the child size used for scroll extent calculation.
207    ///
208    /// The new extent is immediately available to scrolling and scrollbars.
209    pub fn override_child_size(&mut self, size: ComputedData) {
210        self.update_density(tessera_ui::dp::scale_factor());
211        self.override_child_size = Some(size);
212        self.child_size = size;
213    }
214
215    pub(crate) fn target_position(&self) -> PxPosition {
216        self.target_position
217    }
218
219    pub(crate) fn set_target_position(&mut self, target: PxPosition) {
220        self.update_density(tessera_ui::dp::scale_factor());
221        self.cancel_inertia();
222        self.velocity_tracker = None;
223        self.positioned = true;
224        self.target_position = target;
225        self.target_position_f32 = (target.x, target.y);
226    }
227
228    /// Instantly sets the scroll position without animation.
229    ///
230    /// This is useful for restoring a saved scroll position when remounting
231    /// a component.
232    pub fn set_scroll_position(&mut self, position: PxPosition) {
233        self.update_density(tessera_ui::dp::scale_factor());
234        self.cancel_inertia();
235        self.velocity_tracker = None;
236        self.last_frame_nanos = None;
237        self.positioned = true;
238        self.child_position = position;
239        self.target_position = position;
240        self.child_position_f32 = (position.x, position.y);
241        self.animation_position = (position.x as f64, position.y as f64);
242        self.target_position_f32 = (position.x, position.y);
243    }
244
245    pub(crate) fn translate_scroll_position(&mut self, delta: PxPosition) {
246        // Layout anchoring changes the content origin, not the remaining
247        // motion.
248        self.child_position = self.child_position + delta;
249        self.target_position = self.target_position + delta;
250        self.child_position_f32.0 += delta.x;
251        self.child_position_f32.1 += delta.y;
252        self.target_position_f32.0 += delta.x;
253        self.target_position_f32.1 += delta.y;
254        self.animation_position.0 += f64::from(delta.x);
255        self.animation_position.1 += f64::from(delta.y);
256    }
257
258    pub(crate) fn update_density(&mut self, density: f64) -> f64 {
259        let ratio = density / self.density;
260        if density == self.density {
261            return 1.0;
262        }
263        self.density = density;
264        self.animation_position.0 *= ratio;
265        self.animation_position.1 *= ratio;
266        self.child_position_f32 = (
267            self.animation_position.0 as f32,
268            self.animation_position.1 as f32,
269        );
270        self.target_position_f32.0 = (f64::from(self.target_position_f32.0) * ratio) as f32;
271        self.target_position_f32.1 = (f64::from(self.target_position_f32.1) * ratio) as f32;
272        self.child_position =
273            PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
274        self.target_position =
275            PxPosition::from_f32_xy(self.target_position_f32.0, self.target_position_f32.1);
276        let scale_size = |size: ComputedData| ComputedData {
277            width: Px((f64::from(size.width.0) * ratio) as i32),
278            height: Px((f64::from(size.height.0) * ratio) as i32),
279        };
280        self.child_size = scale_size(self.child_size);
281        self.visible_size = scale_size(self.visible_size);
282        self.override_child_size = self.override_child_size.map(scale_size);
283        if let Some(inertia) = &mut self.active_inertia {
284            inertia.position_x *= ratio as f32;
285            inertia.position_y *= ratio as f32;
286            inertia.velocity_x *= ratio as f32;
287            inertia.velocity_y *= ratio as f32;
288        }
289        // Samples collected in the previous physical units cannot be mixed with
290        // new input.
291        self.velocity_tracker = None;
292        ratio
293    }
294
295    fn needs_layout_reconciliation(
296        &self,
297        child_size: ComputedData,
298        visible_size: ComputedData,
299        vertical: bool,
300        horizontal: bool,
301    ) -> bool {
302        self.child_size != child_size
303            || self.visible_size != visible_size
304            || constrain_position(
305                self.child_position,
306                &child_size,
307                &visible_size,
308                vertical,
309                horizontal,
310            ) != self.child_position
311            || constrain_position(
312                self.target_position,
313                &child_size,
314                &visible_size,
315                vertical,
316                horizontal,
317            ) != self.target_position
318    }
319
320    fn reconcile_layout_extents(
321        &mut self,
322        child_size: ComputedData,
323        visible_size: ComputedData,
324        vertical: bool,
325        horizontal: bool,
326    ) {
327        self.child_size = child_size;
328        self.visible_size = visible_size;
329        let minimum = constrain_position(
330            PxPosition::new(Px(i32::MIN), Px(i32::MIN)),
331            &child_size,
332            &visible_size,
333            vertical,
334            horizontal,
335        );
336        self.animation_position.0 = self.animation_position.0.clamp(f64::from(minimum.x), 0.0);
337        self.animation_position.1 = self.animation_position.1.clamp(f64::from(minimum.y), 0.0);
338        self.child_position_f32 = (
339            self.animation_position.0 as f32,
340            self.animation_position.1 as f32,
341        );
342        self.child_position =
343            PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
344        self.target_position_f32.0 = self.target_position_f32.0.clamp(minimum.x, 0.0);
345        self.target_position_f32.1 = self.target_position_f32.1.clamp(minimum.y, 0.0);
346        self.target_position =
347            PxPosition::from_f32_xy(self.target_position_f32.0, self.target_position_f32.1);
348        if let Some(inertia) = &mut self.active_inertia {
349            inertia.position_x = inertia.position_x.clamp(minimum.x, 0.0);
350            inertia.position_y = inertia.position_y.clamp(minimum.y, 0.0);
351        }
352        if !self.has_pending_animation_frame() {
353            self.last_frame_nanos = None;
354        }
355    }
356
357    /// Updates the scroll position based on time-based interpolation
358    /// Returns true if the position changed (needs redraw)
359    pub(crate) fn update_scroll_position(&mut self, frame_nanos: u64, smoothing: f32) -> bool {
360        let delta_time = self
361            .last_frame_nanos
362            .map(|last| frame_nanos.saturating_sub(last) as f32 / 1_000_000_000.0)
363            .unwrap_or(1.0 / 60.0)
364            .clamp(0.0, 0.25);
365        self.last_frame_nanos = Some(frame_nanos);
366        let old = self.child_position;
367        let (target_x, target_y) = self.target_position_f32;
368        let (current_x, current_y) = self.animation_position;
369        let smoothing = smoothing.clamp(0.0, 0.9999);
370        let response = if smoothing == 0.0 {
371            f32::INFINITY
372        } else {
373            -(smoothing).ln() * 60.0
374        };
375        let factor = if response.is_infinite() {
376            1.0
377        } else {
378            1.0 - (-response * delta_time).exp()
379        };
380        self.animation_position = (
381            current_x + (target_x as f64 - current_x) * factor as f64,
382            current_y + (target_y as f64 - current_y) * factor as f64,
383        );
384        if (target_x as f64 - self.animation_position.0).abs() < 0.01 {
385            self.animation_position.0 = target_x as f64;
386        }
387        if (target_y as f64 - self.animation_position.1).abs() < 0.01 {
388            self.animation_position.1 = target_y as f64;
389        }
390        self.child_position_f32 = (
391            self.animation_position.0 as f32,
392            self.animation_position.1 as f32,
393        );
394        self.child_position =
395            PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
396        if !self.has_pending_animation_frame() {
397            self.last_frame_nanos = None;
398            self.animation_position = (target_x as f64, target_y as f64);
399        }
400        if old != self.child_position {
401            self.positioned = true;
402        }
403        old != self.child_position
404    }
405
406    fn cancel_inertia(&mut self) {
407        self.active_inertia = None;
408        self.inertia_last_tick = None;
409    }
410
411    // Direct-manipulation input starts at the displayed floating-point
412    // position, discarding any pending line-wheel animation rather than
413    // jumping to it.
414    fn apply_input_delta(
415        &mut self,
416        delta: ScrollDelta,
417        source: ScrollEventSource,
418        discrete: bool,
419        container_size: &ComputedData,
420        vertical: bool,
421        horizontal: bool,
422    ) -> ScrollDelta {
423        self.cancel_inertia();
424        if source != ScrollEventSource::Touch {
425            self.velocity_tracker = None;
426        }
427        let immediate = !discrete;
428        if immediate {
429            self.target_position_f32 = self.child_position_f32;
430            self.target_position = self.child_position;
431        }
432        let consumed = self.apply_scroll_delta(delta, container_size, vertical, horizontal);
433        if immediate {
434            self.child_position_f32 = self.target_position_f32;
435            self.child_position = self.target_position;
436            self.animation_position = (
437                self.child_position_f32.0 as f64,
438                self.child_position_f32.1 as f64,
439            );
440        }
441        consumed
442    }
443    fn apply_scroll_delta(
444        &mut self,
445        delta: ScrollDelta,
446        container_size: &ComputedData,
447        vertical_scrollable: bool,
448        horizontal_scrollable: bool,
449    ) -> ScrollDelta {
450        self.positioned = true;
451        let current_target = self.target_position_f32;
452        let proposed = (current_target.0 + delta.x, current_target.1 + delta.y);
453        let extent_size = self.override_child_size.unwrap_or(self.child_size);
454        let constrained_target = constrain_position(
455            PxPosition::new(Px::new(i32::MIN), Px::new(i32::MIN)),
456            &extent_size,
457            container_size,
458            vertical_scrollable,
459            horizontal_scrollable,
460        );
461        let constrained = (constrained_target.x, constrained_target.y);
462        let next = (
463            clamp_scroll_coordinate(proposed.0, constrained.0, horizontal_scrollable),
464            clamp_scroll_coordinate(proposed.1, constrained.1, vertical_scrollable),
465        );
466        self.target_position_f32 = next;
467        self.target_position = PxPosition::from_f32_xy(next.0, next.1);
468        ScrollDelta::new(next.0 - current_target.0, next.1 - current_target.1)
469    }
470
471    fn push_touch_delta(&mut self, now: Instant, dx: f32, dy: f32) {
472        let tracker = self.velocity_tracker.get_or_insert_with(|| {
473            FoundationVelocityTracker::new(
474                now,
475                SCROLL_VELOCITY_SAMPLE_WINDOW,
476                SCROLL_VELOCITY_IDLE_CUTOFF,
477            )
478        });
479        tracker.push_delta(now, dx, dy);
480    }
481
482    fn resolve_touch_velocity(&mut self, now: Instant) -> ScrollVelocity {
483        let Some(mut tracker) = self.velocity_tracker.take() else {
484            return ScrollVelocity::ZERO;
485        };
486        if let Some(velocity) = tracker.resolve(now) {
487            let density = self.density as f32;
488            let speed = velocity.x.hypot(velocity.y);
489            if speed.is_finite() && speed > SCROLL_INERTIA_START_THRESHOLD_DP * density {
490                let scale = (SCROLL_INERTIA_MAX_VELOCITY_DP * density / speed).min(1.0);
491                return ScrollVelocity::new(velocity.x * scale, velocity.y * scale);
492            }
493        }
494        ScrollVelocity::ZERO
495    }
496
497    fn start_inertia(&mut self, now: Instant, velocity: ScrollVelocity) {
498        if velocity.is_zero() {
499            return;
500        }
501        self.inertia_last_tick = Some(now);
502        self.active_inertia = Some(ExponentialInertia::new(
503            self.target_position_f32.0,
504            self.target_position_f32.1,
505            tessera_foundation::scroll_physics::ScrollVelocity::new(velocity.x, velocity.y),
506            SCROLL_INERTIA_DECAY_CONSTANT,
507        ));
508    }
509
510    fn has_pending_animation_frame(&self) -> bool {
511        self.child_position_f32 != self.target_position_f32 || self.active_inertia.is_some()
512    }
513}
514
515pub(crate) fn synchronize_scroll_density(controller: State<ScrollableController>) {
516    let density = tessera_ui::dp::scale_factor();
517    if controller.with(|controller| controller.density != density) {
518        controller.with_mut(|controller| {
519            controller.update_density(density);
520        });
521    }
522}
523
524#[derive(Clone, PartialEq)]
525struct ScrollableAlongsideLayout {
526    vertical: bool,
527    horizontal: bool,
528}
529
530impl tessera_ui::LayoutModifierNode for ScrollableAlongsideLayout {
531    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
532        let mut result = LayoutResult::default();
533        let children = input.children();
534        let mut final_size = ComputedData::ZERO;
535        let child_constraint = input.parent_constraint().without_min();
536        let mut content_constraint = Constraint::new(
537            input.parent_constraint().width(),
538            input.parent_constraint().height(),
539        );
540
541        if self.vertical {
542            let scrollbar = children[1];
543            let size = scrollbar.measure(input, &child_constraint)?;
544            content_constraint.width -= size.width;
545            final_size.width += size.width;
546        }
547
548        if self.horizontal {
549            let scrollbar = if self.vertical {
550                children[2]
551            } else {
552                children[1]
553            };
554            let size = scrollbar.measure(input, &child_constraint)?;
555            content_constraint.height -= size.height;
556            final_size.height += size.height;
557        }
558
559        let content = children[0];
560        let content_measurement = content.measure(input, &content_constraint)?;
561        final_size.width += content_measurement.width;
562        final_size.height += content_measurement.height;
563
564        result.place_child(content, PxPosition::ZERO);
565        if self.vertical {
566            result.place_child(
567                children[1],
568                PxPosition::new(content_measurement.width, Px::ZERO),
569            );
570        }
571        if self.horizontal {
572            let scrollbar = if self.vertical {
573                children[2]
574            } else {
575                children[1]
576            };
577            result.place_child(
578                scrollbar,
579                PxPosition::new(Px::ZERO, content_measurement.height),
580            );
581        }
582
583        Ok(result.with_size(final_size))
584    }
585}
586
587struct ScrollLayoutModifierNode {
588    controller: State<ScrollableController>,
589    vertical: bool,
590    horizontal: bool,
591    apply_child_offset: bool,
592    has_override: bool,
593}
594
595impl PartialEq for ScrollLayoutModifierNode {
596    fn eq(&self, other: &Self) -> bool {
597        self.vertical == other.vertical
598            && self.horizontal == other.horizontal
599            && self.apply_child_offset == other.apply_child_offset
600            && self.has_override == other.has_override
601    }
602}
603
604impl LayoutModifierNode for ScrollLayoutModifierNode {
605    fn measure_content(
606        &self,
607        input: &LayoutModifierInput<'_>,
608        child: &mut dyn LayoutModifierChild,
609    ) -> Result<LayoutResult, MeasurementError> {
610        let parent = input.layout_input.parent_constraint();
611        let mut child_constraint = *parent.as_ref();
612        if self.vertical {
613            child_constraint.height = AxisConstraint::NONE;
614        }
615        if self.horizontal {
616            child_constraint.width = AxisConstraint::NONE;
617        }
618
619        let child_measurement = child.measure(&child_constraint)?;
620        let width = parent.width().clamp(child_measurement.width);
621        let height = parent.height().clamp(child_measurement.height);
622        let computed_data = ComputedData { width, height };
623        let current_child_position = input.placement(|| {
624            let next_child_size = self
625                .controller
626                .with(|c| c.override_child_size.unwrap_or(child_measurement));
627            if self.controller.with(|c| {
628                c.needs_layout_reconciliation(
629                    next_child_size,
630                    computed_data,
631                    self.vertical,
632                    self.horizontal,
633                )
634            }) {
635                self.controller.with_mut(|c| {
636                    c.reconcile_layout_extents(
637                        next_child_size,
638                        computed_data,
639                        self.vertical,
640                        self.horizontal,
641                    )
642                });
643            }
644            if self.apply_child_offset {
645                self.controller.with(|c| c.child_position())
646            } else {
647                PxPosition::ZERO
648            }
649        });
650        child.place(current_child_position);
651
652        Ok(LayoutResult::new(computed_data))
653    }
654
655    fn place_children(
656        &self,
657        _scope: &PlacementScope<'_>,
658        inner: &PlacementResult,
659    ) -> Option<PlacementResult> {
660        // The viewport only shifts its content by the current scroll offset.
661        // Re-applying it from cached placements keeps scrolling from forcing a
662        // full re-measure of the content subtree.
663        let (child_size, visible_size) = self.controller.with(|c| (c.child_size, c.visible_size));
664        if self.controller.with(|c| {
665            c.needs_layout_reconciliation(child_size, visible_size, self.vertical, self.horizontal)
666        }) {
667            self.controller.with_mut(|c| {
668                c.reconcile_layout_extents(child_size, visible_size, self.vertical, self.horizontal)
669            });
670        }
671        let offset = if self.apply_child_offset {
672            self.controller.with(|c| c.child_position())
673        } else {
674            PxPosition::ZERO
675        };
676        let mut result = PlacementResult::new();
677        for (instance_key, position) in inner.iter() {
678            result.place(*instance_key, offset + *position);
679        }
680        Some(result)
681    }
682}
683
684/// Makes `base` behave as a scroll viewport driven by `controller`.
685///
686/// Composes the layout (viewport measure + content offset), clipping, wheel and
687/// drag input, smoothing frame ticking, focus reveal, and nested-scroll
688/// participation.
689pub(crate) fn apply_scroll_modifier(
690    base: Modifier,
691    controller: State<ScrollableController>,
692    vertical: bool,
693    horizontal: bool,
694) -> Modifier {
695    if !(vertical || horizontal) {
696        return base;
697    }
698
699    base.push_build(ScrollBuild {
700        controller,
701        vertical,
702        horizontal,
703    })
704}
705
706struct ScrollBuild {
707    controller: State<ScrollableController>,
708    vertical: bool,
709    horizontal: bool,
710}
711
712impl BuildModifierNode for ScrollBuild {
713    fn materialize(&self, context: &mut ModifierBuildContext) -> Option<Modifier> {
714        let Self {
715            controller,
716            vertical,
717            horizontal,
718        } = *self;
719        synchronize_scroll_density(controller);
720
721        let scrollbar_state_v = ScrollBarState::viewport(controller, true);
722        let scrollbar_state_h = ScrollBarState::viewport(controller, false);
723        let tap_recognizer = context.state(TapRecognizer::default);
724        let scroll_recognizer = context.state(ScrollRecognizer::default);
725        if controller.with(|c| c.has_pending_animation_frame()) {
726            context.receive_frame_nanos(move |frame_nanos| {
727                let pending = controller.with_mut(|c| {
728                    c.update_scroll_position(frame_nanos, DEFAULT_SCROLL_SMOOTHING);
729                    c.has_pending_animation_frame()
730                });
731                if pending {
732                    tessera_ui::FrameNanosControl::Continue
733                } else {
734                    tessera_ui::FrameNanosControl::Stop
735                }
736            });
737        }
738        let has_override = controller.with(|c| c.override_child_size.is_some());
739        let nested_scroll_connection = context.nested_scroll_parent();
740        let modifier = apply_scrollable_viewport_input_modifier(ScrollableViewportInputArgs {
741            base: Modifier::new(),
742            controller,
743            vertical,
744            horizontal,
745            scrollbar_behavior: ScrollBarBehavior::Hidden,
746            scrollbar_state_v,
747            scrollbar_state_h,
748            tap_recognizer,
749            scroll_recognizer,
750            nested_scroll_connection,
751        });
752        let modifier = modifier.on_layout_coordinates(move |coordinates: LayoutCoordinates| {
753            let scale = coordinates.local_to_window.scale();
754            if controller.with(|controller| (controller.viewport_scale, controller.viewport_axes))
755                != (scale, Some((vertical, horizontal)))
756            {
757                controller.with_mut(|controller| {
758                    controller.viewport_scale = scale;
759                    controller.viewport_axes = Some((vertical, horizontal));
760                });
761            }
762        });
763        let modifier =
764            apply_scrollable_focus_reveal_modifier(modifier, controller, vertical, horizontal);
765        Some(
766            modifier
767                .clip_to_bounds()
768                .push_layout(ScrollLayoutModifierNode {
769                    controller,
770                    vertical,
771                    horizontal,
772                    apply_child_offset: true,
773                    has_override,
774                }),
775        )
776    }
777}
778
779/// Internal scroll container: a sized viewport with scrollbars around the
780/// scroll modifier. Public scrolling is expressed through
781/// `Modifier::vertical_scroll` / `Modifier::horizontal_scroll`.
782///
783/// Retained as a crate-internal helper because it also lays out scrollbar
784/// siblings, which a modifier cannot add.
785#[tessera]
786pub(crate) fn scrollable(
787    modifier: Option<Modifier>,
788    vertical: Option<bool>,
789    horizontal: Option<bool>,
790    scroll_smoothing: Option<f32>,
791    apply_child_offset: Option<bool>,
792    scrollbar_behavior: Option<ScrollBarBehavior>,
793    scrollbar_track_color: Option<Color>,
794    scrollbar_thumb_color: Option<Color>,
795    scrollbar_thumb_hover_color: Option<Color>,
796    scrollbar_layout: Option<ScrollBarLayout>,
797    controller: Option<State<ScrollableController>>,
798    child: Option<RenderSlot>,
799) {
800    let vertical = vertical.unwrap_or(false);
801    let horizontal = horizontal.unwrap_or(false);
802    let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
803    let apply_child_offset = apply_child_offset.unwrap_or(true);
804    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
805    let scrollbar_layout = scrollbar_layout.unwrap_or_default();
806    let controller = match controller {
807        Some(value) => value,
808        None => remember(ScrollableController::new),
809    };
810    let child = child.unwrap_or_else(RenderSlot::empty);
811    let modifier = modifier.unwrap_or_else(|| Modifier::new().fill_max_size());
812
813    match scrollbar_layout {
814        ScrollBarLayout::Alongside => {
815            layout().modifier(modifier).child(move || {
816                scrollable_with_alongside_scrollbar()
817                    .controller(controller)
818                    .vertical(vertical)
819                    .horizontal(horizontal)
820                    .scroll_smoothing(scroll_smoothing)
821                    .apply_child_offset(apply_child_offset)
822                    .scrollbar_behavior(scrollbar_behavior.clone())
823                    .scrollbar_track_color_optional(scrollbar_track_color)
824                    .scrollbar_thumb_color_optional(scrollbar_thumb_color)
825                    .scrollbar_thumb_hover_color_optional(scrollbar_thumb_hover_color)
826                    .child_shared(child);
827            });
828        }
829        ScrollBarLayout::Overlay => {
830            layout().modifier(modifier).child(move || {
831                scrollable_with_overlay_scrollbar()
832                    .controller(controller)
833                    .vertical(vertical)
834                    .horizontal(horizontal)
835                    .scroll_smoothing(scroll_smoothing)
836                    .apply_child_offset(apply_child_offset)
837                    .scrollbar_behavior(scrollbar_behavior.clone())
838                    .scrollbar_track_color_optional(scrollbar_track_color)
839                    .scrollbar_thumb_color_optional(scrollbar_thumb_color)
840                    .scrollbar_thumb_hover_color_optional(scrollbar_thumb_hover_color)
841                    .child_shared(child);
842            });
843        }
844    }
845}
846
847#[tessera]
848fn scrollbar_v_bound(
849    controller: Option<State<ScrollableController>>,
850    thickness: Option<Dp>,
851    scrollbar_behavior: Option<ScrollBarBehavior>,
852    track_color: Option<Color>,
853    thumb_color: Option<Color>,
854    thumb_hover_color: Option<Color>,
855    scrollbar_state: Option<ScrollBarState>,
856) {
857    let controller = controller.expect("scrollbar_v_bound requires controller");
858    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
859    scrollbar_v()
860        .total(controller.with(|c| c.child_size().height))
861        .visible(controller.with(|c| c.visible_size().height))
862        .offset(controller.with(|c| c.child_position().y_px()))
863        .thickness_optional(thickness)
864        .state(controller)
865        .scrollbar_behavior(scrollbar_behavior)
866        .track_color_optional(track_color)
867        .thumb_color_optional(thumb_color)
868        .thumb_hover_color_optional(thumb_hover_color)
869        .scrollbar_state(
870            scrollbar_state.unwrap_or_else(|| ScrollBarState::viewport(controller, true)),
871        );
872}
873
874#[tessera]
875fn scrollbar_h_bound(
876    controller: Option<State<ScrollableController>>,
877    thickness: Option<Dp>,
878    scrollbar_behavior: Option<ScrollBarBehavior>,
879    track_color: Option<Color>,
880    thumb_color: Option<Color>,
881    thumb_hover_color: Option<Color>,
882    scrollbar_state: Option<ScrollBarState>,
883) {
884    let controller = controller.expect("scrollbar_h_bound requires controller");
885    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
886    scrollbar_h()
887        .total(controller.with(|c| c.child_size().width))
888        .visible(controller.with(|c| c.visible_size().width))
889        .offset(controller.with(|c| c.child_position().x_px()))
890        .thickness_optional(thickness)
891        .state(controller)
892        .scrollbar_behavior(scrollbar_behavior)
893        .track_color_optional(track_color)
894        .thumb_color_optional(thumb_color)
895        .thumb_hover_color_optional(thumb_hover_color)
896        .scrollbar_state(
897            scrollbar_state.unwrap_or_else(|| ScrollBarState::viewport(controller, false)),
898        );
899}
900
901#[tessera]
902fn scrollable_with_alongside_scrollbar(
903    controller: Option<State<ScrollableController>>,
904    vertical: Option<bool>,
905    horizontal: Option<bool>,
906    scroll_smoothing: Option<f32>,
907    apply_child_offset: Option<bool>,
908    scrollbar_behavior: Option<ScrollBarBehavior>,
909    scrollbar_track_color: Option<Color>,
910    scrollbar_thumb_color: Option<Color>,
911    scrollbar_thumb_hover_color: Option<Color>,
912    child: Option<RenderSlot>,
913) {
914    let vertical = vertical.unwrap_or(false);
915    let horizontal = horizontal.unwrap_or(false);
916    let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
917    let apply_child_offset = apply_child_offset.unwrap_or(true);
918    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
919    let controller = controller.expect("scrollable_with_alongside_scrollbar requires controller");
920    let child = child.unwrap_or_else(RenderSlot::empty);
921    let scrollbar_v_state = ScrollBarState::viewport(controller, true);
922    let scrollbar_h_state = ScrollBarState::viewport(controller, false);
923
924    layout()
925        .policy(ScrollableAlongsideLayout {
926            vertical,
927            horizontal,
928        })
929        .child(move || {
930            scrollable_viewport()
931                .vertical(vertical)
932                .horizontal(horizontal)
933                .scroll_smoothing(scroll_smoothing)
934                .apply_child_offset(apply_child_offset)
935                .scrollbar_behavior(scrollbar_behavior.clone())
936                .controller(controller)
937                .scrollbar_state_v(scrollbar_v_state)
938                .scrollbar_state_h(scrollbar_h_state)
939                .child_shared(child);
940
941            if vertical {
942                scrollbar_v_bound()
943                    .controller(controller)
944                    .scrollbar_behavior(scrollbar_behavior.clone())
945                    .thickness(ScrollbarDefaults::THICKNESS)
946                    .track_color_optional(scrollbar_track_color)
947                    .thumb_color_optional(scrollbar_thumb_color)
948                    .thumb_hover_color_optional(scrollbar_thumb_hover_color)
949                    .scrollbar_state(scrollbar_v_state);
950            }
951
952            if horizontal {
953                scrollbar_h_bound()
954                    .controller(controller)
955                    .scrollbar_behavior(scrollbar_behavior.clone())
956                    .thickness(ScrollbarDefaults::THICKNESS)
957                    .track_color_optional(scrollbar_track_color)
958                    .thumb_color_optional(scrollbar_thumb_color)
959                    .thumb_hover_color_optional(scrollbar_thumb_hover_color)
960                    .scrollbar_state(scrollbar_h_state);
961            }
962        });
963}
964
965#[tessera]
966fn scrollable_with_overlay_scrollbar(
967    controller: Option<State<ScrollableController>>,
968    vertical: Option<bool>,
969    horizontal: Option<bool>,
970    scroll_smoothing: Option<f32>,
971    apply_child_offset: Option<bool>,
972    scrollbar_behavior: Option<ScrollBarBehavior>,
973    scrollbar_track_color: Option<Color>,
974    scrollbar_thumb_color: Option<Color>,
975    scrollbar_thumb_hover_color: Option<Color>,
976    child: Option<RenderSlot>,
977) {
978    let vertical = vertical.unwrap_or(false);
979    let horizontal = horizontal.unwrap_or(false);
980    let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
981    let apply_child_offset = apply_child_offset.unwrap_or(true);
982    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
983    let controller = controller.expect("scrollable_with_overlay_scrollbar requires controller");
984    let child = child.unwrap_or_else(RenderSlot::empty);
985
986    boxed()
987        .modifier(Modifier::new().fill_max_size())
988        .alignment(Alignment::BottomEnd)
989        .children(move || {
990            {
991                let child = child;
992                let scrollbar_v_state = ScrollBarState::viewport(controller, true);
993                let scrollbar_h_state = ScrollBarState::viewport(controller, false);
994                let scrollbar_behavior = scrollbar_behavior.clone();
995                scrollable_viewport()
996                    .vertical(vertical)
997                    .horizontal(horizontal)
998                    .scroll_smoothing(scroll_smoothing)
999                    .apply_child_offset(apply_child_offset)
1000                    .scrollbar_behavior(scrollbar_behavior.clone())
1001                    .controller(controller)
1002                    .scrollbar_state_v(scrollbar_v_state)
1003                    .scrollbar_state_h(scrollbar_h_state)
1004                    .child_shared(child);
1005            };
1006            {
1007                let scrollbar_v_state = ScrollBarState::viewport(controller, true);
1008                let scrollbar_behavior = scrollbar_behavior.clone();
1009                if vertical {
1010                    scrollbar_v_bound()
1011                        .controller(controller)
1012                        .scrollbar_behavior(scrollbar_behavior.clone())
1013                        .thickness(ScrollbarDefaults::THICKNESS)
1014                        .track_color_optional(scrollbar_track_color)
1015                        .thumb_color_optional(scrollbar_thumb_color)
1016                        .thumb_hover_color_optional(scrollbar_thumb_hover_color)
1017                        .scrollbar_state(scrollbar_v_state);
1018                }
1019            };
1020            {
1021                let scrollbar_h_state = ScrollBarState::viewport(controller, false);
1022                let scrollbar_behavior = scrollbar_behavior.clone();
1023                if horizontal {
1024                    scrollbar_h_bound()
1025                        .controller(controller)
1026                        .scrollbar_behavior(scrollbar_behavior.clone())
1027                        .thickness(ScrollbarDefaults::THICKNESS)
1028                        .track_color_optional(scrollbar_track_color)
1029                        .thumb_color_optional(scrollbar_thumb_color)
1030                        .thumb_hover_color_optional(scrollbar_thumb_hover_color)
1031                        .scrollbar_state(scrollbar_h_state);
1032                }
1033            };
1034        });
1035}
1036
1037struct ScrollSampleConsumption {
1038    child: ScrollDelta,
1039    gesture: ScrollDelta,
1040}
1041
1042struct ScrollInputTarget<'a> {
1043    controller: State<ScrollableController>,
1044    vertical: bool,
1045    horizontal: bool,
1046    viewport: ComputedData,
1047    connection: Option<&'a NestedScrollConnection>,
1048    transform: tessera_ui::LocalToWindow,
1049}
1050
1051impl ScrollInputTarget<'_> {
1052    fn apply(
1053        &self,
1054        event: &mut tessera_ui::ScrollEventContent,
1055        discrete: bool,
1056    ) -> ScrollSampleConsumption {
1057        let connection = self
1058            .connection
1059            .map(|connection| connection.in_space(self.transform));
1060        let available = ScrollDelta::new(event.delta_x, event.delta_y);
1061        let pre = connection
1062            .as_ref()
1063            .map(|connection| connection.pre_scroll(available, event.source))
1064            .unwrap_or(ScrollDelta::ZERO);
1065        let child = self.controller.with_mut(|controller| {
1066            controller.apply_input_delta(
1067                available - pre,
1068                event.source,
1069                discrete,
1070                &self.viewport,
1071                self.vertical,
1072                self.horizontal,
1073            )
1074        });
1075        let available_after_child = available - pre - child;
1076        let post = connection
1077            .as_ref()
1078            .map(|connection| connection.post_scroll(child, available_after_child, event.source))
1079            .unwrap_or(ScrollDelta::ZERO);
1080        let remaining = available_after_child - post;
1081        event.delta_x = remaining.x;
1082        event.delta_y = remaining.y;
1083        ScrollSampleConsumption {
1084            child,
1085            gesture: pre + child + post,
1086        }
1087    }
1088}
1089
1090struct ScrollbarWheelModifierNode {
1091    controller: State<ScrollableController>,
1092    recognizer: State<ScrollRecognizer>,
1093    connection: Option<NestedScrollConnection>,
1094    scrollbar_state: ScrollBarState,
1095    behavior: ScrollBarBehavior,
1096}
1097
1098impl PointerInputModifierNode for ScrollbarWheelModifierNode {
1099    fn on_pointer_input(&self, input: PointerInput<'_>) {
1100        if input.pass != tessera_ui::PointerEventPass::Main
1101            || !input
1102                .cursor_position_rel
1103                .is_some_and(|position| is_position_inside_bounds(input.computed_data, position))
1104        {
1105            return;
1106        }
1107        let (viewport_scale, axes) = self
1108            .controller
1109            .with(|controller| (controller.viewport_scale, controller.viewport_axes));
1110        let Some(axes) = axes else {
1111            return;
1112        };
1113        let target = ScrollInputTarget {
1114            controller: self.controller,
1115            vertical: axes.0,
1116            horizontal: axes.1,
1117            viewport: self.controller.with(|controller| controller.visible_size()),
1118            connection: self.connection.as_ref(),
1119            transform: tessera_ui::LocalToWindow::new(viewport_scale, PxPosition::ZERO)
1120                .expect("Viewport scale is finite and positive"),
1121        };
1122        for change in input.pointer_changes.iter_mut() {
1123            if !matches!(&change.content, tessera_ui::CursorEventContent::Scroll(event)
1124                if event.source == ScrollEventSource::Wheel)
1125            {
1126                continue;
1127            }
1128            let discrete = change.is_discrete_scroll();
1129            self.recognizer.with_mut(|recognizer| {
1130                recognizer.for_each(input.pass, std::slice::from_mut(change), |_, event| {
1131                    event.normalize_to_pixels();
1132                    let to_viewport = input.local_to_window.scale() / viewport_scale;
1133                    event.delta_x *= to_viewport;
1134                    event.delta_y *= to_viewport;
1135                    let consumption = target.apply(event, discrete);
1136                    event.delta_x /= to_viewport;
1137                    event.delta_y /= to_viewport;
1138                    if !consumption.child.is_zero() && self.behavior == ScrollBarBehavior::AutoHide
1139                    {
1140                        self.scrollbar_state.with_mut(|state| {
1141                            state.last_scroll_activity_frame_nanos = Some(current_frame_nanos());
1142                            state.should_be_visible = true;
1143                        });
1144                    }
1145                });
1146            });
1147        }
1148    }
1149}
1150
1151struct ScrollableViewportPointerModifierNode {
1152    controller: State<ScrollableController>,
1153    vertical: bool,
1154    horizontal: bool,
1155    scrollbar_behavior: ScrollBarBehavior,
1156    scrollbar_state_v: ScrollBarState,
1157    scrollbar_state_h: ScrollBarState,
1158    tap_recognizer: State<TapRecognizer>,
1159    scroll_recognizer: State<ScrollRecognizer>,
1160    nested_scroll_connection: Option<NestedScrollConnection>,
1161}
1162
1163struct ScrollableViewportInputArgs {
1164    base: Modifier,
1165    controller: State<ScrollableController>,
1166    vertical: bool,
1167    horizontal: bool,
1168    scrollbar_behavior: ScrollBarBehavior,
1169    scrollbar_state_v: ScrollBarState,
1170    scrollbar_state_h: ScrollBarState,
1171    tap_recognizer: State<TapRecognizer>,
1172    scroll_recognizer: State<ScrollRecognizer>,
1173    nested_scroll_connection: Option<NestedScrollConnection>,
1174}
1175
1176fn apply_scrollable_viewport_input_modifier(args: ScrollableViewportInputArgs) -> Modifier {
1177    let ScrollableViewportInputArgs {
1178        base,
1179        controller,
1180        vertical,
1181        horizontal,
1182        scrollbar_behavior,
1183        scrollbar_state_v,
1184        scrollbar_state_h,
1185        tap_recognizer,
1186        scroll_recognizer,
1187        nested_scroll_connection,
1188    } = args;
1189    base.push_pointer_input(ScrollableViewportPointerModifierNode {
1190        controller,
1191        vertical,
1192        horizontal,
1193        scrollbar_behavior,
1194        scrollbar_state_v,
1195        scrollbar_state_h,
1196        tap_recognizer,
1197        scroll_recognizer,
1198        nested_scroll_connection,
1199    })
1200}
1201
1202impl PointerInputModifierNode for ScrollableViewportPointerModifierNode {
1203    fn on_pointer_input(&self, input: PointerInput<'_>) {
1204        self.handle_changes(
1205            input.pass,
1206            input.pointer_changes.as_mut_slice(),
1207            input.cursor_position_rel,
1208            input.computed_data,
1209            input.local_to_window,
1210            current_frame_time().unwrap_or_else(Instant::now),
1211            current_frame_nanos(),
1212        );
1213    }
1214}
1215
1216impl ScrollableViewportPointerModifierNode {
1217    fn advance_inertia(
1218        &self,
1219        now: Instant,
1220        viewport: &ComputedData,
1221        transform: tessera_ui::LocalToWindow,
1222    ) {
1223        let connection = self
1224            .nested_scroll_connection
1225            .as_ref()
1226            .map(|connection| connection.in_space(transform));
1227        let step = self.controller.with_mut(|controller| {
1228            let mut inertia = controller.active_inertia.take()?;
1229            let last = controller.inertia_last_tick.replace(now).unwrap_or(now);
1230            let displacement = inertia.advance(now.duration_since(last));
1231            Some((inertia, ScrollDelta::new(displacement.x, displacement.y)))
1232        });
1233        let Some((mut inertia, displacement)) = step else {
1234            return;
1235        };
1236        let pre = connection
1237            .as_ref()
1238            .map(|connection| connection.pre_scroll(displacement, ScrollEventSource::Touch))
1239            .unwrap_or(ScrollDelta::ZERO);
1240        let child = self.controller.with_mut(|controller| {
1241            controller.apply_input_delta(
1242                displacement - pre,
1243                ScrollEventSource::Touch,
1244                false,
1245                viewport,
1246                self.vertical,
1247                self.horizontal,
1248            )
1249        });
1250        let available = displacement - pre - child;
1251        let post = connection
1252            .as_ref()
1253            .map(|connection| connection.post_scroll(child, available, ScrollEventSource::Touch))
1254            .unwrap_or(ScrollDelta::ZERO);
1255        let remaining = available - post;
1256        // Subtracting large scroll coordinates loses fractional precision;
1257        // that rounding residue does not indicate a scroll boundary.
1258        let target = self
1259            .controller
1260            .with(|controller| controller.target_position_f32);
1261        let tolerance_x = (target.0.abs() * f32::EPSILON * 2.0).max(0.001);
1262        let tolerance_y = (target.1.abs() * f32::EPSILON * 2.0).max(0.001);
1263        let mut velocity = ScrollVelocity::ZERO;
1264        if remaining.x.abs() > tolerance_x {
1265            velocity.x = inertia.velocity_x;
1266            inertia.velocity_x = 0.0;
1267        }
1268        if remaining.y.abs() > tolerance_y {
1269            velocity.y = inertia.velocity_y;
1270            inertia.velocity_y = 0.0;
1271        }
1272        let stop_speed = self.controller.with(|c| c.density as f32) / transform.scale()
1273            * SCROLL_INERTIA_MIN_VELOCITY_DP;
1274        let active =
1275            inertia.velocity_x.abs() >= stop_speed || inertia.velocity_y.abs() >= stop_speed;
1276        self.controller.with_mut(|controller| {
1277            if active {
1278                controller.active_inertia = Some(inertia);
1279                controller.inertia_last_tick = Some(now);
1280            }
1281        });
1282        if !active && let Some(connection) = &connection {
1283            connection.post_fling(velocity);
1284        }
1285    }
1286
1287    fn handle_changes(
1288        &self,
1289        pass: tessera_ui::PointerEventPass,
1290        changes: &mut [tessera_ui::PointerChange],
1291        cursor_position_rel: Option<PxPosition>,
1292        computed_data: ComputedData,
1293        transform: tessera_ui::LocalToWindow,
1294        now: Instant,
1295        frame_nanos: u64,
1296    ) {
1297        if pass != tessera_ui::PointerEventPass::Main {
1298            return;
1299        }
1300        let connection = self
1301            .nested_scroll_connection
1302            .as_ref()
1303            .map(|connection| connection.in_space(transform));
1304        let is_cursor_in_component = cursor_position_rel
1305            .map(|pos| is_position_inside_bounds(computed_data, pos))
1306            .unwrap_or(false);
1307        for change in changes {
1308            let changes = std::slice::from_mut(change);
1309            let should_handle_scroll = is_cursor_in_component
1310                || matches!(
1311                    changes[0].content,
1312                    tessera_ui::CursorEventContent::Scroll(ref scroll)
1313                        if scroll.source == ScrollEventSource::Touch
1314                );
1315            let tap_result = self
1316                .tap_recognizer
1317                .with_mut(|recognizer| recognizer.update(pass, changes, is_cursor_in_component));
1318            if tap_result.pressed {
1319                self.controller.with_mut(|c| {
1320                    c.cancel_inertia();
1321                    c.target_position_f32 = c.child_position_f32;
1322                    c.target_position = c.child_position;
1323                    c.velocity_tracker = Some(FoundationVelocityTracker::new(
1324                        tap_result.press_timestamp.unwrap_or(now),
1325                        SCROLL_VELOCITY_SAMPLE_WINDOW,
1326                        SCROLL_VELOCITY_IDLE_CUTOFF,
1327                    ));
1328                });
1329            }
1330
1331            // Release is intentionally handled after all scroll samples in this
1332            // frame.
1333            if should_handle_scroll {
1334                let discrete = changes[0].is_discrete_scroll();
1335                self.scroll_recognizer.with_mut(|recognizer| {
1336                    recognizer.for_each(pass, changes, |context, scroll_event| {
1337                        scroll_event.normalize_to_pixels();
1338                        let consumption = ScrollInputTarget {
1339                            controller: self.controller,
1340                            vertical: self.vertical,
1341                            horizontal: self.horizontal,
1342                            viewport: computed_data,
1343                            connection: self.nested_scroll_connection.as_ref(),
1344                            transform,
1345                        }
1346                        .apply(scroll_event, discrete);
1347                        let child_consumed = consumption.child;
1348                        let gesture_consumed = consumption.gesture;
1349                        if scroll_event.source == ScrollEventSource::Touch
1350                            && !gesture_consumed.is_zero()
1351                        {
1352                            self.controller.with_mut(|c| {
1353                                let window = transform.map_vector(PxPosition::from_f32_xy(
1354                                    gesture_consumed.x,
1355                                    gesture_consumed.y,
1356                                ));
1357                                c.push_touch_delta(context.timestamp, window.x, window.y);
1358                            });
1359                        }
1360
1361                        if matches!(self.scrollbar_behavior, ScrollBarBehavior::AutoHide)
1362                            && !child_consumed.is_zero()
1363                        {
1364                            if self.vertical {
1365                                self.scrollbar_state_v.with_mut(|scrollbar_state| {
1366                                    scrollbar_state.last_scroll_activity_frame_nanos =
1367                                        Some(frame_nanos);
1368                                    scrollbar_state.should_be_visible = true;
1369                                });
1370                            }
1371                            if self.horizontal {
1372                                self.scrollbar_state_h.with_mut(|scrollbar_state| {
1373                                    scrollbar_state.last_scroll_activity_frame_nanos =
1374                                        Some(frame_nanos);
1375                                    scrollbar_state.should_be_visible = true;
1376                                });
1377                            }
1378                        }
1379                    });
1380                });
1381
1382                let target = self.controller.with(|c| c.target_position());
1383                let child_size = self.controller.with(|c| c.child_size());
1384                let constrained_position = constrain_position(
1385                    target,
1386                    &child_size,
1387                    &computed_data,
1388                    self.vertical,
1389                    self.horizontal,
1390                );
1391                if target != constrained_position {
1392                    self.controller
1393                        .with_mut(|c| c.set_target_position(constrained_position));
1394                }
1395            }
1396            // A captured gesture can end outside the viewport. Resolve only
1397            // after the last scroll sample, independently of the
1398            // current hit-test result.
1399            let cancelled = changes.iter().any(|change| {
1400                matches!(change.content, tessera_ui::CursorEventContent::Cancelled(_))
1401            });
1402            if cancelled {
1403                self.controller.with_mut(|c| {
1404                    c.velocity_tracker = None;
1405                    c.cancel_inertia();
1406                });
1407                if let Some(connection) = &connection {
1408                    connection.post_fling(ScrollVelocity::ZERO);
1409                }
1410            } else if let Some(release_timestamp) = tap_result.release_timestamp {
1411                let available_velocity = self
1412                    .controller
1413                    .with_mut(|c| c.resolve_touch_velocity(release_timestamp));
1414                let local = transform.inverse_vector(PxPosition::from_f32_xy(
1415                    available_velocity.x,
1416                    available_velocity.y,
1417                ));
1418                let available_velocity = ScrollVelocity::new(local.x, local.y);
1419                let consumed_velocity = connection
1420                    .as_ref()
1421                    .map(|connection| connection.pre_fling(available_velocity))
1422                    .unwrap_or(ScrollVelocity::ZERO);
1423                self.controller.with_mut(|c| {
1424                    c.start_inertia(release_timestamp, available_velocity - consumed_velocity)
1425                });
1426                if self.controller.with(|c| c.active_inertia.is_none()) {
1427                    if let Some(connection) = &connection {
1428                        connection.post_fling(ScrollVelocity::ZERO);
1429                    }
1430                }
1431            }
1432        }
1433        if self.controller.with(|c| c.active_inertia.is_some()) {
1434            self.advance_inertia(now, &computed_data, transform);
1435        }
1436    }
1437}
1438
1439#[tessera]
1440fn scrollable_viewport(
1441    vertical: Option<bool>,
1442    horizontal: Option<bool>,
1443    scroll_smoothing: Option<f32>,
1444    apply_child_offset: Option<bool>,
1445    scrollbar_behavior: Option<ScrollBarBehavior>,
1446    controller: Option<State<ScrollableController>>,
1447    scrollbar_state_v: Option<ScrollBarState>,
1448    scrollbar_state_h: Option<ScrollBarState>,
1449    child: Option<RenderSlot>,
1450) {
1451    let vertical = vertical.unwrap_or(false);
1452    let horizontal = horizontal.unwrap_or(false);
1453    let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
1454    let apply_child_offset = apply_child_offset.unwrap_or(true);
1455    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
1456    let controller = controller.expect("scrollable_viewport requires controller");
1457    synchronize_scroll_density(controller);
1458    let scrollbar_state_v =
1459        scrollbar_state_v.unwrap_or_else(|| ScrollBarState::viewport(controller, true));
1460    let scrollbar_state_h =
1461        scrollbar_state_h.unwrap_or_else(|| ScrollBarState::viewport(controller, false));
1462    let child = child.unwrap_or_else(RenderSlot::empty);
1463    let tap_recognizer = remember(TapRecognizer::default);
1464    let scroll_recognizer = remember(ScrollRecognizer::default);
1465    if controller.with(|c| c.has_pending_animation_frame()) {
1466        let smoothing = scroll_smoothing;
1467        receive_frame_nanos(move |frame_nanos| {
1468            let has_pending_animation_frame = controller.with_mut(|c| {
1469                c.update_scroll_position(frame_nanos, smoothing);
1470                c.has_pending_animation_frame()
1471            });
1472            if has_pending_animation_frame {
1473                tessera_ui::FrameNanosControl::Continue
1474            } else {
1475                tessera_ui::FrameNanosControl::Stop
1476            }
1477        });
1478    }
1479    let has_override = controller.with(|c| c.override_child_size.is_some());
1480    let nested_scroll_connection = NestedScrollConnection::nearest();
1481    let modifier = apply_scrollable_viewport_input_modifier(ScrollableViewportInputArgs {
1482        base: Modifier::new(),
1483        controller,
1484        vertical,
1485        horizontal,
1486        scrollbar_behavior: scrollbar_behavior.clone(),
1487        scrollbar_state_v,
1488        scrollbar_state_h,
1489        tap_recognizer,
1490        scroll_recognizer,
1491        nested_scroll_connection,
1492    });
1493    let modifier = modifier.on_layout_coordinates(move |coordinates: LayoutCoordinates| {
1494        let scale = coordinates.local_to_window.scale();
1495        if controller.with(|controller| (controller.viewport_scale, controller.viewport_axes))
1496            != (scale, Some((vertical, horizontal)))
1497        {
1498            controller.with_mut(|controller| {
1499                controller.viewport_scale = scale;
1500                controller.viewport_axes = Some((vertical, horizontal));
1501            });
1502        }
1503    });
1504    let modifier = if vertical || horizontal {
1505        apply_scrollable_focus_reveal_modifier(modifier, controller, vertical, horizontal)
1506    } else {
1507        modifier
1508    };
1509    let modifier = modifier
1510        .clip_to_bounds()
1511        .push_layout(ScrollLayoutModifierNode {
1512            controller,
1513            vertical,
1514            horizontal,
1515            apply_child_offset,
1516            has_override,
1517        });
1518    layout().modifier(modifier).child(move || child.render());
1519}
1520
1521fn apply_scrollable_focus_reveal_modifier(
1522    base: Modifier,
1523    controller: State<ScrollableController>,
1524    vertical: bool,
1525    horizontal: bool,
1526) -> Modifier {
1527    base.focus_reveal_handler(CallbackWith::new(move |request: FocusRevealRequest| {
1528        let target = request.local_target_bounds();
1529        let viewport = request.local_viewport_bounds();
1530        let (current_position, child_size, visible_size) =
1531            controller.with(|c| (c.child_position(), c.child_size(), c.visible_size()));
1532        let mut desired_position = current_position;
1533
1534        if horizontal {
1535            desired_position.x = reveal_axis_position(
1536                current_position.x,
1537                target.x,
1538                target.x + target.width,
1539                viewport.x,
1540                viewport.x + viewport.width,
1541            );
1542        }
1543
1544        if vertical {
1545            desired_position.y = reveal_axis_position(
1546                current_position.y,
1547                target.y,
1548                target.y + target.height,
1549                viewport.y,
1550                viewport.y + viewport.height,
1551            );
1552        }
1553
1554        let constrained_position = constrain_position(
1555            desired_position,
1556            &child_size,
1557            &visible_size,
1558            vertical,
1559            horizontal,
1560        );
1561        if constrained_position == current_position {
1562            return false;
1563        }
1564
1565        controller.with_mut(|c| c.set_scroll_position(constrained_position));
1566        true
1567    }))
1568}
1569
1570fn reveal_axis_position(
1571    current: f32,
1572    target_start: f32,
1573    target_end: f32,
1574    viewport_start: f32,
1575    viewport_end: f32,
1576) -> f32 {
1577    if target_start < viewport_start {
1578        current + (viewport_start - target_start)
1579    } else if target_end > viewport_end {
1580        current - (target_end - viewport_end)
1581    } else {
1582        current
1583    }
1584}
1585
1586fn constrain_axis(pos: f32, child_len: Px, container_len: Px) -> f32 {
1587    let child_len = child_len.0 as f32;
1588    let container_len = container_len.0 as f32;
1589    if child_len <= container_len {
1590        return 0.0;
1591    }
1592
1593    if pos > 0.0 {
1594        0.0
1595    } else if pos + child_len < container_len {
1596        container_len - child_len
1597    } else {
1598        pos
1599    }
1600}
1601
1602fn constrain_position(
1603    position: PxPosition,
1604    child_size: &ComputedData,
1605    container_size: &ComputedData,
1606    vertical_scrollable: bool,
1607    horizontal_scrollable: bool,
1608) -> PxPosition {
1609    let x = if horizontal_scrollable {
1610        constrain_axis(position.x, child_size.width, container_size.width)
1611    } else {
1612        0.0
1613    };
1614
1615    let y = if vertical_scrollable {
1616        constrain_axis(position.y, child_size.height, container_size.height)
1617    } else {
1618        0.0
1619    };
1620
1621    PxPosition::from_f32_xy(x, y)
1622}
1623
1624#[cfg(test)]
1625mod scroll_tests {
1626    use super::*;
1627    fn controller() -> ScrollableController {
1628        let mut controller = ScrollableController::new();
1629        controller.child_size = ComputedData {
1630            width: Px(100),
1631            height: Px(2000),
1632        };
1633        controller.visible_size = ComputedData {
1634            width: Px(100),
1635            height: Px(100),
1636        };
1637        controller
1638    }
1639    #[test]
1640    fn extent_reconciliation_preserves_valid_interpolation_and_inertia() {
1641        let mut scroll = controller();
1642        scroll.set_scroll_position(PxPosition::from_f32_xy(0.0, -200.5));
1643        scroll.animation_position.1 = -200.500_000_001;
1644        scroll.set_target_position(PxPosition::from_f32_xy(0.0, -300.75));
1645        let now = Instant::now();
1646        scroll.start_inertia(now, ScrollVelocity::new(0.0, -120.5));
1647        scroll.last_frame_nanos = Some(16_000_000);
1648        let animation = scroll.animation_position;
1649        let inertia = scroll.active_inertia;
1650        let viewport = scroll.visible_size;
1651        scroll.reconcile_layout_extents(
1652            ComputedData {
1653                width: Px(100),
1654                height: Px(1800),
1655            },
1656            viewport,
1657            true,
1658            false,
1659        );
1660        assert_eq!(scroll.animation_position, animation);
1661        assert_eq!(scroll.child_position().y, -200.5);
1662        assert_eq!(scroll.target_position().y, -300.75);
1663        assert_eq!(scroll.active_inertia, inertia);
1664        assert_eq!(scroll.inertia_last_tick, Some(now));
1665        assert_eq!(scroll.last_frame_nanos, Some(16_000_000));
1666        assert!(scroll.has_pending_animation_frame());
1667    }
1668
1669    #[test]
1670    fn override_extent_reconciliation_clamps_disabled_axis_and_preserves_boundary_velocity() {
1671        let mut scroll = controller();
1672        scroll.set_scroll_position(PxPosition::from_f32_xy(-12.25, -1500.5));
1673        scroll.set_target_position(PxPosition::from_f32_xy(-30.75, -1900.25));
1674        let now = Instant::now();
1675        scroll.start_inertia(now, ScrollVelocity::new(-40.0, -120.5));
1676        scroll.override_child_size(ComputedData {
1677            width: Px(100),
1678            height: Px(800),
1679        });
1680        let (child, viewport) = (scroll.child_size, scroll.visible_size);
1681        assert!(
1682            scroll.needs_layout_reconciliation(child, viewport, true, false),
1683            "An already-published virtual extent must still invalidate out-of-range offsets"
1684        );
1685        scroll.reconcile_layout_extents(child, viewport, true, false);
1686        assert_eq!(
1687            scroll.child_position(),
1688            PxPosition::from_f32_xy(0.0, -700.0)
1689        );
1690        assert_eq!(
1691            scroll.target_position(),
1692            PxPosition::from_f32_xy(0.0, -700.0)
1693        );
1694        assert_eq!(scroll.animation_position, (0.0, -700.0));
1695        let inertia = scroll
1696            .active_inertia
1697            .expect("Boundary velocity remains available for nested dispatch");
1698        assert_eq!((inertia.position_x, inertia.position_y), (0.0, -700.0));
1699        assert_eq!((inertia.velocity_x, inertia.velocity_y), (-40.0, -120.5));
1700        assert_eq!(scroll.inertia_last_tick, Some(now));
1701        assert!(!scroll.needs_layout_reconciliation(child, viewport, true, false));
1702    }
1703
1704    #[test]
1705    fn pixel_input_accumulates_subpixels_without_smoothing() {
1706        let mut controller = controller();
1707        let viewport = controller.visible_size;
1708        for _ in 0..20 {
1709            controller.apply_input_delta(
1710                ScrollDelta::new(0.0, -0.1),
1711                ScrollEventSource::Wheel,
1712                false,
1713                &viewport,
1714                true,
1715                false,
1716            );
1717        }
1718        assert!((controller.child_position_f32.1 + 2.0).abs() < 0.0001);
1719        assert!(!controller.has_pending_animation_frame());
1720    }
1721    #[test]
1722    fn smoothing_does_not_snap_when_one_frame_rounds_to_same_pixel() {
1723        let mut controller = controller();
1724        controller.set_target_position(PxPosition::new(Px(0), Px(-100)));
1725        controller.last_frame_nanos = Some(0);
1726        controller.update_scroll_position(1_000_000, 0.99);
1727        assert!(controller.child_position_f32.1 > -1.0);
1728        assert!(controller.has_pending_animation_frame());
1729        for frame in 1..2000 {
1730            controller.update_scroll_position(frame * 16_666_667, 0.99);
1731        }
1732        assert!(!controller.has_pending_animation_frame());
1733        assert_eq!(controller.child_position.y, -100.0);
1734    }
1735
1736    #[test]
1737    fn translating_layout_origin_preserves_smoothing_and_idle_state() {
1738        let mut original = controller();
1739        original.set_scroll_position(PxPosition::from_f32_xy(-100.25, -200.5));
1740        original.set_target_position(PxPosition::from_f32_xy(-300.25, -500.5));
1741        original.update_scroll_position(16_666_667, 0.75);
1742        let mut translated = original.clone();
1743        let delta = PxPosition::new(Px(-80), Px(-120));
1744        translated.translate_scroll_position(delta);
1745        for frame in 2..100 {
1746            original.update_scroll_position(frame * 16_666_667, 0.75);
1747            translated.update_scroll_position(frame * 16_666_667, 0.75);
1748            let expected = original.child_position() + delta;
1749            let actual = translated.child_position();
1750            assert!((actual.x - expected.x).abs() < 0.0001);
1751            assert!((actual.y - expected.y).abs() < 0.0001);
1752            assert_eq!(
1753                translated.has_pending_animation_frame(),
1754                original.has_pending_animation_frame()
1755            );
1756        }
1757        assert_eq!(
1758            translated.child_position(),
1759            PxPosition::from_f32_xy(-380.25, -620.5)
1760        );
1761        translated.translate_scroll_position(PxPosition::new(Px(80), Px(120)));
1762        assert_eq!(translated.child_position(), original.child_position());
1763        assert!(!translated.has_pending_animation_frame());
1764    }
1765
1766    #[test]
1767    fn density_changes_scale_animation_distance_and_inertial_motion() {
1768        let mut original = controller();
1769        original.set_scroll_position(PxPosition::from_f32_xy(-100.25, -200.5));
1770        original.set_target_position(PxPosition::from_f32_xy(-300.25, -500.5));
1771        original.update_scroll_position(16_666_667, 0.75);
1772        let now = Instant::now();
1773        original.start_inertia(now, ScrollVelocity::new(120.0, -240.0));
1774        let mut scaled = original.clone();
1775        scaled.update_density(original.density * 1.5);
1776        assert_eq!(scaled.last_frame_nanos, original.last_frame_nanos);
1777        assert_eq!(scaled.inertia_last_tick, Some(now));
1778        assert_eq!(
1779            scaled.target_position(),
1780            PxPosition::from_f32_xy(-450.375, -750.75)
1781        );
1782        let remaining = original.target_position().y - original.child_position().y;
1783        let scaled_remaining = scaled.target_position().y - scaled.child_position().y;
1784        assert!((scaled_remaining - remaining * 1.5).abs() < 0.0001);
1785        let mut original_motion = original.active_inertia.expect("active inertia");
1786        let motion = scaled.active_inertia.as_mut().expect("scaled inertia");
1787        assert_eq!(motion.velocity_x, 180.0);
1788        assert_eq!(motion.velocity_y, -360.0);
1789        let before = original_motion.advance(Duration::from_millis(32));
1790        let after = motion.advance(Duration::from_millis(32));
1791        assert!((after.x - before.x * 1.5).abs() < 0.0001);
1792        assert!((after.y - before.y * 1.5).abs() < 0.0001);
1793        scaled.update_density(original.density);
1794        assert_eq!(scaled.child_position(), original.child_position());
1795        assert_eq!(scaled.target_position(), original.target_position());
1796        assert!(scaled.has_pending_animation_frame());
1797    }
1798
1799    #[test]
1800    fn large_virtual_offsets_reach_the_target_and_stop_the_animation_clock() {
1801        for smoothing in [0.75, 0.99] {
1802            let mut controller = controller();
1803            controller.set_scroll_position(PxPosition::from_f32_xy(-765_072.0, -1_250_500.0));
1804            let target = PxPosition::from_f32_xy(-765_576.0, -1_251_004.0);
1805            controller.set_target_position(target);
1806            for frame in 1..2000 {
1807                controller.update_scroll_position(frame * 16_666_667, smoothing);
1808            }
1809            assert_eq!(controller.child_position(), target);
1810            assert!(!controller.has_pending_animation_frame());
1811        }
1812    }
1813
1814    #[test]
1815    fn overridden_child_extent_allows_scrolling_past_measured_child() {
1816        let mut controller = controller();
1817        controller.child_size.height = controller.visible_size.height;
1818        controller.override_child_size(ComputedData {
1819            width: controller.child_size.width,
1820            height: Px(2000),
1821        });
1822        let viewport = controller.visible_size;
1823        controller.apply_input_delta(
1824            ScrollDelta::new(0.0, -300.0),
1825            ScrollEventSource::Wheel,
1826            true,
1827            &viewport,
1828            true,
1829            false,
1830        );
1831        assert!(controller.target_position.y < 0.0);
1832    }
1833    #[test]
1834    fn touch_and_programmatic_position_interrupt_inertia() {
1835        let mut controller = controller();
1836        let viewport = controller.visible_size;
1837        let now = Instant::now();
1838        controller.start_inertia(now, ScrollVelocity::new(0.0, -1000.0));
1839        controller.apply_input_delta(
1840            ScrollDelta::new(0.0, -2.0),
1841            ScrollEventSource::Touch,
1842            false,
1843            &viewport,
1844            true,
1845            false,
1846        );
1847        assert!(controller.active_inertia.is_none());
1848        controller.start_inertia(now, ScrollVelocity::new(0.0, -1000.0));
1849        controller.set_scroll_position(PxPosition::ZERO);
1850        assert!(!controller.has_pending_animation_frame());
1851    }
1852    #[test]
1853    fn holding_still_does_not_start_inertia_and_release_has_no_velocity() {
1854        let mut controller = controller();
1855        let now = Instant::now();
1856        controller.push_touch_delta(now, 0.0, -10.0);
1857        controller.push_touch_delta(now + Duration::from_millis(10), 0.0, -10.0);
1858        assert!(controller.active_inertia.is_none());
1859        assert!(
1860            controller
1861                .resolve_touch_velocity(now + Duration::from_millis(100))
1862                .is_zero()
1863        );
1864    }
1865
1866    #[test]
1867    fn fling_launch_threshold_and_cap_use_the_same_logical_speed_at_each_density() {
1868        let start = Instant::now();
1869        let sample = start + Duration::from_millis(10);
1870        for density in [1.0, 3.0] {
1871            let mut controller = controller();
1872            controller.update_density(density);
1873            controller.push_touch_delta(start, 0.0, 0.0);
1874            controller.push_touch_delta(sample, 0.0, -0.4 * density as f32);
1875            assert!(controller.resolve_touch_velocity(sample).is_zero());
1876
1877            controller.push_touch_delta(start, 0.0, 0.0);
1878            controller.push_touch_delta(sample, 0.0, -100.0 * density as f32);
1879            let logical = controller.resolve_touch_velocity(sample).y / density as f32;
1880            assert!(
1881                (logical + 6000.0).abs() < 0.01,
1882                "Logical fling speed: {logical}"
1883            );
1884        }
1885    }
1886
1887    #[test]
1888    fn line_wheel_steps_reach_the_top_from_the_bottom() {
1889        let mut controller = controller();
1890        let viewport = controller.visible_size;
1891        controller.set_scroll_position(PxPosition::new(Px(0), Px(-1900)));
1892        let mut frame = 0u64;
1893        let mut steps = 0;
1894        while controller.child_position.y != 0.0 && steps < 200 {
1895            controller.apply_input_delta(
1896                ScrollDelta::new(0.0, 40.0),
1897                ScrollEventSource::Wheel,
1898                true,
1899                &viewport,
1900                true,
1901                false,
1902            );
1903            steps += 1;
1904            for _ in 0..30 {
1905                frame += 1;
1906                controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1907            }
1908        }
1909        assert_eq!(
1910            controller.child_position.y,
1911            0.0,
1912            "steps={steps} current={:?} target={:?} target_f32={:?} pending={}",
1913            controller.child_position,
1914            controller.target_position,
1915            controller.target_position_f32,
1916            controller.has_pending_animation_frame()
1917        );
1918    }
1919
1920    #[test]
1921    fn pixel_overshoot_reaches_the_top_from_the_bottom() {
1922        let mut controller = controller();
1923        let viewport = controller.visible_size;
1924        controller.set_scroll_position(PxPosition::new(Px(0), Px(-1900)));
1925        controller.apply_input_delta(
1926            ScrollDelta::new(0.0, 1900.5),
1927            ScrollEventSource::Wheel,
1928            true,
1929            &viewport,
1930            true,
1931            false,
1932        );
1933        for frame in 1..=60u64 {
1934            controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1935        }
1936        assert_eq!(
1937            controller.child_position.y, 0.0,
1938            "current={:?}",
1939            controller.child_position
1940        );
1941        assert!(!controller.has_pending_animation_frame());
1942    }
1943
1944    #[test]
1945    fn discrete_wheel_notch_eases_towards_its_target() {
1946        let mut controller = controller();
1947        let viewport = controller.visible_size;
1948        controller.apply_input_delta(
1949            ScrollDelta::new(0.0, -120.0),
1950            ScrollEventSource::Wheel,
1951            true,
1952            &viewport,
1953            true,
1954            false,
1955        );
1956        // A notch must not teleport: the position only moves once frames run.
1957        assert_eq!(controller.child_position.y, 0.0);
1958        assert!(controller.has_pending_animation_frame());
1959
1960        let mut travelled = Vec::new();
1961        for frame in 1..=4u64 {
1962            controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1963            travelled.push(controller.child_position.y);
1964        }
1965        // Each frame covers less than the whole step and moves forwards.
1966        assert!(travelled[0] < 0.0 && travelled[0] > -120.0, "{travelled:?}");
1967        assert!(
1968            travelled.windows(2).all(|pair| pair[1] <= pair[0]),
1969            "scroll must stay monotonic: {travelled:?}"
1970        );
1971        assert!(
1972            travelled[3] < travelled[0],
1973            "later frames must keep easing: {travelled:?}"
1974        );
1975
1976        for frame in 5..=60u64 {
1977            controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1978        }
1979        assert_eq!(controller.child_position.y, -120.0);
1980        assert!(!controller.has_pending_animation_frame());
1981    }
1982
1983    #[test]
1984    fn continuous_trackpad_delta_keeps_tracking_directly() {
1985        let mut controller = controller();
1986        let viewport = controller.visible_size;
1987        controller.apply_input_delta(
1988            ScrollDelta::new(0.0, -80.0),
1989            ScrollEventSource::Wheel,
1990            false,
1991            &viewport,
1992            true,
1993            false,
1994        );
1995        assert_eq!(controller.child_position.y, -80.0);
1996        assert!(!controller.has_pending_animation_frame());
1997    }
1998
1999    #[test]
2000    fn fresh_viewport_is_not_positioned_until_it_scrolls() {
2001        let controller = controller();
2002        assert!(!controller.is_positioned());
2003    }
2004
2005    #[test]
2006    fn viewport_stays_positioned_after_scrolling_back_to_the_top() {
2007        let mut controller = controller();
2008        let viewport = controller.visible_size;
2009        controller.apply_input_delta(
2010            ScrollDelta::new(0.0, -500.0),
2011            ScrollEventSource::Wheel,
2012            true,
2013            &viewport,
2014            true,
2015            false,
2016        );
2017        let mut frame = 0u64;
2018        for _ in 0..40 {
2019            frame += 1;
2020            controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
2021        }
2022        assert!(controller.is_positioned());
2023        assert_eq!(controller.child_position.y, -500.0);
2024        controller.apply_input_delta(
2025            ScrollDelta::new(0.0, 500.0),
2026            ScrollEventSource::Wheel,
2027            true,
2028            &viewport,
2029            true,
2030            false,
2031        );
2032        for _ in 0..40 {
2033            frame += 1;
2034            controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
2035        }
2036        assert_eq!(controller.child_position(), PxPosition::ZERO);
2037        assert!(
2038            controller.is_positioned(),
2039            "a viewport at the top must not look like a fresh one"
2040        );
2041    }
2042
2043    mod scroll_modifier_api {
2044
2045        use tessera_ui::{
2046            Dp, Modifier, Px, PxPosition, State,
2047            layout::layout,
2048            remember, tessera,
2049            testing::{layout_test, pump_layout_test_session, start_layout_test_session},
2050        };
2051
2052        use crate::{
2053            column::column,
2054            modifier::{ModifierExt as _, SemanticsArgs},
2055            spacer::spacer,
2056        };
2057
2058        use super::ScrollableController;
2059
2060        #[tessera]
2061        fn scroll_modifier_case(
2062            output: tessera_ui::testing::TestProbe<Option<State<ScrollableController>>>,
2063        ) {
2064            let controller = remember(ScrollableController::new);
2065            output.set(Some(controller));
2066            layout()
2067                .modifier(Modifier::new().vertical_scroll(controller))
2068                .child(move || {
2069                    column().children(move || {
2070                        spacer().modifier(Modifier::new().height(Dp(400.0)).semantics(
2071                            SemanticsArgs {
2072                                test_tag: Some("inner".to_string()),
2073                                ..Default::default()
2074                            },
2075                        ));
2076                    });
2077                });
2078        }
2079
2080        #[test]
2081        fn cached_viewport_publishes_updated_scroll_extent() {
2082            let output = tessera_ui::testing::TestProbe::default();
2083            let probe = output.clone();
2084            let mut session = start_layout_test_session(
2085                layout_test(move || {
2086                    scroll_modifier_case(probe.clone());
2087                })
2088                .viewport_px(100, 100),
2089            );
2090            let _ = pump_layout_test_session(&mut session, 0);
2091            let controller = output.get().expect("scroll controller");
2092            controller.with_mut(|c| {
2093                c.override_child_size(tessera_ui::ComputedData {
2094                    width: Px(100),
2095                    height: Px(400),
2096                });
2097            });
2098            let _ = pump_layout_test_session(&mut session, 16_666_667);
2099            controller.with_mut(|c| {
2100                c.override_child_size(tessera_ui::ComputedData {
2101                    width: Px(100),
2102                    height: Px(800),
2103                });
2104            });
2105            let _ = pump_layout_test_session(&mut session, 33_333_334);
2106            assert_eq!(controller.with(|c| c.child_size().height), Px(800));
2107            controller.with_mut(|c| c.set_scroll_position(PxPosition::new(Px(0), Px(-650))));
2108            let snapshot = pump_layout_test_session(&mut session, 50_000_001);
2109            snapshot.node("inner").y(-650);
2110        }
2111
2112        #[test]
2113        fn vertical_scroll_modifier_reapplies_offset() {
2114            let output = tessera_ui::testing::TestProbe::default();
2115            let probe = output.clone();
2116            let mut session = start_layout_test_session(
2117                layout_test(move || {
2118                    scroll_modifier_case(probe.clone());
2119                })
2120                .viewport_px(100, 100),
2121            );
2122            let snapshot = pump_layout_test_session(&mut session, 0);
2123            snapshot.node("inner").y(0);
2124
2125            let controller = output.get().expect("scroll controller");
2126            controller.with_mut(|c| {
2127                c.set_scroll_position(PxPosition::new(Px(0), Px(-40)));
2128            });
2129
2130            let snapshot = pump_layout_test_session(&mut session, 16_666_667);
2131            snapshot.node("inner").y(-40);
2132        }
2133    }
2134}
2135
2136#[cfg(all(test, not(target_family = "wasm")))]
2137#[path = "scrollable_focus_tests.rs"]
2138mod focus_tests;
2139
2140#[cfg(all(test, not(target_family = "wasm")))]
2141#[path = "scrollable_extent_tests.rs"]
2142mod extent_tests;