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: Insets,
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: 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 = Insets::uniform(padding);
139 self
140 }
141
142 /// Padding per side: top, right, bottom, left.
143 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
144 self.padding = Insets::new(top, right, bottom, left);
145 self
146 }
147
148 /// Spacing between children, in logical pixels.
149 pub fn gap(mut self, gap: f32) -> Self {
150 self.gap = gap;
151 self
152 }
153
154 /// Spacing **between the lines** of a wrapping container, where it differs from
155 /// [`gap`](Self::gap) — the reference's `runSpacing` to `gap`'s `spacing`.
156 ///
157 /// One number for both is the wrong shape often enough to be worth two: a wrap of
158 /// chips usually wants them close side by side and further apart line to line,
159 /// because the eye reads a line as a unit and needs the break to see where it ends.
160 ///
161 /// Untold, the lines are spaced by `gap`, so nothing that says nothing changes.
162 /// Silent on a container that does not wrap, which has only one line.
163 pub fn run_gap(mut self, gap: f32) -> Self {
164 self.run_gap = Some(gap);
165 self
166 }
167
168 /// How the **lines** of a wrapping container are distributed across it — the
169 /// reference's `runAlignment`.
170 ///
171 /// Not [`align`](Self::align), which places each child within its line. This places
172 /// the lines themselves, and it has an answer only when there is cross-axis room to
173 /// spare: three lines in a box exactly three lines tall sit where they sit.
174 pub fn align_lines(mut self, align: AlignContent) -> Self {
175 self.align_content = align;
176 self
177 }
178
179 /// Turns **wrapping** on: children that overflow the main axis move to a new
180 /// line, a responsive reflow. See also [`Wrap`] as a named entry point.
181 pub fn wrap(mut self) -> Self {
182 self.wrap = true;
183 self
184 }
185
186 /// Adds a child.
187 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
188 self.children.push(Box::new(child));
189 self
190 }
191
192 /// Adds an **already boxed** child — useful when children are built dynamically,
193 /// in a `Vec<Box<dyn Widget>>`, and already erased to the dynamic type.
194 pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
195 self.children.push(child);
196 self
197 }
198}
199
200impl<Msg: Clone> Widget<Msg> for Flex<Msg> {
201 fn style(&self) -> Style {
202 Style {
203 width: self.width,
204 height: self.height,
205 flex_grow: self.flex_grow,
206 flex_shrink: self.flex_shrink,
207 flex_direction: self.direction,
208 justify: self.justify,
209 align: self.align,
210 padding: self.padding,
211 margin: Insets::ZERO,
212 aspect_ratio: None,
213 gap: self.gap,
214 // `run_gap` is spacing **between lines**, and which CSS axis that is
215 // depends on which way the container runs: the lines of a wrapping *row*
216 // stack downwards, the lines of a wrapping *column* stack sideways. Writing
217 // it straight into `row_gap` would silently do the wrong thing to half the
218 // wraps in the framework — and, worse, the right thing to the other half,
219 // so it would look correct wherever anyone happened to check.
220 row_gap: match self.direction {
221 FlexDirection::Row | FlexDirection::RowReverse => self.run_gap,
222 _ => None,
223 },
224 column_gap: match self.direction {
225 FlexDirection::Column | FlexDirection::ColumnReverse => self.run_gap,
226 _ => None,
227 },
228 align_content: self.align_content,
229 flex_wrap: self.wrap,
230 grid_columns: None,
231 ..Default::default()
232 }
233 }
234
235 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
236 &self.children
237 }
238
239 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
240 // A flex container is transparent: no decoration of its own.
241 }
242
243 fn on_click(&self) -> Option<Msg> {
244 None
245 }
246}
247
248/// A named entry point for the **body of a scrollable**: the reference's `ListBody`.
249///
250/// Children one after another along an axis, each at its natural extent on that axis and
251/// stretched across the other, starting at the beginning and never squashed. Its box grows
252/// to hold them all, which is why it belongs inside something that scrolls — a body taller
253/// than its parent with nowhere to go is an overflow, and says so.
254///
255/// It returns a [`Flex`], because in this framework that is already what a list body is:
256/// the flex defaults here are `flex_shrink: 0`, `align: Stretch` and `justify: Start` — a
257/// column that squashes nothing, stretches everything across, and packs from the top. What
258/// the name adds is the **statement**: this column is the content of a viewport, not a
259/// layout with opinions, and nothing in it is meant to flex. Every one of `Flex`'s
260/// settings is still there, `gap` and `padding` included.
261///
262/// ```ignore
263/// Scroll::new().child(
264/// ListBody::vertical()
265/// .gap(8.0)
266/// .child(header())
267/// .child(body()),
268/// )
269/// ```
270///
271/// [`ListBody::vertical`] is the reference's default. Its `reverse` is
272/// [`Flex::reverse`], which lays the children out from the far end — the shape a chat
273/// transcript wants, together with a scroll region that starts at the bottom.
274pub struct ListBody;
275
276impl ListBody {
277 /// Children stacked downwards — the usual one.
278 pub fn vertical<Msg>() -> Flex<Msg> {
279 Flex::with_direction(FlexDirection::Column)
280 }
281
282 /// Children laid out across, in the reading direction.
283 pub fn horizontal<Msg>() -> Flex<Msg> {
284 Flex::with_direction(FlexDirection::Row)
285 }
286}
287
288/// A named entry point for a **wrapping** row (flex-wrap).
289///
290/// `Wrap::new().gap(8.0).child(a).child(b)…` — the children flow over several lines
291/// according to the available width, with no breakpoint. It returns a [`Flex`], a row
292/// with `wrap` turned on, so all its settings remain available.
293pub struct Wrap;
294
295impl Wrap {
296 /// A row with wrapping turned on.
297 #[allow(clippy::new_ret_no_self)]
298 pub fn new<Msg>() -> Flex<Msg> {
299 Flex::row().wrap()
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 use crate::{build_ui, Container, Runtime, Size};
308 use frus_core::Color;
309
310 /// Four fixed boxes that wrap two per line in a 210-wide window.
311 fn wrapped(flex: Flex<()>) -> Vec<Rect> {
312 let boxes = (0..4).fold(flex, |f, _| {
313 f.child(
314 Container::<()>::new()
315 .width(100.0)
316 .height(20.0)
317 .color(Color::WHITE),
318 )
319 });
320 let ui = build_ui(
321 &boxes,
322 Size::new(210.0, 300.0),
323 &Runtime::default(),
324 &Theme::default(),
325 );
326 ui.scene()
327 .primitives()
328 .iter()
329 .filter_map(|p| match p {
330 frus_core::Primitive::Rect { rect, .. } if rect.width == 100.0 => Some(*rect),
331 _ => None,
332 })
333 .collect()
334 }
335
336 /// The **lines** of a wrap can be spaced apart from the items on them — the
337 /// reference\'s `runSpacing` beside its `spacing`. This is measured from the laid-out
338 /// rectangles rather than read back off the style, because a field set and never
339 /// reaching the layout engine is exactly the failure worth catching.
340 #[test]
341 fn a_wraps_lines_take_their_own_spacing() {
342 let together = wrapped(Wrap::new::<()>().gap(4.0));
343 assert_eq!(together.len(), 4, "two lines of two");
344 // Line two starts one box height plus the gap below line one.
345 assert_eq!(together[2].y - together[0].y, 24.0);
346
347 let apart = wrapped(Wrap::new::<()>().gap(4.0).run_gap(30.0));
348 assert_eq!(apart[2].y - apart[0].y, 50.0, "the lines, not the items");
349 // The items on a line keep the plain gap.
350 assert_eq!(apart[1].x - apart[0].x, 104.0);
351 }
352
353 /// **Which axis the runs stack on depends on which way the container runs.** The
354 /// lines of a wrapping *row* stack downwards; the lines of a wrapping *column*
355 /// stack sideways. Writing `run_gap` straight into `row_gap` would be right for
356 /// half the wraps in the framework and silently wrong for the other half.
357 #[test]
358 fn the_run_gap_follows_the_direction() {
359 let row = Widget::<()>::style(&Flex::<()>::row().wrap().run_gap(30.0));
360 assert_eq!(row.row_gap, Some(30.0), "a row\'s lines stack downwards");
361 assert_eq!(row.column_gap, None);
362
363 let column = Widget::<()>::style(&Flex::<()>::column().wrap().run_gap(30.0));
364 assert_eq!(column.column_gap, Some(30.0), "a column\'s stack sideways");
365 assert_eq!(column.row_gap, None);
366 }
367
368 /// The lines can be packed rather than stretched. Flexbox stretches them by
369 /// default, which is what we keep — so a wrap that says nothing is unchanged.
370 #[test]
371 fn a_wraps_lines_can_be_packed() {
372 // The height is the point: a wrap sizes to its content, and lines with no
373 // cross-axis room to spare sit where they sit whatever they are told. This is
374 // what the first draft of the test got wrong — both alignments came out at
375 // y = 0 and the feature looked broken when the container was.
376 let tall = || Wrap::new::<()>().gap(4.0).height(300.0);
377 let stretched = wrapped(tall());
378 let packed = wrapped(tall().align_lines(AlignContent::End));
379 // Stretched, the two lines share the 300; packed at the far edge they sit at
380 // the bottom of it.
381 assert_eq!(stretched[0].y, 0.0);
382 assert!(
383 packed[0].y > 200.0,
384 "packed at the end, got {}",
385 packed[0].y
386 );
387 // And they keep their own height rather than filling the room between them.
388 assert_eq!(packed[2].y - packed[0].y, 24.0);
389 assert!(
390 stretched[2].y - stretched[0].y > 24.0,
391 "stretched, the lines spread: {}",
392 stretched[2].y - stretched[0].y
393 );
394 }
395
396 /// A container that says nothing is untouched: no run gap, lines stretched, the
397 /// same rectangles.
398 #[test]
399 fn a_wrap_that_says_nothing_is_what_it_was() {
400 let style = Widget::<()>::style(&Wrap::new::<()>().gap(8.0));
401 assert_eq!(style.row_gap, None);
402 assert_eq!(style.column_gap, None);
403 assert_eq!(style.align_content, AlignContent::Stretch);
404 }
405
406 #[test]
407 fn wrap_sets_flex_wrap_in_style() {
408 let plain = Flex::<()>::row();
409 assert!(!Widget::<()>::style(&plain).flex_wrap);
410
411 let wrapped = Wrap::new::<()>().child(Flex::<()>::row());
412 assert!(Widget::<()>::style(&wrapped).flex_wrap);
413 }
414
415 /// The boxes of `list`, laid out in a 100 × 100 window, in paint order.
416 fn laid_out(list: Flex<()>) -> Vec<Rect> {
417 let ui = build_ui(
418 &list,
419 Size::new(100.0, 100.0),
420 &Runtime::default(),
421 &Theme::default(),
422 );
423 ui.scene()
424 .primitives()
425 .iter()
426 .filter_map(|p| match p {
427 frus_core::Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
428 _ => None,
429 })
430 .collect()
431 }
432
433 /// A row of `height` with no width of its own, red.
434 fn row_of(height: f32) -> Container<()> {
435 Container::<()>::new()
436 .height(height)
437 .color(Color::rgb(1.0, 0.0, 0.0))
438 }
439
440 /// The two halves of what a list body promises: each child at its own extent along
441 /// the axis, and the whole width across it.
442 #[test]
443 fn a_list_body_gives_each_child_its_own_extent_and_the_full_width() {
444 let boxes = laid_out(
445 ListBody::vertical()
446 .width(100.0)
447 .child(row_of(20.0))
448 .child(row_of(30.0)),
449 );
450 assert_eq!(boxes.len(), 2);
451 assert_eq!((boxes[0].y, boxes[0].height), (0.0, 20.0));
452 assert_eq!((boxes[1].y, boxes[1].height), (20.0, 30.0));
453 assert!(
454 boxes.iter().all(|r| (r.width - 100.0).abs() < 0.5),
455 "stretched across: {boxes:?}"
456 );
457 }
458
459 /// And the one that matters inside a viewport: children that do not fit are **not**
460 /// squashed to make them. A body taller than its box overflows it, which is what a
461 /// scroll region is for — and what the debug band says when there is no scroll region.
462 #[test]
463 fn a_list_body_never_squashes_a_child_that_does_not_fit() {
464 let boxes = laid_out(
465 ListBody::vertical()
466 .width(100.0)
467 .height(50.0)
468 .child(row_of(40.0))
469 .child(row_of(40.0)),
470 );
471 assert_eq!(boxes[0].height, 40.0);
472 assert_eq!(boxes[1].height, 40.0, "the second one absorbed the deficit");
473 assert_eq!(boxes[1].y, 40.0);
474 }
475
476 /// Reversed, the first child is placed last: a transcript grows from the bottom.
477 #[test]
478 fn a_reversed_column_lays_its_children_out_from_the_far_end() {
479 let boxes = laid_out(
480 ListBody::vertical()
481 .reverse()
482 .width(100.0)
483 .height(100.0)
484 .child(row_of(20.0))
485 .child(row_of(30.0)),
486 );
487 assert_eq!(boxes[0].y, 80.0, "the first child sits at the bottom");
488 assert_eq!(boxes[1].y, 50.0, "the second one above it");
489 }
490
491 /// Reversing twice is the direction it started with — the builder is a flip, not a
492 /// flag, so `ListBody::horizontal().reverse()` is a row from the end.
493 #[test]
494 fn reversing_a_row_twice_is_the_row_it_was() {
495 let there = Widget::<()>::style(&ListBody::horizontal::<()>().reverse()).flex_direction;
496 let back = Widget::<()>::style(&ListBody::horizontal::<()>().reverse().reverse());
497 assert_eq!(there, FlexDirection::RowReverse);
498 assert_eq!(back.flex_direction, FlexDirection::Row);
499 }
500}