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. A bounded proposal below the
8//! minimum is raised to it before it is forwarded, so that wrap-aware children
9//! (e.g. a multi-line `TextWidget`) measure against the width they will
10//! actually be placed into. An unbounded axis stays unbounded: the child
11//! answers with its ideal size, and the minimum applies to that answer, as
12//! SwiftUI's `.frame(minWidth:)` does. Axes with no minimum set are passed
13//! through unchanged.
14//!
15//! `MinSize` propagates the child's `flex` and `shrink` weights so that a
16//! `Spacer` or `Expand` inside `MinSize` still participates in stack
17//! slack-distribution; the child's own compression floor is composed with
18//! the `MinSize` floor.
19//!
20//! For the inverse operation (capping a maximum size) see [`MaxSize`](super::MaxSize).
21//!
22//! # Hit targets: take the floor from the theme
23//!
24//! `MinSize` is the mechanism for a minimum hit box, but the *number* belongs to
25//! the active density rather than to the call site. Route it through
26//! [`density_min_size`](teksilo_core::styles::density::density_min_size) (or
27//! `dp(.., TargetRole::Target, ..)`), which raises the named axes to
28//! `theme.input.target_size` — 24 dp at `Compact`, 32 at `Comfortable`, 44 at
29//! `Touch`, and 48 under the Material 3 preset. That is what every shipped
30//! recipe does; see `docs/density-and-targets.md`.
31//!
32//! ```rust
33//! # use teksilo_widgets::primitives::{MinSize, icon_widget::IconWidget};
34//! # use teksilo_core::styles::density::density_min_size;
35//! # use teksilo_tokens::{InputTokens, TargetAxes, TargetDensity};
36//! # let tokens = InputTokens::for_density(TargetDensity::Touch);
37//! # let base = teksilo_canvas::Size::new(20.0, 20.0);
38//! // The density decides the floor; the call site only says "both axes".
39//! let min = density_min_size(base, TargetAxes::BOTH, &tokens);
40//! let _tap_target = MinSize::new(min.width, min.height)
41//!     .child(IconWidget::checkmark(20.0));
42//! ```
43//!
44//! A hard-coded literal is right only where the number is a *design* dimension
45//! that must not move with density. Where it is a target, name its bar: 24 dp is
46//! WCAG 2.2 SC 2.5.8 *Target Size (Minimum)*, level AA — the floor
47//! `min_target_conformance` holds at every density; 44 dp is Apple's HIG minimum
48//! and SC 2.5.5 *Target Size (Enhanced)*, level AAA, which is what the `Touch`
49//! ladder aims for. 44 dp is never the AA figure.
50
51use teksilo_canvas::{Rect, Size, SizeProposal};
52use teksilo_core::signal::Prop;
53use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
54use teksilo_core::widget_id::WidgetId;
55
56/// Layout modifier that enforces a minimum width and/or height on a single child widget.
57///
58/// Constraints can be static or bound to a reactive `Signal<f32>` for dynamic resizing.
59#[derive(Debug)]
60pub struct MinSize {
61    child_id: Option<WidgetId>,
62    pending_child: Option<PendingChild>,
63    min_width: Option<Prop<f32>>,
64    min_height: Option<Prop<f32>>,
65}
66
67impl MinSize {
68    /// Enforce a minimum on both axes: the child's width will be at least `width` and its height at least `height`.
69    pub fn new(width: f32, height: f32) -> Self {
70        Self {
71            child_id: None,
72            pending_child: None,
73            min_width: Some(Prop::Static(width)),
74            min_height: Some(Prop::Static(height)),
75        }
76    }
77
78    /// Enforce a minimum only on the width axis; the height axis is unconstrained by this modifier.
79    pub fn width(width: f32) -> Self {
80        Self {
81            child_id: None,
82            pending_child: None,
83            min_width: Some(Prop::Static(width)),
84            min_height: None,
85        }
86    }
87
88    /// Enforce a minimum only on the height axis; the width axis is unconstrained by this modifier.
89    pub fn height(height: f32) -> Self {
90        Self {
91            child_id: None,
92            pending_child: None,
93            min_width: None,
94            min_height: Some(Prop::Static(height)),
95        }
96    }
97
98    /// Bind min width to a reactive state.
99    pub fn min_width(mut self, state: impl Into<Prop<f32>>) -> Self {
100        self.min_width = Some(state.into());
101        self
102    }
103
104    /// Bind min height to a reactive state.
105    pub fn min_height(mut self, state: impl Into<Prop<f32>>) -> Self {
106        self.min_height = Some(state.into());
107        self
108    }
109
110    /// Set an inline child widget (deferred insertion).
111    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
112        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
113        self
114    }
115    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
116    ///
117    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
118    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
119    /// adds no arena node, so nothing is laid out, painted, or published to the
120    /// accessibility tree, and a stack applies no spacing around it.
121    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
122        match widget {
123            Some(w) => self.child(w),
124            None => self,
125        }
126    }
127}
128
129impl Widget for MinSize {
130    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
131        if let Some(pending) = self.pending_child.take() {
132            self.child_id = Some(match pending {
133                PendingChild::Id(id) => id,
134                PendingChild::Deferred(w) => ctx.add_boxed(w),
135            });
136        }
137        let self_id = ctx.self_id();
138        let registry = ctx.binding_registry();
139        if let Some(ref w) = self.min_width {
140            w.register_if_bound(
141                self_id,
142                registry,
143                teksilo_core::binding::BindingLevel::Relayout,
144            );
145        }
146        if let Some(ref h) = self.min_height {
147            h.register_if_bound(
148                self_id,
149                registry,
150                teksilo_core::binding::BindingLevel::Relayout,
151            );
152        }
153        self.child_id.into_iter().collect()
154    }
155
156    fn layout_response(
157        &self,
158        proposal: SizeProposal,
159        ctx: &LayoutContext,
160    ) -> teksilo_core::widget::LayoutResponse {
161        let min_w = self.min_width.as_ref().map(|r| r.get());
162        let min_h = self.min_height.as_ref().map(|r| r.get());
163
164        // Raise a bounded proposal to the minimum before forwarding, so
165        // wrap-aware children (TextWidget, etc.) measure against the width
166        // they will be placed into. An unbounded axis is forwarded unbounded:
167        // a minimum is a floor, not a size. Proposing the minimum there would
168        // make it a ceiling instead — a stack measures its children unbounded
169        // on its main axis, so a shrinkable label inside `MinSize` (a
170        // `Checkbox`'s, a `RadioButton`'s) was squeezed to its ellipsis in
171        // every `HStack`. The floor applies to the child's answer below.
172        let clamped_proposal = SizeProposal {
173            width: match (proposal.width, min_w) {
174                (Some(w), Some(min)) => Some(w.max(min)),
175                (w, _) => w,
176            },
177            height: match (proposal.height, min_h) {
178                (Some(h), Some(min)) => Some(h.max(min)),
179                (h, _) => h,
180            },
181        };
182
183        // Use child_layout_response to capture flex so a Spacer (or any
184        // other flex child) inside MinSize is still seen as flex by the
185        // parent stack. Without this, MinSize(Spacer) would report flex=0
186        // and break the parent HStack's slack distribution.
187        let child_response = self
188            .child_id
189            .and_then(|id| ctx.child_layout_response(id, clamped_proposal));
190        let child_size = child_response
191            .as_ref()
192            .map(|r| r.size)
193            .unwrap_or(Size::ZERO);
194        let child_flex = child_response.map(|r| r.flex).unwrap_or(0.0);
195        let child_shrink = child_response.map(|r| r.shrink).unwrap_or(0.0);
196        let child_min = child_response.map(|r| r.min).unwrap_or(Size::ZERO);
197
198        let w = match min_w {
199            Some(min) => child_size.width.max(min),
200            None => child_size.width,
201        };
202        let h = match min_h {
203            Some(min) => child_size.height.max(min),
204            None => child_size.height,
205        };
206
207        // Compose the compression floor: a shrinkable child may still shrink,
208        // but never below MinSize's own minimum nor the child's own floor.
209        // Clamp componentwise to the wanted size so `min <= size` holds.
210        let floor_w = child_min.width.max(min_w.unwrap_or(0.0)).min(w);
211        let floor_h = child_min.height.max(min_h.unwrap_or(0.0)).min(h);
212        teksilo_core::widget::LayoutResponse::flexible(Size::new(w, h), child_flex)
213            .with_shrink(child_shrink)
214            .with_min(Size::new(floor_w, floor_h))
215    }
216
217    fn place_children(
218        &self,
219        bounds: Rect,
220        _proposal: SizeProposal,
221        children: &mut [WidgetPlacement],
222        _ctx: &LayoutContext,
223    ) {
224        for child in children.iter_mut() {
225            child.origin = bounds.origin();
226            child.size = bounds.size();
227        }
228    }
229
230    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
231
232    fn children(&self) -> Vec<WidgetId> {
233        self.child_id.into_iter().collect()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use teksilo_core::signal::Signal;
241    use teksilo_core::widget_tree::WidgetTree;
242
243    #[derive(Debug)]
244    struct FixedLeaf(f32, f32);
245    impl Widget for FixedLeaf {
246        fn layout_response(
247            &self,
248            _proposal: SizeProposal,
249            _ctx: &LayoutContext,
250        ) -> teksilo_core::widget::LayoutResponse {
251            Size::new(self.0, self.1).into()
252        }
253    }
254
255    #[test]
256    fn clamps_small_child_to_minimum() {
257        let mut tree = WidgetTree::new();
258        let child = tree.add(FixedLeaf(20.0, 10.0));
259        let min = tree.add(MinSize::new(48.0, 48.0).child(child));
260        tree.layout(SizeProposal::unspecified());
261
262        let mb = tree.bounds(min);
263        assert!((mb.width - 48.0).abs() < 0.01);
264        assert!((mb.height - 48.0).abs() < 0.01);
265    }
266
267    #[test]
268    fn large_child_is_not_clamped() {
269        let mut tree = WidgetTree::new();
270        let child = tree.add(FixedLeaf(100.0, 80.0));
271        let min = tree.add(MinSize::new(48.0, 48.0).child(child));
272        tree.layout(SizeProposal::unspecified());
273
274        let mb = tree.bounds(min);
275        assert!((mb.width - 100.0).abs() < 0.01);
276        assert!((mb.height - 80.0).abs() < 0.01);
277    }
278
279    #[test]
280    fn min_width_only() {
281        let mut tree = WidgetTree::new();
282        let child = tree.add(FixedLeaf(20.0, 10.0));
283        let min = tree.add(MinSize::width(48.0).child(child));
284        tree.layout(SizeProposal::unspecified());
285
286        let mb = tree.bounds(min);
287        assert!((mb.width - 48.0).abs() < 0.01);
288        assert!((mb.height - 10.0).abs() < 0.01);
289    }
290
291    #[test]
292    fn min_width_dynamic() {
293        let min_w = Signal::new(48.0_f32);
294        let mut tree = WidgetTree::new();
295        let child = tree.add(FixedLeaf(20.0, 10.0));
296        let min = tree.add(MinSize::width(0.0).min_width(min_w.clone()).child(child));
297        tree.layout(SizeProposal::unspecified());
298        assert!((tree.bounds(min).width - 48.0).abs() < 0.01);
299
300        min_w.set(80.0);
301        tree.layout(SizeProposal::unspecified());
302        assert!((tree.bounds(min).width - 80.0).abs() < 0.01);
303    }
304
305    /// A leaf that simulates wrapping text: it has 120 logical px of
306    /// content. When the proposal width is >= 120 it reports 120×20
307    /// (single line). When narrower, it wraps: width = proposal,
308    /// height = ceil(120 / proposal) * 20.
309    #[derive(Debug)]
310    struct WrappingLeaf;
311    impl Widget for WrappingLeaf {
312        fn layout_response(
313            &self,
314            proposal: SizeProposal,
315            _ctx: &LayoutContext,
316        ) -> teksilo_core::widget::LayoutResponse {
317            let content_width = 120.0_f32;
318            let line_height = 20.0_f32;
319            let w = proposal.width.unwrap_or(content_width).min(content_width);
320            let lines = (content_width / w.max(1.0)).ceil();
321            Size::new(w, lines * line_height).into()
322        }
323    }
324
325    #[test]
326    fn a_narrow_proposal_is_raised_to_the_minimum_before_the_child_measures() {
327        // A VStack 60 px wide proposes 60 to the MinSize (min_width=100). The
328        // MinSize will be placed 100 wide, so the wrapping child must measure
329        // at 100, not 60: its height is then the height it is placed at.
330        use crate::primitives::vstack::VStack;
331
332        let mut tree = WidgetTree::new();
333        let child = tree.add(WrappingLeaf);
334        let min = tree.add(MinSize::width(100.0).child(child));
335        let _stack = tree.add(VStack::new().child(min));
336        tree.layout(SizeProposal::exact(60.0, 200.0));
337
338        let mb = tree.bounds(min);
339        assert!(
340            (mb.width - 100.0).abs() < 0.01,
341            "width should be 100, got {}",
342            mb.width
343        );
344        // At 100px width: ceil(120/100) = 2 lines → 40px height
345        assert!(
346            (mb.height - 40.0).abs() < 0.01,
347            "height should be 40 (2 lines at 100px), got {}",
348            mb.height
349        );
350    }
351
352    #[test]
353    fn an_unbounded_axis_stays_unbounded_for_the_child() {
354        // No width proposed: the child answers with its ideal width (one
355        // 120 px line), above the 80 px floor. Forwarding the floor as the
356        // proposal would wrap it at 80 — a minimum acting as a maximum.
357        let mut tree = WidgetTree::new();
358        let child = tree.add(WrappingLeaf);
359        let min = tree.add(MinSize::width(80.0).child(child));
360        tree.layout(SizeProposal::unspecified());
361
362        let mb = tree.bounds(min);
363        assert!(
364            (mb.width - 120.0).abs() < 0.01,
365            "width should be 120, got {}",
366            mb.width
367        );
368        assert!(
369            (mb.height - 20.0).abs() < 0.01,
370            "height should be 20 (one line), got {}",
371            mb.height
372        );
373    }
374
375    /// A single-line label: 120 px wanted, shrinkable down to a 12 px
376    /// ellipsis, answering a bounded proposal with what fits.
377    #[derive(Debug)]
378    struct ShrinkableLabel;
379    impl Widget for ShrinkableLabel {
380        fn layout_response(
381            &self,
382            proposal: SizeProposal,
383            _ctx: &LayoutContext,
384        ) -> teksilo_core::widget::LayoutResponse {
385            let w = proposal.width.unwrap_or(120.0).clamp(12.0, 120.0);
386            teksilo_core::widget::LayoutResponse::shrinkable(
387                Size::new(w, 20.0),
388                Size::new(12.0, 20.0),
389                1.0,
390            )
391        }
392    }
393
394    #[test]
395    fn a_shrinkable_child_keeps_its_wanted_width_on_a_stacks_main_axis() {
396        // An HStack measures its children unbounded on its main axis. Inside
397        // a 24 px MinSize, the label must still report its 120 px, not the
398        // 24 px floor squeezed down to its ellipsis.
399        use crate::primitives::hstack::HStack;
400
401        let mut tree = WidgetTree::new();
402        let label = tree.add(ShrinkableLabel);
403        let min = tree.add(MinSize::new(24.0, 24.0).child(label));
404        let _row = tree.add(HStack::new().child(min));
405        tree.layout(SizeProposal::exact(900.0, 100.0));
406
407        let mb = tree.bounds(min);
408        assert!(
409            (mb.width - 120.0).abs() < 0.01,
410            "width should be 120, got {}",
411            mb.width
412        );
413        assert!(
414            (tree.bounds(label).width - 120.0).abs() < 0.01,
415            "the label should be 120, got {}",
416            tree.bounds(label).width
417        );
418    }
419}