Skip to main content

teksilo_widgets/primitives/
min_size.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MinSize — a layout modifier that ensures a child reaches a minimum width and/or height.
5//!
6//! The child's reported size is clamped upward so it never falls below the
7//! configured minimum on each constrained axis. The minimum is also forwarded
8//! as part of the clamped proposal so that wrap-aware children (e.g. a
9//! multi-line `TextWidget`) measure against the constraint they will actually
10//! be placed into. Axes with no minimum set are passed through unchanged.
11//!
12//! `MinSize` propagates the child's `flex` and `shrink` weights so that a
13//! `Spacer` or `Expand` inside `MinSize` still participates in stack
14//! slack-distribution; the child's own compression floor is composed with
15//! the `MinSize` floor.
16//!
17//! For the inverse operation (capping a maximum size) see [`MaxSize`](super::MaxSize).
18//!
19//! ```rust
20//! # use teksilo_widgets::primitives::{MinSize, icon_widget::IconWidget};
21//! // Guarantee a 44×44 dp tap target around a 20 dp icon.
22//! let _tap_target = MinSize::new(44.0, 44.0)
23//!     .child(IconWidget::checkmark(20.0));
24//! ```
25
26use teksilo_canvas::{Rect, Size, SizeProposal};
27use teksilo_core::signal::Prop;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30
31/// Layout modifier that enforces a minimum width and/or height on a single child widget.
32///
33/// Constraints can be static or bound to a reactive `Signal<f32>` for dynamic resizing.
34#[derive(Debug)]
35pub struct MinSize {
36    child_id: Option<WidgetId>,
37    pending_child: Option<PendingChild>,
38    min_width: Option<Prop<f32>>,
39    min_height: Option<Prop<f32>>,
40}
41
42impl MinSize {
43    /// Enforce a minimum on both axes: the child's width will be at least `width` and its height at least `height`.
44    pub fn new(width: f32, height: f32) -> Self {
45        Self {
46            child_id: None,
47            pending_child: None,
48            min_width: Some(Prop::Static(width)),
49            min_height: Some(Prop::Static(height)),
50        }
51    }
52
53    /// Enforce a minimum only on the width axis; the height axis is unconstrained by this modifier.
54    pub fn width(width: f32) -> Self {
55        Self {
56            child_id: None,
57            pending_child: None,
58            min_width: Some(Prop::Static(width)),
59            min_height: None,
60        }
61    }
62
63    /// Enforce a minimum only on the height axis; the width axis is unconstrained by this modifier.
64    pub fn height(height: f32) -> Self {
65        Self {
66            child_id: None,
67            pending_child: None,
68            min_width: None,
69            min_height: Some(Prop::Static(height)),
70        }
71    }
72
73    /// Bind min width to a reactive state.
74    pub fn min_width(mut self, state: impl Into<Prop<f32>>) -> Self {
75        self.min_width = Some(state.into());
76        self
77    }
78
79    /// Bind min height to a reactive state.
80    pub fn min_height(mut self, state: impl Into<Prop<f32>>) -> Self {
81        self.min_height = Some(state.into());
82        self
83    }
84
85    /// Set child by pre-registered ID.
86    pub fn child_id(mut self, id: WidgetId) -> Self {
87        self.pending_child = Some(PendingChild::Id(id));
88        self
89    }
90
91    /// Set an inline child widget (deferred insertion).
92    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
93        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
94        self
95    }
96}
97
98impl Widget for MinSize {
99    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
100        if let Some(pending) = self.pending_child.take() {
101            self.child_id = Some(match pending {
102                PendingChild::Id(id) => id,
103                PendingChild::Deferred(w) => ctx.add_boxed(w),
104            });
105        }
106        let self_id = ctx.self_id();
107        let registry = ctx.binding_registry();
108        if let Some(ref w) = self.min_width {
109            w.register_if_bound(
110                self_id,
111                registry,
112                teksilo_core::binding::BindingLevel::Relayout,
113            );
114        }
115        if let Some(ref h) = self.min_height {
116            h.register_if_bound(
117                self_id,
118                registry,
119                teksilo_core::binding::BindingLevel::Relayout,
120            );
121        }
122        self.child_id.into_iter().collect()
123    }
124
125    fn layout_response(
126        &self,
127        proposal: SizeProposal,
128        ctx: &LayoutContext,
129    ) -> teksilo_core::widget::LayoutResponse {
130        let min_w = self.min_width.as_ref().map(|r| r.get());
131        let min_h = self.min_height.as_ref().map(|r| r.get());
132
133        // Clamp the proposal upward to the minimums before forwarding,
134        // so wrap-aware children (TextWidget, etc.) measure against the
135        // actual constraint they will be placed into. Mirrors MaxSize's
136        // approach of clamping the proposal before forwarding.
137        let clamped_proposal = SizeProposal {
138            width: match (proposal.width, min_w) {
139                (Some(w), Some(min)) => Some(w.max(min)),
140                (None, Some(min)) => Some(min),
141                (w, None) => w,
142            },
143            height: match (proposal.height, min_h) {
144                (Some(h), Some(min)) => Some(h.max(min)),
145                (None, Some(min)) => Some(min),
146                (h, None) => h,
147            },
148        };
149
150        // Use child_layout_response to capture flex so a Spacer (or any
151        // other flex child) inside MinSize is still seen as flex by the
152        // parent stack. Without this, MinSize(Spacer) would report flex=0
153        // and break the parent HStack's slack distribution.
154        let child_response = self
155            .child_id
156            .and_then(|id| ctx.child_layout_response(id, clamped_proposal));
157        let child_size = child_response
158            .as_ref()
159            .map(|r| r.size)
160            .unwrap_or(Size::ZERO);
161        let child_flex = child_response.map(|r| r.flex).unwrap_or(0.0);
162        let child_shrink = child_response.map(|r| r.shrink).unwrap_or(0.0);
163        let child_min = child_response.map(|r| r.min).unwrap_or(Size::ZERO);
164
165        let w = match min_w {
166            Some(min) => child_size.width.max(min),
167            None => child_size.width,
168        };
169        let h = match min_h {
170            Some(min) => child_size.height.max(min),
171            None => child_size.height,
172        };
173
174        // Compose the compression floor: a shrinkable child may still shrink,
175        // but never below MinSize's own minimum nor the child's own floor.
176        // Clamp componentwise to the wanted size so `min <= size` holds.
177        let floor_w = child_min.width.max(min_w.unwrap_or(0.0)).min(w);
178        let floor_h = child_min.height.max(min_h.unwrap_or(0.0)).min(h);
179        teksilo_core::widget::LayoutResponse::flexible(Size::new(w, h), child_flex)
180            .with_shrink(child_shrink)
181            .with_min(Size::new(floor_w, floor_h))
182    }
183
184    fn place_children(
185        &self,
186        bounds: Rect,
187        _proposal: SizeProposal,
188        children: &mut [WidgetPlacement],
189        _ctx: &LayoutContext,
190    ) {
191        for child in children.iter_mut() {
192            child.origin = bounds.origin();
193            child.size = bounds.size();
194        }
195    }
196
197    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
198
199    fn children(&self) -> Vec<WidgetId> {
200        self.child_id.into_iter().collect()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use teksilo_core::signal::Signal;
208    use teksilo_core::widget_tree::WidgetTree;
209
210    #[derive(Debug)]
211    struct FixedLeaf(f32, f32);
212    impl Widget for FixedLeaf {
213        fn layout_response(
214            &self,
215            _proposal: SizeProposal,
216            _ctx: &LayoutContext,
217        ) -> teksilo_core::widget::LayoutResponse {
218            Size::new(self.0, self.1).into()
219        }
220    }
221
222    #[test]
223    fn clamps_small_child_to_minimum() {
224        let mut tree = WidgetTree::new();
225        let child = tree.add(FixedLeaf(20.0, 10.0));
226        let min = tree.add(MinSize::new(48.0, 48.0).child_id(child));
227        tree.layout(SizeProposal::unspecified());
228
229        let mb = tree.bounds(min);
230        assert!((mb.width - 48.0).abs() < 0.01);
231        assert!((mb.height - 48.0).abs() < 0.01);
232    }
233
234    #[test]
235    fn large_child_is_not_clamped() {
236        let mut tree = WidgetTree::new();
237        let child = tree.add(FixedLeaf(100.0, 80.0));
238        let min = tree.add(MinSize::new(48.0, 48.0).child_id(child));
239        tree.layout(SizeProposal::unspecified());
240
241        let mb = tree.bounds(min);
242        assert!((mb.width - 100.0).abs() < 0.01);
243        assert!((mb.height - 80.0).abs() < 0.01);
244    }
245
246    #[test]
247    fn min_width_only() {
248        let mut tree = WidgetTree::new();
249        let child = tree.add(FixedLeaf(20.0, 10.0));
250        let min = tree.add(MinSize::width(48.0).child_id(child));
251        tree.layout(SizeProposal::unspecified());
252
253        let mb = tree.bounds(min);
254        assert!((mb.width - 48.0).abs() < 0.01);
255        assert!((mb.height - 10.0).abs() < 0.01);
256    }
257
258    #[test]
259    fn min_width_dynamic() {
260        let min_w = Signal::new(48.0_f32);
261        let mut tree = WidgetTree::new();
262        let child = tree.add(FixedLeaf(20.0, 10.0));
263        let min = tree.add(MinSize::width(0.0).min_width(min_w.clone()).child_id(child));
264        tree.layout(SizeProposal::unspecified());
265        assert!((tree.bounds(min).width - 48.0).abs() < 0.01);
266
267        min_w.set(80.0);
268        tree.layout(SizeProposal::unspecified());
269        assert!((tree.bounds(min).width - 80.0).abs() < 0.01);
270    }
271
272    /// A leaf that simulates wrapping text: it has 120 logical px of
273    /// content. When the proposal width is >= 120 it reports 120×20
274    /// (single line). When narrower, it wraps: width = proposal,
275    /// height = ceil(120 / proposal) * 20.
276    #[derive(Debug)]
277    struct WrappingLeaf;
278    impl Widget for WrappingLeaf {
279        fn layout_response(
280            &self,
281            proposal: SizeProposal,
282            _ctx: &LayoutContext,
283        ) -> teksilo_core::widget::LayoutResponse {
284            let content_width = 120.0_f32;
285            let line_height = 20.0_f32;
286            let w = proposal.width.unwrap_or(content_width).min(content_width);
287            let lines = (content_width / w.max(1.0)).ceil();
288            Size::new(w, lines * line_height).into()
289        }
290    }
291
292    #[test]
293    fn child_receives_clamped_proposal() {
294        // A VStack with unspecified width queries the MinSize for its
295        // intrinsic size. MinSize (min_width=100) should forward 100px
296        // to the wrapping child (not leave width unspecified or too
297        // narrow), yielding the correct wrapped height.
298        use crate::primitives::vstack::VStack;
299
300        let mut tree = WidgetTree::new();
301        let child = tree.add(WrappingLeaf);
302        let min = tree.add(MinSize::width(100.0).child_id(child));
303        let _stack = tree.add(VStack::new().add_child(min));
304        tree.layout(SizeProposal {
305            width: None,
306            height: None,
307        });
308
309        let mb = tree.bounds(min);
310        assert!(
311            (mb.width - 100.0).abs() < 0.01,
312            "width should be 100, got {}",
313            mb.width
314        );
315        // At 100px width: ceil(120/100) = 2 lines → 40px height
316        assert!(
317            (mb.height - 40.0).abs() < 0.01,
318            "height should be 40 (2 lines at 100px), got {}",
319            mb.height
320        );
321    }
322
323    #[test]
324    fn unspecified_proposal_gets_clamped_to_minimum() {
325        // When the parent proposes no width at all, MinSize should
326        // forward the minimum as the proposal so the child measures
327        // against the constraint it will actually be placed into.
328        let mut tree = WidgetTree::new();
329        let child = tree.add(WrappingLeaf);
330        let min = tree.add(MinSize::width(80.0).child_id(child));
331        tree.layout(SizeProposal::unspecified());
332
333        let mb = tree.bounds(min);
334        assert!(
335            (mb.width - 80.0).abs() < 0.01,
336            "width should be 80, got {}",
337            mb.width
338        );
339        // At 80px width: ceil(120/80) = 2 lines → 40px
340        assert!(
341            (mb.height - 40.0).abs() < 0.01,
342            "height should be 40 (2 lines at 80px), got {}",
343            mb.height
344        );
345    }
346}