Repository: Evertras/bubble-table (go)

Issue:
feat: method to expand to fill height tea.WithAltScreen()
Hello -- awesome library. I'm looking to use this with `tea.WithAltScreen()`, however I'm struggling to figure out the best approach to have the table expand to fill the entire height. This is the current layout of my UI (ignore the broken background):

![](https://cdn.liam.sh/share/2022/07/WindowsTerminal_22gIjsLZhT.png)

I'd essentially like the outer border to expand towards the bottom like shown below, however I don't see any height fields exposed to allow such a configuration.

![](https://cdn.liam.sh/share/2022/07/WindowsTerminal_1uxdiNGkjQ.png)

My first two thoughts would be:

1. An empty row, with no column dividers (and depending on if footer is enabled, the borders would be rounded).
2. A flag for toggling outer borders, but still allowing inner borders. This could allow the user to draw their own border. Currently, toggling border settings seems to apply across the board, including all columns/rows.

Thoughts? Is there another way that I'm missing to do this? I've thought about repurposing the footer, and dynamically changing the height of it, but that feels fragile given the dynamic height of some of the other components inside of the table.

Pull request description:
Add minimum height option to table
Fixes #115.

Adds a `WithMinimumHeight()` option to `Model`. When set, we add "padding" rows when rendering the view to meet the specified minimum height.

Caveats:
* If the data size or page size is large, the table ends up being taller than the minimum height. The issue mentions "fixed" height (from me too), so I just want to call out that this solution is slightly different, but the use case is to fill the screen so I think this PR still fits that criteria.
* There is an edge case where there are zero rows of data and a small minimum height, where we are one line height away from the requested minimum. Because we can't add only a border, we add an empty row with border anyway, leading to a table that is one line taller than the requested minimum.

Patch:
diff --git a/examples/flex/main.go b/examples/flex/main.go
index 6e0963d..d8756e7 100644
--- a/examples/flex/main.go
+++ b/examples/flex/main.go
@@ -14,13 +14,23 @@ const (
 	columnKeyElement     = "element"
 	columnKeyDescription = "description"
 
-	minWidth = 30
+	minWidth  = 30
+	minHeight = 8
+
+	// Add a fixed margin to account for description & instructions
+	fixedVerticalMargin = 4
 )
 
 type Model struct {
-	flexTable   table.Model
-	totalMargin int
+	flexTable table.Model
+
+	// Window dimensions
 	totalWidth  int
+	totalHeight int
+
+	// Table dimensions
+	horizontalMargin int
+	verticalMargin   int
 }
 
 func NewModel() Model {
@@ -69,20 +79,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, tea.Quit)
 
 		case "left":
-			if m.totalWidth-m.totalMargin > minWidth {
-				m.totalMargin++
+			if m.calculateWidth() > minWidth {
+				m.horizontalMargin++
 				m.recalculateTable()
 			}
 
 		case "right":
-			if m.totalMargin > 0 {
-				m.totalMargin--
+			if m.horizontalMargin > 0 {
+				m.horizontalMargin--
+				m.recalculateTable()
+			}
+
+		case "up":
+			if m.calculateHeight() > minHeight {
+				m.verticalMargin++
+				m.recalculateTable()
+			}
+
+		case "down":
+			if m.verticalMargin > 0 {
+				m.verticalMargin--
 				m.recalculateTable()
 			}
 		}
 
 	case tea.WindowSizeMsg:
 		m.totalWidth = msg.Width
+		m.totalHeight = msg.Height
 
 		m.recalculateTable()
 	}
@@ -91,13 +114,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *Model) recalculateTable() {
-	m.flexTable = m.flexTable.WithTargetWidth(m.totalWidth - m.totalMargin)
+	m.flexTable = m.flexTable.
+		WithTargetWidth(m.calculateWidth()).
+		WithMinimumHeight(m.calculateHeight())
+}
+
+func (m Model) calculateWidth() int {
+	return m.totalWidth - m.horizontalMargin
+}
+
+func (m Model) calculateHeight() int {
+	return m.totalHeight - m.verticalMargin - fixedVerticalMargin
 }
 
 func (m Model) View() string {
 	strs := []string{
-		"A flexible table that fills available space (Name is fixed-width)",
-		fmt.Sprintf("Target size: %d (left/right to adjust)", m.totalWidth-m.totalMargin),
+		"A flexible table that fills available space (Name column is fixed-width)",
+		fmt.Sprintf("Target size: %d W ⨉ %d H (arrow keys to adjust)",
+			m.calculateWidth(), m.calculateHeight()),
 		"Press q or ctrl+c to quit",
 		m.flexTable.View(),
 	}
diff --git a/table/border.go b/table/border.go
index 9176e73..f086760 100644
--- a/table/border.go
+++ b/table/border.go
@@ -358,7 +358,7 @@ func (b *borderStyleRow) inherit(s lipgloss.Style) {
 //
 //nolint:nestif
 func (m Model) styleHeaders() borderStyleRow {
-	hasRows := len(m.GetVisibleRows()) > 0
+	hasRows := len(m.GetVisibleRows()) > 0 || m.calculatePadding(0) > 0
 	singleColumn := len(m.columns) == 1
 	styles := borderStyleRow{}
 
diff --git a/table/dimensions.go b/table/dimensions.go
index dbf5997..3807461 100644
--- a/table/dimensions.go
+++ b/table/dimensions.go
@@ -1,5 +1,9 @@
 package table
 
+import (
+	"github.com/charmbracelet/lipgloss"
+)
+
 func (m *Model) recalculateWidth() {
 	if m.targetTotalWidth != 0 {
 		m.totalWidth = m.targetTotalWidth
@@ -71,3 +75,42 @@ func updateColumnWidths(cols []Column, totalWidth int) {
 		cols[index].style = cols[index].style.Width(width)
 	}
 }
+
+func (m *Model) recalculateHeight() {
+	header := m.renderHeaders()
+	headerHeight := 1 // Header always has the top border
+	if m.headerVisible {
+		headerHeight = lipgloss.Height(header)
+	}
+
+	footer := m.renderFooter(lipgloss.Width(header), false)
+	var footerHeight int
+	if footer != "" {
+		footerHeight = lipgloss.Height(footer)
+	}
+
+	m.metaHeight = headerHeight + footerHeight
+}
+
+func (m *Model) calculatePadding(numRows int) int {
+	if m.minimumHeight == 0 {
+		return 0
+	}
+
+	padding := m.minimumHeight - m.metaHeight - numRows - 1 // additional 1 for bottom border
+
+	if padding == 0 && numRows == 0 {
+		// This is an edge case where we want to add 1 additional line of height, i.e.
+		// add a border without an empty row. However, this is not possible, so we need
+		// to add an extra row which will result in the table being 1 row taller than
+		// the requested minimum height.
+		return 1
+	}
+
+	if padding < 0 {
+		// Table is already larger than minimum height, do nothing.
+		return 0
+	}
+
+	return padding
+}
diff --git a/table/model.go b/table/model.go
index be25cf2..2794ecd 100644
--- a/table/model.go
+++ b/table/model.go
@@ -82,6 +82,13 @@ type Model struct {
 
 	// Calculated maximum column we can scroll to before the last is displayed
 	maxHorizontalColumnIndex int
+
+	// Minimum total height of the table
+	minimumHeight int
+
+	// Internal cached calculation, the height of the header and footer
+	// including borders. Used to determine how many padding rows to add.
+	metaHeight int
 }
 
 // New creates a new table ready for further modifications.
diff --git a/table/options.go b/table/options.go
index 05f6fb8..5aa5258 100644
--- a/table/options.go
+++ b/table/options.go
@@ -132,6 +132,10 @@ func (m Model) Filtered(filtered bool) Model {
 	m.filtered = filtered
 	m.visibleRowCacheUpdated = false
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
@@ -146,6 +150,10 @@ func (m Model) StartFilterTyping() Model {
 func (m Model) WithStaticFooter(footer string) Model {
 	m.staticFooter = footer
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
@@ -160,6 +168,10 @@ func (m Model) WithPageSize(pageSize int) Model {
 		m.currentPage = maxPages - 1
 	}
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
@@ -167,6 +179,10 @@ func (m Model) WithPageSize(pageSize int) Model {
 func (m Model) WithNoPagination() Model {
 	m.pageSize = 0
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
@@ -211,6 +227,15 @@ func (m Model) WithTargetWidth(totalWidth int) Model {
 	return m
 }
 
+// WithMinimumHeight sets the minimum total height of the table, including borders.
+func (m Model) WithMinimumHeight(minimumHeight int) Model {
+	m.minimumHeight = minimumHeight
+
+	m.recalculateHeight()
+
+	return m
+}
+
 // PageDown goes to the next page of a paginated table, wrapping to the first
 // page if the table is already on the last page.
 func (m Model) PageDown() Model {
@@ -310,6 +335,10 @@ func (m Model) WithFilterInputValue(value string) Model {
 func (m Model) WithFooterVisibility(visibility bool) Model {
 	m.footerVisible = visibility
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
@@ -317,6 +346,10 @@ func (m Model) WithFooterVisibility(visibility bool) Model {
 func (m Model) WithHeaderVisibility(visibility bool) Model {
 	m.headerVisible = visibility
 
+	if m.minimumHeight > 0 {
+		m.recalculateHeight()
+	}
+
 	return m
 }
 
diff --git a/table/row.go b/table/row.go
index d063f68..dc17690 100644
--- a/table/row.go
+++ b/table/row.go
@@ -92,17 +92,9 @@ func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Sty
 	return cellStr
 }
 
-// This is long and could use some refactoring in the future, but not quite sure
-// how to pick it apart yet.
-//
-//nolint:funlen, cyclop, gocognit
 func (m Model) renderRow(rowIndex int, last bool) string {
-	numColumns := len(m.columns)
 	row := m.GetVisibleRows()[rowIndex]
 	highlighted := rowIndex == m.rowCursorIndex
-	totalRenderedWidth := 0
-
-	columnStrings := []string{}
 
 	rowStyle := row.Style.Copy()
 
@@ -110,6 +102,23 @@ func (m Model) renderRow(rowIndex int, last bool) string {
 		rowStyle = rowStyle.Inherit(m.highlightStyle)
 	}
 
+	return m.renderRowData(row, rowStyle, last)
+}
+
+func (m Model) renderBlankRow(last bool) string {
+	return m.renderRowData(NewRow(nil), lipgloss.NewStyle(), last)
+}
+
+// This is long and could use some refactoring in the future, but not quite sure
+// how to pick it apart yet.
+//
+//nolint:funlen, cyclop, gocognit
+func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string {
+	numColumns := len(m.columns)
+
+	columnStrings := []string{}
+	totalRenderedWidth := 0
+
 	stylesInner, stylesLast := m.styleRows()
 
 	for columnIndex, column := range m.columns {
diff --git a/table/view.go b/table/view.go
index e6bb9d2..b9fcb83 100644
--- a/table/view.go
+++ b/table/view.go
@@ -8,6 +8,8 @@ import (
 
 // View renders the table.  It does not end in a newline, so that it can be
 // composed with other elements more consistently.
+//
+//nolint:cyclop
 func (m Model) View() string {
 	// Safety valve for empty tables
 	if len(m.columns) == 0 {
@@ -21,17 +23,24 @@ func (m Model) View() string {
 	headers := m.renderHeaders()
 
 	startRowIndex, endRowIndex := m.VisibleIndices()
+	numRows := endRowIndex - startRowIndex + 1
+
+	padding := m.calculatePadding(numRows)
 
 	if m.headerVisible {
 		rowStrs = append(rowStrs, headers)
-	} else if endRowIndex-startRowIndex > 0 {
+	} else if numRows > 0 || padding > 0 {
 		//nolint: gomnd // This is just getting the first newlined substring
 		split := strings.SplitN(headers, "\n", 2)
 		rowStrs = append(rowStrs, split[0])
 	}
 
 	for i := startRowIndex; i <= endRowIndex; i++ {
-		rowStrs = append(rowStrs, m.renderRow(i, i == endRowIndex))
+		rowStrs = append(rowStrs, m.renderRow(i, padding == 0 && i == endRowIndex))
+	}
+
+	for i := 1; i <= padding; i++ {
+		rowStrs = append(rowStrs, m.renderBlankRow(i == padding))
 	}
 
 	var footer string


Test patch:
diff --git a/table/dimensions_test.go b/table/dimensions_test.go
index 66970fc..b9c9dae 100644
--- a/table/dimensions_test.go
+++ b/table/dimensions_test.go
@@ -149,3 +149,87 @@ func TestColumnUpdateWidths(t *testing.T) {
 		})
 	}
 }
+
+// This function is long because it has many test cases
+//
+//nolint:funlen
+func TestRecalculateHeight(t *testing.T) {
+	columns := []Column{
+		NewColumn("ka", "a", 3),
+		NewColumn("kb", "b", 4),
+		NewColumn("kc", "c", 5),
+	}
+
+	rows := []Row{
+		NewRow(RowData{"ka": 1, "kb": 23, "kc": "zyx"}),
+		NewRow(RowData{"ka": 3, "kb": 34, "kc": "wvu"}),
+		NewRow(RowData{"ka": 5, "kb": 45, "kc": "zyx"}),
+		NewRow(RowData{"ka": 7, "kb": 56, "kc": "wvu"}),
+	}
+
+	tests := []struct {
+		name           string
+		model          Model
+		expectedHeight int
+	}{
+		{
+			name:           "Default header",
+			model:          New(columns).WithRows(rows),
+			expectedHeight: 3,
+		},
+		{
+			name:           "Empty page with default header",
+			model:          New(columns),
+			expectedHeight: 3,
+		},
+		{
+			name:           "Filtered with default header",
+			model:          New(columns).WithRows(rows).Filtered(true),
+			expectedHeight: 5,
+		},
+		{
+			name:           "Static footer one line",
+			model:          New(columns).WithRows(rows).WithStaticFooter("single line"),
+			expectedHeight: 5,
+		},
+		{
+			name: "Static footer overflow",
+			model: New(columns).WithRows(rows).
+				WithStaticFooter("single line but it's long"),
+			expectedHeight: 6,
+		},
+		{
+			name: "Static footer multi-line",
+			model: New(columns).WithRows(rows).
+				WithStaticFooter("footer with\nmultiple lines"),
+			expectedHeight: 6,
+		},
+		{
+			name:           "Paginated",
+			model:          New(columns).WithRows(rows).WithPageSize(2),
+			expectedHeight: 5,
+		},
+		{
+			name:           "No pagination",
+			model:          New(columns).WithRows(rows).WithPageSize(2).WithNoPagination(),
+			expectedHeight: 3,
+		},
+		{
+			name:           "Footer not visible",
+			model:          New(columns).WithRows(rows).Filtered(true).WithFooterVisibility(false),
+			expectedHeight: 3,
+		},
+		{
+			name:           "Header not visible",
+			model:          New(columns).WithRows(rows).WithHeaderVisibility(false),
+			expectedHeight: 1,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			test.model.recalculateHeight()
+			assert.Equal(t, test.expectedHeight, test.model.metaHeight)
+		})
+	}
+}
diff --git a/table/options_test.go b/table/options_test.go
index c45b358..d75b2ec 100644
--- a/table/options_test.go
+++ b/table/options_test.go
@@ -139,6 +139,36 @@ func TestPageOptions(t *testing.T) {
 	assert.Contains(t, model.renderFooter(10, false), "6/6")
 }
 
+func TestMinimumHeightOptions(t *testing.T) {
+	columns := []Column{
+		NewColumn("ka", "a", 3),
+		NewColumn("kb", "b", 4),
+		NewColumn("kc", "c", 5),
+	}
+
+	model := New(columns).WithMinimumHeight(10)
+	assert.Equal(t, 10, model.minimumHeight)
+	assert.Equal(t, 3, model.metaHeight)
+
+	model = model.WithPageSize(2)
+	assert.Equal(t, 5, model.metaHeight)
+
+	model = model.WithNoPagination()
+	assert.Equal(t, 3, model.metaHeight)
+
+	model = model.WithStaticFooter("footer with\nmultiple lines")
+	assert.Equal(t, 6, model.metaHeight)
+
+	model = model.WithStaticFooter("").Filtered(true)
+	assert.Equal(t, 5, model.metaHeight)
+
+	model = model.WithFooterVisibility(false)
+	assert.Equal(t, 3, model.metaHeight)
+
+	model = model.WithHeaderVisibility(false)
+	assert.Equal(t, 1, model.metaHeight)
+}
+
 // This is long only because the test cases are larger
 //
 //nolint:funlen
diff --git a/table/view_test.go b/table/view_test.go
index 4637237..e01449b 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -139,6 +139,26 @@ func TestSingleRowView(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSingleRowViewWithHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).
+		WithHeaderVisibility(false).
+		WithRows([]Row{
+			NewRow(RowData{"1": "a", "2": "b", "3": "c"}),
+		})
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   a┃   b┃   c┃
+┗━━━━┻━━━━┻━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestTableWithNoRowsAndHiddenHeaderHidesTable(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -1150,3 +1170,216 @@ func TestSingleColumnViewSortedAndFormatted(t *testing.T) {
 
 	assert.Equal(t, expectedTable, rendered)
 }
+
+func TestMinimumHeightSingleCellView(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithMinimumHeight(5)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃    ┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleColumnView(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithRows([]Row{
+		NewRow(RowData{"id": "1"}),
+		NewRow(RowData{"id": "2"}),
+	}).WithMinimumHeight(8)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃   1┃
+┃   2┃
+┃    ┃
+┃    ┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightHeaderNoData(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).WithMinimumHeight(5)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃    ┃    ┃    ┃
+┗━━━━┻━━━━┻━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleRowWithHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).
+		WithHeaderVisibility(false).
+		WithRows([]Row{
+			NewRow(RowData{"1": "a", "2": "b", "3": "c"}),
+		}).
+		WithMinimumHeight(4)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   a┃   b┃   c┃
+┃    ┃    ┃    ┃
+┗━━━━┻━━━━┻━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightNoRowsAndHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).WithHeaderVisibility(false).WithMinimumHeight(3)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃    ┃    ┃    ┃
+┗━━━━┻━━━━┻━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleColumnNoDataWithFooter(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithStaticFooter("Foot").WithMinimumHeight(7)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃    ┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleColumnWithFooterAndHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).
+		WithStaticFooter("Foot").
+		WithHeaderVisibility(false).
+		WithMinimumHeight(6)
+
+	const expectedTable = `┏━━━━┓
+┃    ┃
+┃    ┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleRowWithFooter(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).WithStaticFooter("Footer").WithMinimumHeight(7)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃    ┃    ┃    ┃
+┣━━━━┻━━━━┻━━━━┫
+┃        Footer┃
+┗━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSingleColumnWithFooter(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithRows([]Row{
+		NewRow(RowData{"id": "1"}),
+		NewRow(RowData{"id": "2"}),
+	}).WithStaticFooter("Foot").WithMinimumHeight(9)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃   1┃
+┃   2┃
+┃    ┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightExtraRow(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithStaticFooter("Foot").WithMinimumHeight(6)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃    ┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
+func TestMinimumHeightSmallerThanTable(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithRows([]Row{
+		NewRow(RowData{"id": "1"}),
+		NewRow(RowData{"id": "2"}),
+	}).WithStaticFooter("Foot").WithMinimumHeight(7)
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃   1┃
+┃   2┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}