Skip to main content

teksilo_widgets/primitives/
vstack.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! VStack — a vertical layout container that distributes children top-to-bottom.
5//!
6//! Each child is offered the full container width and its intrinsic preferred
7//! height.  Positive slack (container height minus the sum of children heights
8//! minus spacing) is distributed among children that declare a non-zero `flex`
9//! weight (e.g. [`Expand`](crate::primitives::Expand)).  Over-constraint
10//! deficits are absorbed by children with a non-zero `shrink` weight.
11//! Cross-axis (horizontal) alignment defaults to `Leading` and can be
12//! overridden per container with [`VStack::alignment`] or per child via
13//! `WidgetTree::set_alignment`.
14//!
15//! Use `VStack` when children should be stacked vertically with a configurable
16//! gap; use [`HStack`](crate::primitives::HStack) for the horizontal
17//! counterpart.
18//!
19//! ```rust
20//! # use teksilo_widgets::primitives::{VStack, TextWidget};
21//! # use teksilo_i18n::lit;
22//! let _col = VStack::new()
23//!     .spacing(8.0)
24//!     .child(TextWidget::new(lit!("Heading")))
25//!     .child(TextWidget::new(lit!("Body text")));
26//! ```
27
28use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::signal::Prop;
31use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
32use teksilo_core::widget_id::WidgetId;
33use teksilo_tokens::HAlignment;
34
35use crate::primitives::linear_layout::{self, Axis};
36
37/// Vertical layout container that distributes children top-to-bottom
38/// based on their intrinsic sizes. Cross-axis alignment is controlled
39/// by `HAlignment` (default: `Leading`).
40#[derive(Debug)]
41pub struct VStack {
42    child_ids: Vec<WidgetId>,
43    pending: Vec<PendingChild>,
44    spacing: Prop<f32>,
45    alignment: HAlignment,
46}
47
48impl VStack {
49    /// Create an empty vertical stack with `Leading` alignment and zero spacing.
50    pub fn new() -> Self {
51        Self {
52            child_ids: Vec::new(),
53            pending: Vec::new(),
54            spacing: Prop::Static(0.0),
55            alignment: HAlignment::Leading,
56        }
57    }
58
59    /// Set inter-child spacing. Accepts a static `f32` or a reactive
60    /// `Signal<f32>`.
61    pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
62        self.spacing = spacing.into();
63        self
64    }
65
66    /// Set the cross-axis (horizontal) alignment applied to every child that
67    /// does not have a per-child override set via `WidgetTree::set_alignment`.
68    pub fn alignment(mut self, alignment: HAlignment) -> Self {
69        self.alignment = alignment;
70        self
71    }
72
73    /// Add a pre-registered child by ID.
74    pub fn add_child(mut self, id: WidgetId) -> Self {
75        self.pending.push(PendingChild::Id(id));
76        self
77    }
78
79    /// Add an inline child widget (deferred insertion).
80    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
81        self.pending.push(PendingChild::Deferred(Box::new(widget)));
82        self
83    }
84
85    /// Add multiple inline children from an iterator.
86    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
87        for widget in iter {
88            self.pending.push(PendingChild::Deferred(Box::new(widget)));
89        }
90        self
91    }
92
93    /// Conditionally add a child. No-op if None.
94    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
95        if let Some(w) = widget {
96            self.pending.push(PendingChild::Deferred(Box::new(w)));
97        }
98        self
99    }
100}
101
102impl Default for VStack {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Widget for VStack {
109    fn layout_response(
110        &self,
111        proposal: SizeProposal,
112        ctx: &LayoutContext,
113    ) -> teksilo_core::widget::LayoutResponse {
114        if self.child_ids.is_empty() {
115            return proposal.resolve(0.0, 0.0).into();
116        }
117        // Main-then-cross negotiation along the vertical axis: grow on surplus
118        // (flex), shrink on a deficit (shrink/min), and measure each child's
119        // width at its final height. See [`linear_layout`].
120        let neg = linear_layout::negotiate(
121            &self.child_ids,
122            ctx,
123            proposal.height,
124            proposal.width,
125            self.spacing.get(),
126            Axis::Vertical,
127        );
128        linear_layout::response(&neg)
129    }
130
131    fn place_children(
132        &self,
133        bounds: Rect,
134        _proposal: SizeProposal,
135        children: &mut [WidgetPlacement],
136        ctx: &LayoutContext,
137    ) {
138        if children.is_empty() {
139            return;
140        }
141
142        let ids: Vec<WidgetId> = children.iter().map(|c| c.id).collect();
143        let neg = linear_layout::negotiate(
144            &ids,
145            ctx,
146            Some(bounds.height),
147            Some(bounds.width),
148            self.spacing.get(),
149            Axis::Vertical,
150        );
151        // For the vertical axis, `main` is height and `cross` is width.
152        let heights = &neg.children.main;
153        let widths = &neg.children.cross;
154
155        // Place children top-to-bottom with cross-axis (horizontal) alignment.
156        let spacing = self.spacing.get();
157        let rtl = ctx.is_rtl();
158        let mut y = bounds.y;
159        for (i, child) in children.iter_mut().enumerate() {
160            let w = widths[i];
161            let h = heights[i];
162
163            let halign = ctx
164                .child_alignment(child.id)
165                .map(|a| a.horizontal)
166                .unwrap_or(self.alignment);
167            let x_offset = halign.resolve(w, bounds.width, rtl);
168
169            child.origin = Point::new(bounds.x + x_offset, y);
170            child.size = Size::new(w, h);
171            y += h + spacing;
172        }
173    }
174
175    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
176
177    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
178        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
179    }
180
181    fn children(&self) -> Vec<WidgetId> {
182        self.child_ids.clone()
183    }
184
185    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
186        let pending = std::mem::take(&mut self.pending);
187        if !pending.is_empty() {
188            self.child_ids = pending
189                .into_iter()
190                .map(|child| match child {
191                    PendingChild::Id(id) => id,
192                    PendingChild::Deferred(w) => ctx.add_boxed(w),
193                })
194                .collect();
195        }
196        let self_id = ctx.self_id();
197        let registry = ctx.binding_registry();
198        self.spacing.register_if_bound(
199            self_id,
200            registry,
201            teksilo_core::binding::BindingLevel::Relayout,
202        );
203        self.child_ids.clone()
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use teksilo_core::widget_tree::WidgetTree;
211
212    /// A leaf that always reports a fixed intrinsic size.
213    #[derive(Debug)]
214    struct FixedLeaf(f32, f32);
215    impl Widget for FixedLeaf {
216        fn layout_response(
217            &self,
218            _proposal: SizeProposal,
219            _ctx: &LayoutContext,
220        ) -> teksilo_core::widget::LayoutResponse {
221            Size::new(self.0, self.1).into()
222        }
223    }
224
225    #[test]
226    fn children_get_intrinsic_heights() {
227        let mut tree = WidgetTree::new();
228        let a = tree.add(FixedLeaf(80.0, 30.0));
229        let b = tree.add(FixedLeaf(60.0, 50.0));
230        let _stack = tree.add(VStack::new().add_child(a).add_child(b));
231        tree.layout(SizeProposal::exact(200.0, 300.0));
232
233        assert!((tree.bounds(a).height - 30.0).abs() < 0.01);
234        assert!((tree.bounds(b).height - 50.0).abs() < 0.01);
235        assert!((tree.bounds(b).y - 30.0).abs() < 0.01);
236    }
237
238    #[test]
239    fn nested_vstack_with_content_carrying_expand_reports_full_height() {
240        // Regression for the TabWidget "tabs half-visible behind switcher"
241        // bug. An inner VStack contains [leaf(32),
242        // Expand::vertical().respect_intrinsic().child(leaf(200))]. Without
243        // `respect_intrinsic`, the Expand wants 0 on the flex axis (clean
244        // ratios), and an unconstrained outer parent would squash the inner
245        // VStack to 32 dp and the wrapped content would overflow. Auto-basis
246        // makes the Expand report the child's natural size as a floor, so
247        // the inner stack honestly reports 232.
248        use crate::primitives::expand::Expand;
249
250        let mut tree = WidgetTree::new();
251        let tab_bar = tree.add(FixedLeaf(120.0, 32.0));
252        let content = tree.add(FixedLeaf(120.0, 200.0));
253        let filled = tree.add(Expand::vertical().respect_intrinsic().child_id(content));
254        let inner = tree.add(VStack::new().add_child(tab_bar).add_child(filled));
255
256        // Outer VStack with another sibling underneath. Height is
257        // unconstrained so the outer has to fall back to intrinsic sizes.
258        let sibling = tree.add(FixedLeaf(120.0, 40.0));
259        let outer = tree.add(VStack::new().add_child(inner).add_child(sibling));
260        tree.layout(SizeProposal {
261            width: Some(400.0),
262            height: None,
263        });
264
265        // Inner stack should report 32 + 200 = 232, not 32.
266        let inner_bounds = tree.bounds(inner);
267        assert!(
268            (inner_bounds.height - 232.0).abs() < 0.01,
269            "inner VStack height should include the content-carrying Expand, got {}",
270            inner_bounds.height,
271        );
272
273        // Sibling must sit BELOW the inner stack's full height, not overlap
274        // it.
275        let sibling_bounds = tree.bounds(sibling);
276        assert!(
277            sibling_bounds.y >= inner_bounds.bottom() - 0.01,
278            "sibling should be placed below the inner stack; \
279             inner bottom {}, sibling y {}",
280            inner_bounds.bottom(),
281            sibling_bounds.y,
282        );
283
284        // And the tab_bar should live at the TOP of the inner stack, with
285        // the content filling the remaining ~200 dp below it.
286        assert!((tree.bounds(tab_bar).y - inner_bounds.y).abs() < 0.01);
287        let filled_bounds = tree.bounds(filled);
288        assert!(filled_bounds.y >= inner_bounds.y + 32.0 - 0.01);
289
290        // Outer container also behaves: its full height is 232 + 40 = 272.
291        let outer_bounds = tree.bounds(outer);
292        assert!(
293            (outer_bounds.height - 272.0).abs() < 0.01,
294            "outer VStack height got {}, expected 272",
295            outer_bounds.height,
296        );
297    }
298
299    #[test]
300    fn spacing_between_children() {
301        let mut tree = WidgetTree::new();
302        let a = tree.add(FixedLeaf(80.0, 40.0));
303        let b = tree.add(FixedLeaf(80.0, 40.0));
304        let _stack = tree.add(VStack::new().spacing(10.0).add_child(a).add_child(b));
305        tree.layout(SizeProposal::exact(200.0, 300.0));
306
307        assert!((tree.bounds(b).y - 50.0).abs() < 0.01); // 40 + 10
308    }
309
310    #[test]
311    fn horizontal_flex_does_not_leak_into_vertical_growth() {
312        // Regression: an HStack with a horizontal Spacer (flex=1) nested in a
313        // VStack with vertical slack must NOT grow vertically. `flex` is an
314        // axis-agnostic scalar; a stack only advertises it on its own main axis.
315        use crate::primitives::hstack::HStack;
316        use crate::primitives::spacer::Spacer;
317
318        let mut tree = WidgetTree::new();
319        let row = tree.add(
320            HStack::new()
321                .child(FixedLeaf(40.0, 30.0))
322                .child(Spacer::new())
323                .child(FixedLeaf(40.0, 30.0)),
324        );
325        let _col = tree.add(VStack::new().add_child(row));
326        tree.layout(SizeProposal::exact(400.0, 500.0)); // 500 ≫ 30 → lots of vertical slack
327
328        // The row keeps its 30px content height; it does NOT stretch to 500.
329        assert!(
330            (tree.bounds(row).height - 30.0).abs() < 0.01,
331            "row should stay at content height 30, got {}",
332            tree.bounds(row).height
333        );
334    }
335
336    #[test]
337    fn cross_axis_leading_alignment_ltr() {
338        let mut tree = WidgetTree::new();
339        let a = tree.add(FixedLeaf(80.0, 30.0));
340        let _stack = tree.add(VStack::new().add_child(a)); // default: Leading
341        tree.layout(SizeProposal::exact(200.0, 300.0));
342
343        assert!((tree.bounds(a).x - 0.0).abs() < 0.01); // Leading = left in LTR
344    }
345
346    #[test]
347    fn cross_axis_center_alignment() {
348        let mut tree = WidgetTree::new();
349        let a = tree.add(FixedLeaf(80.0, 30.0));
350        let _stack = tree.add(VStack::new().alignment(HAlignment::Center).add_child(a));
351        tree.layout(SizeProposal::exact(200.0, 300.0));
352
353        assert!((tree.bounds(a).x - 60.0).abs() < 0.01); // (200-80)/2
354    }
355
356    #[test]
357    fn cross_axis_trailing_alignment() {
358        let mut tree = WidgetTree::new();
359        let a = tree.add(FixedLeaf(80.0, 30.0));
360        let _stack = tree.add(VStack::new().alignment(HAlignment::Trailing).add_child(a));
361        tree.layout(SizeProposal::exact(200.0, 300.0));
362
363        assert!((tree.bounds(a).x - 120.0).abs() < 0.01); // 200 - 80
364    }
365
366    #[test]
367    fn per_child_alignment_override() {
368        let mut tree = WidgetTree::new();
369        let a = tree.add(FixedLeaf(80.0, 30.0));
370        let b = tree.add(FixedLeaf(60.0, 30.0));
371        let _stack = tree.add(VStack::new().add_child(a).add_child(b)); // default: Leading
372        tree.set_alignment(
373            b,
374            teksilo_tokens::Alignment {
375                horizontal: teksilo_tokens::HAlignment::Trailing,
376                vertical: teksilo_tokens::VAlignment::Center,
377            },
378        );
379        tree.layout(SizeProposal::exact(200.0, 300.0));
380
381        assert!((tree.bounds(a).x - 0.0).abs() < 0.01); // Leading
382        assert!((tree.bounds(b).x - 140.0).abs() < 0.01); // Trailing: 200-60
383    }
384
385    #[test]
386    fn empty_vstack() {
387        let mut tree = WidgetTree::new();
388        let _stack = tree.add(VStack::new());
389        tree.layout(SizeProposal::exact(200.0, 50.0));
390    }
391
392    // --- Inline builder API tests ---
393
394    #[test]
395    fn child_inline_resolves_layout() {
396        let mut tree = WidgetTree::new();
397        let stack = tree.add(
398            VStack::new()
399                .child(FixedLeaf(80.0, 30.0))
400                .child(FixedLeaf(60.0, 50.0)),
401        );
402        tree.layout(SizeProposal::exact(200.0, 300.0));
403
404        let kids = tree.children(stack);
405        assert_eq!(kids.len(), 2);
406        assert!((tree.bounds(kids[0]).height - 30.0).abs() < 0.01);
407        assert!((tree.bounds(kids[1]).height - 50.0).abs() < 0.01);
408        assert!((tree.bounds(kids[1]).y - 30.0).abs() < 0.01);
409    }
410
411    #[test]
412    fn mixed_add_child_and_inline_child() {
413        let mut tree = WidgetTree::new();
414        let pre = tree.add(FixedLeaf(80.0, 20.0));
415        let stack = tree.add(VStack::new().add_child(pre).child(FixedLeaf(80.0, 40.0)));
416        tree.layout(SizeProposal::exact(200.0, 300.0));
417
418        let kids = tree.children(stack);
419        assert_eq!(kids.len(), 2);
420        assert_eq!(kids[0], pre);
421        assert!((tree.bounds(kids[0]).height - 20.0).abs() < 0.01);
422        assert!((tree.bounds(kids[1]).height - 40.0).abs() < 0.01);
423        assert!((tree.bounds(kids[1]).y - 20.0).abs() < 0.01);
424    }
425
426    #[test]
427    fn children_iterator() {
428        let leaves: Vec<FixedLeaf> = vec![
429            FixedLeaf(80.0, 10.0),
430            FixedLeaf(80.0, 20.0),
431            FixedLeaf(80.0, 30.0),
432        ];
433        let mut tree = WidgetTree::new();
434        let stack = tree.add(VStack::new().children(leaves));
435        tree.layout(SizeProposal::exact(200.0, 300.0));
436
437        let kids = tree.children(stack);
438        assert_eq!(kids.len(), 3);
439        assert!((tree.bounds(kids[2]).y - 30.0).abs() < 0.01); // 10 + 20
440    }
441
442    #[test]
443    fn child_opt_none_is_noop() {
444        let mut tree = WidgetTree::new();
445        let stack = tree.add(
446            VStack::new()
447                .child(FixedLeaf(80.0, 30.0))
448                .child_opt(None::<FixedLeaf>)
449                .child(FixedLeaf(80.0, 50.0)),
450        );
451        tree.layout(SizeProposal::exact(200.0, 300.0));
452
453        let kids = tree.children(stack);
454        assert_eq!(kids.len(), 2);
455    }
456
457    #[test]
458    fn child_opt_some_adds_child() {
459        let mut tree = WidgetTree::new();
460        let stack = tree.add(VStack::new().child_opt(Some(FixedLeaf(80.0, 25.0))));
461        tree.layout(SizeProposal::exact(200.0, 300.0));
462
463        let kids = tree.children(stack);
464        assert_eq!(kids.len(), 1);
465        assert!((tree.bounds(kids[0]).height - 25.0).abs() < 0.01);
466    }
467
468    #[test]
469    fn nested_inline_children() {
470        use crate::primitives::hstack::HStack;
471
472        let mut tree = WidgetTree::new();
473        let outer = tree.add(
474            VStack::new()
475                .child(
476                    HStack::new()
477                        .child(FixedLeaf(40.0, 30.0))
478                        .child(FixedLeaf(50.0, 30.0)),
479                )
480                .child(FixedLeaf(80.0, 20.0)),
481        );
482        tree.layout(SizeProposal::exact(200.0, 300.0));
483
484        let outer_kids = tree.children(outer);
485        assert_eq!(outer_kids.len(), 2);
486        // The HStack should have 2 children
487        let hstack_kids = tree.children(outer_kids[0]);
488        assert_eq!(hstack_kids.len(), 2);
489        // Second VStack child starts after HStack height (30)
490        assert!((tree.bounds(outer_kids[1]).y - 30.0).abs() < 0.01);
491    }
492
493    #[test]
494    fn single_child_wrapper_inline() {
495        use crate::primitives::padding::Padding;
496
497        let mut tree = WidgetTree::new();
498        let stack =
499            tree.add(VStack::new().child(Padding::uniform(10.0).child(FixedLeaf(80.0, 30.0))));
500        tree.layout(SizeProposal::exact(200.0, 300.0));
501
502        let kids = tree.children(stack);
503        assert_eq!(kids.len(), 1);
504        // Padding adds 10 on each side: 30 + 20 = 50
505        assert!((tree.bounds(kids[0]).height - 50.0).abs() < 0.01);
506    }
507
508    #[test]
509    fn dormant_child_does_not_take_layout_space() {
510        let mut tree = WidgetTree::new();
511        let a = tree.add(FixedLeaf(80.0, 30.0));
512        let b = tree.add(FixedLeaf(80.0, 40.0));
513        let c = tree.add(FixedLeaf(80.0, 50.0));
514        let _stack = tree.add(
515            VStack::new()
516                .spacing(10.0)
517                .add_child(a)
518                .add_child(b)
519                .add_child(c),
520        );
521        tree.layout(SizeProposal::exact(200.0, 300.0));
522
523        // Before dormant: a(0..30), gap(10), b(40..80), gap(10), c(90..140)
524        assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
525
526        // Make middle child dormant
527        tree.set_dormant(b);
528        tree.layout(SizeProposal::exact(200.0, 300.0));
529
530        // After dormant: a(0..30), gap(10), c(40..90) — b's space is reclaimed
531        assert!((tree.bounds(c).y - 40.0).abs() < 0.01);
532    }
533
534    #[test]
535    fn dormant_child_via_visible_when_does_not_take_layout_space() {
536        use teksilo_core::signal::Signal;
537
538        let show_b = Signal::new(true);
539        let mut tree = WidgetTree::new();
540        let a = tree.add(FixedLeaf(80.0, 30.0));
541        let b = tree.add(FixedLeaf(80.0, 40.0));
542        tree.visible_when(b, show_b.clone());
543        let c = tree.add(FixedLeaf(80.0, 50.0));
544        let _stack = tree.add(
545            VStack::new()
546                .spacing(10.0)
547                .add_child(a)
548                .add_child(b)
549                .add_child(c),
550        );
551        tree.layout(SizeProposal::exact(200.0, 300.0));
552
553        // All visible: a(0..30), gap(10), b(40..80), gap(10), c(90..140)
554        assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
555
556        // Hide b via state
557        show_b.set(false);
558        tree.layout(SizeProposal::exact(200.0, 300.0));
559
560        // b is dormant: a(0..30), gap(10), c(40..90)
561        assert!((tree.bounds(c).y - 40.0).abs() < 0.01);
562
563        // Show b again
564        show_b.set(true);
565        tree.layout(SizeProposal::exact(200.0, 300.0));
566
567        // Back to original layout
568        assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
569    }
570
571    /// The cross-axis fix must be a no-op whenever content fits: a stack still
572    /// FILLS the width it is offered rather than shrinking to its content, or
573    /// alignment, backgrounds and stretch behaviour would all change.
574    #[test]
575    fn cross_axis_still_fills_offered_width_when_content_fits() {
576        let mut tree = WidgetTree::new();
577        let a = tree.add(FixedLeaf(100.0, 40.0));
578        let b = tree.add(FixedLeaf(100.0, 40.0));
579        let row = tree.add(crate::primitives::HStack::new().add_child(a).add_child(b));
580        let stack = tree.add(VStack::new().add_child(row));
581
582        // 200 dp of content in a 560 dp slot.
583        tree.layout(SizeProposal::exact(560.0, 400.0));
584
585        assert!(
586            (tree.bounds(stack).width - 560.0).abs() < 0.01,
587            "fitting content must still fill the offered 560 dp, not collapse \
588             to its natural 200: got {}",
589            tree.bounds(stack).width
590        );
591    }
592}