Skip to main content

tessera_components/
row.rs

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