1#![forbid(unsafe_code)]
2
3use crate::Constraint;
41use ftui_core::geometry::Rect;
42use std::collections::HashMap;
43
44#[derive(Debug, Clone, Default)]
46pub struct Grid {
47 row_constraints: Vec<Constraint>,
49 col_constraints: Vec<Constraint>,
51 row_gap: u16,
53 col_gap: u16,
55 named_areas: HashMap<String, GridArea>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct GridArea {
62 pub row: usize,
64 pub col: usize,
66 pub rowspan: usize,
68 pub colspan: usize,
70}
71
72impl GridArea {
73 #[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 #[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#[derive(Debug, Clone)]
100pub struct GridLayout {
101 row_heights: Vec<u16>,
103 col_widths: Vec<u16>,
105 row_positions: Vec<u16>,
107 col_positions: Vec<u16>,
109 named_areas: HashMap<String, GridArea>,
111 row_gap: u16,
113 col_gap: u16,
115}
116
117impl Grid {
118 #[inline]
120 #[must_use]
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 #[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 #[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 #[must_use]
141 pub fn row_gap(mut self, gap: u16) -> Self {
142 self.row_gap = gap;
143 self
144 }
145
146 #[must_use]
148 pub fn col_gap(mut self, gap: u16) -> Self {
149 self.col_gap = gap;
150 self
151 }
152
153 #[must_use]
155 pub fn gap(self, gap: u16) -> Self {
156 self.row_gap(gap).col_gap(gap)
157 }
158
159 #[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 #[inline]
178 pub fn num_rows(&self) -> usize {
179 self.row_constraints.len()
180 }
181
182 #[inline]
184 pub fn num_cols(&self) -> usize {
185 self.col_constraints.len()
186 }
187
188 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 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 let available_height = area.height.saturating_sub(total_row_gap);
221 let available_width = area.width.saturating_sub(total_col_gap);
222
223 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 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 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 #[inline]
264 pub fn cell(&self, row: usize, col: usize) -> Rect {
265 self.span(row, col, 1, 1)
266 }
267
268 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 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 let x = self.col_positions[col];
285 let y = self.row_positions[row];
286
287 let mut width: u16 = 0;
289 for c in col..end_col {
290 width = width.saturating_add(self.col_widths[c]);
291 }
292 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 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 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 #[inline]
322 pub fn num_rows(&self) -> usize {
323 self.row_heights.len()
324 }
325
326 #[inline]
328 pub fn num_cols(&self) -> usize {
329 self.col_widths.len()
330 }
331
332 #[inline]
334 pub fn row_height(&self, row: usize) -> u16 {
335 self.row_heights.get(row).copied().unwrap_or(0)
336 }
337
338 #[inline]
340 pub fn col_width(&self, col: usize) -> u16 {
341 self.col_widths.get(col).copied().unwrap_or(0)
342 }
343
344 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 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 assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 20, 10));
395 assert_eq!(layout.cell(0, 1), Rect::new(25, 0, 20, 10));
397 assert_eq!(layout.cell(1, 0), Rect::new(0, 12, 20, 10));
399 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 assert_eq!(layout.row_height(0), 10);
427 assert_eq!(layout.row_height(1), 40); assert_eq!(layout.col_width(0), 20);
429 assert_eq!(layout.col_width(1), 80); }
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); 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 assert_eq!(layout.span(0, 0, 1, 1), Rect::new(0, 0, 20, 10));
495
496 assert_eq!(layout.span(0, 0, 1, 2), Rect::new(0, 0, 40, 10));
498
499 assert_eq!(layout.span(0, 0, 2, 1), Rect::new(0, 0, 20, 20));
501
502 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 let full = layout.span(0, 0, 2, 2);
518 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 let header = layout.area("header").unwrap();
542 assert_eq!(header.y, 0);
543 assert_eq!(header.height, 5);
544
545 let sidebar = layout.area("sidebar").unwrap();
547 assert_eq!(sidebar.x, 0);
548 assert_eq!(sidebar.width, 20);
549
550 let content = layout.area("content").unwrap();
552 assert_eq!(content.x, 20);
553 assert_eq!(content.y, 5);
554
555 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 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 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 assert_eq!(layout.row_height(0), 10);
637 assert_eq!(layout.row_height(1), 20);
638 }
639
640 #[test]
641 fn max_constraints() {
642 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 assert!(layout.row_height(0) <= 5);
652 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 #[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 assert_eq!(layout.cell(0, 1).x, 23); assert_eq!(layout.cell(1, 0).y, 13); }
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 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); assert_eq!(layout.col_width(0), 20);
739 assert_eq!(layout.col_width(99), 0); }
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 let r = layout.span(0, 0, 5, 5);
751 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 let total: u16 = (0..layout.num_rows()).map(|r| layout.row_height(r)).sum();
771 assert!(total <= 50);
772 }
773
774 #[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}