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