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