frus_widgets/flex.rs
1//! [`Flex`]: a container that lays its children out in a row or a column, with
2//! distribution (justify) and alignment (align).
3
4use frus_core::{Insets, Rect, Scene};
5use frus_layout::{Align, AlignContent, Dimension, FlexDirection, Justify, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// A flex container, row or column. It paints no decoration of its own.
12pub struct Flex<Msg = crate::callback::Callback> {
13 direction: FlexDirection,
14 width: Dimension,
15 height: Dimension,
16 flex_grow: f32,
17 flex_shrink: f32,
18 justify: Justify,
19 align: Align,
20 padding: frus_core::InsetsGeometry,
21 gap: f32,
22 /// Spacing **between the lines** of a wrapping container; `None` = `gap`.
23 run_gap: Option<f32>,
24 /// How those lines are distributed across the container.
25 align_content: AlignContent,
26 wrap: bool,
27 children: Vec<Box<dyn Widget<Msg>>>,
28}
29
30impl<Msg> Flex<Msg> {
31 /// A container laying its children out horizontally.
32 pub fn row() -> Self {
33 Self::with_direction(FlexDirection::Row)
34 }
35
36 /// A container laying its children out vertically.
37 pub fn column() -> Self {
38 Self::with_direction(FlexDirection::Column)
39 }
40
41 fn with_direction(direction: FlexDirection) -> Self {
42 Self {
43 direction,
44 width: Dimension::Auto,
45 height: Dimension::Auto,
46 flex_grow: 0.0,
47 flex_shrink: 0.0,
48 justify: Justify::Start,
49 align: Align::Stretch,
50 padding: frus_core::InsetsGeometry::Physical(Insets::ZERO),
51 gap: 0.0,
52 run_gap: None,
53 align_content: AlignContent::default(),
54 wrap: false,
55 children: Vec::new(),
56 }
57 }
58
59 /// Sets the width, in logical pixels.
60 pub fn width(mut self, width: f32) -> Self {
61 self.width = Dimension::Length(width);
62 self
63 }
64
65 /// Sets the height, in logical pixels.
66 pub fn height(mut self, height: f32) -> Self {
67 self.height = Dimension::Length(height);
68 self
69 }
70
71 /// Sets the width as a **fraction of the space the parent offers** — `1.0` fills it.
72 ///
73 /// The answer for a row that must occupy its parent rather than hug its children,
74 /// when the number is the layout's to know and not the caller's. A row that hugs
75 /// leaves a spring inside it nothing to share, so a centred child is not centred;
76 /// naming a width the caller only guessed at is the other way to get that wrong.
77 pub fn width_fraction(mut self, fraction: f32) -> Self {
78 self.width = Dimension::Percent(fraction);
79 self
80 }
81
82 /// The flex grow factor along the parent's main axis.
83 pub fn flex(mut self, grow: f32) -> Self {
84 self.flex_grow = grow;
85 self
86 }
87
88 /// How much of a row's deficit this box absorbs. The default is `0.0` — the
89 /// reference's rule, where an inflexible child is never squeezed and a row that does
90 /// not fit overflows and says so.
91 ///
92 /// `shrink(1.0)` asks for flexbox's behaviour instead: give way rather than let the
93 /// row run over. It is the right answer for a box whose size is a preference rather
94 /// than a requirement, and the wrong one for fixed chrome — an icon button at the end
95 /// of a row should keep its width however long the label beside it grows.
96 pub fn shrink(mut self, shrink: f32) -> Self {
97 self.flex_shrink = shrink;
98 self
99 }
100
101 /// This box never shrinks — the default said out loud, kept because a layout that
102 /// depends on it reads better for saying so. See [`Self::shrink`].
103 pub fn no_shrink(self) -> Self {
104 self.shrink(0.0)
105 }
106
107 /// Lays the children out from the **far end** of the axis: a column from the bottom
108 /// up, a row from the end of the line back. Their order is unchanged — the first
109 /// child is simply placed last.
110 ///
111 /// It is what a transcript wants, where the newest line is at the bottom and the
112 /// column grows upwards from there, and it composes with a scroll region that starts
113 /// at its end.
114 pub fn reverse(mut self) -> Self {
115 self.direction = match self.direction {
116 FlexDirection::Row => FlexDirection::RowReverse,
117 FlexDirection::RowReverse => FlexDirection::Row,
118 FlexDirection::Column => FlexDirection::ColumnReverse,
119 FlexDirection::ColumnReverse => FlexDirection::Column,
120 };
121 self
122 }
123
124 /// How the children are distributed along the main axis.
125 pub fn justify(mut self, justify: Justify) -> Self {
126 self.justify = justify;
127 self
128 }
129
130 /// How the children are aligned on the cross axis.
131 pub fn align(mut self, align: Align) -> Self {
132 self.align = align;
133 self
134 }
135
136 /// Uniform padding, in logical pixels.
137 pub fn padding(mut self, padding: f32) -> Self {
138 self.padding = frus_core::InsetsGeometry::Physical(Insets::uniform(padding));
139 self
140 }
141
142 /// Sets the padding from any insets: one number, physical [`Insets`] — whose left stays
143 /// on the left in a right-to-left script — or
144 /// [`InsetsDirectional`](frus_core::InsetsDirectional), whose start is the right there.
145 pub fn padding_insets(mut self, padding: impl Into<frus_core::InsetsGeometry>) -> Self {
146 self.padding = padding.into();
147 self
148 }
149
150 /// Padding per side: top, right, bottom, left — physical: the left stays on the left in
151 /// a right-to-left script. [`padding_insets`](Self::padding_insets) takes a start and an
152 /// end.
153 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
154 self.padding = frus_core::InsetsGeometry::Physical(Insets::new(top, right, bottom, left));
155 self
156 }
157
158 /// Spacing between children, in logical pixels.
159 pub fn gap(mut self, gap: f32) -> Self {
160 self.gap = gap;
161 self
162 }
163
164 /// Spacing **between the lines** of a wrapping container, where it differs from
165 /// [`gap`](Self::gap) — the reference's `runSpacing` to `gap`'s `spacing`.
166 ///
167 /// One number for both is the wrong shape often enough to be worth two: a wrap of
168 /// chips usually wants them close side by side and further apart line to line,
169 /// because the eye reads a line as a unit and needs the break to see where it ends.
170 ///
171 /// Untold, the lines are spaced by `gap`, so nothing that says nothing changes.
172 /// Silent on a container that does not wrap, which has only one line.
173 pub fn run_gap(mut self, gap: f32) -> Self {
174 self.run_gap = Some(gap);
175 self
176 }
177
178 /// How the **lines** of a wrapping container are distributed across it — the
179 /// reference's `runAlignment`.
180 ///
181 /// Not [`align`](Self::align), which places each child within its line. This places
182 /// the lines themselves, and it has an answer only when there is cross-axis room to
183 /// spare: three lines in a box exactly three lines tall sit where they sit.
184 pub fn align_lines(mut self, align: AlignContent) -> Self {
185 self.align_content = align;
186 self
187 }
188
189 /// Turns **wrapping** on: children that overflow the main axis move to a new
190 /// line, a responsive reflow. See also [`Wrap`] as a named entry point.
191 pub fn wrap(mut self) -> Self {
192 self.wrap = true;
193 self
194 }
195
196 /// Adds a child.
197 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
198 self.children.push(Box::new(child));
199 self
200 }
201
202 /// Adds an **already boxed** child — useful when children are built dynamically,
203 /// in a `Vec<Box<dyn Widget>>`, and already erased to the dynamic type.
204 pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
205 self.children.push(child);
206 self
207 }
208}
209
210impl<Msg> Flex<Msg> {
211 /// The style, with the padding laid out for `direction`.
212 fn style_in(&self, direction: frus_core::TextDirection) -> Style {
213 Style {
214 width: self.width,
215 height: self.height,
216 flex_grow: self.flex_grow,
217 flex_shrink: self.flex_shrink,
218 flex_direction: self.direction,
219 justify: self.justify,
220 align: self.align,
221 padding: self.padding.laid_out(direction),
222 margin: Insets::ZERO,
223 aspect_ratio: None,
224 gap: self.gap,
225 // `run_gap` is spacing **between lines**, and which CSS axis that is
226 // depends on which way the container runs: the lines of a wrapping *row*
227 // stack downwards, the lines of a wrapping *column* stack sideways. Writing
228 // it straight into `row_gap` would silently do the wrong thing to half the
229 // wraps in the framework — and, worse, the right thing to the other half,
230 // so it would look correct wherever anyone happened to check.
231 row_gap: match self.direction {
232 FlexDirection::Row | FlexDirection::RowReverse => self.run_gap,
233 _ => None,
234 },
235 column_gap: match self.direction {
236 FlexDirection::Column | FlexDirection::ColumnReverse => self.run_gap,
237 _ => None,
238 },
239 align_content: self.align_content,
240 flex_wrap: self.wrap,
241 grid_columns: None,
242 ..Default::default()
243 }
244 }
245}
246
247impl<Msg: Clone> Widget<Msg> for Flex<Msg> {
248 fn style(&self) -> Style {
249 self.style_in(frus_core::TextDirection::Ltr)
250 }
251
252 /// The padding laid out for the reading direction the subtree is in (milestone 588).
253 fn style_themed(&self, theme: &crate::theme::Theme) -> Style {
254 self.style_in(theme.direction)
255 }
256
257 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
258 &self.children
259 }
260
261 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
262 // A flex container is transparent: no decoration of its own.
263 }
264
265 fn on_click(&self) -> Option<Msg> {
266 None
267 }
268}
269
270/// A named entry point for the **body of a scrollable**: the reference's `ListBody`.
271///
272/// Children one after another along an axis, each at its natural extent on that axis and
273/// stretched across the other, starting at the beginning and never squashed. Its box grows
274/// to hold them all, which is why it belongs inside something that scrolls — a body taller
275/// than its parent with nowhere to go is an overflow, and says so.
276///
277/// It returns a [`Flex`], because in this framework that is already what a list body is:
278/// the flex defaults here are `flex_shrink: 0`, `align: Stretch` and `justify: Start` — a
279/// column that squashes nothing, stretches everything across, and packs from the top. What
280/// the name adds is the **statement**: this column is the content of a viewport, not a
281/// layout with opinions, and nothing in it is meant to flex. Every one of `Flex`'s
282/// settings is still there, `gap` and `padding` included.
283///
284/// ```ignore
285/// Scroll::new().child(
286/// ListBody::vertical()
287/// .gap(8.0)
288/// .child(header())
289/// .child(body()),
290/// )
291/// ```
292///
293/// [`ListBody::vertical`] is the reference's default. Its `reverse` is
294/// [`Flex::reverse`], which lays the children out from the far end — the shape a chat
295/// transcript wants, together with a scroll region that starts at the bottom.
296pub struct ListBody;
297
298impl ListBody {
299 /// Children stacked downwards — the usual one.
300 pub fn vertical<Msg>() -> Flex<Msg> {
301 Flex::with_direction(FlexDirection::Column)
302 }
303
304 /// Children laid out across, in the reading direction.
305 pub fn horizontal<Msg>() -> Flex<Msg> {
306 Flex::with_direction(FlexDirection::Row)
307 }
308}
309
310/// A named entry point for a **wrapping** row (flex-wrap).
311///
312/// `Wrap::new().gap(8.0).child(a).child(b)…` — the children flow over several lines
313/// according to the available width, with no breakpoint. It returns a [`Flex`], a row
314/// with `wrap` turned on, so all its settings remain available.
315pub struct Wrap;
316
317impl Wrap {
318 /// A row with wrapping turned on.
319 #[allow(clippy::new_ret_no_self)]
320 pub fn new<Msg>() -> Flex<Msg> {
321 Flex::row().wrap()
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 use crate::{build_ui, Container, Runtime, Size};
330 use frus_core::Color;
331
332 /// Four fixed boxes that wrap two per line in a 210-wide window.
333 fn wrapped(flex: Flex<()>) -> Vec<Rect> {
334 let boxes = (0..4).fold(flex, |f, _| {
335 f.child(
336 Container::<()>::new()
337 .width(100.0)
338 .height(20.0)
339 .color(Color::WHITE),
340 )
341 });
342 let ui = build_ui(
343 &boxes,
344 Size::new(210.0, 300.0),
345 &Runtime::default(),
346 &Theme::default(),
347 );
348 ui.scene()
349 .primitives()
350 .iter()
351 .filter_map(|p| match p {
352 frus_core::Primitive::Rect { rect, .. } if rect.width == 100.0 => Some(*rect),
353 _ => None,
354 })
355 .collect()
356 }
357
358 /// The **lines** of a wrap can be spaced apart from the items on them — the
359 /// reference\'s `runSpacing` beside its `spacing`. This is measured from the laid-out
360 /// rectangles rather than read back off the style, because a field set and never
361 /// reaching the layout engine is exactly the failure worth catching.
362 #[test]
363 fn a_wraps_lines_take_their_own_spacing() {
364 let together = wrapped(Wrap::new::<()>().gap(4.0));
365 assert_eq!(together.len(), 4, "two lines of two");
366 // Line two starts one box height plus the gap below line one.
367 assert_eq!(together[2].y - together[0].y, 24.0);
368
369 let apart = wrapped(Wrap::new::<()>().gap(4.0).run_gap(30.0));
370 assert_eq!(apart[2].y - apart[0].y, 50.0, "the lines, not the items");
371 // The items on a line keep the plain gap.
372 assert_eq!(apart[1].x - apart[0].x, 104.0);
373 }
374
375 /// **Which axis the runs stack on depends on which way the container runs.** The
376 /// lines of a wrapping *row* stack downwards; the lines of a wrapping *column*
377 /// stack sideways. Writing `run_gap` straight into `row_gap` would be right for
378 /// half the wraps in the framework and silently wrong for the other half.
379 #[test]
380 fn the_run_gap_follows_the_direction() {
381 let row = Widget::<()>::style(&Flex::<()>::row().wrap().run_gap(30.0));
382 assert_eq!(row.row_gap, Some(30.0), "a row\'s lines stack downwards");
383 assert_eq!(row.column_gap, None);
384
385 let column = Widget::<()>::style(&Flex::<()>::column().wrap().run_gap(30.0));
386 assert_eq!(column.column_gap, Some(30.0), "a column\'s stack sideways");
387 assert_eq!(column.row_gap, None);
388 }
389
390 /// The lines can be packed rather than stretched. Flexbox stretches them by
391 /// default, which is what we keep — so a wrap that says nothing is unchanged.
392 #[test]
393 fn a_wraps_lines_can_be_packed() {
394 // The height is the point: a wrap sizes to its content, and lines with no
395 // cross-axis room to spare sit where they sit whatever they are told. This is
396 // what the first draft of the test got wrong — both alignments came out at
397 // y = 0 and the feature looked broken when the container was.
398 let tall = || Wrap::new::<()>().gap(4.0).height(300.0);
399 let stretched = wrapped(tall());
400 let packed = wrapped(tall().align_lines(AlignContent::End));
401 // Stretched, the two lines share the 300; packed at the far edge they sit at
402 // the bottom of it.
403 assert_eq!(stretched[0].y, 0.0);
404 assert!(
405 packed[0].y > 200.0,
406 "packed at the end, got {}",
407 packed[0].y
408 );
409 // And they keep their own height rather than filling the room between them.
410 assert_eq!(packed[2].y - packed[0].y, 24.0);
411 assert!(
412 stretched[2].y - stretched[0].y > 24.0,
413 "stretched, the lines spread: {}",
414 stretched[2].y - stretched[0].y
415 );
416 }
417
418 /// A container that says nothing is untouched: no run gap, lines stretched, the
419 /// same rectangles.
420 #[test]
421 fn a_wrap_that_says_nothing_is_what_it_was() {
422 let style = Widget::<()>::style(&Wrap::new::<()>().gap(8.0));
423 assert_eq!(style.row_gap, None);
424 assert_eq!(style.column_gap, None);
425 assert_eq!(style.align_content, AlignContent::Stretch);
426 }
427
428 #[test]
429 fn wrap_sets_flex_wrap_in_style() {
430 let plain = Flex::<()>::row();
431 assert!(!Widget::<()>::style(&plain).flex_wrap);
432
433 let wrapped = Wrap::new::<()>().child(Flex::<()>::row());
434 assert!(Widget::<()>::style(&wrapped).flex_wrap);
435 }
436
437 /// The boxes of `list`, laid out in a 100 × 100 window, in paint order.
438 fn laid_out(list: Flex<()>) -> Vec<Rect> {
439 let ui = build_ui(
440 &list,
441 Size::new(100.0, 100.0),
442 &Runtime::default(),
443 &Theme::default(),
444 );
445 ui.scene()
446 .primitives()
447 .iter()
448 .filter_map(|p| match p {
449 frus_core::Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
450 _ => None,
451 })
452 .collect()
453 }
454
455 /// A row of `height` with no width of its own, red.
456 fn row_of(height: f32) -> Container<()> {
457 Container::<()>::new()
458 .height(height)
459 .color(Color::rgb(1.0, 0.0, 0.0))
460 }
461
462 /// The two halves of what a list body promises: each child at its own extent along
463 /// the axis, and the whole width across it.
464 #[test]
465 fn a_list_body_gives_each_child_its_own_extent_and_the_full_width() {
466 let boxes = laid_out(
467 ListBody::vertical()
468 .width(100.0)
469 .child(row_of(20.0))
470 .child(row_of(30.0)),
471 );
472 assert_eq!(boxes.len(), 2);
473 assert_eq!((boxes[0].y, boxes[0].height), (0.0, 20.0));
474 assert_eq!((boxes[1].y, boxes[1].height), (20.0, 30.0));
475 assert!(
476 boxes.iter().all(|r| (r.width - 100.0).abs() < 0.5),
477 "stretched across: {boxes:?}"
478 );
479 }
480
481 /// And the one that matters inside a viewport: children that do not fit are **not**
482 /// squashed to make them. A body taller than its box overflows it, which is what a
483 /// scroll region is for — and what the debug band says when there is no scroll region.
484 #[test]
485 fn a_list_body_never_squashes_a_child_that_does_not_fit() {
486 let boxes = laid_out(
487 ListBody::vertical()
488 .width(100.0)
489 .height(50.0)
490 .child(row_of(40.0))
491 .child(row_of(40.0)),
492 );
493 assert_eq!(boxes[0].height, 40.0);
494 assert_eq!(boxes[1].height, 40.0, "the second one absorbed the deficit");
495 assert_eq!(boxes[1].y, 40.0);
496 }
497
498 /// Reversed, the first child is placed last: a transcript grows from the bottom.
499 #[test]
500 fn a_reversed_column_lays_its_children_out_from_the_far_end() {
501 let boxes = laid_out(
502 ListBody::vertical()
503 .reverse()
504 .width(100.0)
505 .height(100.0)
506 .child(row_of(20.0))
507 .child(row_of(30.0)),
508 );
509 assert_eq!(boxes[0].y, 80.0, "the first child sits at the bottom");
510 assert_eq!(boxes[1].y, 50.0, "the second one above it");
511 }
512
513 /// Reversing twice is the direction it started with — the builder is a flip, not a
514 /// flag, so `ListBody::horizontal().reverse()` is a row from the end.
515 #[test]
516 fn reversing_a_row_twice_is_the_row_it_was() {
517 let there = Widget::<()>::style(&ListBody::horizontal::<()>().reverse()).flex_direction;
518 let back = Widget::<()>::style(&ListBody::horizontal::<()>().reverse().reverse());
519 assert_eq!(there, FlexDirection::RowReverse);
520 assert_eq!(back.flex_direction, FlexDirection::Row);
521 }
522}