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    // Read while the composition is still running: measurement happens after it
59    // and cannot reach a composition local. Reading here also subscribes, so a
60    // subtree given a different grid recomposes and re-captures.
61    let composed_density = crate::density::density();
62    if let Err(err) = cranpose_core::with_node_mut(id, |node: &mut LayoutNode| {
63        node.set_modifier(modifier.clone());
64        node.set_measure_policy(Rc::clone(&policy));
65        node.set_density(composed_density);
66    }) {
67        debug_assert!(false, "failed to update Layout node: {err}");
68    }
69    cranpose_core::push_parent(id);
70    content();
71    cranpose_core::pop_parent();
72    id
73}
74
75#[composable]
76pub fn SubcomposeLayout(
77    modifier: Modifier,
78    measure_policy: impl for<'scope> Fn(&mut SubcomposeMeasureScopeImpl<'scope>, Constraints) -> MeasureResult
79        + 'static,
80) -> NodeId {
81    cranpose_core::debug_label_current_scope("SubcomposeLayout");
82    let policy_cell =
83        cranpose_core::remember(|| Rc::new(RefCell::new(None::<Rc<SubcomposeMeasurePolicy>>)))
84            .with(|cell| cell.clone());
85    let current_policy: Rc<SubcomposeMeasurePolicy> = Rc::new(measure_policy);
86    let policy_captures_changed = {
87        let mut policy_cell_ref = policy_cell.borrow_mut();
88        let changed = policy_cell_ref
89            .as_ref()
90            .is_none_or(|previous| !Rc::ptr_eq(previous, &current_policy));
91        *policy_cell_ref = Some(current_policy);
92        changed
93    };
94    let policy: Rc<SubcomposeMeasurePolicy> = cranpose_core::remember(move || {
95        let policy_cell = policy_cell.clone();
96        let policy: Rc<SubcomposeMeasurePolicy> =
97            Rc::new(
98                move |scope, constraints| match policy_cell.borrow().as_ref().cloned() {
99                    Some(current) => current(scope, constraints),
100                    None => empty_subcompose_measure_result(constraints),
101                },
102            );
103        policy
104    })
105    .with(|policy| policy.clone());
106    let id = cranpose_core::with_current_composer(|composer| {
107        composer.emit_node(|| SubcomposeLayoutNode::new(modifier.clone(), Rc::clone(&policy)))
108    });
109    // Measure-time composition inherits both locals and the source scope. The
110    // ownership link prevents secondary-host callbacks from outliving this
111    // composition while preserving the call-site local providers.
112    let captured_context =
113        cranpose_core::with_current_composer(|composer| composer.capture_composition_context());
114    // Read while the composition is still running, same as `Layout`: measurement
115    // happens after it and cannot reach a composition local. Reading here also
116    // subscribes, so a subtree given a different grid recomposes and re-captures.
117    let composed_density = crate::density::density();
118    if let Err(err) = cranpose_core::with_node_mut(id, |node: &mut SubcomposeLayoutNode| {
119        node.set_modifier(modifier.clone());
120        node.set_measure_policy(Rc::clone(&policy));
121        node.set_captured_context(captured_context.clone());
122        node.set_density(composed_density);
123        if policy_captures_changed {
124            node.request_measure_recompose();
125        }
126    }) {
127        debug_assert!(false, "failed to update SubcomposeLayout node: {err}");
128    }
129    id
130}
131
132fn empty_subcompose_measure_result(constraints: Constraints) -> MeasureResult {
133    let (width, height) = constraints.constrain(0.0, 0.0);
134    MeasureResult::new(Size { width, height }, Vec::new())
135}
136
137#[composable(no_skip)]
138pub fn BoxWithConstraints<F>(modifier: Modifier, content: F) -> NodeId
139where
140    F: FnMut(BoxWithConstraintsScopeImpl) + 'static,
141{
142    let content_ref: Rc<RefCell<F>> = Rc::new(RefCell::new(content));
143    SubcomposeLayout(modifier, move |scope, constraints| {
144        let scope_impl = BoxWithConstraintsScopeImpl::new(constraints);
145        let scope_for_content = scope_impl;
146        let measurables = {
147            let content_ref = Rc::clone(&content_ref);
148            scope.subcompose(SlotId::new(0), move || {
149                cranpose_core::debug_label_current_scope("BoxWithConstraints.slot(0)");
150                let mut content = content_ref.borrow_mut();
151                content(scope_for_content);
152            })
153        };
154        let child_constraints = Constraints {
155            min_width: 0.0,
156            max_width: constraints.max_width,
157            min_height: 0.0,
158            max_height: constraints.max_height,
159        };
160
161        let mut width = 0.0_f32;
162        let mut height = 0.0_f32;
163        let mut placements = Vec::with_capacity(measurables.len());
164
165        for measurable in measurables {
166            let placeable = scope.measure(measurable, child_constraints);
167            width = width.max(placeable.width());
168            height = height.max(placeable.height());
169            placeable.place(0.0, 0.0);
170            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
171        }
172
173        width = width.clamp(constraints.min_width, constraints.max_width);
174        height = height.clamp(constraints.min_height, constraints.max_height);
175        scope.layout(width, height, placements)
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use cranpose_core::{location_key, Composition, MemoryApplier, MutableState};
183    use std::cell::Cell;
184
185    #[test]
186    fn layout_recomposes_when_content_reads_state() {
187        let _app_context = crate::render_state::app_context_test_scope();
188        thread_local! {
189            static INVOCATIONS: Cell<usize> = const { Cell::new(0) };
190        }
191
192        let mut composition = Composition::new(MemoryApplier::new());
193        let runtime = composition.runtime_handle();
194        let state = MutableState::with_runtime(0_i32, runtime);
195
196        composition
197            .render(location_key(file!(), line!(), column!()), {
198                let observed_state = state;
199                move || {
200                    Layout(
201                        Modifier::empty(),
202                        crate::layout::policies::EmptyMeasurePolicy,
203                        {
204                            let observed_state = observed_state;
205                            move || {
206                                let _ = observed_state.value();
207                                INVOCATIONS.with(|calls| calls.set(calls.get() + 1));
208                            }
209                        },
210                    );
211                }
212            })
213            .expect("initial layout render");
214
215        INVOCATIONS.with(|calls| assert_eq!(calls.get(), 1));
216
217        state.set_value(1);
218        composition
219            .process_invalid_scopes()
220            .expect("layout content recomposition");
221
222        INVOCATIONS.with(|calls| assert_eq!(calls.get(), 2));
223    }
224
225    #[test]
226    fn subcompose_missing_policy_cell_measures_empty_layout() {
227        let result = empty_subcompose_measure_result(Constraints {
228            min_width: 12.0,
229            max_width: 120.0,
230            min_height: 8.0,
231            max_height: 90.0,
232        });
233
234        assert_eq!(result.size.width, 12.0);
235        assert_eq!(result.size.height, 8.0);
236        assert!(result.placements.is_empty());
237    }
238}