Skip to main content

teksilo_widgets/primitives/
spacer.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Spacer — an invisible, flexible gap that claims all available space on the
5//! container's main axis.
6//!
7//! Place a `Spacer` inside an [`HStack`](crate::primitives::HStack) or
8//! [`VStack`](crate::primitives::VStack) to push adjacent siblings to opposite
9//! edges; flank a child with two spacers to centre it. A spacer carries flex
10//! weight `1.0` and zero wanted size, so it soaks up leftover slack without
11//! imposing a cross-axis floor. [`min_length`](Spacer::min_length) sets a hard
12//! minimum so the gap never collapses below a fixed amount under tight layout.
13//!
14//! ```rust
15//! # use teksilo_widgets::primitives::{HStack, Spacer, TextWidget};
16//! # use teksilo_i18n::lit;
17//! // Title hugs the leading edge, badge is pushed to the trailing edge.
18//! let _row = HStack::new()
19//!     .child(TextWidget::new(lit!("Title")))
20//!     .child(Spacer::new())
21//!     .child(TextWidget::new(lit!("NEW")));
22//! ```
23
24use teksilo_canvas::{Rect, Size, SizeProposal};
25use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
26
27/// An invisible, flexible gap that claims a container's leftover main-axis space.
28#[derive(Debug)]
29pub struct Spacer {
30    min_length: f32,
31}
32
33impl Spacer {
34    /// Create a spacer with no minimum length (collapses fully when the
35    /// container has no slack to give).
36    pub fn new() -> Self {
37        Self { min_length: 0.0 }
38    }
39
40    /// Set a hard floor, in logical pixels, on the spacer's main-axis size.
41    ///
42    /// The container still adds its slack share on top; the floor only matters
43    /// when the container is too cramped to grant any slack. The cross axis is
44    /// unaffected, so a horizontal spacer never inflates its stack's height.
45    pub fn min_length(mut self, min: f32) -> Self {
46        self.min_length = min;
47        self
48    }
49}
50
51impl Default for Spacer {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl Widget for Spacer {
58    fn layout_response(
59        &self,
60        _proposal: SizeProposal,
61        ctx: &LayoutContext,
62    ) -> teksilo_core::widget::LayoutResponse {
63        // Spacer wants `min_length` as a floor on the stack's MAIN axis; the
64        // parent adds its slack share on top via the flex weight. The cross
65        // axis must be 0 — otherwise an invisible spacer with `min_length > 0`
66        // imposes a spurious cross-axis floor on the stack (e.g. an HStack's
67        // intrinsic height grows by `min_length`). The enclosing stack tells us
68        // its main axis via the context; outside a stack we fall back to
69        // `min_length` on both axes (a spacer there is degenerate anyway).
70        use teksilo_core::widget::StackAxis;
71        let size = match ctx.stack_main_axis() {
72            Some(StackAxis::Horizontal) => Size::new(self.min_length, 0.0),
73            Some(StackAxis::Vertical) => Size::new(0.0, self.min_length),
74            None => Size::new(self.min_length, self.min_length),
75        };
76        teksilo_core::widget::LayoutResponse::flexible(size, 1.0)
77    }
78
79    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
80        // Spacer is invisible.
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use teksilo_core::widget_tree::WidgetTree;
88
89    use crate::primitives::hstack::HStack;
90    use crate::primitives::vstack::VStack;
91
92    #[derive(Debug)]
93    struct FixedLeaf(f32, f32);
94    impl Widget for FixedLeaf {
95        fn layout_response(
96            &self,
97            _proposal: SizeProposal,
98            _ctx: &LayoutContext,
99        ) -> teksilo_core::widget::LayoutResponse {
100            Size::new(self.0, self.1).into()
101        }
102    }
103
104    #[test]
105    fn spacer_pushes_to_trailing_in_hstack() {
106        let mut tree = WidgetTree::new();
107        let spacer = tree.add(Spacer::new());
108        let btn = tree.add(FixedLeaf(60.0, 30.0));
109        let _stack = tree.add(HStack::new().add_child(spacer).add_child(btn));
110        tree.layout(SizeProposal::exact(300.0, 50.0));
111
112        // Spacer takes 300-60=240, button at x=240
113        assert!((tree.bounds(btn).x - 240.0).abs() < 0.01);
114    }
115
116    #[test]
117    fn two_spacers_center_child_in_hstack() {
118        let mut tree = WidgetTree::new();
119        let s1 = tree.add(Spacer::new());
120        let label = tree.add(FixedLeaf(60.0, 30.0));
121        let s2 = tree.add(Spacer::new());
122        let _stack = tree.add(HStack::new().add_child(s1).add_child(label).add_child(s2));
123        tree.layout(SizeProposal::exact(300.0, 50.0));
124
125        // Remaining = 300-60 = 240, each spacer = 120
126        // label at x=120
127        assert!((tree.bounds(label).x - 120.0).abs() < 0.01);
128    }
129
130    #[test]
131    fn spacer_pushes_to_bottom_in_vstack() {
132        let mut tree = WidgetTree::new();
133        let spacer = tree.add(Spacer::new());
134        let btn = tree.add(FixedLeaf(60.0, 30.0));
135        let _stack = tree.add(VStack::new().add_child(spacer).add_child(btn));
136        tree.layout(SizeProposal::exact(200.0, 300.0));
137
138        // Spacer takes 300-30=270, button at y=270
139        assert!((tree.bounds(btn).y - 270.0).abs() < 0.01);
140    }
141
142    #[test]
143    fn spacer_with_min_length() {
144        let mut tree = WidgetTree::new();
145        let btn1 = tree.add(FixedLeaf(60.0, 30.0));
146        let spacer = tree.add(Spacer::new().min_length(20.0));
147        let btn2 = tree.add(FixedLeaf(60.0, 30.0));
148        let _stack = tree.add(
149            HStack::new()
150                .add_child(btn1)
151                .add_child(spacer)
152                .add_child(btn2),
153        );
154        tree.layout(SizeProposal::exact(300.0, 50.0));
155
156        // Spacer gets 300-60-60 = 180 (well above min_length)
157        assert!((tree.bounds(btn2).x - 240.0).abs() < 0.01);
158    }
159
160    #[test]
161    fn min_length_does_not_inflate_cross_axis() {
162        // Regression: a horizontal Spacer with `min_length` must not inflate
163        // its HStack's intrinsic height. The HStack is measured with an open
164        // (intrinsic) height; only the real content (30px) should drive it.
165        let mut tree = WidgetTree::new();
166        let btn = tree.add(FixedLeaf(60.0, 30.0));
167        let spacer = tree.add(Spacer::new().min_length(80.0));
168        let stack = tree.add(HStack::new().add_child(btn).add_child(spacer));
169        // Width fixed, height open → intrinsic height.
170        tree.layout(SizeProposal {
171            width: Some(400.0),
172            height: None,
173        });
174        assert!(
175            (tree.bounds(stack).height - 30.0).abs() < 0.01,
176            "HStack height should follow content (30), not the spacer min_length (80); got {}",
177            tree.bounds(stack).height
178        );
179    }
180
181    #[test]
182    fn min_length_is_honoured_on_the_main_axis() {
183        // The main-axis floor still holds: a cramped HStack keeps the spacer at
184        // least `min_length` wide.
185        let mut tree = WidgetTree::new();
186        let btn1 = tree.add(FixedLeaf(60.0, 30.0));
187        let spacer = tree.add(Spacer::new().min_length(40.0));
188        let btn2 = tree.add(FixedLeaf(60.0, 30.0));
189        let stack = tree.add(
190            HStack::new()
191                .add_child(btn1)
192                .add_child(spacer)
193                .add_child(btn2),
194        );
195        // 60 + 40 + 60 = 160 exactly → spacer at its floor, btn2 at x=100.
196        tree.layout(SizeProposal::exact(160.0, 50.0));
197        assert!((tree.bounds(btn2).x - 100.0).abs() < 0.01);
198        let _ = stack;
199    }
200
201    #[test]
202    fn flex_factor_is_one() {
203        let theme = teksilo_core::presets::intui::light();
204        let ctx = LayoutContext::for_testing(&theme);
205        let r = Spacer::new().layout_response(SizeProposal::unspecified(), &ctx);
206        assert_eq!(r.flex, 1.0);
207    }
208}