Skip to main content

cranpose_ui/widgets/
layout.rs

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