Skip to main content

teksilo_widgets/primitives/
zstack.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ZStack — a layout container that layers children on top of each other.
5//!
6//! The container sizes itself to the maximum width and maximum height across
7//! all children, measured at an unspecified proposal so background rects do not
8//! inflate the size. **Height additionally takes a width-bounded query** when the
9//! parent bound the width, so a wrapping child reports the height it will really
10//! occupy rather than a single line; see `layout_response` for why that query is
11//! width-only. Each child is then offered the full container bounds and
12//! positioned according to the container-level `Alignment` (default: `CENTER`);
13//! individual children can override alignment via `WidgetTree::set_alignment`.
14//!
15//! The primary use-cases are layered UIs — a background `RectWidget` beneath
16//! a `TextWidget`, a floating badge over a button icon — and card-like
17//! compositions where a paint layer and a content layer share the same bounds.
18//! Children that expand to fill their proposal (e.g. `RectWidget`) fill the
19//! full ZStack area; children with fixed intrinsic sizes are positioned by
20//! alignment.
21//!
22//! Propagates shrink weight and minimum size when any child opts in, so
23//! wrapping a shrinkable single-line label in a `ZStack` stays shrinkable.
24//!
25//! ```rust
26//! # use teksilo_widgets::primitives::{ZStack, TextWidget};
27//! # use teksilo_widgets::RectWidget;
28//! # use teksilo_i18n::lit;
29//! # use teksilo_tokens::SurfaceRole;
30//! let _card = ZStack::new()
31//!     .child(RectWidget::new().background(SurfaceRole::Raised))
32//!     .child(TextWidget::new(lit!("Hello")));
33//! ```
34
35use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
36
37use teksilo_core::WidgetId;
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
40use teksilo_tokens::Alignment;
41
42/// A layout container that stacks children on top of each other.
43/// Size = max of children sizes. Children are positioned according to
44/// the container's `Alignment` (default: center), with per-child overrides.
45#[derive(Debug)]
46pub struct ZStack {
47    child_ids: Vec<WidgetId>,
48    pending: Vec<PendingChild>,
49    alignment: Alignment,
50}
51
52impl ZStack {
53    /// Create an empty `ZStack` with center alignment.
54    pub fn new() -> Self {
55        Self {
56            child_ids: Vec::new(),
57            pending: Vec::new(),
58            alignment: Alignment::CENTER,
59        }
60    }
61
62    /// Set the alignment applied to every child that does not have a
63    /// per-child override set via `WidgetTree::set_alignment`.
64    pub fn alignment(mut self, alignment: Alignment) -> Self {
65        self.alignment = alignment;
66        self
67    }
68
69    /// Add a pre-registered child by ID.
70    pub fn add_child(mut self, id: WidgetId) -> Self {
71        self.pending.push(PendingChild::Id(id));
72        self
73    }
74
75    /// Add an inline child widget (deferred insertion).
76    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
77        self.pending.push(PendingChild::Deferred(Box::new(widget)));
78        self
79    }
80
81    /// Add multiple inline children from an iterator.
82    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
83        for widget in iter {
84            self.pending.push(PendingChild::Deferred(Box::new(widget)));
85        }
86        self
87    }
88
89    /// Conditionally add a child. No-op if None.
90    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
91        if let Some(w) = widget {
92            self.pending.push(PendingChild::Deferred(Box::new(w)));
93        }
94        self
95    }
96}
97
98impl Default for ZStack {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl Widget for ZStack {
105    fn layout_response(
106        &self,
107        proposal: SizeProposal,
108        ctx: &LayoutContext,
109    ) -> teksilo_core::widget::LayoutResponse {
110        // Ask each child for its intrinsic size (unspecified proposal) and take the max.
111        // This ensures background elements (like RectWidget, which returns 0x0 for
112        // unspecified) don't inflate the stack's size.
113        //
114        // **Height gets a second, width-bounded query.** A height-for-width child
115        // (wrapping text) measured at `unspecified()` has no basis for wrapping, so it
116        // reports a *single line* — and the ZStack then sized its chrome to that, leaving
117        // the real paragraph to paint outside its own background. Found in Skribisto,
118        // where a toast body spilled over the status bar.
119        //
120        // The obvious fix — forwarding the whole incoming `proposal` — is the one this
121        // code deliberately avoided, for two separate reasons, and both still stand:
122        //
123        //  * **Width.** `MinSize` forwards `Some(min)` as the width, so a *shrinkable*
124        //    single-line label inside `MinSize → ZStack` would truncate to the min width
125        //    during intrinsic measurement. `max_w` is therefore still taken only from the
126        //    unspecified pass — untouched, bug-for-bug.
127        //  * **Height.** Forwarding a bound `proposal.height` would let a greedy child
128        //    (`RectWidget`) claim it and inflate the stack — exactly what the unspecified
129        //    pass exists to prevent. So the second query pins `height: None` and offers
130        //    only the width.
131        //
132        // What is left is precise: children are asked "how tall are you at the width you
133        // will actually get?", and nothing else changes. A greedy background answers 0,
134        // a truncating label answers one line either way, and only a wrapping child moves.
135        let bounded_for_height = SizeProposal {
136            width: proposal.width,
137            height: None,
138        };
139        let query_twice = proposal.width.is_some();
140
141        let mut max_w: f32 = 0.0;
142        let mut max_h: f32 = 0.0;
143        let mut min_w: f32 = 0.0;
144        let mut min_h: f32 = 0.0;
145        let mut any_shrink = false;
146        let mut any_queried = false;
147        for &child_id in &self.child_ids {
148            if let Some(r) = ctx.child_layout_response(child_id, SizeProposal::unspecified()) {
149                max_w = max_w.max(r.size.width);
150                max_h = max_h.max(r.size.height);
151                min_w = min_w.max(r.min.width);
152                min_h = min_h.max(r.min.height);
153                if r.shrink > 0.0 {
154                    any_shrink = true;
155                }
156                any_queried = true;
157            }
158            // Height only. Skipped entirely when the parent left the width open, since
159            // the proposal would then be identical to the unspecified one above.
160            if query_twice && let Some(r) = ctx.child_layout_response(child_id, bounded_for_height)
161            {
162                max_h = max_h.max(r.size.height);
163                any_queried = true;
164            }
165        }
166        if any_queried {
167            // Size = max of children. Propagate a shrink weight + compression
168            // floor when any child can shrink (so a ZStack wrapping shrinkable
169            // content stays shrinkable), but keep `flex = 0`: a ZStack does not
170            // claim growth slack.
171            let size = Size::new(max_w, max_h);
172            let min = Size::new(min_w.min(max_w), min_h.min(max_h));
173            teksilo_core::widget::LayoutResponse::rigid(size)
174                .with_shrink(if any_shrink { 1.0 } else { 0.0 })
175                .with_min(min)
176        } else {
177            proposal.resolve(0.0, 0.0).into()
178        }
179    }
180
181    fn place_children(
182        &self,
183        bounds: Rect,
184        _proposal: SizeProposal,
185        children: &mut [WidgetPlacement],
186        ctx: &LayoutContext,
187    ) {
188        let rtl = ctx.is_rtl();
189        let exact_proposal = SizeProposal::exact(bounds.width, bounds.height);
190        for child in children.iter_mut() {
191            // Query child with the full bounds as proposal. Children that accept
192            // the proposal (e.g. background rects) fill the ZStack. Children with
193            // fixed intrinsic size get their natural size and are positioned by alignment.
194            let child_size = ctx
195                .child_size(child.id, exact_proposal)
196                .unwrap_or(bounds.size());
197
198            // Per-child override or container default
199            let align = ctx.child_alignment(child.id).unwrap_or(self.alignment);
200            let (dx, dy) = align.resolve(
201                (child_size.width, child_size.height),
202                (bounds.width, bounds.height),
203                rtl,
204            );
205
206            child.origin = Point::new(bounds.x + dx, bounds.y + dy);
207            child.size = child_size;
208        }
209    }
210
211    fn paint(&self, _bounds: Rect, _canvas: &mut Canvas, _ctx: &PaintContext) {}
212
213    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
214        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
215    }
216
217    fn children(&self) -> Vec<WidgetId> {
218        self.child_ids.clone()
219    }
220
221    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
222        let pending = std::mem::take(&mut self.pending);
223        if !pending.is_empty() {
224            self.child_ids = pending
225                .into_iter()
226                .map(|child| match child {
227                    PendingChild::Id(id) => id,
228                    PendingChild::Deferred(w) => ctx.add_boxed(w),
229                })
230                .collect();
231        }
232        self.child_ids.clone()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use teksilo_core::widget_tree::WidgetTree;
240
241    #[derive(Debug)]
242    struct FixedLeaf(f32, f32);
243    impl Widget for FixedLeaf {
244        fn layout_response(
245            &self,
246            _proposal: SizeProposal,
247            _ctx: &LayoutContext,
248        ) -> teksilo_core::widget::LayoutResponse {
249            Size::new(self.0, self.1).into()
250        }
251    }
252
253    /// A child whose height depends on the width it is measured at — the shape of a
254    /// wrapping paragraph, including its single-line answer when given no width.
255    #[derive(Debug)]
256    struct WrappingLeaf {
257        natural_width: f32,
258        line_height: f32,
259    }
260    impl Widget for WrappingLeaf {
261        fn layout_response(
262            &self,
263            proposal: SizeProposal,
264            _ctx: &LayoutContext,
265        ) -> teksilo_core::widget::LayoutResponse {
266            match proposal.width {
267                Some(w) if w > 0.0 => {
268                    let lines = (self.natural_width / w).ceil().max(1.0);
269                    Size::new(w, lines * self.line_height).into()
270                }
271                _ => Size::new(self.natural_width, self.line_height).into(),
272            }
273        }
274    }
275
276    /// A child that claims whatever it is offered — `RectWidget`'s shape, and the reason
277    /// the intrinsic pass exists.
278    #[derive(Debug)]
279    struct GreedyLeaf;
280    impl Widget for GreedyLeaf {
281        fn layout_response(
282            &self,
283            proposal: SizeProposal,
284            _ctx: &LayoutContext,
285        ) -> teksilo_core::widget::LayoutResponse {
286            Size::new(
287                proposal.width.unwrap_or(0.0),
288                proposal.height.unwrap_or(0.0),
289            )
290            .into()
291        }
292    }
293
294    /// The toast chrome's exact shape: a greedy background layered under wrapping content.
295    ///
296    /// Regression: both children were measured only at `unspecified()`, so the paragraph
297    /// reported a single line and the ZStack sized its background to that — the remaining
298    /// lines painted outside the chrome entirely. Seen in Skribisto as a toast body
299    /// spilling over the status bar.
300    #[test]
301    fn a_wrapping_child_gets_its_real_height_when_the_width_is_bound() {
302        let mut tree = WidgetTree::new();
303        let bg = tree.add(GreedyLeaf);
304        let text = tree.add(WrappingLeaf {
305            natural_width: 400.0,
306            line_height: 16.0,
307        });
308        let stack = tree.add(ZStack::new().add_child(bg).add_child(text));
309
310        // Width bound, height open — what a toast surface is offered.
311        tree.layout(SizeProposal {
312            width: Some(100.0),
313            height: None,
314        });
315
316        let b = tree.bounds(stack);
317        assert!(
318            (b.height - 64.0).abs() < 0.01,
319            "400px of content at 100px wide is four 16px lines; got {} \
320             (16 means the child was only ever measured unbounded)",
321            b.height
322        );
323    }
324
325    /// The guard the second query must not break: a greedy background still contributes
326    /// nothing to the height, because that query pins `height: None`.
327    #[test]
328    fn a_greedy_background_still_does_not_inflate_the_stack() {
329        let mut tree = WidgetTree::new();
330        let bg = tree.add(GreedyLeaf);
331        let content = tree.add(FixedLeaf(40.0, 20.0));
332        let stack = tree.add(ZStack::new().add_child(bg).add_child(content));
333
334        tree.layout(SizeProposal {
335            width: Some(300.0),
336            height: None,
337        });
338
339        let b = tree.bounds(stack);
340        assert!(
341            (b.height - 20.0).abs() < 0.01,
342            "the background must not set the height; got {}",
343            b.height
344        );
345    }
346
347    #[test]
348    fn default_centers_children() {
349        let mut tree = WidgetTree::new();
350        let a = tree.add(FixedLeaf(40.0, 20.0));
351        let _stack = tree.add(ZStack::new().add_child(a));
352        tree.layout(SizeProposal::exact(100.0, 60.0));
353
354        // Root widget gets the full 100x60 proposal bounds.
355        // Child 40x20 centered in 100x60: x=(100-40)/2=30, y=(60-20)/2=20
356        let b = tree.bounds(a);
357        assert!((b.x - 30.0).abs() < 0.01); // centered in 100: (100-40)/2=30
358        assert!((b.y - 20.0).abs() < 0.01); // centered in 60: (60-20)/2=20
359    }
360
361    #[test]
362    fn alignment_top_leading() {
363        let mut tree = WidgetTree::new();
364        let bg = tree.add(FixedLeaf(100.0, 60.0)); // large child sets ZStack size
365        let fg = tree.add(FixedLeaf(40.0, 20.0));
366        let _stack = tree.add(
367            ZStack::new()
368                .alignment(Alignment::TOP_LEADING)
369                .add_child(bg)
370                .add_child(fg),
371        );
372        tree.layout(SizeProposal::exact(200.0, 200.0));
373
374        let b = tree.bounds(fg);
375        assert!((b.x - 0.0).abs() < 0.01);
376        assert!((b.y - 0.0).abs() < 0.01);
377    }
378
379    #[test]
380    fn alignment_bottom_trailing() {
381        let mut tree = WidgetTree::new();
382        let bg = tree.add(FixedLeaf(100.0, 60.0));
383        let fg = tree.add(FixedLeaf(40.0, 20.0));
384        let _stack = tree.add(
385            ZStack::new()
386                .alignment(Alignment::BOTTOM_TRAILING)
387                .add_child(bg)
388                .add_child(fg),
389        );
390        tree.layout(SizeProposal::exact(200.0, 200.0));
391
392        let b = tree.bounds(fg);
393        assert!((b.x - 160.0).abs() < 0.01); // 200-40
394        assert!((b.y - 180.0).abs() < 0.01); // 200-20
395    }
396
397    #[test]
398    fn alignment_center() {
399        let mut tree = WidgetTree::new();
400        let bg = tree.add(FixedLeaf(100.0, 60.0));
401        let fg = tree.add(FixedLeaf(40.0, 20.0));
402        let _stack = tree.add(ZStack::new().add_child(bg).add_child(fg)); // default: center
403        tree.layout(SizeProposal::exact(200.0, 200.0));
404
405        let b = tree.bounds(fg);
406        assert!((b.x - 80.0).abs() < 0.01); // (200-40)/2
407        assert!((b.y - 90.0).abs() < 0.01); // (200-20)/2
408    }
409
410    #[test]
411    fn per_child_alignment_override() {
412        let mut tree = WidgetTree::new();
413        let bg = tree.add(FixedLeaf(100.0, 60.0));
414        let a = tree.add(FixedLeaf(40.0, 20.0));
415        let b = tree.add(FixedLeaf(30.0, 15.0));
416        let _stack = tree.add(
417            ZStack::new()
418                .alignment(Alignment::TOP_LEADING)
419                .add_child(bg)
420                .add_child(a)
421                .add_child(b),
422        );
423        // Override b to bottom-trailing
424        tree.set_alignment(b, Alignment::BOTTOM_TRAILING);
425        tree.layout(SizeProposal::exact(200.0, 200.0));
426
427        let ab = tree.bounds(a);
428        assert!((ab.x - 0.0).abs() < 0.01); // top-leading
429        assert!((ab.y - 0.0).abs() < 0.01);
430
431        let bb = tree.bounds(b);
432        assert!((bb.x - 170.0).abs() < 0.01); // 200-30
433        assert!((bb.y - 185.0).abs() < 0.01); // 200-15
434    }
435
436    #[test]
437    fn size_is_max_of_children() {
438        let mut tree = WidgetTree::new();
439        let a = tree.add(FixedLeaf(40.0, 60.0));
440        let b = tree.add(FixedLeaf(80.0, 30.0));
441        let stack = tree.add(ZStack::new().add_child(a).add_child(b));
442        tree.layout(SizeProposal {
443            width: None,
444            height: None,
445        });
446
447        let sb = tree.bounds(stack);
448        assert!((sb.width - 80.0).abs() < 0.01);
449        assert!((sb.height - 60.0).abs() < 0.01);
450    }
451}