Skip to main content

cranpose_ui/widgets/
layout.rs

1//! Generic Layout widget and SubcomposeLayout
2
3#![allow(non_snake_case)]
4
5use super::nodes::LayoutNode;
6use super::scopes::BoxWithConstraintsScopeImpl;
7use crate::composable;
8use crate::modifier::Modifier;
9use crate::subcompose_layout::{
10    Constraints, MeasurePolicy as SubcomposeMeasurePolicy, MeasureResult, SubcomposeLayoutNode,
11    SubcomposeLayoutScope, SubcomposeMeasureScope, SubcomposeMeasureScopeImpl,
12};
13use cranpose_core::{NodeId, SlotId};
14use cranpose_ui_graphics::Size;
15use cranpose_ui_layout::{MeasurePolicy, Placement};
16use std::cell::RefCell;
17use std::rc::Rc;
18
19struct RetainedMeasurePolicy<P> {
20    value: P,
21    policy: Rc<dyn MeasurePolicy>,
22}
23
24#[composable]
25pub fn Layout<F, P>(modifier: Modifier, measure_policy: P, mut content: F) -> NodeId
26where
27    F: FnMut() + 'static,
28    P: MeasurePolicy + Clone + PartialEq + 'static,
29{
30    let policy_holder = cranpose_core::remember({
31        let measure_policy = measure_policy.clone();
32        move || {
33            Rc::new(RefCell::new(RetainedMeasurePolicy {
34                value: measure_policy.clone(),
35                policy: Rc::new(measure_policy),
36            }))
37        }
38    })
39    .with(|holder| holder.clone());
40    let policy = {
41        let mut holder = policy_holder.borrow_mut();
42        if holder.value != measure_policy {
43            holder.value = measure_policy.clone();
44            holder.policy = Rc::new(measure_policy);
45        }
46        Rc::clone(&holder.policy)
47    };
48    let modifier_for_reset = modifier.clone();
49    let policy_for_reset = Rc::clone(&policy);
50    let id = cranpose_core::with_current_composer(|composer| {
51        composer.emit_recyclable_node(
52            || LayoutNode::new(modifier.clone(), Rc::clone(&policy)),
53            move |node| {
54                *node = LayoutNode::new(modifier_for_reset.clone(), Rc::clone(&policy_for_reset));
55            },
56        )
57    });
58    if let Err(err) = cranpose_core::with_node_mut(id, |node: &mut LayoutNode| {
59        node.set_modifier(modifier.clone());
60        node.set_measure_policy(Rc::clone(&policy));
61    }) {
62        debug_assert!(false, "failed to update Layout node: {err}");
63    }
64    cranpose_core::push_parent(id);
65    content();
66    cranpose_core::pop_parent();
67    id
68}
69
70#[composable]
71pub fn SubcomposeLayout(
72    modifier: Modifier,
73    measure_policy: impl for<'scope> Fn(&mut SubcomposeMeasureScopeImpl<'scope>, Constraints) -> MeasureResult
74        + 'static,
75) -> NodeId {
76    cranpose_core::debug_label_current_scope("SubcomposeLayout");
77    let policy_cell =
78        cranpose_core::remember(|| Rc::new(RefCell::new(None::<Rc<SubcomposeMeasurePolicy>>)))
79            .with(|cell| cell.clone());
80    let current_policy: Rc<SubcomposeMeasurePolicy> = Rc::new(measure_policy);
81    let policy_captures_changed = {
82        let mut policy_cell_ref = policy_cell.borrow_mut();
83        let changed = policy_cell_ref
84            .as_ref()
85            .is_none_or(|previous| !Rc::ptr_eq(previous, &current_policy));
86        *policy_cell_ref = Some(current_policy);
87        changed
88    };
89    let policy: Rc<SubcomposeMeasurePolicy> = cranpose_core::remember(move || {
90        let policy_cell = policy_cell.clone();
91        let policy: Rc<SubcomposeMeasurePolicy> =
92            Rc::new(
93                move |scope, constraints| match policy_cell.borrow().as_ref().cloned() {
94                    Some(current) => current(scope, constraints),
95                    None => empty_subcompose_measure_result(constraints),
96                },
97            );
98        policy
99    })
100    .with(|policy| policy.clone());
101    let id = cranpose_core::with_current_composer(|composer| {
102        composer.emit_node(|| SubcomposeLayoutNode::new(modifier.clone(), Rc::clone(&policy)))
103    });
104    // Capture the composition locals in scope here (compose time) so the
105    // measure-pass subcomposition can replay them: content subcomposed off the
106    // measure pass then observes the same composition locals as this call site,
107    // instead of the locals in scope during layout (which no longer carry
108    // ancestor providers once composition has unwound).
109    let captured_locals =
110        cranpose_core::with_current_composer(|composer| composer.capture_composition_locals());
111    if let Err(err) = cranpose_core::with_node_mut(id, |node: &mut SubcomposeLayoutNode| {
112        node.set_modifier(modifier.clone());
113        node.set_measure_policy(Rc::clone(&policy));
114        node.set_captured_locals(captured_locals.clone());
115        if policy_captures_changed {
116            node.request_measure_recompose();
117        }
118    }) {
119        debug_assert!(false, "failed to update SubcomposeLayout node: {err}");
120    }
121    id
122}
123
124fn empty_subcompose_measure_result(constraints: Constraints) -> MeasureResult {
125    let (width, height) = constraints.constrain(0.0, 0.0);
126    MeasureResult::new(Size { width, height }, Vec::new())
127}
128
129#[composable(no_skip)]
130pub fn BoxWithConstraints<F>(modifier: Modifier, content: F) -> NodeId
131where
132    F: FnMut(BoxWithConstraintsScopeImpl) + 'static,
133{
134    let content_ref: Rc<RefCell<F>> = Rc::new(RefCell::new(content));
135    SubcomposeLayout(modifier, move |scope, constraints| {
136        let scope_impl = BoxWithConstraintsScopeImpl::new(constraints);
137        let scope_for_content = scope_impl;
138        let measurables = {
139            let content_ref = Rc::clone(&content_ref);
140            scope.subcompose(SlotId::new(0), move || {
141                cranpose_core::debug_label_current_scope("BoxWithConstraints.slot(0)");
142                let mut content = content_ref.borrow_mut();
143                content(scope_for_content);
144            })
145        };
146        let child_constraints = Constraints {
147            min_width: 0.0,
148            max_width: constraints.max_width,
149            min_height: 0.0,
150            max_height: constraints.max_height,
151        };
152
153        let mut width = 0.0_f32;
154        let mut height = 0.0_f32;
155        let mut placements = Vec::with_capacity(measurables.len());
156
157        for measurable in measurables {
158            let placeable = scope.measure(measurable, child_constraints);
159            width = width.max(placeable.width());
160            height = height.max(placeable.height());
161            placeable.place(0.0, 0.0);
162            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
163        }
164
165        width = width.clamp(constraints.min_width, constraints.max_width);
166        height = height.clamp(constraints.min_height, constraints.max_height);
167        scope.layout(width, height, placements)
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use cranpose_core::{location_key, Composition, MemoryApplier, MutableState};
175    use std::cell::Cell;
176
177    #[test]
178    fn layout_recomposes_when_content_reads_state() {
179        let _app_context = crate::render_state::app_context_test_scope();
180        thread_local! {
181            static INVOCATIONS: Cell<usize> = const { Cell::new(0) };
182        }
183
184        let mut composition = Composition::new(MemoryApplier::new());
185        let runtime = composition.runtime_handle();
186        let state = MutableState::with_runtime(0_i32, runtime);
187
188        composition
189            .render(location_key(file!(), line!(), column!()), {
190                let observed_state = state;
191                move || {
192                    Layout(
193                        Modifier::empty(),
194                        crate::layout::policies::EmptyMeasurePolicy,
195                        {
196                            let observed_state = observed_state;
197                            move || {
198                                let _ = observed_state.value();
199                                INVOCATIONS.with(|calls| calls.set(calls.get() + 1));
200                            }
201                        },
202                    );
203                }
204            })
205            .expect("initial layout render");
206
207        INVOCATIONS.with(|calls| assert_eq!(calls.get(), 1));
208
209        state.set_value(1);
210        composition
211            .process_invalid_scopes()
212            .expect("layout content recomposition");
213
214        INVOCATIONS.with(|calls| assert_eq!(calls.get(), 2));
215    }
216
217    #[test]
218    fn subcompose_missing_policy_cell_measures_empty_layout() {
219        let result = empty_subcompose_measure_result(Constraints {
220            min_width: 12.0,
221            max_width: 120.0,
222            min_height: 8.0,
223            max_height: 90.0,
224        });
225
226        assert_eq!(result.size.width, 12.0);
227        assert_eq!(result.size.height, 8.0);
228        assert!(result.placements.is_empty());
229    }
230}