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: 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}