Repository: Evertras/bubble-table
Pull request #103 (2022-06-11 13:04:24)

Header visibility option
Adds an option to turn header visibility on or off.

Evertras: Closes #101 

Patch:
diff --git a/README.md b/README.md
index 0e8c968..5693a64 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,9 @@ for a few helpful tips!
 For a code reference of most available features, please see the [full feature example](./examples/features).
 If you want to get started with a simple default table, [check the simplest example](./examples/simplest).
 
-Displays a table with a header, rows, footer, and borders.
+Displays a table with a header, rows, footer, and borders.  The header can be
+hidden, and the footer can be set to automatically show page information, use
+custom text, or be hidden by default.
 
 Columns can be fixed-width [or flexible width](./examples/flex).  A maximum
 width can be specified which enables [horizontal scrolling](./examples/scrolling),
diff --git a/examples/features/main.go b/examples/features/main.go
index cecfc74..5ff248e 100644
--- a/examples/features/main.go
+++ b/examples/features/main.go
@@ -156,6 +156,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		case "ctrl+c", "esc", "q":
 			cmds = append(cmds, tea.Quit)
+
+		case "i":
+			m.tableModel = m.tableModel.WithHeaderVisibility(!m.tableModel.GetHeaderVisibility())
 		}
 	}
 
