Skip to main content

ftui_layout/
grid.rs

1#![forbid(unsafe_code)]
2
3//! 2D Grid layout system for dashboard-style positioning.
4//!
5//! Grid provides constraint-based 2D positioning with support for:
6//! - Row and column constraints
7//! - Cell spanning (colspan, rowspan)
8//! - Named areas for semantic layout references
9//! - Gap configuration
10//!
11//! # Example
12//!
13//! ```
14//! use ftui_layout::grid::Grid;
15//! use ftui_layout::Constraint;
16//! use ftui_core::geometry::Rect;
17//!
18//! // Create a 3x2 grid (3 rows, 2 columns)
19//! let grid = Grid::new()
20//!     .rows([
21//!         Constraint::Fixed(3),      // Header
22//!         Constraint::Min(10),       // Content
23//!         Constraint::Fixed(1),      // Footer
24//!     ])
25//!     .columns([
26//!         Constraint::Percentage(30.0),  // Sidebar
27//!         Constraint::Min(20),            // Main
28//!     ])
29//!     .row_gap(1)
30//!     .col_gap(2);
31//!
32//! let area = Rect::new(0, 0, 80, 24);
33//! let layout = grid.split(area);
34//!
35//! // Access cell by (row, col)
36//! let header_left = layout.cell(0, 0);
37//! let content_main = layout.cell(1, 1);
38//! ```
39
40use crate::Constraint;
41use ftui_core::geometry::Rect;
42use std::collections::HashMap;
43
44/// A 2D grid layout container.
45#[derive(Debug, Clone, Default)]
46pub struct Grid {
47    /// Row constraints (height of each row).
48    row_constraints: Vec<Constraint>,
49    /// Column constraints (width of each column).
50    col_constraints: Vec<Constraint>,
51    /// Gap between rows.
52    row_gap: u16,
53    /// Gap between columns.
54    col_gap: u16,
55    /// Named areas mapping to (row, col, rowspan, colspan).
56    named_areas: HashMap<String, GridArea>,
57}
58
59/// Definition of a named grid area.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct GridArea {
62    /// Starting row (0-indexed).
63    pub row: usize,
64    /// Starting column (0-indexed).
65    pub col: usize,
66    /// Number of rows this area spans.
67    pub rowspan: usize,
68    /// Number of columns this area spans.
69    pub colspan: usize,
70}
71
72impl GridArea {
73    /// Create a single-cell area.
74    #[inline]
75    #[must_use]
76    pub fn cell(row: usize, col: usize) -> Self {
77        Self {
78            row,
79            col,
80            rowspan: 1,
81            colspan: 1,
82        }
83    }
84
85    /// Create a spanning area.
86    #[inline]
87    #[must_use]
88    pub fn span(row: usize, col: usize, rowspan: usize, colspan: usize) -> Self {
89        Self {
90            row,
91            col,
92            rowspan: rowspan.max(1),
93            colspan: colspan.max(1),
94        }
95    }
96}
97
98/// Result of solving a grid layout.
99#[derive(Debug, Clone)]
100pub struct GridLayout {
101    /// Row heights.
102    row_heights: Vec<u16>,
103    /// Column widths.
104    col_widths: Vec<u16>,
105    /// Row Y positions (cumulative with gaps).
106    row_positions: Vec<u16>,
107    /// Column X positions (cumulative with gaps).
108    col_positions: Vec<u16>,
109    /// Named areas from the grid definition.
110    named_areas: HashMap<String, GridArea>,
111    /// Gap between rows.
112    row_gap: u16,
113    /// Gap between columns.
114    col_gap: u16,
115}
116
117impl Grid {
118    /// Create a new empty grid.
119    #[inline]
120    #[must_use]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Set the row constraints.
126    #[must_use]
127    pub fn rows(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
128        self.row_constraints = constraints.into_iter().collect();
129        self
130    }
131
132    /// Set the column constraints.
133    #[must_use]
134    pub fn columns(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
135        self.col_constraints = constraints.into_iter().collect();
136        self
137    }
138
139    /// Set the gap between rows.
140    #[must_use]
141    pub fn row_gap(mut self, gap: u16) -> Self {
142        self.row_gap = gap;
143        self
144    }
145
146    /// Set the gap between columns.
147    #[must_use]
148    pub fn col_gap(mut self, gap: u16) -> Self {
149        self.col_gap = gap;
150        self
151    }
152
153    /// Set uniform gap for both rows and columns.
154    #[must_use]
155    pub fn gap(self, gap: u16) -> Self {
156        self.row_gap(gap).col_gap(gap)
157    }
158
159    /// Define a named area in the grid.
160    ///
161    /// Named areas allow semantic access to grid regions:
162    /// ```ignore
163    /// let grid = Grid::new()
164    ///     .rows([Constraint::Fixed(3), Constraint::Min(10)])
165    ///     .columns([Constraint::Fixed(20), Constraint::Min(40)])
166    ///     .area("sidebar", GridArea::span(0, 0, 2, 1))  // Left column, both rows
167    ///     .area("content", GridArea::cell(0, 1))        // Top right
168    ///     .area("footer", GridArea::cell(1, 1));        // Bottom right
169    /// ```
170    #[must_use]
171    pub fn area(mut self, name: impl Into<String>, area: GridArea) -> Self {
172        self.named_areas.insert(name.into(), area);
173        self
174    }
175
176    /// Get the number of rows.
177    #[inline]
178    pub fn num_rows(&self) -> usize {
179        self.row_constraints.len()
180    }
181
182    /// Get the number of columns.
183    #[inline]
184    pub fn num_cols(&self) -> usize {
185        self.col_constraints.len()
186    }
187
188    /// Split the given area according to the grid configuration.
189    pub fn split(&self, area: Rect) -> GridLayout {
190        let num_rows = self.row_constraints.len();
191        let num_cols = self.col_constraints.len();
192
193        if num_rows == 0 || num_cols == 0 || area.is_empty() {
194            return GridLayout {
195                row_heights: vec![0; num_rows],
196                col_widths: vec![0; num_cols],
197                row_positions: vec![area.y; num_rows],
198                col_positions: vec![area.x; num_cols],
199                named_areas: self.named_areas.clone(),
200                row_gap: self.row_gap,
201                col_gap: self.col_gap,
202            };
203        }
204
205        // Calculate total gaps
206        let total_row_gap = if num_rows > 1 {
207            let gaps = (num_rows - 1) as u64;
208            (gaps * self.row_gap as u64).min(u16::MAX as u64) as u16
209        } else {
210            0
211        };
212        let total_col_gap = if num_cols > 1 {
213            let gaps = (num_cols - 1) as u64;
214            (gaps * self.col_gap as u64).min(u16::MAX as u64) as u16
215        } else {
216            0
217        };
218
219        // Available space after gaps
220        let available_height = area.height.saturating_sub(total_row_gap);
221        let available_width = area.width.saturating_sub(total_col_gap);
222
223        // Solve constraints
224        let row_heights = crate::solve_constraints(&self.row_constraints, available_height);
225        let col_widths = crate::solve_constraints(&self.col_constraints, available_width);
226
227        // Calculate positions
228        let row_positions = self.calculate_positions(&row_heights, area.y, self.row_gap);
229        let col_positions = self.calculate_positions(&col_widths, area.x, self.col_gap);
230
231        GridLayout {
232            row_heights,
233            col_widths,
234            row_positions,
235            col_positions,
236            named_areas: self.named_areas.clone(),
237            row_gap: self.row_gap,
238            col_gap: self.col_gap,
239        }
240    }
241
242    /// Calculate cumulative positions from sizes.
243    fn calculate_positions(&self, sizes: &[u16], start: u16, gap: u16) -> Vec<u16> {
244        let mut positions = Vec::with_capacity(sizes.len());
245        let mut pos = start;
246
247        for (i, &size) in sizes.iter().enumerate() {
248            positions.push(pos);
249            pos = pos.saturating_add(size);
250            if i < sizes.len() - 1 {
251                pos = pos.saturating_add(gap);
252            }
253        }
254
255        positions
256    }
257}
258
259impl GridLayout {
260    /// Get the rectangle for a specific cell.
261    ///
262    /// Returns an empty Rect if coordinates are out of bounds.
263    #[inline]
264    pub fn cell(&self, row: usize, col: usize) -> Rect {
265        self.span(row, col, 1, 1)
266    }
267
268    /// Get the rectangle for a spanning region.
269    ///
270    /// The region starts at (row, col) and spans rowspan rows and colspan columns.
271    pub fn span(&self, row: usize, col: usize, rowspan: usize, colspan: usize) -> Rect {
272        let rowspan = rowspan.max(1);
273        let colspan = colspan.max(1);
274
275        // Bounds check
276        if row >= self.row_heights.len() || col >= self.col_widths.len() {
277            return Rect::default();
278        }
279
280        let end_row = (row + rowspan).min(self.row_heights.len());
281        let end_col = (col + colspan).min(self.col_widths.len());
282
283        // Get starting position
284        let x = self.col_positions[col];
285        let y = self.row_positions[row];
286
287        // Calculate total width (sum of widths + gaps between spanned columns)
288        let mut width: u16 = 0;
289        for c in col..end_col {
290            width = width.saturating_add(self.col_widths[c]);
291        }
292        // Add gaps between columns (not after last)
293        if end_col > col + 1 {
294            let gap_count = (end_col - col - 1) as u16;
295            width = width.saturating_add(self.col_gap.saturating_mul(gap_count));
296        }
297
298        // Calculate total height (sum of heights + gaps between spanned rows)
299        let mut height: u16 = 0;
300        for r in row..end_row {
301            height = height.saturating_add(self.row_heights[r]);
302        }
303        if end_row > row + 1 {
304            let gap_count = (end_row - row - 1) as u16;
305            height = height.saturating_add(self.row_gap.saturating_mul(gap_count));
306        }
307
308        Rect::new(x, y, width, height)
309    }
310
311    /// Get the rectangle for a named area.
312    ///
313    /// Returns None if the area name is not defined.
314    pub fn area(&self, name: &str) -> Option<Rect> {
315        self.named_areas
316            .get(name)
317            .map(|a| self.span(a.row, a.col, a.rowspan, a.colspan))
318    }
319
320    /// Get the number of rows in this layout.
321    #[inline]
322    pub fn num_rows(&self) -> usize {
323        self.row_heights.len()
324    }
325
326    /// Get the number of columns in this layout.
327    #[inline]
328    pub fn num_cols(&self) -> usize {
329        self.col_widths.len()
330    }
331
332    /// Get the height of a specific row.
333    #[inline]
334    pub fn row_height(&self, row: usize) -> u16 {
335        self.row_heights.get(row).copied().unwrap_or(0)
336    }
337
338    /// Get the width of a specific column.
339    #[inline]
340    pub fn col_width(&self, col: usize) -> u16 {
341        self.col_widths.get(col).copied().unwrap_or(0)
342    }
343
344    /// Iterate over all cells, yielding (row, col, Rect).
345    pub fn iter_cells(&self) -> impl Iterator<Item = (usize, usize, Rect)> + '_ {
346        let num_rows = self.num_rows();
347        let num_cols = self.num_cols();
348        (0..num_rows)
349            .flat_map(move |row| (0..num_cols).map(move |col| (row, col, self.cell(row, col))))
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn empty_grid() {
359        let grid = Grid::new();
360        let layout = grid.split(Rect::new(0, 0, 100, 50));
361        assert_eq!(layout.num_rows(), 0);
362        assert_eq!(layout.num_cols(), 0);
363    }
364
365    #[test]
366    fn simple_2x2_grid() {
367        let grid = Grid::new()
368            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
369            .columns([Constraint::Fixed(20), Constraint::Fixed(20)]);
370
371        let layout = grid.split(Rect::new(0, 0, 100, 50));
372
373        assert_eq!(layout.num_rows(), 2);
374        assert_eq!(layout.num_cols(), 2);
375
376        // Check each cell
377        assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 20, 10));
378        assert_eq!(layout.cell(0, 1), Rect::new(20, 0, 20, 10));
379        assert_eq!(layout.cell(1, 0), Rect::new(0, 10, 20, 10));
380        assert_eq!(layout.cell(1, 1), Rect::new(20, 10, 20, 10));
381    }
382
383    #[test]
384    fn grid_with_gaps() {
385        let grid = Grid::new()
386            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
387            .columns([Constraint::Fixed(20), Constraint::Fixed(20)])
388            .row_gap(2)
389            .col_gap(5);
390
391        let layout = grid.split(Rect::new(0, 0, 100, 50));
392
393        // First row, first col
394        assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 20, 10));
395        // First row, second col (after col_gap of 5)
396        assert_eq!(layout.cell(0, 1), Rect::new(25, 0, 20, 10));
397        // Second row, first col (after row_gap of 2)
398        assert_eq!(layout.cell(1, 0), Rect::new(0, 12, 20, 10));
399        // Second row, second col
400        assert_eq!(layout.cell(1, 1), Rect::new(25, 12, 20, 10));
401    }
402
403    #[test]
404    fn percentage_constraints() {
405        let grid = Grid::new()
406            .rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
407            .columns([Constraint::Percentage(30.0), Constraint::Percentage(70.0)]);
408
409        let layout = grid.split(Rect::new(0, 0, 100, 50));
410
411        assert_eq!(layout.row_height(0), 25);
412        assert_eq!(layout.row_height(1), 25);
413        assert_eq!(layout.col_width(0), 30);
414        assert_eq!(layout.col_width(1), 70);
415    }
416
417    #[test]
418    fn min_constraints_fill_space() {
419        let grid = Grid::new()
420            .rows([Constraint::Fixed(10), Constraint::Min(5)])
421            .columns([Constraint::Fixed(20), Constraint::Min(10)]);
422
423        let layout = grid.split(Rect::new(0, 0, 100, 50));
424
425        // Min should expand to fill remaining space
426        assert_eq!(layout.row_height(0), 10);
427        assert_eq!(layout.row_height(1), 40); // 50 - 10 = 40
428        assert_eq!(layout.col_width(0), 20);
429        assert_eq!(layout.col_width(1), 80); // 100 - 20 = 80
430    }
431
432    #[test]
433    fn grid_span_clamps_out_of_bounds() {
434        let grid = Grid::new()
435            .rows([Constraint::Fixed(4), Constraint::Fixed(6)])
436            .columns([Constraint::Fixed(8), Constraint::Fixed(12)]);
437
438        let layout = grid.split(Rect::new(0, 0, 40, 20));
439        let span = layout.span(1, 1, 5, 5);
440
441        assert_eq!(span, Rect::new(8, 4, 12, 6));
442    }
443
444    #[test]
445    fn grid_span_includes_gaps_between_tracks() {
446        let grid = Grid::new()
447            .rows([Constraint::Fixed(3)])
448            .columns([
449                Constraint::Fixed(2),
450                Constraint::Fixed(2),
451                Constraint::Fixed(2),
452            ])
453            .col_gap(1);
454
455        let layout = grid.split(Rect::new(0, 0, 20, 10));
456        let span = layout.span(0, 0, 1, 3);
457
458        assert_eq!(span.width, 8); // 2 + 1 + 2 + 1 + 2
459        assert_eq!(span.height, 3);
460    }
461
462    #[test]
463    fn grid_tiny_area_with_gaps_produces_zero_tracks() {
464        let grid = Grid::new()
465            .rows([Constraint::Fixed(1), Constraint::Fixed(1)])
466            .columns([Constraint::Fixed(1), Constraint::Fixed(1)])
467            .row_gap(2)
468            .col_gap(2);
469
470        let layout = grid.split(Rect::new(0, 0, 1, 1));
471        assert_eq!(layout.row_height(0), 0);
472        assert_eq!(layout.row_height(1), 0);
473        assert_eq!(layout.col_width(0), 0);
474        assert_eq!(layout.col_width(1), 0);
475    }
476
477    #[test]
478    fn cell_spanning() {
479        let grid = Grid::new()
480            .rows([
481                Constraint::Fixed(10),
482                Constraint::Fixed(10),
483                Constraint::Fixed(10),
484            ])
485            .columns([
486                Constraint::Fixed(20),
487                Constraint::Fixed(20),
488                Constraint::Fixed(20),
489            ]);
490
491        let layout = grid.split(Rect::new(0, 0, 100, 50));
492
493        // Single cell
494        assert_eq!(layout.span(0, 0, 1, 1), Rect::new(0, 0, 20, 10));
495
496        // Horizontal span (2 columns)
497        assert_eq!(layout.span(0, 0, 1, 2), Rect::new(0, 0, 40, 10));
498
499        // Vertical span (2 rows)
500        assert_eq!(layout.span(0, 0, 2, 1), Rect::new(0, 0, 20, 20));
501
502        // 2x2 block
503        assert_eq!(layout.span(0, 0, 2, 2), Rect::new(0, 0, 40, 20));
504    }
505
506    #[test]
507    fn cell_spanning_with_gaps() {
508        let grid = Grid::new()
509            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
510            .columns([Constraint::Fixed(20), Constraint::Fixed(20)])
511            .row_gap(2)
512            .col_gap(5);
513
514        let layout = grid.split(Rect::new(0, 0, 100, 50));
515
516        // 2x2 span should include the gaps
517        let full = layout.span(0, 0, 2, 2);
518        // Width: 20 + 5 (gap) + 20 = 45
519        // Height: 10 + 2 (gap) + 10 = 22
520        assert_eq!(full.width, 45);
521        assert_eq!(full.height, 22);
522    }
523
524    #[test]
525    fn named_areas() {
526        let grid = Grid::new()
527            .rows([
528                Constraint::Fixed(5),
529                Constraint::Min(10),
530                Constraint::Fixed(3),
531            ])
532            .columns([Constraint::Fixed(20), Constraint::Min(30)])
533            .area("header", GridArea::span(0, 0, 1, 2))
534            .area("sidebar", GridArea::span(1, 0, 2, 1))
535            .area("content", GridArea::cell(1, 1))
536            .area("footer", GridArea::cell(2, 1));
537
538        let layout = grid.split(Rect::new(0, 0, 80, 30));
539
540        // Header spans both columns
541        let header = layout.area("header").unwrap();
542        assert_eq!(header.y, 0);
543        assert_eq!(header.height, 5);
544
545        // Sidebar spans rows 1 and 2
546        let sidebar = layout.area("sidebar").unwrap();
547        assert_eq!(sidebar.x, 0);
548        assert_eq!(sidebar.width, 20);
549
550        // Content is in the middle
551        let content = layout.area("content").unwrap();
552        assert_eq!(content.x, 20);
553        assert_eq!(content.y, 5);
554
555        // Footer is at the bottom right
556        let footer = layout.area("footer").unwrap();
557        assert_eq!(
558            footer.y,
559            layout.area("content").unwrap().y + layout.area("content").unwrap().height
560        );
561    }
562
563    #[test]
564    fn out_of_bounds_returns_empty() {
565        let grid = Grid::new()
566            .rows([Constraint::Fixed(10)])
567            .columns([Constraint::Fixed(20)]);
568
569        let layout = grid.split(Rect::new(0, 0, 100, 50));
570
571        // Out of bounds
572        assert_eq!(layout.cell(5, 5), Rect::default());
573        assert_eq!(layout.cell(0, 5), Rect::default());
574        assert_eq!(layout.cell(5, 0), Rect::default());
575    }
576
577    #[test]
578    fn iter_cells() {
579        let grid = Grid::new()
580            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
581            .columns([Constraint::Fixed(20), Constraint::Fixed(20)]);
582
583        let layout = grid.split(Rect::new(0, 0, 100, 50));
584
585        let cells: Vec<_> = layout.iter_cells().collect();
586        assert_eq!(cells.len(), 4);
587        assert_eq!(cells[0], (0, 0, Rect::new(0, 0, 20, 10)));
588        assert_eq!(cells[1], (0, 1, Rect::new(20, 0, 20, 10)));
589        assert_eq!(cells[2], (1, 0, Rect::new(0, 10, 20, 10)));
590        assert_eq!(cells[3], (1, 1, Rect::new(20, 10, 20, 10)));
591    }
592
593    #[test]
594    fn undefined_area_returns_none() {
595        let grid = Grid::new()
596            .rows([Constraint::Fixed(10)])
597            .columns([Constraint::Fixed(20)]);
598
599        let layout = grid.split(Rect::new(0, 0, 100, 50));
600
601        assert!(layout.area("nonexistent").is_none());
602    }
603
604    #[test]
605    fn empty_area_produces_empty_cells() {
606        let grid = Grid::new()
607            .rows([Constraint::Fixed(10)])
608            .columns([Constraint::Fixed(20)]);
609
610        let layout = grid.split(Rect::new(0, 0, 0, 0));
611
612        assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 0, 0));
613    }
614
615    #[test]
616    fn offset_area() {
617        let grid = Grid::new()
618            .rows([Constraint::Fixed(10)])
619            .columns([Constraint::Fixed(20)]);
620
621        let layout = grid.split(Rect::new(10, 5, 100, 50));
622
623        // Cell should be offset by the area origin
624        assert_eq!(layout.cell(0, 0), Rect::new(10, 5, 20, 10));
625    }
626
627    #[test]
628    fn ratio_constraints() {
629        let grid = Grid::new()
630            .rows([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
631            .columns([Constraint::Fixed(30)]);
632
633        let layout = grid.split(Rect::new(0, 0, 30, 30));
634
635        // 1:2 ratio should give roughly 10:20 split
636        assert_eq!(layout.row_height(0), 10);
637        assert_eq!(layout.row_height(1), 20);
638    }
639
640    #[test]
641    fn max_constraints() {
642        // Test that Max(N) clamps the size to at most N
643        let grid = Grid::new()
644            .rows([Constraint::Max(5), Constraint::Fixed(20)])
645            .columns([Constraint::Fixed(30)]);
646
647        let layout = grid.split(Rect::new(0, 0, 30, 30));
648
649        // Max(5) should get at most 5, but the remaining 5 (from 30-20=10 available)
650        // is distributed to Max, giving 5 which is then clamped to 5
651        assert!(layout.row_height(0) <= 5);
652        // Fixed gets its exact size
653        assert_eq!(layout.row_height(1), 20);
654    }
655
656    #[test]
657    fn fixed_constraints_exceed_available_clamped() {
658        let grid = Grid::new()
659            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
660            .columns([Constraint::Fixed(7), Constraint::Fixed(7)]);
661
662        let layout = grid.split(Rect::new(0, 0, 10, 15));
663
664        assert_eq!(layout.row_height(0), 10);
665        assert_eq!(layout.row_height(1), 5);
666        assert_eq!(layout.col_width(0), 7);
667        assert_eq!(layout.col_width(1), 3);
668    }
669
670    #[test]
671    fn ratio_constraints_rounding_sums_to_available() {
672        let grid = Grid::new()
673            .rows([Constraint::Fixed(1)])
674            .columns([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]);
675
676        let layout = grid.split(Rect::new(0, 0, 5, 1));
677
678        let total = layout.col_width(0) + layout.col_width(1);
679        assert_eq!(total, 5);
680        assert_eq!(layout.col_width(0), 1);
681        assert_eq!(layout.col_width(1), 4);
682    }
683
684    // --- Additional Grid tests ---
685
686    #[test]
687    fn uniform_gap_sets_both() {
688        let grid = Grid::new()
689            .rows([Constraint::Fixed(10), Constraint::Fixed(10)])
690            .columns([Constraint::Fixed(20), Constraint::Fixed(20)])
691            .gap(3);
692
693        let layout = grid.split(Rect::new(0, 0, 100, 50));
694
695        // Both row_gap and col_gap should be 3
696        assert_eq!(layout.cell(0, 1).x, 23); // 20 + 3
697        assert_eq!(layout.cell(1, 0).y, 13); // 10 + 3
698    }
699
700    #[test]
701    fn grid_area_cell_is_1x1_span() {
702        let a = GridArea::cell(2, 3);
703        assert_eq!(a.row, 2);
704        assert_eq!(a.col, 3);
705        assert_eq!(a.rowspan, 1);
706        assert_eq!(a.colspan, 1);
707    }
708
709    #[test]
710    fn grid_area_span_clamps_zero() {
711        // Zero spans should be clamped to 1
712        let a = GridArea::span(0, 0, 0, 0);
713        assert_eq!(a.rowspan, 1);
714        assert_eq!(a.colspan, 1);
715    }
716
717    #[test]
718    fn grid_num_rows_cols() {
719        let grid = Grid::new()
720            .rows([
721                Constraint::Fixed(5),
722                Constraint::Fixed(5),
723                Constraint::Fixed(5),
724            ])
725            .columns([Constraint::Fixed(10), Constraint::Fixed(10)]);
726        assert_eq!(grid.num_rows(), 3);
727        assert_eq!(grid.num_cols(), 2);
728    }
729
730    #[test]
731    fn grid_row_height_col_width_out_of_bounds() {
732        let grid = Grid::new()
733            .rows([Constraint::Fixed(10)])
734            .columns([Constraint::Fixed(20)]);
735        let layout = grid.split(Rect::new(0, 0, 100, 50));
736        assert_eq!(layout.row_height(0), 10);
737        assert_eq!(layout.row_height(99), 0); // Out of bounds returns 0
738        assert_eq!(layout.col_width(0), 20);
739        assert_eq!(layout.col_width(99), 0); // Out of bounds returns 0
740    }
741
742    #[test]
743    fn grid_span_clamped_to_bounds() {
744        let grid = Grid::new()
745            .rows([Constraint::Fixed(10)])
746            .columns([Constraint::Fixed(20)]);
747        let layout = grid.split(Rect::new(0, 0, 100, 50));
748
749        // Spanning beyond grid dimensions should clamp
750        let r = layout.span(0, 0, 5, 5);
751        // Should get the single cell (1x1 grid)
752        assert_eq!(r, Rect::new(0, 0, 20, 10));
753    }
754
755    #[test]
756    fn grid_with_all_constraint_types() {
757        let grid = Grid::new()
758            .rows([
759                Constraint::Fixed(5),
760                Constraint::Percentage(20.0),
761                Constraint::Min(3),
762                Constraint::Max(10),
763                Constraint::Ratio(1, 4),
764            ])
765            .columns([Constraint::Fixed(30)]);
766
767        let layout = grid.split(Rect::new(0, 0, 30, 50));
768
769        // All rows should have non-negative heights
770        let total: u16 = (0..layout.num_rows()).map(|r| layout.row_height(r)).sum();
771        assert!(total <= 50);
772    }
773
774    // Property-like invariant tests
775    #[test]
776    fn invariant_total_size_within_bounds() {
777        for (width, height) in [(50, 30), (100, 50), (80, 24)] {
778            let grid = Grid::new()
779                .rows([
780                    Constraint::Fixed(10),
781                    Constraint::Min(5),
782                    Constraint::Percentage(20.0),
783                ])
784                .columns([
785                    Constraint::Fixed(15),
786                    Constraint::Min(10),
787                    Constraint::Ratio(1, 2),
788                ]);
789
790            let layout = grid.split(Rect::new(0, 0, width, height));
791
792            let total_height: u16 = (0..layout.num_rows()).map(|r| layout.row_height(r)).sum();
793            let total_width: u16 = (0..layout.num_cols()).map(|c| layout.col_width(c)).sum();
794
795            assert!(
796                total_height <= height,
797                "Total height {} exceeds available {}",
798                total_height,
799                height
800            );
801            assert!(
802                total_width <= width,
803                "Total width {} exceeds available {}",
804                total_width,
805                width
806            );
807        }
808    }
809
810    #[test]
811    fn invariant_cells_within_area() {
812        let area = Rect::new(10, 20, 80, 60);
813        let grid = Grid::new()
814            .rows([
815                Constraint::Fixed(15),
816                Constraint::Min(10),
817                Constraint::Fixed(15),
818            ])
819            .columns([
820                Constraint::Fixed(20),
821                Constraint::Min(20),
822                Constraint::Fixed(20),
823            ])
824            .row_gap(2)
825            .col_gap(3);
826
827        let layout = grid.split(area);
828
829        for (row, col, cell) in layout.iter_cells() {
830            assert!(
831                cell.x >= area.x,
832                "Cell ({},{}) x {} < area x {}",
833                row,
834                col,
835                cell.x,
836                area.x
837            );
838            assert!(
839                cell.y >= area.y,
840                "Cell ({},{}) y {} < area y {}",
841                row,
842                col,
843                cell.y,
844                area.y
845            );
846            assert!(
847                cell.right() <= area.right(),
848                "Cell ({},{}) right {} > area right {}",
849                row,
850                col,
851                cell.right(),
852                area.right()
853            );
854            assert!(
855                cell.bottom() <= area.bottom(),
856                "Cell ({},{}) bottom {} > area bottom {}",
857                row,
858                col,
859                cell.bottom(),
860                area.bottom()
861            );
862        }
863    }
864}