Skip to main content

teksilo_widgets/primitives/
hstack.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! HStack — a horizontal layout container that distributes children left-to-right.
5//!
6//! Children are given their intrinsic width and the stack's cross-axis height.
7//! Positive slack (leftover space) is distributed among children that carry a
8//! non-zero `flex` weight (e.g. `Spacer`, `Expand`); negative slack (over-constraint)
9//! is absorbed by children with a non-zero `shrink` weight (e.g. a single-line
10//! `TextWidget`). Vertical alignment defaults to `VAlignment::Center` and can be
11//! overridden per-container or per-child.
12//!
13//! For a vertical counterpart see [`VStack`](crate::primitives::VStack).
14//!
15//! ```rust
16//! # use teksilo_widgets::primitives::{HStack, TextWidget, Spacer};
17//! # use teksilo_i18n::lit;
18//! let _row = HStack::new()
19//!     .spacing(8.0)
20//!     .child(TextWidget::new(lit!("Label")))
21//!     .child(Spacer::new())
22//!     .child(TextWidget::new(lit!("Value")));
23//! ```
24
25use teksilo_canvas::{Point, Rect, Size, SizeProposal};
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::signal::Prop;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30use teksilo_tokens::VAlignment;
31
32use crate::primitives::linear_layout::{self, Axis};
33
34/// Horizontal layout container that distributes children left-to-right.
35///
36/// Cross-axis (vertical) alignment defaults to `VAlignment::Center` and may be
37/// overridden globally via [`alignment`](Self::alignment) or per-child via
38/// `WidgetTree::set_alignment`.
39#[derive(Debug)]
40pub struct HStack {
41    child_ids: Vec<WidgetId>,
42    pending: Vec<PendingChild>,
43    spacing: Prop<f32>,
44    alignment: VAlignment,
45}
46
47impl HStack {
48    /// Create an empty `HStack` with no spacing and `VAlignment::Center`.
49    pub fn new() -> Self {
50        Self {
51            child_ids: Vec::new(),
52            pending: Vec::new(),
53            spacing: Prop::Static(0.0),
54            alignment: VAlignment::Center,
55        }
56    }
57
58    /// Set inter-child spacing. Accepts a static `f32` or a reactive
59    /// `Signal<f32>` — use a signal derived from
60    /// `ctx.theme_signal()` to track theme-driven spacing changes.
61    pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
62        self.spacing = spacing.into();
63        self
64    }
65
66    /// Set the vertical alignment for children that are shorter than the stack's height.
67    pub fn alignment(mut self, alignment: VAlignment) -> Self {
68        self.alignment = alignment;
69        self
70    }
71
72    /// Add a pre-registered child by ID.
73    pub fn add_child(mut self, id: WidgetId) -> Self {
74        self.pending.push(PendingChild::Id(id));
75        self
76    }
77
78    /// Add an inline child widget (deferred insertion).
79    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
80        self.pending.push(PendingChild::Deferred(Box::new(widget)));
81        self
82    }
83
84    /// Add multiple inline children from an iterator.
85    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
86        for widget in iter {
87            self.pending.push(PendingChild::Deferred(Box::new(widget)));
88        }
89        self
90    }
91
92    /// Conditionally add a child. No-op if None.
93    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
94        if let Some(w) = widget {
95            self.pending.push(PendingChild::Deferred(Box::new(w)));
96        }
97        self
98    }
99}
100
101impl Default for HStack {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl Widget for HStack {
108    fn layout_response(
109        &self,
110        proposal: SizeProposal,
111        ctx: &LayoutContext,
112    ) -> teksilo_core::widget::LayoutResponse {
113        if self.child_ids.is_empty() {
114            return proposal.resolve(0.0, 0.0).into();
115        }
116        // Main-then-cross negotiation along the horizontal axis: grow on
117        // surplus (flex), shrink on a deficit (shrink/min), and measure each
118        // child's height at its final width (height-for-width). See
119        // [`linear_layout`].
120        let neg = linear_layout::negotiate(
121            &self.child_ids,
122            ctx,
123            proposal.width,
124            proposal.height,
125            self.spacing.get(),
126            Axis::Horizontal,
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.width),
147            Some(bounds.height),
148            self.spacing.get(),
149            Axis::Horizontal,
150        );
151        let widths = &neg.children.main;
152        let heights = &neg.children.cross;
153
154        // Place children along the main axis with cross-axis (vertical)
155        // alignment. In RTL mode, children are placed right-to-left.
156        let spacing = self.spacing.get();
157        let rtl = ctx.is_rtl();
158        if rtl {
159            let mut x = bounds.right();
160            for (i, child) in children.iter_mut().enumerate() {
161                let w = widths[i];
162                let h = heights[i];
163                let valign = ctx
164                    .child_alignment(child.id)
165                    .map(|a| a.vertical)
166                    .unwrap_or(self.alignment);
167                let y_offset = valign.resolve(h, bounds.height);
168
169                x -= w;
170                child.origin = Point::new(x, bounds.y + y_offset);
171                child.size = Size::new(w, h);
172                x -= spacing;
173            }
174        } else {
175            let mut x = bounds.x;
176            for (i, child) in children.iter_mut().enumerate() {
177                let w = widths[i];
178                let h = heights[i];
179                let valign = ctx
180                    .child_alignment(child.id)
181                    .map(|a| a.vertical)
182                    .unwrap_or(self.alignment);
183                let y_offset = valign.resolve(h, bounds.height);
184
185                child.origin = Point::new(x, bounds.y + y_offset);
186                child.size = Size::new(w, h);
187                x += w + spacing;
188            }
189        }
190    }
191
192    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
193
194    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
195        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
196    }
197
198    fn children(&self) -> Vec<WidgetId> {
199        self.child_ids.clone()
200    }
201
202    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
203        let pending = std::mem::take(&mut self.pending);
204        if !pending.is_empty() {
205            self.child_ids = pending
206                .into_iter()
207                .map(|child| match child {
208                    PendingChild::Id(id) => id,
209                    PendingChild::Deferred(w) => ctx.add_boxed(w),
210                })
211                .collect();
212        }
213        // Register the spacing prop for dirty-tracking so theme-driven
214        // signals trigger a relayout when they change.
215        let self_id = ctx.self_id();
216        let registry = ctx.binding_registry();
217        self.spacing.register_if_bound(
218            self_id,
219            registry,
220            teksilo_core::binding::BindingLevel::Relayout,
221        );
222        self.child_ids.clone()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use teksilo_core::widget_tree::WidgetTree;
230
231    /// A leaf that always reports a fixed intrinsic size.
232    #[derive(Debug)]
233    struct FixedLeaf(f32, f32);
234    impl Widget for FixedLeaf {
235        fn layout_response(
236            &self,
237            _proposal: SizeProposal,
238            _ctx: &LayoutContext,
239        ) -> teksilo_core::widget::LayoutResponse {
240            Size::new(self.0, self.1).into()
241        }
242    }
243
244    use teksilo_core::widget::LayoutResponse;
245
246    /// A leaf with an explicit compression floor and shrink weight.
247    #[derive(Debug)]
248    struct ShrinkLeaf {
249        wanted: f32,
250        min: f32,
251        shrink: f32,
252        height: f32,
253    }
254    impl Widget for ShrinkLeaf {
255        fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
256            LayoutResponse::shrinkable(
257                Size::new(self.wanted, self.height),
258                Size::new(self.min, self.height),
259                self.shrink,
260            )
261        }
262    }
263
264    /// A height-for-width leaf: fixed "area", so its height is `area / width`
265    /// at whatever width it is finally given (narrower → taller). Shrinkable
266    /// down to `min_w`.
267    #[derive(Debug)]
268    struct AreaLeaf {
269        area: f32,
270        wanted_w: f32,
271        min_w: f32,
272    }
273    impl Widget for AreaLeaf {
274        fn layout_response(&self, p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
275            let w = p.width.unwrap_or(self.wanted_w);
276            let h = self.area / w.max(1.0);
277            LayoutResponse::shrinkable(Size::new(w, h), Size::new(self.min_w, h), 1.0)
278        }
279    }
280
281    #[test]
282    fn children_get_intrinsic_widths() {
283        let mut tree = WidgetTree::new();
284        let a = tree.add(FixedLeaf(60.0, 30.0));
285        let b = tree.add(FixedLeaf(40.0, 20.0));
286        let _stack = tree.add(HStack::new().add_child(a).add_child(b));
287        tree.layout(SizeProposal::exact(300.0, 50.0));
288
289        assert!((tree.bounds(a).width - 60.0).abs() < 0.01);
290        assert!((tree.bounds(b).width - 40.0).abs() < 0.01);
291        assert!((tree.bounds(b).x - 60.0).abs() < 0.01);
292    }
293
294    // ── Over-constraint: shrink (Part B) ────────────────────────────────────
295
296    #[test]
297    fn shrink_distributes_deficit_by_weight() {
298        // Two equal shrinkables, 60px deficit → 30px each.
299        let mut tree = WidgetTree::new();
300        let a = tree.add(ShrinkLeaf {
301            wanted: 80.0,
302            min: 20.0,
303            shrink: 1.0,
304            height: 20.0,
305        });
306        let b = tree.add(ShrinkLeaf {
307            wanted: 80.0,
308            min: 20.0,
309            shrink: 1.0,
310            height: 20.0,
311        });
312        let _stack = tree.add(HStack::new().add_child(a).add_child(b));
313        tree.layout(SizeProposal::exact(100.0, 40.0));
314        assert!(
315            (tree.bounds(a).width - 50.0).abs() < 0.01,
316            "a={}",
317            tree.bounds(a).width
318        );
319        assert!(
320            (tree.bounds(b).width - 50.0).abs() < 0.01,
321            "b={}",
322            tree.bounds(b).width
323        );
324    }
325
326    #[test]
327    fn shrink_priority_leaves_rigid_sibling_untouched() {
328        // Shrinkable label absorbs the whole deficit; rigid icon keeps its size.
329        let mut tree = WidgetTree::new();
330        let label = tree.add(ShrinkLeaf {
331            wanted: 80.0,
332            min: 10.0,
333            shrink: 1.0,
334            height: 20.0,
335        });
336        let icon = tree.add(FixedLeaf(40.0, 20.0)); // rigid: shrink == 0
337        let _stack = tree.add(HStack::new().add_child(label).add_child(icon));
338        tree.layout(SizeProposal::exact(100.0, 40.0)); // deficit = 120 - 100 = 20
339        assert!(
340            (tree.bounds(label).width - 60.0).abs() < 0.01,
341            "label={}",
342            tree.bounds(label).width
343        );
344        assert!(
345            (tree.bounds(icon).width - 40.0).abs() < 0.01,
346            "icon={}",
347            tree.bounds(icon).width
348        );
349    }
350
351    #[test]
352    fn shrink_clamps_at_min_with_residual_overflow() {
353        // Deficit exceeds available shrink room: clamp at min, do not go below.
354        let mut tree = WidgetTree::new();
355        let a = tree.add(ShrinkLeaf {
356            wanted: 80.0,
357            min: 50.0,
358            shrink: 1.0,
359            height: 20.0,
360        });
361        let _stack = tree.add(HStack::new().add_child(a));
362        tree.layout(SizeProposal::exact(30.0, 40.0)); // wants to shrink to 30, floored at 50
363        assert!(
364            (tree.bounds(a).width - 50.0).abs() < 0.01,
365            "a={}",
366            tree.bounds(a).width
367        );
368    }
369
370    #[test]
371    fn no_shrink_weight_overflows_unchanged() {
372        // A rigid child still overflows (no silent shrink without opt-in).
373        let mut tree = WidgetTree::new();
374        let a = tree.add(FixedLeaf(80.0, 20.0));
375        let _stack = tree.add(HStack::new().add_child(a));
376        tree.layout(SizeProposal::exact(50.0, 40.0));
377        assert!(
378            (tree.bounds(a).width - 80.0).abs() < 0.01,
379            "a={}",
380            tree.bounds(a).width
381        );
382    }
383
384    // ── Height-for-width (Part B) ───────────────────────────────────────────
385
386    #[test]
387    fn height_for_width_grows_cross_axis_on_shrink() {
388        // AreaLeaf: area 4000, natural width 200 → height 20. Constrained to
389        // width 50, it shrinks to 50 and its height becomes 4000/50 = 80; the
390        // HStack's reported cross size must follow.
391        let mut tree = WidgetTree::new();
392        let leaf = tree.add(AreaLeaf {
393            area: 4000.0,
394            wanted_w: 200.0,
395            min_w: 10.0,
396        });
397        let stack = tree.add(HStack::new().add_child(leaf));
398        tree.layout(SizeProposal {
399            width: Some(50.0),
400            height: None, // ask the stack for its intrinsic height
401        });
402        assert!(
403            (tree.bounds(leaf).width - 50.0).abs() < 0.01,
404            "leaf w={}",
405            tree.bounds(leaf).width
406        );
407        assert!(
408            (tree.bounds(leaf).height - 80.0).abs() < 0.5,
409            "leaf h={}",
410            tree.bounds(leaf).height
411        );
412        assert!(
413            (tree.bounds(stack).height - 80.0).abs() < 0.5,
414            "stack height should follow height-for-width, got {}",
415            tree.bounds(stack).height
416        );
417    }
418
419    #[test]
420    fn spacing_between_children() {
421        let mut tree = WidgetTree::new();
422        let a = tree.add(FixedLeaf(50.0, 30.0));
423        let b = tree.add(FixedLeaf(50.0, 30.0));
424        let _stack = tree.add(HStack::new().spacing(10.0).add_child(a).add_child(b));
425        tree.layout(SizeProposal::exact(300.0, 50.0));
426
427        assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
428        assert!((tree.bounds(b).x - 60.0).abs() < 0.01); // 50 + 10
429    }
430
431    #[test]
432    fn cross_axis_center_alignment() {
433        let mut tree = WidgetTree::new();
434        let a = tree.add(FixedLeaf(50.0, 20.0));
435        let _stack = tree.add(HStack::new().add_child(a)); // default: VAlignment::Center
436        tree.layout(SizeProposal::exact(200.0, 60.0));
437
438        // 20px child centered in 60px height: y = (60-20)/2 = 20
439        assert!((tree.bounds(a).y - 20.0).abs() < 0.01);
440    }
441
442    #[test]
443    fn cross_axis_top_alignment() {
444        let mut tree = WidgetTree::new();
445        let a = tree.add(FixedLeaf(50.0, 20.0));
446        let _stack = tree.add(HStack::new().alignment(VAlignment::Top).add_child(a));
447        tree.layout(SizeProposal::exact(200.0, 60.0));
448
449        assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
450    }
451
452    #[test]
453    fn cross_axis_bottom_alignment() {
454        let mut tree = WidgetTree::new();
455        let a = tree.add(FixedLeaf(50.0, 20.0));
456        let _stack = tree.add(HStack::new().alignment(VAlignment::Bottom).add_child(a));
457        tree.layout(SizeProposal::exact(200.0, 60.0));
458
459        assert!((tree.bounds(a).y - 40.0).abs() < 0.01); // 60 - 20
460    }
461
462    #[test]
463    fn per_child_alignment_override() {
464        let mut tree = WidgetTree::new();
465        let a = tree.add(FixedLeaf(50.0, 20.0));
466        let b = tree.add(FixedLeaf(50.0, 20.0));
467        // Container default: Top, but b overrides to Bottom
468        let _stack = tree.add(
469            HStack::new()
470                .alignment(VAlignment::Top)
471                .add_child(a)
472                .add_child(b),
473        );
474        tree.set_alignment(
475            b,
476            teksilo_tokens::Alignment {
477                horizontal: teksilo_tokens::HAlignment::Center,
478                vertical: teksilo_tokens::VAlignment::Bottom,
479            },
480        );
481        tree.layout(SizeProposal::exact(200.0, 60.0));
482
483        assert!((tree.bounds(a).y - 0.0).abs() < 0.01); // Top
484        assert!((tree.bounds(b).y - 40.0).abs() < 0.01); // Bottom override
485    }
486
487    #[test]
488    fn intrinsic_size_sums_children() {
489        let stack = HStack::new().spacing(5.0);
490        // Without arena, size_that_fits falls back to proposal
491        let theme = teksilo_core::presets::intui::light();
492        let ctx = LayoutContext::for_testing(&theme);
493        let size = stack
494            .layout_response(SizeProposal::exact(100.0, 50.0), &ctx)
495            .size;
496        // No children queryable without arena, so returns proposal
497        assert_eq!(size.width, 100.0);
498        assert_eq!(size.height, 50.0);
499    }
500
501    #[test]
502    fn empty_hstack() {
503        let mut tree = WidgetTree::new();
504        let _stack = tree.add(HStack::new());
505        tree.layout(SizeProposal::exact(200.0, 50.0));
506        // No crash, no children to place
507    }
508
509    #[test]
510    fn flex_distributes_proportionally() {
511        // [Expand::flex(1), Expand::flex(2)] in 300px → 100, 200.
512        use crate::primitives::expand::Expand;
513        let mut tree = WidgetTree::new();
514        let a = tree.add(Expand::new().flex(1.0));
515        let b = tree.add(Expand::new().flex(2.0));
516        let _stack = tree.add(HStack::new().add_child(a).add_child(b));
517        tree.layout(SizeProposal::exact(300.0, 50.0));
518
519        assert!(
520            (tree.bounds(a).width - 100.0).abs() < 0.01,
521            "a.width={}",
522            tree.bounds(a).width
523        );
524        assert!(
525            (tree.bounds(b).width - 200.0).abs() < 0.01,
526            "b.width={}",
527            tree.bounds(b).width
528        );
529    }
530
531    #[test]
532    fn flex_with_rigid_floor() {
533        // [Fixed(100), Expand::flex(1), Expand::flex(2)] in 400 → 100, 100, 200.
534        use crate::primitives::expand::Expand;
535        let mut tree = WidgetTree::new();
536        let fixed = tree.add(FixedLeaf(100.0, 30.0));
537        let a = tree.add(Expand::new().flex(1.0));
538        let b = tree.add(Expand::new().flex(2.0));
539        let _stack = tree.add(HStack::new().add_child(fixed).add_child(a).add_child(b));
540        tree.layout(SizeProposal::exact(400.0, 50.0));
541
542        assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
543        assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
544        assert!((tree.bounds(b).width - 200.0).abs() < 0.01);
545    }
546
547    #[test]
548    fn spacer_min_length_is_floor_plus_share() {
549        // [Spacer::min(20), Spacer::min(20)] in 100 → 50, 50
550        // (each gets 20 floor + 30 share).
551        use crate::primitives::spacer::Spacer;
552        let mut tree = WidgetTree::new();
553        let a = tree.add(Spacer::new().min_length(20.0));
554        let b = tree.add(Spacer::new().min_length(20.0));
555        let _stack = tree.add(HStack::new().add_child(a).add_child(b));
556        tree.layout(SizeProposal::exact(100.0, 50.0));
557
558        assert!((tree.bounds(a).width - 50.0).abs() < 0.01);
559        assert!((tree.bounds(b).width - 50.0).abs() < 0.01);
560    }
561
562    #[test]
563    fn expand_default_is_flex_one_in_hstack() {
564        // `Expand::new()` (no .flex(n)) inside HStack claims all leftover.
565        // The footgun fix: previously `Expand::new()` collapsed without
566        // `.fills_stack()`.
567        use crate::primitives::expand::Expand;
568        let mut tree = WidgetTree::new();
569        let fixed = tree.add(FixedLeaf(80.0, 30.0));
570        let expand = tree.add(Expand::new());
571        let _stack = tree.add(HStack::new().add_child(fixed).add_child(expand));
572        tree.layout(SizeProposal::exact(200.0, 50.0));
573
574        assert!((tree.bounds(fixed).width - 80.0).abs() < 0.01);
575        assert!((tree.bounds(expand).width - 120.0).abs() < 0.01);
576    }
577
578    #[test]
579    fn respect_intrinsic_uses_child_natural_as_floor() {
580        // With respect_intrinsic, Expand::flex(1) wrapping a 60px child
581        // contributes 60 to the rigid pool, then gets the remaining slack.
582        // [Expand::flex(1).respect_intrinsic(child=60), Fixed(100)] in 300:
583        //   - Expand wants 60 (auto-basis), flex=1
584        //   - Fixed wants 100, no flex
585        //   - slack = 300 - 60 - 100 = 140, all to Expand
586        //   - Final: Expand = 60 + 140 = 200, Fixed = 100
587        use crate::primitives::expand::Expand;
588        let mut tree = WidgetTree::new();
589        let inner = tree.add(FixedLeaf(60.0, 20.0));
590        let expand = tree.add(Expand::new().respect_intrinsic().child_id(inner));
591        let fixed = tree.add(FixedLeaf(100.0, 30.0));
592        let _stack = tree.add(HStack::new().add_child(expand).add_child(fixed));
593        tree.layout(SizeProposal::exact(300.0, 50.0));
594
595        assert!((tree.bounds(expand).width - 200.0).abs() < 0.01);
596        assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
597    }
598
599    #[test]
600    fn rtl_reverses_child_order() {
601        let mut tree = WidgetTree::new();
602        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
603        let a = tree.add(FixedLeaf(60.0, 30.0));
604        let b = tree.add(FixedLeaf(40.0, 30.0));
605        let _stack = tree.add(HStack::new().add_child(a).add_child(b));
606        tree.layout(SizeProposal {
607            width: None,
608            height: Some(50.0),
609        });
610
611        // In RTL, first child (a) is placed on the right, second (b) on the left.
612        // HStack without spacers sizes to content: 60+40 = 100px wide.
613        let ab = tree.bounds(a);
614        let bb = tree.bounds(b);
615        assert!(ab.x > bb.x, "a.x={} should be > b.x={} in RTL", ab.x, bb.x);
616        // a (60px) at right edge of 100px HStack
617        assert!((ab.x - 40.0).abs() < 0.01, "a.x={}", ab.x);
618        // b (40px) to the left of a
619        assert!((bb.x - 0.0).abs() < 0.01, "b.x={}", bb.x);
620    }
621}