Skip to main content

telar_ui_core/
scroll_region.rs

1//! Where a node actually *is* on screen, once the scroll viewports around it are accounted for.
2//!
3//! Scrolling is a render transform, not a relayout: a scroll area moves its content by rewriting a matrix and
4//! leaves the layout tree exactly where it was. That makes `absolute_rect` — which reads laid-out positions —
5//! report where a node *would* be at scroll zero, which is wrong for anything positioned against the node's
6//! visible spot. Anchored overlays are the case that matters: a dropdown opened from a trigger scrolled 200px
7//! down would otherwise appear 200px below its button.
8//!
9//! Each scroll area registers its content subtree here with the offset signals driving it, and
10//! [`visible_rect`] subtracts every registered offset whose subtree contains the node — so nested scrolls
11//! compose without either of them knowing about the other.
12
13use std::cell::RefCell;
14use std::mem::ManuallyDrop;
15
16use geometry_core::Rect;
17use layout_core::NodeId;
18use reactive_core::RwSignal;
19
20use crate::context::{absolute_rect, is_descendant_of};
21
22/// Identifies one registration, so a scroll area can withdraw exactly its own on drop.
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub struct ScrollRegionId(u64);
25
26struct ScrollRegion {
27    id: ScrollRegionId,
28    /// Root of the scrolled subtree — the scroll's *content* node, not its viewport leaf. The content is laid
29    /// out as its own root rather than as a child of the viewport, so the viewport is not its ancestor.
30    content: NodeId,
31    offset_x: RwSignal<f32>,
32    offset_y: RwSignal<f32>,
33}
34
35// ManuallyDrop keeps these TLS slots trivially-destructible: registering a TLS destructor from a hot-reloaded dylib would make dlclose unsafe (same constraint as `dismiss` and `named_overlay`).
36thread_local! {
37    static REGIONS: ManuallyDrop<RefCell<Vec<ScrollRegion>>> = ManuallyDrop::new(RefCell::new(Vec::new()));
38    static NEXT_ID: ManuallyDrop<RefCell<u64>> = ManuallyDrop::new(RefCell::new(0));
39}
40
41/// Registers `content` as a scrolled subtree displaced by `(offset_x, offset_y)`.
42pub fn register_scroll_region(
43    content: NodeId,
44    offset_x: RwSignal<f32>,
45    offset_y: RwSignal<f32>,
46) -> ScrollRegionId {
47    let id = NEXT_ID.with(|n| {
48        let mut n = n.borrow_mut();
49        *n += 1;
50        ScrollRegionId(*n)
51    });
52    REGIONS.with(|regions| {
53        regions.borrow_mut().push(ScrollRegion {
54            id,
55            content,
56            offset_x,
57            offset_y,
58        })
59    });
60    id
61}
62
63/// Withdraws a registration. A no-op for an id already withdrawn.
64pub fn unregister_scroll_region(id: ScrollRegionId) {
65    REGIONS.with(|regions| regions.borrow_mut().retain(|r| r.id != id));
66}
67
68/// The rect `node` occupies on screen: its laid-out window-absolute rect shifted by the current offset of
69/// every scroll viewport it sits inside. `None` when the node has not been laid out under a window root.
70///
71/// Offsets are read without subscribing, because this answers "where is it right now" for a caller that is
72/// positioning something at that moment (an overlay being opened), not one that wants to follow the scroll.
73pub fn visible_rect(node: NodeId) -> Option<Rect> {
74    let mut rect = absolute_rect(node)?;
75    REGIONS.with(|regions| {
76        for region in regions.borrow().iter() {
77            if is_descendant_of(node, region.content) {
78                rect.x -= region.offset_x.peek();
79                rect.y -= region.offset_y.peek();
80            }
81        }
82    });
83    Some(rect)
84}
85
86#[cfg(test)]
87mod tests {
88    use layout_core::{AvailableSpace, LayoutStyle, SizeDimension};
89    use reactive_core::signal;
90
91    use super::*;
92    use crate::container::Container;
93    use crate::context::{compute_layout, new_container, new_leaf, reset_layout_runtime};
94    use crate::layout_item::LayoutItem;
95
96    fn reset() {
97        REGIONS.with(|regions| regions.borrow_mut().clear());
98    }
99
100    /// A trigger inside a scrolled subtree must report where it is *drawn*, not where it was laid out — the
101    /// whole reason an anchored dropdown was landing under the wrong place.
102    #[test]
103    fn a_scrolled_node_reports_its_on_screen_position() {
104        reset_layout_runtime();
105        reset();
106        let (trigger, _r) = new_leaf(LayoutStyle::new().width(50.0).height(20.0)).unwrap();
107        let content = new_container(
108            LayoutStyle::new()
109                .flex_column()
110                .width(SizeDimension::Percent(1.0)),
111            &[trigger],
112        )
113        .unwrap();
114        compute_layout(
115            content,
116            AvailableSpace::Definite(300.0),
117            AvailableSpace::Definite(300.0),
118        )
119        .unwrap();
120
121        let laid_out = absolute_rect(trigger).unwrap();
122        assert_eq!(
123            visible_rect(trigger),
124            Some(laid_out),
125            "unscrolled: identical"
126        );
127
128        let (x, y) = (signal(0.0f32), signal(120.0f32));
129        let id = register_scroll_region(content, x.clone(), y.clone());
130        let shifted = visible_rect(trigger).unwrap();
131        assert_eq!(
132            shifted.y,
133            laid_out.y - 120.0,
134            "scrolled down 120px, so it is drawn 120px higher"
135        );
136        assert_eq!(shifted.x, laid_out.x);
137        assert_eq!(
138            shifted.width, laid_out.width,
139            "scrolling moves a node, it does not resize it"
140        );
141
142        // Scrolling back restores the laid-out position, and withdrawing stops the adjustment entirely.
143        y.set(0.0);
144        assert_eq!(visible_rect(trigger), Some(laid_out));
145        y.set(80.0);
146        unregister_scroll_region(id);
147        assert_eq!(visible_rect(trigger), Some(laid_out));
148    }
149
150    /// Nested scrolls compose: each contributes its own offset, without either knowing about the other.
151    #[test]
152    fn nested_scroll_offsets_accumulate() {
153        reset_layout_runtime();
154        reset();
155        let inner_leaf =
156            Container::new(LayoutStyle::new().width(10.0).height(10.0), vec![]).unwrap();
157        let trigger = inner_leaf.layout_node();
158        let inner = new_container(LayoutStyle::new().flex_column(), &[trigger]).unwrap();
159        let outer = new_container(LayoutStyle::new().flex_column(), &[inner]).unwrap();
160        compute_layout(
161            outer,
162            AvailableSpace::Definite(300.0),
163            AvailableSpace::Definite(300.0),
164        )
165        .unwrap();
166        let laid_out = absolute_rect(trigger).unwrap();
167
168        register_scroll_region(outer, signal(0.0), signal(30.0));
169        register_scroll_region(inner, signal(5.0), signal(7.0));
170        let shifted = visible_rect(trigger).unwrap();
171        assert_eq!(shifted.y, laid_out.y - 37.0);
172        assert_eq!(shifted.x, laid_out.x - 5.0);
173    }
174
175    /// A node outside a registered subtree is untouched by that scroll — otherwise every overlay in the app
176    /// would shift whenever any unrelated pane scrolled.
177    #[test]
178    fn a_node_outside_the_region_is_unaffected() {
179        reset_layout_runtime();
180        reset();
181        let (inside, _a) = new_leaf(LayoutStyle::new().width(10.0).height(10.0)).unwrap();
182        let (outside, _b) = new_leaf(LayoutStyle::new().width(10.0).height(10.0)).unwrap();
183        let scrolled = new_container(LayoutStyle::new().flex_column(), &[inside]).unwrap();
184        let root = new_container(LayoutStyle::new().flex_column(), &[scrolled, outside]).unwrap();
185        compute_layout(
186            root,
187            AvailableSpace::Definite(200.0),
188            AvailableSpace::Definite(200.0),
189        )
190        .unwrap();
191
192        let outside_before = absolute_rect(outside).unwrap();
193        register_scroll_region(scrolled, signal(0.0), signal(50.0));
194        assert_eq!(visible_rect(outside), Some(outside_before));
195        assert_eq!(
196            visible_rect(inside).unwrap().y,
197            absolute_rect(inside).unwrap().y - 50.0
198        );
199    }
200}