Skip to main content

term_wm_layout_engine/
rect.rs

1use core::fmt;
2
3/// A rectangle with signed origin and unsigned dimensions.
4///
5/// Used throughout the engine to represent both screen-space regions and
6/// floating-window geometry where off-screen coordinates are valid.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct LayoutRect {
9    pub x: i32,
10    pub y: i32,
11    pub width: u16,
12    pub height: u16,
13}
14
15impl LayoutRect {
16    /// Centre point of the rectangle, rounding down on odd dimensions.
17    pub fn center(&self) -> (i32, i32) {
18        (
19            self.x + i32::from(self.width) / 2,
20            self.y + i32::from(self.height) / 2,
21        )
22    }
23
24    pub fn contains(&self, col: u16, row: u16) -> bool {
25        if self.width == 0 || self.height == 0 {
26            return false;
27        }
28        let max_x = self.x.saturating_add(i32::from(self.width));
29        let max_y = self.y.saturating_add(i32::from(self.height));
30        i32::from(col) >= self.x
31            && i32::from(col) < max_x
32            && i32::from(row) >= self.y
33            && i32::from(row) < max_y
34    }
35
36    pub fn clamp(self, bounds: LayoutRect) -> LayoutRect {
37        let x1 = self.x.max(bounds.x);
38        let y1 = self.y.max(bounds.y);
39        let self_right = self.x.saturating_add(i32::from(self.width));
40        let bounds_right = bounds.x.saturating_add(i32::from(bounds.width));
41        let self_bottom = self.y.saturating_add(i32::from(self.height));
42        let bounds_bottom = bounds.y.saturating_add(i32::from(bounds.height));
43        let x2 = self_right.min(bounds_right);
44        let y2 = self_bottom.min(bounds_bottom);
45        if x2 <= x1 || y2 <= y1 {
46            return LayoutRect {
47                x: 0,
48                y: 0,
49                width: 0,
50                height: 0,
51            };
52        }
53        LayoutRect {
54            x: x1,
55            y: y1,
56            width: (x2.saturating_sub(x1)) as u16,
57            height: (y2.saturating_sub(y1)) as u16,
58        }
59    }
60
61    pub fn visible_portion(self, bounds: LayoutRect) -> LayoutRect {
62        self.clamp(bounds)
63    }
64
65    pub fn intersects(self, other: LayoutRect) -> bool {
66        let a_right = self.x.saturating_add(i32::from(self.width));
67        let a_bottom = self.y.saturating_add(i32::from(self.height));
68        let b_right = other.x.saturating_add(i32::from(other.width));
69        let b_bottom = other.y.saturating_add(i32::from(other.height));
70        self.x < b_right && a_right > other.x && self.y < b_bottom && a_bottom > other.y
71    }
72}
73
74/// Convenience wrapper around [`LayoutRect::contains`].
75pub fn rect_contains(rect: &LayoutRect, col: u16, row: u16) -> bool {
76    rect.contains(col, row)
77}
78
79/// Shrink a rectangle by the given margins on each side.
80/// The resulting width/height saturate at zero.
81pub fn inset(rect: LayoutRect, left: u16, right: u16, top: u16, bottom: u16) -> LayoutRect {
82    LayoutRect {
83        x: rect.x.saturating_add(i32::from(left)),
84        y: rect.y.saturating_add(i32::from(top)),
85        width: rect.width.saturating_sub(left.saturating_add(right)),
86        height: rect.height.saturating_sub(top.saturating_add(bottom)),
87    }
88}
89
90/// Offset a rectangle by `gap * index` along the given orientation.
91/// Used when placing children in a split with inter-child gaps.
92pub fn gap_insert(
93    rect: LayoutRect,
94    gap: u16,
95    index: usize,
96    orientation: Orientation,
97) -> LayoutRect {
98    let offset = gap.saturating_mul(index as u16);
99    match orientation {
100        Orientation::Horizontal => LayoutRect {
101            x: rect.x.saturating_add(i32::from(offset)),
102            ..rect
103        },
104        Orientation::Vertical => LayoutRect {
105            y: rect.y.saturating_add(i32::from(offset)),
106            ..rect
107        },
108    }
109}
110
111/// The direction children are stacked in a split container.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Orientation {
114    /// Children are placed left-to-right, sharing the available height.
115    Horizontal,
116    /// Children are placed top-to-bottom, sharing the available width.
117    Vertical,
118}
119
120/// One of the four cardinal directions, used for drag-and-drop insertion.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Quadrant {
123    North,
124    South,
125    East,
126    West,
127}
128
129/// An integer ratio `(p, q)` meaning `p/(p+q)` of the parent's size.
130///
131/// Remainder isolation guarantees `sum(child sizes) == parent size` —
132/// the last child receives any leftover pixels.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Ratio(pub u16, pub u16);
135
136impl Ratio {
137    /// Equal split: `(1, 1)` = 50/50.
138    pub fn half() -> Self {
139        Ratio(1, 1)
140    }
141
142    /// Numerator of the ratio.
143    pub fn left_part(&self) -> u16 {
144        self.0
145    }
146
147    /// Denominator contribution of the ratio.
148    pub fn right_part(&self) -> u16 {
149        self.1
150    }
151
152    /// Sum of both parts.
153    pub fn total(&self) -> u16 {
154        self.0 + self.1
155    }
156}
157
158/// Minimum dimensions enforced by tree mutation functions.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct SizeConstraints {
161    pub min_width: u16,
162    pub min_height: u16,
163}
164
165/// Errors returned by tree mutation operations.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum LayoutError {
168    /// The operation would produce a child smaller than the allowed minimum.
169    ConstraintViolated(SizeConstraints),
170    /// The target node was not found in the tree.
171    NotFound,
172}
173
174impl fmt::Display for LayoutError {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            LayoutError::ConstraintViolated(c) => {
178                write!(
179                    f,
180                    "minimum dimension violated (min {}x{})",
181                    c.min_width, c.min_height
182                )
183            }
184            LayoutError::NotFound => write!(f, "target node not found"),
185        }
186    }
187}
188
189/// A rectangle specification that can be either absolute or percentage-based.
190///
191/// Percentage values are relative to `bounds` at resolution time.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum RectSpec {
194    /// Fixed pixel/cell position and size.
195    Absolute(LayoutRect),
196    /// Percentage of the bounding rectangle.
197    Percent {
198        x: u16,
199        y: u16,
200        width: u16,
201        height: u16,
202    },
203}
204
205impl RectSpec {
206    /// Resolve this spec against `bounds` to produce a concrete [`LayoutRect`].
207    pub fn resolve(&self, bounds: LayoutRect) -> LayoutRect {
208        match *self {
209            RectSpec::Absolute(r) => r,
210            RectSpec::Percent {
211                x,
212                y,
213                width,
214                height,
215            } => {
216                let bw = i32::from(bounds.width);
217                let bh = i32::from(bounds.height);
218                LayoutRect {
219                    x: bounds.x.saturating_add(bw * i32::from(x) / 100),
220                    y: bounds.y.saturating_add(bh * i32::from(y) / 100),
221                    width: ((bw * i32::from(width) / 100) as u16).min(bounds.width),
222                    height: ((bh * i32::from(height) / 100) as u16).min(bounds.height),
223                }
224            }
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn r(x: i32, y: i32, w: u16, h: u16) -> LayoutRect {
234        LayoutRect {
235            x,
236            y,
237            width: w,
238            height: h,
239        }
240    }
241
242    #[test]
243    fn center_of_rect() {
244        assert_eq!(r(10, 20, 100, 60).center(), (60, 50));
245    }
246
247    #[test]
248    fn contains_inside() {
249        assert!(r(0, 0, 10, 10).contains(5, 5));
250    }
251
252    #[test]
253    fn contains_outside() {
254        assert!(!r(0, 0, 10, 10).contains(10, 10));
255    }
256
257    #[test]
258    fn contains_zero_dim() {
259        assert!(!r(0, 0, 0, 10).contains(0, 0));
260    }
261
262    #[test]
263    fn ratio_half() {
264        assert_eq!(Ratio::half(), Ratio(1, 1));
265    }
266
267    #[test]
268    fn clamp_within_bounds() {
269        let result = r(5, 5, 10, 10).clamp(r(0, 0, 20, 20));
270        assert_eq!(result, r(5, 5, 10, 10));
271    }
272
273    #[test]
274    fn clamp_partially_outside() {
275        let result = r(-5, -5, 20, 20).clamp(r(0, 0, 10, 10));
276        assert_eq!(result, r(0, 0, 10, 10));
277    }
278
279    #[test]
280    fn clamp_fully_outside() {
281        let result = r(100, 100, 10, 10).clamp(r(0, 0, 10, 10));
282        assert_eq!(result.width, 0);
283        assert_eq!(result.height, 0);
284    }
285
286    #[test]
287    fn intersects_overlapping() {
288        assert!(r(0, 0, 10, 10).intersects(r(5, 5, 10, 10)));
289    }
290
291    #[test]
292    fn intersects_non_overlapping() {
293        assert!(!r(0, 0, 10, 10).intersects(r(20, 20, 10, 10)));
294    }
295
296    #[test]
297    fn visible_portion_same_as_clamp() {
298        let r1 = r(-5, -5, 20, 20);
299        let bounds = r(0, 0, 10, 10);
300        assert_eq!(r1.visible_portion(bounds), r1.clamp(bounds));
301    }
302
303    #[test]
304    fn inset_shrinks_rect() {
305        let result = inset(r(10, 10, 100, 50), 5, 5, 2, 2);
306        assert_eq!(result.x, 15);
307        assert_eq!(result.y, 12);
308        assert_eq!(result.width, 90);
309        assert_eq!(result.height, 46);
310    }
311
312    #[test]
313    fn gap_insert_horizontal() {
314        let result = gap_insert(r(0, 0, 80, 24), 2, 1, Orientation::Horizontal);
315        assert_eq!(result.x, 2);
316        assert_eq!(result.y, 0);
317    }
318
319    #[test]
320    fn gap_insert_vertical() {
321        let result = gap_insert(r(0, 0, 80, 24), 2, 1, Orientation::Vertical);
322        assert_eq!(result.x, 0);
323        assert_eq!(result.y, 2);
324    }
325
326    #[test]
327    fn rect_spec_absolute() {
328        let spec = RectSpec::Absolute(r(10, 20, 30, 40));
329        let resolved = spec.resolve(r(0, 0, 80, 24));
330        assert_eq!(resolved, r(10, 20, 30, 40));
331    }
332
333    #[test]
334    fn rect_spec_percent() {
335        let spec = RectSpec::Percent {
336            x: 50,
337            y: 50,
338            width: 50,
339            height: 50,
340        };
341        let resolved = spec.resolve(r(0, 0, 100, 100));
342        assert_eq!(resolved, r(50, 50, 50, 50));
343    }
344}