Skip to main content

frus_widgets/
rowcolumn.rs

1//! [`Row`] and [`Column`]: the two containers almost every screen is built out of.
2//!
3//! [`crate::Flex`] has laid children out in a line since the beginning, and it is a
4//! flexbox container: it shrink-wraps its line, it stretches its children across it, and
5//! its `start` is the left whatever the reading direction. Those are flexbox's answers,
6//! and they are not the reference's. A row there **fills the line it is given**, aligns
7//! its children on their **centres**, and reads `start` as the start *of the reading
8//! direction*.
9//!
10//! Those three differences are not decoration. A row of buttons that shrink-wraps
11//! silently ignores a `SpaceBetween` — there is no space to put between anything. A row
12//! that stretches its children makes every icon beside a two-line label as tall as the
13//! label. And a `start` that means "left" puts the Arabic label on the wrong side.
14//!
15//! So `Row` and `Column` are not aliases for `Flex::row()` and `Flex::column()`. They
16//! are the same machinery with the reference's defaults and the reference's two ordering
17//! knobs — which way the main axis runs, and which way the cross axis runs — and those
18//! are what the rest of this module is about.
19
20use frus_core::{Rect, Scene, TextDirection};
21use frus_layout::{Align, FlexDirection, Justify, Style};
22
23use crate::interaction::Status;
24use crate::theme::Theme;
25use crate::widget::Widget;
26
27/// How much room a [`Row`] or [`Column`] takes **along its own main axis**.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
29pub enum MainAxisSize {
30    /// All of it. A row fills the width it is offered, a column the height — which is
31    /// what makes `SpaceBetween`, `SpaceAround` and `End` mean anything at all, and it
32    /// is the default here as it is in the reference.
33    #[default]
34    Max,
35    /// Only as much as the children need: the line shrink-wraps. This is what a row
36    /// inside a button or a chip wants, where the box is measured *from* its contents.
37    Min,
38}
39
40/// Which way the **vertical** axis runs: a column's main axis, a row's cross axis.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
42pub enum VerticalDirection {
43    /// Top to bottom — the ordinary reading order, and the default.
44    #[default]
45    Down,
46    /// Bottom to top. A column laid out this way puts its first child at the bottom and
47    /// grows upwards, which is the shape of a chat transcript or a stack of toasts.
48    Up,
49}
50
51/// A **horizontal** run of children.
52///
53/// ```ignore
54/// Row::new()
55///     .main_axis_alignment(Justify::SpaceBetween)
56///     .cross_axis_alignment(Align::Center)
57///     .spacing(8.0)
58///     .child(Text::new("Total"))
59///     .child(Text::new("42.00"))
60/// ```
61///
62/// The defaults are the reference's, and they differ from [`crate::Flex`]'s on three
63/// counts: the row **fills** the width it is offered ([`MainAxisSize::Max`]), it aligns
64/// its children on their **centres** rather than stretching them, and its main axis runs
65/// with the **reading direction**.
66///
67/// To give one child the space the others do not need, wrap it in [`crate::Expanded`];
68/// to let it take that space without being forced to fill it,
69/// [`Expanded::loose`](crate::Expanded::loose). To put the children on one **text
70/// baseline** rather than on their centres, ask for [`Align::Baseline`].
71pub struct Row<Msg = crate::callback::Callback> {
72    common: Common<Msg>,
73}
74
75/// A **vertical** run of children.
76///
77/// ```ignore
78/// Column::new()
79///     .cross_axis_alignment(Align::Start)
80///     .main_axis_size(MainAxisSize::Min)
81///     .spacing(4.0)
82///     .child(Text::new("Title"))
83///     .child(Text::new("Subtitle"))
84/// ```
85///
86/// Everything [`Row`] says applies, with the axes swapped: the column fills the height
87/// it is offered, centres its children horizontally, and runs top to bottom.
88///
89/// A column does not scroll. More children than fit is an overflow, reported as one; if
90/// the content can outgrow the screen, that is what [`crate::ListView`] is for.
91pub struct Column<Msg = crate::callback::Callback> {
92    common: Common<Msg>,
93}
94
95/// Everything the two share. Splitting it out is what lets the two types differ only in
96/// **which axis is which**, which is the only thing that actually differs.
97struct Common<Msg> {
98    main: Justify,
99    cross: Align,
100    size: MainAxisSize,
101    spacing: f32,
102    text_direction: Option<TextDirection>,
103    vertical_direction: VerticalDirection,
104    children: Vec<Box<dyn Widget<Msg>>>,
105}
106
107impl<Msg> Default for Common<Msg> {
108    fn default() -> Self {
109        Self {
110            main: Justify::Start,
111            // The reference's default, and not flexbox's: stretching is the wrong answer
112            // for a line of mixed content, where it makes an icon as tall as the
113            // paragraph beside it.
114            cross: Align::Center,
115            size: MainAxisSize::Max,
116            spacing: 0.0,
117            text_direction: None,
118            vertical_direction: VerticalDirection::Down,
119            children: Vec::new(),
120        }
121    }
122}
123
124/// Writes the settings both types share, given which axis each one governs.
125macro_rules! shared_api {
126    ($t:ident) => {
127        impl<Msg> $t<Msg> {
128            /// A run with the reference's defaults and no children yet.
129            pub fn new() -> Self {
130                Self {
131                    common: Common::default(),
132                }
133            }
134
135            /// How the children are distributed **along** the main axis.
136            pub fn main_axis_alignment(mut self, alignment: Justify) -> Self {
137                self.common.main = alignment;
138                self
139            }
140
141            /// How the children are aligned **across** the main axis.
142            pub fn cross_axis_alignment(mut self, alignment: Align) -> Self {
143                self.common.cross = alignment;
144                self
145            }
146
147            /// Whether the run fills its main axis or shrink-wraps its children.
148            pub fn main_axis_size(mut self, size: MainAxisSize) -> Self {
149                self.common.size = size;
150                self
151            }
152
153            /// Shrink-wraps the main axis: [`MainAxisSize::Min`], named.
154            pub fn shrink_wrap(self) -> Self {
155                self.main_axis_size(MainAxisSize::Min)
156            }
157
158            /// Space between adjacent children, in logical pixels. It is not added
159            /// before the first or after the last.
160            pub fn spacing(mut self, spacing: f32) -> Self {
161                self.common.spacing = spacing;
162                self
163            }
164
165            /// The **reading direction** this run should use, overriding the ambient one.
166            ///
167            /// For a [`Row`] it decides which end the first child goes to; for a
168            /// [`Column`] it decides which side `Align::Start` means. Left unset — the
169            /// usual case — the run follows the theme, and an application flips as a
170            /// whole rather than a container at a time.
171            pub fn text_direction(mut self, direction: TextDirection) -> Self {
172                self.common.text_direction = Some(direction);
173                self
174            }
175
176            /// Which way the **vertical** axis runs: a [`Column`]'s order, a [`Row`]'s
177            /// idea of which edge `Align::Start` means.
178            pub fn vertical_direction(mut self, direction: VerticalDirection) -> Self {
179                self.common.vertical_direction = direction;
180                self
181            }
182
183            /// Adds a child.
184            pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
185                self.common.children.push(Box::new(child));
186                self
187            }
188
189            /// Adds an **already boxed** child, for children built in a loop.
190            pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
191                self.common.children.push(child);
192                self
193            }
194
195            /// Adds every child of an iterator.
196            pub fn children(
197                mut self,
198                children: impl IntoIterator<Item = Box<dyn Widget<Msg>>>,
199            ) -> Self {
200                self.common.children.extend(children);
201                self
202            }
203        }
204
205        impl<Msg> Default for $t<Msg> {
206            fn default() -> Self {
207                Self::new()
208            }
209        }
210    };
211}
212
213shared_api!(Row);
214shared_api!(Column);
215
216impl<Msg> Common<Msg> {
217    /// Whether this run reads **against** the ambient direction, which is the only thing
218    /// a per-container reading direction can mean here.
219    ///
220    /// The frame is mirrored as a whole for a right-to-left theme, so a run that agrees
221    /// with the ambient direction needs no reversal of its own — and one that disagrees
222    /// needs exactly one, whichever direction the two of them happen to be.
223    fn reads_backwards(&self, theme: &Theme) -> bool {
224        matches!(self.text_direction, Some(d) if d != theme.direction)
225    }
226
227    /// The cross alignment, with `Start` and `End` swapped when the cross axis runs
228    /// backwards. Everything else is symmetric and unaffected.
229    fn cross_flipped(&self, flipped: bool) -> Align {
230        match (self.cross, flipped) {
231            (Align::Start, true) => Align::End,
232            (Align::End, true) => Align::Start,
233            (align, _) => align,
234        }
235    }
236
237    fn style(&self, direction: FlexDirection, cross_flipped: bool) -> Style {
238        Style {
239            flex_direction: direction,
240            justify: self.main,
241            align: self.cross_flipped(cross_flipped),
242            gap: self.spacing,
243            ..Default::default()
244        }
245    }
246
247    /// The axis to fill, or `None` when the run shrink-wraps. The direction returned is
248    /// the **unreversed** one: filling is about which axis, not which way along it.
249    fn fill(&self, axis: FlexDirection) -> crate::widget::FillAxes {
250        match self.size {
251            MainAxisSize::Max => crate::widget::FillAxes::along(axis),
252            MainAxisSize::Min => crate::widget::FillAxes::NONE,
253        }
254    }
255}
256
257impl<Msg: Clone> Widget<Msg> for Row<Msg> {
258    fn style(&self) -> Style {
259        self.common.style(FlexDirection::Row, false)
260    }
261
262    fn style_themed(&self, theme: &Theme) -> Style {
263        let direction = if self.common.reads_backwards(theme) {
264            FlexDirection::RowReverse
265        } else {
266            FlexDirection::Row
267        };
268        // A row's cross axis is the vertical one, so it is `vertical_direction` that
269        // decides whether `Start` means the top or the bottom.
270        let flipped = self.common.vertical_direction == VerticalDirection::Up;
271        self.common.style(direction, flipped)
272    }
273
274    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
275        &self.common.children
276    }
277
278    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
279        self.common.fill(FlexDirection::Row)
280    }
281
282    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
283        // A run of children paints nothing of its own; wrap it if it needs a background.
284    }
285
286    fn on_click(&self) -> Option<Msg> {
287        None
288    }
289
290    fn debug_name(&self) -> &'static str {
291        "Row"
292    }
293}
294
295impl<Msg: Clone> Widget<Msg> for Column<Msg> {
296    fn style(&self) -> Style {
297        self.common.style(FlexDirection::Column, false)
298    }
299
300    fn style_themed(&self, theme: &Theme) -> Style {
301        let direction = if self.common.vertical_direction == VerticalDirection::Up {
302            FlexDirection::ColumnReverse
303        } else {
304            FlexDirection::Column
305        };
306        // A column's cross axis is the horizontal one, so it is the reading direction
307        // that decides whether `Start` means the left or the right.
308        let flipped = self.common.reads_backwards(theme);
309        self.common.style(direction, flipped)
310    }
311
312    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
313        &self.common.children
314    }
315
316    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
317        self.common.fill(FlexDirection::Column)
318    }
319
320    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
321
322    fn on_click(&self) -> Option<Msg> {
323        None
324    }
325
326    fn debug_name(&self) -> &'static str {
327        "Column"
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::{Container, Expanded};
335    use frus_core::{Color, Primitive, Size};
336
337    /// The painted rectangles, in walk order — the only honest way to ask where a
338    /// layout put things.
339    fn boxes(root: &impl Widget<()>, size: Size, theme: &Theme) -> Vec<Rect> {
340        let rt = crate::runtime::Runtime::default();
341        let ui = crate::ui::build_ui(root, size, &rt, theme);
342        ui.scene()
343            .primitives()
344            .iter()
345            .filter_map(|p| match p {
346                Primitive::Rect { rect, .. } => Some(*rect),
347                _ => None,
348            })
349            .collect()
350    }
351
352    fn tile(w: f32, h: f32) -> Container<()> {
353        Container::new().width(w).height(h).color(Color::WHITE)
354    }
355
356    /// The difference that started it: a row **fills its line**, so a distribution has
357    /// something to distribute. `Flex::row()` shrink-wraps and `SpaceBetween` does
358    /// nothing at all.
359    #[test]
360    fn a_row_fills_the_line_it_is_given() {
361        let theme = Theme::dark();
362        let root = Container::new().width(300.0).height(100.0).child(
363            Row::<()>::new()
364                .main_axis_alignment(Justify::SpaceBetween)
365                .child(tile(20.0, 20.0))
366                .child(tile(20.0, 20.0)),
367        );
368        let rects = boxes(&root, Size::new(400.0, 200.0), &theme);
369        // The outer box paints nothing of its own, so the two tiles are all there is.
370        let (first, last) = (rects[0], rects[1]);
371        assert!(first.x < 1.0, "at the left edge: {}", first.x);
372        assert!(
373            (last.x + last.width - 300.0).abs() < 1.0,
374            "and at the right: {}",
375            last.x + last.width
376        );
377    }
378
379    /// And asked to shrink-wrap, it does: the two tiles end up side by side with
380    /// nothing between them, however wide the box around them is.
381    #[test]
382    fn shrink_wrapped_it_does_not() {
383        let theme = Theme::dark();
384        let root = Container::new().width(300.0).height(100.0).child(
385            Row::<()>::new()
386                .shrink_wrap()
387                .main_axis_alignment(Justify::SpaceBetween)
388                .child(tile(20.0, 20.0))
389                .child(tile(20.0, 20.0)),
390        );
391        let rects = boxes(&root, Size::new(400.0, 200.0), &theme);
392        assert!((rects[1].x - 20.0).abs() < 1.0, "abutting: {}", rects[1].x);
393    }
394
395    /// The second difference: children are **centred** across the line rather than
396    /// stretched to fill it, so a short thing beside a tall one stays short.
397    #[test]
398    fn children_are_centred_across_the_line_not_stretched() {
399        let theme = Theme::dark();
400        let root = Row::<()>::new()
401            .child(tile(20.0, 60.0))
402            .child(tile(20.0, 20.0));
403        let rects = boxes(&root, Size::new(300.0, 100.0), &theme);
404        assert!((rects[1].height - 20.0).abs() < 0.5, "still short");
405        // Centred inside the 60 the tall one sets: (60 - 20) / 2.
406        assert!((rects[1].y - 20.0).abs() < 1.0, "centred: {}", rects[1].y);
407    }
408
409    /// The third: `Justify::Start` is the start **of the reading direction**. Setting
410    /// the row's own direction against the ambient one reverses it.
411    #[test]
412    fn a_reading_direction_of_its_own_reverses_the_row() {
413        let theme = Theme::dark();
414        let root = Row::<()>::new()
415            .text_direction(TextDirection::Rtl)
416            .child(tile(20.0, 20.0))
417            .child(tile(40.0, 20.0));
418        let rects = boxes(&root, Size::new(300.0, 100.0), &theme);
419        // The first child is now at the right-hand end.
420        assert!(
421            (rects[0].x + rects[0].width - 300.0).abs() < 1.0,
422            "first child at the right: {}",
423            rects[0].x
424        );
425        assert!(
426            (rects[1].x + rects[1].width - 280.0).abs() < 1.0,
427            "and the second beside it: {}",
428            rects[1].x
429        );
430    }
431
432    /// Agreeing with the ambient direction is not a reversal: the frame is mirrored as
433    /// a whole, and a second reversal would undo it.
434    #[test]
435    fn agreeing_with_the_theme_changes_nothing() {
436        let plain = Row::<()>::new()
437            .child(tile(20.0, 20.0))
438            .child(tile(40.0, 20.0));
439        let spelled = Row::<()>::new()
440            .text_direction(TextDirection::Ltr)
441            .child(tile(20.0, 20.0))
442            .child(tile(40.0, 20.0));
443        let theme = Theme::dark();
444        let size = Size::new(300.0, 100.0);
445        assert_eq!(boxes(&plain, size, &theme), boxes(&spelled, size, &theme));
446    }
447
448    /// A column that runs upwards puts its first child at the bottom.
449    #[test]
450    fn a_column_can_grow_upwards() {
451        let theme = Theme::dark();
452        let root = Column::<()>::new()
453            .vertical_direction(VerticalDirection::Up)
454            .child(tile(20.0, 20.0))
455            .child(tile(20.0, 40.0));
456        let rects = boxes(&root, Size::new(100.0, 300.0), &theme);
457        assert!(
458            (rects[0].y + rects[0].height - 300.0).abs() < 1.0,
459            "first child at the bottom: {}",
460            rects[0].y
461        );
462    }
463
464    /// `Align::Start` on a row follows the vertical direction, which is what makes it
465    /// mean *the start of the cross axis* and not *the top*.
466    #[test]
467    fn an_upward_row_starts_at_the_bottom() {
468        let theme = Theme::dark();
469        let row = |direction: VerticalDirection| {
470            Row::<()>::new()
471                .cross_axis_alignment(Align::Start)
472                .vertical_direction(direction)
473                .child(tile(20.0, 60.0))
474                .child(tile(20.0, 20.0))
475        };
476        let down = boxes(
477            &row(VerticalDirection::Down),
478            Size::new(300.0, 100.0),
479            &theme,
480        );
481        let up = boxes(&row(VerticalDirection::Up), Size::new(300.0, 100.0), &theme);
482        assert!((down[1].y - 0.0).abs() < 1.0, "top: {}", down[1].y);
483        assert!((up[1].y - 40.0).abs() < 1.0, "bottom: {}", up[1].y);
484    }
485
486    /// Filling is a question about the **parent**, not about the row: down a column the
487    /// row stretches across, and the width it ends up with is the column's.
488    #[test]
489    fn a_row_in_a_column_fills_the_column() {
490        let theme = Theme::dark();
491        let root = Container::new().width(300.0).height(200.0).child(
492            Column::<()>::new().child(
493                Row::<()>::new()
494                    .main_axis_alignment(Justify::End)
495                    .child(tile(20.0, 20.0)),
496            ),
497        );
498        let rects = boxes(&root, Size::new(400.0, 300.0), &theme);
499        assert!(
500            (rects[0].x + rects[0].width - 300.0).abs() < 1.0,
501            "pushed to the right edge of the column: {}",
502            rects[0].x
503        );
504    }
505
506    /// `Expanded` still works inside one, which is the other half of how a line is
507    /// divided up.
508    #[test]
509    fn expanded_takes_what_is_left() {
510        let theme = Theme::dark();
511        let root = Container::new().width(300.0).height(100.0).child(
512            Row::<()>::new()
513                .child(tile(100.0, 20.0))
514                .child(Expanded::new(tile(10.0, 20.0))),
515        );
516        let rects = boxes(&root, Size::new(400.0, 200.0), &theme);
517        assert!(
518            (rects[1].width - 200.0).abs() < 1.0,
519            "the rest of the line: {}",
520            rects[1].width
521        );
522    }
523
524    /// A column of columns is the ordinary case, and the fill has to reach through: the
525    /// inner one is stretched by the outer, not shrink-wrapped inside it.
526    #[test]
527    fn spacing_goes_between_children_and_not_around_them() {
528        let theme = Theme::dark();
529        let root = Column::<()>::new()
530            .spacing(10.0)
531            .child(tile(20.0, 20.0))
532            .child(tile(20.0, 20.0));
533        let rects = boxes(&root, Size::new(100.0, 300.0), &theme);
534        assert!((rects[0].y - 0.0).abs() < 0.5, "no leading gap");
535        assert!((rects[1].y - 30.0).abs() < 0.5, "one gap: {}", rects[1].y);
536    }
537
538    /// `SpaceEvenly` is the distribution `SpaceAround` is mistaken for: the gap at each
539    /// end is the same as the gaps between.
540    #[test]
541    fn space_evenly_makes_every_gap_the_same() {
542        let theme = Theme::dark();
543        let root = Container::new().width(300.0).height(100.0).child(
544            Row::<()>::new()
545                .main_axis_alignment(Justify::SpaceEvenly)
546                .child(tile(20.0, 20.0))
547                .child(tile(20.0, 20.0)),
548        );
549        let rects = boxes(&root, Size::new(400.0, 200.0), &theme);
550        // Two 20px tiles in 300: three gaps of (300 - 40) / 3.
551        let gap = 260.0 / 3.0;
552        assert!((rects[0].x - gap).abs() < 1.0, "first gap: {}", rects[0].x);
553        assert!(
554            (rects[1].x - (2.0 * gap + 20.0)).abs() < 1.0,
555            "second gap: {}",
556            rects[1].x
557        );
558    }
559}