Skip to main content

telar_ui_core/
lazy.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use geometry_core::Rect;
5use layout_core::{LayoutError, LayoutStyle, NodeId};
6use platform_core::Event;
7use reactive_core::{Effect, RwSignal, effect, signal};
8use ui_tree::{Component, EventResult, RenderNode};
9
10use crate::context::{mark_dirty, new_container, set_children, set_display, track_layout};
11use crate::layout_item::{LayoutItem, TrackedChildren, make_child};
12use crate::pointer::dispatch_container_event;
13
14/// The deferred subtree, taken out of the cell and run the first time the block is shown.
15type LazyBuild = Box<dyn FnOnce() -> Result<Vec<Box<dyn LayoutItem>>, LayoutError>>;
16
17struct LazyState {
18    node: NodeId,
19    children: TrackedChildren,
20    build: Option<LazyBuild>,
21}
22
23/// A subtree that is not built until the first time it would be shown — `lazy when:$cond { … }` in `.rsx`.
24///
25/// This is the general form of what a [`NavHost`](../../navigate_core/struct.NavHost.html) does per route:
26/// pay for a screen when the user first reaches it, not at startup. Use it for anything expensive behind a
27/// condition the user may never satisfy — a settings panel, an inspector, a tab body, a chart that only some
28/// accounts see.
29///
30/// It is deliberately *not* what a reactive `if $cond` does. That builds its branch whenever the condition
31/// turns true and disposes it when it turns false, so a repeatedly toggled panel is rebuilt every time and
32/// loses whatever state it held. This builds **once**, on the first `true`, and from then on only shows or
33/// hides the same subtree — so scroll position, form entry and in-flight work survive being closed and
34/// reopened. The cost is symmetric: a subtree shown once is held until the whole block is dropped.
35pub struct Lazy {
36    node: NodeId,
37    rect: RwSignal<Rect>,
38    state: Rc<RefCell<LazyState>>,
39    visible: Rc<dyn Fn() -> bool>,
40    /// Bumped when the subtree is finally built, so `view()` (which reads it) re-emits with real children.
41    version: RwSignal<u64>,
42    _effect: Effect,
43}
44
45impl Lazy {
46    /// `visible` is the reactive condition; `build` constructs the children the first time it holds, against
47    /// the live layout tree from inside the tracking effect — the same way a reactive list builds its items.
48    pub fn new(
49        container_style: LayoutStyle,
50        visible: impl Fn() -> bool + 'static,
51        build: impl FnOnce() -> Result<Vec<Box<dyn LayoutItem>>, LayoutError> + 'static,
52    ) -> Result<Self, LayoutError> {
53        let node = new_container(container_style, &[])?;
54        let rect = track_layout(node).expect("lazy container is registered");
55        let state = Rc::new(RefCell::new(LazyState {
56            node,
57            children: Vec::new(),
58            build: Some(Box::new(build)),
59        }));
60        let version = signal(0u64);
61        let visible: Rc<dyn Fn() -> bool> = Rc::new(visible);
62
63        let eff_state = Rc::clone(&state);
64        let eff_version = version.clone();
65        let eff_visible = Rc::clone(&visible);
66        // Runs once now — which is what makes an initially-false block cost nothing — and again on every change to a signal the condition reads.
67        let _effect = effect(move || {
68            let show = eff_visible();
69            if show && realize(&eff_state) {
70                eff_version.update(|v| *v = v.wrapping_add(1));
71            }
72            set_display(node, show);
73            mark_dirty(node).ok();
74        });
75
76        Ok(Self {
77            node,
78            rect,
79            state,
80            visible,
81            version,
82            _effect,
83        })
84    }
85
86    /// Whether the subtree has been built yet — false until the condition first holds.
87    pub fn is_built(&self) -> bool {
88        self.state.borrow().build.is_none()
89    }
90}
91
92/// Builds the deferred children if this is the first showing, reporting whether it did any work. Taking the
93/// builder out of the cell is what makes it once-only: every later showing finds `None` and just toggles
94/// display.
95fn realize(state: &Rc<RefCell<LazyState>>) -> bool {
96    let Some(build) = state.borrow_mut().build.take() else {
97        return false;
98    };
99    // Built outside the state borrow: constructing widgets reads and writes signals, whose effects can reach back into this same cell.
100    let Ok(items) = build() else {
101        return false;
102    };
103    let children: TrackedChildren = items.into_iter().map(make_child).collect();
104    let nodes: Vec<NodeId> = children.iter().map(|c| c.node()).collect();
105
106    let mut st = state.borrow_mut();
107    st.children = children;
108    let container = st.node;
109    drop(st);
110    let _ = set_children(container, &nodes);
111    true
112}
113
114impl LayoutItem for Lazy {
115    fn layout_node(&self) -> NodeId {
116        self.node
117    }
118}
119
120impl Component for Lazy {
121    fn view(&self) -> RenderNode {
122        // Subscribe to both: the condition (so hiding re-renders without children) and the build (so the first showing re-emits with them).
123        let show = (self.visible)();
124        self.version.get();
125        let _ = self.rect.get();
126        if !show {
127            return RenderNode::Empty;
128        }
129        let st = self.state.borrow();
130        RenderNode::group(st.children.iter().map(|c| c.segment.boundary()))
131    }
132
133    fn on_event(&mut self, event: &Event) -> EventResult {
134        // A hidden block is inert: it takes no space, so it must not answer for the content shown over it.
135        if !(self.visible)() {
136            return EventResult::Ignored;
137        }
138        let mut st = self.state.borrow_mut();
139        dispatch_container_event(&mut st.children, event)
140    }
141
142    fn debug_name(&self) -> &'static str {
143        "Lazy"
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use std::cell::Cell;
150
151    use layout_core::AvailableSpace;
152    use reactive_core::signal;
153
154    use super::*;
155    use crate::container::Container;
156    use crate::context::{compute_layout, reset_layout_runtime};
157
158    fn leaf() -> Result<Box<dyn LayoutItem>, LayoutError> {
159        Ok(Box::new(Container::new(
160            LayoutStyle::new().width(10.0).height(10.0),
161            vec![],
162        )?))
163    }
164
165    #[test]
166    fn defers_construction_until_the_condition_first_holds() {
167        reset_layout_runtime();
168        let show = signal(false);
169        let builds = Rc::new(Cell::new(0));
170        let lazy = {
171            let (cond, builds) = (show.clone(), builds.clone());
172            Lazy::new(
173                LayoutStyle::new().flex_column(),
174                move || cond.get(),
175                move || {
176                    builds.set(builds.get() + 1);
177                    Ok(vec![leaf()?])
178                },
179            )
180            .unwrap()
181        };
182        assert_eq!(builds.get(), 0, "a block never shown costs nothing");
183        assert!(!lazy.is_built());
184        assert!(matches!(lazy.view(), RenderNode::Empty));
185
186        show.set(true);
187        assert_eq!(builds.get(), 1, "the first showing builds the subtree");
188        assert!(lazy.is_built());
189    }
190
191    /// The difference from a reactive `if`: toggling off and on again shows the *same* subtree rather than
192    /// disposing and rebuilding it, so anything it held is still there.
193    #[test]
194    fn builds_once_and_only_toggles_afterwards() {
195        reset_layout_runtime();
196        let show = signal(true);
197        let builds = Rc::new(Cell::new(0));
198        let lazy = {
199            let (cond, builds) = (show.clone(), builds.clone());
200            Lazy::new(
201                LayoutStyle::new().flex_column(),
202                move || cond.get(),
203                move || {
204                    builds.set(builds.get() + 1);
205                    Ok(vec![leaf()?])
206                },
207            )
208            .unwrap()
209        };
210        assert_eq!(builds.get(), 1, "an initially-true block builds at once");
211        let node = lazy.state.borrow().children[0].node();
212
213        show.set(false);
214        show.set(true);
215        show.set(false);
216        show.set(true);
217        assert_eq!(builds.get(), 1, "reopening never rebuilds");
218        assert_eq!(
219            lazy.state.borrow().children[0].node(),
220            node,
221            "it is the same subtree, not a fresh one"
222        );
223    }
224
225    #[test]
226    fn a_hidden_block_takes_no_space() {
227        reset_layout_runtime();
228        let show = signal(true);
229        let lazy = {
230            let cond = show.clone();
231            Lazy::new(
232                LayoutStyle::new().flex_column(),
233                move || cond.get(),
234                || Ok(vec![leaf()?]),
235            )
236            .unwrap()
237        };
238        let node = lazy.layout_node();
239        compute_layout(
240            node,
241            AvailableSpace::Definite(100.0),
242            AvailableSpace::Definite(100.0),
243        )
244        .unwrap();
245        assert!(track_layout(node).unwrap().get().height > 0.0);
246
247        show.set(false);
248        compute_layout(
249            node,
250            AvailableSpace::Definite(100.0),
251            AvailableSpace::Definite(100.0),
252        )
253        .unwrap();
254        assert_eq!(track_layout(node).unwrap().get().height, 0.0);
255    }
256
257    #[test]
258    fn a_hidden_block_ignores_events() {
259        reset_layout_runtime();
260        let show = signal(false);
261        let mut lazy = {
262            let cond = show.clone();
263            Lazy::new(
264                LayoutStyle::new().flex_column(),
265                move || cond.get(),
266                || Ok(vec![leaf()?]),
267            )
268            .unwrap()
269        };
270        assert_eq!(
271            lazy.on_event(&Event::CursorEntered),
272            EventResult::Ignored,
273            "an unbuilt block answers for nothing"
274        );
275    }
276}