1use 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#[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}