diff --git a/README.md b/README.md index f6eaa0a..b16f48b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A table component for the [Bubble Tea framework](https://github.com/charmbracelet/bubbletea). -![Table](sample.png) +![Table with all features](https://user-images.githubusercontent.com/5923958/154826826-226346a7-96f9-43ca-a484-49e81b3ab45d.png) [View sample source code](./examples/features/main.go) @@ -16,7 +16,7 @@ A table component for the [Bubble Tea framework](https://github.com/charmbracele For a code reference, please see the [full feature example](./examples/features/main.go). -Displays a table with a header, rows, and borders. +Displays a table with a header, rows, footer, and borders. Border shape is customizable with a basic thick square default. diff --git a/examples/features/main.go b/examples/features/main.go index 03108a1..cdd814b 100644 --- a/examples/features/main.go +++ b/examples/features/main.go @@ -79,21 +79,31 @@ func NewModel() Model { keys.RowDown.SetKeys("j", "down", "s") keys.RowUp.SetKeys("k", "up", "w") - return Model{ + model := Model{ tableModel: table.New(columns). WithRows(rows). HeaderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)). SelectableRows(true). Focused(true). Border(customBorder). - WithKeyMap(keys), + WithKeyMap(keys). + WithStaticFooter("Footer!"), } + + model.updateFooter() + + return model } func (m Model) Init() tea.Cmd { return nil } +func (m *Model) updateFooter() { + highlightedRow := m.tableModel.HighlightedRow() + m.tableModel = m.tableModel.WithStaticFooter(fmt.Sprintf("Currently looking at ID: %s", highlightedRow.Data[columnKeyID])) +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd @@ -103,6 +113,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tableModel, cmd = m.tableModel.Update(msg) cmds = append(cmds, cmd) + // We control the footer text, so make sure to update it + m.updateFooter() + switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { @@ -117,11 +130,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) View() string { body := strings.Builder{} - highlightedRow := m.tableModel.HighlightedRow() body.WriteString("Table demo with all features enabled!\nPress space/enter to select a row, q or ctrl+c to quit\n") - body.WriteString(fmt.Sprintf("Currently looking at ID: %s\n", highlightedRow.Data[columnKeyID])) - selectedIDs := []string{} for _, row := range m.tableModel.SelectedRows() { diff --git a/sample.png b/sample.png deleted file mode 100644 index cace7a9..0000000 Binary files a/sample.png and /dev/null differ diff --git a/table/border.go b/table/border.go index 7cb8e3b..51fa8ac 100644 --- a/table/border.go +++ b/table/border.go @@ -45,6 +45,9 @@ type Border struct { // Style for a table with only one cell styleSingleCell lipgloss.Style + + // Style for the footer + styleFooter lipgloss.Style } var ( @@ -80,6 +83,34 @@ func (b *Border) generateStyles() { b.generateSingleColumnStyles() 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) +} + +func (b *Border) styleLeftWithFooter(original lipgloss.Style) lipgloss.Style { + border := original.GetBorderStyle() + + border.BottomLeft = b.LeftJunction + + return original.Copy().BorderStyle(border) +} + +func (b *Border) styleRightWithFooter(original lipgloss.Style) lipgloss.Style { + border := original.GetBorderStyle() + + border.BottomRight = b.RightJunction + + return original.Copy().BorderStyle(border) +} + +func (b *Border) styleBothWithFooter(original lipgloss.Style) lipgloss.Style { + border := original.GetBorderStyle() + + border.BottomLeft = b.LeftJunction + border.BottomRight = b.RightJunction + + return original.Copy().BorderStyle(border) } // This function is long, but it's just repetitive... @@ -287,6 +318,9 @@ func (b *borderStyleRow) inherit(s lipgloss.Style) { b.right = b.right.Copy().Inherit(s) } +// There's a lot of branches here, but splitting it up further would make it +// harder to follow. So just be careful with comments and make sure it's tested! +// nolint:nestif func (m Model) styleHeaders() borderStyleRow { hasRows := len(m.rows) > 0 singleColumn := len(m.columns) == 1 @@ -302,19 +336,28 @@ func (m Model) styleHeaders() borderStyleRow { if hasRows { // Single column styles.left = m.border.styleSingleColumnTop - styles.inner = m.border.styleSingleColumnTop - styles.right = m.border.styleSingleColumnTop + styles.inner = styles.left + styles.right = styles.left } else { // Single cell styles.left = m.border.styleSingleCell - styles.inner = m.border.styleSingleCell - styles.right = m.border.styleSingleCell + styles.inner = styles.left + styles.right = styles.left + + if m.hasFooter() { + styles.left = m.border.styleBothWithFooter(styles.left) + } } } else if !hasRows { // Single row styles.left = m.border.styleSingleRowLeft styles.inner = m.border.styleSingleRowInner styles.right = m.border.styleSingleRowRight + + if m.hasFooter() { + styles.left = m.border.styleLeftWithFooter(styles.left) + styles.right = m.border.styleRightWithFooter(styles.right) + } } else { // Multi styles.left = m.border.styleMultiTopLeft @@ -334,6 +377,11 @@ func (m Model) styleRows() (inner borderStyleRow, last borderStyleRow) { inner.right = inner.left last.left = m.border.styleSingleColumnBottom + + if m.hasFooter() { + last.left = m.border.styleBothWithFooter(last.left) + } + last.inner = last.left last.right = last.left } else { @@ -344,6 +392,11 @@ func (m Model) styleRows() (inner borderStyleRow, last borderStyleRow) { last.left = m.border.styleMultiBottomLeft last.inner = m.border.styleMultiBottom last.right = m.border.styleMultiBottomRight + + if m.hasFooter() { + last.left = m.border.styleLeftWithFooter(last.left) + last.right = m.border.styleRightWithFooter(last.right) + } } return inner, last diff --git a/table/dimensions.go b/table/dimensions.go new file mode 100644 index 0000000..b8270cf --- /dev/null +++ b/table/dimensions.go @@ -0,0 +1,11 @@ +package table + +func (m *Model) recalculateWidth() { + total := 0 + + for _, column := range m.columns { + total += column.Width + } + + m.totalWidth = total + len(m.columns) - 1 +} diff --git a/table/footer.go b/table/footer.go new file mode 100644 index 0000000..e646c91 --- /dev/null +++ b/table/footer.go @@ -0,0 +1,13 @@ +package table + +func (m Model) hasFooter() bool { + return m.staticFooter != "" +} + +func (m Model) renderFooter() string { + if !m.hasFooter() { + return "" + } + + return m.border.styleFooter.Width(m.totalWidth).Render(m.staticFooter) +} diff --git a/table/model.go b/table/model.go index 7ebf731..dd2c95b 100644 --- a/table/model.go +++ b/table/model.go @@ -19,6 +19,8 @@ type Model struct { columns []Column headerStyle lipgloss.Style + staticFooter string + rows []Row selectableRows bool @@ -32,6 +34,8 @@ type Model struct { selectedRows []Row border Border + + totalWidth int } // New creates a new table ready for further modifications. @@ -46,6 +50,8 @@ func New(columns []Column) Model { // Do a full deep copy to avoid unexpected edits copy(model.columns, columns) + model.recalculateWidth() + return model } diff --git a/table/options.go b/table/options.go index d81ac7b..9601f0a 100644 --- a/table/options.go +++ b/table/options.go @@ -46,6 +46,8 @@ func (m Model) SelectableRows(selectable bool) Model { } } + m.recalculateWidth() + return m } @@ -79,3 +81,10 @@ func (m Model) Focused(focused bool) Model { return m } + +// WithStaticFooter adds a footer that only displays the given text. +func (m Model) WithStaticFooter(footer string) Model { + m.staticFooter = footer + + return m +} diff --git a/table/view.go b/table/view.go index faaf814..776d198 100644 --- a/table/view.go +++ b/table/view.go @@ -45,6 +45,12 @@ func (m Model) View() string { rowStrs = append(rowStrs, m.renderRow(i)) } + footer := m.renderFooter() + + if footer != "" { + rowStrs = append(rowStrs, footer) + } + body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...)) return body.String() diff --git a/table/view_test.go b/table/view_test.go index 73a4450..436ebf4 100644 --- a/table/view_test.go +++ b/table/view_test.go @@ -125,3 +125,96 @@ func TestSimple3x2(t *testing.T) { assert.Equal(t, expectedTable, rendered) } + +func TestSingleHeaderWithFooter(t *testing.T) { + model := New([]Column{ + NewColumn("id", "ID", 4), + }).WithStaticFooter("Foot") + + const expectedTable = `┏━━━━┓ +┃ ID┃ +┣━━━━┫ +┃Foot┃ +┗━━━━┛` + rendered := model.View() + + assert.Equal(t, expectedTable, rendered) +} + +func TestSingleRowWithFooterView(t *testing.T) { + model := New([]Column{ + NewColumn("1", "1", 4), + NewColumn("2", "2", 4), + NewColumn("3", "3", 4), + }).WithStaticFooter("Footer") + + const expectedTable = `┏━━━━┳━━━━┳━━━━┓ +┃ 1┃ 2┃ 3┃ +┣━━━━┻━━━━┻━━━━┫ +┃ Footer┃ +┗━━━━━━━━━━━━━━┛` + + rendered := model.View() + + assert.Equal(t, expectedTable, rendered) +} + +func TestSingleColumnWithFooterView(t *testing.T) { + model := New([]Column{ + NewColumn("id", "ID", 4), + }).WithRows([]Row{ + NewRow(RowData{"id": "1"}), + NewRow(RowData{"id": "2"}), + }).WithStaticFooter("Foot") + + const expectedTable = `┏━━━━┓ +┃ ID┃ +┣━━━━┫ +┃ 1┃ +┃ 2┃ +┣━━━━┫ +┃Foot┃ +┗━━━━┛` + + rendered := model.View() + + assert.Equal(t, expectedTable, rendered) +} + +func TestSimple3x2WithFooterView(t *testing.T) { + model := New([]Column{ + NewColumn("1", "1", 4), + NewColumn("2", "2", 4), + NewColumn("3", "3", 4), + }) + + 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).WithStaticFooter("Footer") + + const expectedTable = `┏━━━━┳━━━━┳━━━━┓ +┃ 1┃ 2┃ 3┃ +┣━━━━╋━━━━╋━━━━┫ +┃ 1,1┃ 2,1┃ 3,1┃ +┃ 1,2┃ 2,2┃ 3,2┃ +┃ 1,3┃ 2,3┃ 3,3┃ +┣━━━━┻━━━━┻━━━━┫ +┃ Footer┃ +┗━━━━━━━━━━━━━━┛` + + rendered := model.View() + + assert.Equal(t, expectedTable, rendered) +}