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. A badge on an avatar, a caption over a picture, a button floating over
3//! content.
4//!
5//! Two things decide where a layer lands, as in the reference (`rendering/stack.dart`):
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 stack is as big as its largest unpinned layer** (`stack.dart:625`), held to the
13//! room it is given; with none, or under [`StackFit::Expand`], it takes all the room it is
14//! given. Pinned layers never size it. A layer that goes past its edges is clipped there,
15//! unless [`Stack::clip_behavior`] says [`Clip::None`].
16
17use frus_core::{AlignmentDirectional, AlignmentGeometry, Rect, Scene, TextDirection};
18use frus_layout::{Dimension, Style};
19
20use crate::interaction::Status;
21use crate::theme::Theme;
22use crate::widget::{FillAxes, Widget};
23
24/// How a stack sizes the layers that are not [`crate::Positioned`] — the reference's
25/// `StackFit` (`stack.dart:301`).
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum StackFit {
28    /// **Allowed** up to the stack's room: each layer is as big as it wants to be, at most
29    /// the stack. The default, as in the reference.
30    #[default]
31    Loose,
32    /// **Forced** to the room the stack is given: every unpinned layer is exactly that big,
33    /// whatever size it has of its own, and so is the stack.
34    Expand,
35    /// **Handed** the room the stack is given: a layer with no size of its own fills it,
36    /// and one with a size keeps it — the stack's own constraints, passed through, for a
37    /// stack that is allowed its room rather than forced into it.
38    Passthrough,
39}
40
41/// What happens to a layer that goes past its stack's edges — the reference's `Clip`, as a
42/// stack uses it (`stack.dart:476`).
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Clip {
45    /// Nothing is cut: a badge may hang off the corner of the avatar it sits on.
46    None,
47    /// Cut at the stack's edges. The default.
48    #[default]
49    HardEdge,
50    /// Cut at the stack's edges, smoothed. The edges of a stack are straight, so this cuts
51    /// as [`Clip::HardEdge`] does.
52    AntiAlias,
53    /// Cut at the stack's edges, smoothed, in a layer of its own. As [`Clip::AntiAlias`]
54    /// here.
55    AntiAliasWithSaveLayer,
56}
57
58impl Clip {
59    /// Whether anything is cut.
60    pub fn clips(self) -> bool {
61        self != Clip::None
62    }
63}
64
65/// Whether a stack with these layers takes all the room it is given: under
66/// [`StackFit::Expand`], or with no layer that is not pinned (`stack.dart:657`).
67fn takes_the_room<Msg>(fit: StackFit, layers: &[Box<dyn Widget<Msg>>]) -> FillAxes {
68    if fit == StackFit::Expand || layers.iter().all(|layer| layer.positioned().is_some()) {
69        FillAxes::BOTH
70    } else {
71        FillAxes::NONE
72    }
73}
74
75/// A container of overlaid layers.
76pub struct Stack<Msg = crate::callback::Callback> {
77    width: Dimension,
78    height: Dimension,
79    flex_grow: f32,
80    fit: StackFit,
81    alignment: AlignmentGeometry,
82    clip: Clip,
83    direction: Option<TextDirection>,
84    layers: Vec<Box<dyn Widget<Msg>>>,
85}
86
87impl<Msg> Stack<Msg> {
88    /// Creates an empty stack.
89    pub fn new() -> Self {
90        Self {
91            width: Dimension::Auto,
92            height: Dimension::Auto,
93            flex_grow: 0.0,
94            fit: StackFit::Loose,
95            // The reference's default anchor, and it follows the reading direction: the
96            // start corner is the left in a left-to-right script and the right in a
97            // right-to-left one.
98            alignment: AlignmentGeometry::Directional(AlignmentDirectional::TOP_START),
99            clip: Clip::HardEdge,
100            direction: None,
101            layers: Vec::new(),
102        }
103    }
104
105    /// How the layers that are not [`crate::Positioned`] are sized. Unset,
106    /// [`StackFit::Loose`].
107    pub fn fit(mut self, fit: StackFit) -> Self {
108        self.fit = fit;
109        self
110    }
111
112    /// Where a layer smaller than the stack sits in it: the layers that are not
113    /// [`crate::Positioned`], and the axes of one that pinned neither edge. Unset, the top
114    /// start corner.
115    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
116        self.alignment = alignment.into();
117        self
118    }
119
120    /// **What happens to a layer that goes past the stack's edges.** Unset,
121    /// [`Clip::HardEdge`]: it is cut there. [`Clip::None`] lets it hang over.
122    pub fn clip_behavior(mut self, clip: Clip) -> Self {
123        self.clip = clip;
124        self
125    }
126
127    /// **The reading direction** the alignment and the start and end pins are resolved
128    /// in. Unset, the theme's.
129    pub fn text_direction(mut self, direction: TextDirection) -> Self {
130        self.direction = Some(direction);
131        self
132    }
133
134    /// Sets the width, in logical pixels.
135    pub fn width(mut self, width: f32) -> Self {
136        self.width = Dimension::Length(width);
137        self
138    }
139
140    /// Sets the height, in logical pixels.
141    pub fn height(mut self, height: f32) -> Self {
142        self.height = Dimension::Length(height);
143        self
144    }
145
146    /// Flex growth factor along the parent's main axis.
147    pub fn flex(mut self, grow: f32) -> Self {
148        self.flex_grow = grow;
149        self
150    }
151
152    /// Adds a layer, on top of the previous ones.
153    pub fn layer(mut self, layer: impl Widget<Msg> + 'static) -> Self {
154        self.layers.push(Box::new(layer));
155        self
156    }
157}
158
159impl<Msg> Default for Stack<Msg> {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl<Msg: Clone> Widget<Msg> for Stack<Msg> {
166    fn style(&self) -> Style {
167        Style {
168            width: self.width,
169            height: self.height,
170            flex_grow: self.flex_grow,
171            ..Default::default()
172        }
173    }
174
175    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
176        &self.layers
177    }
178
179    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
180
181    fn on_click(&self) -> Option<Msg> {
182        None
183    }
184
185    fn stack(&self) -> bool {
186        true
187    }
188
189    fn stack_fit(&self) -> StackFit {
190        self.fit
191    }
192
193    fn stack_measured(&self) -> bool {
194        true
195    }
196
197    fn stack_clips(&self) -> bool {
198        self.clip.clips()
199    }
200
201    fn stack_direction(&self) -> Option<TextDirection> {
202        self.direction
203    }
204
205    /// All the room it is given under [`StackFit::Expand`] or with every layer pinned.
206    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
207        takes_the_room(self.fit, &self.layers)
208    }
209
210    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
211        Some(self.alignment)
212    }
213}
214
215/// A stack that lays **every** child out and paints one of them.
216///
217/// The difference between this and [`crate::Offstage`] is the whole reason it exists.
218/// `Offstage` takes a branch out of the tree — no box, no paint, and nothing retained:
219/// right for a branch that is genuinely gone, wrong for the three pages of a tabbed
220/// screen. There, every switch measures a subtree that was laid out a moment ago, and
221/// the page that comes back comes back blank: its scroll offset, its caret and its
222/// focus were the runtime's record of a widget that stopped existing.
223///
224/// An `IndexedStack` keeps them all. Each child is laid out, in the same box, and one is
225/// painted; the others are **silent** — not drawn, not clickable, not read out — but
226/// still measured, and still holding whatever the runtime holds for them. So switching
227/// keeps their sizes, and keeps where they had been scrolled to.
228///
229/// ```ignore
230/// IndexedStack::new(self.tab)
231///     .child(inbox_page())
232///     .child(sent_page())
233///     .child(drafts_page())
234/// ```
235///
236/// The price is exactly that: every page laid out on every frame, however many are
237/// shown. It is what a handful of tabs wants, and what a list of ten thousand rows does
238/// not — an index past the end simply paints nothing.
239pub struct IndexedStack<Msg = crate::callback::Callback> {
240    index: usize,
241    width: Dimension,
242    height: Dimension,
243    flex_grow: f32,
244    fit: StackFit,
245    alignment: AlignmentGeometry,
246    clip: Clip,
247    direction: Option<TextDirection>,
248    children: Vec<Box<dyn Widget<Msg>>>,
249}
250
251impl<Msg> IndexedStack<Msg> {
252    /// A stack showing the child at `index`, with none yet added.
253    pub fn new(index: usize) -> Self {
254        Self {
255            index,
256            width: Dimension::Auto,
257            height: Dimension::Auto,
258            flex_grow: 0.0,
259            fit: StackFit::Loose,
260            alignment: AlignmentGeometry::Directional(frus_core::AlignmentDirectional::TOP_START),
261            clip: Clip::HardEdge,
262            direction: None,
263            children: Vec::new(),
264        }
265    }
266
267    /// Which child is shown. The others stay laid out.
268    pub fn index(mut self, index: usize) -> Self {
269        self.index = index;
270        self
271    }
272
273    /// Adds a child, after the previous ones.
274    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
275        self.children.push(Box::new(child));
276        self
277    }
278
279    /// How the children are sized. Unset, [`StackFit::Loose`].
280    pub fn fit(mut self, fit: StackFit) -> Self {
281        self.fit = fit;
282        self
283    }
284
285    /// Where a child smaller than the stack sits in it.
286    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
287        self.alignment = alignment.into();
288        self
289    }
290
291    /// What happens to a child that goes past the stack's edges. Unset, it is cut there.
292    pub fn clip_behavior(mut self, clip: Clip) -> Self {
293        self.clip = clip;
294        self
295    }
296
297    /// The reading direction the alignment is resolved in. Unset, the theme's.
298    pub fn text_direction(mut self, direction: TextDirection) -> Self {
299        self.direction = Some(direction);
300        self
301    }
302
303    /// Sets the width, in logical pixels.
304    pub fn width(mut self, width: f32) -> Self {
305        self.width = Dimension::Length(width);
306        self
307    }
308
309    /// Sets the height, in logical pixels.
310    pub fn height(mut self, height: f32) -> Self {
311        self.height = Dimension::Length(height);
312        self
313    }
314
315    /// Flex growth factor along the parent's main axis.
316    pub fn flex(mut self, grow: f32) -> Self {
317        self.flex_grow = grow;
318        self
319    }
320}
321
322impl<Msg: Clone> Widget<Msg> for IndexedStack<Msg> {
323    fn style(&self) -> Style {
324        Style {
325            width: self.width,
326            height: self.height,
327            flex_grow: self.flex_grow,
328            ..Default::default()
329        }
330    }
331
332    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
333        &self.children
334    }
335
336    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
337
338    fn on_click(&self) -> Option<Msg> {
339        None
340    }
341
342    fn stack(&self) -> bool {
343        true
344    }
345
346    fn stack_fit(&self) -> StackFit {
347        self.fit
348    }
349
350    /// As big as its largest child, shown or not (`stack.dart:768` lays every child out
351    /// and sizes as a stack does): switching pages does not resize it.
352    fn stack_measured(&self) -> bool {
353        true
354    }
355
356    fn stack_clips(&self) -> bool {
357        self.clip.clips()
358    }
359
360    fn stack_direction(&self) -> Option<TextDirection> {
361        self.direction
362    }
363
364    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
365        takes_the_room(self.fit, &self.children)
366    }
367
368    fn stack_visible(&self) -> Option<usize> {
369        Some(self.index)
370    }
371
372    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
373        Some(self.alignment)
374    }
375
376    fn debug_name(&self) -> &'static str {
377        "IndexedStack"
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::{build_ui, Container, Runtime, Size};
385    use frus_core::{Color, Primitive};
386
387    #[test]
388    fn layers_overlap_in_same_box() {
389        let red = Color::rgb(1.0, 0.0, 0.0);
390        let blue = Color::rgb(0.0, 0.0, 1.0);
391        let stack = Stack::<()>::new()
392            .width(100.0)
393            .height(100.0)
394            .layer(Container::<()>::new().width(100.0).height(100.0).color(red))
395            .layer(Container::<()>::new().width(40.0).height(40.0).color(blue));
396        let ui = build_ui(
397            &stack,
398            Size::new(100.0, 100.0),
399            &Runtime::default(),
400            &Theme::default(),
401        );
402
403        let rect_of = |c: Color| {
404            ui.scene()
405                .primitives()
406                .iter()
407                .find_map(|p| match p {
408                    Primitive::Rect { color, rect, .. } if *color == c => Some(*rect),
409                    _ => None,
410                })
411                .expect("the layer is present")
412        };
413        // Both layers share the same origin, being overlaid.
414        assert_eq!(rect_of(red).origin(), rect_of(blue).origin());
415    }
416
417    /// Every rectangle of a colour, in a 100×100 stack.
418    fn rects(stack: Stack<()>, of: Color) -> Vec<Rect> {
419        let ui = build_ui(
420            &stack,
421            Size::new(100.0, 100.0),
422            &Runtime::default(),
423            &Theme::default(),
424        );
425        ui.scene()
426            .primitives()
427            .iter()
428            .filter_map(|p| match p {
429                Primitive::Rect { color, rect, .. } if *color == of => Some(*rect),
430                _ => None,
431            })
432            .collect()
433    }
434
435    const BADGE: Color = Color::rgb(0.0, 0.0, 1.0);
436
437    fn badge() -> Container<()> {
438        Container::<()>::new().width(20.0).height(20.0).color(BADGE)
439    }
440
441    /// Two edges pinned: the badge sits that far from each of them.
442    #[test]
443    fn a_pinned_layer_sits_against_the_edges_it_names() {
444        let stack = Stack::<()>::new()
445            .width(100.0)
446            .height(100.0)
447            .layer(crate::Positioned::new(badge()).top(8.0).right(12.0));
448        let r = rects(stack, BADGE)[0];
449        assert_eq!(r.y, 8.0, "8 below the top: {r:?}");
450        assert_eq!(r.x, 100.0 - 12.0 - 20.0, "12 in from the right: {r:?}");
451        assert_eq!((r.width, r.height), (20.0, 20.0), "its own size");
452    }
453
454    /// Both edges of an axis pinned: they decide the extent, and the layer's own is
455    /// overruled — which is what makes a bar across the bottom of a stack one line.
456    #[test]
457    fn opposite_pins_decide_the_extent() {
458        let stack = Stack::<()>::new().width(100.0).height(100.0).layer(
459            crate::Positioned::new(badge())
460                .left(10.0)
461                .right(30.0)
462                .bottom(0.0),
463        );
464        let r = rects(stack, BADGE)[0];
465        assert_eq!(r.x, 10.0);
466        assert_eq!(r.width, 60.0, "100 - 10 - 30: {r:?}");
467        assert_eq!(r.y, 80.0, "its own height, off the bottom: {r:?}");
468    }
469
470    /// `inset` is all four at once: the layer fills the stack, held off every edge.
471    #[test]
472    fn an_inset_layer_fills_what_is_left() {
473        let stack = Stack::<()>::new()
474            .width(100.0)
475            .height(100.0)
476            .layer(crate::Positioned::new(badge()).inset(5.0));
477        let r = rects(stack, BADGE)[0];
478        assert_eq!((r.x, r.y, r.width, r.height), (5.0, 5.0, 90.0, 90.0));
479    }
480
481    /// An axis nobody pinned is placed by the stack's alignment, at the layer's own size.
482    #[test]
483    fn an_unpinned_axis_follows_the_alignment() {
484        let stack = Stack::<()>::new()
485            .width(100.0)
486            .height(100.0)
487            .alignment(frus_core::Alignment::CENTER)
488            .layer(crate::Positioned::new(badge()).bottom(0.0));
489        let r = rects(stack, BADGE)[0];
490        assert_eq!(r.x, 40.0, "centred across: {r:?}");
491        assert_eq!(r.y, 80.0, "and pinned down: {r:?}");
492    }
493
494    /// **The reference's three fits** (`stack.dart:634`): loose, the default, asks a layer
495    /// and lets the alignment place it; expand forces every layer to the stack's box,
496    /// whatever size it has of its own; passthrough hands it the box, which fills a layer
497    /// with no size and leaves a sized one alone.
498    #[test]
499    fn the_three_fits_are_the_reference_s() {
500        let unsized_layer = || {
501            Container::<()>::new()
502                .color(BADGE)
503                .child(crate::Text::new("x"))
504        };
505        let sized_layer = || Container::<()>::new().width(40.0).height(40.0).color(BADGE);
506        let in_box = |fit: StackFit, layer: Container<()>| {
507            let stack = Stack::<()>::new()
508                .width(100.0)
509                .height(100.0)
510                .fit(fit)
511                .alignment(frus_core::Alignment::BOTTOM_RIGHT)
512                .layer(layer);
513            rects(stack, BADGE)[0]
514        };
515
516        let r = in_box(StackFit::Loose, unsized_layer());
517        assert!(r.width < 60.0, "asked, and it hugged its text: {r:?}");
518        assert_eq!(r.x + r.width, 100.0, "against the right edge: {r:?}");
519        assert_eq!(r.y + r.height, 100.0, "and the bottom: {r:?}");
520        assert_eq!(
521            Stack::<()>::new().stack_fit(),
522            StackFit::Loose,
523            "the default, as in the reference"
524        );
525
526        let r = in_box(StackFit::Expand, sized_layer());
527        assert_eq!((r.width, r.height), (100.0, 100.0), "forced: {r:?}");
528        let r = in_box(StackFit::Expand, unsized_layer());
529        assert_eq!((r.width, r.height), (100.0, 100.0), "forced: {r:?}");
530
531        let r = in_box(StackFit::Passthrough, unsized_layer());
532        assert_eq!((r.width, r.height), (100.0, 100.0), "handed the box: {r:?}");
533        let r = in_box(StackFit::Passthrough, sized_layer());
534        assert_eq!(
535            (r.width, r.height),
536            (40.0, 40.0),
537            "and a size of its own kept: {r:?}"
538        );
539        let r = in_box(StackFit::Loose, sized_layer());
540        assert_eq!((r.width, r.height), (40.0, 40.0));
541        // A layer that asks for the room takes it, loose: an empty container is as big as
542        // the stack, as a childless box is in the reference.
543        let r = in_box(StackFit::Loose, Container::<()>::new().color(BADGE));
544        assert_eq!((r.width, r.height), (100.0, 100.0), "it asked: {r:?}");
545    }
546
547    /// Every rectangle painted in `colour`, in a column of a stack and a 10 px square under
548    /// it, in a 200 px square.
549    fn in_a_column(stack: Stack<()>, colour: Color) -> (Vec<Rect>, Rect) {
550        let under = Color::rgb(0.1, 0.7, 0.3);
551        let col = crate::Flex::<()>::column()
552            .child(stack)
553            .child(Container::new().width(10.0).height(10.0).color(under));
554        let ui = build_ui(
555            &col,
556            Size::new(200.0, 200.0),
557            &Runtime::default(),
558            &crate::Theme::default(),
559        );
560        let painted = |c: Color| -> Vec<Rect> {
561            ui.scene()
562                .primitives()
563                .iter()
564                .filter_map(|p| match p {
565                    Primitive::Rect { rect, color, .. } if *color == c => Some(*rect),
566                    _ => None,
567                })
568                .collect()
569        };
570        let next = painted(under)[0];
571        (painted(colour), next)
572    }
573
574    /// **A stack is as big as its largest unpinned layer** (`stack.dart:625`), and a
575    /// pinned layer does not count: the next child of the column starts under the biggest
576    /// one, not at the top.
577    #[test]
578    fn a_stack_is_as_big_as_its_largest_unpinned_layer() {
579        // The tallest is not the last: the stack takes each axis's largest.
580        let stack = Stack::<()>::new()
581            .layer(Container::new().width(20.0).height(50.0).color(BADGE))
582            .layer(Container::new().width(40.0).height(30.0).color(BADGE))
583            .layer(
584                crate::Positioned::new(Container::new().width(90.0).height(90.0).color(BADGE))
585                    .top(0.0)
586                    .left(0.0),
587            );
588        let (_, next) = in_a_column(stack, BADGE);
589        assert_eq!(
590            next.y, 50.0,
591            "under the tallest unpinned layer, not the pinned one"
592        );
593    }
594
595    /// **With every layer pinned, or under `Expand`, it takes the room it is given**
596    /// (`stack.dart:657`, `:636`).
597    #[test]
598    fn with_only_pinned_layers_it_takes_the_room() {
599        let pinned = Stack::<()>::new().height(60.0).layer(
600            crate::Positioned::new(Container::new().width(10.0).height(10.0).color(BADGE))
601                .top(0.0)
602                .left(0.0),
603        );
604        assert_eq!(
605            Widget::<()>::fill_axes(&pinned, &crate::Theme::default()),
606            FillAxes::BOTH
607        );
608        let expanded = Stack::<()>::new()
609            .fit(StackFit::Expand)
610            .layer(Container::new().width(10.0).height(10.0).color(BADGE));
611        assert_eq!(
612            Widget::<()>::fill_axes(&expanded, &crate::Theme::default()),
613            FillAxes::BOTH
614        );
615        let loose = Stack::<()>::new().layer(Container::new().width(10.0).height(10.0));
616        assert_eq!(
617            Widget::<()>::fill_axes(&loose, &crate::Theme::default()),
618            FillAxes::NONE,
619            "a layer that is not pinned sizes it"
620        );
621        // Laid out: a stack of pinned layers inside a sized box is that box.
622        let boxed = Container::<()>::new().width(120.0).height(70.0).child(
623            Stack::new().layer(
624                crate::Positioned::new(Container::new().color(BADGE))
625                    .right(0.0)
626                    .bottom(0.0)
627                    .width(10.0)
628                    .height(10.0),
629            ),
630        );
631        let ui = build_ui(
632            &boxed,
633            Size::new(200.0, 200.0),
634            &Runtime::default(),
635            &crate::Theme::default(),
636        );
637        let badge = ui
638            .scene()
639            .primitives()
640            .iter()
641            .find_map(|p| match p {
642                Primitive::Rect { rect, color, .. } if *color == BADGE => Some(*rect),
643                _ => None,
644            })
645            .expect("the badge");
646        assert_eq!((badge.x, badge.y), (110.0, 60.0), "in the box's corner");
647    }
648
649    /// **`Clip::None` lets a layer hang over the edges**; the default cuts it there
650    /// (`stack.dart:718`).
651    #[test]
652    fn a_layer_hangs_over_only_when_told() {
653        let hanging = |clip: Clip| {
654            Stack::<()>::new()
655                .width(40.0)
656                .height(40.0)
657                .clip_behavior(clip)
658                .layer(
659                    crate::Positioned::new(Container::new().width(20.0).height(20.0).color(BADGE))
660                        .top(-10.0)
661                        .right(-10.0),
662                )
663        };
664        assert!(Widget::<()>::stack_clips(&hanging(Clip::HardEdge)));
665        assert!(!Widget::<()>::stack_clips(&hanging(Clip::None)));
666        assert!(Clip::AntiAlias.clips() && Clip::AntiAliasWithSaveLayer.clips());
667        assert_eq!(Clip::default(), Clip::HardEdge);
668        let r = rects(hanging(Clip::None), BADGE)[0];
669        assert_eq!(
670            (r.x, r.y),
671            (30.0, -10.0),
672            "past the top right corner: {r:?}"
673        );
674        // What is cut: at the stack's edges by default, not at all with `Clip::None`.
675        let clip_of = |clip: Clip| {
676            let stack = hanging(clip);
677            let ui = build_ui(
678                &stack,
679                Size::new(100.0, 100.0),
680                &Runtime::default(),
681                &crate::Theme::default(),
682            );
683            ui.scene()
684                .primitives()
685                .iter()
686                .find_map(|p| match p {
687                    Primitive::Rect { color, clip, .. } if *color == BADGE => Some(*clip),
688                    _ => None,
689                })
690                .expect("the badge")
691        };
692        assert_eq!(clip_of(Clip::HardEdge), Rect::new(0.0, 0.0, 40.0, 40.0));
693        assert!(
694            clip_of(Clip::None).contains(frus_core::Point::new(45.0, 5.0)),
695            "nothing cut past the edge"
696        );
697    }
698
699    /// **A layer hanging over the edge is drawn there but not touched there**: the stack
700    /// hit-tests inside its own box only, as the reference's does (`box.dart`'s `hitTest`
701    /// asks the box first).
702    #[test]
703    fn a_hanging_layer_is_only_touched_inside_the_stack() {
704        #[derive(Clone, Debug, PartialEq)]
705        struct Tap;
706        let page = Container::<Tap>::new().padding(50.0).child(
707            Stack::<Tap>::new()
708                .width(40.0)
709                .height(40.0)
710                .clip_behavior(Clip::None)
711                .layer(
712                    crate::Positioned::new(Container::new().width(20.0).height(20.0).on_click(Tap))
713                        .top(-10.0)
714                        .right(-10.0),
715                ),
716        );
717        let ui = build_ui(
718            &page,
719            Size::new(200.0, 200.0),
720            &Runtime::default(),
721            &crate::Theme::default(),
722        );
723        let tap = |x: f32, y: f32| {
724            ui.hit(frus_core::Point::new(x, y))
725                .and_then(|id| ui.msg_for(id))
726        };
727        assert_eq!(tap(85.0, 55.0), Some(Tap), "inside the stack");
728        assert_eq!(
729            tap(95.0, 45.0),
730            None,
731            "outside it, where only the drawing is"
732        );
733    }
734
735    /// **The stack's own reading direction** places a start-anchored layer.
736    #[test]
737    fn the_stack_s_own_direction_is_read() {
738        let stack = Stack::<()>::new()
739            .width(100.0)
740            .height(100.0)
741            .text_direction(TextDirection::Rtl)
742            .layer(Container::new().width(20.0).height(20.0).color(BADGE));
743        let r = rects(stack, BADGE)[0];
744        assert_eq!(r.x, 80.0, "the start is the right: {r:?}");
745    }
746
747    /// **`Positioned::fill`, `from_rect` and `from_relative_rect`** are the reference's
748    /// three shorthands.
749    #[test]
750    fn the_positioned_shorthands() {
751        let at = |layer: crate::Positioned<()>| {
752            rects(
753                Stack::<()>::new().width(100.0).height(100.0).layer(layer),
754                BADGE,
755            )[0]
756        };
757        let square = || Container::<()>::new().color(BADGE);
758        assert_eq!(
759            at(crate::Positioned::fill(square())),
760            Rect::new(0.0, 0.0, 100.0, 100.0)
761        );
762        assert_eq!(
763            at(crate::Positioned::from_rect(
764                square(),
765                Rect::new(10.0, 20.0, 30.0, 40.0)
766            )),
767            Rect::new(10.0, 20.0, 30.0, 40.0)
768        );
769        assert_eq!(
770            at(crate::Positioned::from_relative_rect(
771                square(),
772                frus_core::Insets::new(5.0, 10.0, 15.0, 20.0)
773            )),
774            Rect::new(20.0, 5.0, 70.0, 80.0)
775        );
776    }
777
778    /// **An indexed stack is as big as its largest child, shown or not**, so switching
779    /// pages does not resize it.
780    #[test]
781    fn an_indexed_stack_is_as_big_as_its_largest_child() {
782        let pages = |index: usize| {
783            IndexedStack::<()>::new(index)
784                .child(Container::new().width(30.0).height(20.0).color(BADGE))
785                .child(Container::new().width(30.0).height(60.0).color(BADGE))
786        };
787        for index in [0, 1] {
788            let col = crate::Flex::<()>::column().child(pages(index)).child(
789                Container::new()
790                    .width(10.0)
791                    .height(10.0)
792                    .color(Color::rgb(0.1, 0.7, 0.3)),
793            );
794            let ui = build_ui(
795                &col,
796                Size::new(200.0, 200.0),
797                &Runtime::default(),
798                &crate::Theme::default(),
799            );
800            let next = ui
801                .scene()
802                .primitives()
803                .iter()
804                .find_map(|p| match p {
805                    Primitive::Rect { rect, color, .. } if *color == Color::rgb(0.1, 0.7, 0.3) => {
806                        Some(*rect)
807                    }
808                    _ => None,
809                })
810                .expect("the square under it");
811            assert_eq!(next.y, 60.0, "page {index}: the tallest page's height");
812        }
813    }
814
815    /// A wrapper must not eat the pins: `Keyed(Positioned(…))` keeps its place, which is
816    /// what the transparent-wrapper macro's third stated hook is for.
817    #[test]
818    fn a_wrapped_pin_still_pins() {
819        let stack = Stack::<()>::new()
820            .width(100.0)
821            .height(100.0)
822            .layer(crate::Keyed::new(
823                3u64,
824                crate::Positioned::new(badge()).top(8.0).left(8.0),
825            ));
826        let r = rects(stack, BADGE)[0];
827        assert_eq!((r.x, r.y), (8.0, 8.0));
828    }
829
830    /// A page in `colour`, `side` on a side, answering `msg` when clicked.
831    fn page(colour: Color, side: f32, msg: i32) -> Container<i32> {
832        Container::<i32>::new()
833            .width(side)
834            .height(side)
835            .color(colour)
836            .on_click(msg)
837    }
838
839    const PAGE_ONE: Color = Color::rgb(1.0, 0.0, 0.0);
840    const PAGE_TWO: Color = Color::rgb(0.0, 0.0, 1.0);
841
842    /// Two pages of different sizes in an indexed stack, one of them shown.
843    fn pages(shown: usize) -> IndexedStack<i32> {
844        IndexedStack::<i32>::new(shown)
845            .width(100.0)
846            .height(100.0)
847            .child(page(PAGE_ONE, 40.0, 1))
848            .child(page(PAGE_TWO, 80.0, 2))
849    }
850
851    fn built(root: &dyn Widget<i32>) -> (crate::Ui<i32>, Vec<crate::InspectorNode>) {
852        crate::build_ui_inspected(
853            root,
854            Size::new(100.0, 100.0),
855            &Runtime::default(),
856            &Theme::default(),
857        )
858    }
859
860    /// Every box of `side`×`side` the walk gave out, whether or not it was painted.
861    fn boxes(nodes: &[crate::InspectorNode], side: f32) -> usize {
862        nodes
863            .iter()
864            .filter(|n| (n.rect.width - side).abs() < 0.5 && (n.rect.height - side).abs() < 0.5)
865            .count()
866    }
867
868    fn painted(ui: &crate::Ui<i32>, of: Color) -> bool {
869        ui.scene()
870            .primitives()
871            .iter()
872            .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == of))
873    }
874
875    #[test]
876    fn an_indexed_stack_lays_every_page_out_and_paints_one() {
877        let (ui, nodes) = built(&pages(0));
878        assert!(painted(&ui, PAGE_ONE), "the shown page is drawn");
879        assert!(!painted(&ui, PAGE_TWO), "the others are not");
880        assert_eq!(
881            boxes(&nodes, 80.0),
882            1,
883            "the hidden page still has its box: {}",
884            crate::dump_tree(&nodes)
885        );
886    }
887
888    /// The promise of the whole widget: a page's box does not depend on which page is
889    /// shown, so switching costs no re-measurement and moves nothing.
890    #[test]
891    fn a_hidden_page_keeps_the_size_it_had() {
892        let (_, showing_first) = built(&pages(0));
893        let (_, showing_second) = built(&pages(1));
894        for side in [40.0, 80.0] {
895            assert_eq!(
896                boxes(&showing_first, side),
897                boxes(&showing_second, side),
898                "the {side} px page changed box when the other one was shown"
899            );
900        }
901    }
902
903    /// And the contrast that is the reason for it: [`crate::Offstage`] takes the page out
904    /// of the tree, so there is no box to keep.
905    #[test]
906    fn an_offstage_page_has_no_box_at_all() {
907        let stack = Stack::<i32>::new()
908            .width(100.0)
909            .height(100.0)
910            .fit(StackFit::Loose)
911            .layer(page(PAGE_ONE, 40.0, 1))
912            .layer(crate::Offstage::new(page(PAGE_TWO, 80.0, 2)));
913        let (_, nodes) = built(&stack);
914        assert_eq!(boxes(&nodes, 80.0), 0, "{}", crate::dump_tree(&nodes));
915    }
916
917    #[test]
918    fn a_hidden_page_takes_no_input() {
919        // (60, 60) is inside the second page and outside the first.
920        let point = frus_core::Point::new(60.0, 60.0);
921        let showing_first = built(&pages(0)).0;
922        assert!(
923            showing_first
924                .hit(point)
925                .and_then(|id| showing_first.msg_for(id))
926                != Some(2),
927            "the hidden page answered a click"
928        );
929        let showing_second = built(&pages(1)).0;
930        assert_eq!(
931            showing_second
932                .hit(point)
933                .and_then(|id| showing_second.msg_for(id)),
934            Some(2),
935            "and the shown one does"
936        );
937    }
938}