ailloli_ui_runtime/layout/
layout_commit.rs

1//! Commit phase that writes computed layout geometry into retained elements.
2
3use ailloli_ui_core::geometry::{Offset, Rect};
4use ailloli_ui_core::ids::ElementId;
5
6use crate::app::{Invalidation, RuntimeHandle};
7use crate::component::reactive::{with_untracked_reads, ReactiveReadScope, ReactiveStage};
8use crate::element::{ElementKind, ElementTree};
9use crate::layout::LayoutCtx;
10
11/// Commits authoritative layout bounds and notifies retained widgets recursively.
12///
13/// `origin` is an absolute logical-pixel translation for the root result's
14/// `paint_bounds`. Unknown elements and elements without a cached layout are
15/// no-ops. A widget is notified only when its layout changed or its absolute
16/// bounds changed; clean, unchanged subtrees are skipped.
17///
18/// Direct-child records are paired with retained children by index. Missing
19/// layout entries skip excess tree children, and excess layout entries are
20/// ignored. Descendant bounds use the parent's committed origin plus child
21/// offset and size; a child's `paint_bounds` is not used for that calculation.
22/// A real authoritative widget layout callback also receives exactly one hook
23/// after publication, even if it returned equal geometry. Stable cache hits do
24/// not receive a hook unless their absolute bounds or retained hook inputs
25/// changed.
26///
27/// Geometry and commit flags for the complete visited subtree are published
28/// before the first widget callback runs. If a callback panics, all retained
29/// bounds therefore stay authoritative while later callbacks are not invoked.
30/// A panicking hook also keeps its previous reactive dependency set.
31///
32/// # Examples
33///
34/// ```
35/// use ailloli_ui_core::{ElementId, Offset, Scale};
36/// use ailloli_ui_runtime::element::ElementTree;
37/// use ailloli_ui_runtime::layout::{commit_layout_element, LayoutCtx};
38///
39/// let mut tree = ElementTree::<()>::new();
40/// let mut ctx = LayoutCtx::new(Scale::new(1.0));
41/// // Missing IDs are intentionally harmless.
42/// commit_layout_element(&mut tree, &mut ctx, ElementId(99), Offset::new(5.0, 8.0));
43/// assert!(tree.root().is_none());
44/// ```
45pub fn commit_layout_element<A: 'static>(
46    tree: &mut ElementTree<A>,
47    ctx: &mut LayoutCtx<'_>,
48    element_id: ElementId,
49    origin: Offset,
50) {
51    commit_layout_element_impl(tree, None, ctx, element_id, origin);
52}
53
54/// Commits layout while retaining reads made by `layout_committed` hooks.
55pub(crate) fn commit_layout_element_observed<A: 'static>(
56    tree: &mut ElementTree<A>,
57    runtime: &RuntimeHandle<A>,
58    ctx: &mut LayoutCtx<'_>,
59    element_id: ElementId,
60    origin: Offset,
61) {
62    commit_layout_element_impl(tree, Some(runtime), ctx, element_id, origin);
63}
64
65/// Shared entry point for standalone and runtime-owned layout commits.
66fn commit_layout_element_impl<A: 'static>(
67    tree: &mut ElementTree<A>,
68    runtime: Option<&RuntimeHandle<A>>,
69    ctx: &mut LayoutCtx<'_>,
70    element_id: ElementId,
71    origin: Offset,
72) {
73    let Some(el) = tree.get(element_id) else {
74        return;
75    };
76    if el
77        .layout_cache_key
78        .is_some_and(|key| key.layout_pass.is_measure())
79    {
80        return;
81    }
82    let Some(layout) = el.layout.as_ref() else {
83        return;
84    };
85
86    let bounds = layout.paint_bounds.translate(origin);
87    let mut notifications = Vec::new();
88    precommit_layout_subtree(tree, element_id, bounds, &mut notifications);
89    notify_layout_committed(tree, runtime, ctx, notifications);
90}
91
92/// One post-commit notification retained until the complete subtree is stable.
93struct LayoutCommitNotification<A> {
94    /// Retained element whose authoritative result changed.
95    element_id: ElementId,
96    /// Absolute bounds already stored on the retained element.
97    bounds: Rect,
98    /// Payload snapshot used for the post-commit callback.
99    kind: ElementKind<A>,
100    /// Authoritative layout already published by the layout transaction.
101    layout: crate::layout::LayoutResult,
102}
103
104/// Publishes bounds and commit flags for a complete retained subtree.
105fn precommit_layout_subtree<A: 'static>(
106    tree: &mut ElementTree<A>,
107    element_id: ElementId,
108    bounds: Rect,
109    notifications: &mut Vec<LayoutCommitNotification<A>>,
110) {
111    let Some(el) = tree.get(element_id) else {
112        return;
113    };
114    let Some(layout) = el.layout.as_ref() else {
115        return;
116    };
117
118    let bounds_changed = el.committed_bounds != Some(bounds);
119    if !el.commit_dirty && !bounds_changed {
120        return;
121    }
122    let hook_dependencies_stale = el.commit_dirty
123        && !el.layout_commit_reactive_dependencies.is_empty()
124        && !el.layout_commit_reactive_dependencies.is_current();
125    let should_commit_widget = el.layout_changed
126        || el.layout_callback_executed
127        || bounds_changed
128        || hook_dependencies_stale;
129    let kind = el.kind.clone();
130    let layout = layout.clone();
131    let children = el.children.clone();
132
133    if let Some(el) = tree.get_mut(element_id) {
134        el.committed_bounds = Some(bounds);
135        el.layout_changed = false;
136        el.layout_callback_executed = false;
137        el.commit_dirty = false;
138    }
139
140    if should_commit_widget {
141        notifications.push(LayoutCommitNotification {
142            element_id,
143            bounds,
144            kind,
145            layout: layout.clone(),
146        });
147    }
148
149    for (idx, child_id) in children.into_iter().enumerate() {
150        let Some(child_layout) = layout.children.get(idx) else {
151            continue;
152        };
153        let child_bounds = Rect::new(
154            bounds.x + child_layout.offset.x,
155            bounds.y + child_layout.offset.y,
156            child_layout.size.w,
157            child_layout.size.h,
158        );
159        precommit_layout_subtree(tree, child_id, child_bounds, notifications);
160    }
161}
162
163/// Runs post-commit widget hooks after every retained bound is authoritative.
164fn notify_layout_committed<A: 'static>(
165    tree: &mut ElementTree<A>,
166    runtime: Option<&RuntimeHandle<A>>,
167    ctx: &mut LayoutCtx<'_>,
168    notifications: Vec<LayoutCommitNotification<A>>,
169) {
170    for notification in notifications {
171        tree.record_layout_commit(notification.element_id);
172        if let ElementKind::Widget(widget) = notification.kind {
173            let attempt_token = tree
174                .get(notification.element_id)
175                .and_then(|element| element.committed_layout_attempt);
176            if let Some(runtime) = runtime {
177                let observation = ReactiveReadScope::new();
178                ctx.with_committed_layout_attempt(attempt_token, |ctx| {
179                    widget.layout_committed(ctx, notification.bounds, &notification.layout);
180                });
181                let dependencies = observation.finish();
182                if dependencies.is_current() {
183                    let Some(element) = tree.get_mut(notification.element_id) else {
184                        continue;
185                    };
186                    element.layout_commit_reactive_dependencies = dependencies;
187                    let mut combined = element.layout_reactive_dependencies.clone();
188                    combined.merge(&element.layout_commit_reactive_dependencies);
189                    let mount_generation = element.mount_generation();
190                    let _ = runtime.replace_reactive_dependencies(
191                        notification.element_id,
192                        mount_generation,
193                        ReactiveStage::Layout,
194                        &combined,
195                    );
196                } else {
197                    runtime.invalidate(notification.element_id, Invalidation::Layout);
198                }
199            } else {
200                with_untracked_reads(|| {
201                    ctx.with_committed_layout_attempt(attempt_token, |ctx| {
202                        widget.layout_committed(ctx, notification.bounds, &notification.layout)
203                    })
204                });
205            }
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use std::panic::{catch_unwind, AssertUnwindSafe};
213    use std::rc::Rc;
214
215    use ailloli_ui_core::{Constraints, Scale, Size};
216
217    use super::*;
218    use crate::component::Widget;
219    use crate::layout::{ChildLayout, LayoutChild, LayoutEngine, LayoutResult};
220    use crate::scene::PaintCtx;
221
222    /// Root widget whose post-commit callback proves geometry precedes hooks.
223    struct PanickingCommitHook;
224
225    impl Widget<()> for PanickingCommitHook {
226        fn debug_name(&self) -> &'static str {
227            "PanickingCommitHook"
228        }
229
230        fn layout(
231            &self,
232            _engine: &mut LayoutEngine<'_, ()>,
233            _ctx: &mut LayoutCtx<'_>,
234            _children: &mut [LayoutChild],
235            _constraints: Constraints,
236        ) -> LayoutResult {
237            LayoutResult::empty()
238        }
239
240        fn layout_committed(
241            &self,
242            _ctx: &mut LayoutCtx<'_>,
243            _bounds: Rect,
244            _layout: &LayoutResult,
245        ) {
246            panic!("intentional parent layout_committed panic");
247        }
248
249        fn paint(&self, _ctx: &mut PaintCtx<'_>, _bounds: Rect, _layout: &LayoutResult) {}
250    }
251
252    #[test]
253    fn parent_hook_panic_keeps_precommitted_child_bounds() {
254        let mut tree = ElementTree::<()>::new();
255        let root = tree.create_element(
256            ElementKind::Widget(Rc::new(PanickingCommitHook)),
257            None,
258            None,
259        );
260        let child = tree.create_element(ElementKind::Empty, None, Some(root));
261        tree.set_children(root, vec![child]);
262
263        let child_size = Size::new(20.0, 10.0);
264        tree.set_layout(
265            child,
266            LayoutResult {
267                size: child_size,
268                paint_bounds: Rect::new(50.0, 60.0, child_size.w, child_size.h),
269                visual_bounds: Rect::new(50.0, 60.0, child_size.w, child_size.h),
270                ..LayoutResult::empty()
271            },
272        );
273        tree.set_layout(
274            root,
275            LayoutResult {
276                size: Size::new(40.0, 30.0),
277                children: vec![ChildLayout {
278                    offset: Offset::new(7.0, 9.0),
279                    size: child_size,
280                    paint_bounds: Rect::new(7.0, 9.0, child_size.w, child_size.h),
281                    visual_bounds: Rect::new(7.0, 9.0, child_size.w, child_size.h),
282                }],
283                paint_bounds: Rect::new(1.0, 2.0, 40.0, 30.0),
284                visual_bounds: Rect::new(1.0, 2.0, 40.0, 30.0),
285                ..LayoutResult::empty()
286            },
287        );
288
289        let mut ctx = LayoutCtx::new(Scale::new(1.0));
290        let panic = catch_unwind(AssertUnwindSafe(|| {
291            commit_layout_element(&mut tree, &mut ctx, root, Offset::new(3.0, 4.0));
292        }));
293
294        assert!(panic.is_err());
295        assert_eq!(
296            tree.get(root).and_then(|element| element.committed_bounds),
297            Some(Rect::new(4.0, 6.0, 40.0, 30.0))
298        );
299        assert_eq!(
300            tree.get(child).and_then(|element| element.committed_bounds),
301            Some(Rect::new(11.0, 15.0, 20.0, 10.0)),
302            "a parent hook panic must not leave child bounds behind published layout caches"
303        );
304    }
305}