Repository: Evertras/bubble-table
Pull request #155 (2023-10-17 03:31:49)

Render missing data for columns with format string
If a column has a format string, and a row is missing data in that column, an unexpected error is shown.

```go
table.New([]table.Column{
	table.NewColumn(columnKeyName, "Name", 13),
	table.NewColumn(columnKeyElement, "Element", 10),
	table.NewColumn(columnKeyHeight, "Height", 16).WithFormatString("%.2f m"),
}).WithRows([]table.Row{
	table.NewRow(table.RowData{
		columnKeyName:    "Pikachu",
		columnKeyElement: "Electric",
		// height is missing
	}),
	table.NewRow(table.RowData{
		columnKeyName:    "Charmander",
		columnKeyElement: "Fire",
		columnKeyHeight:  0.6,
	}),
})
```

```
┏━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
┃         Name┃   Element┃          Height┃
┣━━━━━━━━━━━━━╋━━━━━━━━━━╋━━━━━━━━━━━━━━━━┫
┃      Pikachu┃  Electric┃  %!f(string=) m┃
┃   Charmander┃      Fire┃          0.60 m┃
┗━━━━━━━━━━━━━┻━━━━━━━━━━┻━━━━━━━━━━━━━━━━┛
```

This PR fixes the bug by only setting the format string if data exists.


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..a604b30 100644
--- a/table/row.go
+++ b/table/row.go
@@ -61,22 +61,22 @@ func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Sty
 	} else if column.key == columnKeyOverflowLeft {
 		str = "<"
 	} else {
+		fmtString := "%v"
+
 		var data interface{}
 
 		if entry, exists := row.Data[column.key]; exists {
 			data = entry
+
+			if column.fmtString != "" {
+				fmtString = column.fmtString
+			}
 		} else if m.missingDataIndicator != nil {
 			data = m.missingDataIndicator
 		} else {
 			data = ""
 		}
 
-		fmtString := "%v"
-
-		if column.fmtString != "" {
-			fmtString = column.fmtString
-		}
-
 		switch entry := data.(type) {
 		case StyledCell:
 			str = fmt.Sprintf(fmtString, entry.Data)
@@ -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..787ece3 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),
@@ -434,6 +454,49 @@ func TestSimple3x3WithMissingData(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestFmtStringWithMissingData(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4).WithFormatString("%.1f"),
+		NewColumn("3", "3", 4),
+	})
+
+	rows := []Row{}
+
+	for rowIndex := 1; rowIndex <= 3; rowIndex++ {
+		rowData := RowData{}
+
+		for columnIndex := 1; columnIndex <= 3; columnIndex++ {
+			// Take out the center
+			if rowIndex == 2 && columnIndex == 2 {
+				continue
+			}
+
+			id := fmt.Sprintf("%d", columnIndex)
+
+			rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0
+		}
+
+		rows = append(rows, NewRow(rowData))
+	}
+
+	model = model.WithRows(rows).WithStaticFooter("Footer")
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1.1┃ 2.1┃ 3.1┃
+┃ 1.2┃    ┃ 3.2┃
+┃ 1.3┃ 2.3┃ 3.3┃
+┣━━━━┻━━━━┻━━━━┫
+┃        Footer┃
+┗━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSimple3x3WithMissingIndicator(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -484,6 +547,49 @@ func TestSimple3x3WithMissingIndicator(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestFmtStringWithMissingIndicator(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4).WithFormatString("%.1f"),
+		NewColumn("3", "3", 4),
+	}).WithMissingDataIndicator("XX")
+
+	rows := []Row{}
+
+	for rowIndex := 1; rowIndex <= 3; rowIndex++ {
+		rowData := RowData{}
+
+		for columnIndex := 1; columnIndex <= 3; columnIndex++ {
+			// Take out the center
+			if rowIndex == 2 && columnIndex == 2 {
+				continue
+			}
+
+			id := fmt.Sprintf("%d", columnIndex)
+
+			rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0
+		}
+
+		rows = append(rows, NewRow(rowData))
+	}
+
+	model = model.WithRows(rows).WithStaticFooter("Footer")
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1.1┃ 2.1┃ 3.1┃
+┃ 1.2┃  XX┃ 3.2┃
+┃ 1.3┃ 2.3┃ 3.3┃
+┣━━━━┻━━━━┻━━━━┫
+┃        Footer┃
+┗━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSimple3x3WithMissingIndicatorStyled(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -530,6 +636,52 @@ func TestSimple3x3WithMissingIndicatorStyled(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestFmtStringWithMissingIndicatorStyled(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4).WithFormatString("%.1f"),
+		NewColumn("3", "3", 4),
+	}).WithMissingDataIndicatorStyled(StyledCell{
+		Style: lipgloss.NewStyle().Align(lipgloss.Left),
+		Data:  "XX",
+	})
+
+	rows := []Row{}
+
+	for rowIndex := 1; rowIndex <= 3; rowIndex++ {
+		rowData := RowData{}
+
+		for columnIndex := 1; columnIndex <= 3; columnIndex++ {
+			// Take out the center
+			if rowIndex == 2 && columnIndex == 2 {
+				continue
+			}
+
+			id := fmt.Sprintf("%d", columnIndex)
+
+			rowData[id] = float64(columnIndex) + float64(rowIndex)/10.0
+		}
+
+		rows = append(rows, NewRow(rowData))
+	}
+
+	model = model.WithRows(rows).WithStaticFooter("Footer")
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1.1┃ 2.1┃ 3.1┃
+┃ 1.2┃XX  ┃ 3.2┃
+┃ 1.3┃ 2.3┃ 3.3┃
+┣━━━━┻━━━━┻━━━━┫
+┃        Footer┃
+┗━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestPaged3x3WithNoSpecifiedFooter(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -1150,3 +1302,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)
+}