#![forbid(unsafe_code)]
use crate::{Constraint, Sizes};
use ftui_core::geometry::Rect;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct Grid {
row_constraints: Vec<Constraint>,
col_constraints: Vec<Constraint>,
row_gap: u16,
col_gap: u16,
named_areas: HashMap<String, GridArea>,
overflow: crate::OverflowBehavior,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridArea {
pub row: usize,
pub col: usize,
pub rowspan: usize,
pub colspan: usize,
}
impl GridArea {
#[inline]
#[must_use]
pub fn cell(row: usize, col: usize) -> Self {
Self {
row,
col,
rowspan: 1,
colspan: 1,
}
}
#[inline]
#[must_use]
pub fn span(row: usize, col: usize, rowspan: usize, colspan: usize) -> Self {
Self {
row,
col,
rowspan: rowspan.max(1),
colspan: colspan.max(1),
}
}
}
#[derive(Debug, Clone)]
pub struct GridLayout {
row_heights: Sizes,
col_widths: Sizes,
row_positions: Sizes,
col_positions: Sizes,
named_areas: HashMap<String, GridArea>,
row_gap: u16,
col_gap: u16,
bounds: Rect,
}
impl Grid {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn rows(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
self.row_constraints = constraints.into_iter().collect();
self
}
#[must_use]
pub fn columns(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
self.col_constraints = constraints.into_iter().collect();
self
}
#[must_use]
pub fn row_gap(mut self, gap: u16) -> Self {
self.row_gap = gap;
self
}
#[must_use]
pub fn col_gap(mut self, gap: u16) -> Self {
self.col_gap = gap;
self
}
#[must_use]
pub fn gap(self, gap: u16) -> Self {
self.row_gap(gap).col_gap(gap)
}
#[must_use]
pub fn area(mut self, name: impl Into<String>, area: GridArea) -> Self {
self.named_areas.insert(name.into(), area);
self
}
#[must_use]
pub fn overflow(mut self, overflow: crate::OverflowBehavior) -> Self {
self.overflow = overflow;
self
}
#[must_use]
pub fn overflow_behavior(&self) -> crate::OverflowBehavior {
self.overflow
}
#[inline]
pub fn num_rows(&self) -> usize {
self.row_constraints.len()
}
#[inline]
pub fn num_cols(&self) -> usize {
self.col_constraints.len()
}
pub fn split(&self, area: Rect) -> GridLayout {
let num_rows = self.row_constraints.len();
let num_cols = self.col_constraints.len();
if num_rows == 0 || num_cols == 0 || area.is_empty() {
return GridLayout {
row_heights: smallvec::smallvec![0; num_rows],
col_widths: smallvec::smallvec![0; num_cols],
row_positions: smallvec::smallvec![area.y; num_rows],
col_positions: smallvec::smallvec![area.x; num_cols],
named_areas: self.named_areas.clone(),
row_gap: 0,
col_gap: 0,
bounds: area,
};
}
let total_row_gap = if num_rows > 1 {
let gaps = (num_rows - 1) as u64;
(gaps * self.row_gap as u64).min(u16::MAX as u64) as u16
} else {
0
};
let total_col_gap = if num_cols > 1 {
let gaps = (num_cols - 1) as u64;
(gaps * self.col_gap as u64).min(u16::MAX as u64) as u16
} else {
0
};
let available_height = area.height.saturating_sub(total_row_gap);
let available_width = area.width.saturating_sub(total_col_gap);
let row_heights = crate::solve_constraints(&self.row_constraints, available_height);
let col_widths = crate::solve_constraints(&self.col_constraints, available_width);
let row_positions = self.calculate_positions(&row_heights, area.y, self.row_gap);
let col_positions = self.calculate_positions(&col_widths, area.x, self.col_gap);
GridLayout {
row_heights,
col_widths,
row_positions,
col_positions,
named_areas: self.named_areas.clone(),
row_gap: self.row_gap,
col_gap: self.col_gap,
bounds: area,
}
}
fn calculate_positions(&self, sizes: &[u16], start: u16, gap: u16) -> Sizes {
let mut positions = Sizes::with_capacity(sizes.len());
let mut pos = start;
for (i, &size) in sizes.iter().enumerate() {
positions.push(pos);
pos = pos.saturating_add(size);
if i < sizes.len() - 1 {
pos = pos.saturating_add(gap);
}
}
positions
}
}
impl GridLayout {
#[inline]
pub fn cell(&self, row: usize, col: usize) -> Rect {
self.span(row, col, 1, 1)
}
pub fn span(&self, row: usize, col: usize, rowspan: usize, colspan: usize) -> Rect {
let rowspan = rowspan.max(1);
let colspan = colspan.max(1);
if row >= self.row_heights.len() || col >= self.col_widths.len() {
return Rect::default();
}
let end_row = (row + rowspan).min(self.row_heights.len());
let end_col = (col + colspan).min(self.col_widths.len());
let x = self.col_positions[col];
let y = self.row_positions[row];
let mut width: u16 = 0;
for c in col..end_col {
width = width.saturating_add(self.col_widths[c]);
}
if end_col > col + 1 {
let gap_count = (end_col - col - 1) as u16;
width = width.saturating_add(self.col_gap.saturating_mul(gap_count));
}
let mut height: u16 = 0;
for r in row..end_row {
height = height.saturating_add(self.row_heights[r]);
}
if end_row > row + 1 {
let gap_count = (end_row - row - 1) as u16;
height = height.saturating_add(self.row_gap.saturating_mul(gap_count));
}
Rect::new(x, y, width, height).intersection(&self.bounds)
}
pub fn area(&self, name: &str) -> Option<Rect> {
self.named_areas
.get(name)
.map(|a| self.span(a.row, a.col, a.rowspan, a.colspan))
}
#[inline]
pub fn num_rows(&self) -> usize {
self.row_heights.len()
}
#[inline]
pub fn num_cols(&self) -> usize {
self.col_widths.len()
}
#[inline]
pub fn row_height(&self, row: usize) -> u16 {
self.row_heights.get(row).copied().unwrap_or(0)
}
#[inline]
pub fn col_width(&self, col: usize) -> u16 {
self.col_widths.get(col).copied().unwrap_or(0)
}
pub fn iter_cells(&self) -> impl Iterator<Item = (usize, usize, Rect)> + '_ {
let num_rows = self.num_rows();
let num_cols = self.num_cols();
(0..num_rows)
.flat_map(move |row| (0..num_cols).map(move |col| (row, col, self.cell(row, col))))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_grid() {
let grid = Grid::new();
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.num_rows(), 0);
assert_eq!(layout.num_cols(), 0);
}
#[test]
fn simple_2x2_grid() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(20), Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.num_rows(), 2);
assert_eq!(layout.num_cols(), 2);
assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 20, 10));
assert_eq!(layout.cell(0, 1), Rect::new(20, 0, 20, 10));
assert_eq!(layout.cell(1, 0), Rect::new(0, 10, 20, 10));
assert_eq!(layout.cell(1, 1), Rect::new(20, 10, 20, 10));
}
#[test]
fn grid_with_gaps() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(20), Constraint::Fixed(20)])
.row_gap(2)
.col_gap(5);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 20, 10));
assert_eq!(layout.cell(0, 1), Rect::new(25, 0, 20, 10));
assert_eq!(layout.cell(1, 0), Rect::new(0, 12, 20, 10));
assert_eq!(layout.cell(1, 1), Rect::new(25, 12, 20, 10));
}
#[test]
fn percentage_constraints() {
let grid = Grid::new()
.rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
.columns([Constraint::Percentage(30.0), Constraint::Percentage(70.0)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.row_height(0), 25);
assert_eq!(layout.row_height(1), 25);
assert_eq!(layout.col_width(0), 30);
assert_eq!(layout.col_width(1), 70);
}
#[test]
fn min_constraints_fill_space() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Min(5)])
.columns([Constraint::Fixed(20), Constraint::Min(10)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.row_height(0), 10);
assert_eq!(layout.row_height(1), 40); assert_eq!(layout.col_width(0), 20);
assert_eq!(layout.col_width(1), 80); }
#[test]
fn grid_span_clamps_out_of_bounds() {
let grid = Grid::new()
.rows([Constraint::Fixed(4), Constraint::Fixed(6)])
.columns([Constraint::Fixed(8), Constraint::Fixed(12)]);
let layout = grid.split(Rect::new(0, 0, 40, 20));
let span = layout.span(1, 1, 5, 5);
assert_eq!(span, Rect::new(8, 4, 12, 6));
}
#[test]
fn grid_span_includes_gaps_between_tracks() {
let grid = Grid::new()
.rows([Constraint::Fixed(3)])
.columns([
Constraint::Fixed(2),
Constraint::Fixed(2),
Constraint::Fixed(2),
])
.col_gap(1);
let layout = grid.split(Rect::new(0, 0, 20, 10));
let span = layout.span(0, 0, 1, 3);
assert_eq!(span.width, 8); assert_eq!(span.height, 3);
}
#[test]
fn grid_tiny_area_with_gaps_produces_zero_tracks() {
let grid = Grid::new()
.rows([Constraint::Fixed(1), Constraint::Fixed(1)])
.columns([Constraint::Fixed(1), Constraint::Fixed(1)])
.row_gap(2)
.col_gap(2);
let layout = grid.split(Rect::new(0, 0, 1, 1));
assert_eq!(layout.row_height(0), 0);
assert_eq!(layout.row_height(1), 0);
assert_eq!(layout.col_width(0), 0);
assert_eq!(layout.col_width(1), 0);
}
#[test]
fn cell_spanning() {
let grid = Grid::new()
.rows([
Constraint::Fixed(10),
Constraint::Fixed(10),
Constraint::Fixed(10),
])
.columns([
Constraint::Fixed(20),
Constraint::Fixed(20),
Constraint::Fixed(20),
]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.span(0, 0, 1, 1), Rect::new(0, 0, 20, 10));
assert_eq!(layout.span(0, 0, 1, 2), Rect::new(0, 0, 40, 10));
assert_eq!(layout.span(0, 0, 2, 1), Rect::new(0, 0, 20, 20));
assert_eq!(layout.span(0, 0, 2, 2), Rect::new(0, 0, 40, 20));
}
#[test]
fn cell_spanning_with_gaps() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(20), Constraint::Fixed(20)])
.row_gap(2)
.col_gap(5);
let layout = grid.split(Rect::new(0, 0, 100, 50));
let full = layout.span(0, 0, 2, 2);
assert_eq!(full.width, 45);
assert_eq!(full.height, 22);
}
#[test]
fn named_areas() {
let grid = Grid::new()
.rows([
Constraint::Fixed(5),
Constraint::Min(10),
Constraint::Fixed(3),
])
.columns([Constraint::Fixed(20), Constraint::Min(30)])
.area("header", GridArea::span(0, 0, 1, 2))
.area("sidebar", GridArea::span(1, 0, 2, 1))
.area("content", GridArea::cell(1, 1))
.area("footer", GridArea::cell(2, 1));
let layout = grid.split(Rect::new(0, 0, 80, 30));
let header = layout.area("header").unwrap();
assert_eq!(header.y, 0);
assert_eq!(header.height, 5);
let sidebar = layout.area("sidebar").unwrap();
assert_eq!(sidebar.x, 0);
assert_eq!(sidebar.width, 20);
let content = layout.area("content").unwrap();
assert_eq!(content.x, 20);
assert_eq!(content.y, 5);
let footer = layout.area("footer").unwrap();
assert_eq!(
footer.y,
layout.area("content").unwrap().y + layout.area("content").unwrap().height
);
}
#[test]
fn out_of_bounds_returns_empty() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.cell(5, 5), Rect::default());
assert_eq!(layout.cell(0, 5), Rect::default());
assert_eq!(layout.cell(5, 0), Rect::default());
}
#[test]
fn iter_cells() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(20), Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
let cells: Vec<_> = layout.iter_cells().collect();
assert_eq!(cells.len(), 4);
assert_eq!(cells[0], (0, 0, Rect::new(0, 0, 20, 10)));
assert_eq!(cells[1], (0, 1, Rect::new(20, 0, 20, 10)));
assert_eq!(cells[2], (1, 0, Rect::new(0, 10, 20, 10)));
assert_eq!(cells[3], (1, 1, Rect::new(20, 10, 20, 10)));
}
#[test]
fn undefined_area_returns_none() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert!(layout.area("nonexistent").is_none());
}
#[test]
fn empty_area_produces_empty_cells() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 0, 0));
assert_eq!(layout.cell(0, 0), Rect::new(0, 0, 0, 0));
}
#[test]
fn offset_area() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(10, 5, 100, 50));
assert_eq!(layout.cell(0, 0), Rect::new(10, 5, 20, 10));
}
#[test]
fn ratio_constraints() {
let grid = Grid::new()
.rows([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.columns([Constraint::Fixed(30)]);
let layout = grid.split(Rect::new(0, 0, 30, 30));
assert_eq!(layout.row_height(0), 10);
assert_eq!(layout.row_height(1), 20);
}
#[test]
fn max_constraints() {
let grid = Grid::new()
.rows([Constraint::Max(5), Constraint::Fixed(20)])
.columns([Constraint::Fixed(30)]);
let layout = grid.split(Rect::new(0, 0, 30, 30));
assert!(layout.row_height(0) <= 5);
assert_eq!(layout.row_height(1), 20);
}
#[test]
fn fixed_constraints_exceed_available_clamped() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(7), Constraint::Fixed(7)]);
let layout = grid.split(Rect::new(0, 0, 10, 15));
assert_eq!(layout.row_height(0), 10);
assert_eq!(layout.row_height(1), 5);
assert_eq!(layout.col_width(0), 7);
assert_eq!(layout.col_width(1), 3);
}
#[test]
fn ratio_constraints_calculate_strictly() {
let grid = Grid::new()
.rows([Constraint::Fixed(1)])
.columns([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]);
let layout = grid.split(Rect::new(0, 0, 5, 1));
assert_eq!(layout.col_width(0), 1);
assert_eq!(layout.col_width(1), 3);
}
#[test]
fn uniform_gap_sets_both() {
let grid = Grid::new()
.rows([Constraint::Fixed(10), Constraint::Fixed(10)])
.columns([Constraint::Fixed(20), Constraint::Fixed(20)])
.gap(3);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.cell(0, 1).x, 23); assert_eq!(layout.cell(1, 0).y, 13); }
#[test]
fn grid_area_cell_is_1x1_span() {
let a = GridArea::cell(2, 3);
assert_eq!(a.row, 2);
assert_eq!(a.col, 3);
assert_eq!(a.rowspan, 1);
assert_eq!(a.colspan, 1);
}
#[test]
fn grid_area_span_clamps_zero() {
let a = GridArea::span(0, 0, 0, 0);
assert_eq!(a.rowspan, 1);
assert_eq!(a.colspan, 1);
}
#[test]
fn grid_num_rows_cols() {
let grid = Grid::new()
.rows([
Constraint::Fixed(5),
Constraint::Fixed(5),
Constraint::Fixed(5),
])
.columns([Constraint::Fixed(10), Constraint::Fixed(10)]);
assert_eq!(grid.num_rows(), 3);
assert_eq!(grid.num_cols(), 2);
}
#[test]
fn grid_row_height_col_width_out_of_bounds() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
assert_eq!(layout.row_height(0), 10);
assert_eq!(layout.row_height(99), 0); assert_eq!(layout.col_width(0), 20);
assert_eq!(layout.col_width(99), 0); }
#[test]
fn grid_span_clamped_to_bounds() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
let r = layout.span(0, 0, 5, 5);
assert_eq!(r, Rect::new(0, 0, 20, 10));
}
#[test]
fn grid_with_all_constraint_types() {
let grid = Grid::new()
.rows([
Constraint::Fixed(5),
Constraint::Percentage(20.0),
Constraint::Min(3),
Constraint::Max(10),
Constraint::Ratio(1, 4),
])
.columns([Constraint::Fixed(30)]);
let layout = grid.split(Rect::new(0, 0, 30, 50));
let total: u16 = (0..layout.num_rows()).map(|r| layout.row_height(r)).sum();
assert!(total <= 50);
}
#[test]
fn invariant_total_size_within_bounds() {
for (width, height) in [(50, 30), (100, 50), (80, 24)] {
let grid = Grid::new()
.rows([
Constraint::Fixed(10),
Constraint::Min(5),
Constraint::Percentage(20.0),
])
.columns([
Constraint::Fixed(15),
Constraint::Min(10),
Constraint::Ratio(1, 2),
]);
let layout = grid.split(Rect::new(0, 0, width, height));
let total_height: u16 = (0..layout.num_rows()).map(|r| layout.row_height(r)).sum();
let total_width: u16 = (0..layout.num_cols()).map(|c| layout.col_width(c)).sum();
assert!(
total_height <= height,
"Total height {} exceeds available {}",
total_height,
height
);
assert!(
total_width <= width,
"Total width {} exceeds available {}",
total_width,
width
);
}
}
#[test]
fn invariant_cells_within_area() {
let area = Rect::new(10, 20, 80, 60);
let grid = Grid::new()
.rows([
Constraint::Fixed(15),
Constraint::Min(10),
Constraint::Fixed(15),
])
.columns([
Constraint::Fixed(20),
Constraint::Min(20),
Constraint::Fixed(20),
])
.row_gap(2)
.col_gap(3);
let layout = grid.split(area);
for (row, col, cell) in layout.iter_cells() {
assert!(
cell.x >= area.x,
"Cell ({},{}) x {} < area x {}",
row,
col,
cell.x,
area.x
);
assert!(
cell.y >= area.y,
"Cell ({},{}) y {} < area y {}",
row,
col,
cell.y,
area.y
);
assert!(
cell.right() <= area.right(),
"Cell ({},{}) right {} > area right {}",
row,
col,
cell.right(),
area.right()
);
assert!(
cell.bottom() <= area.bottom(),
"Cell ({},{}) bottom {} > area bottom {}",
row,
col,
cell.bottom(),
area.bottom()
);
}
}
#[test]
fn grid_layout_span_clamps_zero() {
let grid = Grid::new()
.rows([Constraint::Fixed(10)])
.columns([Constraint::Fixed(20)]);
let layout = grid.split(Rect::new(0, 0, 100, 50));
let r = layout.span(0, 0, 0, 0);
assert_eq!(r, layout.cell(0, 0));
assert_eq!(r.width, 20);
assert_eq!(r.height, 10);
}
}