Skip to main content

frus_widgets/
overflowbar.rs

1//! [`OverflowBar`]: a row of buttons that becomes a **column** when they stop fitting.
2//!
3//! A flex row does not wrap and its children do not shrink, so a row of buttons that runs
4//! out of room does not squash and does not fold: it keeps its natural width and runs
5//! straight past whatever is holding it. In a dialog that is not a rare case. It is what
6//! a reader who has turned their system font size up gets, every time: the buttons grow,
7//! the dialog does not, and "Delete permanently" leaves the screen.
8//!
9//! This is the widget for that. One line while the children fit on one line, a column
10//! when they do not — never a squashed row, and never a second line with one button
11//! stranded on it.
12//!
13//! ```ignore
14//! OverflowBar::new()
15//!     .spacing(8.0)
16//!     .alignment(Justify::End)
17//!     .overflow_alignment(Align::End)
18//!     .child(button("Cancel", Msg::Close))
19//!     .child(button("Delete permanently", Msg::Delete))
20//! ```
21//!
22//! ## All of them, or none
23//!
24//! It is not [`Wrap`](crate::Wrap), and the difference is the point. A wrap fills each
25//! line and moves what is left to the next, so three buttons become two and one — which
26//! for a row of chips is exactly right and for a row of buttons reads as a mistake, the
27//! last one hanging alone under the others. A bar folds **all** of them or none.
28//!
29//! ## How it knows
30//!
31//! The available width is not known when the tree is built, so the arrangement cannot be
32//! either: it is chosen during layout, from the box actually offered, against the natural
33//! width of the children measured under the theme in force — which is what makes the text
34//! scale reach it. Two consequences, both of them the price of building late:
35//!
36//! - **The children are held by shared pointer** ([`std::rc::Rc`]), because the subtree is
37//!   composed more than once per frame — once to measure, once to draw.
38//! - **A deferred overlay declared inside one does not open.** Clicks, hovers, focus and
39//!   the accessibility tree all work; a tooltip on a button in a bar does not, for the
40//!   reason [`LayoutBuilder`](crate::LayoutBuilder) documents. Put it around the bar
41//!   rather than inside it.
42
43use std::cell::{Cell, OnceCell};
44use std::rc::Rc;
45
46use frus_core::{Rect, Scene, Size};
47use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
48
49use crate::interaction::Status;
50use crate::rowcolumn::VerticalDirection;
51use crate::theme::Theme;
52use crate::widget::Widget;
53
54/// The arrangement, once the width it is laid out in is known — the same shape a
55/// [`GridView`](crate::GridView) that builds its cells late holds.
56type Composed<Msg> = Box<dyn Fn(Size) -> Box<dyn Widget<Msg>>>;
57
58/// A row of children that folds into a **column** when they do not fit on one line.
59///
60/// A flex row does not wrap and its children do not shrink, so a row of buttons that has
61/// run out of room keeps its natural width and is drawn straight past whatever is holding
62/// it — which is what a reader who has turned their system font size up gets, every time.
63/// A bar folds instead, and folds **all** of them rather than stranding the last one on a
64/// second line the way a [`Wrap`](crate::Wrap) would.
65///
66/// ```ignore
67/// OverflowBar::new()
68///     .spacing(8.0)
69///     .alignment(Justify::End)
70///     .overflow_alignment(Align::End)
71///     .child(button("Cancel", Msg::Close))
72///     .child(button("Delete permanently", Msg::Delete))
73/// ```
74///
75/// The arrangement is chosen **during layout**, from the box actually offered and the
76/// natural width of the children measured under the theme in force — which is what lets
77/// the text scale reach it. That has a price, the same one
78/// [`LayoutBuilder`](crate::LayoutBuilder) pays: the subtree is composed more than once a
79/// frame, so the children are held by shared pointer, and a **deferred overlay declared
80/// inside a bar does not open**. Clicks, hover, focus and the accessibility tree all
81/// work; a tooltip on a button in a bar does not, and belongs around the bar instead.
82///
83/// No child is ever wider than the bar: one that would be is capped, and ellipsises or
84/// wraps inside its own box rather than escaping it.
85pub struct OverflowBar<Msg = crate::callback::Callback> {
86    children: Vec<Rc<dyn Widget<Msg>>>,
87    spacing: f32,
88    overflow_spacing: f32,
89    alignment: Justify,
90    overflow_alignment: Align,
91    overflow_direction: VerticalDirection,
92    /// Composed on the first ask and kept, because the hook hands back a borrow.
93    composed: OnceCell<Composed<Msg>>,
94}
95
96impl<Msg: 'static> Default for OverflowBar<Msg> {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl<Msg: 'static> OverflowBar<Msg> {
103    /// An empty bar: children packed at the start of the line, nothing between them.
104    pub fn new() -> Self {
105        Self {
106            children: Vec::new(),
107            spacing: 0.0,
108            overflow_spacing: 0.0,
109            alignment: Justify::Start,
110            overflow_alignment: Align::Start,
111            overflow_direction: VerticalDirection::Down,
112            composed: OnceCell::new(),
113        }
114    }
115
116    /// Adds a child, after the ones already there.
117    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
118        self.children.push(Rc::new(child));
119        self
120    }
121
122    /// Adds an **already boxed** child — what a builder holding `Vec<Box<dyn Widget>>`
123    /// has to hand.
124    pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
125        self.children.push(Rc::from(child));
126        self
127    }
128
129    /// The space between the children **on one line**.
130    #[must_use]
131    pub fn spacing(mut self, spacing: f32) -> Self {
132        self.spacing = spacing.max(0.0);
133        self
134    }
135
136    /// The space between them **once they have stacked**.
137    ///
138    /// A separate number rather than [`spacing`](Self::spacing) reused, because the two
139    /// are not the same distance: buttons side by side sit closer than buttons above one
140    /// another, where the gap is what keeps a mis-tap from being the wrong answer.
141    #[must_use]
142    pub fn overflow_spacing(mut self, spacing: f32) -> Self {
143        self.overflow_spacing = spacing.max(0.0);
144        self
145    }
146
147    /// How the children are distributed **along the line** while they fit.
148    #[must_use]
149    pub fn alignment(mut self, alignment: Justify) -> Self {
150        self.alignment = alignment;
151        self
152    }
153
154    /// Which edge the **column** lines up on once they have stacked.
155    ///
156    /// [`Align::Stretch`] is not one of the answers — a stacked column of buttons each
157    /// stretched to the full width is a different design, and one a caller can build with
158    /// a [`Flex`](crate::Flex) — so it is read as [`Align::Start`].
159    #[must_use]
160    pub fn overflow_alignment(mut self, alignment: Align) -> Self {
161        self.overflow_alignment = match alignment {
162            Align::Stretch | Align::Baseline => Align::Start,
163            other => other,
164        };
165        self
166    }
167
168    /// Which way the column runs once they have stacked.
169    ///
170    /// It matters because the conventions disagree with each other: the confirming button
171    /// goes **last** on a line and **first** in a column, so a bar that folded without
172    /// being told would move the destructive answer under the reader's thumb.
173    /// [`VerticalDirection::Up`] keeps it where it was.
174    #[must_use]
175    pub fn overflow_direction(mut self, direction: VerticalDirection) -> Self {
176        self.overflow_direction = direction;
177        self
178    }
179}
180
181impl<Msg: 'static> Widget<Msg> for OverflowBar<Msg> {
182    /// No box of its own: it is as wide as it is offered and as tall as what it arranged.
183    fn style(&self) -> Style {
184        Style::default()
185    }
186
187    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
188        &[]
189    }
190
191    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
192
193    fn on_click(&self) -> Option<Msg> {
194        None
195    }
196
197    /// The arrangement is built from the box actually offered — see the module docs.
198    fn layout_builder(&self) -> Option<&dyn Fn(Size) -> Box<dyn Widget<Msg>>> {
199        if self.children.is_empty() {
200            return None;
201        }
202        let children = self.children.clone();
203        let (spacing, overflow_spacing) = (self.spacing, self.overflow_spacing);
204        let (alignment, overflow_alignment) = (self.alignment, self.overflow_alignment);
205        let overflow_direction = self.overflow_direction;
206        Some(&**self.composed.get_or_init(|| {
207            Box::new(move |size: Size| {
208                Box::new(Bar {
209                    children: children
210                        .iter()
211                        .map(|child| Box::new(Capped::new(child.clone())) as Box<dyn Widget<Msg>>)
212                        .collect(),
213                    offered: size.width,
214                    spacing,
215                    overflow_spacing,
216                    alignment,
217                    overflow_alignment,
218                    overflow_direction,
219                    stacked: Cell::new(None),
220                }) as Box<dyn Widget<Msg>>
221            })
222        }))
223    }
224
225    fn debug_name(&self) -> &'static str {
226        "OverflowBar"
227    }
228}
229
230/// A child of a bar, held by shared pointer and **never wider than the bar**.
231///
232/// The cap is the reference's rule and the second half of the fix: folding two buttons
233/// into a column does nothing for a single button that is wider than the surface on its
234/// own, which is what the longest label becomes at a large enough font size. A ceiling of
235/// a hundred per cent leaves anything that fits exactly as it was — a percentage of an
236/// indefinite width is no ceiling at all, so an intrinsic measurement still comes back
237/// with the child's own width — and gives the ones that do not something to wrap or
238/// ellipsise inside.
239struct Capped<Msg> {
240    inner: Rc<dyn Widget<Msg>>,
241}
242
243impl<Msg> Capped<Msg> {
244    fn new(inner: Rc<dyn Widget<Msg>>) -> Self {
245        Self { inner }
246    }
247
248    fn restyle(&self, base: Style) -> Style {
249        Style {
250            max_width: Dimension::Percent(1.0),
251            ..base
252        }
253    }
254}
255
256crate::transparent::forward_transparent!(Capped {
257    /// Forwarded, all of them: a ceiling on the width is not an identity, not a place,
258    /// not a theme and not a surface.
259    fn key(&self) -> Option<u64> {
260        self.inner.key()
261    }
262    fn positioned(&self) -> Option<crate::positioned::Positioning> {
263        self.inner.positioned()
264    }
265    fn theme_override(
266        &self,
267        inherited: &crate::theme::Theme,
268    ) -> Option<Box<crate::theme::Theme>> {
269        self.inner.theme_override(inherited)
270    }
271    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
272        self.inner.media_override(inherited)
273    }
274    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
275        self.inner.scaffold_override()
276    }
277    /// Forwarded: a wrapper around a form is still that form (milestone 512).
278    fn autofill_group(&self) -> bool {
279        self.inner.autofill_group()
280    }
281});
282
283/// One arrangement of a bar: the children, and the width they were offered.
284///
285/// It exists because the two halves of the answer arrive at different moments. The
286/// **width** comes from the layout, through the closure above; the **theme** comes from
287/// [`Widget::style_themed`], and there is no getting at it before then — which matters,
288/// because a reader's font size lives in it and changing the font size is the everyday
289/// way to make a row of buttons stop fitting.
290struct Bar<Msg> {
291    children: Vec<Box<dyn Widget<Msg>>>,
292    offered: f32,
293    spacing: f32,
294    overflow_spacing: f32,
295    alignment: Justify,
296    overflow_alignment: Align,
297    overflow_direction: VerticalDirection,
298    /// The answer, once someone has asked for it. `style_themed` is called more than once
299    /// on the same arrangement and the measurement is a layout pass per child.
300    stacked: Cell<Option<bool>>,
301}
302
303impl<Msg: 'static> Bar<Msg> {
304    /// Whether the children have to stack: their natural widths and the gaps between
305    /// them, against the width offered.
306    ///
307    /// **An offer of nothing is an intrinsic question** — *how wide would you like to
308    /// be?* — and the honest answer to that is the row, which is the width a parent
309    /// deciding how much to offer needs to hear. Answering "a column" would let a bar
310    /// talk a dialog into being narrow and then complain that it was.
311    fn stacked(&self, theme: &Theme) -> bool {
312        if self.offered <= 0.0 || self.children.len() < 2 {
313            return false;
314        }
315        if let Some(known) = self.stacked.get() {
316            return known;
317        }
318        let runtime = crate::runtime::Runtime::default();
319        let mut needed = self.spacing * (self.children.len() - 1) as f32;
320        for (index, child) in self.children.iter().enumerate() {
321            needed += crate::ui::natural_size(
322                child.as_ref(),
323                crate::interaction::WidgetId::ROOT.child(index),
324                &runtime,
325                theme,
326            )
327            .width;
328        }
329        // Half a pixel of slack: a row that fits to within a rounding error fits, and
330        // folding a dialog's buttons over one is a change nobody can see the reason for.
331        let stacked = needed > self.offered + 0.5;
332        self.stacked.set(Some(stacked));
333        stacked
334    }
335
336    fn arrangement(&self, stacked: bool) -> Style {
337        if stacked {
338            return Style {
339                flex_direction: match self.overflow_direction {
340                    VerticalDirection::Down => FlexDirection::Column,
341                    VerticalDirection::Up => FlexDirection::ColumnReverse,
342                },
343                align: self.overflow_alignment,
344                gap: self.overflow_spacing,
345                width: Dimension::Percent(1.0),
346                ..Default::default()
347            };
348        }
349        Style {
350            flex_direction: FlexDirection::Row,
351            justify: self.alignment,
352            gap: self.spacing,
353            ..Default::default()
354        }
355    }
356}
357
358impl<Msg: 'static> Widget<Msg> for Bar<Msg> {
359    /// Without a theme there is no measuring to be done — no font size, no button
360    /// padding — so this is the row, the same answer an intrinsic question gets.
361    fn style(&self) -> Style {
362        self.arrangement(false)
363    }
364
365    fn style_themed(&self, theme: &Theme) -> Style {
366        self.arrangement(self.stacked(theme))
367    }
368
369    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
370        &self.children
371    }
372
373    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
374
375    fn on_click(&self) -> Option<Msg> {
376        None
377    }
378
379    fn debug_name(&self) -> &'static str {
380        "OverflowBar"
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::{build_ui, Container, MediaQuery, Runtime};
388    use frus_core::{Color, Primitive};
389
390    type Msg = ();
391
392    /// A fixed box, so a bar's arithmetic is the test's arithmetic.
393    fn block(width: f32) -> Container<Msg> {
394        Container::new()
395            .width(width)
396            .height(20.0)
397            .color(Color::WHITE)
398    }
399
400    /// The boxes a bar drew, in paint order.
401    fn boxes(bar: OverflowBar<Msg>, width: f32) -> Vec<Rect> {
402        let ui = build_ui(
403            &crate::Flex::<Msg>::column().width(width).child(bar),
404            Size::new(width, 400.0),
405            &Runtime::default(),
406            &Theme::default(),
407        );
408        ui.scene()
409            .primitives()
410            .iter()
411            .filter_map(|p| match p {
412                Primitive::Rect { rect, color, .. } if color.a > 0.5 => Some(*rect),
413                _ => None,
414            })
415            .collect()
416    }
417
418    /// **While they fit, it is a row** — and nothing about it is different from the row
419    /// it replaces, which is what lets a dialog take it without moving.
420    #[test]
421    fn children_that_fit_stay_on_one_line() {
422        let bar = OverflowBar::new()
423            .spacing(8.0)
424            .child(block(60.0))
425            .child(block(60.0));
426        let drawn = boxes(bar, 300.0);
427        assert_eq!(drawn.len(), 2);
428        assert_eq!(drawn[0].y, drawn[1].y, "one line: {drawn:?}");
429        assert_eq!(drawn[1].x, drawn[0].x + 60.0 + 8.0, "spaced by `spacing`");
430    }
431
432    /// **When they do not, they stack** — all of them, rather than the last one alone on
433    /// a second line, which is what a wrap would have done.
434    #[test]
435    fn children_that_do_not_fit_stack() {
436        let bar = OverflowBar::new()
437            .spacing(8.0)
438            .overflow_spacing(4.0)
439            .child(block(120.0))
440            .child(block(120.0));
441        let drawn = boxes(bar, 200.0);
442        assert_eq!(drawn.len(), 2);
443        assert_eq!(drawn[0].x, drawn[1].x, "one column: {drawn:?}");
444        assert_eq!(
445            drawn[1].y,
446            drawn[0].y + 20.0 + 4.0,
447            "spaced by `overflow_spacing`, not by `spacing`"
448        );
449    }
450
451    /// **Nothing leaves the box.** The bug this widget exists for was not a row that
452    /// looked wrong; it was buttons drawn outside the surface holding them.
453    #[test]
454    fn nothing_is_drawn_outside_the_width_it_was_given() {
455        let bar = OverflowBar::new()
456            .spacing(8.0)
457            .child(block(120.0))
458            .child(block(150.0));
459        for rect in boxes(bar, 200.0) {
460            assert!(rect.x >= 0.0, "starts inside: {rect:?}");
461            assert!(rect.x + rect.width <= 200.0 + 0.5, "ends inside: {rect:?}");
462        }
463    }
464
465    /// **The stacked column lines up where it was told**, which is the whole of
466    /// `overflow_alignment`: a dialog wants its answers on the trailing edge, not
467    /// scattered under the question.
468    #[test]
469    fn a_stacked_column_takes_its_own_alignment() {
470        let ends = OverflowBar::new()
471            .overflow_alignment(Align::End)
472            .child(block(120.0))
473            .child(block(150.0));
474        let drawn = boxes(ends, 200.0);
475        assert_eq!(drawn[0].x + drawn[0].width, 200.0, "flush right: {drawn:?}");
476        assert_eq!(drawn[1].x + drawn[1].width, 200.0);
477    }
478
479    /// **`Up` puts the first child at the bottom**, which is how the confirming button
480    /// stays where a reader expects it: last on a line, first in a column.
481    #[test]
482    fn the_column_can_run_the_other_way() {
483        let bar = |direction| {
484            OverflowBar::new()
485                .overflow_direction(direction)
486                .child(block(120.0))
487                .child(block(150.0))
488        };
489        let down = boxes(bar(VerticalDirection::Down), 200.0);
490        let up = boxes(bar(VerticalDirection::Up), 200.0);
491        // The wide one is second in both; it is at the bottom in one and the top in the
492        // other. Paint order is the order the children were given, either way.
493        assert!(down[1].y > down[0].y, "first at the top: {down:?}");
494        assert!(up[1].y < up[0].y, "first at the bottom: {up:?}");
495    }
496
497    /// **A reader's font size is what makes a row stop fitting**, so it is the case the
498    /// widget has to answer: the same two buttons, the same box, and only the ambient
499    /// text scale different.
500    #[test]
501    fn a_larger_text_scale_is_what_folds_it() {
502        let bar = || {
503            OverflowBar::<Msg>::new()
504                .spacing(8.0)
505                .child(crate::dsl::button("Cancel", ()))
506                .child(crate::dsl::button("Delete permanently", ()))
507        };
508        let ordinary = MediaQuery::new(Size::new(340.0, 400.0)).scope(|| boxes(bar(), 340.0));
509        assert_eq!(
510            ordinary[0].y, ordinary[1].y,
511            "at the ordinary size they fit: {ordinary:?}"
512        );
513        let large = MediaQuery::new(Size::new(340.0, 400.0))
514            .with_text_scaler(2.0)
515            .scope(|| boxes(bar(), 340.0));
516        assert_eq!(large[0].x, large[1].x, "turned up, they stack: {large:?}");
517    }
518
519    /// **Asked how wide it would like to be, a bar answers as a row.** A parent that
520    /// sizes itself to its content — a dialog — has to hear the width the children
521    /// actually want, or it would offer the narrow box it was told about and then be told
522    /// the children do not fit in it.
523    #[test]
524    fn its_natural_width_is_the_row_s() {
525        let bar = OverflowBar::new()
526            .spacing(8.0)
527            .child(block(120.0))
528            .child(block(150.0));
529        // A column that hugs its content, in a window far wider than the row.
530        let ui = build_ui(
531            &crate::Flex::<Msg>::column().child(bar),
532            Size::new(1000.0, 400.0),
533            &Runtime::default(),
534            &Theme::default(),
535        );
536        let drawn: Vec<Rect> = ui
537            .scene()
538            .primitives()
539            .iter()
540            .filter_map(|p| match p {
541                Primitive::Rect { rect, color, .. } if color.a > 0.5 => Some(*rect),
542                _ => None,
543            })
544            .collect();
545        assert_eq!(drawn[0].y, drawn[1].y, "one line: {drawn:?}");
546    }
547
548    /// An empty bar is nothing at all, and one child never has anything to fold.
549    #[test]
550    fn an_empty_bar_builds_nothing() {
551        let empty = OverflowBar::<Msg>::new();
552        assert!(Widget::<Msg>::layout_builder(&empty).is_none());
553        let one = OverflowBar::<Msg>::new().child(block(400.0));
554        let drawn = boxes(one, 200.0);
555        assert_eq!(drawn.len(), 1, "still drawn: {drawn:?}");
556    }
557}