Skip to main content

ftui_core/
geometry.rs

1#![forbid(unsafe_code)]
2
3//! Geometric primitives.
4
5/// A 2D size in terminal cells.
6///
7/// Represents dimensions (width and height) using `u16` for terminal coordinates.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
9pub struct Size {
10    /// Width in cells.
11    pub width: u16,
12    /// Height in cells.
13    pub height: u16,
14}
15
16impl Size {
17    /// Zero size (0x0).
18    pub const ZERO: Self = Self {
19        width: 0,
20        height: 0,
21    };
22
23    /// Maximum size (u16::MAX x u16::MAX).
24    ///
25    /// Useful as a sentinel for "unbounded" or "as large as needed".
26    pub const MAX: Self = Self {
27        width: u16::MAX,
28        height: u16::MAX,
29    };
30
31    /// Create a new size.
32    #[inline]
33    pub const fn new(width: u16, height: u16) -> Self {
34        Self { width, height }
35    }
36
37    /// Check if this size has zero area.
38    #[inline]
39    pub const fn is_empty(&self) -> bool {
40        self.width == 0 || self.height == 0
41    }
42
43    /// Area in cells.
44    #[inline]
45    pub const fn area(&self) -> u32 {
46        self.width as u32 * self.height as u32
47    }
48
49    /// Clamp width and height to the given maximums.
50    #[inline]
51    pub const fn clamp_max(&self, max: Size) -> Size {
52        Size {
53            width: if self.width > max.width {
54                max.width
55            } else {
56                self.width
57            },
58            height: if self.height > max.height {
59                max.height
60            } else {
61                self.height
62            },
63        }
64    }
65
66    /// Clamp width and height to the given minimums.
67    #[inline]
68    pub const fn clamp_min(&self, min: Size) -> Size {
69        Size {
70            width: if self.width < min.width {
71                min.width
72            } else {
73                self.width
74            },
75            height: if self.height < min.height {
76                min.height
77            } else {
78                self.height
79            },
80        }
81    }
82}
83
84impl From<(u16, u16)> for Size {
85    fn from((width, height): (u16, u16)) -> Self {
86        Self { width, height }
87    }
88}
89
90impl From<Rect> for Size {
91    fn from(rect: Rect) -> Self {
92        Self {
93            width: rect.width,
94            height: rect.height,
95        }
96    }
97}
98
99/// A rectangle for scissor regions, layout bounds, and hit testing.
100///
101/// Uses terminal coordinates (0-indexed, origin at top-left).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub struct Rect {
104    /// Left edge (inclusive).
105    pub x: u16,
106    /// Top edge (inclusive).
107    pub y: u16,
108    /// Width in cells.
109    pub width: u16,
110    /// Height in cells.
111    pub height: u16,
112}
113
114impl Rect {
115    /// Create a new rectangle.
116    #[inline]
117    pub const fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
118        Self {
119            x,
120            y,
121            width,
122            height,
123        }
124    }
125
126    /// Create a rectangle from origin with given size.
127    #[inline]
128    pub const fn from_size(width: u16, height: u16) -> Self {
129        Self::new(0, 0, width, height)
130    }
131
132    /// Left edge (inclusive). Alias for `self.x`.
133    #[inline]
134    pub const fn left(&self) -> u16 {
135        self.x
136    }
137
138    /// Top edge (inclusive). Alias for `self.y`.
139    #[inline]
140    pub const fn top(&self) -> u16 {
141        self.y
142    }
143
144    /// Right edge (exclusive).
145    #[inline]
146    pub const fn right(&self) -> u16 {
147        self.x.saturating_add(self.width)
148    }
149
150    /// Bottom edge (exclusive).
151    #[inline]
152    pub const fn bottom(&self) -> u16 {
153        self.y.saturating_add(self.height)
154    }
155
156    /// Area in cells.
157    #[inline]
158    pub const fn area(&self) -> u32 {
159        self.width as u32 * self.height as u32
160    }
161
162    /// Check if the rectangle has zero area.
163    #[inline]
164    pub const fn is_empty(&self) -> bool {
165        self.width == 0 || self.height == 0
166    }
167
168    /// Check if a point is inside the rectangle.
169    #[inline]
170    pub const fn contains(&self, x: u16, y: u16) -> bool {
171        x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
172    }
173
174    /// Compute the intersection with another rectangle.
175    ///
176    /// Returns an empty rectangle if the rectangles don't overlap.
177    #[inline]
178    pub fn intersection(&self, other: &Rect) -> Rect {
179        self.intersection_opt(other).unwrap_or_default()
180    }
181
182    /// Create a new rectangle inside the current one with the given margin.
183    #[inline]
184    pub fn inner(&self, margin: Sides) -> Rect {
185        let x = self.x.saturating_add(margin.left);
186        let y = self.y.saturating_add(margin.top);
187        let width = self
188            .width
189            .saturating_sub(margin.left)
190            .saturating_sub(margin.right);
191        let height = self
192            .height
193            .saturating_sub(margin.top)
194            .saturating_sub(margin.bottom);
195
196        Rect {
197            x,
198            y,
199            width,
200            height,
201        }
202    }
203
204    /// Create a new rectangle that is the union of this rectangle and another.
205    ///
206    /// The result is the smallest rectangle that contains both.
207    #[inline]
208    pub fn union(&self, other: &Rect) -> Rect {
209        let x = self.x.min(other.x);
210        let y = self.y.min(other.y);
211        let right = self.right().max(other.right());
212        let bottom = self.bottom().max(other.bottom());
213
214        Rect {
215            x,
216            y,
217            width: right.saturating_sub(x),
218            height: bottom.saturating_sub(y),
219        }
220    }
221
222    /// Compute the intersection with another rectangle, returning `None` if no overlap.
223    #[inline]
224    pub fn intersection_opt(&self, other: &Rect) -> Option<Rect> {
225        let x = self.x.max(other.x);
226        let y = self.y.max(other.y);
227        let right = self.right().min(other.right());
228        let bottom = self.bottom().min(other.bottom());
229
230        if x < right && y < bottom {
231            Some(Rect::new(
232                x,
233                y,
234                right.saturating_sub(x),
235                bottom.saturating_sub(y),
236            ))
237        } else {
238            None
239        }
240    }
241}
242
243/// Sides for padding/margin.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
245pub struct Sides {
246    /// Top padding/margin in cells.
247    pub top: u16,
248    /// Right padding/margin in cells.
249    pub right: u16,
250    /// Bottom padding/margin in cells.
251    pub bottom: u16,
252    /// Left padding/margin in cells.
253    pub left: u16,
254}
255
256impl Sides {
257    /// Create new sides with equal values.
258    pub const fn all(val: u16) -> Self {
259        Self {
260            top: val,
261            right: val,
262            bottom: val,
263            left: val,
264        }
265    }
266
267    /// Create new sides with horizontal values only.
268    pub const fn horizontal(val: u16) -> Self {
269        Self {
270            top: 0,
271            right: val,
272            bottom: 0,
273            left: val,
274        }
275    }
276
277    /// Create new sides with vertical values only.
278    pub const fn vertical(val: u16) -> Self {
279        Self {
280            top: val,
281            right: 0,
282            bottom: val,
283            left: 0,
284        }
285    }
286
287    /// Create new sides with specific values.
288    pub const fn new(top: u16, right: u16, bottom: u16, left: u16) -> Self {
289        Self {
290            top,
291            right,
292            bottom,
293            left,
294        }
295    }
296
297    /// Sum of left and right.
298    #[inline]
299    pub const fn horizontal_sum(&self) -> u16 {
300        self.left.saturating_add(self.right)
301    }
302
303    /// Sum of top and bottom.
304    #[inline]
305    pub const fn vertical_sum(&self) -> u16 {
306        self.top.saturating_add(self.bottom)
307    }
308}
309
310impl From<u16> for Sides {
311    fn from(val: u16) -> Self {
312        Self::all(val)
313    }
314}
315
316impl From<(u16, u16)> for Sides {
317    fn from((vertical, horizontal): (u16, u16)) -> Self {
318        Self {
319            top: vertical,
320            right: horizontal,
321            bottom: vertical,
322            left: horizontal,
323        }
324    }
325}
326
327impl From<(u16, u16, u16, u16)> for Sides {
328    fn from((top, right, bottom, left): (u16, u16, u16, u16)) -> Self {
329        Self {
330            top,
331            right,
332            bottom,
333            left,
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::{Rect, Sides, Size};
341
342    #[test]
343    fn rect_contains_edges() {
344        let rect = Rect::new(2, 3, 4, 5);
345        assert!(rect.contains(2, 3));
346        assert!(rect.contains(5, 7));
347        assert!(!rect.contains(6, 3));
348        assert!(!rect.contains(2, 8));
349    }
350
351    #[test]
352    fn rect_intersection_overlaps() {
353        let a = Rect::new(0, 0, 4, 4);
354        let b = Rect::new(2, 2, 4, 4);
355        assert_eq!(a.intersection(&b), Rect::new(2, 2, 2, 2));
356    }
357
358    #[test]
359    fn rect_intersection_no_overlap_is_empty() {
360        let a = Rect::new(0, 0, 2, 2);
361        let b = Rect::new(3, 3, 2, 2);
362        assert_eq!(a.intersection(&b), Rect::default());
363    }
364
365    #[test]
366    fn rect_inner_reduces() {
367        let rect = Rect::new(0, 0, 10, 10);
368        let inner = rect.inner(Sides {
369            top: 1,
370            right: 2,
371            bottom: 3,
372            left: 4,
373        });
374        assert_eq!(inner, Rect::new(4, 1, 4, 6));
375    }
376
377    #[test]
378    fn sides_constructors_and_conversions() {
379        assert_eq!(Sides::all(3), Sides::from(3));
380        assert_eq!(
381            Sides::horizontal(2),
382            Sides {
383                top: 0,
384                right: 2,
385                bottom: 0,
386                left: 2,
387            }
388        );
389        assert_eq!(
390            Sides::vertical(4),
391            Sides {
392                top: 4,
393                right: 0,
394                bottom: 4,
395                left: 0,
396            }
397        );
398        assert_eq!(
399            Sides::from((1, 2)),
400            Sides {
401                top: 1,
402                right: 2,
403                bottom: 1,
404                left: 2,
405            }
406        );
407        assert_eq!(
408            Sides::from((1, 2, 3, 4)),
409            Sides {
410                top: 1,
411                right: 2,
412                bottom: 3,
413                left: 4,
414            }
415        );
416    }
417
418    #[test]
419    fn sides_sums() {
420        let sides = Sides {
421            top: 1,
422            right: 2,
423            bottom: 3,
424            left: 4,
425        };
426        assert_eq!(sides.horizontal_sum(), 6);
427        assert_eq!(sides.vertical_sum(), 4);
428    }
429
430    // --- Rect constructors ---
431
432    #[test]
433    fn rect_new_and_default() {
434        let r = Rect::new(5, 10, 20, 15);
435        assert_eq!(r.x, 5);
436        assert_eq!(r.y, 10);
437        assert_eq!(r.width, 20);
438        assert_eq!(r.height, 15);
439
440        let d = Rect::default();
441        assert_eq!(d, Rect::new(0, 0, 0, 0));
442    }
443
444    #[test]
445    fn rect_from_size() {
446        let r = Rect::from_size(80, 24);
447        assert_eq!(r.x, 0);
448        assert_eq!(r.y, 0);
449        assert_eq!(r.width, 80);
450        assert_eq!(r.height, 24);
451    }
452
453    // --- Edge accessors ---
454
455    #[test]
456    fn rect_left_top_right_bottom() {
457        let r = Rect::new(10, 20, 30, 40);
458        assert_eq!(r.left(), 10);
459        assert_eq!(r.top(), 20);
460        assert_eq!(r.right(), 40);
461        assert_eq!(r.bottom(), 60);
462    }
463
464    #[test]
465    fn rect_right_bottom_saturating() {
466        // Near u16::MAX — should not overflow
467        let r = Rect::new(u16::MAX - 5, u16::MAX - 3, 100, 100);
468        assert_eq!(r.right(), u16::MAX);
469        assert_eq!(r.bottom(), u16::MAX);
470    }
471
472    // --- Area and is_empty ---
473
474    #[test]
475    fn rect_area() {
476        assert_eq!(Rect::new(0, 0, 10, 20).area(), 200);
477        assert_eq!(Rect::new(5, 5, 0, 10).area(), 0);
478        assert_eq!(Rect::new(0, 0, 1, 1).area(), 1);
479    }
480
481    #[test]
482    fn rect_is_empty() {
483        assert!(Rect::new(0, 0, 0, 0).is_empty());
484        assert!(Rect::new(5, 5, 0, 10).is_empty());
485        assert!(Rect::new(5, 5, 10, 0).is_empty());
486        assert!(!Rect::new(0, 0, 1, 1).is_empty());
487    }
488
489    // --- Contains ---
490
491    #[test]
492    fn rect_contains_boundary_conditions() {
493        let r = Rect::new(0, 0, 5, 5);
494        // Top-left corner (inclusive)
495        assert!(r.contains(0, 0));
496        // Just inside right/bottom edge
497        assert!(r.contains(4, 4));
498        // Right edge is exclusive
499        assert!(!r.contains(5, 0));
500        // Bottom edge is exclusive
501        assert!(!r.contains(0, 5));
502    }
503
504    #[test]
505    fn rect_contains_empty_rect() {
506        let r = Rect::new(5, 5, 0, 0);
507        // Empty rect contains nothing, not even its own origin
508        assert!(!r.contains(5, 5));
509    }
510
511    // --- Union ---
512
513    #[test]
514    fn rect_union_basic() {
515        let a = Rect::new(0, 0, 5, 5);
516        let b = Rect::new(3, 3, 5, 5);
517        let u = a.union(&b);
518        assert_eq!(u, Rect::new(0, 0, 8, 8));
519    }
520
521    #[test]
522    fn rect_union_disjoint() {
523        let a = Rect::new(0, 0, 2, 2);
524        let b = Rect::new(10, 10, 3, 3);
525        let u = a.union(&b);
526        assert_eq!(u, Rect::new(0, 0, 13, 13));
527    }
528
529    #[test]
530    fn rect_union_contained() {
531        let outer = Rect::new(0, 0, 10, 10);
532        let inner = Rect::new(2, 2, 3, 3);
533        assert_eq!(outer.union(&inner), outer);
534        assert_eq!(inner.union(&outer), outer);
535    }
536
537    #[test]
538    fn rect_union_self() {
539        let r = Rect::new(5, 10, 20, 15);
540        assert_eq!(r.union(&r), r);
541    }
542
543    // --- Intersection ---
544
545    #[test]
546    fn rect_intersection_self() {
547        let r = Rect::new(5, 5, 10, 10);
548        assert_eq!(r.intersection(&r), r);
549    }
550
551    #[test]
552    fn rect_intersection_contained() {
553        let outer = Rect::new(0, 0, 20, 20);
554        let inner = Rect::new(5, 5, 5, 5);
555        assert_eq!(outer.intersection(&inner), inner);
556        assert_eq!(inner.intersection(&outer), inner);
557    }
558
559    #[test]
560    fn rect_intersection_adjacent_no_overlap() {
561        // Rects share an edge but don't overlap (right edge is exclusive)
562        let a = Rect::new(0, 0, 5, 5);
563        let b = Rect::new(5, 0, 5, 5);
564        assert!(a.intersection(&b).is_empty());
565    }
566
567    #[test]
568    fn rect_intersection_opt_returns_none_for_no_overlap() {
569        let a = Rect::new(0, 0, 2, 2);
570        let b = Rect::new(5, 5, 2, 2);
571        assert_eq!(a.intersection_opt(&b), None);
572    }
573
574    #[test]
575    fn rect_intersection_opt_returns_some_for_overlap() {
576        let a = Rect::new(0, 0, 5, 5);
577        let b = Rect::new(3, 3, 5, 5);
578        assert_eq!(a.intersection_opt(&b), Some(Rect::new(3, 3, 2, 2)));
579    }
580
581    // --- Inner margin edge cases ---
582
583    #[test]
584    fn rect_inner_large_margin_clamps_to_zero() {
585        let r = Rect::new(0, 0, 10, 10);
586        let inner = r.inner(Sides::all(20));
587        // Width/height should clamp to 0 (not underflow)
588        assert_eq!(inner.width, 0);
589        assert_eq!(inner.height, 0);
590    }
591
592    #[test]
593    fn rect_inner_zero_margin() {
594        let r = Rect::new(5, 10, 20, 30);
595        let inner = r.inner(Sides::all(0));
596        assert_eq!(inner, r);
597    }
598
599    #[test]
600    fn rect_inner_asymmetric_margin() {
601        let r = Rect::new(0, 0, 20, 20);
602        let inner = r.inner(Sides::new(2, 3, 4, 5));
603        assert_eq!(inner.x, 5);
604        assert_eq!(inner.y, 2);
605        assert_eq!(inner.width, 12); // 20 - 5 - 3
606        assert_eq!(inner.height, 14); // 20 - 2 - 4
607    }
608
609    // --- Sides ---
610
611    #[test]
612    fn sides_new_explicit() {
613        let s = Sides::new(1, 2, 3, 4);
614        assert_eq!(s.top, 1);
615        assert_eq!(s.right, 2);
616        assert_eq!(s.bottom, 3);
617        assert_eq!(s.left, 4);
618    }
619
620    #[test]
621    fn sides_default_is_zero() {
622        let s = Sides::default();
623        assert_eq!(s, Sides::new(0, 0, 0, 0));
624    }
625
626    #[test]
627    fn sides_sums_saturating() {
628        let s = Sides::new(u16::MAX, 0, u16::MAX, 0);
629        assert_eq!(s.vertical_sum(), u16::MAX);
630    }
631
632    // --- Size tests ---
633
634    #[test]
635    fn size_new_and_constants() {
636        let s = Size::new(80, 24);
637        assert_eq!(s.width, 80);
638        assert_eq!(s.height, 24);
639
640        assert_eq!(Size::ZERO, Size::new(0, 0));
641        assert_eq!(Size::MAX, Size::new(u16::MAX, u16::MAX));
642    }
643
644    #[test]
645    fn size_default_is_zero() {
646        assert_eq!(Size::default(), Size::ZERO);
647    }
648
649    #[test]
650    fn size_is_empty() {
651        assert!(Size::ZERO.is_empty());
652        assert!(Size::new(0, 10).is_empty());
653        assert!(Size::new(10, 0).is_empty());
654        assert!(!Size::new(1, 1).is_empty());
655    }
656
657    #[test]
658    fn size_area() {
659        assert_eq!(Size::new(10, 20).area(), 200);
660        assert_eq!(Size::ZERO.area(), 0);
661        assert_eq!(Size::new(1, 1).area(), 1);
662    }
663
664    #[test]
665    fn size_clamp_max() {
666        let s = Size::new(100, 50);
667        assert_eq!(s.clamp_max(Size::new(80, 40)), Size::new(80, 40));
668        assert_eq!(s.clamp_max(Size::new(200, 200)), s);
669        assert_eq!(s.clamp_max(Size::new(80, 100)), Size::new(80, 50));
670    }
671
672    #[test]
673    fn size_clamp_min() {
674        let s = Size::new(10, 5);
675        assert_eq!(s.clamp_min(Size::new(20, 10)), Size::new(20, 10));
676        assert_eq!(s.clamp_min(Size::new(5, 3)), s);
677        assert_eq!(s.clamp_min(Size::new(15, 3)), Size::new(15, 5));
678    }
679
680    #[test]
681    fn size_from_tuple() {
682        let s: Size = (80, 24).into();
683        assert_eq!(s, Size::new(80, 24));
684    }
685
686    #[test]
687    fn size_from_rect() {
688        let r = Rect::new(5, 10, 80, 24);
689        let s: Size = r.into();
690        assert_eq!(s, Size::new(80, 24));
691    }
692}