diff --git a/go.mod b/go.mod index 28157038..b68a225d 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbletea v1.1.0 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss v0.13.0 - github.com/charmbracelet/x/ansi v0.2.3 + github.com/charmbracelet/lipgloss v0.13.1-0.20240924011622-17b470d8991e + github.com/charmbracelet/x/ansi v0.3.2 github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 diff --git a/go.sum b/go.sum index 9eb7dd65..c3f3071a 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,10 @@ github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69J github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= -github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/lipgloss v0.13.1-0.20240924011622-17b470d8991e h1:sc5K4dGwO7gpZsNNigIaDOgpMoe2JLuHXekLGD9EdwA= +github.com/charmbracelet/lipgloss v0.13.1-0.20240924011622-17b470d8991e/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U= +github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= +github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= diff --git a/proposal.org b/proposal.org new file mode 100644 index 00000000..b78b2e8a --- /dev/null +++ b/proposal.org @@ -0,0 +1,16 @@ +#+title: v2 Proposal + +* Table +** API Changes +The API will change to streamline the experience of working with tables across +the Bubbles and Lip Gloss libraries. +*** Change +- ~type Column, Row~ -> ~[]string~ for better reusability +- ~func (m Model) Columns() []Column~ -> ~func (m Model) Headers(headers []string)~ +- Columns -> Headers +- Header calculations are delegated to Lip Gloss +**** Modifiers +- ~func (m *Model) SetRows(r []Row)~ -> ~func (m *Model) Rows(rows ...[]string)~ +- ~func (m *Model) SetStyles(s Styles)~ -> ~func (m *Model) Styles(s Styles)~ +- ~func (m *Model) Width(w int)~ -> ~func (m *Model) Width(w int)~ +- ~func (m Model) Height() int~ -> ~func (m *Model) Height(h int) *Model~ diff --git a/table/table.go b/table/table.go index 6103c836..620631d1 100644 --- a/table/table.go +++ b/table/table.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" + "github.com/charmbracelet/lipgloss/table" ) // Model defines a state for the table widget. @@ -16,21 +16,29 @@ type Model struct { KeyMap KeyMap Help help.Model - cols []Column - rows []Row - cursor int - focus bool - styles Styles + yoffset int + height int + headers []string + rows [][]string + cursor int + focus bool + styles Styles + styleFunc table.StyleFunc + table *table.Table + start int + end int + + // deprecated: don't use viewport, use table instead. viewport viewport.Model - start int - end int } // Row represents one line in the table. +// Deprecated: use []string. type Row []string // Column defines the table structure. +// Deprecated: use []string. type Column struct { Title string Width int @@ -104,37 +112,141 @@ func DefaultKeyMap() KeyMap { // Styles contains style definitions for this list component. By default, these // values are generated by DefaultStyles. type Styles struct { - Header lipgloss.Style - Cell lipgloss.Style - Selected lipgloss.Style + Border lipgloss.Border + BorderStyle lipgloss.Style + BorderHeader bool + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style } // DefaultStyles returns a set of default style definitions for this table. 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), + Border: lipgloss.NormalBorder(), + BorderHeader: true, + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Margin(0, 1), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), + Cell: lipgloss.NewStyle().Margin(0, 1), } } // SetStyles sets the table styles. func (m *Model) SetStyles(s Styles) { m.styles = s - m.UpdateViewport() + m.table.Border(s.Border) + m.table.BorderStyle(s.BorderStyle) + m.table.BorderHeader(s.BorderHeader) +} + +// 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.styleFunc = s +} + +// 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) { + top, right, bottom, left, rowSeparator, columnSeparator := whichSides(s...) + m.table. + BorderTop(top). + BorderRight(right). + BorderBottom(bottom). + BorderLeft(left). + BorderRow(rowSeparator). + BorderColumn(columnSeparator) +} + +// 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 whichSides(s ...bool) (top, right, bottom, left, rowSeparator, columnSeparator bool) { + // set the separators to true unless otherwise set. + rowSeparator = true + columnSeparator = true + + 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 = true + right = true + bottom = true + left = true + } + 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}})) +// table := New(WithRows([]Row{{"Foo"},{"Bar"},{"Baz"},})) type Option func(*Model) // New creates a new model for the table widget. func New(opts ...Option) Model { m := Model{ - cursor: 0, - viewport: viewport.New(0, 20), - + table: table.New(), KeyMap: DefaultKeyMap(), Help: help.New(), styles: DefaultStyles(), @@ -144,36 +256,51 @@ func New(opts ...Option) Model { opt(&m) } - m.UpdateViewport() - return m } +// WithHeaders sets the table headers. +func WithHeaders(headers []string) Option { + return func(m *Model) { + m.SetHeaders(headers...) + } +} + // WithColumns sets the table columns (headers). +// Deprecated: use WithHeaders instead. func WithColumns(cols []Column) Option { return func(m *Model) { - m.cols = cols + m.SetHeaders(colToString(cols)...) + } +} + +// colToString helper to unwrap the Column type to its underlying string type. +func colToString(cols []Column) []string { + var out []string + for _, col := range cols { + out = append(out, col.Title) } + return out } // WithRows sets the table rows (data). func WithRows(rows []Row) Option { return func(m *Model) { - m.rows = rows + m.SetRows(rows) } } // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) + m.SetHeight(h) } } // WithWidth sets the width of the table. func WithWidth(w int) Option { return func(m *Model) { - m.viewport.Width = w + m.SetWidth(w) } } @@ -187,7 +314,14 @@ func WithFocused(f bool) Option { // WithStyles sets the table styles. func WithStyles(s Styles) Option { return func(m *Model) { - m.styles = s + m.SetStyles(s) + } +} + +// WithStyleFunc sets the table StyleFunc for conditional styling. +func WithStyleFunc(s table.StyleFunc) Option { + return func(m *Model) { + m.SetStyleFunc(s) } } @@ -198,7 +332,7 @@ func WithKeyMap(km KeyMap) Option { } } -// Update is the Bubble Tea update loop. +// Update for the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { return m, nil @@ -212,13 +346,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(m.height) case key.Matches(msg, m.KeyMap.PageDown): - m.MoveDown(m.viewport.Height) + m.MoveDown(m.height) case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height / 2) + m.MoveUp(m.height / 2) case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height / 2) + m.MoveDown(m.height / 2) case key.Matches(msg, m.KeyMap.LineDown): m.MoveDown(1) case key.Matches(msg, m.KeyMap.GotoTop): @@ -240,18 +374,29 @@ 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() + if m.styleFunc != nil { + m.table.StyleFunc(m.styleFunc) + } else { + m.table.StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return m.styles.Header + } + if row == m.cursor { + return m.styles.Selected + } + return m.styles.Cell + }) + } + return m.table.String() } // HelpView is a helper method for rendering the help menu from the keymap. @@ -261,29 +406,6 @@ 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 { @@ -294,48 +416,67 @@ func (m Model) SelectedRow() Row { return m.rows[m.cursor] } -// Rows returns the current rows. -func (m Model) Rows() []Row { - return m.rows +// Append appends rows to the table. +func (m *Model) Append(rows ...[]string) { + m.rows = append(m.rows, rows...) + m.table.Rows(m.rows...) } -// Columns returns the current columns. -func (m Model) Columns() []Column { - return m.cols +// SetColumns sets a new columns state. +// Deprecated: use SetHeaders instead. +func (m *Model) SetColumns(c []Column) { + m.SetHeaders(colToString(c)...) } -// SetRows sets a new rows state. +// SetRows overwrites existing rows with new ones. func (m *Model) SetRows(r []Row) { - m.rows = r - m.UpdateViewport() + // lipgloss' table requires []string, so it's easier to convert these. + // TODO should we just deprecate the Row type altogether? + rows := rowToString(r) + m.rows = rows + m.table.ClearRows() + m.table.Rows(rows...) } -// SetColumns sets a new columns state. -func (m *Model) SetColumns(c []Column) { - m.cols = c - m.UpdateViewport() +// Rows returns the rows set for the table. +// TODO do we need this? We used to have m.rows public... +func (m Model) Rows() [][]string { + return m.rows } -// SetWidth sets the width of the viewport of the table. +// rowToString helper to unwrap the Row type. +func rowToString(rows []Row) [][]string { + var out [][]string + for _, row := range rows { + var newRow []string + for _, val := range row { + newRow = append(newRow, val) + } + out = append(out, newRow) + } + return out +} + +// SetHeaders sets the table headers. +func (m *Model) SetHeaders(headers ...string) { + m.headers = headers + m.table.Headers(headers...) +} + +// SetWidth sets the width of the table. func (m *Model) SetWidth(w int) { - m.viewport.Width = w - m.UpdateViewport() + m.table.Width(w) } -// SetHeight sets the height of the viewport of the table. +// SetHeight sets the height of the table. func (m *Model) SetHeight(h int) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) - m.UpdateViewport() + m.height = h + m.table.Height(h) } -// Height returns the viewport height of the table. +// Height returns the height of the table, including borders. 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 + return m.height } // Cursor returns the index of the selected row. @@ -346,39 +487,34 @@ func (m Model) Cursor() int { // SetCursor sets the cursor position in the table. func (m *Model) SetCursor(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) } // 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) - switch { - case m.start == 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) - case m.start < m.viewport.Height: - m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) - case m.viewport.YOffset >= 1: - m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) - } - m.UpdateViewport() + m.SetCursor(m.cursor - n) + + // only set the offset outside of the last available rows. + m.setYOffset(m.yoffset - n) + m.table.Offset(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() - - switch { - case m.end == len(m.rows) && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) - case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) - case m.viewport.YOffset > 1: - case m.cursor > m.viewport.YOffset+m.viewport.Height-1: - m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1)) - } + // once we're at the last set of rows, where there is no truncation + // stop setting the y offset and only move cursor + + // visible lines after updating viewport + m.SetCursor(m.cursor + n) + + m.setYOffset(m.yoffset + n) + m.table.Offset(m.yoffset) } // GotoTop moves the selection to the first row. @@ -407,39 +543,6 @@ func (m *Model) FromValues(value, separator string) { 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...) -} - -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 max(a, b int) int { if a > b { return a diff --git a/table/table_test.go b/table/table_test.go index cc49f0d3..8b856bb0 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -1,30 +1,71 @@ package table import ( + "reflect" "testing" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) func TestFromValues(t *testing.T) { - input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" - table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) - table.FromValues(input, ",") + t.Run("Headers", func(t *testing.T) { + input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" + table := New() + table.SetHeaders("Foo", "Bar") + table.FromValues(input, ",") - if len(table.rows) != 3 { - t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows)) - } + if len(table.rows) != 3 { + t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows)) + } - expect := []Row{ - {"foo1", "bar1"}, - {"foo2", "bar2"}, - {"foo3", "bar3"}, - } - if !deepEqual(table.rows, expect) { - t.Fatal("table rows is not equals to the input") - } + expect := [][]string{ + {"foo1", "bar1"}, + {"foo2", "bar2"}, + {"foo3", "bar3"}, + } + if !reflect.DeepEqual(table.rows, expect) { + t.Fatal("table rows is not equals to the input") + } + }) + t.Run("WithColumns", func(t *testing.T) { + input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" + table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) + table.FromValues(input, ",") + + if len(table.rows) != 3 { + t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows)) + } + + expect := [][]string{ + {"foo1", "bar1"}, + {"foo2", "bar2"}, + {"foo3", "bar3"}, + } + if !reflect.DeepEqual(table.rows, expect) { + t.Fatal("table rows is not equals to the input") + } + }) + t.Run("WithHeaders", func(t *testing.T) { + input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" + table := New(WithHeaders([]string{"Foo", "Bar"})) + table.FromValues(input, ",") + + if len(table.rows) != 3 { + t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows)) + } + + expect := [][]string{ + {"foo1", "bar1"}, + {"foo2", "bar2"}, + {"foo3", "bar3"}, + } + if !reflect.DeepEqual(table.rows, expect) { + t.Fatal("table rows is not equals to the input") + } + }) } func TestFromValuesWithTabSeparator(t *testing.T) { @@ -36,74 +77,57 @@ func TestFromValuesWithTabSeparator(t *testing.T) { t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows)) } - expect := []Row{ + expect := [][]string{ {"foo1.", "bar1"}, {"foo,bar,baz", "bar,2"}, } - if !deepEqual(table.rows, expect) { - t.Fatal("table rows is not equals to the input") + if !reflect.DeepEqual(table.rows, expect) { + t.Fatalf("table rows is not equal to the input. got: %#v, want %#v", table.rows, expect) } } -func deepEqual(a, b []Row) bool { - if len(a) != len(b) { - return false - } - for i, r := range a { - for j, f := range r { - if f != b[i][j] { - return false - } - } - } - return true -} - -var cols = []Column{ - {Title: "col1", Width: 10}, - {Title: "col2", Width: 10}, - {Title: "col3", Width: 10}, -} - -func TestRenderRow(t *testing.T) { +func TestSetCursor(t *testing.T) { + /* + the range for rows goes from 1 to len(rows) because in the bubble, the + first row is the headers, so we're adding 1 to the standard range. + **/ tests := []struct { name string - table *Model - expected string + cursor int + expected int }{ - { - name: "simple row", - table: &Model{ - rows: []Row{{"Foooooo", "Baaaaar", "Baaaaaz"}}, - cols: cols, - styles: Styles{Cell: lipgloss.NewStyle()}, - }, - expected: "Foooooo Baaaaar Baaaaaz ", - }, - { - name: "simple row with truncations", - table: &Model{ - rows: []Row{{"Foooooooooo", "Baaaaaaaaar", "Quuuuuuuuux"}}, - cols: cols, - styles: Styles{Cell: lipgloss.NewStyle()}, - }, - expected: "Foooooooo…Baaaaaaaa…Quuuuuuuu…", - }, - { - name: "simple row avoiding truncations", - table: &Model{ - rows: []Row{{"Fooooooooo", "Baaaaaaaar", "Quuuuuuuux"}}, - cols: cols, - styles: Styles{Cell: lipgloss.NewStyle()}, - }, - expected: "FoooooooooBaaaaaaaarQuuuuuuuux", - }, + {"cursor exceeds rows", 10, 2}, + {"cursor less than rows", -10, 0}, + {"cursor at zero", 0, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - row := tc.table.renderRow(0) - if row != tc.expected { - t.Fatalf("\n\nWant: \n%s\n\nGot: \n%s\n", tc.expected, row) + table := New( + WithRows([]Row{ + {"Foo"}, + {"Bar"}, + {"Baz"}, + }), + ) + table.SetCursor(tc.cursor) + if table.cursor != tc.expected { + t.Fatalf("wrong cursor value, should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) + } + }) + t.Run(tc.name+"/ table with headers", func(t *testing.T) { + table := New( + WithColumns([]Column{ + {Title: "Name", Width: 10}, + }), + WithRows([]Row{ + {"Foo"}, + {"Bar"}, + {"Baz"}, + }), + ) + table.SetCursor(tc.cursor) + if table.cursor != tc.expected { + t.Fatalf("wrong cursor value, should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) } }) } @@ -111,8 +135,10 @@ func TestRenderRow(t *testing.T) { func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { + s := DefaultStyles() + s.BorderHeader = false biscuits := New( - WithHeight(5), + WithHeight(10), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -123,24 +149,39 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), + WithStyles(s), ) + + // unset borders; hidden border leaves space. + biscuits.SetBorder(false) got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("With border", func(t *testing.T) { - baseStyle := lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) + biscuits := New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithHeight(10), + WithStyles(DefaultStyles()), + ) + got := ansi.Strip(biscuits.View()) + golden.RequireEqual(t, []byte(got)) + }) +} +func TestSetStyleFunc(t *testing.T) { + t.Run("single cell styling", func(t *testing.T) { s := DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - + s.BorderHeader = false biscuits := New( - WithHeight(5), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -151,9 +192,108 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), - WithStyles(s), ) - got := ansi.Strip(baseStyle.Render(biscuits.View())) - golden.RequireEqual(t, []byte(got)) + biscuits.SetStyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + // TODO this should be exported to be usable outside of the lib. + return s.Header + } + if row == 1 && col == 1 { + return s.Cell.Bold(true) + } + return s.Cell + }) + golden.RequireEqual(t, []byte(biscuits.View())) + }) +} + +func TestWithStyleFunc(t *testing.T) { + t.Run("single cell styling", func(t *testing.T) { + // #502 + s := DefaultStyles() + s.BorderHeader = false + biscuits := New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return s.Header + } + // TODO we should probably make it possible to retrieve Style + // from the model in case it has been modified from the + // defaults. + if row == 1 && col == 1 { + return s.Cell.Bold(true) + } + return s.Cell + })) + golden.RequireEqual(t, []byte(biscuits.View())) + }) + t.Run("cell styling by content", func(t *testing.T) { + // #502 + rows := []Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + } + s := DefaultStyles() + s.BorderHeader = false + biscuits := New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows(rows), + WithStyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return s.Header + } + // TODO we should probably make it possible to retrieve Style + // from the model in case it has been modified from the + // defaults. + + // you need to pre-define the rows for this to be accessible in + // WithStyleFunc + if rows[row][col] == "Yes" { + return s.Cell.Bold(true) + } + return s.Cell + })) + golden.RequireEqual(t, []byte(biscuits.View())) + }) + t.Run("change column text alignment", func(t *testing.T) { + // #399 + s := DefaultStyles() + s.BorderHeader = false + biscuits := New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return s.Header + } + if col == 1 { + return s.Cell.Align(lipgloss.Right) + } + return s.Cell + })) + golden.RequireEqual(t, []byte(biscuits.View())) }) } diff --git a/table/testdata/TestSetStyleFunc/single_cell_styling.golden b/table/testdata/TestSetStyleFunc/single_cell_styling.golden new file mode 100644 index 00000000..908d313e --- /dev/null +++ b/table/testdata/TestSetStyleFunc/single_cell_styling.golden @@ -0,0 +1,7 @@ +╭──────────────────────┬───────────────────┬───────────╮ +│ Name │ Country of Origin │ Dunk-able │ +├──────────────────────┼───────────────────┼───────────┤ +│ Chocolate Digestives │ UK │ Yes │ +│ Tim Tams │ Australia │ No │ +│ Hobnobs │ UK │ Yes │ +╰──────────────────────┴───────────────────┴───────────╯ \ No newline at end of file diff --git a/table/testdata/TestTableAlignment/No_border.golden b/table/testdata/TestTableAlignment/No_border.golden index a4664a8f..5184ba7d 100644 --- a/table/testdata/TestTableAlignment/No_border.golden +++ b/table/testdata/TestTableAlignment/No_border.golden @@ -1,5 +1,4 @@ - Name Country of Orig… Dunk-able - Chocolate Digestives UK Yes - Tim Tams Australia No - Hobnobs UK Yes - \ No newline at end of file + Name Country of Origin Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No + Hobnobs UK Yes \ No newline at end of file diff --git a/table/testdata/TestTableAlignment/With_border.golden b/table/testdata/TestTableAlignment/With_border.golden index 49f7909d..956b1d4d 100644 --- a/table/testdata/TestTableAlignment/With_border.golden +++ b/table/testdata/TestTableAlignment/With_border.golden @@ -1,8 +1,7 @@ -┌───────────────────────────────────────────────────────────┐ -│ Name Country of Orig… Dunk-able │ -│───────────────────────────────────────────────────────────│ -│ Chocolate Digestives UK Yes │ -│ Tim Tams Australia No │ -│ Hobnobs UK Yes │ -│ │ -└───────────────────────────────────────────────────────────┘ \ No newline at end of file +┌──────────────────────┬───────────────────┬───────────┐ +│ Name │ Country of Origin │ Dunk-able │ +├──────────────────────┼───────────────────┼───────────┤ +│ Chocolate Digestives │ UK │ Yes │ +│ Tim Tams │ Australia │ No │ +│ Hobnobs │ UK │ Yes │ +└──────────────────────┴───────────────────┴───────────┘ \ No newline at end of file diff --git a/table/testdata/TestWithStyleFunc/cell_styling_by_content.golden b/table/testdata/TestWithStyleFunc/cell_styling_by_content.golden new file mode 100644 index 00000000..d52cef36 --- /dev/null +++ b/table/testdata/TestWithStyleFunc/cell_styling_by_content.golden @@ -0,0 +1,7 @@ +╭──────────────────────┬───────────────────┬───────────╮ +│ Name │ Country of Origin │ Dunk-able │ +├──────────────────────┼───────────────────┼───────────┤ +│ Chocolate Digestives │ UK │ Yes │ +│ Tim Tams │ Australia │ No │ +│ Hobnobs │ UK │ Yes │ +╰──────────────────────┴───────────────────┴───────────╯ \ No newline at end of file diff --git a/table/testdata/TestWithStyleFunc/change_column_text_alignment.golden b/table/testdata/TestWithStyleFunc/change_column_text_alignment.golden new file mode 100644 index 00000000..2bbba1e9 --- /dev/null +++ b/table/testdata/TestWithStyleFunc/change_column_text_alignment.golden @@ -0,0 +1,7 @@ +╭──────────────────────┬───────────────────┬───────────╮ +│ Name │ Country of Origin │ Dunk-able │ +├──────────────────────┼───────────────────┼───────────┤ +│ Chocolate Digestives │ UK│ Yes │ +│ Tim Tams │ Australia│ No │ +│ Hobnobs │ UK│ Yes │ +╰──────────────────────┴───────────────────┴───────────╯ diff --git a/table/testdata/TestWithStyleFunc/single_cell_styling.golden b/table/testdata/TestWithStyleFunc/single_cell_styling.golden new file mode 100644 index 00000000..908d313e --- /dev/null +++ b/table/testdata/TestWithStyleFunc/single_cell_styling.golden @@ -0,0 +1,7 @@ +╭──────────────────────┬───────────────────┬───────────╮ +│ Name │ Country of Origin │ Dunk-able │ +├──────────────────────┼───────────────────┼───────────┤ +│ Chocolate Digestives │ UK │ Yes │ +│ Tim Tams │ Australia │ No │ +│ Hobnobs │ UK │ Yes │ +╰──────────────────────┴───────────────────┴───────────╯ \ No newline at end of file