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}