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