Skip to main content

tessera_components/
column.rs

1//! A vertical layout component.
2//!
3//! ## Usage
4//!
5//! Use to stack children vertically.
6use tessera_ui::{
7    __private::configure_layout_node,
8    AxisConstraint, ComputedData, Constraint, LayoutResult, MeasurementError, Modifier,
9    NoopDrawModifier, Px, PxPosition, RenderSlot,
10    layout::{LayoutChild, LayoutPlacementTarget, MeasureScope, PlacementResult, PlacementScope},
11    tessera,
12};
13
14use crate::{
15    alignment::{CrossAxisAlignment, MainAxisAlignment},
16    layout_flex::{classify_children, cross_axis_child_constraint},
17};
18
19/// # column
20///
21/// A layout component that arranges its children in a vertical column.
22///
23/// ## Usage
24///
25/// Stack components vertically, with options for alignment and flexible
26/// spacing.
27///
28/// ## Parameters
29///
30/// - `modifier` — modifier chain applied to the column container.
31/// - `main_axis_alignment` — alignment along the vertical axis.
32/// - `cross_axis_alignment` — alignment along the horizontal axis.
33/// - `children` — child slot rendered inside the column.
34///
35/// ## Examples
36///
37/// ```
38/// use tessera_components::{
39///     column::column, modifier::ModifierExt as _, spacer::spacer, text::text,
40/// };
41/// use tessera_ui::Modifier;
42///
43/// # use tessera_ui::tessera;
44/// # #[tessera]
45/// # fn component() {
46/// column().children(|| {
47///     text("First item");
48///     spacer().modifier(Modifier::new().weight(1.0));
49///     text("Last item");
50/// });
51/// # }
52/// # tessera_ui::testing::layout_test(component).run();
53/// ```
54#[tessera(tessera_ui, layout)]
55pub fn column(
56    modifier: Option<Modifier>,
57    main_axis_alignment: Option<MainAxisAlignment>,
58    cross_axis_alignment: Option<CrossAxisAlignment>,
59    children: Option<RenderSlot>,
60) {
61    let modifier = modifier.unwrap_or_default();
62    let main_axis_alignment = main_axis_alignment.unwrap_or_default();
63    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
64    configure_layout_node(
65        ColumnLayout {
66            main_axis_alignment,
67            cross_axis_alignment,
68        },
69        NoopDrawModifier,
70        modifier,
71    );
72    children.unwrap_or_else(RenderSlot::empty).render();
73}
74
75#[derive(Clone, PartialEq)]
76struct ColumnLayout {
77    main_axis_alignment: MainAxisAlignment,
78    cross_axis_alignment: CrossAxisAlignment,
79}
80
81impl tessera_ui::LayoutModifierNode for ColumnLayout {
82    fn measure_eq(&self, other: &Self) -> bool {
83        (self.cross_axis_alignment == CrossAxisAlignment::Stretch)
84            == (other.cross_axis_alignment == CrossAxisAlignment::Stretch)
85    }
86
87    fn place_children(
88        &self,
89        scope: &PlacementScope<'_>,
90        _inner: &PlacementResult,
91    ) -> Option<PlacementResult> {
92        let children = scope.children();
93        let children_sizes = children
94            .iter()
95            .map(|child| Some(child.size()))
96            .collect::<Vec<_>>();
97        let total_children_height = children
98            .iter()
99            .map(|child| child.size().height)
100            .fold(Px::ZERO, |total, size| total + size);
101        let mut result = LayoutResult::new(scope.size());
102        place_children_with_alignment(
103            &PlaceChildrenArgs {
104                children_sizes: &children_sizes,
105                children: &children,
106                final_column_width: scope.size().width,
107                final_column_height: scope.size().height,
108                total_children_height,
109                main_axis_alignment: self.main_axis_alignment,
110                cross_axis_alignment: self.cross_axis_alignment,
111                child_count: children.len(),
112            },
113            &mut result,
114        );
115        Some(result.into_placement_result())
116    }
117
118    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
119        let mut result = LayoutResult::default();
120        let children = input.children();
121        let child_weights = collect_child_weights(input, &children);
122        let n = child_weights.len();
123        assert_eq!(
124            children.len(),
125            n,
126            "Mismatch between children defined in scope and runtime children count"
127        );
128
129        let column_parent_constraint = *input.parent_constraint().as_ref();
130
131        let mut children_sizes = vec![None; n];
132        let mut max_child_width = Px(0);
133
134        let has_weighted_children = child_weights.iter().any(|&weight| weight > 0.0);
135        let should_use_weight_for_height =
136            has_weighted_children && column_parent_constraint.height.resolve_max().is_some();
137
138        let (final_column_width, final_column_height, total_measured_children_height) =
139            if should_use_weight_for_height {
140                measure_weighted_column(
141                    &children,
142                    input,
143                    &child_weights,
144                    &column_parent_constraint,
145                    &mut children_sizes,
146                    &mut max_child_width,
147                    self.cross_axis_alignment,
148                )?
149            } else {
150                measure_unweighted_column(
151                    &children,
152                    input,
153                    &column_parent_constraint,
154                    &mut children_sizes,
155                    &mut max_child_width,
156                    self.cross_axis_alignment,
157                )?
158            };
159
160        place_children_with_alignment(
161            &PlaceChildrenArgs {
162                children_sizes: &children_sizes,
163                children: &children,
164                final_column_width,
165                final_column_height,
166                total_children_height: total_measured_children_height,
167                main_axis_alignment: self.main_axis_alignment,
168                cross_axis_alignment: self.cross_axis_alignment,
169                child_count: n,
170            },
171            &mut result,
172        );
173
174        Ok(result.with_size(ComputedData {
175            width: final_column_width,
176            height: final_column_height,
177        }))
178    }
179}
180
181struct PlaceChildrenArgs<'a, T> {
182    children_sizes: &'a [Option<ComputedData>],
183    children: &'a [T],
184    final_column_width: Px,
185    final_column_height: Px,
186    total_children_height: Px,
187    main_axis_alignment: MainAxisAlignment,
188    cross_axis_alignment: CrossAxisAlignment,
189    child_count: usize,
190}
191
192fn measure_unweighted_children_for_column(
193    children: &[LayoutChild],
194    input: &mut MeasureScope<'_>,
195    indices: &[usize],
196    children_sizes: &mut [Option<ComputedData>],
197    max_child_width: &mut Px,
198    column_parent_constraint: &Constraint,
199    cross_axis_alignment: CrossAxisAlignment,
200) -> Result<Px, MeasurementError> {
201    let mut total = Px(0);
202
203    let parent_offered_constraint_for_child = Constraint::new(
204        cross_axis_child_constraint(column_parent_constraint.width, cross_axis_alignment),
205        column_parent_constraint.height.without_min(),
206    );
207
208    for &child_idx in indices {
209        let child_id = children[child_idx];
210        let child_result = child_id.measure(input, &parent_offered_constraint_for_child)?;
211        children_sizes[child_idx] = Some(child_result.size());
212        total += child_result.height;
213        *max_child_width = (*max_child_width).max(child_result.width);
214    }
215
216    Ok(total)
217}
218
219struct WeightedColumnMeasureContext<'a> {
220    children_sizes: &'a mut [Option<ComputedData>],
221    max_child_width: &'a mut Px,
222    column_parent_constraint: &'a Constraint,
223    child_weights: &'a [f32],
224    cross_axis_alignment: CrossAxisAlignment,
225}
226
227fn measure_weighted_children_for_column(
228    children: &[LayoutChild],
229    scope: &mut MeasureScope<'_>,
230    ctx: WeightedColumnMeasureContext<'_>,
231    weighted_indices: &[usize],
232    remaining_height: Px,
233    total_weight: f32,
234) -> Result<(), MeasurementError> {
235    if total_weight <= 0.0 {
236        return Ok(());
237    }
238
239    for &child_idx in weighted_indices {
240        let child_weight = ctx.child_weights[child_idx];
241        let allocated_height =
242            Px((remaining_height.0 as f32 * (child_weight / total_weight)) as i32);
243        let child_id = children[child_idx];
244        let parent_offered_constraint_for_child = Constraint::new(
245            cross_axis_child_constraint(
246                ctx.column_parent_constraint.width,
247                ctx.cross_axis_alignment,
248            ),
249            AxisConstraint::exact(allocated_height),
250        );
251        let child_result = child_id.measure(scope, &parent_offered_constraint_for_child)?;
252        ctx.children_sizes[child_idx] = Some(child_result.size());
253        *ctx.max_child_width = (*ctx.max_child_width).max(child_result.width);
254    }
255
256    Ok(())
257}
258
259fn measure_weighted_column(
260    children: &[LayoutChild],
261    input: &mut MeasureScope<'_>,
262    child_weights: &[f32],
263    column_parent_constraint: &Constraint,
264    children_sizes: &mut [Option<ComputedData>],
265    max_child_width: &mut Px,
266    cross_axis_alignment: CrossAxisAlignment,
267) -> Result<(Px, Px, Px), MeasurementError> {
268    let available_height_for_children = column_parent_constraint
269        .height
270        .resolve_max()
271        .expect("Column height Fill expected with finite max constraint");
272
273    let (weighted_children_indices, unweighted_children_indices, total_weight_sum) =
274        classify_children(child_weights);
275
276    let total_height_of_unweighted_children = measure_unweighted_children_for_column(
277        children,
278        input,
279        &unweighted_children_indices,
280        children_sizes,
281        max_child_width,
282        column_parent_constraint,
283        cross_axis_alignment,
284    )?;
285
286    let remaining_height_for_weighted_children =
287        (available_height_for_children - total_height_of_unweighted_children).max(Px(0));
288
289    measure_weighted_children_for_column(
290        children,
291        input,
292        WeightedColumnMeasureContext {
293            children_sizes,
294            max_child_width,
295            column_parent_constraint,
296            child_weights,
297            cross_axis_alignment,
298        },
299        &weighted_children_indices,
300        remaining_height_for_weighted_children,
301        total_weight_sum,
302    )?;
303
304    let total_measured_children_height: Px = children_sizes
305        .iter()
306        .filter_map(|s| s.as_ref().map(|s| s.height))
307        .fold(Px(0), |acc, h| acc + h);
308
309    let final_column_height = column_parent_constraint
310        .height
311        .clamp(total_measured_children_height);
312    let final_column_width = column_parent_constraint.width.clamp(*max_child_width);
313
314    Ok((
315        final_column_width,
316        final_column_height,
317        total_measured_children_height,
318    ))
319}
320
321fn collect_child_weights(input: &MeasureScope<'_>, children: &[LayoutChild]) -> Vec<f32> {
322    children
323        .iter()
324        .map(|child_id| {
325            child_id
326                .parent_data::<crate::modifier::WeightParentData>(input)
327                .map(|data| data.weight)
328                .unwrap_or(0.0)
329        })
330        .collect()
331}
332
333fn measure_unweighted_column(
334    children: &[LayoutChild],
335    input: &mut MeasureScope<'_>,
336    column_parent_constraint: &Constraint,
337    children_sizes: &mut [Option<ComputedData>],
338    max_child_width: &mut Px,
339    cross_axis_alignment: CrossAxisAlignment,
340) -> Result<(Px, Px, Px), MeasurementError> {
341    let n = children_sizes.len();
342    let mut total_children_measured_height = Px(0);
343
344    let parent_offered_constraint_for_child = Constraint::new(
345        cross_axis_child_constraint(column_parent_constraint.width, cross_axis_alignment),
346        column_parent_constraint.height.without_min(),
347    );
348
349    for (i, child_id) in children.iter().enumerate().take(n) {
350        let child_result = child_id.measure(input, &parent_offered_constraint_for_child)?;
351        children_sizes[i] = Some(child_result.size());
352        total_children_measured_height += child_result.height;
353        *max_child_width = (*max_child_width).max(child_result.width);
354    }
355
356    let final_column_height = column_parent_constraint
357        .height
358        .clamp(total_children_measured_height);
359    let final_column_width = column_parent_constraint.width.clamp(*max_child_width);
360    Ok((
361        final_column_width,
362        final_column_height,
363        total_children_measured_height,
364    ))
365}
366
367fn place_children_with_alignment<T: LayoutPlacementTarget>(
368    args: &PlaceChildrenArgs<'_, T>,
369    result: &mut LayoutResult,
370) {
371    let (mut current_y, spacing_between_children) = crate::layout_flex::main_axis_offsets(
372        args.final_column_height,
373        args.total_children_height,
374        args.main_axis_alignment,
375        args.child_count,
376    );
377
378    for (i, child_size_opt) in args.children_sizes.iter().enumerate() {
379        if let Some(child_actual_size) = child_size_opt {
380            let child_id = args.children[i];
381            let x_offset = crate::layout_flex::cross_axis_offset(
382                child_actual_size.width,
383                args.final_column_width,
384                args.cross_axis_alignment,
385            );
386            result.place_child(child_id, PxPosition::from_f32_xy(x_offset, current_y));
387            current_y += child_actual_size.height.0 as f32;
388            if i < args.child_count - 1 {
389                current_y += spacing_between_children;
390            }
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use tessera_ui::{
398        AxisConstraint, ComputedData, Dp, LayoutResult, MeasurementError, Modifier,
399        NoopDrawModifier, Px,
400        layout::{MeasureScope, layout},
401        tessera,
402    };
403
404    use crate::{
405        alignment::{CrossAxisAlignment, MainAxisAlignment},
406        modifier::{ModifierExt as _, SemanticsArgs},
407    };
408
409    use super::column;
410
411    #[derive(Clone, PartialEq)]
412    struct FixedTestLayout {
413        width: i32,
414        height: i32,
415    }
416
417    #[derive(Clone, PartialEq)]
418    struct FillHeightTestLayout {
419        width: i32,
420    }
421
422    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
423        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
424            Ok(LayoutResult::new(ComputedData {
425                width: Px::new(self.width),
426                height: Px::new(self.height),
427            }))
428        }
429    }
430
431    impl tessera_ui::LayoutModifierNode for FillHeightTestLayout {
432        fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
433            let height = input
434                .parent_constraint()
435                .height()
436                .resolve_max()
437                .expect("FillHeightTestLayout requires a bounded height constraint");
438
439            Ok(LayoutResult::new(ComputedData {
440                width: Px::new(self.width),
441                height,
442            }))
443        }
444    }
445
446    #[tessera]
447    fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
448        let tag = tag.unwrap_or_default();
449        let width = width.unwrap_or_default();
450        let height = height.unwrap_or_default();
451
452        layout()
453            .policy(FixedTestLayout { width, height })
454            .draw(NoopDrawModifier)
455            .modifier(Modifier::new().semantics(SemanticsArgs {
456                test_tag: Some(tag),
457                ..Default::default()
458            }));
459    }
460
461    #[tessera]
462    fn fill_height_test_box(tag: Option<String>, width: Option<i32>) {
463        let tag = tag.unwrap_or_default();
464        let width = width.unwrap_or_default();
465
466        layout()
467            .policy(FillHeightTestLayout { width })
468            .draw(NoopDrawModifier)
469            .modifier(Modifier::new().semantics(SemanticsArgs {
470                test_tag: Some(tag),
471                ..Default::default()
472            }));
473    }
474
475    #[tessera]
476    fn column_layout_case() {
477        column()
478            .modifier(Modifier::new().constrain(
479                Some(AxisConstraint::exact(Px::new(100))),
480                Some(AxisConstraint::exact(Px::new(60))),
481            ))
482            .main_axis_alignment(MainAxisAlignment::Start)
483            .cross_axis_alignment(CrossAxisAlignment::Center)
484            .children(|| {
485                column_first_box();
486                column_second_box();
487            });
488    }
489
490    #[tessera]
491    fn column_first_box() {
492        fixed_test_box()
493            .tag("column_first".to_string())
494            .width(20)
495            .height(10);
496    }
497
498    #[tessera]
499    fn column_second_box() {
500        fixed_test_box()
501            .tag("column_second".to_string())
502            .width(40)
503            .height(15);
504    }
505
506    #[tessera]
507    fn column_weighted_case() {
508        column()
509            .modifier(Modifier::new().constrain(
510                Some(AxisConstraint::exact(Px::new(60))),
511                Some(AxisConstraint::exact(Px::new(90))),
512            ))
513            .main_axis_alignment(MainAxisAlignment::Start)
514            .cross_axis_alignment(CrossAxisAlignment::Start)
515            .children(|| {
516                fixed_test_box()
517                    .tag("column_weighted_fixed".to_string())
518                    .width(20)
519                    .height(10);
520                layout().modifier(Modifier::new().weight(1.0)).child(|| {
521                    fill_height_test_box()
522                        .tag("column_weighted_fill".to_string())
523                        .width(15);
524                });
525            });
526    }
527
528    #[test]
529    fn column_centers_children_on_cross_axis() {
530        tessera_ui::assert_layout! {
531            viewport: (120, 80),
532            content: {
533                column_layout_case();
534            },
535            expect: {
536                node("column_first").position(40, 0).size(20, 10);
537                node("column_second").position(30, 10).size(40, 15);
538            }
539        }
540    }
541
542    #[test]
543    fn column_allocates_remaining_height_to_weighted_child() {
544        tessera_ui::assert_layout! {
545            viewport: (100, 100),
546            content: {
547                column_weighted_case();
548            },
549            expect: {
550                node("column_weighted_fixed").position(0, 0).size(20, 10);
551                node("column_weighted_fill").position(0, 10).size(15, 80);
552            }
553        }
554    }
555
556    #[tessera]
557    fn sized_test_box(tag: Option<String>, width: Option<Dp>, height: Option<Dp>) {
558        let tag = tag.unwrap_or_default();
559        let width = width.unwrap_or(Dp(0.0));
560        let height = height.unwrap_or(Dp(0.0));
561
562        layout()
563            .draw(NoopDrawModifier)
564            .modifier(
565                Modifier::new()
566                    .size(width, height)
567                    .semantics(SemanticsArgs {
568                        test_tag: Some(tag),
569                        ..Default::default()
570                    }),
571            );
572    }
573
574    #[tessera]
575    fn column_center_sized_case() {
576        column()
577            .modifier(Modifier::new().fill_max_width())
578            .main_axis_alignment(MainAxisAlignment::Start)
579            .cross_axis_alignment(CrossAxisAlignment::Center)
580            .children(|| {
581                sized_test_box()
582                    .tag("column_center_sized".to_string())
583                    .width(Dp(32.0))
584                    .height(Dp(4.0));
585            });
586    }
587
588    #[tessera]
589    fn column_stretch_sized_case() {
590        column()
591            .modifier(Modifier::new().fill_max_width())
592            .main_axis_alignment(MainAxisAlignment::Start)
593            .cross_axis_alignment(CrossAxisAlignment::Stretch)
594            .children(|| {
595                sized_test_box()
596                    .tag("column_stretch_sized".to_string())
597                    .width(Dp(32.0))
598                    .height(Dp(4.0));
599            });
600    }
601
602    #[test]
603    fn column_measures_center_aligned_sized_child_intrinsically() {
604        tessera_ui::assert_layout! {
605            viewport: (100, 60),
606            content: {
607                column_center_sized_case();
608            },
609            expect: {
610                node("column_center_sized").position(34, 0).size(32, 4);
611            }
612        }
613    }
614
615    #[test]
616    fn column_stretches_sized_child_to_container_width() {
617        tessera_ui::assert_layout! {
618            viewport: (100, 60),
619            content: {
620                column_stretch_sized_case();
621            },
622            expect: {
623                node("column_stretch_sized").position(0, 0).size(100, 4);
624            }
625        }
626    }
627}