Skip to main content

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}