Skip to main content

tessera_components/
flow_row.rs

1//! A flowing horizontal layout component.
2//!
3//! ## Usage
4//!
5//! Wrap chips, tags, or button groups across multiple rows.
6use tessera_ui::{
7    __private::configure_layout_node,
8    AxisConstraint, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier,
9    NoopDrawModifier, Px, PxPosition, RenderSlot,
10    layout::{LayoutChild, MeasureScope},
11    tessera,
12};
13
14use crate::{
15    alignment::{CrossAxisAlignment, MainAxisAlignment},
16    layout_flex::{
17        alignment_offsets as calculate_alignment_offsets, cross_axis_child_constraint,
18        cross_axis_offset as calculate_cross_axis_offset, px_mul,
19    },
20};
21
22/// # flow_row
23///
24/// A layout component that wraps children into multiple horizontal rows.
25///
26/// ## Usage
27///
28/// Wrap chips, tags, or button groups to fit variable-width screens.
29///
30/// ## Parameters
31///
32/// - `modifier` — modifier chain applied to the flowing row container.
33/// - `main_axis_alignment` — alignment within each wrapped line.
34/// - `cross_axis_alignment` — cross-axis alignment for items inside a line.
35/// - `line_alignment` — alignment between wrapped lines.
36/// - `item_spacing` — spacing between items in the same line.
37/// - `line_spacing` — spacing between wrapped lines.
38/// - `max_items_per_line` — optional cap for items per line.
39/// - `max_lines` — optional cap for total wrapped lines.
40/// - `children` — child slot rendered inside the layout.
41///
42/// ## Examples
43///
44/// ```
45/// use tessera_components::{boxed::boxed, flow_row::flow_row, modifier::{ModifierExt as _, SemanticsArgs}};
46/// use tessera_ui::{Dp, Modifier, tessera, testing::layout_test};
47///
48/// #[tessera]
49/// fn demo() {
50///     flow_row().modifier(Modifier::new().width(Dp(50.0))).children(|| {
51///         for tag in ["first", "second"] {
52///             boxed().modifier(
53///                 Modifier::new().size(Dp(30.0), Dp(10.0))
54///                     .semantics(SemanticsArgs { test_tag: Some(tag.into()), ..Default::default() })
55///             ).children(|| {});
56///         }
57///     });
58/// }
59///
60/// let snapshot = layout_test(demo).viewport_px(50, 100).run();
61/// snapshot.node("first").position(0, 0).size(30, 10);
62/// snapshot.node("second").position(0, 10).size(30, 10);
63/// ```
64#[tessera(tessera_ui, layout)]
65pub fn flow_row(
66    modifier: Option<Modifier>,
67    main_axis_alignment: Option<MainAxisAlignment>,
68    cross_axis_alignment: Option<CrossAxisAlignment>,
69    line_alignment: Option<MainAxisAlignment>,
70    item_spacing: Option<Dp>,
71    line_spacing: Option<Dp>,
72    max_items_per_line: Option<usize>,
73    max_lines: Option<usize>,
74    children: Option<RenderSlot>,
75) {
76    let modifier = modifier.unwrap_or_default();
77    let main_axis_alignment = main_axis_alignment.unwrap_or_default();
78    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
79    let line_alignment = line_alignment.unwrap_or_default();
80    let item_spacing = sanitize_spacing(Px::from(item_spacing.unwrap_or(Dp(0.0))));
81    let line_spacing = sanitize_spacing(Px::from(line_spacing.unwrap_or(Dp(0.0))));
82    let max_items_per_line = max_items_per_line.unwrap_or(usize::MAX);
83    let max_lines = max_lines.unwrap_or(usize::MAX);
84    configure_layout_node(
85        FlowRowLayout {
86            main_axis_alignment,
87            cross_axis_alignment,
88            line_alignment,
89            item_spacing,
90            line_spacing,
91            max_items_per_line,
92            max_lines,
93        },
94        NoopDrawModifier,
95        modifier,
96    );
97    children.unwrap_or_else(RenderSlot::empty).render();
98}
99
100#[derive(Clone, PartialEq)]
101struct FlowRowLayout {
102    main_axis_alignment: MainAxisAlignment,
103    cross_axis_alignment: CrossAxisAlignment,
104    line_alignment: MainAxisAlignment,
105    item_spacing: Px,
106    line_spacing: Px,
107    max_items_per_line: usize,
108    max_lines: usize,
109}
110
111impl tessera_ui::LayoutModifierNode for FlowRowLayout {
112    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
113        let mut result = LayoutResult::default();
114        let children = input.children();
115        let child_weights = collect_child_weights(input);
116        let n = child_weights.len();
117        assert_eq!(
118            children.len(),
119            n,
120            "Mismatch between children defined in scope and runtime children count"
121        );
122
123        let flow_constraint = *input.parent_constraint().as_ref();
124        let max_width = flow_constraint.width.resolve_max();
125
126        let child_constraint = Constraint::new(
127            flow_constraint.width.without_min(),
128            cross_axis_child_constraint(flow_constraint.height, self.cross_axis_alignment),
129        );
130
131        let use_weighted_remeasure = max_width.is_some();
132        let mut unweighted_nodes = Vec::new();
133        let mut weighted_nodes = Vec::new();
134        for (idx, child_id) in children.iter().enumerate().take(n) {
135            let weight = child_weights.get(idx).copied().flatten().unwrap_or(0.0);
136            if weight > 0.0 && use_weighted_remeasure {
137                weighted_nodes.push((*child_id, child_constraint));
138            } else {
139                unweighted_nodes.push((*child_id, child_constraint));
140            }
141        }
142
143        let mut children_sizes = vec![None; n];
144        for (i, child_id) in children.iter().enumerate().take(n) {
145            if let Some((_, constraint)) = unweighted_nodes
146                .iter()
147                .find(|(candidate, _)| candidate == child_id)
148            {
149                children_sizes[i] = Some(child_id.measure(input, constraint)?.size());
150                continue;
151            }
152            if let Some((_, constraint)) = weighted_nodes
153                .iter()
154                .find(|(candidate, _)| candidate == child_id)
155            {
156                children_sizes[i] = Some(child_id.probe(input, constraint)?.size());
157            }
158        }
159
160        let lines = build_row_lines(
161            &children_sizes,
162            max_width,
163            self.item_spacing,
164            self.max_items_per_line,
165            self.max_lines,
166        );
167
168        if let Some(max_width) = max_width {
169            apply_weighted_children_row(
170                input,
171                WeightedRowMeasureInput {
172                    children: &children,
173                    flow_constraint: &flow_constraint,
174                    lines: &lines,
175                    children_sizes: &mut children_sizes,
176                    child_weights: &child_weights,
177                    item_spacing: self.item_spacing,
178                    max_width,
179                    cross_axis_alignment: self.cross_axis_alignment,
180                },
181            )?;
182        }
183
184        let line_metrics = compute_row_line_metrics(&lines, &children_sizes, self.item_spacing);
185        if self.cross_axis_alignment == CrossAxisAlignment::Stretch {
186            for (line, metric) in lines.iter().zip(&line_metrics) {
187                for &index in line {
188                    let size = children_sizes[index].expect("measured flow child");
189                    let size = children[index]
190                        .measure(
191                            input,
192                            &Constraint::new(
193                                AxisConstraint::exact(size.width),
194                                AxisConstraint::exact(metric.cross),
195                            ),
196                        )?
197                        .size();
198                    children_sizes[index] = Some(size);
199                }
200            }
201        }
202        let (content_width, content_height) =
203            compute_row_content_size(&line_metrics, self.line_spacing);
204
205        let final_width = flow_constraint.width.clamp(content_width);
206        let final_height = flow_constraint.height.clamp(content_height);
207
208        place_flow_row(
209            &mut result,
210            &children,
211            &lines,
212            &children_sizes,
213            &line_metrics,
214            self.main_axis_alignment,
215            self.cross_axis_alignment,
216            self.line_alignment,
217            self.item_spacing,
218            self.line_spacing,
219            final_width,
220            final_height,
221        );
222
223        Ok(result.with_size(ComputedData {
224            width: final_width,
225            height: final_height,
226        }))
227    }
228}
229
230fn collect_child_weights(input: &mut MeasureScope<'_>) -> Vec<Option<f32>> {
231    input
232        .children()
233        .iter()
234        .map(|child_id| {
235            child_id
236                .parent_data::<crate::modifier::WeightParentData>(input)
237                .map(|data| data.weight)
238        })
239        .collect()
240}
241
242#[derive(Clone, PartialEq, Copy)]
243struct LineMetric {
244    main: Px,
245    cross: Px,
246}
247
248fn build_row_lines(
249    children_sizes: &[Option<ComputedData>],
250    max_width: Option<Px>,
251    item_spacing: Px,
252    max_items_per_line: usize,
253    max_lines: usize,
254) -> Vec<Vec<usize>> {
255    if max_items_per_line == 0 || max_lines == 0 {
256        return Vec::new();
257    }
258
259    let mut lines: Vec<Vec<usize>> = Vec::new();
260    let mut current_line: Vec<usize> = Vec::new();
261    let mut current_width = Px::ZERO;
262
263    for (index, child_size_opt) in children_sizes.iter().enumerate() {
264        let Some(child_size) = child_size_opt else {
265            continue;
266        };
267
268        let spacing = if current_line.is_empty() {
269            Px::ZERO
270        } else {
271            item_spacing
272        };
273        let proposed_width = current_width + spacing + child_size.width;
274        let exceeds_width = match max_width {
275            Some(limit) => proposed_width > limit && !current_line.is_empty(),
276            None => false,
277        };
278        let exceeds_items = current_line.len() >= max_items_per_line;
279
280        if (exceeds_width || exceeds_items) && !current_line.is_empty() {
281            lines.push(current_line);
282            if lines.len() >= max_lines {
283                return lines;
284            }
285            current_line = Vec::new();
286            current_width = Px::ZERO;
287        }
288
289        current_width = if current_line.is_empty() {
290            child_size.width
291        } else {
292            current_width + item_spacing + child_size.width
293        };
294        current_line.push(index);
295    }
296
297    if !current_line.is_empty() && lines.len() < max_lines {
298        lines.push(current_line);
299    }
300
301    lines
302}
303
304struct WeightedRowMeasureInput<'a> {
305    children: &'a [LayoutChild],
306    flow_constraint: &'a Constraint,
307    lines: &'a [Vec<usize>],
308    children_sizes: &'a mut [Option<ComputedData>],
309    child_weights: &'a [Option<f32>],
310    item_spacing: Px,
311    max_width: Px,
312    cross_axis_alignment: CrossAxisAlignment,
313}
314
315fn apply_weighted_children_row(
316    scope: &mut MeasureScope<'_>,
317    args: WeightedRowMeasureInput<'_>,
318) -> Result<(), MeasurementError> {
319    let WeightedRowMeasureInput {
320        children,
321        flow_constraint,
322        lines,
323        children_sizes,
324        child_weights,
325        item_spacing,
326        max_width,
327        cross_axis_alignment,
328    } = args;
329    let mut allocations: Vec<(usize, Px)> = Vec::new();
330
331    for line in lines {
332        let mut total_weight = 0.0;
333        let mut fixed_width = Px::ZERO;
334        let mut weighted_indices: Vec<usize> = Vec::new();
335
336        for &idx in line {
337            let weight = child_weights[idx].unwrap_or(0.0);
338            if weight > 0.0 {
339                total_weight += weight;
340                weighted_indices.push(idx);
341            } else if let Some(size) = children_sizes[idx] {
342                fixed_width += size.width;
343            }
344        }
345
346        if total_weight <= 0.0 {
347            continue;
348        }
349
350        let spacing_total = if line.len() > 1 {
351            px_mul(item_spacing, line.len().saturating_sub(1))
352        } else {
353            Px::ZERO
354        };
355        let remaining = (max_width - fixed_width - spacing_total).max(Px::ZERO);
356
357        let mut allocated_total = Px::ZERO;
358        for (pos, idx) in weighted_indices.iter().enumerate() {
359            let weight = child_weights[*idx].unwrap_or(0.0);
360            let allocated = if pos + 1 == weighted_indices.len() {
361                (remaining - allocated_total).max(Px::ZERO)
362            } else {
363                Px((remaining.0 as f32 * (weight / total_weight)) as i32)
364            };
365            allocated_total += allocated;
366            allocations.push((*idx, allocated));
367        }
368    }
369
370    if allocations.is_empty() {
371        return Ok(());
372    }
373
374    for &(idx, allocated) in &allocations {
375        let child_id = children[idx];
376        let child_size = child_id.measure(
377            scope,
378            &Constraint::new(
379                AxisConstraint::exact(allocated),
380                cross_axis_child_constraint(flow_constraint.height, cross_axis_alignment),
381            ),
382        )?;
383        children_sizes[idx] = Some(child_size.size());
384    }
385
386    Ok(())
387}
388
389fn compute_row_line_metrics(
390    lines: &[Vec<usize>],
391    children_sizes: &[Option<ComputedData>],
392    item_spacing: Px,
393) -> Vec<LineMetric> {
394    let mut metrics = Vec::with_capacity(lines.len());
395
396    for line in lines {
397        let mut main = Px::ZERO;
398        let mut cross = Px::ZERO;
399
400        for (pos, idx) in line.iter().enumerate() {
401            if let Some(child_size) = children_sizes[*idx] {
402                main += child_size.width;
403                if pos + 1 < line.len() {
404                    main += item_spacing;
405                }
406                cross = cross.max(child_size.height);
407            }
408        }
409
410        metrics.push(LineMetric { main, cross });
411    }
412
413    metrics
414}
415
416fn compute_row_content_size(line_metrics: &[LineMetric], line_spacing: Px) -> (Px, Px) {
417    let mut width = Px::ZERO;
418    let mut height = Px::ZERO;
419
420    for metric in line_metrics {
421        width = width.max(metric.main);
422        height += metric.cross;
423    }
424
425    if !line_metrics.is_empty() {
426        height += px_mul(line_spacing, line_metrics.len().saturating_sub(1));
427    }
428
429    (width, height)
430}
431
432#[allow(clippy::too_many_arguments)]
433fn place_flow_row(
434    result: &mut LayoutResult,
435    children: &[LayoutChild],
436    lines: &[Vec<usize>],
437    children_sizes: &[Option<ComputedData>],
438    line_metrics: &[LineMetric],
439    main_axis_alignment: MainAxisAlignment,
440    cross_axis_alignment: CrossAxisAlignment,
441    line_alignment: MainAxisAlignment,
442    item_spacing: Px,
443    line_spacing: Px,
444    final_width: Px,
445    final_height: Px,
446) {
447    if lines.is_empty() {
448        return;
449    }
450
451    let mut total_line_cross = Px::ZERO;
452    for metric in line_metrics {
453        total_line_cross += metric.cross;
454    }
455    let total_line_spacing = px_mul(line_spacing, lines.len().saturating_sub(1));
456    let total_cross = total_line_cross + total_line_spacing;
457    let available_cross = (final_height.0 - total_cross.0).max(0) as f32;
458
459    let (start_cross, extra_line_spacing) =
460        calculate_alignment_offsets(available_cross, lines.len(), line_alignment);
461    let line_gap = line_spacing.0 as f32 + extra_line_spacing;
462
463    let mut current_y = start_cross;
464
465    for (line_index, line) in lines.iter().enumerate() {
466        let line_metric = line_metrics[line_index];
467        let available_main = (final_width.0 - line_metric.main.0).max(0) as f32;
468        let (start_main, extra_item_spacing) =
469            calculate_alignment_offsets(available_main, line.len(), main_axis_alignment);
470        let item_gap = item_spacing.0 as f32 + extra_item_spacing;
471
472        let mut current_x = start_main;
473        for (pos, idx) in line.iter().enumerate() {
474            if let Some(child_size) = children_sizes[*idx] {
475                let child_id = children[*idx];
476                let y_offset = calculate_cross_axis_offset(
477                    child_size.height,
478                    line_metric.cross,
479                    cross_axis_alignment,
480                );
481                result.place_child(
482                    child_id,
483                    PxPosition::from_f32_xy(current_x, current_y + y_offset),
484                );
485                current_x += child_size.width.0 as f32;
486                if pos + 1 < line.len() {
487                    current_x += item_gap;
488                }
489            }
490        }
491
492        current_y += line_metric.cross.0 as f32;
493        if line_index + 1 < lines.len() {
494            current_y += line_gap;
495        }
496    }
497}
498
499fn sanitize_spacing(px: Px) -> Px {
500    if px < Px::ZERO { Px::ZERO } else { px }
501}
502
503#[cfg(test)]
504mod tests {
505    use tessera_ui::{
506        AxisConstraint, ComputedData, Dp, LayoutResult, MeasurementError, Modifier,
507        NoopDrawModifier, Px,
508        layout::{MeasureScope, layout},
509        tessera,
510    };
511
512    use crate::{
513        alignment::{CrossAxisAlignment, MainAxisAlignment},
514        modifier::{ModifierExt as _, SemanticsArgs},
515    };
516
517    use super::flow_row;
518
519    #[derive(Clone, PartialEq)]
520    struct FixedTestLayout {
521        width: i32,
522        height: i32,
523    }
524
525    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
526        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
527            Ok(LayoutResult::new(ComputedData {
528                width: Px::new(self.width),
529                height: Px::new(self.height),
530            }))
531        }
532    }
533
534    #[tessera]
535    fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
536        let tag = tag.unwrap_or_default();
537        let width = width.unwrap_or_default();
538        let height = height.unwrap_or_default();
539
540        layout()
541            .policy(FixedTestLayout { width, height })
542            .draw(NoopDrawModifier)
543            .modifier(Modifier::new().semantics(SemanticsArgs {
544                test_tag: Some(tag),
545                ..Default::default()
546            }));
547    }
548
549    #[tessera]
550    fn flow_row_layout_case() {
551        flow_row()
552            .modifier(Modifier::new().constrain(
553                Some(AxisConstraint::exact(Px::new(60))),
554                Some(AxisConstraint::new(Px::ZERO, Some(Px::new(100)))),
555            ))
556            .main_axis_alignment(MainAxisAlignment::Start)
557            .cross_axis_alignment(CrossAxisAlignment::Start)
558            .line_alignment(MainAxisAlignment::Start)
559            .item_spacing(Px::new(5).into())
560            .line_spacing(Px::new(4).into())
561            .children(|| {
562                fixed_test_box()
563                    .tag("flow_row_first".to_string())
564                    .width(30)
565                    .height(10);
566                fixed_test_box()
567                    .tag("flow_row_second".to_string())
568                    .width(25)
569                    .height(10);
570                fixed_test_box()
571                    .tag("flow_row_third".to_string())
572                    .width(20)
573                    .height(12);
574            });
575    }
576
577    #[test]
578    fn flow_row_wraps_children_across_lines() {
579        tessera_ui::assert_layout! {
580            viewport: (100, 100),
581            content: {
582                flow_row_layout_case();
583            },
584            expect: {
585                node("flow_row_first").position(0, 0).size(30, 10);
586                node("flow_row_second").position(35, 0).size(25, 10);
587                node("flow_row_third").position(0, 14).size(20, 12);
588            }
589        }
590    }
591
592    #[tessera]
593    fn sized_test_box(tag: Option<String>, width: Option<Dp>, height: Option<Dp>) {
594        let tag = tag.unwrap_or_default();
595        let width = width.unwrap_or(Dp(0.0));
596        let height = height.unwrap_or(Dp(0.0));
597
598        layout()
599            .draw(NoopDrawModifier)
600            .modifier(
601                Modifier::new()
602                    .size(width, height)
603                    .semantics(SemanticsArgs {
604                        test_tag: Some(tag),
605                        ..Default::default()
606                    }),
607            );
608    }
609
610    #[tessera]
611    fn flow_row_center_sized_case() {
612        flow_row()
613            .modifier(Modifier::new().fill_max_height())
614            .main_axis_alignment(MainAxisAlignment::Start)
615            .cross_axis_alignment(CrossAxisAlignment::Center)
616            .line_alignment(MainAxisAlignment::Start)
617            .children(|| {
618                sized_test_box()
619                    .tag("flow_row_center_sized".to_string())
620                    .width(Dp(4.0))
621                    .height(Dp(32.0));
622            });
623    }
624
625    #[test]
626    fn flow_row_center_measures_sized_child_intrinsically() {
627        tessera_ui::assert_layout! {
628            viewport: (60, 100),
629            content: {
630                flow_row_center_sized_case();
631            },
632            expect: {
633                node("flow_row_center_sized").position(0, 0).size(4, 32);
634            }
635        }
636    }
637    #[tessera]
638    fn stretch_wrapped_case() {
639        flow_row()
640            .modifier(Modifier::new().constrain(
641                Some(AxisConstraint::exact(Px(60))),
642                Some(AxisConstraint::new(Px::ZERO, Some(Px(100)))),
643            ))
644            .cross_axis_alignment(CrossAxisAlignment::Stretch)
645            .item_spacing(Dp(5.0))
646            .line_spacing(Dp(4.0))
647            .children(|| {
648                sized_test_box()
649                    .tag("stretch_0".to_owned())
650                    .width(Dp(30.0))
651                    .height(Dp(10.0));
652                sized_test_box()
653                    .tag("stretch_1".to_owned())
654                    .width(Dp(25.0))
655                    .height(Dp(20.0));
656                sized_test_box()
657                    .tag("stretch_2".to_owned())
658                    .width(Dp(20.0))
659                    .height(Dp(12.0));
660            });
661    }
662
663    #[test]
664    fn stretch_matches_the_cross_size_of_each_wrapped_row() {
665        tessera_ui::assert_layout! {
666            viewport: (100, 100),
667            content: { stretch_wrapped_case(); },
668            expect: {
669                node("stretch_0").position(0, 0).size(30, 20);
670                node("stretch_1").position(35, 0).size(25, 20);
671                node("stretch_2").position(0, 24).size(20, 12);
672            }
673        }
674    }
675}