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.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 an inline child widget (deferred insertion).
200 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
201 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
202 self
203 }
204 /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
205 ///
206 /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
207 /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
208 /// adds no arena node, so nothing is laid out, painted, or published to the
209 /// accessibility tree, and a stack applies no spacing around it.
210 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
211 match widget {
212 Some(w) => self.child(w),
213 None => self,
214 }
215 }
216}
217
218impl Default for Expand {
219 fn default() -> Self {
220 Self::new()
221 }
222}
223
224impl Widget for Expand {
225 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
226 if let Some(pending) = self.pending_child.take() {
227 self.child_id = Some(match pending {
228 PendingChild::Id(id) => id,
229 PendingChild::Deferred(w) => ctx.add_boxed(w),
230 });
231 }
232 self.child_id.into_iter().collect()
233 }
234
235 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
236 // Measure against the parent's own proposal, NOT `unspecified()`.
237 //
238 // The two are identical on any axis the parent left open — which is every axis
239 // whose measurement we actually consume below, since each `child_size` read sits
240 // in a `None` arm. What differs is the *other* axis: passing the bound one
241 // through lets a child whose size on one axis depends on the other report the
242 // truth.
243 //
244 // Wrapping text is the case that exposed it. `TextWidget` in `Wrap` mode has no
245 // basis for wrapping without a width, so it measures as a **single line**
246 // (text_widget.rs, the `None => layout_single_line` arm). With `unspecified()`,
247 // an `Expand::horizontal` inside a width-bounded `HStack` therefore reported a
248 // one-line height for a paragraph that paints three — and the parent sized its
249 // chrome to that lie, so the text rendered *outside* its own container. Found in
250 // Skribisto, where a toast body spilled over the status bar.
251 //
252 // Flex-basis semantics are unaffected: `basis_w` / `basis_h` are only read when
253 // the parent left that axis open, so the proposal we forward has it open too.
254 let child_size = self
255 .child_id
256 .and_then(|id| ctx.child_size(id, proposal))
257 .unwrap_or(Size::ZERO);
258
259 // Two separate concerns:
260 //
261 // 1. **Sizing.** Whether we *can* fill an axis depends on whether
262 // the parent bound it. If `proposal.{axis}` is `Some(_)`, the
263 // parent is offering exact space — claim it on every axis,
264 // regardless of `horizontal` / `vertical` (otherwise an
265 // `Expand::vertical` inside a `VStack` would collapse on the
266 // cross axis to its child's intrinsic width). When the parent
267 // leaves an axis open (`None`), we want pure slack on flex
268 // axes (basis 0) or child's natural size as a floor when
269 // `respect_intrinsic` is set.
270 //
271 // 2. **Flex contribution.** A wrapper should only ask for slack
272 // on its *named* axis: `Expand::horizontal()` in a `VStack`
273 // must NOT compete for the VStack's vertical slack, otherwise
274 // a horizontal-fill wrapper would steal vertical space from
275 // siblings. The parent's distributing axis is whichever side
276 // of the proposal it left open. So we report `self.flex` only
277 // when the open axis matches one of our named axes.
278 let basis_w = if self.respect_intrinsic {
279 child_size.width
280 } else {
281 0.0
282 };
283 let basis_h = if self.respect_intrinsic {
284 child_size.height
285 } else {
286 0.0
287 };
288 let w = match proposal.width {
289 Some(pw) => pw,
290 None if self.horizontal => basis_w,
291 None => child_size.width,
292 };
293 let h = match proposal.height {
294 Some(ph) => ph,
295 None if self.vertical => basis_h,
296 None => child_size.height,
297 };
298
299 // Flex axis logic: a parent stack distributes slack on the axis
300 // it left open in the proposal. Only contribute flex on an axis
301 // where (a) the parent is distributing (proposal=None on it),
302 // and (b) we want to expand on that axis. When both axes are
303 // bound or both are unspecified, report the full flex weight —
304 // the value is moot in non-stack contexts and the unspecified
305 // case happens during intrinsic measurement where the caller
306 // wants to know our "would-be" flex.
307 let flex = match (proposal.width, proposal.height) {
308 (None, Some(_)) if !self.horizontal => 0.0,
309 (Some(_), None) if !self.vertical => 0.0,
310 _ => self.flex,
311 };
312
313 LayoutResponse::flexible(Size::new(w, h), flex)
314 }
315
316 fn place_children(
317 &self,
318 bounds: Rect,
319 _proposal: SizeProposal,
320 children: &mut [WidgetPlacement],
321 ctx: &LayoutContext,
322 ) {
323 for child in children.iter_mut() {
324 if let Some(alignment) = self.child_alignment {
325 // Align mode: child takes its natural size, capped by the
326 // slot; we position it. Offering the bounds (instead of an
327 // unbounded `unspecified()`) lets adaptive children respond —
328 // an ellipsis `TextWidget` truncates at the slot width rather
329 // than being placed at its full untruncated line and
330 // overflowing the slot. Rigid children ignore the proposal
331 // and are aligned at their natural size as before.
332 let child_size = ctx
333 .child_size(child.id, SizeProposal::exact(bounds.width, bounds.height))
334 .unwrap_or(bounds.size());
335 let rtl = ctx.is_rtl();
336 let (dx, dy) = alignment.resolve(
337 (child_size.width, child_size.height),
338 (bounds.width, bounds.height),
339 rtl,
340 );
341 child.origin = Point::new(bounds.x + dx, bounds.y + dy);
342 child.size = child_size;
343 } else {
344 // Fill mode (default): child takes the full Expand bounds.
345 child.origin = bounds.origin();
346 child.size = bounds.size();
347 }
348 }
349 }
350
351 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
352
353 fn children(&self) -> Vec<WidgetId> {
354 self.child_id.into_iter().collect()
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use teksilo_core::widget_tree::WidgetTree;
362
363 #[derive(Debug)]
364 struct FixedLeaf(f32, f32);
365 impl Widget for FixedLeaf {
366 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
367 Size::new(self.0, self.1).into()
368 }
369 }
370
371 /// A child whose height depends on the width it is measured at — the shape of every
372 /// wrapping paragraph, and the one `unspecified()` measurement could not see.
373 #[derive(Debug)]
374 struct WrappingLeaf {
375 /// Total width the content needs on one line.
376 natural_width: f32,
377 line_height: f32,
378 }
379 impl Widget for WrappingLeaf {
380 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
381 match proposal.width {
382 // Bounded: wrap into as many lines as it takes.
383 Some(w) if w > 0.0 => {
384 let lines = (self.natural_width / w).ceil().max(1.0);
385 Size::new(w, lines * self.line_height).into()
386 }
387 // Unbounded: exactly what `TextWidget` does in `Wrap` mode — no basis for
388 // wrapping, so report a single line.
389 _ => Size::new(self.natural_width, self.line_height).into(),
390 }
391 }
392 }
393
394 /// `Expand` must measure its child against the parent's own proposal, so a child
395 /// whose height depends on its width reports the height it will really occupy.
396 ///
397 /// Regression: `Expand::layout_response` measured with `SizeProposal::unspecified()`,
398 /// so a wrapping paragraph inside a width-bounded stack reported ONE line while
399 /// painting four. Every container above it sized to the one-line lie and the text
400 /// rendered outside its own chrome — seen in Skribisto as a toast body spilling over
401 /// the status bar.
402 #[test]
403 fn expand_measures_a_width_dependent_child_at_the_width_it_will_get() {
404 let mut tree = WidgetTree::new();
405 let child = tree.add(WrappingLeaf {
406 natural_width: 400.0,
407 line_height: 16.0,
408 });
409 let expand = tree.add(Expand::horizontal().child(child));
410
411 // The parent binds width and leaves height open — exactly what `ToastHost`
412 // proposes to each toast surface.
413 tree.layout(SizeProposal {
414 width: Some(100.0),
415 height: None,
416 });
417
418 let eb = tree.bounds(expand);
419 assert!(
420 (eb.height - 64.0).abs() < 0.01,
421 "400px of content at 100px wide is four 16px lines; \
422 got {} (a one-line answer means the bound width was discarded)",
423 eb.height
424 );
425 }
426
427 /// The companion property: on an axis the parent left open, the measurement is
428 /// unchanged — the forwarded proposal has that axis open too, so flex-basis and
429 /// shrink-wrap semantics are exactly what they were.
430 #[test]
431 fn expand_still_shrink_wraps_on_a_fully_open_proposal() {
432 let mut tree = WidgetTree::new();
433 let child = tree.add(WrappingLeaf {
434 natural_width: 400.0,
435 line_height: 16.0,
436 });
437 let expand = tree.add(Expand::horizontal().child(child));
438
439 tree.layout(SizeProposal::unspecified());
440
441 let eb = tree.bounds(expand);
442 assert!(
443 (eb.height - 16.0).abs() < 0.01,
444 "with no width to wrap at, the child's single-line height stands (got {})",
445 eb.height
446 );
447 }
448
449 #[test]
450 fn expand_at_root_fills_proposal() {
451 // At the tree root, the proposal IS the bounds. Expand claims it
452 // and fills its child to those bounds.
453 let mut tree = WidgetTree::new();
454 let child = tree.add(FixedLeaf(40.0, 20.0));
455 let expand = tree.add(Expand::new().child(child));
456 tree.layout(SizeProposal::exact(200.0, 100.0));
457
458 let eb = tree.bounds(expand);
459 assert!((eb.width - 200.0).abs() < 0.01);
460 assert!((eb.height - 100.0).abs() < 0.01);
461
462 // Default fill mode: child stretches to full Expand bounds.
463 let cb = tree.bounds(child);
464 assert!((cb.width - 200.0).abs() < 0.01);
465 assert!((cb.height - 100.0).abs() < 0.01);
466 }
467
468 #[test]
469 fn align_child_top_trailing() {
470 let mut tree = WidgetTree::new();
471 let child = tree.add(FixedLeaf(40.0, 20.0));
472 let _expand = tree.add(
473 Expand::new()
474 .align_child(Alignment::TOP_TRAILING)
475 .child(child),
476 );
477 tree.layout(SizeProposal::exact(200.0, 100.0));
478
479 let cb = tree.bounds(child);
480 // Child stays at natural 40x20, placed top-trailing.
481 assert!((cb.width - 40.0).abs() < 0.01);
482 assert!((cb.height - 20.0).abs() < 0.01);
483 assert!((cb.x - 160.0).abs() < 0.01); // 200 - 40
484 assert!((cb.y - 0.0).abs() < 0.01); // top
485 }
486
487 #[test]
488 fn flex_default_is_one() {
489 let theme = teksilo_core::presets::intui::light();
490 let ctx = LayoutContext::for_testing(&theme);
491 let r = Expand::new().layout_response(SizeProposal::unspecified(), &ctx);
492 assert_eq!(r.flex, 1.0);
493 }
494
495 #[test]
496 fn flex_zero_opts_out() {
497 let theme = teksilo_core::presets::intui::light();
498 let ctx = LayoutContext::for_testing(&theme);
499 let r = Expand::new()
500 .flex(0.0)
501 .layout_response(SizeProposal::unspecified(), &ctx);
502 assert_eq!(r.flex, 0.0);
503 }
504}