Skip to main content

teksilo_widgets/
scroll_area.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ScrollArea — a clipping viewport that scrolls its content on wheel, on a
5//! finger's pan, and on assistive-technology actions.
6//!
7//! Wrap any widget in `ScrollArea` to make it scrollable. The scroll position
8//! is stored in reactive `Signal<f32>` signals (one per axis), shared with the
9//! built-in [`ScrollBar`] children. Two display
10//! modes cover most use cases: `Overlay` (the default, macOS-style thin-at-rest
11//! indicator that expands on hover) and `Permanent` (a layout-consuming gutter
12//! always on screen). Use [`ScrollBarPolicy`] to control when each axis shows.
13//!
14//! ## Pan to scroll
15//!
16//! `ScrollArea` is the reference adopter of [`ScrollableBehavior`]: it
17//! declares a both-axis pan claim, so a direct pointer dragging its content is
18//! synthesised by the router into a positioned `Scroll` and delivered along the
19//! claimant chain. A release hands its velocity to the tree's fling driver,
20//! whose coast arrives back here as ordinary scroll deltas and stops — and
21//! chains outward — at the boundary, exactly as a wheel notch does. A mouse
22//! never pans: the wheel is its scroll device, and its behaviour here is
23//! unchanged in every particular.
24//!
25//! Following the finger *past* the end is off by default
26//! ([`ScrollArea::rubber_band`]); a nested area that banded at its own end could
27//! never hand the gesture to the container around it.
28//!
29//! ## Accessibility
30//!
31//! Reports `Role::ScrollView` with per-axis `scroll_y` / `scroll_x` position
32//! and limit fields. Advertises `ScrollUp` / `ScrollDown` / `ScrollLeft` /
33//! `ScrollRight` actions only for the axes that actually overflow, so AT clients
34//! (NVDA, JAWS, VoiceOver) know which directions are reachable.
35//!
36//! ```rust
37//! # use teksilo_widgets::scroll_area::{ScrollArea, ScrollBarMode};
38//! # use teksilo_widgets::primitives::MinSize;
39//! let _w = ScrollArea::new()
40//!     .child(MinSize::new(0.0, 2000.0))
41//!     .scroll_bar_style(ScrollBarMode::Permanent)
42//!     .smooth_scrolling(true);
43//! ```
44
45use std::cell::{Cell, RefCell};
46use std::rc::Rc;
47use std::time::Duration;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal, Vec2};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::binding::BindingLevel;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::color_prop::ColorProp;
54use teksilo_core::event::{EventResponse, WidgetEvent};
55use teksilo_core::kinetic::KineticScroller;
56use teksilo_core::pointer::touch_action::PanAxes;
57use teksilo_core::pointer::{ScrollPhase, ScrollSource};
58use teksilo_core::signal::{Prop, Signal};
59use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
60use teksilo_core::widget_builder::HandlerSet;
61use teksilo_core::widget_id::WidgetId;
62use teksilo_tokens::{Easing, OverscrollStyle, RevealPolicy};
63
64use crate::common::scroll::OverscrollBehavior;
65use crate::common::scrollable::{ScrollableAxes, ScrollableBehavior};
66use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
67
68/// How the scroll bar is presented relative to the viewport content.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum ScrollBarMode {
71    /// Scroll bar overlays the content (macOS-style): a thin passive indicator
72    /// is painted while scrolling; the full interactive track expands on pointer
73    /// proximity. Does not reduce the viewport width.
74    #[default]
75    Overlay,
76    /// Scroll bar is a permanent layout sibling of the viewport, reserving its
77    /// full thickness and always remaining interactive — the classic Windows/Linux
78    /// gutter style.
79    Permanent,
80    /// Floats over the content like `Overlay` but only ever shows the thin resting
81    /// indicator, never the full track. A passive scroll-position display for
82    /// minimal UIs; drag and track-click still work against the full slot bounds.
83    ///
84    /// **Not the keyboard.** The bar's arrow / `Home` / `End` / `Page` arms sit on
85    /// a node built `focusable(false)`, so no keyboard user reaches them under any
86    /// of the three modes — see [`ScrollBarPolicy::AlwaysOff`], which states the
87    /// same limit from the other side, and `docs/touch-and-pen.md` §10.2, which
88    /// carries it as an open finding.
89    Thin,
90}
91
92/// Controls when the scroll bar appears for a given axis.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum ScrollBarPolicy {
95    /// Show the scroll bar only when content exceeds the viewport size (default).
96    #[default]
97    AsNeeded,
98    /// Always show the scroll bar, even when content fits without scrolling.
99    AlwaysOn,
100    /// Never show the scroll bar; the content still scrolls on a wheel, on a
101    /// finger's pan, and from the assistive-technology scroll actions the
102    /// viewport advertises.
103    ///
104    /// Not from the keyboard: `ScrollArea` installs no key handler, and the
105    /// arrow / Home / End / Page arms on [`ScrollBar`] belong to a node built
106    /// `focusable(false)`, so no keyboard user reaches them. A focused
107    /// descendant is still revealed — that is `ScrollIntoView`, not a key the
108    /// viewport handles.
109    AlwaysOff,
110}
111
112/// A clipping viewport that makes any child widget scrollable.
113///
114/// The scroll offset per axis is stored in a reactive `Signal<f32>`, shared
115/// with the built-in `ScrollBar` children. See [`ScrollBarMode`] for display
116/// options and [`ScrollBarPolicy`] for per-axis visibility control.
117pub struct ScrollArea {
118    content_child: Option<Box<dyn Widget>>,
119    content_child_id: Option<WidgetId>,
120    scroll_bar_style: ScrollBarMode,
121    /// Per-axis scroll bar visibility policy.
122    vertical_policy: ScrollBarPolicy,
123    horizontal_policy: ScrollBarPolicy,
124    /// Pixels per scroll line (for line-based mouse wheel events).
125    line_height: f32,
126    /// Thickness of the scroll bar (for permanent mode layout).
127    scroll_bar_thickness: f32,
128    /// Optional thumb tint forwarded to the built-in scroll bars. `None`
129    /// (default) paints from the theme's `scrollbar_thumb*` tokens. See
130    /// [`Self::scroll_bar_thumb_color`].
131    scroll_bar_thumb_color: Option<ColorProp>,
132    /// When true, content smaller than the viewport is stretched to fill it.
133    widget_resizable: bool,
134    /// Whether line-based scroll events animate smoothly to their target.
135    smooth_scrolling: bool,
136    /// Duration of the smooth scroll animation.
137    smooth_scroll_duration: Duration,
138    /// Preferred size returned by `layout_response` when the proposal is
139    /// unconstrained. `None` falls back to cached content size or 300×200.
140    preferred_size: Option<Size>,
141    /// Height-only cap; width still follows the content. See `preferred_height`.
142    preferred_height: Option<f32>,
143    /// Scroll-chaining behavior at the boundary. `Chain` (default) lets a
144    /// boundary scroll bubble to an ancestor scrollable; `Contain` absorbs it
145    /// (the web's `overscroll-behavior`).
146    overscroll_behavior: OverscrollBehavior,
147    /// Extra scrollable range past the end of the content, as a fraction of the
148    /// viewport height. See [`Self::scroll_past_end`].
149    scroll_past_end: Prop<f32>,
150
151    // --- shared reactive state ---
152    /// Vertical scroll position (0.0 = top).
153    scroll_y: Signal<f32>,
154    /// Horizontal scroll position (0.0 = left).
155    scroll_x: Signal<f32>,
156    /// Maximum vertical scroll (content_height - viewport_height).
157    max_scroll_y: Signal<f32>,
158    /// Maximum horizontal scroll (content_width - viewport_width).
159    max_scroll_x: Signal<f32>,
160    /// Vertical viewport/content ratio (0.0..1.0).
161    viewport_ratio_y: Signal<f32>,
162    /// Horizontal viewport/content ratio (0.0..1.0).
163    viewport_ratio_x: Signal<f32>,
164
165    // --- resolved children ---
166    /// Resolved child IDs: [content, optional_v_scrollbar, optional_h_scrollbar]
167    child_ids: Vec<WidgetId>,
168
169    // --- cached sizes for event handling ---
170    content_size: Cell<Size>,
171    /// Shared with the on_scroll / on_access_action handler closures.
172    /// Wrapped in `Rc` because cloning a bare `Cell` produces an
173    /// independent cell — the closure would never see updates from
174    /// `place_children`.
175    viewport_size: Rc<Cell<Size>>,
176    /// Absolute top-left of the viewport in tree/screen coordinates.
177    /// Needed to convert `target_bounds` (which `ScrollIntoView` carries
178    /// in absolute tree coords) into content-relative coordinates.
179    /// Shared via `Rc` for the same reason as `viewport_size`.
180    viewport_origin: Rc<Cell<Point>>,
181
182    // --- one-shot restore ---
183    /// A vertical offset waiting for a range long enough to hold it. See
184    /// [`Self::restore_scroll_y`]. Shared via `Rc` because the scroll handler
185    /// stands it down when the reader takes over, and that closure cannot borrow
186    /// `self`.
187    pending_restore_y: Rc<Cell<Option<f32>>>,
188    /// What the pending restore last wrote to `scroll_y`, so a write by anyone
189    /// else can be recognised on the following pass.
190    ///
191    /// The `on_scroll` handler stands the restore down for a wheel gesture and a
192    /// `ScrollIntoView`, which is every route that reaches *it* — but not every
193    /// route that moves the scroll. **A scroll bar holds a clone of `scroll_y`
194    /// and calls `set` on it directly** (`ScrollBar::new` is handed the signal in
195    /// `build`), so dragging the thumb never reaches that handler. With a pending
196    /// offset the content is too short to ever honour, the drag was undone by the
197    /// next layout pass and the reader was pinned at the clamped bottom with no
198    /// way out.
199    ///
200    /// Shared via `Rc` for the same reason as `pending_restore_y`: it is cleared
201    /// beside it, from a closure that cannot borrow `self`.
202    restore_wrote_y: Rc<Cell<Option<f32>>>,
203
204    // --- pan / kinetic ---
205    /// This surface's physics: the range, the rubber band, and the offset a
206    /// pan is currently holding. Owned here rather than by the behaviour so it
207    /// survives a rebuild and so `place_children` — the only place the viewport
208    /// extent is known — can publish into it.
209    scroller: Rc<RefCell<KineticScroller>>,
210    /// How far past its range the content is being held, per axis. Zero unless
211    /// [`Self::rubber_band`] is on and a finger is past the end.
212    overscroll: Signal<Vec2>,
213    /// Whether a finger may drag the content past the end. Off by default —
214    /// see [`Self::rubber_band`].
215    rubber_band: bool,
216    /// Raised while a finger's pan is in flight, so an overlay bar shows what
217    /// is moving. Read by both `ScrollBar` children.
218    scrollbar_reveal: Signal<bool>,
219}
220
221impl Default for ScrollArea {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl std::fmt::Debug for ScrollArea {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("ScrollArea")
230            .field("scroll_y", &self.scroll_y.get())
231            .field("scroll_x", &self.scroll_x.get())
232            .field("style", &self.scroll_bar_style)
233            .field("v_policy", &self.vertical_policy)
234            .field("h_policy", &self.horizontal_policy)
235            .field("widget_resizable", &self.widget_resizable)
236            .field("content_size", &self.content_size.get())
237            .field("viewport_size", &self.viewport_size.get())
238            .finish()
239    }
240}
241
242impl ScrollArea {
243    /// Create a new `ScrollArea` with overlay scroll bars, smooth scrolling, and no content yet.
244    pub fn new() -> Self {
245        Self {
246            content_child: None,
247            content_child_id: None,
248            scroll_bar_style: ScrollBarMode::default(),
249            vertical_policy: ScrollBarPolicy::default(),
250            horizontal_policy: ScrollBarPolicy::default(),
251            line_height: 20.0,
252            scroll_bar_thickness: 12.0,
253            scroll_bar_thumb_color: None,
254            widget_resizable: false,
255            smooth_scrolling: true,
256            smooth_scroll_duration: Duration::from_millis(150),
257            preferred_size: None,
258            preferred_height: None,
259            overscroll_behavior: OverscrollBehavior::default(),
260            scroll_past_end: Prop::Static(0.0),
261            scroll_y: Signal::new_animated(0.0),
262            scroll_x: Signal::new_animated(0.0),
263            max_scroll_y: Signal::new(0.0),
264            max_scroll_x: Signal::new(0.0),
265            viewport_ratio_y: Signal::new(1.0),
266            viewport_ratio_x: Signal::new(1.0),
267            child_ids: Vec::new(),
268            content_size: Cell::new(Size::ZERO),
269            viewport_size: Rc::new(Cell::new(Size::ZERO)),
270            viewport_origin: Rc::new(Cell::new(Point::ZERO)),
271            pending_restore_y: Rc::new(Cell::new(None)),
272            restore_wrote_y: Rc::new(Cell::new(None)),
273            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
274            overscroll: Signal::new(Vec2::ZERO),
275            rubber_band: false,
276            scrollbar_reveal: Signal::new(false),
277        }
278    }
279
280    /// Let a finger drag the content past its end, with decreasing gain, and
281    /// release it on the lift — the iOS / Flutter `BouncingScrollPhysics` feel.
282    ///
283    /// **Off by default, and the default is load-bearing.** A surface that
284    /// follows the finger past its end has absorbed the movement, so a nested
285    /// area that banded could never hand the gesture to the container around
286    /// it. The band belongs to the outermost area of a scroll chain.
287    ///
288    /// `prefers-reduced-motion` hard-clamps it whatever this says.
289    pub fn rubber_band(mut self, enabled: bool) -> Self {
290        self.rubber_band = enabled;
291        self
292    }
293
294    /// How far past its range the content is currently being held, per axis,
295    /// after the band. Always `ZERO` with [`Self::rubber_band`] off.
296    ///
297    /// The scroll offset itself never leaves the range, so this is the signal
298    /// a surface binds to draw a stretch or a glow; ignoring it is correct.
299    pub fn overscroll_signal(&self) -> Signal<Vec2> {
300        self.overscroll.clone()
301    }
302
303    /// Set the scrollable content widget.
304    pub fn child(mut self, child: impl teksilo_core::IntoTeksiChild) -> Self {
305        match teksilo_core::IntoTeksiChild::into_pending(child) {
306            teksilo_core::PendingChild::Id(id) => {
307                self.content_child_id = Some(id);
308                self.content_child = None;
309                self
310            }
311            teksilo_core::PendingChild::Deferred(w) => {
312                self.content_child = Some(w);
313                self.content_child_id = None;
314                self
315            }
316        }
317    }
318    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
319    ///
320    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
321    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
322    /// adds no arena node, so nothing is laid out, painted, or published to the
323    /// accessibility tree, and a stack applies no spacing around it.
324    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
325        match widget {
326            Some(w) => self.child(w),
327            None => self,
328        }
329    }
330
331    /// Construct from an already-registered child WidgetId.
332    pub fn from_id(child: WidgetId) -> Self {
333        let mut sa = Self::new();
334        sa.content_child_id = Some(child);
335        sa
336    }
337
338    /// Set the scroll bar display mode (`Overlay`, `Permanent`, or `Thin`).
339    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
340        self.scroll_bar_style = style;
341        self
342    }
343
344    /// Tint the built-in scroll bars' thumb with an explicit colour instead of
345    /// the theme's `scrollbar_thumb*` tokens. Accepts anything
346    /// `impl Into<ColorProp>` — a `Color`, a theme role, or a `Signal` —
347    /// resolved against the live theme at paint, so roles/signals stay
348    /// reactive. Forwarded to both scroll bars via
349    /// [`ScrollBar::thumb_color`](crate::scroll_bar::ScrollBar::thumb_color).
350    /// Use when the area sits on a surface the surface-relative tokens don't
351    /// suit — e.g. a tooltip's inverse chip (`TextRole::TooltipText`).
352    pub fn scroll_bar_thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
353        self.scroll_bar_thumb_color = Some(color.into());
354        self
355    }
356
357    /// Set the vertical scroll bar visibility policy.
358    pub fn vertical_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
359        self.vertical_policy = policy;
360        self
361    }
362
363    /// Set the horizontal scroll bar visibility policy.
364    pub fn horizontal_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self {
365        self.horizontal_policy = policy;
366        self
367    }
368
369    /// Set the pixels-per-line used when translating line-based wheel events.
370    pub fn line_height(mut self, lh: f32) -> Self {
371        self.line_height = lh;
372        self
373    }
374
375    /// Set the scroll bar thickness in logical pixels (applies to both axes).
376    pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
377        self.scroll_bar_thickness = thickness;
378        self
379    }
380
381    /// When true, content smaller than the viewport is stretched to fill it.
382    /// Similar to Qt's `QScrollArea::setWidgetResizable(true)`.
383    pub fn widget_resizable(mut self, resizable: bool) -> Self {
384        self.widget_resizable = resizable;
385        self
386    }
387
388    /// Enable or disable smooth animated scrolling for wheel events.
389    /// Enabled by default. Applies to both line-based (`ScrollDelta::Lines`)
390    /// and pixel-based (`ScrollDelta::Pixels`) wheel events — on Wayland and
391    /// other platforms with high-resolution scroll axes, mouse wheel notches
392    /// are delivered as pixel deltas, so animating both paths is required for
393    /// a fast flick to feel smooth instead of jumping.
394    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
395        self.smooth_scrolling = enabled;
396        self
397    }
398
399    /// Set the duration of the smooth scroll animation (default: 150ms).
400    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
401        self.smooth_scroll_duration = duration;
402        self
403    }
404
405    /// Allow scrolling past the end of the content by `fraction` of the
406    /// viewport height (default `0.0` — the last pixel of content stops flush
407    /// with the bottom of the viewport).
408    ///
409    /// This extends the scroll **range** only. It adds no widget, no padding and
410    /// no layout, so it cannot interfere with the content's own padding — a
411    /// distinction worth keeping, since padding-based implementations of this
412    /// idea in other toolkits are a recurring source of "single-line content is
413    /// scrollable" bugs.
414    ///
415    /// The motivating case is typewriter scrolling: to pin the caret's line at
416    /// the middle of the viewport, the view must be able to scroll half a
417    /// viewport past the last line, or the pin quietly stops working over the
418    /// final page — exactly where a writer spends their time. Pair with
419    /// [`EventContext::ensure_visible_aligned`], passing `1.0 - fraction` here
420    /// for a pin at `fraction`.
421    ///
422    /// Accepts a literal or a `Signal<f32>`, so it can follow a setting live.
423    /// Negative values are treated as `0.0`.
424    ///
425    /// [`EventContext::ensure_visible_aligned`]: teksilo_core::widget::EventContext::ensure_visible_aligned
426    pub fn scroll_past_end(mut self, fraction: impl Into<Prop<f32>>) -> Self {
427        self.scroll_past_end = fraction.into();
428        self
429    }
430
431    /// Set a preferred size returned when the parent proposes unconstrained
432    /// dimensions. If not set, falls back to cached content size or 300×200.
433    ///
434    /// This overrides **both** axes. If you only want to cap the height and let
435    /// the width follow the content — the usual case for a menu or popover, which
436    /// must be as wide as its widest row — use [`preferred_height`] instead.
437    /// Passing a width of `0.0` here does *not* mean "no preference": it means
438    /// zero, and the scroll area will collapse.
439    ///
440    /// [`preferred_height`]: Self::preferred_height
441    pub fn preferred_size(mut self, width: f32, height: f32) -> Self {
442        self.preferred_size = Some(Size::new(width, height));
443        self
444    }
445
446    /// The content's natural width, for reporting an intrinsic width to a parent
447    /// that hugs (a menu, a popover).
448    ///
449    /// **Measured, not remembered.** `content_size` is only populated in
450    /// `place_children`, so on the very first layout pass — which is exactly when
451    /// a popover decides how wide to be — it is still zero, and the old code fell
452    /// back to a hard-coded `300.0`. That is how a menu of long rows ended up
453    /// narrower than its own content and clipped every one of them. Measuring the
454    /// child with an unbounded width asks it what it actually wants.
455    ///
456    /// Falls back to the cached size, then to `300.0`, if the child cannot be
457    /// measured (no content child yet).
458    fn natural_content_width(&self, ctx: &LayoutContext) -> f32 {
459        // The content child is `child_ids[0]` — `content_child` / `content_child_id`
460        // are both *consumed* by `build()`, so they are `None` by layout time.
461        if let Some(&child) = self.child_ids.first()
462            && let Some(size) = ctx.child_size(
463                child,
464                SizeProposal {
465                    width: None,
466                    height: None,
467                },
468            )
469            && size.width > 0.0
470        {
471            return size.width;
472        }
473        let cached = self.content_size.get().width;
474        if cached > 0.0 { cached } else { 300.0 }
475    }
476
477    /// Cap the height when the parent proposes an unconstrained one, while
478    /// letting the **width** continue to follow the content.
479    ///
480    /// This is what a scrolling menu/popover wants: it must not grow taller than
481    /// its viewport, but it must still be as wide as its widest item. Using
482    /// [`preferred_size`](Self::preferred_size) with a `0.0` width for this
483    /// collapses the panel to its minimum width and clips every row — the parent
484    /// proposes an unconstrained width (it is hugging its content), so the `0.0`
485    /// is taken literally.
486    pub fn preferred_height(mut self, height: f32) -> Self {
487        self.preferred_height = Some(height);
488        self
489    }
490
491    /// Set the scroll-chaining behavior at the boundary. Default
492    /// [`OverscrollBehavior::Chain`] (a boundary scroll bubbles to an ancestor
493    /// scrollable); [`OverscrollBehavior::Contain`] absorbs it instead.
494    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
495        self.overscroll_behavior = behavior;
496        self
497    }
498
499    /// Land `offset` on the first layout pass at which this area has a real
500    /// scrollable range, then forget it.
501    ///
502    /// `max_scroll_y` is `0.0` until the content has been measured, so an
503    /// offset a host writes before that first measurement is clamped away to
504    /// zero and the page paints at the top for a frame before jumping to
505    /// where it should have started. This stores the offset instead and
506    /// applies it itself, inside layout, as soon as `max_scroll_y` becomes
507    /// nonzero, before the ordinary clamp would otherwise discard it, so the
508    /// very first frame the content is measured on is already laid out at
509    /// the restored position, with no visible jump.
510    ///
511    /// It is a one-shot: once applied, it is dropped, so a later reflow (a
512    /// wider window, an edit that lengthens the document) never yanks the
513    /// reader back to where they came in. The offset is still clamped to the
514    /// real range when it lands: past the end it lands at the end, negative
515    /// it lands at zero.
516    ///
517    /// `offset <= 0.0` is a no-op: there is nothing to restore, and it clears
518    /// any previously armed offset rather than leaving it pending.
519    ///
520    /// An area that never calls this behaves exactly as it always has.
521    pub fn restore_scroll_y(self, offset: f32) -> Self {
522        // Set through the existing cell rather than replacing it: the scroll handler
523        // captured this `Rc` when the area was constructed, and handing it a fresh
524        // one would leave it standing down a slot nothing reads.
525        self.pending_restore_y.set((offset > 0.0).then_some(offset));
526        // A fresh one-shot has written nothing yet. Left over from a previous
527        // arming on the same area, this would make the first pass mistake the
528        // *old* landing for somebody else's write and stand the new offset down
529        // before it had a chance.
530        self.restore_wrote_y.set(None);
531        self
532    }
533
534    /// Get the vertical scroll position signal (for external observation).
535    pub fn scroll_y_signal(&self) -> &Signal<f32> {
536        &self.scroll_y
537    }
538
539    /// Get the horizontal scroll position signal (for external observation).
540    pub fn scroll_x_signal(&self) -> &Signal<f32> {
541        &self.scroll_x
542    }
543
544    /// Maximum vertical scroll offset for the current content
545    /// (`content_height − viewport_height`, or 0 when content fits), plus any
546    /// range bought with [`scroll_past_end`](Self::scroll_past_end).
547    /// External callers bind to this for "is there more to scroll?"
548    /// chrome (e.g. trailing scroll-arrow visibility).
549    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
550        &self.max_scroll_y
551    }
552
553    /// Fraction of the scrollable height currently visible (`1.0` when
554    /// everything fits) — what sizes the vertical scroll bar's thumb. Accounts
555    /// for [`scroll_past_end`](Self::scroll_past_end), so the thumb stays
556    /// proportional to the range the user can actually travel.
557    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
558        &self.viewport_ratio_y
559    }
560
561    /// Maximum horizontal scroll offset for the current content.
562    /// External callers bind to this for "is there more to scroll?"
563    /// chrome (e.g. trailing scroll-arrow visibility on a tab bar).
564    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
565        &self.max_scroll_x
566    }
567
568    /// The viewport size this area last placed its content into, shared
569    /// live (an `Rc<Cell<_>>`, not a snapshot).
570    ///
571    /// Deliberately not public: it reports the *previous* layout pass, so
572    /// it is only sound for a widget that also knows when that pass is
573    /// still current. `TabBar` reads it to resolve the axis its own
574    /// measurement leaves unbounded — a vertical bar's content is
575    /// measured with `height: None`, so the row cannot recover the
576    /// viewport height from its size proposal.
577    pub(crate) fn viewport_size_cell(&self) -> Rc<Cell<Size>> {
578        self.viewport_size.clone()
579    }
580
581    fn clamp_and_set_scroll(&self) {
582        let max_y = self.max_scroll_y.get();
583        let max_x = self.max_scroll_x.get();
584        let cur_y = self.scroll_y.get();
585        let cur_x = self.scroll_x.get();
586        let clamped_y = cur_y.clamp(0.0, max_y);
587        let clamped_x = cur_x.clamp(0.0, max_x);
588        if (clamped_y - cur_y).abs() > f32::EPSILON {
589            self.scroll_y.set(clamped_y);
590        }
591        if (clamped_x - cur_x).abs() > f32::EPSILON {
592            self.scroll_x.set(clamped_x);
593        }
594    }
595}
596
597impl Widget for ScrollArea {
598    /// Opt into concrete-type introspection so a host's tests can read the
599    /// scroll metrics of an area built deep inside a composite (a page whose
600    /// `ScrollArea` no caller holds a reference to) rather than only of one they
601    /// constructed themselves.
602    fn as_any(&self) -> Option<&dyn std::any::Any> {
603        Some(self)
604    }
605
606    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
607        let mut ids = Vec::new();
608
609        // Resolve the content child
610        let content_id = if let Some(child) = self.content_child.take() {
611            ctx.add_boxed(child)
612        } else if let Some(id) = self.content_child_id.take() {
613            id
614        } else if !self.child_ids.is_empty() {
615            // Already built — return existing children
616            return self.child_ids.clone();
617        } else {
618            // No content was set (e.g. `ScrollArea::default()` reaching the
619            // tree). `build()` must never panic — an empty content area is a
620            // valid, if useless, widget: `place_children` early-returns on an
621            // empty child list and `layout_response` falls back to its default
622            // size. Leave `child_ids` empty and render nothing.
623            self.child_ids.clear();
624            return Vec::new();
625        };
626        ids.push(content_id);
627
628        // Scrollbar visual tuning depends on mode
629        let visual = match self.scroll_bar_style {
630            ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
631            ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
632            ScrollBarMode::Thin => ScrollBarVisual::Thin,
633        };
634        let thickness = self.scroll_bar_thickness; // full thickness for all modes
635
636        // Create vertical scrollbar
637        let mut v_scrollbar = ScrollBar::new(
638            ScrollBarOrientation::Vertical,
639            self.scroll_y.clone(),
640            self.max_scroll_y.clone(),
641            self.viewport_ratio_y.clone(),
642        )
643        .thickness(thickness)
644        .visual(visual)
645        // A contact writes no hover, so an overlay bar would stay hidden under
646        // the very gesture that is moving it. Both bars watch the one signal
647        // the scroll handler raises while a pan is in flight.
648        .reveal(self.scrollbar_reveal.clone());
649        if let Some(tint) = &self.scroll_bar_thumb_color {
650            v_scrollbar = v_scrollbar.thumb_color(tint.clone());
651        }
652        let v_id = ctx.add(v_scrollbar);
653        ids.push(v_id);
654
655        // Create horizontal scrollbar
656        let mut h_scrollbar = ScrollBar::new(
657            ScrollBarOrientation::Horizontal,
658            self.scroll_x.clone(),
659            self.max_scroll_x.clone(),
660            self.viewport_ratio_x.clone(),
661        )
662        .thickness(thickness)
663        .visual(visual)
664        .reveal(self.scrollbar_reveal.clone());
665        if let Some(tint) = &self.scroll_bar_thumb_color {
666            h_scrollbar = h_scrollbar.thumb_color(tint.clone());
667        }
668        let h_id = ctx.add(h_scrollbar);
669        ids.push(h_id);
670
671        // Register animated signals
672        ctx.register_animated_signal(&self.scroll_y);
673        ctx.register_animated_signal(&self.scroll_x);
674
675        // Register bindings: scroll position changes trigger relayout (content offset moves)
676        let self_id = ctx.self_id();
677        let registry = ctx.binding_registry();
678        self.scroll_y
679            .bind_to(self_id, registry, BindingLevel::Relayout);
680        self.scroll_x
681            .bind_to(self_id, registry, BindingLevel::Relayout);
682        // An *input* to the scroll range, unlike the metrics published at the
683        // bottom of `layout` — binding it at `Relayout` is safe (nothing writes
684        // it during layout) and is what makes a live settings change re-measure.
685        self.scroll_past_end
686            .register_if_bound(self_id, registry, BindingLevel::Relayout);
687
688        self.child_ids = ids.clone();
689
690        // Set up handlers
691        let scroll_y = self.scroll_y.clone();
692        let scroll_x = self.scroll_x.clone();
693        let max_scroll_y = self.max_scroll_y.clone();
694        let max_scroll_x = self.max_scroll_x.clone();
695        let viewport_size = self.viewport_size.clone();
696        let viewport_origin = self.viewport_origin.clone();
697        let line_height = self.line_height;
698        let smooth_scrolling = self.smooth_scrolling;
699        let smooth_scroll_duration = self.smooth_scroll_duration;
700        let overscroll_behavior = self.overscroll_behavior;
701
702        let clamp_and_set = {
703            let scroll_y = scroll_y.clone();
704            let scroll_x = scroll_x.clone();
705            let max_scroll_y = max_scroll_y.clone();
706            let max_scroll_x = max_scroll_x.clone();
707            move || {
708                let max_y = max_scroll_y.get();
709                let max_x = max_scroll_x.get();
710                let cur_y = scroll_y.get();
711                let cur_x = scroll_x.get();
712                let clamped_y = cur_y.clamp(0.0, max_y);
713                let clamped_x = cur_x.clamp(0.0, max_x);
714                if (clamped_y - cur_y).abs() > f32::EPSILON {
715                    scroll_y.set(clamped_y);
716                }
717                if (clamped_x - cur_x).abs() > f32::EPSILON {
718                    scroll_x.set(clamped_x);
719                }
720            }
721        };
722
723        let mut handlers = HandlerSet::new().clips_children(true);
724
725        // Everything that scrolls this area arrives as `WidgetEvent::Scroll`,
726        // so there is one handler and no `on_pan`: a wheel notch
727        // (`ScrollDelta::Lines`), a trackpad stream (`ScrollDelta::Pixels`), a
728        // finger's pan synthesised by the router, and the coast that follows a
729        // release all come through the same door.
730        // `ScrollableBehavior::install` attaches both halves of that — the
731        // shared handler and the pan claim that puts this node on the claimant
732        // chain — and the arm below is what `ScrollArea` adds on top: its
733        // `ScrollIntoView` reveal, and the two pieces of per-scroll bookkeeping
734        // that must run before any delta lands.
735        {
736            // The `ScrollIntoView` reveal and the two pieces of per-scroll
737            // bookkeeping this area owns. Installed as the behaviour's `before`
738            // arm, so it sees every event first: it claims a reveal outright,
739            // and it *observes* a scroll and then declines, which is what lets
740            // the shared handler own the delta while this arm still gets to run
741            // ahead of it.
742            let scroll_y = scroll_y.clone();
743            let scroll_x = scroll_x.clone();
744            let max_scroll_y = max_scroll_y.clone();
745            let max_scroll_x = max_scroll_x.clone();
746            let viewport_size = viewport_size.clone();
747            let viewport_origin = viewport_origin.clone();
748            // Anything that scrolls this area on purpose outranks a restore that has
749            // not landed yet: a reader who has started scrolling, or a caret being
750            // revealed, has said where they want to be. Without this, a pending
751            // offset the content is still too short to honour would be re-asserted
752            // on every layout pass and fight them for it.
753            let pending_restore_y = self.pending_restore_y.clone();
754            let restore_wrote_y = self.restore_wrote_y.clone();
755            // An overlay bar is revealed for as long as a finger's pan is in
756            // flight, and returns to its resting state on the lift. At a
757            // density whose `RevealPolicy` is `Always` the resting state is
758            // "shown", so this only ever adds a reveal, never takes one away.
759            let reveal = self.scrollbar_reveal.clone();
760            let reveal_at_rest = ctx.theme().input.reveal == RevealPolicy::Always;
761
762            let own_arm = move |event: &WidgetEvent,
763                                ctx: &mut teksilo_core::widget::EventContext|
764                  -> Option<EventResponse> {
765                match event {
766                    WidgetEvent::Scroll { phase, .. } => {
767                        pending_restore_y.set(None);
768                        restore_wrote_y.set(None);
769                        if ctx.scroll_source() == ScrollSource::TouchPan {
770                            let in_flight = !matches!(
771                                phase,
772                                ScrollPhase::Ended
773                                    | ScrollPhase::MomentumEnded
774                                    | ScrollPhase::Cancelled
775                            );
776                            let want = in_flight || reveal_at_rest;
777                            if reveal.get() != want {
778                                reveal.set(want);
779                            }
780                        }
781                        // Declined on purpose: the delta belongs to the shared
782                        // handler, which is the whole point of installing one.
783                        // `None`, not `Some(Ignored)`: this arm observed the
784                        // event, it did not consume it.
785                        None
786                    }
787                    WidgetEvent::ScrollIntoView {
788                        target_bounds,
789                        margin,
790                        align,
791                        motion,
792                        applied_scroll,
793                    } => {
794                        pending_restore_y.set(None);
795                        restore_wrote_y.set(None);
796                        // `target_bounds` is in absolute tree coordinates (the
797                        // arena stores screen-space rects). Convert to the
798                        // content's local frame by subtracting the viewport's
799                        // absolute origin and adding the current scroll offset:
800                        // a child whose absolute top equals the viewport's
801                        // absolute top is at content-space y = scroll_y.
802                        let vp = viewport_size.get();
803                        let vo = viewport_origin.get();
804                        let sy = scroll_y.get();
805                        let sx = scroll_x.get();
806
807                        // Reveal on each axis independently, but leave an axis
808                        // untouched when the (margin-expanded) target already spans
809                        // the viewport on it: a target larger than the viewport is
810                        // "as visible as it can be", and aligning one of its edges
811                        // would spuriously move that axis — e.g. a full-width row
812                        // (or any target as wide as the content) resetting a
813                        // horizontally-scrolled ancestor on a vertical-only nav.
814                        let viewport_top = sy;
815                        let viewport_bottom = viewport_top + vp.height;
816                        let target_top = target_bounds.y - vo.y + sy - margin;
817                        let target_bottom = target_top + target_bounds.height + margin * 2.0;
818
819                        let mut new_y = sy;
820                        match align {
821                            // Pin: put the target at `f` of the way down the
822                            // viewport regardless of where it currently sits. The
823                            // margin is deliberately not applied — a pin already
824                            // names an exact position, and padding it would only
825                            // shift the pin by an amount the caller did not ask for.
826                            teksilo_core::event::ScrollAlign::Fraction(f) => {
827                                let target_top = target_bounds.y - vo.y + sy;
828                                new_y = target_top - (vp.height - target_bounds.height) * f;
829                            }
830                            teksilo_core::event::ScrollAlign::Minimal => {
831                                if !(target_top <= viewport_top && target_bottom >= viewport_bottom)
832                                {
833                                    if target_top < viewport_top {
834                                        new_y = target_top;
835                                    } else if target_bottom > viewport_bottom {
836                                        new_y = target_bottom - vp.height;
837                                    }
838                                }
839                            }
840                        }
841
842                        let viewport_left = sx;
843                        let viewport_right = viewport_left + vp.width;
844                        let target_left = target_bounds.x - vo.x + sx - margin;
845                        let target_right = target_left + target_bounds.width + margin * 2.0;
846
847                        let mut new_x = sx;
848                        if !(target_left <= viewport_left && target_right >= viewport_right) {
849                            if target_left < viewport_left {
850                                new_x = target_left;
851                            } else if target_right > viewport_right {
852                                new_x = target_right - vp.width;
853                            }
854                        }
855
856                        // Clamp up front rather than setting then calling
857                        // `clamp_and_set`: an animated scroll must be aimed at a
858                        // reachable offset, or the tween would start toward a
859                        // target the clamp immediately retracts.
860                        let new_y = new_y.clamp(0.0, max_scroll_y.get());
861                        let new_x = new_x.clamp(0.0, max_scroll_x.get());
862
863                        match motion {
864                            teksilo_core::event::ScrollMotion::Smooth if smooth_scrolling => {
865                                scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
866                                scroll_x.animate_to(new_x, smooth_scroll_duration, Easing::EaseOut);
867                            }
868                            _ => {
869                                scroll_y.set(new_y);
870                                scroll_x.set(new_x);
871                            }
872                        }
873                        // Report the applied scroll delta so a nested outer
874                        // container can re-target the same rect. Computed from the
875                        // clamped *targets*, not the live signal, so an animated
876                        // scroll reports where it is heading rather than the single
877                        // frame it has travelled so far.
878                        if let Some(cell) = applied_scroll
879                            && let Ok(mut d) = cell.lock()
880                        {
881                            *d = teksilo_canvas::Point::new(new_x - sx, new_y - sy);
882                        }
883                        Some(EventResponse::Handled)
884                    }
885                    _ => None,
886                }
887            };
888
889            let axes = ScrollableAxes {
890                x: self.scroll_x.clone(),
891                y: self.scroll_y.clone(),
892                max_x: self.max_scroll_x.clone(),
893                max_y: self.max_scroll_y.clone(),
894                overscroll: self.overscroll.clone(),
895            };
896            let behavior = ScrollableBehavior::new(axes)
897                .with_scroller(self.scroller.clone())
898                // Both axes: a claim on an axis this area cannot currently
899                // scroll costs nothing, because the chain re-offers the whole
900                // event outward the moment the axis declines it.
901                .axes(PanAxes::BOTH)
902                .overscroll(overscroll_behavior)
903                .rubber_band(self.rubber_band)
904                .overscroll_style(OverscrollStyle::RubberBand)
905                .smooth(smooth_scrolling)
906                .smooth_duration(smooth_scroll_duration)
907                .line_height(line_height)
908                .reduced_motion(ctx.prefers_reduced_motion())
909                .physics(ctx.theme().input.scroll_physics)
910                .before(own_arm);
911            handlers = behavior.install(handlers);
912        }
913
914        // Access action handler
915        {
916            let scroll_y = scroll_y.clone();
917            let scroll_x = scroll_x.clone();
918            let viewport_size = viewport_size.clone();
919            let clamp_and_set = clamp_and_set.clone();
920            handlers = handlers.on_access_action(move |action, _ctx| match action {
921                teksilo_core::accesskit::Action::ScrollDown => {
922                    let step = viewport_size.get().height * 0.9;
923                    scroll_y.set(scroll_y.get() + step);
924                    clamp_and_set();
925                    EventResponse::Handled
926                }
927                teksilo_core::accesskit::Action::ScrollUp => {
928                    let step = viewport_size.get().height * 0.9;
929                    scroll_y.set(scroll_y.get() - step);
930                    clamp_and_set();
931                    EventResponse::Handled
932                }
933                teksilo_core::accesskit::Action::ScrollRight => {
934                    let step = viewport_size.get().width * 0.9;
935                    scroll_x.set(scroll_x.get() + step);
936                    clamp_and_set();
937                    EventResponse::Handled
938                }
939                teksilo_core::accesskit::Action::ScrollLeft => {
940                    let step = viewport_size.get().width * 0.9;
941                    scroll_x.set(scroll_x.get() - step);
942                    clamp_and_set();
943                    EventResponse::Handled
944                }
945                _ => EventResponse::Ignored,
946            });
947        }
948
949        ctx.apply_self_handlers(handlers);
950
951        ids
952    }
953
954    fn layout_response(
955        &self,
956        proposal: SizeProposal,
957        ctx: &LayoutContext,
958    ) -> teksilo_core::widget::LayoutResponse {
959        // A scroll area's HEIGHT should come from its parent, not its content —
960        // otherwise it grows to fit everything and no scrolling is needed, and
961        // the intrinsic height is unstable across layout passes. Its WIDTH,
962        // though, must follow the content, or a horizontally-hugging parent (a
963        // menu, a popover) collapses it and clips every row.
964        let (default_w, default_h) = if let Some(pref) = self.preferred_size {
965            (pref.width, pref.height)
966        } else {
967            let h = self.preferred_height.unwrap_or(200.0);
968            // `resolve()` below only ever consults `default_w` when
969            // `proposal.width` is `None` — computing it otherwise measures the
970            // whole content subtree via an unbounded `ctx.child_size` query
971            // and then discards the result. Gate on that literal condition
972            // (not on `preferred_height.is_some()`, which happens to hold for
973            // the one known width-hugging caller, `menu_list.rs`, but isn't
974            // the actual necessary-and-sufficient test — any other
975            // `ScrollArea` under a genuinely width-hugging parent without
976            // `preferred_height` set would silently regress under that
977            // narrower gate).
978            let w = if proposal.width.is_none() {
979                self.natural_content_width(ctx)
980            } else {
981                0.0
982            };
983            (w, h)
984        };
985        proposal.resolve(default_w, default_h).into()
986    }
987
988    fn place_children(
989        &self,
990        bounds: Rect,
991        _proposal: SizeProposal,
992        children: &mut [WidgetPlacement],
993        ctx: &LayoutContext,
994    ) {
995        if children.is_empty() {
996            return;
997        }
998
999        // Children layout depends on policies:
1000        //   AlwaysOff  → scrollbar child exists but is collapsed to zero size
1001        //   AlwaysOn   → scrollbar always visible (reserves space in Permanent)
1002        //   AsNeeded   → visible only when content overflows
1003        let has_v = children.len() > 1;
1004        let has_h = children.len() > 2;
1005        let v_off = self.vertical_policy == ScrollBarPolicy::AlwaysOff;
1006        let _h_off = self.horizontal_policy == ScrollBarPolicy::AlwaysOff;
1007
1008        // Scrollbar thickness — same for both modes (overlay paints thin at rest)
1009        let sb_thickness = self.scroll_bar_thickness;
1010
1011        // --- Step 1: Compute viewport size (two-pass for cross-axis dependencies) ---
1012
1013        // Helper: determine scrollbar visibility from policy + overflow.
1014        let resolve_show = |policy: ScrollBarPolicy, has_bar: bool, overflows: bool| -> bool {
1015            has_bar
1016                && match policy {
1017                    ScrollBarPolicy::AlwaysOn => true,
1018                    ScrollBarPolicy::AlwaysOff => false,
1019                    ScrollBarPolicy::AsNeeded => overflows,
1020                }
1021        };
1022
1023        // Pass 1: measure with optimistic vertical reservation.
1024        let v_reserved_1 = match self.scroll_bar_style {
1025            ScrollBarMode::Permanent if has_v && !v_off => sb_thickness,
1026            _ => 0.0,
1027        };
1028        let vp_w1 = (bounds.width - v_reserved_1).max(0.0);
1029        let content_size_1 = ctx
1030            .child_size(
1031                children[0].id,
1032                SizeProposal {
1033                    width: Some(vp_w1),
1034                    height: None,
1035                },
1036            )
1037            .unwrap_or(Size::new(vp_w1, bounds.height));
1038
1039        let show_v_1 = resolve_show(
1040            self.vertical_policy,
1041            has_v,
1042            content_size_1.height > bounds.height + 0.5,
1043        );
1044        let show_h_1 = resolve_show(
1045            self.horizontal_policy,
1046            has_h,
1047            content_size_1.width > vp_w1 + 0.5,
1048        );
1049
1050        // Compute actual reservations from pass-1 results.
1051        let v_res = match self.scroll_bar_style {
1052            ScrollBarMode::Permanent if show_v_1 => sb_thickness,
1053            _ => 0.0,
1054        };
1055        let h_res = match self.scroll_bar_style {
1056            ScrollBarMode::Permanent if show_h_1 => sb_thickness,
1057            _ => 0.0,
1058        };
1059
1060        // Pass 2: re-measure if reservations changed, and re-evaluate cross-axis.
1061        let vp_h_after_h = (bounds.height - h_res).max(0.0);
1062        let new_needs_v = content_size_1.height > vp_h_after_h + 0.5;
1063        let show_v = resolve_show(self.vertical_policy, has_v, new_needs_v);
1064        let new_v_res = match self.scroll_bar_style {
1065            ScrollBarMode::Permanent if show_v => sb_thickness,
1066            _ => 0.0,
1067        };
1068
1069        let (viewport_width, content_size, show_h) = if (new_v_res - v_res).abs() > 0.01 {
1070            // Vertical reservation changed — re-measure content.
1071            let vp_w2 = (bounds.width - new_v_res).max(0.0);
1072            let cs2 = ctx
1073                .child_size(
1074                    children[0].id,
1075                    SizeProposal {
1076                        width: Some(vp_w2),
1077                        height: None,
1078                    },
1079                )
1080                .unwrap_or(Size::new(vp_w2, bounds.height));
1081            let sh2 = resolve_show(self.horizontal_policy, has_h, cs2.width > vp_w2 + 0.5);
1082            (vp_w2, cs2, sh2)
1083        } else {
1084            (
1085                (bounds.width - new_v_res).max(0.0),
1086                content_size_1,
1087                show_h_1,
1088            )
1089        };
1090
1091        let v_reserved = new_v_res;
1092        let h_reserved = match self.scroll_bar_style {
1093            ScrollBarMode::Permanent if show_h => sb_thickness,
1094            _ => 0.0,
1095        };
1096        let viewport_height = (bounds.height - h_reserved).max(0.0);
1097
1098        // --- Step 1b: widget_resizable — stretch content to fill viewport ---
1099        let placed_content_size = if self.widget_resizable {
1100            Size::new(
1101                content_size.width.max(viewport_width),
1102                content_size.height.max(viewport_height),
1103            )
1104        } else {
1105            content_size
1106        };
1107
1108        // --- Step 2: Update shared reactive state ---
1109        //
1110        // CAUTION: this method mixes layout output with reactive-state writes.
1111        // It is loop-safe today because (a) the `Signal<f32>` metrics below are
1112        // NOT relayout-bound on the ScrollArea itself, and (b) the writes are
1113        // guarded so they only fire on a genuine change. If anyone ever binds
1114        // one of these metrics at `BindingLevel::Relayout` on the ScrollArea,
1115        // it becomes an instant layout loop — bind them on the scrollbar
1116        // children only.
1117        //
1118        // `content_size` / `viewport_size` / `viewport_origin` are `Cell`s, so
1119        // their `set` never notifies — written unconditionally.
1120        self.content_size.set(placed_content_size);
1121        self.viewport_size
1122            .set(Size::new(viewport_width, viewport_height));
1123        self.viewport_origin.set(bounds.origin());
1124        // The rubber band's resistance is a fraction of the viewport, so a tall
1125        // area resists over a longer travel than a short one. This is the only
1126        // pass that knows the number, and the scroller is a `RefCell` precisely
1127        // so `&self` can hand it over.
1128        self.scroller
1129            .borrow_mut()
1130            .set_viewport(Vec2::new(viewport_width, viewport_height));
1131
1132        // The scrollbar children bind these `Signal<f32>` metrics for thumb
1133        // size/position. `Signal::set` always notifies regardless of whether
1134        // the value changed, so an unconditional write would re-dirty those
1135        // children on every relayout that reaches this node (window resize,
1136        // sibling content change, …) even when the metrics are identical.
1137        // Guard with the same EPSILON pattern as `clamp_and_set_scroll`.
1138        let set_if_changed = |sig: &Signal<f32>, v: f32| {
1139            if (sig.get() - v).abs() > f32::EPSILON {
1140                sig.set(v);
1141            }
1142        };
1143
1144        // Scrolling past the end extends the *range* the user can reach without
1145        // changing the content's height. Everything downstream (the max offsets,
1146        // the thumb proportions) therefore works off this effective height, so
1147        // the scroll bar keeps telling the truth about how far there is to go.
1148        let past_end = (self.scroll_past_end.get().max(0.0)) * viewport_height;
1149        let scrollable_height = placed_content_size.height + past_end;
1150
1151        let max_y = (scrollable_height - viewport_height).max(0.0);
1152        let max_x = (placed_content_size.width - viewport_width).max(0.0);
1153        set_if_changed(&self.max_scroll_y, max_y);
1154        set_if_changed(&self.max_scroll_x, max_x);
1155
1156        let ratio_y = if scrollable_height > 0.0 {
1157            (viewport_height / scrollable_height).clamp(0.0, 1.0)
1158        } else {
1159            1.0
1160        };
1161        let ratio_x = if placed_content_size.width > 0.0 {
1162            (viewport_width / placed_content_size.width).clamp(0.0, 1.0)
1163        } else {
1164            1.0
1165        };
1166        set_if_changed(&self.viewport_ratio_y, ratio_y);
1167        set_if_changed(&self.viewport_ratio_x, ratio_x);
1168
1169        // A pending `restore_scroll_y` lands here, ahead of the ordinary clamp
1170        // below, which is what keeps the restored position from ever being
1171        // visible as a jump from the top.
1172        //
1173        // **It is honoured only once the range is long enough to hold it**, and
1174        // re-applied on every pass until then. A first nonzero range is not the
1175        // same thing as a measured one: a rich text editor reports its
1176        // `min_lines` height until its own content has been typeset, so a page
1177        // holding a long document grows through several passes, and taking the
1178        // offset on the first of them lands it clamped against a document that
1179        // is not there yet. That is not a near miss. Restoring 11560 into a
1180        // range that has reached 500 puts the reader back at the top of a
1181        // chapter they were at the end of, which is indistinguishable from the
1182        // restore never having happened.
1183        //
1184        // **And only for as long as nothing else has moved the scroll.** The
1185        // `on_scroll` handler stands the restore down for a wheel gesture and for
1186        // a `ScrollIntoView`; a scroll bar reaches neither, because it holds a
1187        // clone of `scroll_y` and writes it directly. Comparing against what this
1188        // block last wrote catches every route rather than the two that happen to
1189        // pass through a handler — and without it a pending offset the content is
1190        // *never* long enough to honour is re-asserted for the life of the widget,
1191        // so dragging the thumb away from the clamped bottom is undone on the very
1192        // next layout pass and the reader is pinned there.
1193        //
1194        // `get()` and not `animation_target()`: the only writer that gets this far
1195        // is a plain `set`. A wheel scroll animates, but it has already cleared the
1196        // pending, so an in-flight animation cannot be reached from here.
1197        if let Some(ours) = self.restore_wrote_y.get()
1198            && (self.scroll_y.get() - ours).abs() > f32::EPSILON
1199        {
1200            self.pending_restore_y.set(None);
1201            self.restore_wrote_y.set(None);
1202        }
1203        if let Some(pending) = self.pending_restore_y.get()
1204            && max_y > 0.0
1205        {
1206            let landed = pending.min(max_y);
1207            if (landed - self.scroll_y.get()).abs() > f32::EPSILON {
1208                self.scroll_y.set(landed);
1209            }
1210            if max_y >= pending {
1211                self.pending_restore_y.set(None);
1212                self.restore_wrote_y.set(None);
1213            } else {
1214                // Still short. Remember the clamped landing so the next pass can
1215                // tell "the content has not grown yet" from "the reader has moved".
1216                self.restore_wrote_y.set(Some(landed));
1217            }
1218        }
1219
1220        self.clamp_and_set_scroll();
1221        let scroll_y = self.scroll_y.get();
1222        let scroll_x = self.scroll_x.get();
1223
1224        // --- Step 3: Place content ---
1225        // RTL: anchor the content at the trailing (right) edge of the
1226        // bounds. With `scroll_x = 0` and content narrower than the
1227        // viewport, this puts the content flush-right — matching how
1228        // the surrounding RTL-aware stacks place their children.
1229        // Without this mirror, narrow content sits flush-left in both
1230        // directions (visible on widget-catalog tabs whose demos have
1231        // intrinsic widths smaller than the scroll viewport).
1232        let content_x = if ctx.is_rtl() {
1233            bounds.right() - placed_content_size.width + scroll_x
1234        } else {
1235            bounds.x - scroll_x
1236        };
1237        children[0].origin = Point::new(content_x, bounds.y - scroll_y);
1238        children[0].size = placed_content_size;
1239
1240        // --- Step 4: Place vertical scrollbar ---
1241        if has_v {
1242            if show_v {
1243                let sb_x = if ctx.is_rtl() {
1244                    bounds.x
1245                } else {
1246                    bounds.right() - sb_thickness
1247                };
1248                let sb_h = if h_reserved > 0.0
1249                    || (matches!(
1250                        self.scroll_bar_style,
1251                        ScrollBarMode::Overlay | ScrollBarMode::Thin
1252                    ) && show_h)
1253                {
1254                    bounds.height - sb_thickness
1255                } else {
1256                    bounds.height
1257                };
1258                children[1].origin = Point::new(sb_x, bounds.y);
1259                children[1].size = Size::new(sb_thickness, sb_h);
1260            } else {
1261                // Collapse hidden scrollbar to zero
1262                children[1].origin = Point::new(bounds.x, bounds.y);
1263                children[1].size = Size::ZERO;
1264            }
1265        }
1266
1267        // --- Step 5: Place horizontal scrollbar ---
1268        if has_h {
1269            if show_h {
1270                let sb_y = bounds.bottom() - sb_thickness;
1271                let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
1272                    bounds.x + sb_thickness
1273                } else {
1274                    bounds.x
1275                };
1276                let sb_w = if v_reserved > 0.0
1277                    || (matches!(
1278                        self.scroll_bar_style,
1279                        ScrollBarMode::Overlay | ScrollBarMode::Thin
1280                    ) && show_v)
1281                {
1282                    bounds.width - sb_thickness
1283                } else {
1284                    bounds.width
1285                };
1286                children[2].origin = Point::new(sb_x, sb_y);
1287                children[2].size = Size::new(sb_w, sb_thickness);
1288            } else {
1289                children[2].origin = Point::new(bounds.x, bounds.y);
1290                children[2].size = Size::ZERO;
1291            }
1292        }
1293    }
1294
1295    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
1296        // ScrollBar child widgets handle all painting in both modes.
1297    }
1298
1299    fn children(&self) -> Vec<WidgetId> {
1300        self.child_ids.clone()
1301    }
1302
1303    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1304        builder.set_role(teksilo_core::accesskit::Role::ScrollView);
1305        builder.inner_mut().set_clips_children();
1306
1307        let scroll_y = self.scroll_y.get();
1308        let scroll_x = self.scroll_x.get();
1309        let max_y = self.max_scroll_y.get();
1310        let max_x = self.max_scroll_x.get();
1311
1312        builder.inner_mut().set_scroll_y(scroll_y as f64);
1313        builder.inner_mut().set_scroll_y_min(0.0);
1314        builder.inner_mut().set_scroll_y_max(max_y as f64);
1315        builder.inner_mut().set_scroll_x(scroll_x as f64);
1316        builder.inner_mut().set_scroll_x_min(0.0);
1317        builder.inner_mut().set_scroll_x_max(max_x as f64);
1318
1319        // Only advertise scroll actions for axes that actually overflow —
1320        // AT uses these to know which directions are available.
1321        if max_y > 0.0 {
1322            if scroll_y < max_y {
1323                builder.add_action(teksilo_core::accesskit::Action::ScrollDown);
1324            }
1325            if scroll_y > 0.0 {
1326                builder.add_action(teksilo_core::accesskit::Action::ScrollUp);
1327            }
1328        }
1329        if max_x > 0.0 {
1330            if scroll_x < max_x {
1331                builder.add_action(teksilo_core::accesskit::Action::ScrollRight);
1332            }
1333            if scroll_x > 0.0 {
1334                builder.add_action(teksilo_core::accesskit::Action::ScrollLeft);
1335            }
1336        }
1337    }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342    use super::*;
1343    use teksilo_canvas::SizeProposal;
1344    use teksilo_core::event::ScrollDelta;
1345    use teksilo_core::widget::LayoutContext;
1346    use teksilo_core::widget_tree::WidgetTree;
1347
1348    use teksilo_core::widget_builder::WidgetBuilder;
1349
1350    use crate::primitives::VStack;
1351
1352    /// A leaf widget with a fixed intrinsic size.
1353    #[derive(Debug)]
1354    struct TallLeaf {
1355        width: f32,
1356        height: f32,
1357    }
1358
1359    impl TallLeaf {
1360        fn new(w: f32, h: f32) -> Self {
1361            Self {
1362                width: w,
1363                height: h,
1364            }
1365        }
1366    }
1367
1368    impl Widget for TallLeaf {
1369        fn layout_response(
1370            &self,
1371            proposal: SizeProposal,
1372            _ctx: &LayoutContext,
1373        ) -> teksilo_core::widget::LayoutResponse {
1374            Size::new(
1375                proposal.width.unwrap_or(self.width),
1376                proposal.height.unwrap_or(self.height),
1377            )
1378            .into()
1379        }
1380    }
1381
1382    /// A leaf whose intrinsic height can change between layout passes, standing in
1383    /// for a rich text editor: one reports its `min_lines` height until its own
1384    /// content has been typeset, so a page holding a long document grows through
1385    /// several passes rather than arriving at its full height on the first.
1386    #[derive(Debug)]
1387    struct GrowingLeaf {
1388        width: f32,
1389        height: Rc<Cell<f32>>,
1390    }
1391
1392    impl GrowingLeaf {
1393        fn new(w: f32, height: Rc<Cell<f32>>) -> Self {
1394            Self { width: w, height }
1395        }
1396    }
1397
1398    impl Widget for GrowingLeaf {
1399        fn layout_response(
1400            &self,
1401            proposal: SizeProposal,
1402            _ctx: &LayoutContext,
1403        ) -> teksilo_core::widget::LayoutResponse {
1404            Size::new(
1405                proposal.width.unwrap_or(self.width),
1406                proposal.height.unwrap_or(self.height.get()),
1407            )
1408            .into()
1409        }
1410    }
1411
1412    #[test]
1413    fn scroll_area_clips_hit_test() {
1414        let mut tree = WidgetTree::new();
1415
1416        // Content taller than viewport: 3 items x 100px = 300px
1417        let a = tree.add(TallLeaf::new(200.0, 100.0));
1418        let b = tree.add(TallLeaf::new(200.0, 100.0));
1419        let c = tree.add(TallLeaf::new(200.0, 100.0));
1420        let content = tree.add(VStack::new().child(a).child(b).child(c));
1421
1422        let scroll = tree.add(ScrollArea::from_id(content));
1423
1424        // Viewport is 200x80 — only first 80px visible
1425        tree.layout(SizeProposal::exact(200.0, 80.0));
1426
1427        // Point inside viewport: should hit a child
1428        let hit = tree.hit_test(Point::new(50.0, 40.0));
1429        assert!(hit.is_some());
1430
1431        // Point outside viewport (below): should not hit any child
1432        let hit_outside = tree.hit_test(Point::new(50.0, 100.0));
1433        // This point is outside the scroll area's 80px bounds
1434        assert!(hit_outside.is_none() || hit_outside == Some(scroll));
1435    }
1436
1437    #[test]
1438    fn scroll_changes_visible_content() {
1439        let mut tree = WidgetTree::new();
1440
1441        let a = tree.add(TallLeaf::new(200.0, 100.0));
1442        let b = tree.add(TallLeaf::new(200.0, 100.0));
1443        let content = tree.add(VStack::new().child(a).child(b));
1444
1445        let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1446
1447        tree.layout(SizeProposal::exact(200.0, 80.0));
1448
1449        // Before scrolling, item a is at y=0
1450        assert!(tree.bounds(a).y >= 0.0);
1451
1452        // Move pointer into viewport so Scroll events have a target
1453        tree.pointer_move(Point::new(50.0, 40.0));
1454
1455        // Scroll down 100px
1456        tree.dispatch_event(WidgetEvent::scroll(
1457            ScrollDelta::Pixels { x: 0.0, y: 100.0 },
1458            Default::default(),
1459        ));
1460        tree.layout(SizeProposal::exact(200.0, 80.0));
1461
1462        // After scrolling, item a should be above viewport (negative y)
1463        assert!(tree.bounds(a).y < 0.0);
1464        // Item b should now be at or near viewport top
1465        assert!(tree.bounds(b).y < 80.0);
1466    }
1467
1468    #[test]
1469    fn scroll_accessibility_reports_position() {
1470        let mut tree = WidgetTree::new();
1471        let content = tree.add(TallLeaf::new(200.0, 1000.0));
1472        let scroll = tree.add(ScrollArea::from_id(content));
1473
1474        tree.layout(SizeProposal::exact(200.0, 80.0));
1475
1476        let info = tree.accessibility_node(scroll);
1477        assert_eq!(info.role(), teksilo_core::accesskit::Role::ScrollView);
1478    }
1479
1480    #[test]
1481    fn scroll_offset_is_clamped() {
1482        let mut tree = WidgetTree::new();
1483        let content = tree.add(TallLeaf::new(200.0, 200.0));
1484        let _scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
1485
1486        tree.layout(SizeProposal::exact(200.0, 100.0));
1487
1488        // Move pointer into viewport
1489        tree.pointer_move(Point::new(50.0, 50.0));
1490
1491        // Scroll way past the end
1492        tree.dispatch_event(WidgetEvent::scroll(
1493            ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1494            Default::default(),
1495        ));
1496        tree.layout(SizeProposal::exact(200.0, 100.0));
1497
1498        // Content should not be scrolled past max (200 - 100 = 100)
1499        let content_y = tree.bounds(content).y;
1500        assert!(content_y >= -100.0 - 0.01);
1501    }
1502
1503    #[test]
1504    fn permanent_scrollbar_reduces_viewport() {
1505        let mut tree = WidgetTree::new();
1506
1507        let content = TallLeaf::new(200.0, 500.0);
1508        let scroll = tree.add(
1509            ScrollArea::new()
1510                .child(content)
1511                .scroll_bar_style(ScrollBarMode::Permanent)
1512                .scroll_bar_thickness(12.0),
1513        );
1514
1515        tree.layout(SizeProposal::exact(200.0, 100.0));
1516
1517        // The scroll area should be 200x100
1518        let scroll_bounds = tree.bounds(scroll);
1519        assert!((scroll_bounds.width - 200.0).abs() < 0.01);
1520        assert!((scroll_bounds.height - 100.0).abs() < 0.01);
1521    }
1522
1523    #[test]
1524    fn permanent_scrollbar_scroll_event_updates_content() {
1525        let mut tree = WidgetTree::new();
1526
1527        let leaf = TallLeaf::new(180.0, 500.0);
1528        let scroll = tree.add(
1529            ScrollArea::new()
1530                .child(leaf)
1531                .scroll_bar_style(ScrollBarMode::Permanent)
1532                .smooth_scrolling(false),
1533        );
1534
1535        tree.layout(SizeProposal::exact(200.0, 100.0));
1536
1537        // Scroll via mouse wheel
1538        tree.pointer_move(Point::new(50.0, 50.0));
1539        tree.dispatch_event(WidgetEvent::scroll(
1540            ScrollDelta::Pixels { x: 0.0, y: 50.0 },
1541            Default::default(),
1542        ));
1543        tree.layout(SizeProposal::exact(200.0, 100.0));
1544
1545        // The content child should have moved up
1546        let children = tree.children(scroll);
1547        assert!(!children.is_empty());
1548        let content_y = tree.bounds(children[0]).y;
1549        assert!(
1550            content_y < 0.0,
1551            "Expected negative y after scroll, got {}",
1552            content_y
1553        );
1554    }
1555
1556    #[test]
1557    fn overlay_mode_has_scrollbar_children() {
1558        let mut tree = WidgetTree::new();
1559        let content = tree.add(TallLeaf::new(200.0, 500.0));
1560        let scroll = tree.add(ScrollArea::from_id(content));
1561
1562        tree.layout(SizeProposal::exact(200.0, 100.0));
1563
1564        // Overlay mode has 3 children: content + v_scrollbar + h_scrollbar
1565        let children = tree.children(scroll);
1566        assert_eq!(children.len(), 3, "Overlay mode should have 3 children");
1567
1568        // Viewport uses full width (no space reserved for scrollbar)
1569        let content_bounds = tree.bounds(children[0]);
1570        assert!(
1571            (content_bounds.width - 200.0).abs() < 0.01,
1572            "Overlay mode should not shrink viewport"
1573        );
1574    }
1575
1576    #[test]
1577    fn scroll_area_new_accepts_inline_widget() {
1578        let mut tree = WidgetTree::new();
1579        // Test the new API: pass widget directly, not a WidgetId
1580        let scroll = tree.add(ScrollArea::new().child(TallLeaf::new(200.0, 500.0)));
1581
1582        tree.layout(SizeProposal::exact(200.0, 100.0));
1583
1584        let bounds = tree.bounds(scroll);
1585        assert!((bounds.width - 200.0).abs() < 0.01);
1586    }
1587
1588    /// A leaf widget that always reports its intrinsic size, ignoring proposals.
1589    #[derive(Debug)]
1590    struct WideLeaf {
1591        width: f32,
1592        height: f32,
1593    }
1594    impl WideLeaf {
1595        fn new(w: f32, h: f32) -> Self {
1596            Self {
1597                width: w,
1598                height: h,
1599            }
1600        }
1601    }
1602    impl Widget for WideLeaf {
1603        fn layout_response(
1604            &self,
1605            _proposal: SizeProposal,
1606            _ctx: &LayoutContext,
1607        ) -> teksilo_core::widget::LayoutResponse {
1608            Size::new(self.width, self.height).into()
1609        }
1610    }
1611
1612    #[test]
1613    fn permanent_horizontal_scrollbar_present() {
1614        let mut tree = WidgetTree::new();
1615        // Content wider and taller than viewport
1616        let scroll = tree.add(
1617            ScrollArea::new()
1618                .child(WideLeaf::new(400.0, 500.0))
1619                .scroll_bar_style(ScrollBarMode::Permanent)
1620                .scroll_bar_thickness(12.0),
1621        );
1622
1623        tree.layout(SizeProposal::exact(200.0, 100.0));
1624
1625        let children = tree.children(scroll);
1626        assert_eq!(
1627            children.len(),
1628            3,
1629            "Permanent mode should have content + v_sb + h_sb"
1630        );
1631
1632        // Vertical scrollbar: right edge, height = bounds.height - h_sb_thickness
1633        let v_sb = tree.bounds(children[1]);
1634        assert!((v_sb.width - 12.0).abs() < 0.01, "v_sb width should be 12");
1635        assert!((v_sb.x - (200.0 - 12.0)).abs() < 0.01, "v_sb at right edge");
1636        assert!(
1637            (v_sb.height - (100.0 - 12.0)).abs() < 0.01,
1638            "v_sb height reduced by h_sb thickness, got {}",
1639            v_sb.height
1640        );
1641
1642        // Horizontal scrollbar: bottom edge, width = viewport_width
1643        let h_sb = tree.bounds(children[2]);
1644        assert!(
1645            (h_sb.height - 12.0).abs() < 0.01,
1646            "h_sb height should be 12"
1647        );
1648        assert!(
1649            (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1650            "h_sb at bottom edge"
1651        );
1652        assert!(
1653            (h_sb.width - (200.0 - 12.0)).abs() < 0.01,
1654            "h_sb width = bounds.width - v_sb, got {}",
1655            h_sb.width
1656        );
1657    }
1658
1659    #[test]
1660    fn permanent_no_horizontal_when_content_fits() {
1661        let mut tree = WidgetTree::new();
1662        // Content taller but NOT wider than viewport (accounting for v_sb)
1663        let scroll = tree.add(
1664            ScrollArea::new()
1665                .child(TallLeaf::new(180.0, 500.0))
1666                .scroll_bar_style(ScrollBarMode::Permanent)
1667                .scroll_bar_thickness(12.0),
1668        );
1669
1670        tree.layout(SizeProposal::exact(200.0, 100.0));
1671
1672        let children = tree.children(scroll);
1673        assert_eq!(children.len(), 3);
1674
1675        // Horizontal scrollbar still exists as child but max_scroll_x == 0
1676        // so it paints nothing. No space reserved vertically.
1677        let v_sb = tree.bounds(children[1]);
1678        assert!(
1679            (v_sb.height - 100.0).abs() < 0.01,
1680            "v_sb should use full height when no h-scroll needed, got {}",
1681            v_sb.height
1682        );
1683    }
1684
1685    #[test]
1686    fn overlay_scrollbar_does_not_reduce_viewport() {
1687        let mut tree = WidgetTree::new();
1688        let scroll = tree.add(
1689            ScrollArea::new()
1690                .child(WideLeaf::new(400.0, 500.0))
1691                .scroll_bar_style(ScrollBarMode::Overlay),
1692        );
1693
1694        tree.layout(SizeProposal::exact(200.0, 100.0));
1695
1696        let children = tree.children(scroll);
1697        assert_eq!(children.len(), 3);
1698
1699        // Content should use full width (overlay doesn't shrink viewport)
1700        let content = tree.bounds(children[0]);
1701        assert!(
1702            content.width >= 400.0,
1703            "Content should report its full intrinsic width, got {}",
1704            content.width
1705        );
1706
1707        // Vertical scrollbar overlays the right edge (full thickness, paints thin at rest)
1708        let v_sb = tree.bounds(children[1]);
1709        assert!(
1710            (v_sb.width - 12.0).abs() < 0.01,
1711            "Overlay v_sb should have full thickness for hover expansion, got {}",
1712            v_sb.width
1713        );
1714        assert!(
1715            (v_sb.x - (200.0 - 12.0)).abs() < 0.01,
1716            "Overlay v_sb at right edge"
1717        );
1718
1719        // Horizontal scrollbar overlays the bottom edge
1720        let h_sb = tree.bounds(children[2]);
1721        assert!(
1722            (h_sb.height - 12.0).abs() < 0.01,
1723            "Overlay h_sb should have full thickness for hover expansion, got {}",
1724            h_sb.height
1725        );
1726        assert!(
1727            (h_sb.y - (100.0 - 12.0)).abs() < 0.01,
1728            "Overlay h_sb at bottom edge"
1729        );
1730    }
1731
1732    #[test]
1733    fn horizontal_scroll_via_wheel() {
1734        let mut tree = WidgetTree::new();
1735        let scroll = tree.add(
1736            ScrollArea::new()
1737                .child(WideLeaf::new(400.0, 100.0))
1738                .scroll_bar_style(ScrollBarMode::Permanent)
1739                .scroll_bar_thickness(12.0)
1740                .smooth_scrolling(false),
1741        );
1742
1743        tree.layout(SizeProposal::exact(200.0, 100.0));
1744
1745        tree.pointer_move(Point::new(50.0, 50.0));
1746
1747        // Scroll right via horizontal wheel
1748        tree.dispatch_event(WidgetEvent::scroll(
1749            ScrollDelta::Pixels { x: 80.0, y: 0.0 },
1750            Default::default(),
1751        ));
1752        tree.layout(SizeProposal::exact(200.0, 100.0));
1753
1754        // Content should have shifted left
1755        let children = tree.children(scroll);
1756        let content_x = tree.bounds(children[0]).x;
1757        assert!(
1758            content_x < 0.0,
1759            "Expected negative x after h-scroll, got {}",
1760            content_x
1761        );
1762    }
1763
1764    // --- ScrollBarPolicy tests ---
1765
1766    #[test]
1767    fn vertical_scrollbar_always_off_hides_scrollbar() {
1768        let mut tree = WidgetTree::new();
1769        let scroll = tree.add(
1770            ScrollArea::new()
1771                .child(TallLeaf::new(200.0, 500.0))
1772                .scroll_bar_style(ScrollBarMode::Permanent)
1773                .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1774                .scroll_bar_thickness(12.0),
1775        );
1776
1777        tree.layout(SizeProposal::exact(200.0, 100.0));
1778
1779        let children = tree.children(scroll);
1780        // v_scrollbar should be collapsed to zero
1781        let v_sb = tree.bounds(children[1]);
1782        assert!(
1783            (v_sb.width).abs() < 0.01,
1784            "v_sb should be zero-width, got {}",
1785            v_sb.width
1786        );
1787        assert!(
1788            (v_sb.height).abs() < 0.01,
1789            "v_sb should be zero-height, got {}",
1790            v_sb.height
1791        );
1792
1793        // Content should use full width (no space reserved)
1794        let content = tree.bounds(children[0]);
1795        assert!(
1796            (content.width - 200.0).abs() < 0.01,
1797            "Content should use full width when v_sb is off, got {}",
1798            content.width
1799        );
1800    }
1801
1802    #[test]
1803    fn horizontal_scrollbar_always_off_hides_scrollbar() {
1804        let mut tree = WidgetTree::new();
1805        let scroll = tree.add(
1806            ScrollArea::new()
1807                .child(WideLeaf::new(400.0, 500.0))
1808                .scroll_bar_style(ScrollBarMode::Permanent)
1809                .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1810                .scroll_bar_thickness(12.0),
1811        );
1812
1813        tree.layout(SizeProposal::exact(200.0, 100.0));
1814
1815        let children = tree.children(scroll);
1816        // h_scrollbar should be collapsed to zero
1817        let h_sb = tree.bounds(children[2]);
1818        assert!(
1819            (h_sb.width).abs() < 0.01,
1820            "h_sb should be zero-width, got {}",
1821            h_sb.width
1822        );
1823
1824        // v_scrollbar should use full height (no h_sb reservation)
1825        let v_sb = tree.bounds(children[1]);
1826        assert!(
1827            (v_sb.height - 100.0).abs() < 0.01,
1828            "v_sb should use full height when h_sb off, got {}",
1829            v_sb.height
1830        );
1831    }
1832
1833    #[test]
1834    fn scrollbar_always_on_shows_even_when_content_fits() {
1835        let mut tree = WidgetTree::new();
1836        // Content fits in viewport — normally scrollbar would hide
1837        let scroll = tree.add(
1838            ScrollArea::new()
1839                .child(TallLeaf::new(100.0, 50.0))
1840                .scroll_bar_style(ScrollBarMode::Permanent)
1841                .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOn)
1842                .scroll_bar_thickness(12.0),
1843        );
1844
1845        tree.layout(SizeProposal::exact(200.0, 100.0));
1846
1847        let children = tree.children(scroll);
1848        let v_sb = tree.bounds(children[1]);
1849        // Scrollbar should be visible despite content fitting
1850        assert!(
1851            (v_sb.width - 12.0).abs() < 0.01,
1852            "v_sb should be visible (12px) even when content fits, got {}",
1853            v_sb.width
1854        );
1855    }
1856
1857    // --- widget_resizable tests ---
1858
1859    #[test]
1860    fn widget_resizable_stretches_small_content() {
1861        let mut tree = WidgetTree::new();
1862        // Content is 100x50, viewport is 200x100
1863        let scroll = tree.add(
1864            ScrollArea::new()
1865                .child(TallLeaf::new(100.0, 50.0))
1866                .widget_resizable(true),
1867        );
1868
1869        tree.layout(SizeProposal::exact(200.0, 100.0));
1870
1871        let children = tree.children(scroll);
1872        let content = tree.bounds(children[0]);
1873        // Content should be stretched to fill viewport
1874        assert!(
1875            content.width >= 200.0 - 0.01,
1876            "Resizable content width should fill viewport, got {}",
1877            content.width
1878        );
1879        assert!(
1880            content.height >= 100.0 - 0.01,
1881            "Resizable content height should fill viewport, got {}",
1882            content.height
1883        );
1884    }
1885
1886    #[test]
1887    fn widget_resizable_does_not_shrink_large_content() {
1888        let mut tree = WidgetTree::new();
1889        // Content is larger than viewport
1890        let scroll = tree.add(
1891            ScrollArea::new()
1892                .child(WideLeaf::new(400.0, 500.0))
1893                .widget_resizable(true),
1894        );
1895
1896        tree.layout(SizeProposal::exact(200.0, 100.0));
1897
1898        let children = tree.children(scroll);
1899        let content = tree.bounds(children[0]);
1900        assert!(
1901            content.width >= 400.0 - 0.01,
1902            "Large content should not be shrunk, got {}",
1903            content.width
1904        );
1905        assert!(
1906            content.height >= 500.0 - 0.01,
1907            "Large content should not be shrunk, got {}",
1908            content.height
1909        );
1910    }
1911
1912    // --- smooth scrolling tests ---
1913
1914    #[test]
1915    fn smooth_scrolling_line_events_use_animation() {
1916        let mut tree = WidgetTree::new();
1917        let scroll = tree.add(
1918            ScrollArea::new()
1919                .child(TallLeaf::new(200.0, 1000.0))
1920                .smooth_scrolling(true),
1921        );
1922
1923        tree.layout(SizeProposal::exact(200.0, 100.0));
1924
1925        tree.pointer_move(Point::new(50.0, 50.0));
1926
1927        // Scroll via line-based wheel (should animate)
1928        tree.dispatch_event(WidgetEvent::scroll(
1929            ScrollDelta::Lines { x: 0.0, y: 5.0 },
1930            Default::default(),
1931        ));
1932
1933        // The animation target was set but not yet ticked — the state
1934        // should have a pending animation (animate_to marks dirty).
1935        // After a layout + tick, the value should be moving toward the target.
1936        tree.layout(SizeProposal::exact(200.0, 100.0));
1937
1938        // Tick part of the animation
1939        tree.tick_animations(Duration::from_millis(75));
1940        tree.layout(SizeProposal::exact(200.0, 100.0));
1941
1942        let children = tree.children(scroll);
1943        let content_y = tree.bounds(children[0]).y;
1944        // Should have scrolled partially (target = 5 * 20 = 100px)
1945        assert!(
1946            content_y < 0.0,
1947            "Expected partial scroll, got y={}",
1948            content_y
1949        );
1950        assert!(
1951            content_y > -100.0,
1952            "Should not have reached target yet, got y={}",
1953            content_y
1954        );
1955    }
1956
1957    #[test]
1958    fn smooth_scrolling_disabled_jumps_immediately() {
1959        let mut tree = WidgetTree::new();
1960        let scroll = tree.add(
1961            ScrollArea::new()
1962                .child(TallLeaf::new(200.0, 1000.0))
1963                .smooth_scrolling(false),
1964        );
1965
1966        tree.layout(SizeProposal::exact(200.0, 100.0));
1967
1968        tree.pointer_move(Point::new(50.0, 50.0));
1969
1970        tree.dispatch_event(WidgetEvent::scroll(
1971            ScrollDelta::Lines { x: 0.0, y: 5.0 },
1972            Default::default(),
1973        ));
1974        tree.layout(SizeProposal::exact(200.0, 100.0));
1975
1976        let children = tree.children(scroll);
1977        let content_y = tree.bounds(children[0]).y;
1978        // Should jump immediately to target (5 * 20 = 100px)
1979        assert!(
1980            (content_y - (-100.0)).abs() < 0.01,
1981            "Should jump immediately, got y={}",
1982            content_y
1983        );
1984    }
1985
1986    // --- preferred_size tests ---
1987
1988    #[test]
1989    fn preferred_size_overrides_default() {
1990        let mut tree = WidgetTree::new();
1991        let scroll = tree.add(
1992            ScrollArea::new()
1993                .child(TallLeaf::new(200.0, 500.0))
1994                .preferred_size(500.0, 400.0),
1995        );
1996        // With unconstrained proposal, should use preferred size
1997        tree.layout(SizeProposal {
1998            width: None,
1999            height: None,
2000        });
2001        let bounds = tree.bounds(scroll);
2002        assert!(
2003            (bounds.width - 500.0).abs() < 0.01,
2004            "Should use preferred width, got {}",
2005            bounds.width
2006        );
2007        assert!(
2008            (bounds.height - 400.0).abs() < 0.01,
2009            "Should use preferred height, got {}",
2010            bounds.height
2011        );
2012    }
2013
2014    #[test]
2015    fn constrained_proposal_overrides_preferred_size() {
2016        let mut tree = WidgetTree::new();
2017        let scroll = tree.add(
2018            ScrollArea::new()
2019                .child(TallLeaf::new(200.0, 500.0))
2020                .preferred_size(500.0, 400.0),
2021        );
2022        // With constrained proposal, the proposal wins
2023        tree.layout(SizeProposal::exact(200.0, 100.0));
2024        let bounds = tree.bounds(scroll);
2025        assert!((bounds.width - 200.0).abs() < 0.01);
2026        assert!((bounds.height - 100.0).abs() < 0.01);
2027    }
2028
2029    // --- theme/locale rebuild should not reset scroll offset ---
2030
2031    #[test]
2032    fn scroll_survives_theme_switch_at_root() {
2033        let mut tree = WidgetTree::new();
2034        let scroll = tree.add(
2035            ScrollArea::new()
2036                .child(TallLeaf::new(200.0, 500.0))
2037                .smooth_scrolling(false),
2038        );
2039        tree.layout(SizeProposal::exact(200.0, 100.0));
2040
2041        // Scroll partway down
2042        tree.pointer_move(Point::new(50.0, 50.0));
2043        tree.dispatch_event(WidgetEvent::scroll(
2044            ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2045            Default::default(),
2046        ));
2047        tree.layout(SizeProposal::exact(200.0, 100.0));
2048
2049        let content = tree.children(scroll)[0];
2050        let content_y_before = tree.bounds(content).y;
2051        assert!(
2052            content_y_before < -100.0,
2053            "Content should have scrolled; got y={}",
2054            content_y_before
2055        );
2056
2057        // Switch theme — should NOT reset scroll
2058        tree.set_theme(teksilo_core::presets::intui::dark());
2059        tree.layout(SizeProposal::exact(200.0, 100.0));
2060
2061        let content = tree.children(scroll)[0];
2062        let content_y_after = tree.bounds(content).y;
2063        assert!(
2064            (content_y_after - content_y_before).abs() < 0.01,
2065            "Scroll offset should survive theme switch: before={}, after={}",
2066            content_y_before,
2067            content_y_after
2068        );
2069    }
2070
2071    /// Composite parent that wraps a ScrollArea via ctx.add(ScrollArea::new()...).
2072    /// Simulates a typical user widget: its build() runs on every theme change,
2073    /// so a naive ScrollArea::new() inside would lose its scroll offset.
2074    #[derive(Debug)]
2075    struct ScrollParent {
2076        scroll_id: Option<WidgetId>,
2077    }
2078    impl ScrollParent {
2079        fn new() -> Self {
2080            Self { scroll_id: None }
2081        }
2082    }
2083    impl Widget for ScrollParent {
2084        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2085            let id = ctx.add(
2086                ScrollArea::new()
2087                    .child(TallLeaf::new(200.0, 500.0))
2088                    .smooth_scrolling(false),
2089            );
2090            self.scroll_id = Some(id);
2091            vec![id]
2092        }
2093        fn layout_response(
2094            &self,
2095            proposal: SizeProposal,
2096            ctx: &LayoutContext,
2097        ) -> teksilo_core::widget::LayoutResponse {
2098            self.scroll_id
2099                .and_then(|id| ctx.child_size(id, proposal))
2100                .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2101                .into()
2102        }
2103        fn place_children(
2104            &self,
2105            bounds: Rect,
2106            _proposal: SizeProposal,
2107            children: &mut [WidgetPlacement],
2108            _ctx: &LayoutContext,
2109        ) {
2110            if let Some(child) = children.first_mut() {
2111                child.origin = bounds.origin();
2112                child.size = bounds.size();
2113            }
2114        }
2115    }
2116
2117    #[test]
2118    fn scroll_survives_theme_switch_inside_composite() {
2119        let mut tree = WidgetTree::new();
2120        let parent = tree.add(ScrollParent::new());
2121        tree.layout(SizeProposal::exact(200.0, 100.0));
2122
2123        tree.pointer_move(Point::new(50.0, 50.0));
2124        tree.dispatch_event(WidgetEvent::scroll(
2125            ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2126            Default::default(),
2127        ));
2128        tree.layout(SizeProposal::exact(200.0, 100.0));
2129
2130        let scroll_before = tree.children(parent)[0];
2131        let content_before = tree.children(scroll_before)[0];
2132        let y_before = tree.bounds(content_before).y;
2133        assert!(
2134            y_before < -100.0,
2135            "Content should have scrolled; got y={}",
2136            y_before
2137        );
2138
2139        tree.set_theme(teksilo_core::presets::intui::dark());
2140        tree.layout(SizeProposal::exact(200.0, 100.0));
2141
2142        let scroll_after = tree.children(parent)[0];
2143        let content_after = tree.children(scroll_after)[0];
2144        let y_after = tree.bounds(content_after).y;
2145        assert!(
2146            (y_after - y_before).abs() < 0.01,
2147            "Scroll offset should survive theme switch inside composite: before={}, after={}",
2148            y_before,
2149            y_after
2150        );
2151    }
2152
2153    // --- ScrollIntoView regression: focused widget above viewport ---
2154
2155    /// Regression: when the focused widget is above the viewport top and
2156    /// the ScrollArea is itself offset from the tree origin, focusing the
2157    /// widget should scroll *up* (decreasing scroll_y) to bring it back
2158    /// into view — not *down*. The earlier implementation treated
2159    /// `target_bounds` as if it were already viewport-relative and added
2160    /// `scroll_y` to it, which scrolled past the widget when the
2161    /// ScrollArea was not at absolute (0, 0). Cloning a `Cell` also
2162    /// produces an independent cell, so the closure was reading a stale
2163    /// `viewport_size = Size::ZERO`; both must be fixed for the math to
2164    /// produce the right answer.
2165    #[test]
2166    fn scroll_into_view_brings_widget_above_viewport_into_view() {
2167        let mut tree = WidgetTree::new();
2168
2169        // Layout: VStack { 50px header, ScrollArea(content 500px) }.
2170        // Total height 250 → ScrollArea bounds.y = 50 (the offset that
2171        // previously triggered the bug).
2172        let header = tree.add(TallLeaf::new(200.0, 50.0));
2173        // Focusable target near the top of the content.
2174        let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2175        let after = tree.add(TallLeaf::new(200.0, 470.0));
2176        let content = tree.add(VStack::new().child(target).child(after));
2177        let scroll = tree.add(ScrollArea::from_id(content).smooth_scrolling(false));
2178        let _root = tree.add(VStack::new().child(header).child(scroll));
2179
2180        tree.layout(SizeProposal::exact(200.0, 250.0));
2181
2182        let scroll_bounds = tree.bounds(scroll);
2183        assert!(
2184            (scroll_bounds.y - 50.0).abs() < 0.01,
2185            "ScrollArea should sit below the header at y=50, got {}",
2186            scroll_bounds.y
2187        );
2188
2189        // Scroll down so the target is well above the viewport top.
2190        tree.pointer_move(Point::new(100.0, 100.0));
2191        tree.dispatch_event(WidgetEvent::scroll(
2192            ScrollDelta::Pixels { x: 0.0, y: 150.0 },
2193            Default::default(),
2194        ));
2195        tree.layout(SizeProposal::exact(200.0, 250.0));
2196
2197        let target_before = tree.bounds(target);
2198        assert!(
2199            target_before.bottom() < scroll_bounds.y,
2200            "Target should be above viewport before focus, got y={} (viewport top={})",
2201            target_before.y,
2202            scroll_bounds.y
2203        );
2204
2205        // Focus the target — fires ScrollIntoView, should bring the
2206        // widget back into view rather than push it further away.
2207        tree.focus(target);
2208        tree.layout(SizeProposal::exact(200.0, 250.0));
2209
2210        let target_after = tree.bounds(target);
2211        let viewport_top = scroll_bounds.y;
2212        let viewport_bottom = scroll_bounds.bottom();
2213        assert!(
2214            target_after.y >= viewport_top - 0.5 && target_after.bottom() <= viewport_bottom + 0.5,
2215            "Target should be inside viewport after focus, got y={}..{} (viewport={}..{})",
2216            target_after.y,
2217            target_after.bottom(),
2218            viewport_top,
2219            viewport_bottom
2220        );
2221    }
2222
2223    // --- Typewriter scrolling: alignment + scroll-past-end ------------------
2224
2225    /// A `ScrollArea` over `content_h` of content, laid out at `200 x
2226    /// viewport_h`, with a focused child inside it that issues an aligned
2227    /// reveal when it receives a key.
2228    ///
2229    /// The request goes through the real path — `EventContext` →
2230    /// `collect_from_ctx` → the clipping-ancestor walk → the area's handler —
2231    /// because `ScrollIntoView` is deliberately inert in the top-level event
2232    /// router and only ever arrives that way.
2233    struct PinFixture {
2234        tree: WidgetTree,
2235        bounds: Rect,
2236        viewport_h: f32,
2237        scroll_y: Signal<f32>,
2238        max_scroll_y: Signal<f32>,
2239        ratio_y: Signal<f32>,
2240        /// The rect the actor will ask to have pinned, in window space, plus the
2241        /// fraction to pin it at. Rewritten before each key.
2242        request: Rc<Cell<(Rect, f32)>>,
2243    }
2244
2245    fn pin_fixture(content_h: f32, viewport_h: f32, past_end: f32) -> PinFixture {
2246        let request = Rc::new(Cell::new((Rect::new(0.0, 0.0, 0.0, 0.0), 0.5)));
2247        let mut tree = WidgetTree::new();
2248
2249        let req = request.clone();
2250        let actor = tree.add(TallLeaf::new(200.0, content_h).focusable(true).on_key(
2251            move |_ev, ctx| {
2252                let (rect, fraction) = req.get();
2253                ctx.ensure_visible_aligned(
2254                    rect,
2255                    fraction,
2256                    teksilo_core::event::ScrollMotion::Instant,
2257                );
2258                EventResponse::Handled
2259            },
2260        ));
2261        let content = tree.add(VStack::new().child(actor));
2262        let sa = ScrollArea::from_id(content)
2263            .smooth_scrolling(false)
2264            .scroll_past_end(past_end);
2265        let scroll_y = sa.scroll_y_signal().clone();
2266        let max_scroll_y = sa.max_scroll_y_signal().clone();
2267        let ratio_y = sa.viewport_ratio_y_signal().clone();
2268        let scroll = tree.add(sa);
2269        tree.layout(SizeProposal::exact(200.0, viewport_h));
2270        tree.focus(actor);
2271        // Focusing fires a Minimal reveal of the (viewport-sized) actor; settle
2272        // it before the tests measure.
2273        tree.layout(SizeProposal::exact(200.0, viewport_h));
2274        scroll_y.set(0.0);
2275
2276        let bounds = tree.bounds(scroll);
2277        PinFixture {
2278            tree,
2279            bounds,
2280            viewport_h,
2281            scroll_y,
2282            max_scroll_y,
2283            ratio_y,
2284            request,
2285        }
2286    }
2287
2288    impl PinFixture {
2289        /// Pin a `height`-tall line whose top sits at `content_y` in content
2290        /// space, at `fraction` of the viewport.
2291        fn pin(&mut self, content_y: f32, height: f32, fraction: f32) {
2292            let window_y = self.bounds.y + content_y - self.scroll_y.get();
2293            self.request
2294                .set((Rect::new(0.0, window_y, 200.0, height), fraction));
2295            self.tree.dispatch_event(WidgetEvent::KeyDown {
2296                key: teksilo_core::event::Key::ArrowDown,
2297                modifiers: Default::default(),
2298                text: None,
2299            });
2300            self.tree
2301                .layout(SizeProposal::exact(200.0, self.viewport_h));
2302        }
2303    }
2304
2305    #[test]
2306    fn scroll_past_end_extends_the_range_without_changing_the_content() {
2307        // 300px of content in a 100px viewport scrolls 200px normally.
2308        let plain = pin_fixture(300.0, 100.0, 0.0);
2309        assert_eq!(plain.max_scroll_y.get(), 200.0);
2310
2311        // Half a viewport past the end buys exactly 50px more.
2312        let padded = pin_fixture(300.0, 100.0, 0.5);
2313        assert_eq!(
2314            padded.max_scroll_y.get(),
2315            250.0,
2316            "scroll_past_end(0.5) must add half a viewport of range"
2317        );
2318    }
2319
2320    #[test]
2321    fn scroll_past_end_keeps_the_thumb_proportional() {
2322        // The thumb must size against the range the user can actually travel,
2323        // or the scroll bar claims there is less document left than there is.
2324        let f = pin_fixture(300.0, 100.0, 0.5);
2325        // Effective scrollable height is 300 + 50 = 350.
2326        let expected = 100.0 / 350.0;
2327        assert!(
2328            (f.ratio_y.get() - expected).abs() < 1e-4,
2329            "thumb ratio must use the extended range, got {}",
2330            f.ratio_y.get()
2331        );
2332    }
2333
2334    #[test]
2335    fn scroll_past_end_lets_the_last_line_reach_a_centre_pin() {
2336        // The case that motivates the feature: a line at the very bottom of the
2337        // content cannot reach the middle of the viewport without range past
2338        // the end — and that is exactly where a writer spends their time.
2339        let mut f = pin_fixture(300.0, 100.0, 0.5);
2340        f.pin(280.0, 20.0, 0.5);
2341
2342        // Centring a 20px line in a 100px viewport puts its top at 40px, so the
2343        // offset must be 280 - 40 = 240 — reachable only because
2344        // scroll_past_end(0.5) raised the maximum from 200 to 250.
2345        assert_eq!(
2346            f.scroll_y.get(),
2347            240.0,
2348            "the last line must be able to sit at the pin"
2349        );
2350    }
2351
2352    #[test]
2353    fn without_scroll_past_end_the_last_line_cannot_reach_the_pin() {
2354        // The negative control for the test above: same geometry, no extra
2355        // range, so the pin is clamped short and the line stays at the bottom.
2356        let mut f = pin_fixture(300.0, 100.0, 0.0);
2357        f.pin(280.0, 20.0, 0.5);
2358        assert_eq!(
2359            f.scroll_y.get(),
2360            200.0,
2361            "clamped to the un-extended maximum"
2362        );
2363    }
2364
2365    #[test]
2366    fn a_pin_near_the_document_start_clamps_instead_of_scrolling_negative() {
2367        // Deliberate design choice: no padding above the content, so the caret
2368        // rides above the pin until there is room to honour it.
2369        let mut f = pin_fixture(300.0, 100.0, 0.5);
2370        f.pin(0.0, 20.0, 0.5);
2371        assert_eq!(
2372            f.scroll_y.get(),
2373            0.0,
2374            "the first line must clamp at the top, never scroll past it"
2375        );
2376    }
2377
2378    #[test]
2379    fn a_fraction_pin_places_the_target_at_that_height() {
2380        // 0.25 → the target's top sits a quarter of the way down the free space.
2381        let mut f = pin_fixture(600.0, 100.0, 0.0);
2382        f.pin(300.0, 20.0, 0.25);
2383        // Free space = 100 - 20 = 80; a quarter of that is 20 → offset 280.
2384        assert_eq!(f.scroll_y.get(), 280.0);
2385    }
2386
2387    #[test]
2388    fn a_pin_re_asserts_on_an_already_visible_target() {
2389        // The property that separates a pin from a reveal. Park the view so the
2390        // target is comfortably on screen, then pin it and check the view still
2391        // moved to put it exactly on the mark.
2392        let mut f = pin_fixture(600.0, 100.0, 0.0);
2393        f.scroll_y.set(250.0);
2394        f.tree.layout(SizeProposal::exact(200.0, 100.0));
2395
2396        // Content-space 300 is visible at offset 250 (50px down the viewport).
2397        f.pin(300.0, 20.0, 0.5);
2398
2399        assert_eq!(
2400            f.scroll_y.get(),
2401            260.0,
2402            "a pin must move an already-visible target onto the mark"
2403        );
2404    }
2405
2406    #[test]
2407    fn scroll_into_view_reveals_target_through_two_nested_scroll_areas() {
2408        // A focusable target sits deep inside an INNER ScrollArea, which is
2409        // itself below the fold of an OUTER ScrollArea — both must scroll to
2410        // reveal it. The inner reports its applied scroll through the
2411        // `applied_scroll` back-channel so the outer targets where the child
2412        // *lands* (post-inner-scroll), not its stale pre-scroll position. The
2413        // end-to-end check is that the target is actually visible after one pass.
2414        use crate::primitives::FixedSize;
2415
2416        let mut tree = WidgetTree::new();
2417        // Inner content: 200px spacer, the 20px target, 100px tail → 320px.
2418        let target = tree.add(TallLeaf::new(200.0, 20.0).focusable(true));
2419        let inner_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2420        let inner_tail = tree.add(TallLeaf::new(200.0, 100.0));
2421        let inner_content = tree.add(
2422            VStack::new()
2423                .child(inner_spacer)
2424                .child(target)
2425                .child(inner_tail),
2426        );
2427        let inner_sa = tree.add(ScrollArea::from_id(inner_content).smooth_scrolling(false));
2428        // Bound the inner ScrollArea to an 80px viewport.
2429        let inner_box = tree.add(FixedSize::new().width(200.0).height(80.0).child(inner_sa));
2430        // Outer content: 200px spacer, the inner box (below the fold), 200px tail.
2431        let outer_spacer = tree.add(TallLeaf::new(200.0, 200.0));
2432        let outer_tail = tree.add(TallLeaf::new(200.0, 200.0));
2433        let outer_content = tree.add(
2434            VStack::new()
2435                .child(outer_spacer)
2436                .child(inner_box)
2437                .child(outer_tail),
2438        );
2439        let outer_sa = tree.add(ScrollArea::from_id(outer_content).smooth_scrolling(false));
2440
2441        // Outer viewport is 100px tall; the inner box starts at y≈200 → below it.
2442        let sz = SizeProposal::exact(200.0, 100.0);
2443        tree.layout(sz);
2444
2445        // Focus the deeply-nested target → walks both ScrollAreas.
2446        tree.focus(target);
2447        tree.layout(sz);
2448
2449        let outer_bounds = tree.bounds(outer_sa);
2450        let t = tree.bounds(target);
2451        assert!(
2452            t.y >= outer_bounds.y - 1.0 && t.bottom() <= outer_bounds.bottom() + 1.0,
2453            "target must be visible in the outer window after both scroll: target y={}..{}, \
2454             outer viewport {}..{}",
2455            t.y,
2456            t.bottom(),
2457            outer_bounds.y,
2458            outer_bounds.bottom()
2459        );
2460    }
2461
2462    /// A leaf with a fixed intrinsic size that ignores the proposal —
2463    /// needed to test ScrollArea behavior with content narrower than
2464    /// the viewport. `TallLeaf` accepts the proposed width, which would
2465    /// always make content match viewport width and hide RTL bugs.
2466    #[derive(Debug)]
2467    struct FixedLeaf(f32, f32);
2468    impl Widget for FixedLeaf {
2469        fn layout_response(
2470            &self,
2471            _proposal: SizeProposal,
2472            _ctx: &LayoutContext,
2473        ) -> teksilo_core::widget::LayoutResponse {
2474            Size::new(self.0, self.1).into()
2475        }
2476    }
2477
2478    #[test]
2479    fn rtl_anchors_narrow_content_to_trailing_edge() {
2480        // Reproduces the widget-catalog "tab content pushed left in RTL"
2481        // bug: a ScrollArea wrapping content narrower than the viewport
2482        // used to place the content at bounds.x in both directions.
2483        let mut tree = WidgetTree::new();
2484        let content = tree.add(FixedLeaf(120.0, 80.0));
2485        let _scroll = tree.add(ScrollArea::from_id(content));
2486
2487        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2488        tree.layout(SizeProposal::exact(400.0, 200.0));
2489
2490        let cb = tree.bounds(content);
2491        assert!(
2492            (cb.x - (400.0 - 120.0)).abs() < 0.01,
2493            "RTL content should be flush-right at x=280, got {}",
2494            cb.x
2495        );
2496    }
2497
2498    #[test]
2499    fn ltr_anchors_narrow_content_to_leading_edge() {
2500        let mut tree = WidgetTree::new();
2501        let content = tree.add(FixedLeaf(120.0, 80.0));
2502        let _scroll = tree.add(ScrollArea::from_id(content));
2503
2504        tree.layout(SizeProposal::exact(400.0, 200.0));
2505
2506        let cb = tree.bounds(content);
2507        assert!(
2508            cb.x.abs() < 0.01,
2509            "LTR content should be flush-left at x=0, got {}",
2510            cb.x
2511        );
2512    }
2513
2514    /// Build an outer ScrollArea whose content is `[inner ScrollArea (100px
2515    /// viewport, 300px content), 200px filler]` in a 150px outer viewport.
2516    /// Returns `(tree, inner_scroll_y, outer_scroll_y)`.
2517    fn nested_scroll_fixture(
2518        inner_overscroll: OverscrollBehavior,
2519    ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
2520        let mut tree = WidgetTree::new();
2521
2522        let inner_content = tree.add(TallLeaf::new(200.0, 300.0));
2523        let inner_sa = ScrollArea::from_id(inner_content)
2524            .smooth_scrolling(false)
2525            .preferred_size(200.0, 100.0)
2526            .overscroll_behavior(inner_overscroll);
2527        let inner_y = inner_sa.scroll_y_signal().clone();
2528        let inner = tree.add(inner_sa);
2529
2530        let filler = tree.add(TallLeaf::new(200.0, 200.0));
2531        let outer_content = tree.add(VStack::new().child(inner).child(filler));
2532        let outer_sa = ScrollArea::from_id(outer_content).smooth_scrolling(false);
2533        let outer_y = outer_sa.scroll_y_signal().clone();
2534        let _outer = tree.add(outer_sa);
2535
2536        tree.layout(SizeProposal::exact(200.0, 150.0));
2537        (tree, inner_y, outer_y)
2538    }
2539
2540    #[test]
2541    fn nested_scroll_chains_to_outer_at_boundary() {
2542        let (mut tree, inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Chain);
2543
2544        // Pointer over the inner viewport, then scroll the inner to its bottom.
2545        tree.pointer_move(Point::new(50.0, 40.0));
2546        tree.dispatch_event(WidgetEvent::scroll(
2547            ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2548            Default::default(),
2549        ));
2550        tree.layout(SizeProposal::exact(200.0, 150.0));
2551
2552        let inner_bottom = inner_y.get();
2553        assert!(inner_bottom > 0.0, "inner should have scrolled down");
2554        assert!(
2555            outer_y.get() < 0.01,
2556            "outer must not move while the inner still absorbs the scroll"
2557        );
2558
2559        // Another downward scroll: inner is clamped → the event chains to outer.
2560        tree.pointer_move(Point::new(50.0, 40.0));
2561        tree.dispatch_event(WidgetEvent::scroll(
2562            ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2563            Default::default(),
2564        ));
2565        tree.layout(SizeProposal::exact(200.0, 150.0));
2566
2567        assert!(
2568            (inner_y.get() - inner_bottom).abs() < 0.01,
2569            "inner stays clamped at its bottom"
2570        );
2571        assert!(
2572            outer_y.get() > 0.01,
2573            "outer scrolled because the inner chained the boundary scroll"
2574        );
2575    }
2576
2577    #[test]
2578    fn contain_blocks_scroll_chaining() {
2579        let (mut tree, _inner_y, outer_y) = nested_scroll_fixture(OverscrollBehavior::Contain);
2580
2581        tree.pointer_move(Point::new(50.0, 40.0));
2582        tree.dispatch_event(WidgetEvent::scroll(
2583            ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
2584            Default::default(),
2585        ));
2586        tree.layout(SizeProposal::exact(200.0, 150.0));
2587
2588        // Inner at bottom + Contain → a further scroll is absorbed, not chained.
2589        tree.pointer_move(Point::new(50.0, 40.0));
2590        tree.dispatch_event(WidgetEvent::scroll(
2591            ScrollDelta::Pixels { x: 0.0, y: 100.0 },
2592            Default::default(),
2593        ));
2594        tree.layout(SizeProposal::exact(200.0, 150.0));
2595
2596        assert!(
2597            outer_y.get() < 0.01,
2598            "Contain must prevent chaining: outer stays put"
2599        );
2600    }
2601
2602    // --- F6: `layout_response` must only pay for the unbounded natural-width
2603    // measure when the incoming proposal can actually use it ---
2604
2605    /// A leaf widget that records every `SizeProposal` it's laid out at, in
2606    /// addition to behaving like [`TallLeaf`] (reports `self.width`/`self.height`
2607    /// whenever the proposal leaves that axis unspecified).
2608    #[derive(Debug)]
2609    struct RecordingLeaf {
2610        width: f32,
2611        height: f32,
2612        log: Rc<std::cell::RefCell<Vec<SizeProposal>>>,
2613    }
2614
2615    impl Widget for RecordingLeaf {
2616        fn layout_response(
2617            &self,
2618            proposal: SizeProposal,
2619            _ctx: &LayoutContext,
2620        ) -> teksilo_core::widget::LayoutResponse {
2621            self.log.borrow_mut().push(proposal);
2622            Size::new(
2623                proposal.width.unwrap_or(self.width),
2624                proposal.height.unwrap_or(self.height),
2625            )
2626            .into()
2627        }
2628    }
2629
2630    #[test]
2631    fn preferred_height_reports_natural_width_when_parent_proposes_unbounded() {
2632        // Mirrors `menu_list.rs`: preferred_height set, preferred_size unset,
2633        // content wider than the old hardcoded 300px fallback.
2634        let mut tree = WidgetTree::new();
2635        let content = tree.add(TallLeaf::new(392.0, 500.0));
2636        let scroll = tree.add(ScrollArea::from_id(content).preferred_height(150.0));
2637
2638        // Mirrors the popover's own intrinsic-sizing pass: unbounded width.
2639        tree.layout(SizeProposal {
2640            width: None,
2641            height: None,
2642        });
2643
2644        let bounds = tree.bounds(scroll);
2645        assert!(
2646            (bounds.width - 392.0).abs() < 0.01,
2647            "should report the content's real natural width, got {}",
2648            bounds.width
2649        );
2650        assert!(
2651            (bounds.height - 150.0).abs() < 0.01,
2652            "should still cap the height at preferred_height, got {}",
2653            bounds.height
2654        );
2655    }
2656
2657    #[test]
2658    fn bounded_proposal_never_triggers_an_unbounded_content_query() {
2659        // Plain ScrollArea: neither preferred_size nor preferred_height set.
2660        let log: Rc<std::cell::RefCell<Vec<SizeProposal>>> =
2661            Rc::new(std::cell::RefCell::new(Vec::new()));
2662        let mut tree = WidgetTree::new();
2663        let content = tree.add(RecordingLeaf {
2664            width: 900.0,
2665            height: 500.0,
2666            log: log.clone(),
2667        });
2668        tree.add(ScrollArea::from_id(content));
2669
2670        // A real parent already bounds the width — the overwhelmingly common case.
2671        tree.layout(SizeProposal::exact(300.0, 100.0));
2672
2673        let recorded = log.borrow();
2674        assert!(!recorded.is_empty(), "content widget was never laid out");
2675        for proposal in recorded.iter() {
2676            assert!(
2677                proposal.width.is_some(),
2678                "content queried with an unbounded width ({:?}) even though the \
2679                 incoming proposal was already bounded — the unbounded natural-width \
2680                 measure must only run when `proposal.width` is `None`",
2681                proposal
2682            );
2683        }
2684    }
2685
2686    #[test]
2687    fn exact_proposal_still_wins_over_natural_width() {
2688        // No preferred_size / preferred_height: a bounded proposal must still
2689        // resolve to the proposal's own size, not the content's natural size.
2690        let mut tree = WidgetTree::new();
2691        let content = tree.add(TallLeaf::new(900.0, 500.0));
2692        let scroll = tree.add(ScrollArea::from_id(content));
2693
2694        tree.layout(SizeProposal::exact(300.0, 100.0));
2695
2696        let bounds = tree.bounds(scroll);
2697        assert!(
2698            (bounds.width - 300.0).abs() < 0.01,
2699            "exact proposal must win over the content's natural width, got {}",
2700            bounds.width
2701        );
2702        assert!(
2703            (bounds.height - 100.0).abs() < 0.01,
2704            "exact proposal must win over the content's natural height, got {}",
2705            bounds.height
2706        );
2707    }
2708
2709    /// A rigid row wider than the viewport, nested inside a `VStack`, must be
2710    /// reachable by scrolling horizontally.
2711    ///
2712    /// Regression for the cross-axis over-claim asymmetry: `negotiate` used to
2713    /// end with `self_cross = cross_extent.unwrap_or(self_cross)`, discarding
2714    /// the larger natural max it had already computed. A `VStack` in a 560 dp
2715    /// slot holding an 800 dp `HStack` reported 560, so this `ScrollArea` —
2716    /// which measures content by proposing the viewport width and reading the
2717    /// size back — concluded "no overflow", showed no horizontal bar, and
2718    /// `clips_children` swallowed the excess. The 4th cell sat at x=620..820 in
2719    /// a 600 dp viewport and was unreachable at *any* scroll position.
2720    ///
2721    /// The same row placed DIRECTLY under the `ScrollArea` always scrolled;
2722    /// only the intervening stack broke it, which is what made this so easy to
2723    /// miss.
2724    #[test]
2725    fn cross_axis_overflow_through_a_vstack_is_scrollable() {
2726        use crate::primitives::{HStack, Padding};
2727
2728        let mut tree = WidgetTree::new();
2729        // 4 x 200 dp rigid cells = 800 dp of content in a 600 dp viewport.
2730        let cells: Vec<_> = (0..4)
2731            .map(|_| tree.add(TallLeaf::new(200.0, 40.0)))
2732            .collect();
2733        let mut row = HStack::new();
2734        for &c in &cells {
2735            row = row.child(c);
2736        }
2737        let row = tree.add(row);
2738        let col = tree.add(VStack::new().child(row));
2739        let padded = tree.add(Padding::uniform(20.0).child(col));
2740        let _scroll = tree.add(ScrollArea::from_id(padded).smooth_scrolling(false));
2741
2742        tree.layout(SizeProposal::exact(600.0, 400.0));
2743
2744        let last = *cells.last().unwrap();
2745        assert!(
2746            tree.bounds(last).x > 600.0,
2747            "precondition: the 4th cell should start beyond the viewport, got x={}",
2748            tree.bounds(last).x
2749        );
2750
2751        // Scroll right far enough to bring the last cell fully into view.
2752        tree.pointer_move(Point::new(300.0, 40.0));
2753        tree.dispatch_event(WidgetEvent::scroll(
2754            ScrollDelta::Pixels { x: 300.0, y: 0.0 },
2755            Default::default(),
2756        ));
2757        tree.layout(SizeProposal::exact(600.0, 400.0));
2758
2759        let b = tree.bounds(last);
2760        assert!(
2761            b.x >= 0.0 && b.x + b.width <= 600.5,
2762            "the 4th cell must be reachable by horizontal scrolling; got x={} w={}",
2763            b.x,
2764            b.width
2765        );
2766    }
2767
2768    // --- restore_scroll_y: landing a caret-restore offset before the first
2769    // --- clamp would otherwise destroy it -----------------------------------
2770
2771    #[test]
2772    fn restore_scroll_y_lands_on_the_first_measured_layout() {
2773        // 500px of content in a 100px viewport: max_scroll_y ends up 400.
2774        let mut tree = WidgetTree::new();
2775        let sa = ScrollArea::new()
2776            .child(TallLeaf::new(200.0, 500.0))
2777            .smooth_scrolling(false)
2778            .restore_scroll_y(150.0);
2779        let scroll_y = sa.scroll_y_signal().clone();
2780        let max_scroll_y = sa.max_scroll_y_signal().clone();
2781        let _scroll = tree.add(sa);
2782
2783        // The very first layout pass is also the first at which the content
2784        // is measured, so the restore must already have landed by the time
2785        // this call returns; there is no earlier frame to have painted at 0.
2786        tree.layout(SizeProposal::exact(200.0, 100.0));
2787
2788        assert_eq!(max_scroll_y.get(), 400.0);
2789        assert_eq!(
2790            scroll_y.get(),
2791            150.0,
2792            "the restored offset must land on the first laid-out frame"
2793        );
2794    }
2795
2796    #[test]
2797    fn restore_scroll_y_is_not_re_applied_after_a_later_reflow() {
2798        let mut tree = WidgetTree::new();
2799        let sa = ScrollArea::new()
2800            .child(TallLeaf::new(200.0, 500.0))
2801            .smooth_scrolling(false)
2802            .restore_scroll_y(150.0);
2803        let scroll_y = sa.scroll_y_signal().clone();
2804        let _scroll = tree.add(sa);
2805
2806        tree.layout(SizeProposal::exact(200.0, 100.0));
2807        assert_eq!(scroll_y.get(), 150.0, "precondition: restore landed once");
2808
2809        // The writer scrolls elsewhere, then something forces a reflow (a
2810        // window resize, an edit that changes the content's measured size).
2811        scroll_y.set(70.0);
2812        tree.layout(SizeProposal::exact(200.0, 120.0));
2813
2814        assert_eq!(
2815            scroll_y.get(),
2816            70.0,
2817            "a one-shot restore must not re-arm itself on a later reflow"
2818        );
2819    }
2820
2821    #[test]
2822    fn a_restore_the_content_can_never_hold_does_not_pin_the_reader() {
2823        // 150px of content in a 100px viewport: `max_scroll_y` is 50 and stays 50,
2824        // so a pending 200 is never honoured and — before the stand-down below —
2825        // was re-asserted on every layout pass for the life of the widget.
2826        //
2827        // A scroll bar is what makes that fatal rather than merely untidy. It holds
2828        // a clone of `scroll_y` and calls `set` on it directly, so dragging the
2829        // thumb never reaches the `on_scroll` handler that stands a restore down:
2830        // the reader dragged away from the clamped bottom, the next pass put them
2831        // straight back, and there was no gesture that could win.
2832        let mut tree = WidgetTree::new();
2833        let sa = ScrollArea::new()
2834            .child(TallLeaf::new(200.0, 150.0))
2835            .smooth_scrolling(false)
2836            .restore_scroll_y(200.0);
2837        let scroll_y = sa.scroll_y_signal().clone();
2838        let _scroll = tree.add(sa);
2839
2840        tree.layout(SizeProposal::exact(200.0, 100.0));
2841        assert_eq!(
2842            scroll_y.get(),
2843            50.0,
2844            "precondition: the offset lands clamped to the range that exists"
2845        );
2846
2847        // Exactly what `ScrollBar`'s thumb drag does.
2848        scroll_y.set(0.0);
2849        tree.layout(SizeProposal::exact(200.0, 100.0));
2850
2851        assert_eq!(
2852            scroll_y.get(),
2853            0.0,
2854            "a drag away from the clamped landing must stand the restore down, \
2855             not be undone by the next layout pass"
2856        );
2857    }
2858
2859    #[test]
2860    fn a_restore_still_waits_out_content_that_is_only_slow_to_measure() {
2861        // The stand-down must not cost the case the re-apply exists for. A rich
2862        // text editor reports its `min_lines` height until its own content has been
2863        // typeset, so the range grows over several passes; the restore has to keep
2864        // re-asserting through those, and only the *reader* moving may cancel it.
2865        //
2866        // The area is laid out three times against a child that grows underneath
2867        // it, which is what "the content has not finished measuring" looks like
2868        // from here.
2869        let mut tree = WidgetTree::new();
2870        let height = Rc::new(Cell::new(150.0));
2871        let sa = ScrollArea::new()
2872            .child(GrowingLeaf::new(200.0, height.clone()))
2873            .smooth_scrolling(false)
2874            .restore_scroll_y(200.0);
2875        let scroll_y = sa.scroll_y_signal().clone();
2876        let _scroll = tree.add(sa);
2877
2878        tree.layout(SizeProposal::exact(200.0, 100.0));
2879        assert_eq!(scroll_y.get(), 50.0, "clamped to the range measured so far");
2880
2881        height.set(400.0);
2882        tree.layout(SizeProposal::exact(200.0, 100.0));
2883        assert_eq!(
2884            scroll_y.get(),
2885            200.0,
2886            "the range grew past the offset, so the offset lands in full"
2887        );
2888
2889        // And having landed, it is spent: a later reflow leaves the reader alone.
2890        scroll_y.set(10.0);
2891        height.set(900.0);
2892        tree.layout(SizeProposal::exact(200.0, 100.0));
2893        assert_eq!(scroll_y.get(), 10.0, "a one-shot does not re-arm");
2894    }
2895
2896    #[test]
2897    fn restore_scroll_y_past_the_range_never_lets_an_observer_see_the_overshoot() {
2898        // The landing clamps the pending offset itself, which looks redundant beside
2899        // the `clamp_and_set_scroll` that runs immediately afterwards and would
2900        // settle on the same final value. It is not redundant, and asserting the
2901        // final value alone cannot tell the two apart. Writing the raw offset first
2902        // and correcting it after would publish the overshoot through `scroll_y`, so
2903        // anything bound to it, a scroll bar's thumb above all, sees a position the
2904        // content never had. Watch every value the signal takes, not just the last.
2905        let mut tree = WidgetTree::new();
2906        let sa = ScrollArea::new()
2907            .child(TallLeaf::new(200.0, 500.0))
2908            .smooth_scrolling(false)
2909            .restore_scroll_y(9999.0);
2910        let scroll_y = sa.scroll_y_signal().clone();
2911        let max_scroll_y = sa.max_scroll_y_signal().clone();
2912
2913        let seen: Rc<std::cell::RefCell<Vec<f32>>> = Rc::new(std::cell::RefCell::new(Vec::new()));
2914        let recorder = seen.clone();
2915        let _observer = scroll_y.observe(move |v: &f32| recorder.borrow_mut().push(*v));
2916
2917        let _scroll = tree.add(sa);
2918        tree.layout(SizeProposal::exact(200.0, 100.0));
2919
2920        assert_eq!(
2921            scroll_y.get(),
2922            400.0,
2923            "it must settle at the end of the range"
2924        );
2925        assert_eq!(scroll_y.get(), max_scroll_y.get());
2926        let overshoot: Vec<f32> = seen
2927            .borrow()
2928            .iter()
2929            .copied()
2930            .filter(|v| *v > max_scroll_y.get())
2931            .collect();
2932        assert!(
2933            overshoot.is_empty(),
2934            "an observer saw an offset past the end of the content: {overshoot:?}"
2935        );
2936    }
2937
2938    #[test]
2939    fn restore_scroll_y_waits_for_a_range_long_enough_to_hold_it() {
2940        // The bug this exists for, found by driving the real app rather than by any
2941        // headless test: a page holding a long chapter reported a few hundred pixels
2942        // of content on its first laid-out pass and its true height only later. A
2943        // restore taken on the first nonzero range landed clamped against the short
2944        // one, which put the writer back at the top of a chapter they had left the
2945        // end of, and looked exactly like the restore never happening.
2946        let height = Rc::new(Cell::new(500.0_f32));
2947        let mut tree = WidgetTree::new();
2948        let sa = ScrollArea::new()
2949            .child(GrowingLeaf::new(200.0, height.clone()))
2950            .smooth_scrolling(false)
2951            .restore_scroll_y(11560.0);
2952        let scroll_y = sa.scroll_y_signal().clone();
2953        let max_scroll_y = sa.max_scroll_y_signal().clone();
2954        let _scroll = tree.add(sa);
2955
2956        tree.layout(SizeProposal::exact(200.0, 100.0));
2957        assert_eq!(
2958            max_scroll_y.get(),
2959            400.0,
2960            "precondition: a short first pass"
2961        );
2962        assert_eq!(
2963            scroll_y.get(),
2964            400.0,
2965            "as far down as the content so far allows, so the page is never at the top"
2966        );
2967
2968        height.set(12000.0);
2969        tree.layout(SizeProposal::exact(200.0, 100.0));
2970        assert_eq!(
2971            scroll_y.get(),
2972            11560.0,
2973            "once the content is long enough, the offset must land in full"
2974        );
2975
2976        // And having landed it, it is spent: growing further must not move the page.
2977        scroll_y.set(60.0);
2978        height.set(20000.0);
2979        tree.layout(SizeProposal::exact(200.0, 100.0));
2980        assert_eq!(
2981            scroll_y.get(),
2982            60.0,
2983            "a restore already honoured must not re-assert itself on a later reflow"
2984        );
2985    }
2986
2987    #[test]
2988    fn a_reader_scrolling_stands_down_a_restore_that_has_not_landed() {
2989        // While the content is still too short to hold the remembered offset, the
2990        // restore is re-applied on every pass. That must not turn into a fight with
2991        // someone who has started reading: a real scroll says where they want to be,
2992        // and outranks a position they left on a previous run.
2993        let height = Rc::new(Cell::new(500.0_f32));
2994        let mut tree = WidgetTree::new();
2995        let sa = ScrollArea::new()
2996            .child(GrowingLeaf::new(200.0, height.clone()))
2997            .smooth_scrolling(false)
2998            .restore_scroll_y(11560.0);
2999        let scroll_y = sa.scroll_y_signal().clone();
3000        let _scroll = tree.add(sa);
3001
3002        tree.layout(SizeProposal::exact(200.0, 100.0));
3003        assert_eq!(scroll_y.get(), 400.0, "precondition: still pending");
3004
3005        tree.pointer_move(Point::new(50.0, 40.0));
3006        tree.dispatch_event(WidgetEvent::scroll(
3007            ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3008            Default::default(),
3009        ));
3010        let after_reader = scroll_y.get();
3011
3012        height.set(12000.0);
3013        tree.layout(SizeProposal::exact(200.0, 100.0));
3014        assert_eq!(
3015            scroll_y.get(),
3016            after_reader,
3017            "the content growing must not yank a reader who has already scrolled"
3018        );
3019    }
3020
3021    #[test]
3022    fn without_restore_scroll_y_behaviour_is_unchanged() {
3023        // Purely additive: an area that never calls `restore_scroll_y` must
3024        // stay at 0 through layout, exactly as it did before this existed.
3025        let mut tree = WidgetTree::new();
3026        let sa = ScrollArea::new()
3027            .child(TallLeaf::new(200.0, 500.0))
3028            .smooth_scrolling(false);
3029        let scroll_y = sa.scroll_y_signal().clone();
3030        let _scroll = tree.add(sa);
3031
3032        tree.layout(SizeProposal::exact(200.0, 100.0));
3033        assert_eq!(scroll_y.get(), 0.0);
3034
3035        // A later reflow must not conjure an offset out of nowhere either.
3036        tree.layout(SizeProposal::exact(200.0, 120.0));
3037        assert_eq!(scroll_y.get(), 0.0);
3038    }
3039
3040    #[test]
3041    fn restore_scroll_y_of_zero_arms_nothing_and_leaves_a_host_write_alone() {
3042        // Arming `Some(0.0)` and refusing to arm at all reach the same resting
3043        // position, so asserting the final offset proves nothing about the guard.
3044        // What separates them is a host that writes the offset itself between
3045        // construction and the first layout: an armed zero lands on top of that
3046        // write and wipes it, an unarmed one leaves it standing.
3047        let mut tree = WidgetTree::new();
3048        let sa = ScrollArea::new()
3049            .child(TallLeaf::new(200.0, 500.0))
3050            .smooth_scrolling(false)
3051            .restore_scroll_y(0.0);
3052        let scroll_y = sa.scroll_y_signal().clone();
3053        let _scroll = tree.add(sa);
3054
3055        scroll_y.set(120.0);
3056        tree.layout(SizeProposal::exact(200.0, 100.0));
3057
3058        assert_eq!(
3059            scroll_y.get(),
3060            120.0,
3061            "restore_scroll_y(0.0) armed a restore and overwrote the host's own offset"
3062        );
3063    }
3064
3065    #[test]
3066    fn restore_scroll_y_of_zero_disarms_a_previously_armed_offset() {
3067        // `restore_scroll_y(0.0)` must not merely refuse to arm itself: called after
3068        // a nonzero call it must clear that earlier value too, or the "no-op" call
3069        // would silently leave a stale restore pending. Asserted against a host write
3070        // for the same reason as the test above, so that a still-armed 150.0 and a
3071        // still-armed 0.0 are both distinguishable from nothing armed at all.
3072        let mut tree = WidgetTree::new();
3073        let sa = ScrollArea::new()
3074            .child(TallLeaf::new(200.0, 500.0))
3075            .smooth_scrolling(false)
3076            .restore_scroll_y(150.0)
3077            .restore_scroll_y(0.0);
3078        let scroll_y = sa.scroll_y_signal().clone();
3079        let _scroll = tree.add(sa);
3080
3081        scroll_y.set(120.0);
3082        tree.layout(SizeProposal::exact(200.0, 100.0));
3083
3084        assert_eq!(
3085            scroll_y.get(),
3086            120.0,
3087            "a later restore_scroll_y(0.0) must disarm the earlier pending offset"
3088        );
3089    }
3090}
3091
3092/// A finger on the content, and the coast that follows it.
3093///
3094/// The wheel path is covered above and deliberately not restated here: the
3095/// point of these is that adopting `ScrollableBehavior` added a second input
3096/// route without moving the first one.
3097#[cfg(test)]
3098mod pan_tests {
3099    use super::*;
3100    use crate::primitives::VStack;
3101    use std::time::Duration;
3102    use teksilo_canvas::SizeProposal;
3103    use teksilo_core::event::{Modifiers, PointerButton};
3104    use teksilo_core::pointer::clock::ManualClock;
3105    use teksilo_core::pointer::{
3106        BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
3107        PointerSample,
3108    };
3109    use teksilo_core::widget::{LayoutContext, LayoutResponse};
3110    use teksilo_core::widget_tree::WidgetTree;
3111
3112    /// A leaf of a fixed intrinsic size — content for the area to scroll.
3113    #[derive(Debug)]
3114    struct TallLeaf {
3115        width: f32,
3116        height: f32,
3117    }
3118
3119    impl TallLeaf {
3120        fn new(width: f32, height: f32) -> Self {
3121            Self { width, height }
3122        }
3123    }
3124
3125    impl Widget for TallLeaf {
3126        fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3127            Size::new(self.width, self.height).into()
3128        }
3129    }
3130
3131    fn contact_id(raw: u64) -> PointerId {
3132        let alloc = PointerIdAllocator::global();
3133        let device = BackendDeviceKey::new(0x5A7E);
3134        let id = alloc.begin(device, raw);
3135        alloc.end(device, raw);
3136        id
3137    }
3138
3139    fn contact(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
3140        PointerSample {
3141            pointer: PointerInfo::touch(id, EventTime::ZERO),
3142            phase,
3143            position: at,
3144            button: None,
3145            modifiers: Modifiers::NONE,
3146            coalesced: Vec::new(),
3147        }
3148    }
3149
3150    fn pan_slop() -> f32 {
3151        teksilo_core::gesture::default_profile(teksilo_tokens::PointerKind::Touch)
3152            .pan_slop
3153            .expect("a touch profile pans")
3154    }
3155
3156    /// Press at `from`, cross the pan slop, then travel `dy`.
3157    fn drag(tree: &mut WidgetTree, id: PointerId, from: Point, dy: f32) -> Point {
3158        tree.dispatch_pointer(contact(id, PointerPhase::Down, from));
3159        let arm = Point::new(from.x, from.y + pan_slop().copysign(dy) + dy.signum());
3160        tree.dispatch_pointer(contact(id, PointerPhase::Move, arm));
3161        let at = Point::new(from.x, arm.y + (dy - (arm.y - from.y)));
3162        tree.dispatch_pointer(contact(id, PointerPhase::Move, at));
3163        at
3164    }
3165
3166    /// A 200 × 100 area over 200 × 600 of content, with the bars off so a press
3167    /// in the middle of the viewport can only reach the content.
3168    fn area(
3169        build: impl FnOnce(ScrollArea) -> ScrollArea,
3170    ) -> (WidgetTree, Signal<f32>, Signal<Vec2>) {
3171        let mut tree = WidgetTree::new();
3172        let content = tree.add(TallLeaf::new(200.0, 600.0));
3173        let sa = build(
3174            ScrollArea::from_id(content)
3175                .smooth_scrolling(false)
3176                .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3177                .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff),
3178        );
3179        let y = sa.scroll_y_signal().clone();
3180        let overscroll = sa.overscroll_signal();
3181        tree.add(sa);
3182        tree.layout(SizeProposal::exact(200.0, 100.0));
3183        (tree, y, overscroll)
3184    }
3185
3186    /// Dragging a finger up scrolls the content down — the pan claim is
3187    /// declared, the router synthesises a positioned `Scroll`, and this area
3188    /// takes it.
3189    #[test]
3190    fn a_finger_pans_the_content() {
3191        let (mut tree, y, _overscroll) = area(|sa| sa);
3192        drag(&mut tree, contact_id(1), Point::new(100.0, 80.0), -60.0);
3193        assert!(y.get() > 0.0, "the finger scrolled the area: {}", y.get());
3194    }
3195
3196    /// A pan does not tween: `smooth_scrolling` governs the wheel and nothing
3197    /// else, because the content is under the finger.
3198    #[test]
3199    fn a_pan_is_not_smoothed() {
3200        let (mut tree, y, _overscroll) = area(|sa| sa.smooth_scrolling(true));
3201        drag(&mut tree, contact_id(2), Point::new(100.0, 80.0), -60.0);
3202        assert!(y.get() > 0.0);
3203        assert_eq!(y.animation_target(), None);
3204    }
3205
3206    /// A fast release hands its velocity to the tree's coast, which keeps the
3207    /// area moving after the finger has gone.
3208    #[test]
3209    fn a_fast_release_flings() {
3210        let (mut tree, y, _overscroll) = area(|sa| sa);
3211        let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3212        tree.set_input_clock(clock.clone());
3213
3214        let finger = contact_id(3);
3215        let from = Point::new(100.0, 95.0);
3216        tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3217        let mut at = from.y;
3218        for step in 1..=5 {
3219            clock.set(EventTime::from_millis(step * 4));
3220            at -= 15.0;
3221            tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3222        }
3223        clock.set(EventTime::from_millis(24));
3224        tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3225
3226        let at_release = y.get();
3227        assert!(at_release > 0.0);
3228        tree.advance_time(Duration::from_millis(100));
3229        assert!(
3230            y.get() > at_release,
3231            "the coast kept it moving: {at_release} -> {}",
3232            y.get()
3233        );
3234    }
3235
3236    /// `prefers-reduced-motion` turns the coast off: the content stops where
3237    /// the finger left it, and nothing moves it afterwards.
3238    #[test]
3239    fn reduced_motion_collapses_the_fling_to_a_settle() {
3240        let mut tree = WidgetTree::new();
3241        tree.set_accessibility_preferences(false, true, 1.0);
3242        let content = tree.add(TallLeaf::new(200.0, 600.0));
3243        let sa = ScrollArea::from_id(content)
3244            .smooth_scrolling(false)
3245            .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3246            .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3247        let y = sa.scroll_y_signal().clone();
3248        tree.add(sa);
3249        tree.layout(SizeProposal::exact(200.0, 100.0));
3250
3251        let clock = Rc::new(ManualClock::new(EventTime::ZERO));
3252        tree.set_input_clock(clock.clone());
3253        let finger = contact_id(4);
3254        let from = Point::new(100.0, 95.0);
3255        tree.dispatch_pointer(contact(finger, PointerPhase::Down, from));
3256        let mut at = from.y;
3257        for step in 1..=5 {
3258            clock.set(EventTime::from_millis(step * 4));
3259            at -= 15.0;
3260            tree.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, at)));
3261        }
3262        clock.set(EventTime::from_millis(24));
3263        tree.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, at)));
3264
3265        let at_release = y.get();
3266        assert!(at_release > 0.0, "the pan itself still scrolls");
3267        tree.advance_time(Duration::from_millis(200));
3268        assert_eq!(y.get(), at_release, "and nothing coasts afterwards");
3269    }
3270
3271    /// With the band on, a finger past the end holds the content there with
3272    /// decreasing gain, and the lift releases it. The offset never leaves the
3273    /// range, so a surface that ignores `overscroll_signal` is unaffected.
3274    #[test]
3275    fn the_rubber_band_holds_and_releases() {
3276        let (mut tree, y, overscroll) = area(|sa| sa.rubber_band(true));
3277        y.set(500.0);
3278
3279        let finger = contact_id(5);
3280        let at = drag(&mut tree, finger, Point::new(100.0, 95.0), -70.0);
3281        assert_eq!(y.get(), 500.0, "the offset stays inside the range");
3282        let held = overscroll.get().y;
3283        assert!(held > 0.0, "the band is holding it past the end");
3284        assert!(held < 70.0, "with decreasing gain: {held}");
3285
3286        tree.dispatch_pointer(contact(finger, PointerPhase::Up, at));
3287        assert_eq!(overscroll.get(), Vec2::ZERO, "the lift released it");
3288    }
3289
3290    /// Off by default, so a nested area can still hand its boundary pan to the
3291    /// container around it rather than absorbing it into a band.
3292    #[test]
3293    fn the_band_is_off_by_default() {
3294        let (mut tree, y, overscroll) = area(|sa| sa);
3295        y.set(500.0);
3296        drag(&mut tree, contact_id(6), Point::new(100.0, 95.0), -70.0);
3297        assert_eq!(overscroll.get(), Vec2::ZERO);
3298        assert_eq!(y.get(), 500.0);
3299    }
3300
3301    /// A boundary pan hands the **whole** event to the container outward — the
3302    /// same rule the wheel has always followed, with no residual split between
3303    /// the two.
3304    #[test]
3305    fn a_boundary_pan_chains_the_whole_event_outward() {
3306        let mut tree = WidgetTree::new();
3307        let inner_content = tree.add(TallLeaf::new(200.0, 120.0));
3308        let inner_sa = ScrollArea::from_id(inner_content)
3309            .smooth_scrolling(false)
3310            .preferred_size(200.0, 100.0)
3311            .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3312            .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3313        let inner_y = inner_sa.scroll_y_signal().clone();
3314        let inner = tree.add(inner_sa);
3315
3316        let filler = tree.add(TallLeaf::new(200.0, 400.0));
3317        let outer_content = tree.add(VStack::new().child(inner).child(filler));
3318        let outer_sa = ScrollArea::from_id(outer_content)
3319            .smooth_scrolling(false)
3320            .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
3321            .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff);
3322        let outer_y = outer_sa.scroll_y_signal().clone();
3323        tree.add(outer_sa);
3324        tree.layout(SizeProposal::exact(200.0, 150.0));
3325
3326        // Park the inner area at its end, then keep panning in the same
3327        // direction on the same finger.
3328        inner_y.set(20.0);
3329        let finger = contact_id(7);
3330        drag(&mut tree, finger, Point::new(100.0, 80.0), -60.0);
3331
3332        assert_eq!(inner_y.get(), 20.0, "the inner area is pinned at its end");
3333        assert!(
3334            outer_y.get() > 0.0,
3335            "so the container took the pan: {}",
3336            outer_y.get()
3337        );
3338    }
3339
3340    /// A mouse is not a panning pointer: pressing and dragging with the primary
3341    /// button scrolls nothing, and the wheel is untouched.
3342    #[test]
3343    fn a_mouse_press_and_drag_does_not_pan() {
3344        let (mut tree, y, _overscroll) = area(|sa| sa);
3345        tree.dispatch_event(WidgetEvent::pointer_down(
3346            Point::new(100.0, 80.0),
3347            PointerButton::Primary,
3348            Modifiers::NONE,
3349        ));
3350        tree.pointer_move(Point::new(100.0, 20.0));
3351        assert_eq!(y.get(), 0.0, "a mouse drag is not a pan");
3352
3353        tree.dispatch_event(WidgetEvent::scroll(
3354            teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
3355            Modifiers::NONE,
3356        ));
3357        assert_eq!(y.get(), 40.0, "…and its wheel still scrolls");
3358    }
3359}