Skip to main content

frus_widgets/
stack.rs

1//! [`Stack`]: overlays its children in the **same box**, z-layering them. The last layer
2//! sits on top. It underpins the internal overlays: a scrim over content, a badge in a
3//! corner, a floating button.
4//!
5//! Two things decide where a layer lands:
6//!
7//! - a layer wrapped in [`crate::Positioned`] is pinned against the stack's own edges,
8//!   and what is pinned decides its size as well as its place;
9//! - every other layer is sized by the stack's [`StackFit`] and placed by its
10//!   [`Stack::alignment`].
11//!
12//! **The default fit is [`StackFit::Expand`], and the reference's is loose.** That is a
13//! deliberate difference, not an oversight. In the reference a loosely-constrained child
14//! with no size of its own still fills, because a childless box there takes the biggest
15//! size it is allowed; under this framework's layout engine it would hug and come out at
16//! nothing — invisibly, since a stack draws no box of its own. A scrim, a barrier and
17//! every internal overlay here are exactly that widget. `fit(StackFit::Loose)` asks for
18//! the other behaviour, and is what a badge or a caption wants.
19
20use frus_core::{AlignmentDirectional, AlignmentGeometry, Rect, Scene};
21use frus_layout::{Dimension, Style};
22
23use crate::interaction::Status;
24use crate::theme::Theme;
25use crate::widget::Widget;
26
27/// How a stack sizes the layers that are not [`crate::Positioned`] — the reference's
28/// `StackFit`.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum StackFit {
31    /// **Given** the stack's box: every layer is as big as the stack. The default here,
32    /// for the reason the module documentation gives.
33    #[default]
34    Expand,
35    /// **Asked** what size it would like, and then placed by the stack's alignment. The
36    /// reference's default, and what a badge, a caption or a floating button wants.
37    Loose,
38}
39
40/// A container of overlaid layers.
41pub struct Stack<Msg = crate::callback::Callback> {
42    width: Dimension,
43    height: Dimension,
44    flex_grow: f32,
45    fit: StackFit,
46    alignment: AlignmentGeometry,
47    layers: Vec<Box<dyn Widget<Msg>>>,
48}
49
50impl<Msg> Stack<Msg> {
51    /// Creates an empty stack.
52    pub fn new() -> Self {
53        Self {
54            width: Dimension::Auto,
55            height: Dimension::Auto,
56            flex_grow: 0.0,
57            fit: StackFit::Expand,
58            // The reference's default anchor, and it follows the reading direction: the
59            // start corner is the left in a left-to-right script and the right in a
60            // right-to-left one.
61            alignment: AlignmentGeometry::Directional(AlignmentDirectional::TOP_START),
62            layers: Vec::new(),
63        }
64    }
65
66    /// How the layers that are not [`crate::Positioned`] are sized.
67    pub fn fit(mut self, fit: StackFit) -> Self {
68        self.fit = fit;
69        self
70    }
71
72    /// Where a layer smaller than the stack sits in it.
73    ///
74    /// It reaches the layers the stack had to place itself: those under
75    /// [`StackFit::Loose`], and the axes of a [`crate::Positioned`] that pinned neither
76    /// edge. Under [`StackFit::Expand`] an unpinned layer is already the size of the
77    /// stack and there is nothing left to align.
78    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
79        self.alignment = alignment.into();
80        self
81    }
82
83    /// Sets the width, in logical pixels.
84    pub fn width(mut self, width: f32) -> Self {
85        self.width = Dimension::Length(width);
86        self
87    }
88
89    /// Sets the height, in logical pixels.
90    pub fn height(mut self, height: f32) -> Self {
91        self.height = Dimension::Length(height);
92        self
93    }
94
95    /// Flex growth factor along the parent's main axis.
96    pub fn flex(mut self, grow: f32) -> Self {
97        self.flex_grow = grow;
98        self
99    }
100
101    /// Adds a layer, on top of the previous ones.
102    pub fn layer(mut self, layer: impl Widget<Msg> + 'static) -> Self {
103        self.layers.push(Box::new(layer));
104        self
105    }
106}
107
108impl<Msg> Default for Stack<Msg> {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl<Msg: Clone> Widget<Msg> for Stack<Msg> {
115    fn style(&self) -> Style {
116        Style {
117            width: self.width,
118            height: self.height,
119            flex_grow: self.flex_grow,
120            ..Default::default()
121        }
122    }
123
124    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
125        &self.layers
126    }
127
128    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
129
130    fn on_click(&self) -> Option<Msg> {
131        None
132    }
133
134    fn stack(&self) -> bool {
135        true
136    }
137
138    fn stack_loose(&self) -> bool {
139        self.fit == StackFit::Loose
140    }
141
142    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
143        Some(self.alignment)
144    }
145}
146
147/// A stack that lays **every** child out and paints one of them.
148///
149/// The difference between this and [`crate::Offstage`] is the whole reason it exists.
150/// `Offstage` takes a branch out of the tree — no box, no paint, and nothing retained:
151/// right for a branch that is genuinely gone, wrong for the three pages of a tabbed
152/// screen. There, every switch measures a subtree that was laid out a moment ago, and
153/// the page that comes back comes back blank: its scroll offset, its caret and its
154/// focus were the runtime's record of a widget that stopped existing.
155///
156/// An `IndexedStack` keeps them all. Each child is laid out, in the same box, and one is
157/// painted; the others are **silent** — not drawn, not clickable, not read out — but
158/// still measured, and still holding whatever the runtime holds for them. So switching
159/// keeps their sizes, and keeps where they had been scrolled to.
160///
161/// ```ignore
162/// IndexedStack::new(self.tab)
163///     .child(inbox_page())
164///     .child(sent_page())
165///     .child(drafts_page())
166/// ```
167///
168/// The price is exactly that: every page laid out on every frame, however many are
169/// shown. It is what a handful of tabs wants, and what a list of ten thousand rows does
170/// not — an index past the end simply paints nothing.
171pub struct IndexedStack<Msg = crate::callback::Callback> {
172    index: usize,
173    width: Dimension,
174    height: Dimension,
175    flex_grow: f32,
176    fit: StackFit,
177    alignment: AlignmentGeometry,
178    children: Vec<Box<dyn Widget<Msg>>>,
179}
180
181impl<Msg> IndexedStack<Msg> {
182    /// A stack showing the child at `index`, with none yet added.
183    pub fn new(index: usize) -> Self {
184        Self {
185            index,
186            width: Dimension::Auto,
187            height: Dimension::Auto,
188            flex_grow: 0.0,
189            // The reference's sizing for an indexed stack is loose, and here the argument
190            // that makes `Stack`'s default `Expand` does not apply: these children are
191            // pages, each with a size of its own, not the scrims and barriers that would
192            // collapse to nothing if merely asked.
193            fit: StackFit::Loose,
194            alignment: AlignmentGeometry::Directional(frus_core::AlignmentDirectional::TOP_START),
195            children: Vec::new(),
196        }
197    }
198
199    /// Which child is shown. The others stay laid out.
200    pub fn index(mut self, index: usize) -> Self {
201        self.index = index;
202        self
203    }
204
205    /// Adds a child, after the previous ones.
206    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
207        self.children.push(Box::new(child));
208        self
209    }
210
211    /// How the children are sized — [`StackFit::Loose`] here, unlike [`Stack`].
212    pub fn fit(mut self, fit: StackFit) -> Self {
213        self.fit = fit;
214        self
215    }
216
217    /// Where a child smaller than the stack sits in it.
218    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
219        self.alignment = alignment.into();
220        self
221    }
222
223    /// Sets the width, in logical pixels.
224    pub fn width(mut self, width: f32) -> Self {
225        self.width = Dimension::Length(width);
226        self
227    }
228
229    /// Sets the height, in logical pixels.
230    pub fn height(mut self, height: f32) -> Self {
231        self.height = Dimension::Length(height);
232        self
233    }
234
235    /// Flex growth factor along the parent's main axis.
236    pub fn flex(mut self, grow: f32) -> Self {
237        self.flex_grow = grow;
238        self
239    }
240}
241
242impl<Msg: Clone> Widget<Msg> for IndexedStack<Msg> {
243    fn style(&self) -> Style {
244        Style {
245            width: self.width,
246            height: self.height,
247            flex_grow: self.flex_grow,
248            ..Default::default()
249        }
250    }
251
252    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
253        &self.children
254    }
255
256    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
257
258    fn on_click(&self) -> Option<Msg> {
259        None
260    }
261
262    fn stack(&self) -> bool {
263        true
264    }
265
266    fn stack_loose(&self) -> bool {
267        self.fit == StackFit::Loose
268    }
269
270    fn stack_visible(&self) -> Option<usize> {
271        Some(self.index)
272    }
273
274    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
275        Some(self.alignment)
276    }
277
278    fn debug_name(&self) -> &'static str {
279        "IndexedStack"
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::{build_ui, Container, Runtime, Size};
287    use frus_core::{Color, Primitive};
288
289    #[test]
290    fn layers_overlap_in_same_box() {
291        let red = Color::rgb(1.0, 0.0, 0.0);
292        let blue = Color::rgb(0.0, 0.0, 1.0);
293        let stack = Stack::<()>::new()
294            .width(100.0)
295            .height(100.0)
296            .layer(Container::<()>::new().width(100.0).height(100.0).color(red))
297            .layer(Container::<()>::new().width(40.0).height(40.0).color(blue));
298        let ui = build_ui(
299            &stack,
300            Size::new(100.0, 100.0),
301            &Runtime::default(),
302            &Theme::default(),
303        );
304
305        let rect_of = |c: Color| {
306            ui.scene()
307                .primitives()
308                .iter()
309                .find_map(|p| match p {
310                    Primitive::Rect { color, rect, .. } if *color == c => Some(*rect),
311                    _ => None,
312                })
313                .expect("the layer is present")
314        };
315        // Both layers share the same origin, being overlaid.
316        assert_eq!(rect_of(red).origin(), rect_of(blue).origin());
317    }
318
319    /// Every rectangle of a colour, in a 100×100 stack.
320    fn rects(stack: Stack<()>, of: Color) -> Vec<Rect> {
321        let ui = build_ui(
322            &stack,
323            Size::new(100.0, 100.0),
324            &Runtime::default(),
325            &Theme::default(),
326        );
327        ui.scene()
328            .primitives()
329            .iter()
330            .filter_map(|p| match p {
331                Primitive::Rect { color, rect, .. } if *color == of => Some(*rect),
332                _ => None,
333            })
334            .collect()
335    }
336
337    const BADGE: Color = Color::rgb(0.0, 0.0, 1.0);
338
339    fn badge() -> Container<()> {
340        Container::<()>::new().width(20.0).height(20.0).color(BADGE)
341    }
342
343    /// Two edges pinned: the badge sits that far from each of them.
344    #[test]
345    fn a_pinned_layer_sits_against_the_edges_it_names() {
346        let stack = Stack::<()>::new()
347            .width(100.0)
348            .height(100.0)
349            .layer(crate::Positioned::new(badge()).top(8.0).right(12.0));
350        let r = rects(stack, BADGE)[0];
351        assert_eq!(r.y, 8.0, "8 below the top: {r:?}");
352        assert_eq!(r.x, 100.0 - 12.0 - 20.0, "12 in from the right: {r:?}");
353        assert_eq!((r.width, r.height), (20.0, 20.0), "its own size");
354    }
355
356    /// Both edges of an axis pinned: they decide the extent, and the layer's own is
357    /// overruled — which is what makes a bar across the bottom of a stack one line.
358    #[test]
359    fn opposite_pins_decide_the_extent() {
360        let stack = Stack::<()>::new().width(100.0).height(100.0).layer(
361            crate::Positioned::new(badge())
362                .left(10.0)
363                .right(30.0)
364                .bottom(0.0),
365        );
366        let r = rects(stack, BADGE)[0];
367        assert_eq!(r.x, 10.0);
368        assert_eq!(r.width, 60.0, "100 - 10 - 30: {r:?}");
369        assert_eq!(r.y, 80.0, "its own height, off the bottom: {r:?}");
370    }
371
372    /// `inset` is all four at once: the layer fills the stack, held off every edge.
373    #[test]
374    fn an_inset_layer_fills_what_is_left() {
375        let stack = Stack::<()>::new()
376            .width(100.0)
377            .height(100.0)
378            .layer(crate::Positioned::new(badge()).inset(5.0));
379        let r = rects(stack, BADGE)[0];
380        assert_eq!((r.x, r.y, r.width, r.height), (5.0, 5.0, 90.0, 90.0));
381    }
382
383    /// An axis nobody pinned is placed by the stack's alignment, at the layer's own size.
384    #[test]
385    fn an_unpinned_axis_follows_the_alignment() {
386        let stack = Stack::<()>::new()
387            .width(100.0)
388            .height(100.0)
389            .alignment(frus_core::Alignment::CENTER)
390            .layer(crate::Positioned::new(badge()).bottom(0.0));
391        let r = rects(stack, BADGE)[0];
392        assert_eq!(r.x, 40.0, "centred across: {r:?}");
393        assert_eq!(r.y, 80.0, "and pinned down: {r:?}");
394    }
395
396    /// The default fit hands every layer the box; the loose fit asks it, and then the
397    /// alignment says where it goes.
398    #[test]
399    fn a_loose_layer_keeps_its_size_and_is_aligned() {
400        let unsized_layer = || {
401            Container::<()>::new()
402                .color(BADGE)
403                .child(crate::Text::new("x"))
404        };
405
406        let expanded = Stack::<()>::new()
407            .width(100.0)
408            .height(100.0)
409            .layer(unsized_layer());
410        let r = rects(expanded, BADGE)[0];
411        assert_eq!((r.width, r.height), (100.0, 100.0), "given the box: {r:?}");
412
413        let loose = Stack::<()>::new()
414            .width(100.0)
415            .height(100.0)
416            .fit(StackFit::Loose)
417            .alignment(frus_core::Alignment::BOTTOM_RIGHT)
418            .layer(unsized_layer());
419        let r = rects(loose, BADGE)[0];
420        assert!(r.width < 60.0, "asked, and it hugged its text: {r:?}");
421        assert_eq!(r.x + r.width, 100.0, "against the right edge: {r:?}");
422        assert_eq!(r.y + r.height, 100.0, "and the bottom: {r:?}");
423    }
424
425    /// A wrapper must not eat the pins: `Keyed(Positioned(…))` keeps its place, which is
426    /// what the transparent-wrapper macro's third stated hook is for.
427    #[test]
428    fn a_wrapped_pin_still_pins() {
429        let stack = Stack::<()>::new()
430            .width(100.0)
431            .height(100.0)
432            .layer(crate::Keyed::new(
433                3u64,
434                crate::Positioned::new(badge()).top(8.0).left(8.0),
435            ));
436        let r = rects(stack, BADGE)[0];
437        assert_eq!((r.x, r.y), (8.0, 8.0));
438    }
439
440    /// A page in `colour`, `side` on a side, answering `msg` when clicked.
441    fn page(colour: Color, side: f32, msg: i32) -> Container<i32> {
442        Container::<i32>::new()
443            .width(side)
444            .height(side)
445            .color(colour)
446            .on_click(msg)
447    }
448
449    const PAGE_ONE: Color = Color::rgb(1.0, 0.0, 0.0);
450    const PAGE_TWO: Color = Color::rgb(0.0, 0.0, 1.0);
451
452    /// Two pages of different sizes in an indexed stack, one of them shown.
453    fn pages(shown: usize) -> IndexedStack<i32> {
454        IndexedStack::<i32>::new(shown)
455            .width(100.0)
456            .height(100.0)
457            .child(page(PAGE_ONE, 40.0, 1))
458            .child(page(PAGE_TWO, 80.0, 2))
459    }
460
461    fn built(root: &dyn Widget<i32>) -> (crate::Ui<i32>, Vec<crate::InspectorNode>) {
462        crate::build_ui_inspected(
463            root,
464            Size::new(100.0, 100.0),
465            &Runtime::default(),
466            &Theme::default(),
467        )
468    }
469
470    /// Every box of `side`×`side` the walk gave out, whether or not it was painted.
471    fn boxes(nodes: &[crate::InspectorNode], side: f32) -> usize {
472        nodes
473            .iter()
474            .filter(|n| (n.rect.width - side).abs() < 0.5 && (n.rect.height - side).abs() < 0.5)
475            .count()
476    }
477
478    fn painted(ui: &crate::Ui<i32>, of: Color) -> bool {
479        ui.scene()
480            .primitives()
481            .iter()
482            .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == of))
483    }
484
485    #[test]
486    fn an_indexed_stack_lays_every_page_out_and_paints_one() {
487        let (ui, nodes) = built(&pages(0));
488        assert!(painted(&ui, PAGE_ONE), "the shown page is drawn");
489        assert!(!painted(&ui, PAGE_TWO), "the others are not");
490        assert_eq!(
491            boxes(&nodes, 80.0),
492            1,
493            "the hidden page still has its box: {}",
494            crate::dump_tree(&nodes)
495        );
496    }
497
498    /// The promise of the whole widget: a page's box does not depend on which page is
499    /// shown, so switching costs no re-measurement and moves nothing.
500    #[test]
501    fn a_hidden_page_keeps_the_size_it_had() {
502        let (_, showing_first) = built(&pages(0));
503        let (_, showing_second) = built(&pages(1));
504        for side in [40.0, 80.0] {
505            assert_eq!(
506                boxes(&showing_first, side),
507                boxes(&showing_second, side),
508                "the {side} px page changed box when the other one was shown"
509            );
510        }
511    }
512
513    /// And the contrast that is the reason for it: [`crate::Offstage`] takes the page out
514    /// of the tree, so there is no box to keep.
515    #[test]
516    fn an_offstage_page_has_no_box_at_all() {
517        let stack = Stack::<i32>::new()
518            .width(100.0)
519            .height(100.0)
520            .fit(StackFit::Loose)
521            .layer(page(PAGE_ONE, 40.0, 1))
522            .layer(crate::Offstage::new(page(PAGE_TWO, 80.0, 2)));
523        let (_, nodes) = built(&stack);
524        assert_eq!(boxes(&nodes, 80.0), 0, "{}", crate::dump_tree(&nodes));
525    }
526
527    #[test]
528    fn a_hidden_page_takes_no_input() {
529        // (60, 60) is inside the second page and outside the first.
530        let point = frus_core::Point::new(60.0, 60.0);
531        let showing_first = built(&pages(0)).0;
532        assert!(
533            showing_first
534                .hit(point)
535                .and_then(|id| showing_first.msg_for(id))
536                != Some(2),
537            "the hidden page answered a click"
538        );
539        let showing_second = built(&pages(1)).0;
540        assert_eq!(
541            showing_second
542                .hit(point)
543                .and_then(|id| showing_second.msg_for(id)),
544            Some(2),
545            "and the shown one does"
546        );
547    }
548}