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}