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