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    fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
278        self.inner.scroll_behavior_override()
279    }
280    /// Forwarded: a wrapper around a form is still that form (milestone 512).
281    fn autofill_group(&self) -> bool {
282        self.inner.autofill_group()
283    }
284    /// Forwarded: the area's bar is the area's, whatever wraps it.
285    fn area_toolbar(
286        &self,
287        context: crate::ToolbarContext,
288    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
289        self.inner.area_toolbar(context)
290    }
291});
292
293/// One arrangement of a bar: the children, and the width they were offered.
294///
295/// It exists because the two halves of the answer arrive at different moments. The
296/// **width** comes from the layout, through the closure above; the **theme** comes from
297/// [`Widget::style_themed`], and there is no getting at it before then — which matters,
298/// because a reader's font size lives in it and changing the font size is the everyday
299/// way to make a row of buttons stop fitting.
300struct Bar<Msg> {
301    children: Vec<Box<dyn Widget<Msg>>>,
302    offered: f32,
303    spacing: f32,
304    overflow_spacing: f32,
305    alignment: Justify,
306    overflow_alignment: Align,
307    overflow_direction: VerticalDirection,
308    /// The answer, once someone has asked for it. `style_themed` is called more than once
309    /// on the same arrangement and the measurement is a layout pass per child.
310    stacked: Cell<Option<bool>>,
311}
312
313impl<Msg: 'static> Bar<Msg> {
314    /// Whether the children have to stack: their natural widths and the gaps between
315    /// them, against the width offered.
316    ///
317    /// **An offer of nothing is an intrinsic question** — *how wide would you like to
318    /// be?* — and the honest answer to that is the row, which is the width a parent
319    /// deciding how much to offer needs to hear. Answering "a column" would let a bar
320    /// talk a dialog into being narrow and then complain that it was.
321    fn stacked(&self, theme: &Theme) -> bool {
322        if self.offered <= 0.0 || self.children.len() < 2 {
323            return false;
324        }
325        if let Some(known) = self.stacked.get() {
326            return known;
327        }
328        let runtime = crate::runtime::Runtime::default();
329        let mut needed = self.spacing * (self.children.len() - 1) as f32;
330        for (index, child) in self.children.iter().enumerate() {
331            needed += crate::ui::natural_size(
332                child.as_ref(),
333                crate::interaction::WidgetId::ROOT.child(index),
334                &runtime,
335                theme,
336            )
337            .width;
338        }
339        // Half a pixel of slack: a row that fits to within a rounding error fits, and
340        // folding a dialog's buttons over one is a change nobody can see the reason for.
341        let stacked = needed > self.offered + 0.5;
342        self.stacked.set(Some(stacked));
343        stacked
344    }
345
346    fn arrangement(&self, stacked: bool) -> Style {
347        if stacked {
348            return Style {
349                flex_direction: match self.overflow_direction {
350                    VerticalDirection::Down => FlexDirection::Column,
351                    VerticalDirection::Up => FlexDirection::ColumnReverse,
352                },
353                align: self.overflow_alignment,
354                gap: self.overflow_spacing,
355                width: Dimension::Percent(1.0),
356                ..Default::default()
357            };
358        }
359        Style {
360            flex_direction: FlexDirection::Row,
361            justify: self.alignment,
362            gap: self.spacing,
363            ..Default::default()
364        }
365    }
366}
367
368impl<Msg: 'static> Widget<Msg> for Bar<Msg> {
369    /// Without a theme there is no measuring to be done — no font size, no button
370    /// padding — so this is the row, the same answer an intrinsic question gets.
371    fn style(&self) -> Style {
372        self.arrangement(false)
373    }
374
375    fn style_themed(&self, theme: &Theme) -> Style {
376        self.arrangement(self.stacked(theme))
377    }
378
379    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
380        &self.children
381    }
382
383    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
384
385    fn on_click(&self) -> Option<Msg> {
386        None
387    }
388
389    fn debug_name(&self) -> &'static str {
390        "OverflowBar"
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::{build_ui, Container, MediaQuery, Runtime};
398    use frus_core::{Color, Primitive};
399
400    type Msg = ();
401
402    /// A fixed box, so a bar's arithmetic is the test's arithmetic.
403    fn block(width: f32) -> Container<Msg> {
404        Container::new()
405            .width(width)
406            .height(20.0)
407            .color(Color::WHITE)
408    }
409
410    /// The boxes a bar drew, in paint order.
411    fn boxes(bar: OverflowBar<Msg>, width: f32) -> Vec<Rect> {
412        let ui = build_ui(
413            &crate::Flex::<Msg>::column().width(width).child(bar),
414            Size::new(width, 400.0),
415            &Runtime::default(),
416            &Theme::default(),
417        );
418        ui.scene()
419            .primitives()
420            .iter()
421            .filter_map(|p| match p {
422                Primitive::Rect { rect, color, .. } if color.a > 0.5 => Some(*rect),
423                _ => None,
424            })
425            .collect()
426    }
427
428    /// **While they fit, it is a row** — and nothing about it is different from the row
429    /// it replaces, which is what lets a dialog take it without moving.
430    #[test]
431    fn children_that_fit_stay_on_one_line() {
432        let bar = OverflowBar::new()
433            .spacing(8.0)
434            .child(block(60.0))
435            .child(block(60.0));
436        let drawn = boxes(bar, 300.0);
437        assert_eq!(drawn.len(), 2);
438        assert_eq!(drawn[0].y, drawn[1].y, "one line: {drawn:?}");
439        assert_eq!(drawn[1].x, drawn[0].x + 60.0 + 8.0, "spaced by `spacing`");
440    }
441
442    /// **When they do not, they stack** — all of them, rather than the last one alone on
443    /// a second line, which is what a wrap would have done.
444    #[test]
445    fn children_that_do_not_fit_stack() {
446        let bar = OverflowBar::new()
447            .spacing(8.0)
448            .overflow_spacing(4.0)
449            .child(block(120.0))
450            .child(block(120.0));
451        let drawn = boxes(bar, 200.0);
452        assert_eq!(drawn.len(), 2);
453        assert_eq!(drawn[0].x, drawn[1].x, "one column: {drawn:?}");
454        assert_eq!(
455            drawn[1].y,
456            drawn[0].y + 20.0 + 4.0,
457            "spaced by `overflow_spacing`, not by `spacing`"
458        );
459    }
460
461    /// **Nothing leaves the box.** The bug this widget exists for was not a row that
462    /// looked wrong; it was buttons drawn outside the surface holding them.
463    #[test]
464    fn nothing_is_drawn_outside_the_width_it_was_given() {
465        let bar = OverflowBar::new()
466            .spacing(8.0)
467            .child(block(120.0))
468            .child(block(150.0));
469        for rect in boxes(bar, 200.0) {
470            assert!(rect.x >= 0.0, "starts inside: {rect:?}");
471            assert!(rect.x + rect.width <= 200.0 + 0.5, "ends inside: {rect:?}");
472        }
473    }
474
475    /// **The stacked column lines up where it was told**, which is the whole of
476    /// `overflow_alignment`: a dialog wants its answers on the trailing edge, not
477    /// scattered under the question.
478    #[test]
479    fn a_stacked_column_takes_its_own_alignment() {
480        let ends = OverflowBar::new()
481            .overflow_alignment(Align::End)
482            .child(block(120.0))
483            .child(block(150.0));
484        let drawn = boxes(ends, 200.0);
485        assert_eq!(drawn[0].x + drawn[0].width, 200.0, "flush right: {drawn:?}");
486        assert_eq!(drawn[1].x + drawn[1].width, 200.0);
487    }
488
489    /// **`Up` puts the first child at the bottom**, which is how the confirming button
490    /// stays where a reader expects it: last on a line, first in a column.
491    #[test]
492    fn the_column_can_run_the_other_way() {
493        let bar = |direction| {
494            OverflowBar::new()
495                .overflow_direction(direction)
496                .child(block(120.0))
497                .child(block(150.0))
498        };
499        let down = boxes(bar(VerticalDirection::Down), 200.0);
500        let up = boxes(bar(VerticalDirection::Up), 200.0);
501        // The wide one is second in both; it is at the bottom in one and the top in the
502        // other. Paint order is the order the children were given, either way.
503        assert!(down[1].y > down[0].y, "first at the top: {down:?}");
504        assert!(up[1].y < up[0].y, "first at the bottom: {up:?}");
505    }
506
507    /// **A reader's font size is what makes a row stop fitting**, so it is the case the
508    /// widget has to answer: the same two buttons, the same box, and only the ambient
509    /// text scale different.
510    #[test]
511    fn a_larger_text_scale_is_what_folds_it() {
512        let bar = || {
513            OverflowBar::<Msg>::new()
514                .spacing(8.0)
515                .child(crate::dsl::button("Cancel", ()))
516                .child(crate::dsl::button("Delete permanently", ()))
517        };
518        let ordinary = MediaQuery::new(Size::new(340.0, 400.0)).scope(|| boxes(bar(), 340.0));
519        assert_eq!(
520            ordinary[0].y, ordinary[1].y,
521            "at the ordinary size they fit: {ordinary:?}"
522        );
523        let large = MediaQuery::new(Size::new(340.0, 400.0))
524            .with_text_scaler(2.0)
525            .scope(|| boxes(bar(), 340.0));
526        assert_eq!(large[0].x, large[1].x, "turned up, they stack: {large:?}");
527    }
528
529    /// **Asked how wide it would like to be, a bar answers as a row.** A parent that
530    /// sizes itself to its content — a dialog — has to hear the width the children
531    /// actually want, or it would offer the narrow box it was told about and then be told
532    /// the children do not fit in it.
533    #[test]
534    fn its_natural_width_is_the_row_s() {
535        let bar = OverflowBar::new()
536            .spacing(8.0)
537            .child(block(120.0))
538            .child(block(150.0));
539        // A column that hugs its content, in a window far wider than the row.
540        let ui = build_ui(
541            &crate::Flex::<Msg>::column().child(bar),
542            Size::new(1000.0, 400.0),
543            &Runtime::default(),
544            &Theme::default(),
545        );
546        let drawn: Vec<Rect> = ui
547            .scene()
548            .primitives()
549            .iter()
550            .filter_map(|p| match p {
551                Primitive::Rect { rect, color, .. } if color.a > 0.5 => Some(*rect),
552                _ => None,
553            })
554            .collect();
555        assert_eq!(drawn[0].y, drawn[1].y, "one line: {drawn:?}");
556    }
557
558    /// An empty bar is nothing at all, and one child never has anything to fold.
559    #[test]
560    fn an_empty_bar_builds_nothing() {
561        let empty = OverflowBar::<Msg>::new();
562        assert!(Widget::<Msg>::layout_builder(&empty).is_none());
563        let one = OverflowBar::<Msg>::new().child(block(400.0));
564        let drawn = boxes(one, 200.0);
565        assert_eq!(drawn.len(), 1, "still drawn: {drawn:?}");
566    }
567}