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