Skip to main content

telar_ui_core/
scroll_area.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use geometry_core::Rect;
5use layout_core::{AvailableSpace, LayoutError, LayoutStyle, NodeId};
6use platform_core::{Event, ScrollDelta};
7use reactive_core::{Effect, Memo, ReadSignal, RwSignal, effect, memo, signal};
8use renderer_core::{BorderRadius, Color, RectStyle, ShapeStyle};
9use theme_core::use_theme_tokens;
10use ui_tree::{Component, EventResult, RenderNode, Segment};
11
12use ui_tree::NodeVec;
13
14use crate::context::track_layout;
15use crate::impl_leaf_widget;
16use crate::kept::kept;
17use crate::layout_item::{LayoutItem, mount_item_segment};
18use crate::layout_leaf::LayoutLeaf;
19use crate::pointer::{clip_pointer_event, offset_pointer};
20use crate::scroll_region::{ScrollRegionId, register_scroll_region, unregister_scroll_region};
21
22pub struct ScrollbarStyle {
23    pub color: Color,
24    pub width: f32,
25    pub corner_radius: f32,
26}
27
28impl Default for ScrollbarStyle {
29    fn default() -> Self {
30        let color = use_theme_tokens()
31            .map(|t| t.scrollbar())
32            .unwrap_or(Color::rgba(0.5, 0.5, 0.6, 0.6));
33        Self {
34            color,
35            width: 8.0,
36            corner_radius: 3.0,
37        }
38    }
39}
40
41fn draw_scrollbars(
42    viewport: Rect,
43    scroll_x: f32,
44    scroll_y: f32,
45    content_rect: Rect,
46    scrollbar_style: &ScrollbarStyle,
47) -> (RenderNode, RenderNode) {
48    let vbar = if content_rect.height > viewport.height {
49        let bar_h = (viewport.height / content_rect.height * viewport.height).max(24.0);
50        let max_scroll = (content_rect.height - viewport.height).max(1.0);
51        let bar_y = viewport.y + (scroll_y / max_scroll) * (viewport.height - bar_h);
52        RenderNode::rect(
53            Rect::new(
54                viewport.x + viewport.width - scrollbar_style.width,
55                bar_y,
56                scrollbar_style.width - 2.0,
57                bar_h,
58            ),
59            RectStyle::default()
60                .with_fill(scrollbar_style.color)
61                .with_radius(BorderRadius::all(scrollbar_style.corner_radius)),
62        )
63    } else {
64        RenderNode::Empty
65    };
66
67    let hbar = if content_rect.width > viewport.width {
68        let bar_w = (viewport.width / content_rect.width * viewport.width).max(24.0);
69        let max_scroll_x = (content_rect.width - viewport.width).max(1.0);
70        let bar_x = viewport.x + (scroll_x / max_scroll_x) * (viewport.width - bar_w);
71        RenderNode::rect(
72            Rect::new(
73                bar_x,
74                viewport.y + viewport.height - scrollbar_style.width,
75                bar_w,
76                scrollbar_style.width - 2.0,
77            ),
78            RectStyle::default()
79                .with_fill(scrollbar_style.color)
80                .with_radius(BorderRadius::all(scrollbar_style.corner_radius)),
81        )
82    } else {
83        RenderNode::Empty
84    };
85
86    (vbar, hbar)
87}
88
89fn handle_scroll_event(
90    event: &Event,
91    viewport: Rect,
92    scroll_x: RwSignal<f32>,
93    scroll_y: RwSignal<f32>,
94    content_rect_signal: RwSignal<Rect>,
95    content: &Rc<RefCell<Box<dyn LayoutItem>>>,
96    last_pointer: Option<(f32, f32)>,
97) -> EventResult {
98    if let Event::Scrolled { delta } = event {
99        // Nested scroll: offer the wheel to the content first, so an inner scroll area under the pointer
100        // consumes it before this (outer) one does.
101        if content.borrow_mut().on_event(event) == EventResult::Handled {
102            return EventResult::Handled;
103        }
104        // A wheel event carries no position, so only scroll here if the last pointer move was inside this
105        // viewport; otherwise leave it Ignored for an ancestor scroll area to handle.
106        if last_pointer.is_some_and(|(px, py)| !viewport.contains(px, py)) {
107            return EventResult::Ignored;
108        }
109        let (delta_x, delta_y) = match delta {
110            ScrollDelta::Lines { x, y } => (*x * 20.0, *y * 20.0),
111            ScrollDelta::Pixels { x, y } => (*x, *y),
112        };
113        let content_rect = content_rect_signal.get();
114        let max_scroll_x = (content_rect.width - viewport.width).max(0.0);
115        let max_scroll_y = (content_rect.height - viewport.height).max(0.0);
116        scroll_x.set((scroll_x.get() - delta_x).clamp(0.0, max_scroll_x));
117        scroll_y.set((scroll_y.get() - delta_y).clamp(0.0, max_scroll_y));
118        return EventResult::Handled;
119    }
120
121    let Some(event) = clip_pointer_event(event, viewport) else {
122        return EventResult::Ignored;
123    };
124
125    let scroll_offset_x = scroll_x.get() as f64;
126    let scroll_offset_y = scroll_y.get() as f64;
127    let adjusted = offset_pointer(
128        event,
129        viewport.x as f64 - scroll_offset_x,
130        viewport.y as f64 - scroll_offset_y,
131    );
132    let effective = adjusted.as_ref().unwrap_or(event);
133    content.borrow_mut().on_event(effective)
134}
135
136pub(crate) struct ScrollCore {
137    content_rect_signal: RwSignal<Rect>,
138    scroll_x: RwSignal<f32>,
139    scroll_y: RwSignal<f32>,
140    // Shared between event dispatch (borrow_mut) and the content segment (borrow). The content is its own segment so a scroll tick only re-runs this core's view() to rewrite the Transform matrix — the content is referenced as a cheap boundary and is NOT re-flattened on scroll.
141    content: Rc<RefCell<Box<dyn LayoutItem>>>,
142    content_segment: Rc<Segment>,
143    scrollbar_style: ScrollbarStyle,
144    // Touch tap-vs-scroll: finger travel accumulated while a pointer is pressed. Content children see
145    // content-space coords pinned under the finger during a drag, so they can't detect the scroll themselves;
146    // once travel passes SCROLL_TAP_SLOP the scroll area cancels their pending tap (one CursorLeft) so a scroll
147    // doesn't click. Gated on `press_active` so a mouse wheel scroll (no press) never cancels anything.
148    press_active: bool,
149    gesture_scroll: f32,
150    tap_cancelled: bool,
151    // Last pointer position seen (this area's own coordinate space). A wheel `Scrolled` event carries no
152    // position, so nested scroll routing uses this to decide whether the pointer is over this viewport.
153    last_pointer: Option<(f32, f32)>,
154}
155
156/// Accumulated finger travel (logical px) within a gesture past which the scroll area treats it as a scroll
157/// and cancels any pending tap on its content.
158const SCROLL_TAP_SLOP: f32 = 8.0;
159
160impl ScrollCore {
161    /// Adopts externally-created scroll offset signals, so a caller can hand those same signals to the
162    /// content it builds (see [`LayoutScrollArea::new_with`]). Fresh signals give an independent scroll.
163    fn with_offsets(
164        content_rect_signal: RwSignal<Rect>,
165        content: Box<dyn LayoutItem>,
166        scroll_x: RwSignal<f32>,
167        scroll_y: RwSignal<f32>,
168    ) -> Self {
169        let content = Rc::new(RefCell::new(content));
170        let content_segment = mount_item_segment(Rc::clone(&content));
171        Self {
172            content_rect_signal,
173            scroll_x,
174            scroll_y,
175            content,
176            content_segment,
177            scrollbar_style: ScrollbarStyle::default(),
178            press_active: false,
179            gesture_scroll: 0.0,
180            tap_cancelled: false,
181            last_pointer: None,
182        }
183    }
184
185    // Both of these write the offsets they read, so both `peek` them: whoever calls them may be doing so from
186    // an effect, and a reactive read there would subscribe that effect to its own correction (see
187    // `ScrollViewport::reveal`, where that bug bites).
188    fn scroll_to_top(&mut self) {
189        if self.scroll_x.peek() != 0.0 {
190            self.scroll_x.set(0.0);
191        }
192        if self.scroll_y.peek() != 0.0 {
193            self.scroll_y.set(0.0);
194        }
195    }
196
197    fn clamp_scroll(&mut self, viewport: Rect) {
198        let content_rect = self.content_rect_signal.peek();
199        let max_x = (content_rect.width - viewport.width).max(0.0);
200        let max_y = (content_rect.height - viewport.height).max(0.0);
201        let clamped_x = self.scroll_x.peek().clamp(0.0, max_x);
202        let clamped_y = self.scroll_y.peek().clamp(0.0, max_y);
203        if self.scroll_x.peek() != clamped_x {
204            self.scroll_x.set(clamped_x);
205        }
206        if self.scroll_y.peek() != clamped_y {
207            self.scroll_y.set(clamped_y);
208        }
209    }
210
211    fn view(&self, viewport: Rect) -> RenderNode {
212        let scroll_x = self.scroll_x.get();
213        let scroll_y = self.scroll_y.get();
214        let content_rect = self.content_rect_signal.get();
215        let scrollable = RenderNode::Clip {
216            rect: viewport,
217            radius: BorderRadius::zero(),
218            children: NodeVec::collect([RenderNode::Transform {
219                matrix: [
220                    1.0,
221                    0.0,
222                    0.0,
223                    1.0,
224                    viewport.x - scroll_x,
225                    viewport.y - scroll_y,
226                ],
227                children: NodeVec::collect([self.content_segment.boundary()]),
228            }]),
229        };
230        let (vbar, hbar) = draw_scrollbars(
231            viewport,
232            scroll_x,
233            scroll_y,
234            content_rect,
235            &self.scrollbar_style,
236        );
237        RenderNode::group([scrollable, vbar, hbar])
238    }
239
240    fn on_event(&mut self, event: &Event, viewport: Rect) -> EventResult {
241        match event {
242            // A new press starts a fresh tap candidate; forget the prior gesture's accumulated scroll.
243            Event::PointerPressed { .. } => {
244                self.press_active = true;
245                self.gesture_scroll = 0.0;
246                self.tap_cancelled = false;
247            }
248            Event::PointerReleased { .. } => self.press_active = false,
249            // Remember where the pointer is so a subsequent (position-less) wheel `Scrolled` can tell
250            // whether it belongs to this viewport or an ancestor's.
251            Event::PointerMoved { x, y, .. } => self.last_pointer = Some((*x as f32, *y as f32)),
252            // While a pointer is down (a touch drag, not a mouse wheel), once the finger has travelled past
253            // the slop this gesture is a scroll, not a tap: cancel the pending press on the content once (it
254            // sees pinned content-space coords and can't tell on its own).
255            Event::Scrolled { delta } if self.press_active && !self.tap_cancelled => {
256                let (dx, dy) = match delta {
257                    ScrollDelta::Lines { x, y } => (*x * 20.0, *y * 20.0),
258                    ScrollDelta::Pixels { x, y } => (*x, *y),
259                };
260                self.gesture_scroll += (dx * dx + dy * dy).sqrt();
261                if self.gesture_scroll > SCROLL_TAP_SLOP {
262                    self.tap_cancelled = true;
263                    self.content.borrow_mut().on_event(&Event::CursorLeft);
264                }
265            }
266            _ => {}
267        }
268        handle_scroll_event(
269            event,
270            viewport,
271            self.scroll_x.clone(),
272            self.scroll_y.clone(),
273            self.content_rect_signal.clone(),
274            &self.content,
275            self.last_pointer,
276        )
277    }
278}
279
280// Closure-viewport fixture exercising ScrollCore directly; test-only since LayoutScrollArea is the single public scroll-area.
281#[cfg(test)]
282struct ScrollArea {
283    viewport: Box<dyn Fn() -> Rect>,
284    core: ScrollCore,
285}
286
287#[cfg(test)]
288impl ScrollArea {
289    fn new(viewport: impl Fn() -> Rect + 'static, content: Box<dyn LayoutItem>) -> Self {
290        let content_rect_signal =
291            track_layout(content.layout_node()).expect("content node not registered in ctx");
292        Self {
293            viewport: Box::new(viewport),
294            core: ScrollCore::with_offsets(content_rect_signal, content, signal(0.0), signal(0.0)),
295        }
296    }
297}
298
299#[cfg(test)]
300impl Component for ScrollArea {
301    fn view(&self) -> RenderNode {
302        self.core.view((self.viewport)())
303    }
304
305    fn on_event(&mut self, event: &Event) -> EventResult {
306        self.core.on_event(event, (self.viewport)())
307    }
308}
309
310/// A handle to the enclosing scroll area's live viewport, handed to the content builder by
311/// [`LayoutScrollArea::new_with`]. Because a scroll area lays its content out as its OWN layout root,
312/// every descendant's tracked rect is already in the same content-local space the scroll offset
313/// indexes into — so [`visible`](Self::visible) is a plain rect overlap, no scroll-transform math.
314#[derive(Clone)]
315pub struct ScrollViewport {
316    offset_x: ReadSignal<f32>,
317    offset_y: ReadSignal<f32>,
318    rect: ReadSignal<Rect>,
319    // The writable side of the same offsets, so `reveal` can move the view. Kept private: callers should say
320    // *what they want visible*, not compute a scroll position.
321    set_x: RwSignal<f32>,
322    set_y: RwSignal<f32>,
323}
324
325impl ScrollViewport {
326    /// The live scroll offset `(x, y)` in content-local px.
327    pub fn offset(&self) -> (ReadSignal<f32>, ReadSignal<f32>) {
328        (self.offset_x.clone(), self.offset_y.clone())
329    }
330
331    /// Scrolls the minimum distance needed to bring `item` fully into view, leaving `margin` px of breathing
332    /// room at whichever edge it entered from. A no-op when the item is already visible.
333    ///
334    /// This is what keyboard navigation needs and what a scroll offset alone cannot express: moving a selection
335    /// down a list should follow it, without yanking the view when the item was on screen all along. `item` must
336    /// be a node inside this scroll's content, so its tracked rect shares the content-local space the offset
337    /// indexes into.
338    pub fn reveal(&self, item: NodeId, margin: f32) {
339        let Some(item_rect) = track_layout(item) else {
340            return;
341        };
342        let item = item_rect.get();
343        let viewport = self.rect.get();
344
345        let reveal_axis = |offset: f32, span: f32, start: f32, size: f32| -> f32 {
346            if start - margin < offset {
347                // Entered from the near edge: put its leading edge at the top/left of the window.
348                (start - margin).max(0.0)
349            } else if start + size + margin > offset + span {
350                // Entered from the far edge: put its trailing edge at the bottom/right.
351                (start + size + margin - span).max(0.0)
352            } else {
353                offset
354            }
355        };
356
357        // `peek` on the offsets, and it is load-bearing: this is a *command*, and the natural place to call it
358        // from is an effect ("while this row is the selected one, keep it in view"). A reactive read of the
359        // offset there would subscribe that effect to the very signal it writes, so scrolling by hand would
360        // re-run it and drag the view straight back — a list that cannot be scrolled at all. The item and
361        // viewport rects are inputs rather than outputs, so re-running when *those* move is the right thing.
362        let y = reveal_axis(self.set_y.peek(), viewport.height, item.y, item.height);
363        if y != self.set_y.peek() {
364            self.set_y.set(y);
365        }
366        let x = reveal_axis(self.set_x.peek(), viewport.width, item.x, item.width);
367        if x != self.set_x.peek() {
368            self.set_x.set(x);
369        }
370    }
371
372    /// The live viewport rect; its `width`/`height` are the visible window's size.
373    pub fn rect(&self) -> ReadSignal<Rect> {
374        self.rect.clone()
375    }
376
377    /// Puts the view back at the top-left.
378    ///
379    /// For content that has been *replaced* rather than resized — a page swapped for another one — which is
380    /// the one thing the scroll area cannot tell on its own: a shorter page is clamped back into range
381    /// automatically, but only the caller knows that what is in the viewport is now a different thing, and
382    /// that being three screens down someone else's page is not where the reader left off.
383    ///
384    /// `peek` for the same reason as [`reveal`](Self::reveal), and this is where it bites hardest: "the page
385    /// changed" is noticed by an effect, so a reactive read of the offset would make every wheel tick re-run
386    /// the effect that puts the offset back — the viewport pinned to the top for good.
387    pub fn scroll_to_top(&self) {
388        if self.set_x.peek() != 0.0 {
389            self.set_x.set(0.0);
390        }
391        if self.set_y.peek() != 0.0 {
392            self.set_y.set(0.0);
393        }
394    }
395
396    /// A reactive flag for whether `item` currently intersects the visible window, grown by `margin` px
397    /// on every side (a prefetch band, so work can start just before the item scrolls into view). `item`
398    /// must be a node inside this scroll's content, so its tracked rect shares the content-local space
399    /// the offset indexes into.
400    pub fn visible(&self, item: NodeId, margin: f32) -> Memo<bool> {
401        let item_rect = track_layout(item).expect("item node not registered in ctx");
402        let offset_x = self.offset_x.clone();
403        let offset_y = self.offset_y.clone();
404        let rect = self.rect.clone();
405        memo(move || {
406            let vp = rect.get();
407            let window = Rect::new(
408                offset_x.get() - margin,
409                offset_y.get() - margin,
410                vp.width + 2.0 * margin,
411                vp.height + 2.0 * margin,
412            );
413            item_rect.get().overlaps(window)
414        })
415    }
416}
417
418// Taffy-layout viewport; always valid as a LayoutItem — no panic possible.
419pub struct LayoutScrollArea {
420    leaf: LayoutLeaf,
421    core: ScrollCore,
422    // Publishes this viewport's offset so anything positioning against a node inside it (an anchored dropdown's trigger) can ask where that node is drawn rather than where it was laid out.
423    scroll_region: ScrollRegionId,
424    // Lays the detached content subtree out against the viewport width whenever the viewport is (re)sized,
425    // so a `scroll` element works on its own — its content is not a taffy child of the viewport leaf, so
426    // nothing else would lay it out (the app shell computes only its OWN top-level scroll by hand).
427    _layout_effect: Effect,
428    // Keeps the offset inside the range the content and the viewport currently allow. See `clamp_effect`.
429    _clamp_effect: Effect,
430}
431
432impl LayoutScrollArea {
433    pub fn new(
434        layout_style: LayoutStyle,
435        content: Box<dyn LayoutItem>,
436    ) -> Result<Self, LayoutError> {
437        Self::new_with(layout_style, move |_| Ok(content))
438    }
439
440    /// Like [`new`](Self::new), but the content is built with access to this scroll's live
441    /// [`ScrollViewport`], so descendants can gate work (e.g. lazy asset loading) on whether they are
442    /// currently on screen. The offset/viewport signals are created BEFORE `build` runs, so the content
443    /// it returns can capture them — resolving the ordering bind where the scroll is built from its own
444    /// content yet the content needs the scroll's signals.
445    pub fn new_with<F>(layout_style: LayoutStyle, build: F) -> Result<Self, LayoutError>
446    where
447        F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
448    {
449        Self::new_keeping(layout_style, (signal(0.0), signal(0.0)), build)
450    }
451
452    /// A scroll area whose position the *surface* keeps under `key`, so it survives a rebuild of the tree.
453    ///
454    /// The usual spelling of [`new_keeping`](Self::new_keeping): a remounted view — a shell following a
455    /// config edit, a page rebuilt under the same window — reopens where the reader left it instead of
456    /// snapping to the top. `key` names this viewport among everything else the surface keeps, so two scroll
457    /// areas on one surface need two keys (see [`kept`]).
458    pub fn new_kept<F>(
459        key: &'static str,
460        layout_style: LayoutStyle,
461        build: F,
462    ) -> Result<Self, LayoutError>
463    where
464        F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
465    {
466        let offset = kept(key, || (signal(0.0f32), signal(0.0f32)));
467        Self::new_keeping(layout_style, offset, build)
468    }
469
470    /// Like [`new_with`](Self::new_with), but against offset signals the *caller* owns — so the scroll
471    /// position can outlive this widget.
472    ///
473    /// For a tree that is rebuilt while its surface stays (a shell following a config edit, a view remounted
474    /// under the same window): a scroll area built with fresh signals starts at the top every time, which
475    /// reads as the list jumping back under the reader's hands. Hand it the same pair on every build and the
476    /// view is where they left it. [`new_kept`](Self::new_kept) is this with the surface holding the pair.
477    pub fn new_keeping<F>(
478        layout_style: LayoutStyle,
479        offset: (RwSignal<f32>, RwSignal<f32>),
480        build: F,
481    ) -> Result<Self, LayoutError>
482    where
483        F: FnOnce(ScrollViewport) -> Result<Box<dyn LayoutItem>, LayoutError>,
484    {
485        let leaf = LayoutLeaf::register(layout_style)?;
486        let (scroll_x, scroll_y) = offset;
487        let content = build(ScrollViewport {
488            offset_x: scroll_x.read_only(),
489            offset_y: scroll_y.read_only(),
490            rect: leaf.rect.read_only(),
491            set_x: scroll_x.clone(),
492            set_y: scroll_y.clone(),
493        })?;
494        let content_node = content.layout_node();
495        let content_rect_signal =
496            track_layout(content_node).expect("content node not registered in ctx");
497
498        // Re-lay out the content at the viewport width (unbounded height, so it can overflow and scroll)
499        // each time the viewport resizes. The viewport rect is set by the surrounding layout; this effect
500        // fires during that flush — after the runtime borrow is released — so computing here is re-entrancy
501        // safe (same pattern as reactive lists).
502        let viewport = leaf.rect.clone();
503        let layout_effect = effect(move || {
504            let vp = viewport.get();
505            if vp.width > 0.0 {
506                let _ = crate::context::compute_layout_root(
507                    content_node,
508                    AvailableSpace::Definite(vp.width),
509                    AvailableSpace::MaxContent,
510                );
511            }
512        });
513
514        // Nothing may be scrolled past the end of what there is, and *both* things that decide where the end
515        // is move underneath the offset: how tall the content is (a page swapped for a shorter one, a list
516        // that lost rows) and how tall the viewport is (a window resized). Clamped from an effect rather than
517        // from the scroll handler because neither of those is an input event — left to the next wheel tick,
518        // the transform goes on pushing the content clean out of the clip and the viewport shows *nothing*,
519        // which is the shape this bug always takes: a page that is blank until it is touched.
520        let clamp_effect = {
521            let viewport = leaf.rect.clone();
522            let content_rect = content_rect_signal.clone();
523            let (scroll_x, scroll_y) = (scroll_x.clone(), scroll_y.clone());
524            effect(move || {
525                let vp = viewport.get();
526                let content = content_rect.get();
527                // A zero rect is "not laid out yet", not "empty": clamping against it would throw away an
528                // offset the caller deliberately kept, one flush before the layout that justifies it.
529                if vp.height <= 0.0 || content.height <= 0.0 {
530                    return;
531                }
532                let max_x = (content.width - vp.width).max(0.0);
533                let max_y = (content.height - vp.height).max(0.0);
534                // `peek`, not `get`: this effect writes those signals, and reading them would make it its own
535                // dependency and re-run it for its own correction.
536                if scroll_x.peek() > max_x {
537                    scroll_x.set(max_x);
538                }
539                if scroll_y.peek() > max_y {
540                    scroll_y.set(max_y);
541                }
542            })
543        };
544
545        // Registered on the CONTENT node, not the viewport leaf: the content is laid out as its own root (see the effect above), so the leaf is never its ancestor and a subtree test against it would miss.
546        let scroll_region =
547            register_scroll_region(content_node, scroll_x.clone(), scroll_y.clone());
548
549        Ok(Self {
550            leaf,
551            core: ScrollCore::with_offsets(content_rect_signal, content, scroll_x, scroll_y),
552            scroll_region,
553            _layout_effect: layout_effect,
554            _clamp_effect: clamp_effect,
555        })
556    }
557
558    /// The live scroll offset `(x, y)` signals, for a caller that constructed the scroll via
559    /// [`new`](Self::new) and wants to read the offset after the fact.
560    pub fn scroll_offset(&self) -> (ReadSignal<f32>, ReadSignal<f32>) {
561        (
562            self.core.scroll_x.read_only(),
563            self.core.scroll_y.read_only(),
564        )
565    }
566
567    pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
568        self.core.scrollbar_style = style;
569        self
570    }
571
572    pub fn clamp_scroll(&mut self) {
573        self.core.clamp_scroll(self.leaf.rect.get());
574    }
575
576    /// Resets the scroll offset to the top-left, e.g. when swapping the content shown in the viewport.
577    pub fn scroll_to_top(&mut self) {
578        self.core.scroll_to_top();
579    }
580
581    pub fn viewport_rect(&self) -> Rect {
582        self.leaf.rect.get()
583    }
584}
585
586impl Drop for LayoutScrollArea {
587    fn drop(&mut self) {
588        unregister_scroll_region(self.scroll_region);
589    }
590}
591
592impl_leaf_widget!(LayoutScrollArea);
593
594impl Component for LayoutScrollArea {
595    fn view(&self) -> RenderNode {
596        self.core.view(self.leaf.rect.get())
597    }
598
599    fn on_event(&mut self, event: &Event) -> EventResult {
600        self.core.on_event(event, self.leaf.rect.get())
601    }
602
603    fn debug_name(&self) -> &'static str {
604        "ScrollArea"
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use crate::context::reset_layout_runtime;
611    use geometry_core::Rect;
612    use layout_core::{AvailableSpace, LayoutStyle, NodeId, SizeDimension};
613    use platform_core::{Event, PointerSource, ScrollDelta};
614    use renderer_core::DrawCommand;
615    use ui_tree::{Component, EventResult, RenderNode};
616
617    use super::*;
618    use crate::canvas::Canvas;
619    use crate::context::{compute_layout, new_container, track_layout};
620    use crate::layout_item::LayoutItem;
621    use crate::layout_leaf::LayoutLeaf;
622
623    // Laying out only the scroll node must, via its effect, lay out the detached content subtree too —
624    // a `scroll` element has no other owner to compute its content.
625    #[test]
626    fn scroll_area_lays_out_its_detached_content() {
627        reset_layout_runtime();
628        let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
629            RenderNode::Empty
630        })
631        .unwrap();
632        let content_node = content.layout_node();
633        let scroll = LayoutScrollArea::new(
634            LayoutStyle::new().width(300.0).height(160.0),
635            Box::new(content),
636        )
637        .unwrap();
638        let scroll_node = scroll.layout_node();
639        compute_layout(
640            scroll_node,
641            AvailableSpace::Definite(300.0),
642            AvailableSpace::Definite(160.0),
643        )
644        .unwrap();
645        let content_rect = track_layout(content_node).unwrap().get();
646        assert!(
647            content_rect.height > 0.0,
648            "scroll must lay out its content, got {content_rect:?}"
649        );
650    }
651
652    /// A page swapped for a shorter one must not leave the viewport looking at nothing.
653    ///
654    /// This is the bug as a user meets it: scroll down a long page, switch to a short one, and the panel is
655    /// blank — the offset is still 600 while there is 200 of content, so the transform has pushed all of it
656    /// out of the clip. It comes back on the next wheel tick, which is what makes it read as a repaint bug
657    /// rather than a scroll one. Nothing here touches an input event: the layout alone has to put it right.
658    #[test]
659    fn content_that_gets_shorter_pulls_the_view_back_into_it() {
660        reset_layout_runtime();
661        let tall = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
662            RenderNode::Empty
663        })
664        .unwrap();
665        let short = Canvas::new(LayoutStyle::new().width(300.0).height(120.0), |_| {
666            RenderNode::Empty
667        })
668        .unwrap();
669        let short_node = short.layout_node();
670        let page = crate::container::Container::new(
671            LayoutStyle::new().flex_column(),
672            vec![Box::new(tall) as Box<dyn LayoutItem>],
673        )
674        .unwrap();
675        let page_node = page.layout_node();
676        let scroll = LayoutScrollArea::new(
677            LayoutStyle::new().width(300.0).height(200.0),
678            Box::new(page),
679        )
680        .unwrap();
681        compute_layout(
682            scroll.layout_node(),
683            AvailableSpace::Definite(300.0),
684            AvailableSpace::Definite(200.0),
685        )
686        .unwrap();
687
688        scroll.core.scroll_y.set(600.0);
689        assert_eq!(scroll.core.scroll_y.get(), 600.0, "600 of 700 scrollable");
690
691        // The page is swapped, exactly as a reactive list swaps what it shows.
692        crate::context::set_children(page_node, &[short_node]).unwrap();
693        crate::context::mark_dirty(page_node).unwrap();
694        crate::context::relayout_if_dirty();
695
696        assert_eq!(
697            scroll.core.scroll_y.get(),
698            0.0,
699            "a page shorter than the viewport has nothing to scroll, so the view is at its top — not \
700             600px past the end of it, drawing nothing"
701        );
702    }
703
704    /// A viewport command called from an effect must not subscribe that effect to the offset it writes.
705    ///
706    /// This is how the fix for "the page changed, put the view back at the top" turned into "the page can no
707    /// longer be scrolled at all": the effect noticing the page change also *read* the offset, so every wheel
708    /// tick re-ran it, and it dutifully put the view back at the top. Both commands are `peek`-only for this
709    /// reason, and both are exercised here — the launcher's follow-the-selection effect calls `reveal` from
710    /// exactly the same place.
711    #[test]
712    fn a_viewport_command_run_from_an_effect_does_not_undo_the_users_own_scrolling() {
713        reset_layout_runtime();
714        let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
715            RenderNode::Empty
716        })
717        .unwrap();
718        let row = content.layout_node();
719        let captured: Rc<RefCell<Option<ScrollViewport>>> = Rc::new(RefCell::new(None));
720        let sink = Rc::clone(&captured);
721        let scroll = LayoutScrollArea::new_with(
722            LayoutStyle::new().width(300.0).height(200.0),
723            move |viewport| {
724                *sink.borrow_mut() = Some(viewport);
725                Ok(Box::new(content) as Box<dyn LayoutItem>)
726            },
727        )
728        .unwrap();
729        compute_layout(
730            scroll.layout_node(),
731            AvailableSpace::Definite(300.0),
732            AvailableSpace::Definite(200.0),
733        )
734        .unwrap();
735        let viewport = captured.borrow().clone().expect("the builder ran");
736
737        // The shape every caller uses: an effect over what it is following, issuing a command.
738        let page = signal(0u32);
739        let watched = page.read_only();
740        let commanded = viewport.clone();
741        let followed = watched.clone();
742        let _follow = effect(move || {
743            followed.get();
744            commanded.scroll_to_top();
745        });
746
747        scroll.core.scroll_y.set(150.0);
748        reactive_core::batch(|| {});
749        assert_eq!(
750            scroll.core.scroll_y.get(),
751            150.0,
752            "scrolling is not a page change, so the effect must not have run and pulled the view back"
753        );
754
755        page.set(1);
756        reactive_core::batch(|| {});
757        assert_eq!(
758            scroll.core.scroll_y.get(),
759            0.0,
760            "and the command still does its job when the thing it follows actually changes"
761        );
762
763        // `reveal` is the same shape and the same trap.
764        let revealing = viewport.clone();
765        let _follow_row = effect(move || {
766            watched.get();
767            revealing.reveal(row, 4.0);
768        });
769        scroll.core.scroll_y.set(80.0);
770        reactive_core::batch(|| {});
771        assert_eq!(
772            scroll.core.scroll_y.get(),
773            80.0,
774            "a row already in view is left alone, and scrolling must not re-ask about it"
775        );
776    }
777
778    /// The same rule from the other side: the content stayed, the window grew.
779    #[test]
780    fn a_taller_viewport_pulls_the_view_back_into_the_content() {
781        reset_layout_runtime();
782        let content = Canvas::new(LayoutStyle::new().width(300.0).height(500.0), |_| {
783            RenderNode::Empty
784        })
785        .unwrap();
786        // Inside a column that fills the surface, which is how a page area actually gets its height: the
787        // viewport is whatever is left over, so resizing the surface resizes it.
788        let scroll = LayoutScrollArea::new(
789            LayoutStyle::new()
790                .width(SizeDimension::Percent(1.0))
791                .flex_grow(1.0)
792                .min_height(0.0),
793            Box::new(content),
794        )
795        .unwrap();
796        let scroll_node = scroll.layout_node();
797        let surface = new_container(
798            LayoutStyle::new()
799                .flex_column()
800                .width(SizeDimension::Percent(1.0))
801                .height(SizeDimension::Percent(1.0)),
802            &[scroll_node],
803        )
804        .unwrap();
805        compute_layout(
806            surface,
807            AvailableSpace::Definite(300.0),
808            AvailableSpace::Definite(100.0),
809        )
810        .unwrap();
811        scroll.core.scroll_y.set(400.0);
812
813        // The surface is resized taller — a float dragged by its grip, a monitor that changed mode.
814        crate::context::mark_dirty(surface).unwrap();
815        compute_layout(
816            surface,
817            AvailableSpace::Definite(300.0),
818            AvailableSpace::Definite(450.0),
819        )
820        .unwrap();
821
822        assert_eq!(
823            scroll.core.scroll_y.get(),
824            50.0,
825            "500 of content in 450 of window leaves 50 to scroll, and that is where the view lands"
826        );
827    }
828
829    /// A rebuilt tree — a shell following a config edit — is a second scroll area over the same offsets.
830    #[test]
831    fn a_scroll_built_on_kept_offsets_opens_where_the_last_one_left_off() {
832        reset_layout_runtime();
833        let offset = (signal(0.0f32), signal(0.0f32));
834        let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
835            RenderNode::Empty
836        })
837        .unwrap();
838        let first = LayoutScrollArea::new_keeping(
839            LayoutStyle::new().width(300.0).height(200.0),
840            offset.clone(),
841            |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
842        )
843        .unwrap();
844        compute_layout(
845            first.layout_node(),
846            AvailableSpace::Definite(300.0),
847            AvailableSpace::Definite(200.0),
848        )
849        .unwrap();
850        first.core.scroll_y.set(320.0);
851        drop(first);
852
853        let content = Canvas::new(LayoutStyle::new().width(300.0).height(900.0), |_| {
854            RenderNode::Empty
855        })
856        .unwrap();
857        let rebuilt = LayoutScrollArea::new_keeping(
858            LayoutStyle::new().width(300.0).height(200.0),
859            offset,
860            |_| Ok(Box::new(content) as Box<dyn LayoutItem>),
861        )
862        .unwrap();
863        assert_eq!(
864            rebuilt.core.scroll_y.get(),
865            320.0,
866            "the tree was replaced, the reader's place in it was not"
867        );
868        compute_layout(
869            rebuilt.layout_node(),
870            AvailableSpace::Definite(300.0),
871            AvailableSpace::Definite(200.0),
872        )
873        .unwrap();
874        assert_eq!(
875            rebuilt.core.scroll_y.get(),
876            320.0,
877            "and laying the rebuilt content out does not throw it away — a 0×0 rect is 'not measured yet', \
878             not 'nothing to show'"
879        );
880    }
881
882    // The end-to-end shape of the anchored-overlay bug: a trigger deep inside a real scroll area, scrolled away from where it was laid out. Exercises the two things the unit tests cannot: that the area registers its content (not its viewport leaf, which is never the content's ancestor), and that the subtree test reaches into the separately-computed content root.
883    #[test]
884    fn a_trigger_scrolled_inside_a_scroll_area_anchors_where_it_is_drawn() {
885        reset_layout_runtime();
886        let spacer = Canvas::new(LayoutStyle::new().width(400.0).height(600.0), |_| {
887            RenderNode::Empty
888        })
889        .unwrap();
890        let trigger = Canvas::new(LayoutStyle::new().width(80.0).height(24.0), |_| {
891            RenderNode::Empty
892        })
893        .unwrap();
894        let trigger_node = trigger.layout_node();
895        let content = crate::container::Container::new(
896            LayoutStyle::new().flex_column(),
897            vec![Box::new(spacer) as Box<dyn LayoutItem>, Box::new(trigger)],
898        )
899        .unwrap();
900        let scroll = LayoutScrollArea::new(
901            LayoutStyle::new().width(300.0).height(200.0),
902            Box::new(content),
903        )
904        .unwrap();
905        compute_layout(
906            scroll.layout_node(),
907            AvailableSpace::Definite(300.0),
908            AvailableSpace::Definite(200.0),
909        )
910        .unwrap();
911
912        let laid_out = crate::context::absolute_rect(trigger_node).unwrap();
913        assert!(laid_out.y > 200.0, "the trigger starts below the fold");
914        assert_eq!(
915            crate::scroll_region::visible_rect(trigger_node),
916            Some(laid_out),
917            "unscrolled, drawn position and laid-out position agree"
918        );
919
920        scroll.core.scroll_y.set(150.0);
921        let drawn = crate::scroll_region::visible_rect(trigger_node).unwrap();
922        assert_eq!(
923            drawn.y,
924            laid_out.y - 150.0,
925            "scrolling down draws the trigger higher, and that is where a panel must anchor"
926        );
927
928        // Dropping the area withdraws its registration, so a later query is not shifted by a dead viewport.
929        drop(scroll);
930        assert_eq!(
931            crate::scroll_region::visible_rect(trigger_node),
932            Some(laid_out)
933        );
934    }
935
936    fn make_scroll_area() -> ScrollArea {
937        reset_layout_runtime();
938        let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
939            RenderNode::Empty
940        })
941        .unwrap();
942        let node = content.layout_node();
943        let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
944        compute_layout(
945            node,
946            AvailableSpace::Definite(400.0),
947            AvailableSpace::MaxContent,
948        )
949        .unwrap();
950        sa
951    }
952
953    fn make_scroll_area_small() -> ScrollArea {
954        reset_layout_runtime();
955        let content = Canvas::new(LayoutStyle::new().width(400.0).height(200.0), |_| {
956            RenderNode::Empty
957        })
958        .unwrap();
959        let node = content.layout_node();
960        let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
961        compute_layout(
962            node,
963            AvailableSpace::Definite(400.0),
964            AvailableSpace::MaxContent,
965        )
966        .unwrap();
967        sa
968    }
969
970    #[test]
971    fn scroll_content_click_force_tick_no_panic() {
972        use crate::container::Container;
973        use crate::context::track_layout;
974        use crate::styled_container::StyledContainer;
975        use platform_core::PointerButton;
976        use reactive_core::{begin_batch, end_batch, signal};
977
978        reset_layout_runtime();
979        let s = signal(0i32);
980        let s_cb = s.clone();
981        // A pressable primitive stands in for the old high-level Button (now in ui-components).
982        let btn = StyledContainer::new(
983            LayoutStyle::new().width(50.0).height(30.0),
984            |_r| RectStyle::default(),
985            vec![],
986        )
987        .unwrap()
988        .on_press(move || s_cb.update(|n| *n += 1));
989        let btn_node = btn.layout_node();
990        let s_txt = s.clone();
991        let txt = crate::text::Text::new(
992            move || format!("{}", s_txt.get()),
993            LayoutStyle::new().width(50.0).height(20.0),
994            || renderer_core::TextStyle::new(14.0, renderer_core::Color::BLACK),
995        )
996        .unwrap();
997        let content = Container::new(
998            LayoutStyle::new().flex_column().width(400.0).height(1000.0),
999            vec![Box::new(btn), Box::new(txt)],
1000        )
1001        .unwrap();
1002        let content_node = content.layout_node();
1003        let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1004        compute_layout(
1005            content_node,
1006            AvailableSpace::Definite(400.0),
1007            AvailableSpace::MaxContent,
1008        )
1009        .unwrap();
1010        let br = track_layout(btn_node).unwrap().get();
1011
1012        let mut tree = crate::ComponentList::new(sa);
1013        let _ = tree.commands();
1014
1015        // The button fires on release (tap), so send press then release.
1016        let cx = (br.x + br.width / 2.0) as f64;
1017        let cy = (br.y + br.height / 2.0) as f64;
1018        for phase in [true, false] {
1019            begin_batch();
1020            let ev = if phase {
1021                Event::PointerPressed {
1022                    x: cx,
1023                    y: cy,
1024                    button: PointerButton::Primary,
1025                    source: PointerSource::Mouse,
1026                }
1027            } else {
1028                Event::PointerReleased {
1029                    x: cx,
1030                    y: cy,
1031                    button: PointerButton::Primary,
1032                    source: PointerSource::Mouse,
1033                }
1034            };
1035            if tree.on_event(&ev) == EventResult::Handled {
1036                tree.bump_force_ticks();
1037                end_batch();
1038                begin_batch();
1039            }
1040            let _ = tree.commands();
1041            end_batch();
1042        }
1043        assert_eq!(
1044            s.get(),
1045            1,
1046            "scroll-content click should increment the signal"
1047        );
1048    }
1049
1050    // A scroll gesture that begins on a button (touch-down, drag past the slop, release) must scroll the
1051    // content and NOT click the button — the scroll area cancels the pending tap once it detects the scroll.
1052    #[test]
1053    fn scroll_gesture_over_button_does_not_click() {
1054        use crate::container::Container;
1055        use crate::context::track_layout;
1056        use crate::styled_container::StyledContainer;
1057        use platform_core::PointerButton;
1058        use reactive_core::signal;
1059
1060        reset_layout_runtime();
1061        let s = signal(0i32);
1062        let s_cb = s.clone();
1063        // A pressable primitive stands in for the old high-level Button (now in ui-components).
1064        let btn = StyledContainer::new(
1065            LayoutStyle::new().width(50.0).height(30.0),
1066            |_r| RectStyle::default(),
1067            vec![],
1068        )
1069        .unwrap()
1070        .on_press(move || s_cb.update(|n| *n += 1));
1071        let btn_node = btn.layout_node();
1072        let content = Container::new(
1073            LayoutStyle::new().flex_column().width(400.0).height(1000.0),
1074            vec![Box::new(btn)],
1075        )
1076        .unwrap();
1077        let content_node = content.layout_node();
1078        let mut sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1079        compute_layout(
1080            content_node,
1081            AvailableSpace::Definite(400.0),
1082            AvailableSpace::MaxContent,
1083        )
1084        .unwrap();
1085        let br = track_layout(btn_node).unwrap().get();
1086        let (cx, cy) = (
1087            (br.x + br.width / 2.0) as f64,
1088            (br.y + br.height / 2.0) as f64,
1089        );
1090
1091        // Touch-down on the button, then a drag: on Android each move sends Scrolled + PointerMoved.
1092        sa.on_event(&Event::PointerPressed {
1093            x: cx,
1094            y: cy,
1095            button: PointerButton::Primary,
1096            source: PointerSource::Touch { id: 1 },
1097        });
1098        for _ in 0..5 {
1099            sa.on_event(&Event::Scrolled {
1100                delta: ScrollDelta::Pixels { x: 0.0, y: -20.0 },
1101            });
1102            sa.on_event(&Event::PointerMoved {
1103                x: cx,
1104                y: cy,
1105                source: PointerSource::Touch { id: 1 },
1106            });
1107        }
1108        sa.on_event(&Event::PointerReleased {
1109            x: cx,
1110            y: cy,
1111            button: PointerButton::Primary,
1112            source: PointerSource::Touch { id: 1 },
1113        });
1114
1115        assert_eq!(
1116            s.get(),
1117            0,
1118            "a scroll gesture over a button must not click it"
1119        );
1120        assert!(
1121            sa.core.scroll_y.get() > 0.0,
1122            "the gesture should have scrolled the content"
1123        );
1124    }
1125
1126    #[test]
1127    fn as_layout_item_uses_leaf_rect_as_viewport() {
1128        reset_layout_runtime();
1129        let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1130            RenderNode::Empty
1131        })
1132        .unwrap();
1133        let content_node = content.layout_node();
1134        let sa = LayoutScrollArea::new(
1135            LayoutStyle::new().width(400.0).height(300.0),
1136            Box::new(content),
1137        )
1138        .unwrap();
1139        let root = new_container(
1140            LayoutStyle::new().flex_column().width(400.0).height(300.0),
1141            &[sa.layout_node()],
1142        )
1143        .unwrap();
1144        compute_layout(
1145            root,
1146            AvailableSpace::Definite(400.0),
1147            AvailableSpace::Definite(300.0),
1148        )
1149        .unwrap();
1150        compute_layout(
1151            content_node,
1152            AvailableSpace::Definite(400.0),
1153            AvailableSpace::MaxContent,
1154        )
1155        .unwrap();
1156        let vp = sa.viewport_rect();
1157        assert_eq!(vp.width, 400.0);
1158        assert_eq!(vp.height, 300.0);
1159    }
1160
1161    #[test]
1162    fn as_layout_item_emits_clip_and_vbar_on_overflow() {
1163        reset_layout_runtime();
1164        let content = Canvas::new(LayoutStyle::new().width(400.0).height(1000.0), |_| {
1165            RenderNode::Empty
1166        })
1167        .unwrap();
1168        let content_node = content.layout_node();
1169        let sa = LayoutScrollArea::new(
1170            LayoutStyle::new().width(400.0).height(300.0),
1171            Box::new(content),
1172        )
1173        .unwrap();
1174        let root = new_container(
1175            LayoutStyle::new().flex_column().width(400.0).height(300.0),
1176            &[sa.layout_node()],
1177        )
1178        .unwrap();
1179        compute_layout(
1180            root,
1181            AvailableSpace::Definite(400.0),
1182            AvailableSpace::Definite(300.0),
1183        )
1184        .unwrap();
1185        compute_layout(
1186            content_node,
1187            AvailableSpace::Definite(400.0),
1188            AvailableSpace::MaxContent,
1189        )
1190        .unwrap();
1191        if let RenderNode::Group { children, .. } = sa.view() {
1192            assert_eq!(children.len(), 3);
1193            assert!(matches!(&children[0], RenderNode::Clip { .. }));
1194            assert!(matches!(
1195                &children[1],
1196                RenderNode::Primitive(DrawCommand::Rect { .. })
1197            ));
1198        } else {
1199            panic!("expected Group");
1200        }
1201    }
1202
1203    #[test]
1204    fn scroll_lines_updates_offset() {
1205        let mut sa = make_scroll_area();
1206        sa.on_event(&Event::Scrolled {
1207            delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1208        });
1209        assert_eq!(sa.core.scroll_y.get(), 60.0);
1210    }
1211
1212    // Nested scroll: a wheel event is ignored when the pointer is outside this viewport (it belongs to an
1213    // ancestor, or to an inner scroll that already consumed it), so the outer area does not steal it.
1214    #[test]
1215    fn wheel_outside_viewport_does_not_scroll() {
1216        let mut sa = make_scroll_area(); // viewport 400x300
1217        sa.on_event(&Event::PointerMoved {
1218            x: 500.0,
1219            y: 500.0,
1220            source: PointerSource::Mouse,
1221        });
1222        let result = sa.on_event(&Event::Scrolled {
1223            delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1224        });
1225        assert_eq!(result, EventResult::Ignored);
1226        assert_eq!(
1227            sa.core.scroll_y.get(),
1228            0.0,
1229            "must not scroll when the pointer is elsewhere"
1230        );
1231    }
1232
1233    #[test]
1234    fn wheel_inside_viewport_scrolls() {
1235        let mut sa = make_scroll_area();
1236        sa.on_event(&Event::PointerMoved {
1237            x: 100.0,
1238            y: 100.0,
1239            source: PointerSource::Mouse,
1240        });
1241        sa.on_event(&Event::Scrolled {
1242            delta: ScrollDelta::Lines { x: 0.0, y: -3.0 },
1243        });
1244        assert!(
1245            sa.core.scroll_y.get() > 0.0,
1246            "wheel over the viewport scrolls it"
1247        );
1248    }
1249
1250    #[test]
1251    fn scroll_pixels_updates_offset() {
1252        let mut sa = make_scroll_area();
1253        sa.on_event(&Event::Scrolled {
1254            delta: ScrollDelta::Pixels { x: 0.0, y: -80.0 },
1255        });
1256        assert_eq!(sa.core.scroll_y.get(), 80.0);
1257    }
1258
1259    #[test]
1260    fn scroll_clamps_to_max() {
1261        let mut sa = make_scroll_area();
1262        sa.on_event(&Event::Scrolled {
1263            delta: ScrollDelta::Pixels { x: 0.0, y: -9999.0 },
1264        });
1265        assert_eq!(sa.core.scroll_y.get(), 700.0);
1266    }
1267
1268    #[test]
1269    fn scroll_clamps_to_zero() {
1270        let mut sa = make_scroll_area();
1271        sa.on_event(&Event::Scrolled {
1272            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
1273        });
1274        assert_eq!(sa.core.scroll_y.get(), 0.0);
1275    }
1276
1277    #[test]
1278    fn pointer_outside_viewport_is_ignored() {
1279        let mut sa = make_scroll_area();
1280        let result = sa.on_event(&Event::PointerMoved {
1281            x: 500.0,
1282            y: 100.0,
1283            source: PointerSource::Mouse,
1284        });
1285        assert!(matches!(result, EventResult::Ignored));
1286    }
1287
1288    #[test]
1289    fn view_emits_clip_and_scrollbar_when_content_overflows() {
1290        let sa = make_scroll_area();
1291        let view = sa.view();
1292        if let RenderNode::Group { children, .. } = view {
1293            assert_eq!(children.len(), 3);
1294            assert!(matches!(&children[0], RenderNode::Clip { .. }));
1295            assert!(matches!(
1296                &children[1],
1297                RenderNode::Primitive(DrawCommand::Rect { .. })
1298            ));
1299        } else {
1300            panic!("expected Group");
1301        }
1302    }
1303
1304    #[test]
1305    fn view_no_scrollbar_when_content_fits() {
1306        let sa = make_scroll_area_small();
1307        let view = sa.view();
1308        if let RenderNode::Group { children, .. } = view {
1309            assert!(matches!(&children[1], RenderNode::Empty));
1310        } else {
1311            panic!("expected Group");
1312        }
1313    }
1314
1315    #[test]
1316    fn child_receives_offset_pointer_event() {
1317        use std::cell::Cell;
1318        use std::rc::Rc;
1319
1320        let captured_y: Rc<Cell<f64>> = Rc::new(Cell::new(-1.0));
1321        let captured_y_clone = captured_y.clone();
1322
1323        struct CapturingItem {
1324            leaf: LayoutLeaf,
1325            out: Rc<Cell<f64>>,
1326        }
1327        impl Component for CapturingItem {
1328            fn view(&self) -> RenderNode {
1329                RenderNode::Empty
1330            }
1331            fn on_event(&mut self, event: &Event) -> EventResult {
1332                if let Event::PointerMoved { y, .. } = event {
1333                    self.out.set(*y);
1334                    EventResult::Handled
1335                } else {
1336                    EventResult::Ignored
1337                }
1338            }
1339        }
1340        impl LayoutItem for CapturingItem {
1341            fn layout_node(&self) -> NodeId {
1342                self.leaf.node
1343            }
1344        }
1345
1346        reset_layout_runtime();
1347        let leaf = LayoutLeaf::register(LayoutStyle::new().width(400.0).height(1000.0)).unwrap();
1348        let node = leaf.node;
1349        let content = CapturingItem {
1350            leaf,
1351            out: captured_y_clone,
1352        };
1353        let mut sa = ScrollArea::new(|| Rect::new(100.0, 50.0, 400.0, 300.0), Box::new(content));
1354        compute_layout(
1355            node,
1356            AvailableSpace::Definite(400.0),
1357            AvailableSpace::MaxContent,
1358        )
1359        .unwrap();
1360
1361        sa.on_event(&Event::Scrolled {
1362            delta: ScrollDelta::Pixels { x: 0.0, y: -100.0 },
1363        });
1364
1365        sa.on_event(&Event::PointerMoved {
1366            x: 150.0,
1367            y: 200.0,
1368            source: PointerSource::Mouse,
1369        });
1370
1371        assert!((captured_y.get() - 250.0).abs() < 0.001);
1372    }
1373
1374    fn make_scroll_area_wide() -> ScrollArea {
1375        reset_layout_runtime();
1376        let content = Canvas::new(LayoutStyle::new().width(1000.0).height(300.0), |_| {
1377            RenderNode::Empty
1378        })
1379        .unwrap();
1380        let node = content.layout_node();
1381        let sa = ScrollArea::new(|| Rect::new(0.0, 0.0, 400.0, 300.0), Box::new(content));
1382        compute_layout(
1383            node,
1384            AvailableSpace::Definite(1000.0),
1385            AvailableSpace::MaxContent,
1386        )
1387        .unwrap();
1388        sa
1389    }
1390
1391    #[test]
1392    fn scroll_x_lines_updates_offset() {
1393        let mut sa = make_scroll_area_wide();
1394        sa.on_event(&Event::Scrolled {
1395            delta: ScrollDelta::Lines { x: -3.0, y: 0.0 },
1396        });
1397        assert_eq!(sa.core.scroll_x.get(), 60.0);
1398    }
1399
1400    #[test]
1401    fn scroll_x_pixels_updates_offset() {
1402        let mut sa = make_scroll_area_wide();
1403        sa.on_event(&Event::Scrolled {
1404            delta: ScrollDelta::Pixels { x: -80.0, y: 0.0 },
1405        });
1406        assert_eq!(sa.core.scroll_x.get(), 80.0);
1407    }
1408
1409    #[test]
1410    fn scroll_x_clamps_to_max() {
1411        let mut sa = make_scroll_area_wide();
1412        sa.on_event(&Event::Scrolled {
1413            delta: ScrollDelta::Pixels { x: -9999.0, y: 0.0 },
1414        });
1415        assert_eq!(sa.core.scroll_x.get(), 600.0);
1416    }
1417
1418    #[test]
1419    fn scroll_x_clamps_to_zero() {
1420        let mut sa = make_scroll_area_wide();
1421        sa.on_event(&Event::Scrolled {
1422            delta: ScrollDelta::Pixels { x: 9999.0, y: 0.0 },
1423        });
1424        assert_eq!(sa.core.scroll_x.get(), 0.0);
1425    }
1426
1427    #[test]
1428    fn view_emits_hbar_when_content_overflows_x() {
1429        let sa = make_scroll_area_wide();
1430        let view = sa.view();
1431        if let RenderNode::Group { children, .. } = view {
1432            assert_eq!(children.len(), 3);
1433            assert!(matches!(&children[0], RenderNode::Clip { .. }));
1434            assert!(matches!(&children[1], RenderNode::Empty));
1435            assert!(matches!(
1436                &children[2],
1437                RenderNode::Primitive(DrawCommand::Rect { .. })
1438            ));
1439        } else {
1440            panic!("expected Group");
1441        }
1442    }
1443
1444    #[test]
1445    fn view_no_hbar_when_content_fits_x() {
1446        let sa = make_scroll_area();
1447        let view = sa.view();
1448        if let RenderNode::Group { children, .. } = view {
1449            assert_eq!(children.len(), 3);
1450            assert!(matches!(&children[2], RenderNode::Empty));
1451        } else {
1452            panic!("expected Group");
1453        }
1454    }
1455}