From a0c4366a923a220ab67cc10be58a40735ef45869 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 1 Apr 2025 09:48:48 -0700 Subject: [PATCH] feat(table): use lipgloss table to render --- table/table.go | 480 +++++++++++++++++++++++++++++++------------------ 1 file changed, 306 insertions(+), 174 deletions(-) diff --git a/table/table.go b/table/table.go index 13241a1..1508954 100644 --- a/table/table.go +++ b/table/table.go @@ -6,10 +6,9 @@ import ( "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/viewport" + "github.com/charmbracelet/lipgloss/v2/table" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - "github.com/mattn/go-runewidth" ) // Model defines a state for the table widget. @@ -17,24 +16,14 @@ type Model struct { KeyMap KeyMap Help help.Model - cols []Column - rows []Row + headers []string + rows [][]string cursor int focus bool styles Styles + yOffset int - viewport viewport.Model - start int - end int -} - -// Row represents one line in the table. -type Row []string - -// Column defines the table structure. -type Column struct { - Title string - Width int + table *table.Table } // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which @@ -104,26 +93,190 @@ func DefaultKeyMap() KeyMap { // Styles contains style definitions for this list component. By default, these // values are generated by DefaultStyles. type Styles struct { + Border lipgloss.Border + BorderStyle lipgloss.Style + BorderTop bool + BorderBottom bool + BorderLeft bool + BorderRight bool + BorderColumn bool + BorderHeader bool + BorderRow bool + Header lipgloss.Style Cell lipgloss.Style Selected lipgloss.Style } -// DefaultStyles returns a set of default style definitions for this table. -func DefaultStyles() Styles { + func DefaultStyles() Styles { return Styles{ - Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), - Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), - Cell: lipgloss.NewStyle().Padding(0, 1), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), + Cell: lipgloss.NewStyle().Margin(0, 1), + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Margin(0, 1), } + } + +func NewFromTemplate(t *table.Table, rows [][]string, headers []string) *Model { + m := &Model{ + cursor: 0, + KeyMap: DefaultKeyMap(), + Help: help.New(), + table: t, + } + m.SetRows(rows...) + m.SetHeaders(headers...) + + return m +} +// SetBorder is a shorthand function for setting or unsetting borders on a +// table. The arguments work as follows: +// +// With one argument, the argument is applied to all sides. +// +// With two arguments, the arguments are applied to the vertical and horizontal +// sides, in that order. +// +// With three arguments, the arguments are applied to the top side, the +// horizontal sides, and the bottom side, in that order. +// +// With four arguments, the arguments are applied clockwise starting from the +// top side, followed by the right side, then the bottom, and finally the left. +// +// With five arguments, the arguments are applied clockwise starting from the +// top side, followed by the right side, then the bottom, and finally the left. +// The final value will set the row separator. +// +// With six arguments, the arguments are applied clockwise starting from the +// top side, followed by the right side, then the bottom, and finally the left. +// The final two values will set the row and column separators in that order. +// +// With more than four arguments nothing will be set. +func (m *Model) SetBorder(s ...bool) { + m.table.Border(m.styles.Border) + top, right, bottom, left, rowSeparator, columnSeparator := m.whichSides(s...) + m.table. + BorderTop(top). + BorderRight(right). + BorderBottom(bottom). + BorderLeft(left). + BorderRow(rowSeparator). + BorderColumn(columnSeparator) } -// SetStyles sets the table styles. -func (m *Model) SetStyles(s Styles) { - m.styles = s - m.UpdateViewport() +// Border sets the top border. +func (m *Model) Border(border lipgloss.Border) *Model { + m.table.Border(border) + return m } +// BorderBottom sets the bottom border. +func (m *Model) BorderBottom(v bool) *Model { + m.table.BorderBottom(v) + return m +} + +// BorderTop sets the top border. +func (m *Model) BorderTop(v bool) *Model { + m.table.BorderTop(v) + return m +} + +// BorderLeft sets the left border. +func (m *Model) BorderLeft(v bool) *Model { + m.table.BorderLeft(v) + return m +} + +// BorderRight sets the right border. +func (m *Model) BorderRight(v bool) *Model { + m.table.BorderRight(v) + return m +} + +// BorderColumn sets the column border. +func (m *Model) BorderColumn(v bool) *Model { + m.table.BorderColumn(v) + return m +} + +// BorderHeader sets the header's border. +func (m *Model) BorderHeader(v bool) *Model { + m.table.BorderHeader(v) + return m +} + +// BorderRow sets the row borders. +func (m *Model) BorderRow(v bool) *Model { + m.table.BorderRow(v) + return m +} + +// BorderStyle sets the style for the table border. +func (m *Model) BorderStyle(style lipgloss.Style) *Model { + m.table.BorderStyle(style) + return m +} + +// whichSides is a helper method for setting values on sides of a block based on +// the number of arguments given. +// 0: set all sides to true +// 1: set all sides to given arg +// 2: top -> bottom +// 3: top -> horizontal -> bottom +// 4: top -> right -> bottom -> left +// 5: top -> right -> bottom -> left -> rowSeparator +// 6: top -> right -> bottom -> left -> rowSeparator -> columnSeparator +func (m Model) whichSides(s ...bool) (top, right, bottom, left, rowSeparator, columnSeparator bool) { + // set the separators to true unless otherwise set. + rowSeparator = m.styles.BorderRow + columnSeparator = m.styles.BorderColumn + + switch len(s) { + case 1: + top = s[0] + right = s[0] + bottom = s[0] + left = s[0] + rowSeparator = s[0] + columnSeparator = s[0] + case 2: + top = s[0] + right = s[1] + bottom = s[0] + left = s[1] + case 3: + top = s[0] + right = s[1] + bottom = s[2] + left = s[1] + case 4: + top = s[0] + right = s[1] + bottom = s[2] + left = s[3] + case 5: + top = s[0] + right = s[1] + bottom = s[2] + left = s[3] + rowSeparator = s[4] + case 6: + top = s[0] + right = s[1] + bottom = s[2] + left = s[3] + rowSeparator = s[4] + columnSeparator = s[5] + default: + top = m.styles.BorderTop + right = m.styles.BorderRight + bottom = m.styles.BorderBottom + left = m.styles.BorderLeft + } + return top, right, bottom, left, rowSeparator, columnSeparator + } + + // Option is used to set options in New. For example: // // table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) @@ -133,8 +286,6 @@ type Option func(*Model) func New(opts ...Option) Model { m := Model{ cursor: 0, - viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd - KeyMap: DefaultKeyMap(), Help: help.New(), styles: DefaultStyles(), @@ -144,39 +295,65 @@ func New(opts ...Option) Model { opt(&m) } - m.UpdateViewport() + return m +} +func (m *Model) SetHeaders(headers ...string) *Model { + m.headers = headers + m.table.Headers(headers...) return m } // WithColumns sets the table columns (headers). -func WithColumns(cols []Column) Option { +func WithHeaders(headers ...string) Option { return func(m *Model) { - m.cols = cols + m.headers = headers + m.table.Headers(headers...) } } +func (m *Model) SetRows(rows ...[]string) *Model { + m.rows = rows + m.table.Rows(rows...) + return m +} + // WithRows sets the table rows (data). -func WithRows(rows []Row) Option { +func WithRows(rows ...[]string) Option { return func(m *Model) { m.rows = rows } } +func (m *Model) SetHeight(h int) *Model { + m.table.Height(h) + return m +} + // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { - m.viewport.SetHeight(h - lipgloss.Height(m.headersView())) + m.table.Height(h) } } +func (m *Model) SetWidth(w int) *Model { + m.table.Width(w) + return m +} + // WithWidth sets the width of the table. func WithWidth(w int) Option { return func(m *Model) { - m.viewport.SetWidth(w) + m.table.Width(w) } } +func (m *Model) SetFocused(f bool) *Model { + m.focus = f + return m +} + // WithFocused sets the focus state of the table. func WithFocused(f bool) Option { return func(m *Model) { @@ -184,10 +361,30 @@ func WithFocused(f bool) Option { } } + // SetStyles sets the table styles. +func (m *Model) SetStyles(t *table.Table) { + t.Rows(m.rows...) + t.Headers(m.headers...) + m.table = t +} + +// SetStyleFunc sets the table's custom StyleFunc. Use this for conditional +// styling e.g. styling a cell by its contents or by index. +func (m *Model) SetStyleFunc(s table.StyleFunc) { + m.table.StyleFunc(s) +} + // WithStyles sets the table styles. -func WithStyles(s Styles) Option { +func WithStyles(t *table.Table) Option { return func(m *Model) { - m.styles = s + m.SetStyles(t) + } +} + +// WithStyleFunc sets the table StyleFunc for conditional styling. +func WithStyleFunc(s table.StyleFunc) Option { + return func(m *Model) { + m.table.StyleFunc(s) } } @@ -203,6 +400,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { return m, nil } + height := len(m.rows) switch msg := msg.(type) { case tea.KeyPressMsg: @@ -212,13 +410,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.LineDown): m.MoveDown(1) case key.Matches(msg, m.KeyMap.PageUp): - m.MoveUp(m.viewport.Height()) + m.MoveUp(height) case key.Matches(msg, m.KeyMap.PageDown): - m.MoveDown(m.viewport.Height()) + m.MoveDown(height) case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height() / 2) //nolint:mnd + m.MoveUp(height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height() / 2) //nolint:mnd + m.MoveDown(height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.GotoTop): m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): @@ -238,18 +436,16 @@ func (m Model) Focused() bool { // interact. func (m *Model) Focus() { m.focus = true - m.UpdateViewport() } // Blur blurs the table, preventing selection or movement. func (m *Model) Blur() { m.focus = false - m.UpdateViewport() } // View renders the component. func (m Model) View() string { - return m.headersView() + "\n" + m.viewport.View() + return m.table.String() } // HelpView is a helper method for rendering the help menu from the keymap. @@ -259,131 +455,68 @@ func (m Model) HelpView() string { return m.Help.View(m.KeyMap) } -// UpdateViewport updates the list content based on the previously defined -// columns and rows. -func (m *Model) UpdateViewport() { - renderedRows := make([]string, 0, len(m.rows)) - - // Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height - // Constant runtime, independent of number of rows in a table. - // Limits the number of renderedRows to a maximum of 2*m.viewport.Height - if m.cursor >= 0 { - m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor) - } else { - m.start = 0 - } - m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows)) - for i := m.start; i < m.end; i++ { - renderedRows = append(renderedRows, m.renderRow(i)) - } - - m.viewport.SetContent( - lipgloss.JoinVertical(lipgloss.Left, renderedRows...), - ) -} - -// SelectedRow returns the selected row. -// You can cast it to your own implementation. -func (m Model) SelectedRow() Row { - if m.cursor < 0 || m.cursor >= len(m.rows) { - return nil - } - - return m.rows[m.cursor] -} - // Rows returns the current rows. -func (m Model) Rows() []Row { +func (m Model) Rows() [][]string { return m.rows } -// Columns returns the current columns. -func (m Model) Columns() []Column { - return m.cols +// GetHeaders returns the current headers. +func (m Model) Headers() []string { + return m.headers } -// SetRows sets a new rows state. -func (m *Model) SetRows(r []Row) { - m.rows = r - m.UpdateViewport() +// WithWidth sets the width of the viewport of the table. +func (m *Model) WithWidth(w int) { + m.table.Width(w) } -// SetColumns sets a new columns state. -func (m *Model) SetColumns(c []Column) { - m.cols = c - m.UpdateViewport() +// WithHeight sets the height of the viewport of the table. +func (m *Model) WithHeight(h int) { + m.table.Height(h) } -// SetWidth sets the width of the viewport of the table. -func (m *Model) SetWidth(w int) { - m.viewport.SetWidth(w) - m.UpdateViewport() -} - -// SetHeight sets the height of the viewport of the table. -func (m *Model) SetHeight(h int) { - m.viewport.SetHeight(h - lipgloss.Height(m.headersView())) - m.UpdateViewport() -} - -// Height returns the viewport height of the table. -func (m Model) Height() int { - return m.viewport.Height() -} - -// Width returns the viewport width of the table. -func (m Model) Width() int { - return m.viewport.Width() -} +// TODO add docs to use lipgloss.Height and lipgloss.Width to get table height/width // Cursor returns the index of the selected row. func (m Model) Cursor() int { return m.cursor } -// SetCursor sets the cursor position in the table. -func (m *Model) SetCursor(n int) { +// WithCursor sets the cursor position in the table. +func (m *Model) WithCursor(n int) { m.cursor = clamp(n, 0, len(m.rows)-1) - m.UpdateViewport() +} + +// SetYOffset sets the YOffset position in the table. +func (m *Model) SetYOffset(n int) { + m.yOffset = clamp(n, 0, len(m.rows)-1) + m.table.YOffset(m.yOffset) } // MoveUp moves the selection up by any number of rows. // It can not go above the first row. func (m *Model) MoveUp(n int) { - m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) + m.WithCursor(m.cursor - n) - offset := m.viewport.YOffset() - switch { - case m.start == 0: - offset = clamp(offset, 0, m.cursor) - case m.start < m.viewport.Height(): - offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height()) - case offset >= 1: - offset = clamp(offset+n, 1, m.viewport.Height()) - } - m.viewport.SetYOffset(offset) - m.UpdateViewport() + // only set the offset outside of the last available rows. + m.SetYOffset(m.yOffset - n) + m.table.YOffset(m.yOffset) } // MoveDown moves the selection down by any number of rows. // It can not go below the last row. func (m *Model) MoveDown(n int) { - m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) - m.UpdateViewport() + // Once we're at the last set of rows, where there is no truncation + // stop setting the y offset and only move cursor + // Only move cursor on first and last pages + m.WithCursor(m.cursor + n) - offset := m.viewport.YOffset() - switch { - case m.end == len(m.rows) && offset > 0: - offset = clamp(offset-n, 1, m.viewport.Height()) - case m.cursor > (m.end-m.start)/2 && offset > 0: - offset = clamp(offset-n, 1, m.cursor) - case offset > 1: - case m.cursor > offset+m.viewport.Height()-1: - offset = clamp(offset+1, 0, 1) - } - m.viewport.SetYOffset(offset) + // only set the offset outside of the last available rows. + m.SetYOffset(m.yOffset + n) + m.table.YOffset(m.yOffset) } + // GotoTop moves the selection to the first row. func (m *Model) GotoTop() { m.MoveUp(m.cursor) @@ -398,50 +531,49 @@ func (m *Model) GotoBottom() { // default for getting all the rows and the given separator for the fields on // each row. func (m *Model) FromValues(value, separator string) { - rows := []Row{} - for _, line := range strings.Split(value, "\n") { - r := Row{} - for _, field := range strings.Split(line, separator) { - r = append(r, field) + var rows [][]string + for i, line := range strings.Split(value, "\n") { + for j, field := range strings.Split(line, separator) { + rows[i][j] = field } - rows = append(rows, r) } - m.SetRows(rows) + m.SetRows(rows...) } -func (m Model) headersView() string { - s := make([]string, 0, len(m.cols)) - for _, col := range m.cols { - if col.Width <= 0 { - continue - } - style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) - renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) - s = append(s, m.styles.Header.Render(renderedCell)) - } - return lipgloss.JoinHorizontal(lipgloss.Top, s...) -} +// TODO remove this +// func (m Model) headersView() string { +// s := make([]string, 0, len(m.headers)) +// for _, col := range m.headers { +// if col.Width <= 0 { +// continue +// } +// style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) +// renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) +// s = append(s, m.styles.Header.Render(renderedCell)) +// } +// return lipgloss.JoinHorizontal(lipgloss.Top, s...) +// } -func (m *Model) renderRow(r int) string { - s := make([]string, 0, len(m.cols)) - for i, value := range m.rows[r] { - if m.cols[i].Width <= 0 { - continue - } - style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) - renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…"))) - s = append(s, renderedCell) - } - - row := lipgloss.JoinHorizontal(lipgloss.Top, s...) - - if r == m.cursor { - return m.styles.Selected.Render(row) - } - - return row -} +// func (m *Model) renderRow(r int) string { +// s := make([]string, 0, len(m.headers)) +// for i, value := range m.rows[r] { +// if m.headers[i].Width <= 0 { +// continue +// } +// style := lipgloss.NewStyle().Width(m.headers[i].Width).MaxWidth(m.headers[i].Width).Inline(true) +// renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.headers[i].Width, "…"))) +// s = append(s, renderedCell) +// } +// +// row := lipgloss.JoinHorizontal(lipgloss.Top, s...) +// +// if r == m.cursor { +// return m.styles.Selected.Render(row) +// } +// +// return row +// } func clamp(v, low, high int) int { return min(max(v, low), high)