Skip to main content

teksilo_widgets/primitives/
expand.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Expand — a layout modifier that claims slack space in a stack and
5//! stretches its child to fill the allocated bounds.
6//!
7//! Inside an [`HStack`](crate::primitives::HStack) or
8//! [`VStack`](crate::primitives::VStack), `Expand` participates in the flex
9//! distribution pass by reporting a non-zero `flex` weight (default `1.0`).
10//! The parent stack distributes leftover space proportionally to each child's
11//! flex weight. `Expand::new()` competes on **both axes**;
12//! `Expand::horizontal()` and `Expand::vertical()` restrict competition to
13//! the named axis so they do not accidentally steal slack from orthogonal
14//! siblings. By default the wrapped child is stretched to the full allocated
15//! rectangle; call `.align_child(alignment)` to keep the child at its natural
16//! size and align it within the slot instead.
17//!
18//! The default flex basis is **zero** (CSS `flex-basis: 0`), giving exact
19//! proportional ratios. Call `.respect_intrinsic()` to switch to **auto**
20//! basis where the child's natural size acts as a floor before flex slack is
21//! added.
22//!
23//! ```rust
24//! # use teksilo_widgets::primitives::{HStack, Expand, RectWidget};
25//! // Two panels sharing horizontal space in a 1:2 ratio
26//! let _row = HStack::new()
27//!     .child(Expand::new().flex(1.0).child(RectWidget::new()))
28//!     .child(Expand::new().flex(2.0).child(RectWidget::new()));
29//! ```
30
31use teksilo_canvas::{Point, Rect, Size, SizeProposal};
32use teksilo_core::widget::{
33    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
34};
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::Alignment;
37
38/// Layout modifier that claims space along one or both axes from its parent
39/// and stretches its child to fill it.
40///
41/// In an `HStack` / `VStack`, `Expand` participates in flex slack
42/// distribution: it returns a `LayoutResponse` with `flex` (default `1.0`),
43/// so the parent stack hands it a share of the leftover space proportional
44/// to flex. Default basis is **zero** — the wrapped child's natural size
45/// does NOT count in the rigid pool, which gives clean ratio layouts. Call
46/// [`Expand::respect_intrinsic`] to switch to **auto** basis (CSS
47/// flex-basis: auto), where the child's natural size acts as a floor and
48/// flex adds slack on top.
49///
50/// `Expand::new()` is the common case: claim space, fill the child.
51/// Use `.flex(n)` to change the ratio (e.g. 1:2 by pairing `flex(1)` with
52/// `flex(2)`). Use `.align_child(...)` to opt out of fill and align the
53/// child at its natural size within the claimed bounds.
54///
55/// **`horizontal()` / `vertical()` semantics.** The named axis is the one
56/// the wrapper *competes for slack on*. Both sizing and flex behavior
57/// follow from that:
58///
59/// - **Sizing:** when the parent binds an axis (`proposal.{axis} = Some`),
60///   the wrapper claims that axis regardless of its name. So
61///   `Expand::vertical(child)` inside a `VStack` (which binds width and
62///   leaves height open) fills the VStack's full width AND distributes
63///   vertical slack via flex. Cross-axis collapse to child intrinsic only
64///   happens when the parent left that axis open too.
65///
66/// - **Flex contribution:** the wrapper reports its `flex` weight only on
67///   axes the parent is distributing (i.e. left open). `Expand::horizontal()`
68///   inside a `VStack` reports `flex = 0` on the open vertical axis, so it
69///   does NOT compete for vertical slack with siblings — it just claims
70///   the cross-axis width and sits at its child's intrinsic height. Symmetric
71///   for `Expand::vertical()` inside an `HStack`.
72#[derive(Debug)]
73pub struct Expand {
74    child_id: Option<WidgetId>,
75    pending_child: Option<PendingChild>,
76    horizontal: bool,
77    vertical: bool,
78    flex: f32,
79    /// When `Some`, the child is laid out at its natural size and aligned;
80    /// when `None`, the child is stretched to the full Expand bounds.
81    child_alignment: Option<Alignment>,
82    /// When `true`, the wrapped child's natural size acts as a floor on the
83    /// flex axis (CSS flex-basis: auto). When `false` (default), the
84    /// wanted size on flex axes is `0` so the parent stack divides bounds
85    /// purely by flex weight (CSS flex-basis: 0).
86    respect_intrinsic: bool,
87}
88
89impl Expand {
90    /// Expand on both axes. Default `flex(1)`, child fills bounds.
91    pub fn new() -> Self {
92        Self {
93            child_id: None,
94            pending_child: None,
95            horizontal: true,
96            vertical: true,
97            flex: 1.0,
98            child_alignment: None,
99            respect_intrinsic: false,
100        }
101    }
102
103    /// Compete for slack on the horizontal axis only. Inside an `HStack`,
104    /// distributes flex on width while claiming bound height as-is. Inside
105    /// a `VStack` (which binds width and distributes height), claims the
106    /// VStack's full width but reports `flex = 0` so it doesn't steal
107    /// vertical slack from siblings — height stays at child intrinsic.
108    pub fn horizontal() -> Self {
109        Self {
110            child_id: None,
111            pending_child: None,
112            horizontal: true,
113            vertical: false,
114            flex: 1.0,
115            child_alignment: None,
116            respect_intrinsic: false,
117        }
118    }
119
120    /// Compete for slack on the vertical axis only. Inside a `VStack`,
121    /// distributes flex on height while claiming bound width as-is. Inside
122    /// an `HStack` (which binds height and distributes width), claims the
123    /// HStack's full height but reports `flex = 0` so it doesn't steal
124    /// horizontal slack from siblings — width stays at child intrinsic.
125    pub fn vertical() -> Self {
126        Self {
127            child_id: None,
128            pending_child: None,
129            horizontal: false,
130            vertical: true,
131            flex: 1.0,
132            child_alignment: None,
133            respect_intrinsic: false,
134        }
135    }
136
137    /// Override the flex weight reported to a parent stack. `flex(0)` opts
138    /// out of slack distribution (the wrapper still claims any offered
139    /// proposal, useful inside non-stack containers). Default: `1.0`.
140    pub fn flex(mut self, flex: f32) -> Self {
141        self.flex = flex.max(0.0);
142        self
143    }
144
145    /// Opt out of stretching the child. The child is laid out at its
146    /// natural size and positioned within the Expand's bounds according
147    /// to `alignment`.
148    pub fn align_child(mut self, alignment: Alignment) -> Self {
149        self.child_alignment = Some(alignment);
150        self
151    }
152
153    /// Switch to **auto** flex basis — the wrapped child's natural size
154    /// acts as a floor on each flex axis, and the parent stack adds slack
155    /// on top via the flex weight. Useful when the wrapper sits inside an
156    /// unconstrained parent (e.g. an outer `VStack` with `height = None`),
157    /// where the default zero-basis would let the child overflow because
158    /// the parent has no bound to share.
159    ///
160    /// Trade-off: with `respect_intrinsic`, exact ratios bend by content
161    /// width — `[Expand::flex(1).child(60), Expand::flex(2).child(40)]` in
162    /// 300 px gives `60 + 66 = 126` and `40 + 133 = 173` rather than
163    /// `100 / 200`. Without it (the default), the same layout splits
164    /// exactly `100 / 200`.
165    ///
166    /// # Do not use this inside a *bounded* parent
167    ///
168    /// The floor is a hard one: `Expand` reports `shrink = 0`, so if the
169    /// child's natural size exceeds what the parent can offer, the resulting
170    /// over-constraint deficit **cannot be absorbed** and later siblings are
171    /// pushed outside the bounds.
172    ///
173    /// This bites hardest with children whose natural size is large and
174    /// content-driven. A vertical [`TabBar`](crate::TabBar)
175    /// answers an unbounded height query with its *stacked* height — every tab,
176    /// one below another. So:
177    ///
178    /// ```ignore
179    /// // 21 tabs => the bar's natural height is ~1050 dp.
180    /// VStack::new()
181    ///     .child(Expand::vertical().respect_intrinsic().child(tab_widget))
182    ///     .child(status_bar)
183    /// ```
184    ///
185    /// makes the `VStack` want `1050 + status_bar`, at *every* window size. The
186    /// status bar is placed at y=1050 and stays below the fold until the window
187    /// is grown past it — the bar never scrolls, because it was never asked to
188    /// fit. Dropping `respect_intrinsic()` fixes it: the bar takes the slack
189    /// left after the status bar and scrolls its tabs internally.
190    ///
191    /// Rule of thumb: reach for this only when the parent genuinely has no
192    /// bound to share (`height = None`). When the parent is bounded — a window
193    /// root, a sized pane — the default zero basis is what you want.
194    pub fn respect_intrinsic(mut self) -> Self {
195        self.respect_intrinsic = true;
196        self
197    }
198
199    /// Set child by pre-registered ID.
200    pub fn child_id(mut self, id: WidgetId) -> Self {
201        self.pending_child = Some(PendingChild::Id(id));
202        self
203    }
204
205    /// Set an inline child widget (deferred insertion).
206    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
207        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
208        self
209    }
210}
211
212impl Default for Expand {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl Widget for Expand {
219    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
220        if let Some(pending) = self.pending_child.take() {
221            self.child_id = Some(match pending {
222                PendingChild::Id(id) => id,
223                PendingChild::Deferred(w) => ctx.add_boxed(w),
224            });
225        }
226        self.child_id.into_iter().collect()
227    }
228
229    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
230        // Measure against the parent's own proposal, NOT `unspecified()`.
231        //
232        // The two are identical on any axis the parent left open — which is every axis
233        // whose measurement we actually consume below, since each `child_size` read sits
234        // in a `None` arm. What differs is the *other* axis: passing the bound one
235        // through lets a child whose size on one axis depends on the other report the
236        // truth.
237        //
238        // Wrapping text is the case that exposed it. `TextWidget` in `Wrap` mode has no
239        // basis for wrapping without a width, so it measures as a **single line**
240        // (text_widget.rs, the `None => layout_single_line` arm). With `unspecified()`,
241        // an `Expand::horizontal` inside a width-bounded `HStack` therefore reported a
242        // one-line height for a paragraph that paints three — and the parent sized its
243        // chrome to that lie, so the text rendered *outside* its own container. Found in
244        // Skribisto, where a toast body spilled over the status bar.
245        //
246        // Flex-basis semantics are unaffected: `basis_w` / `basis_h` are only read when
247        // the parent left that axis open, so the proposal we forward has it open too.
248        let child_size = self
249            .child_id
250            .and_then(|id| ctx.child_size(id, proposal))
251            .unwrap_or(Size::ZERO);
252
253        // Two separate concerns:
254        //
255        // 1. **Sizing.** Whether we *can* fill an axis depends on whether
256        //    the parent bound it. If `proposal.{axis}` is `Some(_)`, the
257        //    parent is offering exact space — claim it on every axis,
258        //    regardless of `horizontal` / `vertical` (otherwise an
259        //    `Expand::vertical` inside a `VStack` would collapse on the
260        //    cross axis to its child's intrinsic width). When the parent
261        //    leaves an axis open (`None`), we want pure slack on flex
262        //    axes (basis 0) or child's natural size as a floor when
263        //    `respect_intrinsic` is set.
264        //
265        // 2. **Flex contribution.** A wrapper should only ask for slack
266        //    on its *named* axis: `Expand::horizontal()` in a `VStack`
267        //    must NOT compete for the VStack's vertical slack, otherwise
268        //    a horizontal-fill wrapper would steal vertical space from
269        //    siblings. The parent's distributing axis is whichever side
270        //    of the proposal it left open. So we report `self.flex` only
271        //    when the open axis matches one of our named axes.
272        let basis_w = if self.respect_intrinsic {
273            child_size.width
274        } else {
275            0.0
276        };
277        let basis_h = if self.respect_intrinsic {
278            child_size.height
279        } else {
280            0.0
281        };
282        let w = match proposal.width {
283            Some(pw) => pw,
284            None if self.horizontal => basis_w,
285            None => child_size.width,
286        };
287        let h = match proposal.height {
288            Some(ph) => ph,
289            None if self.vertical => basis_h,
290            None => child_size.height,
291        };
292
293        // Flex axis logic: a parent stack distributes slack on the axis
294        // it left open in the proposal. Only contribute flex on an axis
295        // where (a) the parent is distributing (proposal=None on it),
296        // and (b) we want to expand on that axis. When both axes are
297        // bound or both are unspecified, report the full flex weight —
298        // the value is moot in non-stack contexts and the unspecified
299        // case happens during intrinsic measurement where the caller
300        // wants to know our "would-be" flex.
301        let flex = match (proposal.width, proposal.height) {
302            (None, Some(_)) if !self.horizontal => 0.0,
303            (Some(_), None) if !self.vertical => 0.0,
304            _ => self.flex,
305        };
306
307        LayoutResponse::flexible(Size::new(w, h), flex)
308    }
309
310    fn place_children(
311        &self,
312        bounds: Rect,
313        _proposal: SizeProposal,
314        children: &mut [WidgetPlacement],
315        ctx: &LayoutContext,
316    ) {
317        for child in children.iter_mut() {
318            if let Some(alignment) = self.child_alignment {
319                // Align mode: child takes its natural size, capped by the
320                // slot; we position it. Offering the bounds (instead of an
321                // unbounded `unspecified()`) lets adaptive children respond —
322                // an ellipsis `TextWidget` truncates at the slot width rather
323                // than being placed at its full untruncated line and
324                // overflowing the slot. Rigid children ignore the proposal
325                // and are aligned at their natural size as before.
326                let child_size = ctx
327                    .child_size(child.id, SizeProposal::exact(bounds.width, bounds.height))
328                    .unwrap_or(bounds.size());
329                let rtl = ctx.is_rtl();
330                let (dx, dy) = alignment.resolve(
331                    (child_size.width, child_size.height),
332                    (bounds.width, bounds.height),
333                    rtl,
334                );
335                child.origin = Point::new(bounds.x + dx, bounds.y + dy);
336                child.size = child_size;
337            } else {
338                // Fill mode (default): child takes the full Expand bounds.
339                child.origin = bounds.origin();
340                child.size = bounds.size();
341            }
342        }
343    }
344
345    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
346
347    fn children(&self) -> Vec<WidgetId> {
348        self.child_id.into_iter().collect()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use teksilo_core::widget_tree::WidgetTree;
356
357    #[derive(Debug)]
358    struct FixedLeaf(f32, f32);
359    impl Widget for FixedLeaf {
360        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
361            Size::new(self.0, self.1).into()
362        }
363    }
364
365    /// A child whose height depends on the width it is measured at — the shape of every
366    /// wrapping paragraph, and the one `unspecified()` measurement could not see.
367    #[derive(Debug)]
368    struct WrappingLeaf {
369        /// Total width the content needs on one line.
370        natural_width: f32,
371        line_height: f32,
372    }
373    impl Widget for WrappingLeaf {
374        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
375            match proposal.width {
376                // Bounded: wrap into as many lines as it takes.
377                Some(w) if w > 0.0 => {
378                    let lines = (self.natural_width / w).ceil().max(1.0);
379                    Size::new(w, lines * self.line_height).into()
380                }
381                // Unbounded: exactly what `TextWidget` does in `Wrap` mode — no basis for
382                // wrapping, so report a single line.
383                _ => Size::new(self.natural_width, self.line_height).into(),
384            }
385        }
386    }
387
388    /// `Expand` must measure its child against the parent's own proposal, so a child
389    /// whose height depends on its width reports the height it will really occupy.
390    ///
391    /// Regression: `Expand::layout_response` measured with `SizeProposal::unspecified()`,
392    /// so a wrapping paragraph inside a width-bounded stack reported ONE line while
393    /// painting four. Every container above it sized to the one-line lie and the text
394    /// rendered outside its own chrome — seen in Skribisto as a toast body spilling over
395    /// the status bar.
396    #[test]
397    fn expand_measures_a_width_dependent_child_at_the_width_it_will_get() {
398        let mut tree = WidgetTree::new();
399        let child = tree.add(WrappingLeaf {
400            natural_width: 400.0,
401            line_height: 16.0,
402        });
403        let expand = tree.add(Expand::horizontal().child_id(child));
404
405        // The parent binds width and leaves height open — exactly what `ToastHost`
406        // proposes to each toast surface.
407        tree.layout(SizeProposal {
408            width: Some(100.0),
409            height: None,
410        });
411
412        let eb = tree.bounds(expand);
413        assert!(
414            (eb.height - 64.0).abs() < 0.01,
415            "400px of content at 100px wide is four 16px lines; \
416             got {} (a one-line answer means the bound width was discarded)",
417            eb.height
418        );
419    }
420
421    /// The companion property: on an axis the parent left open, the measurement is
422    /// unchanged — the forwarded proposal has that axis open too, so flex-basis and
423    /// shrink-wrap semantics are exactly what they were.
424    #[test]
425    fn expand_still_shrink_wraps_on_a_fully_open_proposal() {
426        let mut tree = WidgetTree::new();
427        let child = tree.add(WrappingLeaf {
428            natural_width: 400.0,
429            line_height: 16.0,
430        });
431        let expand = tree.add(Expand::horizontal().child_id(child));
432
433        tree.layout(SizeProposal::unspecified());
434
435        let eb = tree.bounds(expand);
436        assert!(
437            (eb.height - 16.0).abs() < 0.01,
438            "with no width to wrap at, the child's single-line height stands (got {})",
439            eb.height
440        );
441    }
442
443    #[test]
444    fn expand_at_root_fills_proposal() {
445        // At the tree root, the proposal IS the bounds. Expand claims it
446        // and fills its child to those bounds.
447        let mut tree = WidgetTree::new();
448        let child = tree.add(FixedLeaf(40.0, 20.0));
449        let expand = tree.add(Expand::new().child_id(child));
450        tree.layout(SizeProposal::exact(200.0, 100.0));
451
452        let eb = tree.bounds(expand);
453        assert!((eb.width - 200.0).abs() < 0.01);
454        assert!((eb.height - 100.0).abs() < 0.01);
455
456        // Default fill mode: child stretches to full Expand bounds.
457        let cb = tree.bounds(child);
458        assert!((cb.width - 200.0).abs() < 0.01);
459        assert!((cb.height - 100.0).abs() < 0.01);
460    }
461
462    #[test]
463    fn align_child_top_trailing() {
464        let mut tree = WidgetTree::new();
465        let child = tree.add(FixedLeaf(40.0, 20.0));
466        let _expand = tree.add(
467            Expand::new()
468                .align_child(Alignment::TOP_TRAILING)
469                .child_id(child),
470        );
471        tree.layout(SizeProposal::exact(200.0, 100.0));
472
473        let cb = tree.bounds(child);
474        // Child stays at natural 40x20, placed top-trailing.
475        assert!((cb.width - 40.0).abs() < 0.01);
476        assert!((cb.height - 20.0).abs() < 0.01);
477        assert!((cb.x - 160.0).abs() < 0.01); // 200 - 40
478        assert!((cb.y - 0.0).abs() < 0.01); // top
479    }
480
481    #[test]
482    fn flex_default_is_one() {
483        let theme = teksilo_core::presets::intui::light();
484        let ctx = LayoutContext::for_testing(&theme);
485        let r = Expand::new().layout_response(SizeProposal::unspecified(), &ctx);
486        assert_eq!(r.flex, 1.0);
487    }
488
489    #[test]
490    fn flex_zero_opts_out() {
491        let theme = teksilo_core::presets::intui::light();
492        let ctx = LayoutContext::for_testing(&theme);
493        let r = Expand::new()
494            .flex(0.0)
495            .layout_response(SizeProposal::unspecified(), &ctx);
496        assert_eq!(r.flex, 0.0);
497    }
498}