Skip to main content

tessera_components/
boxed.rs

1//! A container for stacking and aligning multiple children.
2//!
3//! ## Usage
4//!
5//! Use to create layered UIs, overlays, or composite controls.
6use tessera_ui::{
7    __private::configure_layout_node,
8    ComputedData, LayoutResult, MeasurementError, Modifier, NoopDrawModifier, Px, PxPosition,
9    RenderSlot,
10    layout::{MeasureScope, PlacementResult, PlacementScope},
11    tessera,
12};
13
14use crate::alignment::Alignment;
15
16fn center_axis(container: Px, child: Px) -> f32 {
17    (container.0 - child.0) as f32 / 2.0
18}
19
20fn compute_child_offset(
21    alignment: Alignment,
22    container_w: Px,
23    container_h: Px,
24    child_w: Px,
25    child_h: Px,
26) -> (f32, f32) {
27    match alignment {
28        Alignment::TopStart => (0.0, 0.0),
29        Alignment::TopCenter => (center_axis(container_w, child_w), 0.0),
30        Alignment::TopEnd => ((container_w.0 - child_w.0) as f32, 0.0),
31        Alignment::CenterStart => (0.0, center_axis(container_h, child_h)),
32        Alignment::Center => (
33            center_axis(container_w, child_w),
34            center_axis(container_h, child_h),
35        ),
36        Alignment::CenterEnd => (
37            (container_w.0 - child_w.0) as f32,
38            center_axis(container_h, child_h),
39        ),
40        Alignment::BottomStart => (0.0, (container_h.0 - child_h.0) as f32),
41        Alignment::BottomCenter => (
42            center_axis(container_w, child_w),
43            (container_h.0 - child_h.0) as f32,
44        ),
45        Alignment::BottomEnd => (
46            (container_w.0 - child_w.0) as f32,
47            (container_h.0 - child_h.0) as f32,
48        ),
49    }
50}
51
52/// # boxed
53///
54/// A container that overlays its children, aligning them relative to each
55/// other.
56///
57/// ## Usage
58///
59/// Stack children on top of each other to create layered interfaces, such as a
60/// badge on an icon or text over an image.
61///
62/// ## Parameters
63///
64/// - `alignment` — default alignment applied to children without parent data.
65/// - `modifier` — modifier chain applied to the boxed container.
66/// - `children` — child slot rendered inside the boxed container.
67///
68/// ## Examples
69///
70/// ```
71/// use tessera_components::{
72///     alignment::Alignment, boxed::boxed, modifier::ModifierExt as _, text::text,
73/// };
74/// use tessera_ui::Modifier;
75///
76/// # use tessera_ui::tessera;
77/// # #[tessera]
78/// # fn component() {
79/// boxed().children(|| {
80///     text("Background");
81///     text("Foreground")
82///         .modifier(Modifier::new().align(Alignment::Center));
83/// });
84/// # }
85/// # tessera_ui::testing::layout_test(component).run();
86/// ```
87#[tessera(tessera_ui, layout)]
88pub fn boxed(
89    alignment: Option<Alignment>,
90    modifier: Option<Modifier>,
91    children: Option<RenderSlot>,
92) {
93    let alignment = alignment.unwrap_or_default();
94    let modifier = modifier.unwrap_or_default();
95    configure_layout_node(BoxedLayout { alignment }, NoopDrawModifier, modifier);
96    children.unwrap_or_else(RenderSlot::empty).render();
97}
98
99#[derive(Clone, PartialEq)]
100struct BoxedLayout {
101    alignment: Alignment,
102}
103
104impl tessera_ui::LayoutModifierNode for BoxedLayout {
105    fn place_children(
106        &self,
107        scope: &PlacementScope<'_>,
108        _inner: &PlacementResult,
109    ) -> Option<PlacementResult> {
110        let size = scope.size();
111        let mut result = LayoutResult::new(size);
112        for child in scope.children() {
113            let alignment = child
114                .parent_data::<crate::modifier::AlignmentParentData>()
115                .map_or(self.alignment, |data| data.alignment);
116            let child_size = child.size();
117            let (x, y) = compute_child_offset(
118                alignment,
119                size.width,
120                size.height,
121                child_size.width,
122                child_size.height,
123            );
124            result.place_child(child, PxPosition::from_f32_xy(x, y));
125        }
126        Some(result.into_placement_result())
127    }
128
129    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
130        let mut result = LayoutResult::default();
131        let children = input.children();
132        let child_alignments = collect_child_alignments(input, &children);
133        let n = child_alignments.len();
134        debug_assert_eq!(
135            children.len(),
136            n,
137            "Mismatch between children defined in scope and runtime children count"
138        );
139
140        let parent_constraint = *input.parent_constraint().as_ref();
141        let child_constraint = input.parent_constraint().without_min();
142
143        let mut max_child_width = Px(0);
144        let mut max_child_height = Px(0);
145        let mut children_sizes = vec![None; n];
146
147        for (i, child) in children.iter().enumerate().take(n) {
148            let child_result = child.measure(input, &child_constraint)?;
149            max_child_width = max_child_width.max(child_result.width);
150            max_child_height = max_child_height.max(child_result.height);
151            children_sizes[i] = Some(child_result);
152        }
153
154        let final_width = parent_constraint.width.clamp(max_child_width);
155        let final_height = parent_constraint.height.clamp(max_child_height);
156
157        for (i, child_size_opt) in children_sizes.iter().enumerate() {
158            if let Some(child_size) = child_size_opt {
159                let child_id = children[i];
160                let child_alignment = child_alignments[i].unwrap_or(self.alignment);
161                let (x, y) = compute_child_offset(
162                    child_alignment,
163                    final_width,
164                    final_height,
165                    child_size.width,
166                    child_size.height,
167                );
168                result.place_child(child_id, PxPosition::from_f32_xy(x, y));
169            }
170        }
171
172        Ok(result.with_size(ComputedData {
173            width: final_width,
174            height: final_height,
175        }))
176    }
177}
178
179fn collect_child_alignments(
180    scope: &MeasureScope<'_>,
181    children: &[tessera_ui::layout::LayoutChild],
182) -> Vec<Option<Alignment>> {
183    children
184        .iter()
185        .map(|child| {
186            child
187                .parent_data::<crate::modifier::AlignmentParentData>(scope)
188                .map(|data| data.alignment)
189        })
190        .collect()
191}
192
193#[cfg(test)]
194mod tests {
195    use tessera_ui::{
196        AxisConstraint, ComputedData, LayoutResult, MeasurementError, Modifier, NoopDrawModifier,
197        Px,
198        layout::{MeasureScope, layout},
199        tessera,
200    };
201
202    use crate::{
203        alignment::Alignment,
204        modifier::{ModifierExt as _, SemanticsArgs},
205    };
206
207    use super::boxed;
208
209    #[derive(Clone, PartialEq)]
210    struct FixedTestLayout {
211        width: i32,
212        height: i32,
213    }
214
215    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
216        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
217            Ok(LayoutResult::new(ComputedData {
218                width: Px::new(self.width),
219                height: Px::new(self.height),
220            }))
221        }
222    }
223
224    #[tessera]
225    fn fixed_test_box(
226        modifier: Option<Modifier>,
227        tag: Option<String>,
228        width: Option<i32>,
229        height: Option<i32>,
230    ) {
231        let modifier = modifier.unwrap_or_default();
232        let tag = tag.unwrap_or_default();
233        let width = width.unwrap_or_default();
234        let height = height.unwrap_or_default();
235
236        layout()
237            .policy(FixedTestLayout { width, height })
238            .draw(NoopDrawModifier)
239            .modifier(modifier.then(Modifier::new().semantics(SemanticsArgs {
240                test_tag: Some(tag),
241                ..Default::default()
242            })));
243    }
244
245    #[tessera]
246    fn boxed_layout_case() {
247        boxed()
248            .alignment(Alignment::TopStart)
249            .modifier(Modifier::new().constrain(
250                Some(AxisConstraint::exact(Px::new(100))),
251                Some(AxisConstraint::exact(Px::new(80))),
252            ))
253            .children(|| {
254                boxed_start_box();
255                layout()
256                    .modifier(Modifier::new().align(Alignment::BottomEnd))
257                    .child(|| {
258                        boxed_end_box();
259                    });
260            });
261    }
262
263    #[tessera]
264    fn boxed_start_box() {
265        fixed_test_box()
266            .tag("boxed_start".to_string())
267            .width(20)
268            .height(10);
269    }
270
271    #[tessera]
272    fn boxed_end_box() {
273        fixed_test_box()
274            .tag("boxed_end".to_string())
275            .width(30)
276            .height(15);
277    }
278
279    #[tessera]
280    fn boxed_center_case() {
281        boxed()
282            .alignment(Alignment::Center)
283            .modifier(Modifier::new().constrain(
284                Some(AxisConstraint::exact(Px::new(100))),
285                Some(AxisConstraint::exact(Px::new(80))),
286            ))
287            .children(|| {
288                fixed_test_box()
289                    .tag("boxed_center".to_string())
290                    .width(20)
291                    .height(10);
292            });
293    }
294
295    #[tessera]
296    fn boxed_forwarded_parent_data_case() {
297        boxed()
298            .alignment(Alignment::TopStart)
299            .modifier(Modifier::new().constrain(
300                Some(AxisConstraint::exact(Px::new(100))),
301                Some(AxisConstraint::exact(Px::new(80))),
302            ))
303            .children(|| {
304                fixed_test_box()
305                    .modifier(Modifier::new().align(Alignment::Center))
306                    .tag("boxed_forwarded".to_string())
307                    .width(20)
308                    .height(10);
309            });
310    }
311
312    #[test]
313    fn boxed_honors_child_alignment_override() {
314        tessera_ui::assert_layout! {
315            viewport: (120, 100),
316            content: {
317                boxed_layout_case();
318            },
319            expect: {
320                node("boxed_start").position(0, 0).size(20, 10);
321                node("boxed_end").position(70, 65).size(30, 15);
322            }
323        }
324    }
325
326    #[test]
327    fn boxed_uses_default_alignment_for_children_without_parent_data() {
328        tessera_ui::assert_layout! {
329            viewport: (120, 100),
330            content: {
331                boxed_center_case();
332            },
333            expect: {
334                node("boxed_center").position(40, 35).size(20, 10);
335            }
336        }
337    }
338
339    #[test]
340    fn boxed_honors_alignment_on_wrapped_children() {
341        tessera_ui::assert_layout! {
342            viewport: (120, 100),
343            content: {
344                boxed_forwarded_parent_data_case();
345            },
346            expect: {
347                node("boxed_forwarded").position(40, 35).size(20, 10);
348            }
349        }
350    }
351}