@@ -167,6 +170,7 @@ func (m Model) View() string {
 
 	body.WriteString("A (chaotic) table demo with all features enabled!\n")
 	body.WriteString("Press left/right or page up/down to move pages\n")
+	body.WriteString("Press 'i' to toggle the header visibility\n")
 	body.WriteString("Press space/enter to select a row, q or ctrl+c to quit\n")
 
 	selectedIDs := []string{}
diff --git a/table/border.go b/table/border.go
index 2aed41c..b31846b 100644
--- a/table/border.go
+++ b/table/border.go
@@ -105,8 +105,13 @@ func (b *Border) generateStyles() {
 	b.generateSingleRowStyles()
 	b.generateSingleCellStyle()
 
-	// For now a footer is just the same as a single column's last row
-	b.styleFooter = b.styleSingleColumnBottom.Copy().Align(lipgloss.Right)
+	// The footer is a single cell with the top taken off... usually.  We can
+	// re-enable the top if needed this way for certain format configurations.
+	b.styleFooter = b.styleSingleCell.Copy().
+		Align(lipgloss.Right).
+		BorderBottom(true).
+		BorderRight(true).
+		BorderLeft(true)
 }
 
 func (b *Border) styleLeftWithFooter(original lipgloss.Style) lipgloss.Style {
diff --git a/table/footer.go b/table/footer.go
index e2abee5..df4011e 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -9,7 +9,7 @@ func (m Model) hasFooter() bool {
 	return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
 }
 
-func (m Model) renderFooter(width int) string {
+func (m Model) renderFooter(width int, includeTop bool) string {
 	if !m.hasFooter() {
 		return ""
 	}
@@ -18,6 +18,10 @@ func (m Model) renderFooter(width int) string {
 
 	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(width - borderAdjustment)
 
+	if includeTop {
+		styleFooter.BorderTop(true)
+	}
+
 	if m.staticFooter != "" {
 		return styleFooter.Render(m.staticFooter)
 	}
diff --git a/table/model.go b/table/model.go
index f832b4c..e73ba8e 100644
--- a/table/model.go
+++ b/table/model.go
@@ -34,6 +34,9 @@ type Model struct {
 	selectedText   string
 	unselectedText string
 
+	// Header
+	headerVisible bool
+
 	// Footers
 	footerVisible bool
 	staticFooter  string
@@ -78,6 +81,7 @@ func New(columns []Column) Model {
 		columns:        make([]Column, len(columns)),
 		highlightStyle: defaultHighlightStyle.Copy(),
 		border:         borderDefault,
+		headerVisible:  true,
 		footerVisible:  true,
 		keyMap:         DefaultKeyMap(),
 
diff --git a/table/options.go b/table/options.go
index 91e9ec8..177a9d4 100644
--- a/table/options.go
+++ b/table/options.go
@@ -275,6 +275,13 @@ func (m Model) WithFooterVisibility(visibility bool) Model {
 	return m
 }
 
+// WithHeaderVisibility sets the visibility of the header.
+func (m Model) WithHeaderVisibility(visibility bool) Model {
+	m.headerVisible = visibility
+
+	return m
+}
+
 // WithMaxTotalWidth sets the maximum total width that the table should render.
 // If this width is exceeded by either the target width or by the total width
 // of all the columns (including borders!), anything extra will be treated as
diff --git a/table/query.go b/table/query.go
index 7564178..b8158cb 100644
--- a/table/query.go
+++ b/table/query.go
@@ -60,3 +60,9 @@ func (m *Model) GetFocused() bool {
 func (m *Model) GetHorizontalScrollColumnOffset() int {
 	return m.horizontalScrollOffsetCol
 }
+
+// GetHeaderVisibility returns true if the header has been set to visible (default)
+// or false if the header has been set to hidden.
+func (m *Model) GetHeaderVisibility() bool {
+	return m.headerVisible
+}
diff --git a/table/view.go b/table/view.go
index 2c7ee91..8dcda1d 100644
--- a/table/view.go
+++ b/table/view.go
@@ -16,19 +16,40 @@ func (m Model) View() string {
 
 	body := strings.Builder{}
 
-	rowStrs := []string{m.renderHeaders()}
+	rowStrs := make([]string, 0, 1)
+
+	headers := m.renderHeaders()
 
 	startRowIndex, endRowIndex := m.VisibleIndices()
+
+	if m.headerVisible {
+		rowStrs = append(rowStrs, headers)
+	} else if endRowIndex-startRowIndex > 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))
 	}
 
-	footer := m.renderFooter(lipgloss.Width(rowStrs[0]))
+	var footer string
+
+	if len(rowStrs) > 0 {
+		footer = m.renderFooter(lipgloss.Width(rowStrs[0]), false)
+	} else {
+		footer = m.renderFooter(lipgloss.Width(headers), true)
+	}
 
 	if footer != "" {
 		rowStrs = append(rowStrs, footer)
 	}
 
+	if len(rowStrs) == 0 {
+		return ""
+	}
+
 	body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...))
 
 	return body.String()


Test patch:
diff --git a/table/options_test.go b/table/options_test.go
index 100fb52..ac7c49f 100644
--- a/table/options_test.go
+++ b/table/options_test.go
@@ -131,11 +131,11 @@ func TestPageOptions(t *testing.T) {
 	assert.Equal(t, 26, model.rowCursorIndex)
 
 	model = model.WithFooterVisibility(false)
-	assert.Equal(t, "", model.renderFooter(10))
+	assert.Equal(t, "", model.renderFooter(10, false))
 
 	model = model.WithFooterVisibility(true)
-	assert.Greater(t, len(model.renderFooter(10)), 10)
-	assert.Contains(t, model.renderFooter(10), "6/6")
+	assert.Greater(t, len(model.renderFooter(10, false)), 10)
+	assert.Contains(t, model.renderFooter(10, false), "6/6")
 }
 
 func TestSelectRowsProgramatically(t *testing.T) {
diff --git a/table/query_test.go b/table/query_test.go
index 85f9aa6..9477d20 100644
--- a/table/query_test.go
+++ b/table/query_test.go
@@ -160,3 +160,13 @@ func TestGetHorizontalScrollColumnOffset(t *testing.T) {
 		"Should still be 0 after trying to go left again",
 	)
 }
+
+func TestGetHeaderVisibility(t *testing.T) {
+	model := New([]Column{})
+
+	assert.True(t, model.GetHeaderVisibility(), "Header should be visible by default")
+
+	model = model.WithHeaderVisibility(false)
+
+	assert.False(t, model.GetHeaderVisibility(), "Header was not set to hidden")
+}
diff --git a/table/view_test.go b/table/view_test.go
index 08c89dc..a682300 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -139,6 +139,20 @@ func TestSingleRowView(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestTableWithNoRowsAndHiddenHeaderHidesTable(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).WithHeaderVisibility(false)
+
+	const expectedTable = ""
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSimple3x3(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -175,6 +189,40 @@ func TestSimple3x3(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSimple3x3WithHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).WithHeaderVisibility(false)
+
+	rows := []Row{}
+
+	for rowIndex := 1; rowIndex <= 3; rowIndex++ {
+		rowData := RowData{}
+
+		for columnIndex := 1; columnIndex <= 3; columnIndex++ {
+			id := fmt.Sprintf("%d", columnIndex)
+
+			rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex)
+		}
+
+		rows = append(rows, NewRow(rowData))
+	}
+
+	model = model.WithRows(rows)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃ 1,1┃ 2,1┃ 3,1┃
+┃ 1,2┃ 2,2┃ 3,2┃
+┃ 1,3┃ 2,3┃ 3,3┃
+┗━━━━┻━━━━┻━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSingleHeaderWithFooter(t *testing.T) {
 	model := New([]Column{
 		NewColumn("id", "ID", 4),
@@ -190,6 +238,21 @@ func TestSingleHeaderWithFooter(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSingleColumnWithFooterAndHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).
+		WithStaticFooter("Foot").
+		WithHeaderVisibility(false)
+
+	const expectedTable = `┏━━━━┓
+┃Foot┃
+┗━━━━┛`
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSingleRowWithFooterView(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),
@@ -226,6 +289,25 @@ func TestSingleRowWithFooterViewAndBaseStyle(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSingleRowWithFooterViewAndBaseStyleWithHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	}).
+		WithStaticFooter("Footer").
+		WithBaseStyle(lipgloss.NewStyle().Align(lipgloss.Left)).
+		WithHeaderVisibility(false)
+
+	const expectedTable = `┏━━━━━━━━━━━━━━┓
+┃Footer        ┃
+┗━━━━━━━━━━━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSingleColumnWithFooterView(t *testing.T) {
 	model := New([]Column{
 		NewColumn("id", "ID", 4),
@@ -248,6 +330,29 @@ func TestSingleColumnWithFooterView(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSingleColumnWithFooterViewAndHiddenHeader(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).
+		WithRows([]Row{
+			NewRow(RowData{"id": "1"}),
+			NewRow(RowData{"id": "2"}),
+		}).
+		WithStaticFooter("Foot").
+		WithHeaderVisibility(false)
+
+	const expectedTable = `┏━━━━┓
+┃   1┃
+┃   2┃
+┣━━━━┫
+┃Foot┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSimple3x3WithFooterView(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),