Skip to main content

rosace_widgets/tree/
scroll_view.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_layout::Constraints;
4use rosace_render::{Color, DrawCommand};
5use rosace_scroll::{ScrollController, ScrollPhysics, ScrollStyle};
6use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w, avail_h, intersect_rect};
7
8/// Scroll direction.
9#[derive(Debug, Clone, Copy, Default)]
10pub enum ScrollAxis {
11    #[default]
12    Vertical,
13    Horizontal,
14    Both,
15}
16
17/// Maximum content extent (logical px) on the scroll axis that the GPU-layer
18/// path (D090) can composite as a single placed texture. Content taller than
19/// this automatically uses the base (CPU-painted) path instead — correct,
20/// just without the zero-repaint scroll optimization — via the
21/// automatic-default heuristic (`should_auto_gpu`), so it never silently
22/// mis-renders.
23///
24/// This is intentionally NOT solved with GPU-layer re-render windowing (a
25/// moving texture window re-rendered as scroll approaches its edge). For
26/// content that's actually large because it's a LONG LIST, [`super::ListView`]
27/// already solves this the better way: real virtualization — only the rows
28/// intersecting the viewport are ever built, laid out, or painted (O(visible)
29/// cost, no texture-size limit to hit at all, since the full content is never
30/// materialized). Reach for `ListView::builder` for that case rather than
31/// wrapping a huge item list in a `ScrollView`. The base-path fallback here
32/// exists for the much narrower remaining case — one large *non-virtualized*
33/// widget subtree (e.g. a single big `Image`) — where it's correct but not
34/// GPU-accelerated.
35pub const MAX_TL_DIM: f32 = 4096.0;
36
37/// How strongly the `Bounce` spring recovers WHILE wheel/trackpad momentum
38/// events are still arriving (as opposed to full-strength once they've
39/// truly gone idle) — a fraction applied to `dt` before calling
40/// `settle_bounce`, not a separate physics constant, so it reuses the exact
41/// same spring math just running "in slow motion" relative to real time.
42/// 0.15 was chosen empirically (real trackpad testing) to sit comfortably
43/// below the pull each individual resisted wheel push contributes (`bounce_
44/// axis` already resists those to 35% of their raw delta) — high enough to
45/// visibly close most of the gap before the events truly stop (cutting the
46/// old unbounded freeze down to a brief, subtle glide), low enough that the
47/// two don't visibly fight each other frame-to-frame (full strength here
48/// oscillated: push out, spring back further, push out again).
49const CONCURRENT_BOUNCE_DT_SCALE: f32 = 0.15;
50
51/// When the scrollbar thumb/track is drawn at all (D-SCROLLBAR-1 — user-
52/// reported: during a screen-transition slide, an always-drawn thumb reads
53/// as a small opaque box detached from the rest of the sliding UI; fading
54/// it away when idle/off-screen sidesteps that entirely, not just on
55/// transitions).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum ScrollbarVisibility {
58    /// Always visible whenever there's overflow to scroll.
59    #[default]
60    Always,
61    /// Hidden until a drag, wheel, or momentum-coast gesture is active;
62    /// fades out shortly after the content settles.
63    WhileScrolling,
64    /// Visible while the pointer hovers the track, OR while actively
65    /// scrolling (falls back to `WhileScrolling`'s behavior with no mouse
66    /// — touch/mobile has no hover to trigger on).
67    OnHover,
68    /// Never drawn — same effect as [`ScrollView::no_scrollbar`].
69    Hidden,
70}
71
72/// Full scrollbar appearance + behavior, set with [`ScrollView::scrollbar_style`].
73/// The individual `.scrollbar_color()`/`.no_scrollbar()` shorthands still work
74/// and just edit this struct's fields.
75#[derive(Debug, Clone, Copy)]
76pub struct ScrollbarStyle {
77    pub visibility: ScrollbarVisibility,
78    /// Thumb fill.
79    pub color: Color,
80    /// Track background drawn behind the thumb along the whole scrollable
81    /// edge. `None` (default) draws no track, matching the previous
82    /// thumb-only look.
83    pub track_color: Option<Color>,
84    /// Thumb (and track) thickness in logical px.
85    pub thickness: f32,
86    /// Corner radius — `0.0` for the previous square-cornered look.
87    pub radius: f32,
88    /// Gap between the thumb and the viewport's far edge.
89    pub inset: f32,
90    /// Floor on the thumb's drawn length so a huge content/viewport ratio
91    /// never shrinks it down to an unclickable sliver.
92    pub min_thumb_length: f32,
93}
94
95impl Default for ScrollbarStyle {
96    fn default() -> Self {
97        Self {
98            visibility: ScrollbarVisibility::Always,
99            color: Color::rgb(50, 55, 85),
100            track_color: None,
101            thickness: 3.0,
102            radius: 1.5,
103            inset: 4.0,
104            min_thumb_length: 24.0,
105        }
106    }
107}
108
109/// A scrollable viewport. The child can exceed the available size; content
110/// is painted at the scroll offset and clipped to the viewport bounds.
111///
112/// Scrolls by default (D101): the position lives on the widget's render-tree
113/// node and survives rebuilds — no wiring needed. Pass a
114/// [`ScrollController`] (`::controlled` / `.controller()`) only when the app
115/// needs programmatic control.
116///
117/// The GPU-composited layer path (D090) is now the TRANSPARENT DEFAULT for
118/// plain [`ScrollView::new`] scroll views: once content is measured, a scroll
119/// view whose content actually overflows the viewport on the scroll axis and
120/// stays within [`MAX_TL_DIM`] automatically composites as a placed GPU layer
121/// (scrolling becomes a compositor UV shift, zero component repaint) — no
122/// `.gpu_layer()` call needed. Content that doesn't overflow, or that exceeds
123/// `MAX_TL_DIM`, uses the base (CPU-painted) path automatically. `::fixed`
124/// and `::controlled` always use the base path — programmatic control and
125/// snapshot modes need exact, un-composited semantics.
126pub struct ScrollView {
127    child: BoxedWidget,
128    /// Fixed offset for [`ScrollView::fixed`] snapshot mode.
129    fixed_offset: Option<f32>,
130    /// Explicit controller override (D101). `None` = implicit node controller.
131    controller: Option<ScrollController>,
132    pub axis: ScrollAxis,
133    pub scrollbar: ScrollbarStyle,
134    /// Force the GPU-layer path on even when the automatic heuristic
135    /// (`should_auto_gpu`) would not have chosen it (e.g. content smaller
136    /// than the viewport that the app still wants pre-composited). The
137    /// automatic default (see struct docs) already enables it when it helps;
138    /// this flag is now an override for the exceptional case, not the only
139    /// way to get the GPU path.
140    gpu_layer: bool,
141    /// Explicit physics override (D108/Phase 26 Step 2). `None` resolves via
142    /// [`resolve_physics`] — the app's theme `ext` value, else a per-platform
143    /// default. Always the highest-priority source when set.
144    physics: Option<ScrollPhysics>,
145}
146
147/// Resolves the physics a [`ScrollView`] actually uses: an explicit
148/// `.physics(...)` always wins, then the app's own theme override (a
149/// `ScrollStyle` stashed via `ThemeData::with_ext`), then a per-platform
150/// default — never a hardcoded platform branch in widget code itself (see
151/// `.steering/PHASE_26.md` Step 2).
152pub fn resolve_physics(theme: &rosace_theme::ThemeData, explicit: Option<ScrollPhysics>) -> ScrollPhysics {
153    explicit
154        .or_else(|| theme.ext::<ScrollStyle>().map(|s| s.physics))
155        .unwrap_or_else(|| ScrollStyle::default_for_platform(rosace_core::use_platform()))
156}
157
158impl ScrollView {
159    /// A vertical scroll view. Just scrolls — position is implicit per-node
160    /// state (D101). Automatically GPU-composited once content overflows the
161    /// viewport and fits within [`MAX_TL_DIM`] (see struct docs) — no
162    /// `.gpu_layer()` call needed for the common case.
163    pub fn new(child: impl Widget + 'static) -> Self {
164        Self {
165            child: Box::new(child),
166            fixed_offset: None,
167            controller: None,
168            axis: ScrollAxis::Vertical,
169            scrollbar: ScrollbarStyle::default(),
170            gpu_layer: false,
171            physics: None,
172        }
173    }
174
175    /// Override the scroll physics (drag-to-pan momentum + overscroll
176    /// behavior) regardless of the platform default — see
177    /// [`resolve_physics`]. Base (CPU) path only; no effect in GPU-layer
178    /// mode, which doesn't yet have drag-to-pan (D108/Phase 26 Step 2).
179    pub fn physics(mut self, p: ScrollPhysics) -> Self {
180        self.physics = Some(p);
181        self
182    }
183
184    /// Force the GPU-layer path on regardless of the automatic size
185    /// heuristic (see struct docs — [`ScrollView::new`] already auto-detects
186    /// the common case). Content is capped at [`MAX_TL_DIM`]; taller content
187    /// silently falls back to the base path (windowing is not yet built).
188    pub fn gpu(child: impl Widget + 'static) -> Self {
189        Self { gpu_layer: true, ..Self::new(child) }
190    }
191
192    /// Force GPU-layer compositing on (see [`ScrollView::gpu`]).
193    pub fn gpu_layer(mut self) -> Self { self.gpu_layer = true; self }
194
195    /// A horizontal scroll view — carousels, chip rows, code blocks.
196    pub fn horizontal(child: impl Widget + 'static) -> Self {
197        Self { axis: ScrollAxis::Horizontal, ..Self::new(child) }
198    }
199
200    /// A snapshot viewport — never responds to input. Set the offset with
201    /// `.offset(px)`. For golden tests and static mockups.
202    pub fn fixed(child: impl Widget + 'static) -> Self {
203        Self { fixed_offset: Some(0.0), ..Self::new(child) }
204    }
205
206    /// A scroll view driven by an explicit [`ScrollController`] —
207    /// programmatic scroll_to / scroll_by / scroll_to_top / scroll_to_bottom.
208    /// Create the controller with `ScrollController::for_ctx(ctx)`.
209    pub fn controlled(child: impl Widget + 'static, controller: ScrollController) -> Self {
210        Self { controller: Some(controller), ..Self::new(child) }
211    }
212
213    /// Attach an explicit controller (same as [`ScrollView::controlled`]).
214    pub fn controller(mut self, c: ScrollController) -> Self {
215        self.controller = Some(c);
216        self
217    }
218
219    /// Fixed-mode offset in logical pixels (only meaningful with `fixed`).
220    pub fn offset(mut self, o: f32) -> Self { self.fixed_offset = Some(o); self }
221
222    pub fn axis(mut self, a: ScrollAxis) -> Self { self.axis = a; self }
223    pub fn no_scrollbar(mut self) -> Self { self.scrollbar.visibility = ScrollbarVisibility::Hidden; self }
224    pub fn scrollbar_color(mut self, c: Color) -> Self { self.scrollbar.color = c; self }
225    /// Full scrollbar style (visibility mode, color, track, thickness,
226    /// radius, inset, minimum thumb length) in one call.
227    pub fn scrollbar_style(mut self, s: ScrollbarStyle) -> Self { self.scrollbar = s; self }
228    /// Just the visibility mode — shorthand for `.scrollbar_style(..)` when
229    /// only that needs to change.
230    pub fn scrollbar_visibility(mut self, v: ScrollbarVisibility) -> Self { self.scrollbar.visibility = v; self }
231
232    /// Content constraints (unbounded-axis doctrine, API_DESIGN §6): on the
233    /// scroll axis min = viewport, max = Unbounded. Shared by both the GPU
234    /// and base paint paths so content is measured identically either way.
235    fn child_constraints(&self, vp: Rect) -> Constraints {
236        use rosace_layout::AxisBound;
237        match self.axis {
238            ScrollAxis::Vertical => Constraints {
239                min_width: vp.size.width,
240                max_width: AxisBound::Bounded(vp.size.width),
241                min_height: vp.size.height,
242                max_height: AxisBound::Unbounded,
243            },
244            ScrollAxis::Horizontal => Constraints {
245                min_width: vp.size.width,
246                max_width: AxisBound::Unbounded,
247                min_height: vp.size.height,
248                max_height: AxisBound::Bounded(vp.size.height),
249            },
250            ScrollAxis::Both => Constraints {
251                min_width: vp.size.width,
252                max_width: AxisBound::Unbounded,
253                min_height: vp.size.height,
254                max_height: AxisBound::Unbounded,
255            },
256        }
257    }
258
259    /// The automatic-default heuristic (D090 transparent default): GPU-layer
260    /// compositing helps only when there is actually something to scroll
261    /// (content overflows the viewport on the scroll axis) and only when the
262    /// content fits in a single placed texture ([`MAX_TL_DIM`] — taller
263    /// content needs re-render windowing, not yet built, so it must stay on
264    /// the base path rather than silently mis-render).
265    fn should_auto_gpu(&self, vp: Size, child_size: Size) -> bool {
266        let (overflow, extent) = match self.axis {
267            ScrollAxis::Vertical => (child_size.height > vp.height, child_size.height),
268            ScrollAxis::Horizontal => (child_size.width > vp.width, child_size.width),
269            ScrollAxis::Both => (
270                child_size.height > vp.height || child_size.width > vp.width,
271                child_size.height.max(child_size.width),
272            ),
273        };
274        // PHYSICAL fit: the offscreen texture is allocated at `extent * scale`
275        // and hard-capped at `MAX_TL_DIM` (engine.rs). A logical-only check
276        // (`extent <= MAX_TL_DIM`) passes content that then can't fit its
277        // texture on a 2x/3x display, clipping the bottom. Gate on the physical
278        // size so taller-than-cap content falls to the CPU (base) path — which
279        // re-renders only the visible slice and has no single-texture limit.
280        overflow && extent * rosace_state::render_scale() <= MAX_TL_DIM
281    }
282
283    /// GPU-layer paint path (D090). Records the content once into its own
284    /// sub-tree/picture at content-local `(0,0)`, attaches it as a
285    /// TransformLayer entry (the platform composites it as a placed layer), and
286    /// registers wheel scrolling straight into the non-reactive offset channel
287    /// so a scroll tick is a compositor UV shift with no component repaint.
288    /// `child_size` is measured once by the caller ([`Widget::paint`]) and
289    /// passed in — this never re-measures.
290    fn paint_gpu(&self, ctx: &mut PaintCtx, child_size: Size) {
291        use super::TransformLayerEntry;
292        let vp = ctx.rect;
293        let node_id = ctx.node as u64;
294
295        // Controller-backed offset (D101) — the SAME model `paint_base` uses,
296        // so the GPU path gets real drag + flick momentum instead of wheel
297        // only. This path composites the content as an offscreen texture and
298        // shifts its sample offset each frame, so the live offset is also
299        // mirrored to the non-reactive channel the compositor reads
300        // (`scroll_offset`): the controller is the source of truth.
301        let ctrl = ctx.scroll_controller();
302        let axes = match self.axis {
303            ScrollAxis::Vertical   => super::ScrollAxes::Y,
304            ScrollAxis::Horizontal => super::ScrollAxes::X,
305            ScrollAxis::Both       => super::ScrollAxes::BOTH,
306        };
307        let (ax, ay) = (axes.x, axes.y);
308        let physics = resolve_physics(&ctx.theme, self.physics);
309
310        // Publish extents so `apply_momentum`/`coast` can clamp (guarded — an
311        // unconditional atom write during paint would dirty every frame).
312        let vp_s = [vp.size.width, vp.size.height];
313        if ctrl.viewport_size.get() != vp_s { ctrl.viewport_size.set(vp_s); }
314        let cs = [child_size.width, child_size.height];
315        if ctrl.content_size.get() != cs { ctrl.content_size.set(cs); }
316
317        // Momentum drive — identical to `paint_base`: track drag velocity
318        // while pressed, coast / spring-back once released (unless wheel input
319        // is still live). See `paint_base` for the wheel-idle-grace rationale.
320        let dt = rosace_animate::frame_dt().max(0.0001);
321        let is_pressed = ctx.pressed();
322        let was_pressed = ctrl.was_pressed();
323        ctrl.advance_wheel_idle(dt);
324        if is_pressed {
325            ctrl.track_velocity(dt);
326        } else if ctrl.wheel_recently_active() {
327            // A `Bounce` spring must keep recovering even while the OS's
328            // native momentum-phase wheel events are still arriving — see
329            // the long comment on this same branch in `paint_base` for why
330            // waiting for them to stop first produced a visible "pause,
331            // then snap back" that grew with flick speed. Heavily damped
332            // (`CONCURRENT_BOUNCE_DT_SCALE`) — see that constant's own doc
333            // comment for why a full-strength spring here visibly vibrated.
334            if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
335                if ctrl.is_overscrolled() {
336                    ctrl.settle_bounce(spring_stiffness, dt * CONCURRENT_BOUNCE_DT_SCALE);
337                }
338            }
339            ctx.request_animation();
340        } else {
341            if was_pressed { ctrl.end_drag(); }
342            if !ctx.theme.animation.enabled {
343                ctrl.stop_coasting();
344            } else if ctrl.coast(physics, dt) {
345                ctx.request_animation();
346            }
347        }
348        ctrl.set_was_pressed(is_pressed);
349
350        // Live (post-coast) offset drives BOTH this frame's transform and the
351        // compositor's offscreen sample position (via the mirrored channel).
352        let off = ctrl.offset.get();
353        rosace_state::set_scroll_offset(node_id, off);
354
355        // Record the content at (0,0) into its own node/picture (D090).
356        let sub_node = ctx.tree.borrow_mut().slot(ctx.node, true);
357        let mut sub_rec = rosace_render::PictureRecorder::new();
358        let child_rect = Rect { origin: Point { x: 0.0, y: 0.0 }, size: child_size };
359        let mut sub_ctx = PaintCtx {
360            recorder: &mut sub_rec,
361            rect: child_rect,
362            font: ctx.font,
363            theme: ctx.theme.clone(),
364            tree: ctx.tree.clone(),
365            node: sub_node,
366            owner: ctx.owner,
367            clip_rect: None,
368        };
369        self.child.paint(&mut sub_ctx);
370        let picture = sub_rec.finish();
371
372        ctx.attach_transform(TransformLayerEntry {
373            picture,
374            child_size,
375            viewport_rect: vp,
376            zoom: 1.0,
377            scroll_x: off[0],
378            scroll_y: off[1],
379        });
380
381        // Wheel/trackpad → `apply_momentum` (respects Bounce overscroll),
382        // marks wheel active so coast holds off while it's live.
383        let wheel_ctrl = ctrl.clone();
384        ctx.register_scroll_target(vp, axes, Arc::new(move |dx, dy| {
385            wheel_ctrl.apply_momentum(if ax { -dx } else { 0.0 }, if ay { -dy } else { 0.0 }, physics);
386            wheel_ctrl.mark_wheel_active();
387        }));
388
389        // Touch/mouse drag-to-pan (GPU-path parity): a finger produces no
390        // wheel event, so without this the GPU scroll path could not scroll on
391        // touch devices at all (the gallery was frozen on iOS, fine on the
392        // Mac trackpad). Nested-scroll-chain-aware (D-NESTED-SCROLL,
393        // 2026-08-02) — see the base path's own registration for why this
394        // is `register_nested_scroll`, not `on_press_at`.
395        let pan_ctrl = ctrl.clone();
396        ctx.register_nested_scroll(move |dx, dy| {
397            pan_ctrl.try_apply_delta(if ax { -dx } else { 0.0 }, if ay { -dy } else { 0.0 }, physics)
398        });
399
400        // Scrollbar drawn into the base canvas from the live channel offset.
401        self.draw_scrollbars(ctx, vp, child_size, off, Some(&ctrl), is_pressed);
402    }
403
404    /// Base (CPU-painted) path: content painted directly into the main
405    /// canvas at the scroll offset, clipped to the viewport. `child_size` is
406    /// measured once by the caller ([`Widget::paint`]) and passed in.
407    fn paint_base(&self, ctx: &mut PaintCtx, child_size: Size) {
408        let vp = ctx.rect;
409
410        // Resolve the controller: explicit override, or the node's implicit
411        // one (D101). Fixed mode has no controller and never handles input.
412        let ctrl = if self.fixed_offset.is_some() {
413            None
414        } else {
415            Some(self.controller.clone().unwrap_or_else(|| ctx.scroll_controller()))
416        };
417
418        let (scroll_x, scroll_y) = match (&ctrl, self.fixed_offset) {
419            (Some(c), _) => {
420                let [x, y] = c.offset.get();
421                (x, y)
422            }
423            (None, Some(o)) => match self.axis {
424                ScrollAxis::Horizontal => (o, 0.0),
425                _ => (0.0, o),
426            },
427            (None, None) => (0.0, 0.0),
428        };
429
430        let (ox, oy) = match self.axis {
431            ScrollAxis::Vertical   => (0.0, -scroll_y),
432            ScrollAxis::Horizontal => (-scroll_x, 0.0),
433            ScrollAxis::Both       => (-scroll_x, -scroll_y),
434        };
435
436        let child_rect = Rect {
437            origin: Point { x: vp.origin.x + ox, y: vp.origin.y + oy },
438            size: child_size,
439        };
440
441        // Clip child paint output to the viewport.
442        ctx.record(DrawCommand::PushClip { rect: vp });
443        let effective_clip = ctx.clip_rect
444            .and_then(|parent| intersect_rect(parent, vp))
445            .unwrap_or(vp);
446        let mut child_ctx = ctx.child(child_rect);
447        child_ctx.clip_rect = Some(effective_clip);
448        self.child.paint(&mut child_ctx);
449        ctx.record(DrawCommand::PopClip);
450
451        // Publish extents (guarded — unconditional atom writes during paint
452        // would dirty the component every frame) and route wheel input.
453        if let Some(ctrl) = &ctrl {
454            let vp_s = [vp.size.width, vp.size.height];
455            if ctrl.viewport_size.get() != vp_s { ctrl.viewport_size.set(vp_s); }
456            let cs = [child_size.width, child_size.height];
457            if ctrl.content_size.get() != cs { ctrl.content_size.set(cs); }
458
459            let axes = match self.axis {
460                ScrollAxis::Vertical   => super::ScrollAxes::Y,
461                ScrollAxis::Horizontal => super::ScrollAxes::X,
462                ScrollAxis::Both       => super::ScrollAxes::BOTH,
463            };
464            let (ax, ay) = (axes.x, axes.y);
465
466            let physics = resolve_physics(&ctx.theme, self.physics);
467
468            // Drag-to-pan (D108/Phase 26 Step 2; nested-scroll-chain-aware
469            // since D-NESTED-SCROLL, 2026-08-02): a `ScrollHandler` link
470            // via `register_nested_scroll`, not the flat always-consumes
471            // `on_press_at` sliders use — reports whether the delta
472            // actually moved the offset, so once this view is exhausted
473            // in the drag's direction (hard-clamped, or stretched to its
474            // own `Bounce` limit), the SAME delta falls through to
475            // whatever scrollable ancestor encloses it, instead of the
476            // gesture just silently doing nothing. Registering this also
477            // makes the viewport a `nested_scrolls` region, so
478            // `ctx.pressed()` below picks it up for free via the same
479            // `hover_test` walk Step 1's press state already resolves
480            // through (`hover_test_node` checks `nested_scrolls` too).
481            let drag_ctrl = ctrl.clone();
482            ctx.register_nested_scroll(move |dx, dy| {
483                // Content follows the finger: dragging up (dy < 0) reveals
484                // what's below, i.e. INCREASES the offset — negate, exactly
485                // like the wheel-scroll callback above already does.
486                drag_ctrl.try_apply_delta(if ax { -dx } else { 0.0 }, if ay { -dy } else { 0.0 }, physics)
487            });
488
489            // Momentum/bounce drive (D108/Phase 26 Step 2): tracks the REAL
490            // drag speed while pressed, hands off to decay/spring-back once
491            // released. Reuses Step 1's `pressed()` (same node, since the
492            // `on_press_at` region declared above lands on this node).
493            //
494            // Deliberately does NOT reset `last_drag_point` on a was_pressed
495            // false→true transition here — `ctx.pressed()` lags the real
496            // MouseDown event by one frame (same as `ctx.hovered()`), so
497            // that transition is observed on the SAME frame as the drag's
498            // first `MouseMove`, one frame after `drag_delta`'s own
499            // None-baseline logic already established the starting point
500            // from MouseDown's immediate callback invocation. Resetting
501            // here would wipe that baseline out from under the very next
502            // `drag_delta` call — `end_drag` on release (below) is the only
503            // reset needed; a fresh press always starts from `None` anyway
504            // since release already cleared it.
505            let dt = rosace_animate::frame_dt().max(0.0001);
506            let is_pressed = ctx.pressed();
507            let was_pressed = ctrl.was_pressed();
508            // Whether a wheel/trackpad event landed recently (real elapsed
509            // time, not "this exact frame") — if so, hold off `coast`'s
510            // decay/spring-back. A single-frame version of this check (an
511            // earlier revision) sprang back the instant one frame happened
512            // to have no fresh wheel event, then got pushed forward again
513            // by the next one, producing a visible jitter right at the
514            // boundary (found via real trackpad testing — "vibration,
515            // scroll a little up and down"). Real wheel events don't arrive
516            // on a perfectly even one-per-frame cadence, so a short real
517            // time grace period (`WHEEL_IDLE_GRACE`) is needed instead of a
518            // single-frame flag. Also, without ANY such gate at all, `coast`
519            // ran every frame wheel input was active (not just after it
520            // stopped, since wheel scrolling never sets `pressed`) —
521            // friction decayed the velocity away while the user was still
522            // actively scrolling, so nothing real was left to coast with by
523            // release.
524            ctrl.advance_wheel_idle(dt);
525            if is_pressed {
526                ctrl.track_velocity(dt);
527            } else if ctrl.wheel_recently_active() {
528                // Real trackpad testing (2026-08-01): a fast flick's native
529                // momentum-phase wheel-event tail can run for a while — this
530                // branch stays active that whole time, and previously did
531                // nothing but wait, so an ALREADY-overscrolled `Bounce` view
532                // sat frozen at the rubber-band limit until the OS finally
533                // stopped sending events, then sprang back — a pause whose
534                // length scaled directly with flick speed (longer flick =
535                // longer native momentum tail = longer freeze). The spring
536                // must keep recovering concurrently with those still-
537                // arriving events, not wait for them to end; the wheel
538                // callback's own `apply_momentum` (via `bounce_axis`) still
539                // resists any further push deeper into overscroll, so this
540                // doesn't fight it, it just lets the recoil run at the same
541                // time — matching real trackpad/UIScrollView feel, where you
542                // can feel resistance AND a slight recoil simultaneously.
543                // Heavily damped (`CONCURRENT_BOUNCE_DT_SCALE`) — a full-
544                // strength spring here fought each still-arriving resisted
545                // push hard enough to visibly vibrate (real trackpad
546                // testing, 2026-08-01 follow-up): push out 35%-resisted,
547                // spring pulls back a large fraction of that same distance,
548                // next event pushes again — a sawtooth. Damping the spring's
549                // own effective time step keeps its pull well below what a
550                // single resisted push contributes, so it net-decays smoothly
551                // toward the bound instead of visibly fighting each event.
552                if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
553                    if ctrl.is_overscrolled() {
554                        ctrl.settle_bounce(spring_stiffness, dt * CONCURRENT_BOUNCE_DT_SCALE);
555                    }
556                }
557                ctx.request_animation(); // keep the loop alive so coast resumes once wheel events truly stop
558            } else {
559                if was_pressed { ctrl.end_drag(); }
560                if !ctx.theme.animation.enabled {
561                    ctrl.stop_coasting();
562                } else if ctrl.coast(physics, dt) {
563                    ctx.request_animation();
564                }
565            }
566            ctrl.set_was_pressed(is_pressed);
567
568            // Wheel/trackpad input applies its own delta directly (still
569            // respecting Bounce's overscroll resistance via
570            // `apply_momentum`) but does NOT inject a synthetic velocity
571            // for `coast` to decay (D108/Phase 26 Step 2, revised after real
572            // trackpad testing). Reasoning, confirmed by reading winit's own
573            // macOS backend source, not assumed: a trackpad's "coast" feel
574            // during and after a swipe is largely the OS's OWN native
575            // momentum-phase event stream (`NSEvent.momentumPhase`) —
576            // winit's `scrollWheel:` handler reads it and keeps sending
577            // Scroll events for a while after fingers lift. Layering a
578            // SECOND, app-level momentum system on top fought with that OS
579            // tail: each native momentum-phase event nudged the offset
580            // further, ROSACE's own spring-back tried to recover, the next
581            // OS event pushed past the edge again — a real, reproducible
582            // oscillation, confirmed frame-by-frame from a screen recording
583            // (settled, then overscrolled again, then re-settled, well
584            // after release). winit collapses BOTH real finger movement and
585            // OS momentum-phase events into the same `TouchPhase::Moved` —
586            // there's no reliable way to tell them apart from the event
587            // alone, so the only robust fix is to not double up: ROSACE's
588            // own velocity-tracked momentum is reserved for drag gestures
589            // (mouse/touch press-drag-release), which have no OS-native
590            // momentum layer to conflict with. Once wheel input goes idle
591            // (`wheel_recently_active` false), `coast`'s Bounce-overscroll
592            // check (checked first, independent of velocity) still springs
593            // back if left showing blank space — so overscroll recovery
594            // still works, it just isn't fighting a second momentum source.
595            // Honest limitation: a plain (non-trackpad) mouse wheel has no
596            // OS-native momentum either, so it also won't coast under this
597            // scheme — distinguishing that case needs LineDelta/PixelDelta
598            // and momentum-phase info threaded through
599            // `rosace_platform::InputEvent::Scroll`, which doesn't carry
600            // it today; flagged as real follow-up, not silently claimed.
601            let wheel_ctrl = ctrl.clone();
602            ctx.register_scroll_target(vp, axes, Arc::new(move |dx, dy| {
603                let ddx = if ax { -dx } else { 0.0 };
604                let ddy = if ay { -dy } else { 0.0 };
605                wheel_ctrl.apply_momentum(ddx, ddy, physics);
606                wheel_ctrl.mark_wheel_active();
607            }));
608        }
609
610        // Scrollbars drawn AFTER PopClip so they are not clipped. Re-reads
611        // the offset fresh here (D108/Phase 26 Step 2) rather than reusing
612        // `scroll_x`/`scroll_y` captured at the top of this function —
613        // those predate this frame's drag/wheel/momentum updates further
614        // above, so the thumb would lag a full frame behind the content
615        // it's supposed to track (most visible during a fast momentum
616        // coast, where a frame's movement is largest).
617        let fresh = match &ctrl {
618            Some(c) => c.offset.get(),
619            None => [scroll_x, scroll_y],
620        };
621        self.draw_scrollbars(ctx, vp, child_size, fresh, ctrl.as_ref(), ctx.pressed());
622    }
623
624    /// Shared by both the GPU and base paint paths — draws the thumb (and
625    /// optional track), governed by `self.scrollbar`'s visibility mode.
626    /// `off` is this frame's freshly-read scroll offset (not a value
627    /// captured earlier in the same paint call — see the callers' own
628    /// comments on why "fresh" matters for `Bounce` overscroll).
629    fn draw_scrollbars(
630        &self,
631        ctx: &mut PaintCtx,
632        vp: Rect,
633        child_size: Size,
634        off: [f32; 2],
635        ctrl: Option<&ScrollController>,
636        is_pressed: bool,
637    ) {
638        let st = &self.scrollbar;
639        if st.visibility == ScrollbarVisibility::Hidden {
640            return;
641        }
642
643        let show_v = matches!(self.axis, ScrollAxis::Vertical | ScrollAxis::Both)
644            && child_size.height > vp.size.height.max(1.0);
645        let show_h = matches!(self.axis, ScrollAxis::Horizontal | ScrollAxis::Both)
646            && child_size.width > vp.size.width.max(1.0);
647        if !show_v && !show_h {
648            return;
649        }
650
651        // A generous strip along the scrollable edge, not just the exact
652        // thumb rect — real scrollbars reveal on hovering anywhere near the
653        // edge, not only when the cursor lands precisely on the (possibly
654        // short) thumb.
655        const HOVER_STRIP: f32 = 14.0;
656        let (px, py) = super::current_pointer();
657        let in_rect = |r: Rect| px >= r.origin.x && px <= r.origin.x + r.size.width
658            && py >= r.origin.y && py <= r.origin.y + r.size.height;
659
660        let mut v_thumb = None;
661        if show_v {
662            let ratio = (vp.size.height / child_size.height.max(1.0)).min(1.0);
663            let bar_h = (vp.size.height * ratio).max(st.min_thumb_length);
664            // Clamp the THUMB's visible position to the track — under
665            // `Bounce`, `off[1]` can go negative or past the max during an
666            // overscroll, which without this would push the thumb off the
667            // visible track entirely, looking like the scrollbar "isn't
668            // responding" (found via real trackpad testing). The content
669            // itself still tracks the real (unclamped) offset; only the
670            // thumb's on-screen position is clamped.
671            let max_bar_y = vp.origin.y + vp.size.height - bar_h;
672            let bar_y = (vp.origin.y + (off[1] / child_size.height) * vp.size.height)
673                .clamp(vp.origin.y, max_bar_y.max(vp.origin.y));
674            let bar_x = vp.origin.x + vp.size.width - st.inset - st.thickness;
675            v_thumb = Some(Rect {
676                origin: Point { x: bar_x, y: bar_y },
677                size: Size { width: st.thickness, height: bar_h },
678            });
679        }
680        let mut h_thumb = None;
681        if show_h {
682            let ratio = (vp.size.width / child_size.width.max(1.0)).min(1.0);
683            let bar_w = (vp.size.width * ratio).max(st.min_thumb_length);
684            let max_bar_x = vp.origin.x + vp.size.width - bar_w;
685            let bar_x = (vp.origin.x + (off[0] / child_size.width) * vp.size.width)
686                .clamp(vp.origin.x, max_bar_x.max(vp.origin.x));
687            let bar_y = vp.origin.y + vp.size.height - st.inset - st.thickness;
688            h_thumb = Some(Rect {
689                origin: Point { x: bar_x, y: bar_y },
690                size: Size { width: bar_w, height: st.thickness },
691            });
692        }
693
694        let hovered = st.visibility == ScrollbarVisibility::OnHover && {
695            let v_strip = show_v.then_some(Rect {
696                origin: Point { x: vp.origin.x + vp.size.width - st.inset - st.thickness - HOVER_STRIP, y: vp.origin.y },
697                size: Size { width: st.thickness + st.inset + HOVER_STRIP, height: vp.size.height },
698            });
699            let h_strip = show_h.then_some(Rect {
700                origin: Point { x: vp.origin.x, y: vp.origin.y + vp.size.height - st.inset - st.thickness - HOVER_STRIP },
701                size: Size { width: vp.size.width, height: st.thickness + st.inset + HOVER_STRIP },
702            });
703            v_strip.is_some_and(in_rect) || h_strip.is_some_and(in_rect)
704        };
705        let active = is_pressed
706            || ctrl.is_some_and(|c| c.wheel_recently_active()
707                || c.velocity_magnitude() > rosace_scroll::controller::COAST_STOP_THRESHOLD);
708
709        let target = match st.visibility {
710            ScrollbarVisibility::Hidden => 0.0,
711            ScrollbarVisibility::Always => 1.0,
712            ScrollbarVisibility::WhileScrolling => if active { 1.0 } else { 0.0 },
713            ScrollbarVisibility::OnHover => if hovered || active { 1.0 } else { 0.0 },
714        };
715        // Channel 0 — nothing else on a ScrollView's own node animates
716        // today, but reserved via `animate_channel` (not `animate_to`) so a
717        // future per-node animation here doesn't silently collide.
718        let opacity = ctx.animate_channel(0, target, 0.0);
719        if opacity <= 0.001 {
720            return;
721        }
722        let with_alpha = |c: Color| Color::rgba(c.r, c.g, c.b, (c.a as f32 * opacity).round() as u8);
723
724        if let Some(track_color) = st.track_color {
725            if let Some(r) = v_thumb {
726                let track = Rect {
727                    origin: Point { x: r.origin.x, y: vp.origin.y },
728                    size: Size { width: st.thickness, height: vp.size.height },
729                };
730                ctx.fill_rrect(track, st.radius, with_alpha(track_color));
731            }
732            if let Some(r) = h_thumb {
733                let track = Rect {
734                    origin: Point { x: vp.origin.x, y: r.origin.y },
735                    size: Size { width: vp.size.width, height: st.thickness },
736                };
737                ctx.fill_rrect(track, st.radius, with_alpha(track_color));
738            }
739        }
740        if let Some(r) = v_thumb { ctx.fill_rrect(r, st.radius, with_alpha(st.color)); }
741        if let Some(r) = h_thumb { ctx.fill_rrect(r, st.radius, with_alpha(st.color)); }
742    }
743}
744
745impl Widget for ScrollView {
746    fn layout(&self, ctx: &LayoutCtx) -> Size {
747        let constraints = ctx.constraints;
748        Size { width: avail_w(constraints), height: avail_h(constraints) }
749    }
750
751    fn paint(&self, ctx: &mut PaintCtx) {
752        let vp = ctx.rect;
753        let child_size = self.child.layout(&ctx.layout_ctx(self.child_constraints(vp)));
754
755        // `::fixed` and `::controlled` always use the base path — exact,
756        // un-composited semantics for programmatic control and snapshots.
757        // Otherwise: explicit `.gpu_layer()` forces the GPU path on; plain
758        // `ScrollView::new` auto-detects it via the size heuristic (D090
759        // transparent default).
760        let eligible = self.fixed_offset.is_none() && self.controller.is_none();
761        let use_gpu = eligible
762            && (self.gpu_layer || self.should_auto_gpu(vp.size, child_size));
763
764        if use_gpu {
765            self.paint_gpu(ctx, child_size);
766        } else {
767            self.paint_base(ctx, child_size);
768        }
769    }
770}