Skip to main content

frust_core/
layout.rs

1//! Box-constraint layout, the Flutter/Masonry box model (not CSS): a parent
2//! hands each child [`BoxConstraints`] and the child returns a [`Size`] within them.
3
4use kurbo::Size;
5
6/// Clamp both dimensions of a size to be non-negative.
7fn non_negative(size: Size) -> Size {
8    Size::new(size.width.max(0.0), size.height.max(0.0))
9}
10
11/// Min/max size bounds passed down during layout. Every constructor keeps both
12/// non-negative with `min <= max`, so [`constrain`](Self::constrain) never panics.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct BoxConstraints {
15    min: Size,
16    max: Size,
17}
18
19impl BoxConstraints {
20    /// Bounds clamped non-negative; an inverted axis widens `max` to `min`, with a
21    /// debug-build diagnostic since it usually means an upstream layout bug.
22    pub fn new(min: Size, max: Size) -> Self {
23        let min = non_negative(min);
24        let max = non_negative(max);
25        #[cfg(debug_assertions)]
26        if max.width < min.width || max.height < min.height {
27            eprintln!(
28                "frust-core: BoxConstraints::new received inverted bounds \
29                 (min {min:?} > max {max:?}); widening max to min — likely an \
30                 upstream layout bug"
31            );
32        }
33        let max = Size::new(max.width.max(min.width), max.height.max(min.height));
34        Self { min, max }
35    }
36
37    /// Tight constraints that force an exact `size` (`min == max == size`).
38    pub fn tight(size: Size) -> Self {
39        let size = non_negative(size);
40        Self {
41            min: size,
42            max: size,
43        }
44    }
45
46    /// Loose constraints allowing anything from zero up to `max`.
47    pub fn loose(max: Size) -> Self {
48        Self {
49            min: Size::ZERO,
50            max: non_negative(max),
51        }
52    }
53
54    /// The minimum allowed size.
55    pub fn min(&self) -> Size {
56        self.min
57    }
58
59    /// The maximum allowed size.
60    pub fn max(&self) -> Size {
61        self.max
62    }
63
64    /// Clamp `size` so it lies within `[min, max]` on both axes.
65    pub fn constrain(&self, size: Size) -> Size {
66        Size::new(
67            size.width.clamp(self.min.width, self.max.width),
68            size.height.clamp(self.min.height, self.max.height),
69        )
70    }
71
72    /// Relax the minimum to zero, keeping the same maximum.
73    pub fn loosen(&self) -> Self {
74        Self {
75            min: Size::ZERO,
76            max: self.max,
77        }
78    }
79
80    /// Tight constraints at `size` clamped into this range.
81    pub fn tighten(&self, size: Size) -> Self {
82        let size = self.constrain(size);
83        Self {
84            min: size,
85            max: size,
86        }
87    }
88
89    /// Whether the constraints force an exact size (`min == max`).
90    pub fn is_tight(&self) -> bool {
91        self.min == self.max
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn constrain_clamps_into_range() {
101        let bc = BoxConstraints::new(Size::new(10.0, 10.0), Size::new(100.0, 100.0));
102        // Below the minimum snaps up.
103        assert_eq!(bc.constrain(Size::new(5.0, 5.0)), Size::new(10.0, 10.0));
104        // Above the maximum snaps down.
105        assert_eq!(
106            bc.constrain(Size::new(200.0, 200.0)),
107            Size::new(100.0, 100.0)
108        );
109        // Within range is untouched.
110        assert_eq!(bc.constrain(Size::new(50.0, 40.0)), Size::new(50.0, 40.0));
111    }
112
113    #[test]
114    fn loose_and_loosen_zero_the_minimum() {
115        let bc = BoxConstraints::loose(Size::new(80.0, 60.0));
116        assert_eq!(bc.min(), Size::ZERO);
117        assert_eq!(bc.max(), Size::new(80.0, 60.0));
118
119        let tight = BoxConstraints::tight(Size::new(30.0, 30.0));
120        let loosened = tight.loosen();
121        assert_eq!(loosened.min(), Size::ZERO);
122        assert_eq!(loosened.max(), Size::new(30.0, 30.0));
123    }
124
125    #[test]
126    fn tight_forces_exact_size() {
127        let bc = BoxConstraints::tight(Size::new(42.0, 24.0));
128        assert!(bc.is_tight());
129        assert_eq!(bc.min(), bc.max());
130        assert_eq!(
131            bc.constrain(Size::new(1000.0, 1000.0)),
132            Size::new(42.0, 24.0)
133        );
134    }
135
136    #[test]
137    fn tighten_clamps_then_locks() {
138        let bc = BoxConstraints::new(Size::new(10.0, 10.0), Size::new(100.0, 100.0));
139        // Request beyond max is clamped, then locked as tight.
140        let t = bc.tighten(Size::new(500.0, 5.0));
141        assert!(t.is_tight());
142        assert_eq!(t.min(), Size::new(100.0, 10.0));
143    }
144
145    #[test]
146    fn constructors_reject_negative_sizes() {
147        let bc = BoxConstraints::loose(Size::new(-5.0, -5.0));
148        assert_eq!(bc.max(), Size::ZERO);
149    }
150
151    #[test]
152    fn new_widens_max_when_one_axis_is_inverted() {
153        // width is inverted (min > max); height is consistent.
154        let bc = BoxConstraints::new(Size::new(100.0, 10.0), Size::new(10.0, 100.0));
155        assert!(bc.min().width <= bc.max().width);
156        assert!(bc.min().height <= bc.max().height);
157        assert_eq!(bc.min(), Size::new(100.0, 10.0));
158        assert_eq!(bc.max(), Size::new(100.0, 100.0));
159
160        // Does not panic and returns a sane, in-range value.
161        let constrained = bc.constrain(Size::new(0.0, 0.0));
162        assert_eq!(constrained, Size::new(100.0, 10.0));
163    }
164
165    #[test]
166    fn new_widens_max_when_both_axes_are_inverted() {
167        let bc = BoxConstraints::new(Size::new(100.0, 100.0), Size::new(10.0, 10.0));
168        assert!(bc.min().width <= bc.max().width);
169        assert!(bc.min().height <= bc.max().height);
170        assert_eq!(bc.min(), Size::new(100.0, 100.0));
171        assert_eq!(bc.max(), Size::new(100.0, 100.0));
172        assert!(bc.is_tight());
173
174        // Does not panic and returns a sane, in-range value.
175        let constrained = bc.constrain(Size::new(1000.0, 1000.0));
176        assert_eq!(constrained, Size::new(100.0, 100.0));
177    }
178}