Skip to main content

slt/
rect.rs

1//! Axis-aligned rectangle type used throughout SLT for layout regions,
2//! clipping bounds, and hit-test areas.
3
4/// An axis-aligned rectangle with `u32` coordinates.
5///
6/// Uses `u32` rather than `u16` to avoid overflow bugs that affect other TUI
7/// libraries on large terminals. All coordinates are in terminal columns and
8/// rows, with `(0, 0)` at the top-left.
9///
10/// Note: [`Rect::right`] and [`Rect::bottom`] return **exclusive** bounds
11/// (one past the last column/row), consistent with Rust range conventions.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub struct Rect {
14    /// Left edge column, inclusive.
15    pub x: u32,
16    /// Top edge row, inclusive.
17    pub y: u32,
18    /// Width in terminal columns.
19    pub width: u32,
20    /// Height in terminal rows.
21    pub height: u32,
22}
23
24impl Rect {
25    /// Create a new rectangle from position and size.
26    #[inline]
27    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
28        Self {
29            x,
30            y,
31            width,
32            height,
33        }
34    }
35
36    /// Total area in cells (`width * height`).
37    #[inline]
38    pub const fn area(&self) -> u32 {
39        self.width.saturating_mul(self.height)
40    }
41
42    /// Exclusive right edge (`x + width`).
43    ///
44    /// This is one column past the last column in the rectangle.
45    #[inline]
46    pub const fn right(&self) -> u32 {
47        self.x + self.width
48    }
49
50    /// Exclusive bottom edge (`y + height`).
51    ///
52    /// This is one row past the last row in the rectangle.
53    #[inline]
54    pub const fn bottom(&self) -> u32 {
55        self.y + self.height
56    }
57
58    /// Returns `true` if the rectangle has zero area (width or height is zero).
59    #[inline]
60    pub const fn is_empty(&self) -> bool {
61        self.width == 0 || self.height == 0
62    }
63
64    /// Returns a smaller Rect centered within self.
65    ///
66    /// If the inner dimensions exceed self's dimensions, they are clamped to self's size.
67    /// The returned rectangle is positioned such that it is centered both horizontally
68    /// and vertically within self.
69    ///
70    /// # Example
71    /// ```
72    /// use slt::Rect;
73    /// let outer = Rect::new(0, 0, 10, 10);
74    /// let inner = outer.centered(4, 4);
75    /// assert_eq!(inner, Rect::new(3, 3, 4, 4));
76    /// ```
77    #[inline]
78    pub fn centered(&self, inner_w: u32, inner_h: u32) -> Rect {
79        let w = inner_w.min(self.width);
80        let h = inner_h.min(self.height);
81        let x = self.x + (self.width.saturating_sub(w)) / 2;
82        let y = self.y + (self.height.saturating_sub(h)) / 2;
83        Rect {
84            x,
85            y,
86            width: w,
87            height: h,
88        }
89    }
90
91    /// Returns the smallest Rect containing both self and other.
92    ///
93    /// The union encompasses all cells in both rectangles. If either rectangle is empty,
94    /// the result may have unexpected dimensions; use `is_empty()` to check.
95    ///
96    /// # Example
97    /// ```
98    /// use slt::Rect;
99    /// let r1 = Rect::new(0, 0, 5, 5);
100    /// let r2 = Rect::new(3, 3, 5, 5);
101    /// let union = r1.union(r2);
102    /// assert_eq!(union, Rect::new(0, 0, 8, 8));
103    /// ```
104    #[inline]
105    pub fn union(&self, other: Rect) -> Rect {
106        let x = self.x.min(other.x);
107        let y = self.y.min(other.y);
108        let right = self.right().max(other.right());
109        let bottom = self.bottom().max(other.bottom());
110        Rect {
111            x,
112            y,
113            width: right - x,
114            height: bottom - y,
115        }
116    }
117
118    /// Returns the overlapping region between self and other, or None if they don't overlap.
119    ///
120    /// Two rectangles overlap if they share at least one cell. Adjacent rectangles
121    /// (touching at an edge but not overlapping) return None.
122    ///
123    /// # Example
124    /// ```
125    /// use slt::Rect;
126    /// let r1 = Rect::new(0, 0, 5, 5);
127    /// let r2 = Rect::new(3, 3, 5, 5);
128    /// let overlap = r1.intersection(r2);
129    /// assert_eq!(overlap, Some(Rect::new(3, 3, 2, 2)));
130    /// ```
131    #[inline]
132    pub fn intersection(&self, other: Rect) -> Option<Rect> {
133        let x = self.x.max(other.x);
134        let y = self.y.max(other.y);
135        let right = self.right().min(other.right());
136        let bottom = self.bottom().min(other.bottom());
137
138        if x < right && y < bottom {
139            Some(Rect {
140                x,
141                y,
142                width: right - x,
143                height: bottom - y,
144            })
145        } else {
146            None
147        }
148    }
149
150    /// Returns true if the point (x, y) is inside the rectangle.
151    ///
152    /// A point is considered inside if it is within the inclusive left/top bounds
153    /// and exclusive right/bottom bounds (consistent with Rust range conventions).
154    ///
155    /// # Example
156    /// ```
157    /// use slt::Rect;
158    /// let r = Rect::new(5, 5, 10, 10);
159    /// assert!(r.contains(5, 5));   // top-left corner
160    /// assert!(r.contains(14, 14)); // inside
161    /// assert!(!r.contains(15, 15)); // outside (exclusive right/bottom)
162    /// ```
163    #[inline]
164    pub fn contains(&self, x: u32, y: u32) -> bool {
165        x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
166    }
167
168    /// Returns an iterator over row y-coordinates in this rectangle.
169    ///
170    /// Yields values from `self.y` to `self.bottom() - 1` (inclusive).
171    ///
172    /// # Example
173    /// ```
174    /// use slt::Rect;
175    /// let r = Rect::new(0, 2, 5, 3);
176    /// let rows: Vec<u32> = r.rows().collect();
177    /// assert_eq!(rows, vec![2, 3, 4]);
178    /// ```
179    #[inline]
180    pub fn rows(&self) -> impl Iterator<Item = u32> {
181        self.y..self.bottom()
182    }
183
184    /// Returns an iterator over all (x, y) positions in this rectangle, row by row.
185    ///
186    /// Iterates from top-left to bottom-right, filling each row left-to-right before
187    /// moving to the next row. Total count is `width * height`.
188    ///
189    /// # Example
190    /// ```
191    /// use slt::Rect;
192    /// let r = Rect::new(0, 0, 2, 2);
193    /// let positions: Vec<(u32, u32)> = r.positions().collect();
194    /// assert_eq!(positions, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
195    /// ```
196    #[inline]
197    pub fn positions(&self) -> impl Iterator<Item = (u32, u32)> {
198        let x_start = self.x;
199        let x_end = self.right();
200        let y_start = self.y;
201        let y_end = self.bottom();
202
203        (y_start..y_end).flat_map(move |y| (x_start..x_end).map(move |x| (x, y)))
204    }
205
206    /// Position `self` centered both horizontally and vertically inside `parent`.
207    ///
208    /// Returns a [`Rect`] with the same `width`/`height` as `self`, but with
209    /// `x`/`y` adjusted so the result is centered within `parent`. If `self`
210    /// is wider or taller than `parent` on either axis, the corresponding
211    /// dimension is clamped to `parent`'s extent on that axis (matching
212    /// [`Rect::centered`]'s clamp policy). Self's existing `x`/`y` are
213    /// ignored — only its dimensions matter.
214    ///
215    /// This is the inverse of [`Rect::centered`]: `centered` answers "give
216    /// me an inner rect of size W×H centered in me," whereas `center_in`
217    /// answers "position me centered inside parent."
218    ///
219    /// # Example
220    /// ```
221    /// use slt::Rect;
222    /// let dialog = Rect::new(0, 0, 40, 10);
223    /// let screen = Rect::new(0, 0, 120, 40);
224    /// let r = dialog.center_in(screen);
225    /// assert_eq!(r, Rect::new(40, 15, 40, 10));
226    /// ```
227    #[inline]
228    pub const fn center_in(self, parent: Rect) -> Rect {
229        let w = if self.width < parent.width {
230            self.width
231        } else {
232            parent.width
233        };
234        let h = if self.height < parent.height {
235            self.height
236        } else {
237            parent.height
238        };
239        let x = parent.x + parent.width.saturating_sub(w) / 2;
240        let y = parent.y + parent.height.saturating_sub(h) / 2;
241        Rect {
242            x,
243            y,
244            width: w,
245            height: h,
246        }
247    }
248
249    /// Position `self` centered horizontally inside `parent`; preserve `self.y` and `self.height`.
250    ///
251    /// If `self.width` exceeds `parent.width`, the returned rect's width is
252    /// clamped to `parent.width` (matching [`Rect::centered`]).
253    ///
254    /// # Example
255    /// ```
256    /// use slt::Rect;
257    /// let banner = Rect::new(0, 5, 30, 3);
258    /// let screen = Rect::new(0, 0, 120, 40);
259    /// let r = banner.center_horizontally_in(screen);
260    /// assert_eq!(r, Rect::new(45, 5, 30, 3));
261    /// ```
262    #[inline]
263    pub const fn center_horizontally_in(self, parent: Rect) -> Rect {
264        let w = if self.width < parent.width {
265            self.width
266        } else {
267            parent.width
268        };
269        let x = parent.x + parent.width.saturating_sub(w) / 2;
270        Rect {
271            x,
272            y: self.y,
273            width: w,
274            height: self.height,
275        }
276    }
277
278    /// Position `self` centered vertically inside `parent`; preserve `self.x` and `self.width`.
279    ///
280    /// If `self.height` exceeds `parent.height`, the returned rect's height
281    /// is clamped to `parent.height` (matching [`Rect::centered`]).
282    ///
283    /// # Example
284    /// ```
285    /// use slt::Rect;
286    /// let sidebar = Rect::new(2, 0, 20, 10);
287    /// let screen = Rect::new(0, 0, 120, 40);
288    /// let r = sidebar.center_vertically_in(screen);
289    /// assert_eq!(r, Rect::new(2, 15, 20, 10));
290    /// ```
291    #[inline]
292    pub const fn center_vertically_in(self, parent: Rect) -> Rect {
293        let h = if self.height < parent.height {
294            self.height
295        } else {
296            parent.height
297        };
298        let y = parent.y + parent.height.saturating_sub(h) / 2;
299        Rect {
300            x: self.x,
301            y,
302            width: self.width,
303            height: h,
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_centered_normal() {
314        let outer = Rect::new(0, 0, 10, 10);
315        let inner = outer.centered(4, 4);
316        assert_eq!(inner, Rect::new(3, 3, 4, 4));
317    }
318
319    #[test]
320    fn test_centered_larger_than_self() {
321        let outer = Rect::new(0, 0, 10, 10);
322        let inner = outer.centered(20, 20);
323        assert_eq!(inner, Rect::new(0, 0, 10, 10));
324    }
325
326    #[test]
327    fn test_centered_zero_size() {
328        let outer = Rect::new(5, 5, 10, 10);
329        let inner = outer.centered(0, 0);
330        assert_eq!(inner, Rect::new(10, 10, 0, 0));
331    }
332
333    #[test]
334    fn test_centered_offset() {
335        let outer = Rect::new(10, 20, 20, 20);
336        let inner = outer.centered(10, 10);
337        assert_eq!(inner, Rect::new(15, 25, 10, 10));
338    }
339
340    #[test]
341    fn test_union_overlapping() {
342        let r1 = Rect::new(0, 0, 5, 5);
343        let r2 = Rect::new(3, 3, 5, 5);
344        let union = r1.union(r2);
345        assert_eq!(union, Rect::new(0, 0, 8, 8));
346    }
347
348    #[test]
349    fn test_union_non_overlapping() {
350        let r1 = Rect::new(0, 0, 5, 5);
351        let r2 = Rect::new(10, 10, 5, 5);
352        let union = r1.union(r2);
353        assert_eq!(union, Rect::new(0, 0, 15, 15));
354    }
355
356    #[test]
357    fn test_union_same_rect() {
358        let r = Rect::new(5, 5, 10, 10);
359        let union = r.union(r);
360        assert_eq!(union, r);
361    }
362
363    #[test]
364    fn test_intersection_overlapping() {
365        let r1 = Rect::new(0, 0, 5, 5);
366        let r2 = Rect::new(3, 3, 5, 5);
367        let overlap = r1.intersection(r2);
368        assert_eq!(overlap, Some(Rect::new(3, 3, 2, 2)));
369    }
370
371    #[test]
372    fn test_intersection_non_overlapping() {
373        let r1 = Rect::new(0, 0, 5, 5);
374        let r2 = Rect::new(10, 10, 5, 5);
375        let overlap = r1.intersection(r2);
376        assert_eq!(overlap, None);
377    }
378
379    #[test]
380    fn test_intersection_adjacent() {
381        let r1 = Rect::new(0, 0, 5, 5);
382        let r2 = Rect::new(5, 0, 5, 5);
383        let overlap = r1.intersection(r2);
384        assert_eq!(overlap, None);
385    }
386
387    #[test]
388    fn test_intersection_same_rect() {
389        let r = Rect::new(5, 5, 10, 10);
390        let overlap = r.intersection(r);
391        assert_eq!(overlap, Some(r));
392    }
393
394    #[test]
395    fn test_contains_inside() {
396        let r = Rect::new(5, 5, 10, 10);
397        assert!(r.contains(5, 5));
398        assert!(r.contains(10, 10));
399        assert!(r.contains(14, 14));
400    }
401
402    #[test]
403    fn test_contains_outside() {
404        let r = Rect::new(5, 5, 10, 10);
405        assert!(!r.contains(4, 5));
406        assert!(!r.contains(5, 4));
407        assert!(!r.contains(15, 15));
408        assert!(!r.contains(15, 10));
409    }
410
411    #[test]
412    fn test_contains_on_edge() {
413        let r = Rect::new(5, 5, 10, 10);
414        assert!(r.contains(5, 5)); // top-left inclusive
415        assert!(!r.contains(15, 5)); // right exclusive
416        assert!(!r.contains(5, 15)); // bottom exclusive
417    }
418
419    #[test]
420    fn test_rows_correct_range() {
421        let r = Rect::new(0, 2, 5, 3);
422        let rows: Vec<u32> = r.rows().collect();
423        assert_eq!(rows, vec![2, 3, 4]);
424    }
425
426    #[test]
427    fn test_rows_single_row() {
428        let r = Rect::new(0, 5, 10, 1);
429        let rows: Vec<u32> = r.rows().collect();
430        assert_eq!(rows, vec![5]);
431    }
432
433    #[test]
434    fn test_rows_empty() {
435        let r = Rect::new(0, 5, 10, 0);
436        let rows: Vec<u32> = r.rows().collect();
437        assert!(rows.is_empty());
438    }
439
440    #[test]
441    fn test_positions_correct_count() {
442        let r = Rect::new(0, 0, 3, 2);
443        let positions: Vec<(u32, u32)> = r.positions().collect();
444        assert_eq!(positions.len(), 6);
445    }
446
447    #[test]
448    fn test_positions_order() {
449        let r = Rect::new(0, 0, 2, 2);
450        let positions: Vec<(u32, u32)> = r.positions().collect();
451        assert_eq!(positions, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
452    }
453
454    #[test]
455    fn test_positions_offset() {
456        let r = Rect::new(5, 3, 2, 2);
457        let positions: Vec<(u32, u32)> = r.positions().collect();
458        assert_eq!(positions, vec![(5, 3), (6, 3), (5, 4), (6, 4)]);
459    }
460
461    #[test]
462    fn test_positions_empty() {
463        let r = Rect::new(0, 0, 0, 5);
464        let positions: Vec<(u32, u32)> = r.positions().collect();
465        assert!(positions.is_empty());
466    }
467
468    #[test]
469    fn rect_area_no_overflow() {
470        // u32::MAX * u32::MAX would wrap to 0 without saturating_mul
471        let r = Rect::new(0, 0, u32::MAX, u32::MAX);
472        assert_eq!(r.area(), u32::MAX);
473        // Concrete case from issue #166: 65536 * 65536 wraps to 0 without fix
474        let r2 = Rect::new(0, 0, 65536, 65536);
475        assert_eq!(r2.area(), u32::MAX);
476    }
477
478    #[test]
479    fn test_center_in_basic() {
480        let dialog = Rect::new(0, 0, 40, 10);
481        let screen = Rect::new(0, 0, 120, 40);
482        assert_eq!(dialog.center_in(screen), Rect::new(40, 15, 40, 10));
483    }
484
485    #[test]
486    fn test_center_in_self_bigger_clamps() {
487        // self larger than parent on both axes -> clamp to parent extent.
488        let oversize = Rect::new(0, 0, 200, 80);
489        let screen = Rect::new(0, 0, 120, 40);
490        assert_eq!(oversize.center_in(screen), Rect::new(0, 0, 120, 40));
491    }
492
493    #[test]
494    fn test_center_in_offset_parent() {
495        // Parent at (10, 5) with size 100 x 30; centering 40 x 10 ->
496        // x = 10 + (100 - 40) / 2 = 40, y = 5 + (30 - 10) / 2 = 15
497        let dialog = Rect::new(999, 999, 40, 10); // self.x/self.y ignored
498        let parent = Rect::new(10, 5, 100, 30);
499        assert_eq!(dialog.center_in(parent), Rect::new(40, 15, 40, 10));
500    }
501
502    #[test]
503    fn test_center_in_self_position_ignored() {
504        // self.x/self.y must NOT influence the result — only dimensions.
505        let a = Rect::new(0, 0, 10, 4).center_in(Rect::new(0, 0, 20, 10));
506        let b = Rect::new(99, 99, 10, 4).center_in(Rect::new(0, 0, 20, 10));
507        assert_eq!(a, b);
508    }
509
510    #[test]
511    fn test_center_horizontally_in_preserves_y_height() {
512        let banner = Rect::new(0, 5, 30, 3);
513        let screen = Rect::new(0, 0, 120, 40);
514        assert_eq!(
515            banner.center_horizontally_in(screen),
516            Rect::new(45, 5, 30, 3)
517        );
518    }
519
520    #[test]
521    fn test_center_horizontally_in_clamps_width() {
522        let wide = Rect::new(0, 4, 200, 3);
523        let screen = Rect::new(0, 0, 120, 40);
524        // width clamped, x = 0 (saturating_sub(120, 120) = 0)
525        assert_eq!(wide.center_horizontally_in(screen), Rect::new(0, 4, 120, 3));
526    }
527
528    #[test]
529    fn test_center_vertically_in_preserves_x_width() {
530        let sidebar = Rect::new(2, 0, 20, 10);
531        let screen = Rect::new(0, 0, 120, 40);
532        assert_eq!(
533            sidebar.center_vertically_in(screen),
534            Rect::new(2, 15, 20, 10)
535        );
536    }
537
538    #[test]
539    fn test_center_vertically_in_clamps_height() {
540        let tall = Rect::new(3, 0, 8, 200);
541        let screen = Rect::new(0, 0, 120, 40);
542        assert_eq!(tall.center_vertically_in(screen), Rect::new(3, 0, 8, 40));
543    }
544}