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    pub fn inner(&self, margin: Sides) -> Rect {
184        let x = self.x.saturating_add(margin.left);
185        let y = self.y.saturating_add(margin.top);
186        let width = self
187            .width
188            .saturating_sub(margin.left)
189            .saturating_sub(margin.right);
190        let height = self
191            .height
192            .saturating_sub(margin.top)
193            .saturating_sub(margin.bottom);
194
195        Rect {
196            x,
197            y,
198            width,
199            height,
200        }
201    }
202
203    /// Create a new rectangle that is the union of this rectangle and another.
204    ///
205    /// The result is the smallest rectangle that contains both.
206    pub fn union(&self, other: &Rect) -> Rect {
207        let x = self.x.min(other.x);
208        let y = self.y.min(other.y);
209        let right = self.right().max(other.right());
210        let bottom = self.bottom().max(other.bottom());
211
212        Rect {
213            x,
214            y,
215            width: right.saturating_sub(x),
216            height: bottom.saturating_sub(y),
217        }
218    }
219
220    /// Compute the intersection with another rectangle, returning `None` if no overlap.
221    #[inline]
222    pub fn intersection_opt(&self, other: &Rect) -> Option<Rect> {
223        let x = self.x.max(other.x);
224        let y = self.y.max(other.y);
225        let right = self.right().min(other.right());
226        let bottom = self.bottom().min(other.bottom());
227
228        if x < right && y < bottom {
229            Some(Rect::new(
230                x,
231                y,
232                right.saturating_sub(x),
233                bottom.saturating_sub(y),
234            ))
235        } else {
236            None
237        }
238    }
239}
240
241/// Sides for padding/margin.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub struct Sides {
244    /// Top padding/margin in cells.
245    pub top: u16,
246    /// Right padding/margin in cells.
247    pub right: u16,
248    /// Bottom padding/margin in cells.
249    pub bottom: u16,
250    /// Left padding/margin in cells.
251    pub left: u16,
252}
253
254impl Sides {
255    /// Create new sides with equal values.
256    pub const fn all(val: u16) -> Self {
257        Self {
258            top: val,
259            right: val,
260            bottom: val,
261            left: val,
262        }
263    }
264
265    /// Create new sides with horizontal values only.
266    pub const fn horizontal(val: u16) -> Self {
267        Self {
268            top: 0,
269            right: val,
270            bottom: 0,
271            left: val,
272        }
273    }
274
275    /// Create new sides with vertical values only.
276    pub const fn vertical(val: u16) -> Self {
277        Self {
278            top: val,
279            right: 0,
280            bottom: val,
281            left: 0,
282        }
283    }
284
285    /// Create new sides with specific values.
286    pub const fn new(top: u16, right: u16, bottom: u16, left: u16) -> Self {
287        Self {
288            top,
289            right,
290            bottom,
291            left,
292        }
293    }
294
295    /// Sum of left and right.
296    #[inline]
297    pub const fn horizontal_sum(&self) -> u16 {
298        self.left.saturating_add(self.right)
299    }
300
301    /// Sum of top and bottom.
302    #[inline]
303    pub const fn vertical_sum(&self) -> u16 {
304        self.top.saturating_add(self.bottom)
305    }
306}
307
308impl From<u16> for Sides {
309    fn from(val: u16) -> Self {
310        Self::all(val)
311    }
312}
313
314impl From<(u16, u16)> for Sides {
315    fn from((vertical, horizontal): (u16, u16)) -> Self {
316        Self {
317            top: vertical,
318            right: horizontal,
319            bottom: vertical,
320            left: horizontal,
321        }
322    }
323}
324
325impl From<(u16, u16, u16, u16)> for Sides {
326    fn from((top, right, bottom, left): (u16, u16, u16, u16)) -> Self {
327        Self {
328            top,
329            right,
330            bottom,
331            left,
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::{Rect, Sides, Size};
339
340    #[test]
341    fn rect_contains_edges() {
342        let rect = Rect::new(2, 3, 4, 5);
343        assert!(rect.contains(2, 3));
344        assert!(rect.contains(5, 7));
345        assert!(!rect.contains(6, 3));
346        assert!(!rect.contains(2, 8));
347    }
348
349    #[test]
350    fn rect_intersection_overlaps() {
351        let a = Rect::new(0, 0, 4, 4);
352        let b = Rect::new(2, 2, 4, 4);
353        assert_eq!(a.intersection(&b), Rect::new(2, 2, 2, 2));
354    }
355
356    #[test]
357    fn rect_intersection_no_overlap_is_empty() {
358        let a = Rect::new(0, 0, 2, 2);
359        let b = Rect::new(3, 3, 2, 2);
360        assert_eq!(a.intersection(&b), Rect::default());
361    }
362
363    #[test]
364    fn rect_inner_reduces() {
365        let rect = Rect::new(0, 0, 10, 10);
366        let inner = rect.inner(Sides {
367            top: 1,
368            right: 2,
369            bottom: 3,
370            left: 4,
371        });
372        assert_eq!(inner, Rect::new(4, 1, 4, 6));
373    }
374
375    #[test]
376    fn sides_constructors_and_conversions() {
377        assert_eq!(Sides::all(3), Sides::from(3));
378        assert_eq!(
379            Sides::horizontal(2),
380            Sides {
381                top: 0,
382                right: 2,
383                bottom: 0,
384                left: 2,
385            }
386        );
387        assert_eq!(
388            Sides::vertical(4),
389            Sides {
390                top: 4,
391                right: 0,
392                bottom: 4,
393                left: 0,
394            }
395        );
396        assert_eq!(
397            Sides::from((1, 2)),
398            Sides {
399                top: 1,
400                right: 2,
401                bottom: 1,
402                left: 2,
403            }
404        );
405        assert_eq!(
406            Sides::from((1, 2, 3, 4)),
407            Sides {
408                top: 1,
409                right: 2,
410                bottom: 3,
411                left: 4,
412            }
413        );
414    }
415
416    #[test]
417    fn sides_sums() {
418        let sides = Sides {
419            top: 1,
420            right: 2,
421            bottom: 3,
422            left: 4,
423        };
424        assert_eq!(sides.horizontal_sum(), 6);
425        assert_eq!(sides.vertical_sum(), 4);
426    }
427
428    // --- Rect constructors ---
429
430    #[test]
431    fn rect_new_and_default() {
432        let r = Rect::new(5, 10, 20, 15);
433        assert_eq!(r.x, 5);
434        assert_eq!(r.y, 10);
435        assert_eq!(r.width, 20);
436        assert_eq!(r.height, 15);
437
438        let d = Rect::default();
439        assert_eq!(d, Rect::new(0, 0, 0, 0));
440    }
441
442    #[test]
443    fn rect_from_size() {
444        let r = Rect::from_size(80, 24);
445        assert_eq!(r.x, 0);
446        assert_eq!(r.y, 0);
447        assert_eq!(r.width, 80);
448        assert_eq!(r.height, 24);
449    }
450
451    // --- Edge accessors ---
452
453    #[test]
454    fn rect_left_top_right_bottom() {
455        let r = Rect::new(10, 20, 30, 40);
456        assert_eq!(r.left(), 10);
457        assert_eq!(r.top(), 20);
458        assert_eq!(r.right(), 40);
459        assert_eq!(r.bottom(), 60);
460    }
461
462    #[test]
463    fn rect_right_bottom_saturating() {
464        // Near u16::MAX — should not overflow
465        let r = Rect::new(u16::MAX - 5, u16::MAX - 3, 100, 100);
466        assert_eq!(r.right(), u16::MAX);
467        assert_eq!(r.bottom(), u16::MAX);
468    }
469
470    // --- Area and is_empty ---
471
472    #[test]
473    fn rect_area() {
474        assert_eq!(Rect::new(0, 0, 10, 20).area(), 200);
475        assert_eq!(Rect::new(5, 5, 0, 10).area(), 0);
476        assert_eq!(Rect::new(0, 0, 1, 1).area(), 1);
477    }
478
479    #[test]
480    fn rect_is_empty() {
481        assert!(Rect::new(0, 0, 0, 0).is_empty());
482        assert!(Rect::new(5, 5, 0, 10).is_empty());
483        assert!(Rect::new(5, 5, 10, 0).is_empty());
484        assert!(!Rect::new(0, 0, 1, 1).is_empty());
485    }
486
487    // --- Contains ---
488
489    #[test]
490    fn rect_contains_boundary_conditions() {
491        let r = Rect::new(0, 0, 5, 5);
492        // Top-left corner (inclusive)
493        assert!(r.contains(0, 0));
494        // Just inside right/bottom edge
495        assert!(r.contains(4, 4));
496        // Right edge is exclusive
497        assert!(!r.contains(5, 0));
498        // Bottom edge is exclusive
499        assert!(!r.contains(0, 5));
500    }
501
502    #[test]
503    fn rect_contains_empty_rect() {
504        let r = Rect::new(5, 5, 0, 0);
505        // Empty rect contains nothing, not even its own origin
506        assert!(!r.contains(5, 5));
507    }
508
509    // --- Union ---
510
511    #[test]
512    fn rect_union_basic() {
513        let a = Rect::new(0, 0, 5, 5);
514        let b = Rect::new(3, 3, 5, 5);
515        let u = a.union(&b);
516        assert_eq!(u, Rect::new(0, 0, 8, 8));
517    }
518
519    #[test]
520    fn rect_union_disjoint() {
521        let a = Rect::new(0, 0, 2, 2);
522        let b = Rect::new(10, 10, 3, 3);
523        let u = a.union(&b);
524        assert_eq!(u, Rect::new(0, 0, 13, 13));
525    }
526
527    #[test]
528    fn rect_union_contained() {
529        let outer = Rect::new(0, 0, 10, 10);
530        let inner = Rect::new(2, 2, 3, 3);
531        assert_eq!(outer.union(&inner), outer);
532        assert_eq!(inner.union(&outer), outer);
533    }
534
535    #[test]
536    fn rect_union_self() {
537        let r = Rect::new(5, 10, 20, 15);
538        assert_eq!(r.union(&r), r);
539    }
540
541    // --- Intersection ---
542
543    #[test]
544    fn rect_intersection_self() {
545        let r = Rect::new(5, 5, 10, 10);
546        assert_eq!(r.intersection(&r), r);
547    }
548
549    #[test]
550    fn rect_intersection_contained() {
551        let outer = Rect::new(0, 0, 20, 20);
552        let inner = Rect::new(5, 5, 5, 5);
553        assert_eq!(outer.intersection(&inner), inner);
554        assert_eq!(inner.intersection(&outer), inner);
555    }
556
557    #[test]
558    fn rect_intersection_adjacent_no_overlap() {
559        // Rects share an edge but don't overlap (right edge is exclusive)
560        let a = Rect::new(0, 0, 5, 5);
561        let b = Rect::new(5, 0, 5, 5);
562        assert!(a.intersection(&b).is_empty());
563    }
564
565    #[test]
566    fn rect_intersection_opt_returns_none_for_no_overlap() {
567        let a = Rect::new(0, 0, 2, 2);
568        let b = Rect::new(5, 5, 2, 2);
569        assert_eq!(a.intersection_opt(&b), None);
570    }
571
572    #[test]
573    fn rect_intersection_opt_returns_some_for_overlap() {
574        let a = Rect::new(0, 0, 5, 5);
575        let b = Rect::new(3, 3, 5, 5);
576        assert_eq!(a.intersection_opt(&b), Some(Rect::new(3, 3, 2, 2)));
577    }
578
579    // --- Inner margin edge cases ---
580
581    #[test]
582    fn rect_inner_large_margin_clamps_to_zero() {
583        let r = Rect::new(0, 0, 10, 10);
584        let inner = r.inner(Sides::all(20));
585        // Width/height should clamp to 0 (not underflow)
586        assert_eq!(inner.width, 0);
587        assert_eq!(inner.height, 0);
588    }
589
590    #[test]
591    fn rect_inner_zero_margin() {
592        let r = Rect::new(5, 10, 20, 30);
593        let inner = r.inner(Sides::all(0));
594        assert_eq!(inner, r);
595    }
596
597    #[test]
598    fn rect_inner_asymmetric_margin() {
599        let r = Rect::new(0, 0, 20, 20);
600        let inner = r.inner(Sides::new(2, 3, 4, 5));
601        assert_eq!(inner.x, 5);
602        assert_eq!(inner.y, 2);
603        assert_eq!(inner.width, 12); // 20 - 5 - 3
604        assert_eq!(inner.height, 14); // 20 - 2 - 4
605    }
606
607    // --- Sides ---
608
609    #[test]
610    fn sides_new_explicit() {
611        let s = Sides::new(1, 2, 3, 4);
612        assert_eq!(s.top, 1);
613        assert_eq!(s.right, 2);
614        assert_eq!(s.bottom, 3);
615        assert_eq!(s.left, 4);
616    }
617
618    #[test]
619    fn sides_default_is_zero() {
620        let s = Sides::default();
621        assert_eq!(s, Sides::new(0, 0, 0, 0));
622    }
623
624    #[test]
625    fn sides_sums_saturating() {
626        let s = Sides::new(u16::MAX, 0, u16::MAX, 0);
627        assert_eq!(s.vertical_sum(), u16::MAX);
628    }
629
630    // --- Size tests ---
631
632    #[test]
633    fn size_new_and_constants() {
634        let s = Size::new(80, 24);
635        assert_eq!(s.width, 80);
636        assert_eq!(s.height, 24);
637
638        assert_eq!(Size::ZERO, Size::new(0, 0));
639        assert_eq!(Size::MAX, Size::new(u16::MAX, u16::MAX));
640    }
641
642    #[test]
643    fn size_default_is_zero() {
644        assert_eq!(Size::default(), Size::ZERO);
645    }
646
647    #[test]
648    fn size_is_empty() {
649        assert!(Size::ZERO.is_empty());
650        assert!(Size::new(0, 10).is_empty());
651        assert!(Size::new(10, 0).is_empty());
652        assert!(!Size::new(1, 1).is_empty());
653    }
654
655    #[test]
656    fn size_area() {
657        assert_eq!(Size::new(10, 20).area(), 200);
658        assert_eq!(Size::ZERO.area(), 0);
659        assert_eq!(Size::new(1, 1).area(), 1);
660    }
661
662    #[test]
663    fn size_clamp_max() {
664        let s = Size::new(100, 50);
665        assert_eq!(s.clamp_max(Size::new(80, 40)), Size::new(80, 40));
666        assert_eq!(s.clamp_max(Size::new(200, 200)), s);
667        assert_eq!(s.clamp_max(Size::new(80, 100)), Size::new(80, 50));
668    }
669
670    #[test]
671    fn size_clamp_min() {
672        let s = Size::new(10, 5);
673        assert_eq!(s.clamp_min(Size::new(20, 10)), Size::new(20, 10));
674        assert_eq!(s.clamp_min(Size::new(5, 3)), s);
675        assert_eq!(s.clamp_min(Size::new(15, 3)), Size::new(15, 5));
676    }
677
678    #[test]
679    fn size_from_tuple() {
680        let s: Size = (80, 24).into();
681        assert_eq!(s, Size::new(80, 24));
682    }
683
684    #[test]
685    fn size_from_rect() {
686        let r = Rect::new(5, 10, 80, 24);
687        let s: Size = r.into();
688        assert_eq!(s, Size::new(80, 24));
689    }
690}