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}