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}