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