Skip to main content

frust_core/
layout.rs

1//! Box-constraint layout model.
2//!
3//! Constraints flow *down* the widget tree; sizes flow *up*. This is the
4//! Flutter/Masonry box model — not full CSS. A parent hands each child a
5//! [`BoxConstraints`] describing the allowed `min`/`max` size, and the child
6//! returns the concrete [`Size`] it chose within those bounds.
7
8use kurbo::Size;
9
10/// Clamp both dimensions of a size to be non-negative.
11fn non_negative(size: Size) -> Size {
12    Size::new(size.width.max(0.0), size.height.max(0.0))
13}
14
15/// Immutable min/max size bounds passed down during layout.
16///
17/// Invariant: both `min` and `max` are non-negative and `min <= max`
18/// component-wise. This is enforced unconditionally by every constructor —
19/// there is no way to construct a `BoxConstraints` that violates it, so
20/// [`constrain`](Self::constrain) can never panic.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct BoxConstraints {
23    min: Size,
24    max: Size,
25}
26
27impl BoxConstraints {
28    /// Construct constraints from explicit `min`/`max` bounds.
29    ///
30    /// Both bounds are clamped to be non-negative. If `max` is smaller than
31    /// `min` on either axis (an inverted/inconsistent input), `max` is
32    /// widened up to `min` on that axis — `min` is authoritative — so the
33    /// resulting constraints always satisfy `min <= max`. Inverted input
34    /// usually indicates an upstream layout bug (e.g. padding subtraction
35    /// underflow), so debug builds print a diagnostic when this correction
36    /// fires; release builds correct silently.
37    pub fn new(min: Size, max: Size) -> Self {
38        let min = non_negative(min);
39        let max = non_negative(max);
40        #[cfg(debug_assertions)]
41        if max.width < min.width || max.height < min.height {
42            eprintln!(
43                "frust-core: BoxConstraints::new received inverted bounds \
44                 (min {min:?} > max {max:?}); widening max to min — likely an \
45                 upstream layout bug"
46            );
47        }
48        let max = Size::new(max.width.max(min.width), max.height.max(min.height));
49        Self { min, max }
50    }
51
52    /// Tight constraints that force an exact `size` (`min == max == size`).
53    pub fn tight(size: Size) -> Self {
54        let size = non_negative(size);
55        Self {
56            min: size,
57            max: size,
58        }
59    }
60
61    /// Loose constraints allowing anything from zero up to `max`.
62    pub fn loose(max: Size) -> Self {
63        Self {
64            min: Size::ZERO,
65            max: non_negative(max),
66        }
67    }
68
69    /// The minimum allowed size.
70    pub fn min(&self) -> Size {
71        self.min
72    }
73
74    /// The maximum allowed size.
75    pub fn max(&self) -> Size {
76        self.max
77    }
78
79    /// Clamp `size` so it lies within `[min, max]` on both axes.
80    pub fn constrain(&self, size: Size) -> Size {
81        Size::new(
82            size.width.clamp(self.min.width, self.max.width),
83            size.height.clamp(self.min.height, self.max.height),
84        )
85    }
86
87    /// Relax the minimum to zero, keeping the same maximum.
88    pub fn loosen(&self) -> Self {
89        Self {
90            min: Size::ZERO,
91            max: self.max,
92        }
93    }
94
95    /// Return tight constraints at the `size` clamped into this range.
96    ///
97    /// Useful when a parent wants to force a child to a specific size that
98    /// still respects the incoming bounds.
99    pub fn tighten(&self, size: Size) -> Self {
100        let size = self.constrain(size);
101        Self {
102            min: size,
103            max: size,
104        }
105    }
106
107    /// Whether the constraints force an exact size (`min == max`).
108    pub fn is_tight(&self) -> bool {
109        self.min == self.max
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn constrain_clamps_into_range() {
119        let bc = BoxConstraints::new(Size::new(10.0, 10.0), Size::new(100.0, 100.0));
120        // Below the minimum snaps up.
121        assert_eq!(bc.constrain(Size::new(5.0, 5.0)), Size::new(10.0, 10.0));
122        // Above the maximum snaps down.
123        assert_eq!(
124            bc.constrain(Size::new(200.0, 200.0)),
125            Size::new(100.0, 100.0)
126        );
127        // Within range is untouched.
128        assert_eq!(bc.constrain(Size::new(50.0, 40.0)), Size::new(50.0, 40.0));
129    }
130
131    #[test]
132    fn loose_and_loosen_zero_the_minimum() {
133        let bc = BoxConstraints::loose(Size::new(80.0, 60.0));
134        assert_eq!(bc.min(), Size::ZERO);
135        assert_eq!(bc.max(), Size::new(80.0, 60.0));
136
137        let tight = BoxConstraints::tight(Size::new(30.0, 30.0));
138        let loosened = tight.loosen();
139        assert_eq!(loosened.min(), Size::ZERO);
140        assert_eq!(loosened.max(), Size::new(30.0, 30.0));
141    }
142
143    #[test]
144    fn tight_forces_exact_size() {
145        let bc = BoxConstraints::tight(Size::new(42.0, 24.0));
146        assert!(bc.is_tight());
147        assert_eq!(bc.min(), bc.max());
148        assert_eq!(
149            bc.constrain(Size::new(1000.0, 1000.0)),
150            Size::new(42.0, 24.0)
151        );
152    }
153
154    #[test]
155    fn tighten_clamps_then_locks() {
156        let bc = BoxConstraints::new(Size::new(10.0, 10.0), Size::new(100.0, 100.0));
157        // Request beyond max is clamped, then locked as tight.
158        let t = bc.tighten(Size::new(500.0, 5.0));
159        assert!(t.is_tight());
160        assert_eq!(t.min(), Size::new(100.0, 10.0));
161    }
162
163    #[test]
164    fn constructors_reject_negative_sizes() {
165        let bc = BoxConstraints::loose(Size::new(-5.0, -5.0));
166        assert_eq!(bc.max(), Size::ZERO);
167    }
168
169    #[test]
170    fn new_widens_max_when_one_axis_is_inverted() {
171        // width is inverted (min > max); height is consistent.
172        let bc = BoxConstraints::new(Size::new(100.0, 10.0), Size::new(10.0, 100.0));
173        assert!(bc.min().width <= bc.max().width);
174        assert!(bc.min().height <= bc.max().height);
175        assert_eq!(bc.min(), Size::new(100.0, 10.0));
176        assert_eq!(bc.max(), Size::new(100.0, 100.0));
177
178        // Does not panic and returns a sane, in-range value.
179        let constrained = bc.constrain(Size::new(0.0, 0.0));
180        assert_eq!(constrained, Size::new(100.0, 10.0));
181    }
182
183    #[test]
184    fn new_widens_max_when_both_axes_are_inverted() {
185        let bc = BoxConstraints::new(Size::new(100.0, 100.0), Size::new(10.0, 10.0));
186        assert!(bc.min().width <= bc.max().width);
187        assert!(bc.min().height <= bc.max().height);
188        assert_eq!(bc.min(), Size::new(100.0, 100.0));
189        assert_eq!(bc.max(), Size::new(100.0, 100.0));
190        assert!(bc.is_tight());
191
192        // Does not panic and returns a sane, in-range value.
193        let constrained = bc.constrain(Size::new(1000.0, 1000.0));
194        assert_eq!(constrained, Size::new(100.0, 100.0));
195    }
196}