From f24890f15d3cb987e5042c20118f095d6fef5143 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 06:48:21 -0700 Subject: [PATCH 01/27] wip: runs but broken border --- table/table.go | 283 ++++++++++++++++++++++++++----------------------- 1 file changed, 149 insertions(+), 134 deletions(-) diff --git a/table/table.go b/table/table.go index 6103c836..9c13d98a 100644 --- a/table/table.go +++ b/table/table.go @@ -8,29 +8,38 @@ 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" ) +const HEADER int = 0 + // Model defines a state for the table widget. 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 + + 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,24 +113,38 @@ 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 + // TODO is there a way to extract the border from the style? + Border lipgloss.Border + // Why doesn't setting the BorderStyle overwrite the default lip gloss table border? + BorderStyle lipgloss.Style + 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), + BorderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.HiddenBorder()), + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), + Cell: lipgloss.NewStyle().Padding(0, 1), } } // SetStyles sets the table styles. func (m *Model) SetStyles(s Styles) { m.styles = s - m.UpdateViewport() + m.table.BorderStyle(s.BorderStyle) + m.table.StyleFunc(func(row, col int) lipgloss.Style { + if row == HEADER { + return s.Header + } + if row == m.cursor { + return s.Selected + } + return s.Cell + }) } // Option is used to set options in New. For example: @@ -130,43 +153,82 @@ func (m *Model) SetStyles(s Styles) { 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), - +func New(opts ...Option) *Model { + m := &Model{ + cursor: 0, + table: table.New(), KeyMap: DefaultKeyMap(), Help: help.New(), styles: DefaultStyles(), } for _, opt := range opts { - opt(&m) + opt(m) } - m.UpdateViewport() + return m +} +// Headers sets the table headers. +func (m *Model) Headers(headers ...string) *Model { + m.headers = headers + m.table.Headers(headers...) return m } // WithColumns sets the table columns (headers). +// Deprecated: use Headers instead. func WithColumns(cols []Column) Option { return func(m *Model) { - m.cols = cols + m.Headers(colToString(cols)...) + } +} + +// Rows appends rows to the table +func (m *Model) Rows(rows ...[]string) *Model { + m.rows = append(m.rows, rows...) + m.table.Rows(rows...) + return m +} + +// 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 +} + +// rowToString helper to unwrap the Row 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). +// Deprecated: use Rows instead. func WithRows(rows []Row) Option { return func(m *Model) { + rows := rowToString(rows) m.rows = rows + m.table.Rows(rows...) } } +/* options */ + // 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.table.Height(h) } } @@ -187,7 +249,7 @@ 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) } } @@ -198,7 +260,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 @@ -240,18 +302,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. @@ -261,29 +321,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 +331,22 @@ func (m Model) SelectedRow() Row { return m.rows[m.cursor] } -// Rows returns the current rows. -func (m Model) Rows() []Row { - return m.rows -} - -// Columns returns the current columns. -func (m Model) Columns() []Column { - return m.cols -} - -// SetRows sets a new rows state. +// SetRows sets the table content to the new value, overwriting previous values. func (m *Model) SetRows(r []Row) { - m.rows = r - m.UpdateViewport() -} - -// SetColumns sets a new columns state. -func (m *Model) SetColumns(c []Column) { - m.cols = c - m.UpdateViewport() + rows := rowToString(r) + m.rows = rows + m.table.ClearRows().Rows(rows...) } // SetWidth sets the width of the viewport 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. func (m *Model) SetHeight(h int) { - m.viewport.Height = 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 + m.Height = h + m.table.Height(h) } // Cursor returns the index of the selected row. @@ -346,49 +357,53 @@ 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() } // 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.SetCursor(m.cursor-n) 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)) + m.YOffset = 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.viewport.YOffset = (clamp(clamp(m.YOffset+n, 0, m.cursor), 0, m.Height)) + case m.YOffset >= 1: + m.YOffset = clamp(m.YOffset+n, 1, m.Height) } - m.UpdateViewport() + 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() + // TODO make this call SetCursor instead? + m.SetCursor(m.cursor + n) 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)) + case m.end == len(m.rows) && m.YOffset > 0: + m.YOffset = clamp(m.YOffset-n, 1, m.Height) + case m.cursor > (m.end-m.start)/2 && m.YOffset > 0: + m.YOffset = clamp(m.YOffset-n, 1, m.cursor) + case m.YOffset > 1: + case m.cursor > m.YOffset+m.Height-1: + m.YOffset = clamp(m.YOffset+1, 0, 1) } + // update table offset + m.table.Offset(m.YOffset) } // GotoTop moves the selection to the first row. func (m *Model) GotoTop() { - m.MoveUp(m.cursor) + // m.MoveUp(m.cursor) + m.cursor = HEADER + 1 } // GotoBottom moves the selection to the last row. func (m *Model) GotoBottom() { - m.MoveDown(len(m.rows)) + // m.MoveDown(len(m.rows)) + m.cursor = len(m.rows) } // FromValues create the table rows from a simple string. It uses `\n` by @@ -407,38 +422,38 @@ 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 (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.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 max(a, b int) int { if a > b { From 0ce4f9250e126928a2cf220c9ba4d9c24610a3ee Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 13:55:10 -0700 Subject: [PATCH 02/27] wip: include tests --- table/table_test.go | 58 +++++++-------------------------------------- 1 file changed, 8 insertions(+), 50 deletions(-) diff --git a/table/table_test.go b/table/table_test.go index cc49f0d3..df8e97bf 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -1,6 +1,7 @@ package table import ( + "reflect" "testing" "github.com/charmbracelet/lipgloss" @@ -10,19 +11,20 @@ import ( func TestFromValues(t *testing.T) { input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" - table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) + table := New(). + Headers("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 := []Row{ + expect := [][]string{ {"foo1", "bar1"}, {"foo2", "bar2"}, {"foo3", "bar3"}, } - if !deepEqual(table.rows, expect) { + if !reflect.DeepEqual(table.rows, expect) { t.Fatal("table rows is not equals to the input") } } @@ -40,7 +42,7 @@ func TestFromValuesWithTabSeparator(t *testing.T) { {"foo1.", "bar1"}, {"foo,bar,baz", "bar,2"}, } - if !deepEqual(table.rows, expect) { + if !reflect.DeepEqual(table.rows, expect) { t.Fatal("table rows is not equals to the input") } } @@ -65,50 +67,6 @@ var cols = []Column{ {Title: "col3", Width: 10}, } -func TestRenderRow(t *testing.T) { - tests := []struct { - name string - table *Model - expected string - }{ - { - 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", - }, - } - 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) - } - }) - } -} - func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { biscuits := New( @@ -123,6 +81,7 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), + WithStyles(Styles{BorderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.HiddenBorder())}), ) got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) @@ -133,9 +92,8 @@ func TestTableAlignment(t *testing.T) { BorderForeground(lipgloss.Color("240")) s := DefaultStyles() + s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(false) From 63869e38890871c58563e39301d17a1a45d37cd7 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 14:34:32 -0700 Subject: [PATCH 03/27] feat: add BorderHeader to Styles --- table/table.go | 23 ++++++++++++----------- table/table_test.go | 26 +++++++++++--------------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/table/table.go b/table/table.go index 9c13d98a..3684167b 100644 --- a/table/table.go +++ b/table/table.go @@ -113,29 +113,30 @@ func DefaultKeyMap() KeyMap { // Styles contains style definitions for this list component. By default, these // values are generated by DefaultStyles. type Styles struct { - // TODO is there a way to extract the border from the style? - Border lipgloss.Border - // Why doesn't setting the BorderStyle overwrite the default lip gloss table border? - BorderStyle lipgloss.Style - 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{ - BorderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.HiddenBorder()), - Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), - Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), - Cell: lipgloss.NewStyle().Padding(0, 1), + BorderHeader: true, + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + 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.table.Border(s.Border) m.table.BorderStyle(s.BorderStyle) + m.table.BorderHeader(s.BorderHeader) m.table.StyleFunc(func(row, col int) lipgloss.Style { if row == HEADER { return s.Header diff --git a/table/table_test.go b/table/table_test.go index df8e97bf..80c6bc2a 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -61,14 +61,16 @@ func deepEqual(a, b []Row) bool { return true } -var cols = []Column{ - {Title: "col1", Width: 10}, - {Title: "col2", Width: 10}, - {Title: "col3", Width: 10}, -} +// func TestWithColumns(t *testing.T) { +// t.Run("With columns") +// +// t.Run("set headers directly") +// } func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { + s := DefaultStyles() + s.BorderHeader = false biscuits := New( WithHeight(5), WithColumns([]Column{ @@ -81,21 +83,15 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), - WithStyles(Styles{BorderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.HiddenBorder())}), + WithStyles(s), ) 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")) - s := DefaultStyles() - - s.Header = s.Header. - BorderBottom(true). - Bold(false) + s.Border = lipgloss.NormalBorder() + s.BorderStyle = lipgloss.NewStyle().BorderForeground(lipgloss.Color("240")) biscuits := New( WithHeight(5), @@ -111,7 +107,7 @@ func TestTableAlignment(t *testing.T) { }), WithStyles(s), ) - got := ansi.Strip(baseStyle.Render(biscuits.View())) + got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) }) } From 87b61f8646402a672d417817c75bd9357613c46b Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 14:47:04 -0700 Subject: [PATCH 04/27] feat: include NormalBorder in DefaultStyles --- table/table.go | 1 + table/table_test.go | 13 ++----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/table/table.go b/table/table.go index 3684167b..b5b6551c 100644 --- a/table/table.go +++ b/table/table.go @@ -124,6 +124,7 @@ type Styles struct { // DefaultStyles returns a set of default style definitions for this table. func DefaultStyles() Styles { return Styles{ + Border: lipgloss.NormalBorder(), BorderHeader: true, Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), diff --git a/table/table_test.go b/table/table_test.go index 80c6bc2a..17650499 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -61,15 +61,10 @@ func deepEqual(a, b []Row) bool { return true } -// func TestWithColumns(t *testing.T) { -// t.Run("With columns") -// -// t.Run("set headers directly") -// } - func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { s := DefaultStyles() + s.Border = lipgloss.HiddenBorder() s.BorderHeader = false biscuits := New( WithHeight(5), @@ -89,10 +84,6 @@ func TestTableAlignment(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("With border", func(t *testing.T) { - s := DefaultStyles() - s.Border = lipgloss.NormalBorder() - s.BorderStyle = lipgloss.NewStyle().BorderForeground(lipgloss.Color("240")) - biscuits := New( WithHeight(5), WithColumns([]Column{ @@ -105,7 +96,7 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), - WithStyles(s), + WithStyles(DefaultStyles()), ) got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) From 1c621af57d032361f1f600cd10bb3d04e5dc3ec4 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 15:38:29 -0700 Subject: [PATCH 05/27] refactor: tidy; make Options bodies call setters --- table/table.go | 52 +++++++++++++++------------- table/table_test.go | 82 +++++++++++++++++++++++++++++---------------- 2 files changed, 81 insertions(+), 53 deletions(-) diff --git a/table/table.go b/table/table.go index b5b6551c..636fc3a3 100644 --- a/table/table.go +++ b/table/table.go @@ -151,6 +151,8 @@ func (m *Model) SetStyles(s Styles) { // Option is used to set options in New. For example: // +// TODO change this to WithRows as the example instead +// // table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) type Option func(*Model) @@ -178,14 +180,30 @@ func (m *Model) Headers(headers ...string) *Model { return m } +// WithHeaders sets the table headers. +func WithHeaders(headers []string) Option { + return func(m *Model) { + m.Headers(headers...) + } +} + // WithColumns sets the table columns (headers). -// Deprecated: use Headers instead. +// Deprecated: use WithHeaders instead. func WithColumns(cols []Column) Option { return func(m *Model) { m.Headers(colToString(cols)...) } } +// colToString helper to unwrap the Column type. +func colToString(cols []Column) []string { + var out []string + for _, col := range cols { + out = append(out, col.Title) + } + return out +} + // Rows appends rows to the table func (m *Model) Rows(rows ...[]string) *Model { m.rows = append(m.rows, rows...) @@ -193,6 +211,13 @@ func (m *Model) Rows(rows ...[]string) *Model { return m } +// WithRows sets the table rows (data). +func WithRows(rows []Row) Option { + return func(m *Model) { + m.SetRows(rows) + } +} + // rowToString helper to unwrap the Row type. func rowToString(rows []Row) [][]string { var out [][]string @@ -206,38 +231,17 @@ func rowToString(rows []Row) [][]string { return out } -// rowToString helper to unwrap the Row 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). -// Deprecated: use Rows instead. -func WithRows(rows []Row) Option { - return func(m *Model) { - rows := rowToString(rows) - m.rows = rows - m.table.Rows(rows...) - } -} - -/* options */ - // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { - m.table.Height(h) + 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) } } diff --git a/table/table_test.go b/table/table_test.go index 17650499..2a59f3a4 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -10,23 +10,61 @@ import ( ) func TestFromValues(t *testing.T) { - input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" - table := New(). - Headers("Foo", "Bar") - table.FromValues(input, ",") + t.Run("Headers", func(t *testing.T) { + input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" + table := New(). + Headers("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 := [][]string{ - {"foo1", "bar1"}, - {"foo2", "bar2"}, - {"foo3", "bar3"}, - } - if !reflect.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([]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") + } + }) } func TestFromValuesWithTabSeparator(t *testing.T) { @@ -47,20 +85,6 @@ func TestFromValuesWithTabSeparator(t *testing.T) { } } -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 -} - func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { s := DefaultStyles() From e18aafc29bd783aec709ced63ddcc648ab1bc6f3 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 15:42:39 -0700 Subject: [PATCH 06/27] chore: remove header const from exports --- table/table.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/table/table.go b/table/table.go index 636fc3a3..16bac3d2 100644 --- a/table/table.go +++ b/table/table.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/lipgloss/table" ) -const HEADER int = 0 +const header int = 0 // Model defines a state for the table widget. type Model struct { @@ -139,7 +139,7 @@ func (m *Model) SetStyles(s Styles) { m.table.BorderStyle(s.BorderStyle) m.table.BorderHeader(s.BorderHeader) m.table.StyleFunc(func(row, col int) lipgloss.Style { - if row == HEADER { + if row == header { return s.Header } if row == m.cursor { @@ -403,7 +403,7 @@ func (m *Model) MoveDown(n int) { // GotoTop moves the selection to the first row. func (m *Model) GotoTop() { // m.MoveUp(m.cursor) - m.cursor = HEADER + 1 + m.cursor = header + 1 } // GotoBottom moves the selection to the last row. From 7b5c6cbcb2af75b540476f249007242c9a081c72 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 17:07:14 -0700 Subject: [PATCH 07/27] chore: tidy --- table/table.go | 149 ++++++++++++++++++-------------------------- table/table_test.go | 6 +- 2 files changed, 62 insertions(+), 93 deletions(-) diff --git a/table/table.go b/table/table.go index 16bac3d2..4cd1329f 100644 --- a/table/table.go +++ b/table/table.go @@ -19,7 +19,7 @@ type Model struct { Help help.Model YOffset int - Height int + height int headers []string rows [][]string cursor int @@ -157,8 +157,8 @@ func (m *Model) SetStyles(s Styles) { type Option func(*Model) // New creates a new model for the table widget. -func New(opts ...Option) *Model { - m := &Model{ +func New(opts ...Option) Model { + m := Model{ cursor: 0, table: table.New(), KeyMap: DefaultKeyMap(), @@ -167,23 +167,16 @@ func New(opts ...Option) *Model { } for _, opt := range opts { - opt(m) + opt(&m) } return m } -// Headers sets the table headers. -func (m *Model) Headers(headers ...string) *Model { - m.headers = headers - m.table.Headers(headers...) - return m -} - // WithHeaders sets the table headers. func WithHeaders(headers []string) Option { return func(m *Model) { - m.Headers(headers...) + m.SetHeaders(headers...) } } @@ -191,11 +184,11 @@ func WithHeaders(headers []string) Option { // Deprecated: use WithHeaders instead. func WithColumns(cols []Column) Option { return func(m *Model) { - m.Headers(colToString(cols)...) + m.SetHeaders(colToString(cols)...) } } -// colToString helper to unwrap the Column type. +// colToString helper to unwrap the Column type to its underlying string type. func colToString(cols []Column) []string { var out []string for _, col := range cols { @@ -204,13 +197,6 @@ func colToString(cols []Column) []string { return out } -// Rows appends rows to the table -func (m *Model) Rows(rows ...[]string) *Model { - m.rows = append(m.rows, rows...) - m.table.Rows(rows...) - return m -} - // WithRows sets the table rows (data). func WithRows(rows []Row) Option { return func(m *Model) { @@ -218,19 +204,6 @@ func WithRows(rows []Row) Option { } } -// 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 -} - // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { @@ -280,13 +253,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): @@ -337,21 +310,48 @@ func (m Model) SelectedRow() Row { return m.rows[m.cursor] } -// SetRows sets the table content to the new value, overwriting previous values. +// Append appends rows to the table. +func (m *Model) Append(rows ...[]string) { + m.rows = append(m.rows, rows...) + m.table.Rows(m.rows...) +} + +// SetRows overwrites existing rows with new ones. func (m *Model) SetRows(r []Row) { + // 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().Rows(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.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.Height = h + m.height = h m.table.Height(h) } @@ -363,20 +363,25 @@ 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) + // TODO should I update YOffset here? } // 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.SetCursor(m.cursor-n) - m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) + // check that the table has contents + if len(m.rows) < 1 { + return + } + firstRow := header + 1 + m.SetCursor(m.cursor - n) switch { - case m.start == 0: - m.YOffset = clamp(m.viewport.YOffset, 0, m.cursor) - case m.start < m.viewport.Height: - m.viewport.YOffset = (clamp(clamp(m.YOffset+n, 0, m.cursor), 0, m.Height)) - case m.YOffset >= 1: - m.YOffset = clamp(m.YOffset+n, 1, m.Height) + case m.start == firstRow: + m.YOffset = clamp(m.YOffset, firstRow, m.cursor) + case m.start < m.height: + m.YOffset = (clamp(clamp(m.YOffset+n, firstRow, m.cursor), firstRow, m.height)) + case m.YOffset >= firstRow+1: + m.YOffset = clamp(m.YOffset+n, firstRow+1, m.height) } m.table.Offset(m.YOffset) } @@ -384,16 +389,15 @@ func (m *Model) MoveUp(n int) { // MoveDown moves the selection down by any number of rows. // It can not go below the last row. func (m *Model) MoveDown(n int) { - // TODO make this call SetCursor instead? m.SetCursor(m.cursor + n) switch { case m.end == len(m.rows) && m.YOffset > 0: - m.YOffset = clamp(m.YOffset-n, 1, m.Height) + m.YOffset = clamp(m.YOffset-n, 1, m.height) case m.cursor > (m.end-m.start)/2 && m.YOffset > 0: m.YOffset = clamp(m.YOffset-n, 1, m.cursor) case m.YOffset > 1: - case m.cursor > m.YOffset+m.Height-1: + case m.cursor > m.YOffset+m.height-1: m.YOffset = clamp(m.YOffset+1, 0, 1) } // update table offset @@ -402,14 +406,12 @@ func (m *Model) MoveDown(n int) { // GotoTop moves the selection to the first row. func (m *Model) GotoTop() { - // m.MoveUp(m.cursor) - m.cursor = header + 1 + m.MoveUp(m.cursor) } // GotoBottom moves the selection to the last row. func (m *Model) GotoBottom() { - // m.MoveDown(len(m.rows)) - m.cursor = len(m.rows) + m.MoveDown(len(m.rows)) } // FromValues create the table rows from a simple string. It uses `\n` by @@ -428,39 +430,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.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 max(a, b int) int { if a > b { return a diff --git a/table/table_test.go b/table/table_test.go index 2a59f3a4..08893b75 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -12,8 +12,8 @@ import ( func TestFromValues(t *testing.T) { t.Run("Headers", func(t *testing.T) { input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" - table := New(). - Headers("Foo", "Bar") + table := New() + table.Headers("Foo", "Bar") table.FromValues(input, ",") if len(table.rows) != 3 { @@ -49,7 +49,7 @@ func TestFromValues(t *testing.T) { }) t.Run("WithHeaders", func(t *testing.T) { input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" - table := New(WithHeaders([]Column{{Title: "Foo"}, {Title: "Bar"}})) + table := New(WithHeaders([]string{"Foo", "Bar"})) table.FromValues(input, ",") if len(table.rows) != 3 { From 2e34a232d2af978328dd762d87fc36453dcc692e Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 11 Sep 2024 17:12:22 -0700 Subject: [PATCH 08/27] refactor: make SetHeaders variadic --- table/table.go | 3 ++- table/table_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/table/table.go b/table/table.go index 4cd1329f..e4f47bc2 100644 --- a/table/table.go +++ b/table/table.go @@ -339,7 +339,8 @@ func rowToString(rows []Row) [][]string { } // SetHeaders sets the table headers. -func (m *Model) SetHeaders(headers []string) { +// TODO should this be variadic to match lipgloss table? +func (m *Model) SetHeaders(headers ...string) { m.headers = headers m.table.Headers(headers...) } diff --git a/table/table_test.go b/table/table_test.go index 08893b75..051bb7ce 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -13,7 +13,7 @@ func TestFromValues(t *testing.T) { t.Run("Headers", func(t *testing.T) { input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" table := New() - table.Headers("Foo", "Bar") + table.SetHeaders("Foo", "Bar") table.FromValues(input, ",") if len(table.rows) != 3 { From 30bed825aac6000d7b2848de6b942dba4405cb68 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Fri, 13 Sep 2024 18:44:51 -0700 Subject: [PATCH 09/27] fix(table): style selected row --- table/table.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/table/table.go b/table/table.go index e4f47bc2..3fa9464f 100644 --- a/table/table.go +++ b/table/table.go @@ -11,7 +11,10 @@ import ( "github.com/charmbracelet/lipgloss/table" ) -const header int = 0 +const ( + header int = 0 + firstRow int = 1 +) // Model defines a state for the table widget. type Model struct { @@ -138,15 +141,6 @@ func (m *Model) SetStyles(s Styles) { m.table.Border(s.Border) m.table.BorderStyle(s.BorderStyle) m.table.BorderHeader(s.BorderHeader) - m.table.StyleFunc(func(row, col int) lipgloss.Style { - if row == header { - return s.Header - } - if row == m.cursor { - return s.Selected - } - return s.Cell - }) } // Option is used to set options in New. For example: @@ -159,7 +153,7 @@ type Option func(*Model) // New creates a new model for the table widget. func New(opts ...Option) Model { m := Model{ - cursor: 0, + cursor: firstRow, table: table.New(), KeyMap: DefaultKeyMap(), Help: help.New(), @@ -290,6 +284,17 @@ func (m *Model) Blur() { // View renders the component. func (m Model) View() string { + m.table.StyleFunc(func(row, col int) lipgloss.Style { + if row == header { + return m.styles.Header + } + if row == m.cursor { + log.Printf("row and cursor match %d\n", m.cursor) + return m.styles.Selected + } + return m.styles.Cell + }) + return m.table.String() } @@ -322,7 +327,9 @@ func (m *Model) SetRows(r []Row) { // TODO should we just deprecate the Row type altogether? rows := rowToString(r) m.rows = rows - m.table.ClearRows().Rows(rows...) + // TODO test this + m.table.ClearRows() + m.table.Rows(rows...) } // rowToString helper to unwrap the Row type. @@ -374,7 +381,7 @@ func (m *Model) MoveUp(n int) { if len(m.rows) < 1 { return } - firstRow := header + 1 + firstRow := firstRow m.SetCursor(m.cursor - n) switch { case m.start == firstRow: From 605b8ac4e5260b1c5cb58c548b7e6b87de1f67e3 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Mon, 16 Sep 2024 10:05:09 -0700 Subject: [PATCH 10/27] wip: add scrolling --- table/table.go | 50 ++++++++++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/table/table.go b/table/table.go index 3fa9464f..0a0ea50c 100644 --- a/table/table.go +++ b/table/table.go @@ -377,39 +377,37 @@ func (m *Model) SetCursor(n int) { // MoveUp moves the selection up by any number of rows. // It can not go above the first row. func (m *Model) MoveUp(n int) { - // check that the table has contents - if len(m.rows) < 1 { - return - } - firstRow := firstRow - m.SetCursor(m.cursor - n) - switch { - case m.start == firstRow: - m.YOffset = clamp(m.YOffset, firstRow, m.cursor) - case m.start < m.height: - m.YOffset = (clamp(clamp(m.YOffset+n, firstRow, m.cursor), firstRow, m.height)) - case m.YOffset >= firstRow+1: - m.YOffset = clamp(m.YOffset+n, firstRow+1, m.height) + if m.cursor > firstRow { + m.SetCursor(m.cursor - n) + m.YOffset = m.YOffset - n + m.table.Offset(m.YOffset) } - m.table.Offset(m.YOffset) + // check that the table has contents + // if len(m.rows) < 1 { + // return + // } + // firstRow := firstRow + // m.SetCursor(m.cursor - n) + // switch { + // case m.start == firstRow: + // m.YOffset = clamp(m.YOffset, firstRow, m.cursor) + // case m.start < m.height: + // m.YOffset = (clamp(clamp(m.YOffset+n, firstRow, m.cursor), firstRow, m.height)) + // case m.YOffset >= firstRow+1: + // m.YOffset = clamp(m.YOffset+n, firstRow+1, m.height) + // } } // 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.SetCursor(m.cursor + n) - - switch { - case m.end == len(m.rows) && m.YOffset > 0: - m.YOffset = clamp(m.YOffset-n, 1, m.height) - case m.cursor > (m.end-m.start)/2 && m.YOffset > 0: - m.YOffset = clamp(m.YOffset-n, 1, m.cursor) - case m.YOffset > 1: - case m.cursor > m.YOffset+m.height-1: - m.YOffset = clamp(m.YOffset+1, 0, 1) + if m.cursor < len(m.rows) { + m.SetCursor(m.cursor + n) + m.YOffset = m.YOffset + n + m.table.Offset(m.YOffset) } - // update table offset - m.table.Offset(m.YOffset) + // TODO stop setting offset when visible rows less than expected + // TODO we should track visible row indices } // GotoTop moves the selection to the first row. From 791d89b668e3fa6f86e1b5fee2ed68cda1cd7b7f Mon Sep 17 00:00:00 2001 From: bashbunni Date: Fri, 20 Sep 2024 10:47:15 -0700 Subject: [PATCH 11/27] wip: general yoffset logic. Needs clean + height fix --- table/table.go | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/table/table.go b/table/table.go index 0a0ea50c..68d3f531 100644 --- a/table/table.go +++ b/table/table.go @@ -289,7 +289,6 @@ func (m Model) View() string { return m.styles.Header } if row == m.cursor { - log.Printf("row and cursor match %d\n", m.cursor) return m.styles.Selected } return m.styles.Cell @@ -379,8 +378,14 @@ func (m *Model) SetCursor(n int) { func (m *Model) MoveUp(n int) { if m.cursor > firstRow { m.SetCursor(m.cursor - n) - m.YOffset = m.YOffset - n - m.table.Offset(m.YOffset) + + // only set the offset outside of the last available rows. + // TODO use maxYOffset + if m.cursor < len(m.rows)-m.height { + m.YOffset = m.YOffset - n + m.table.Offset(m.YOffset) + } + } // check that the table has contents // if len(m.rows) < 1 { @@ -398,16 +403,34 @@ func (m *Model) MoveUp(n int) { // } } +// maxYOffset returns the maximum possible value of the y-offset based on the +// table's rows and height. +func (m Model) maxYOffset() int { + return max(0, len(m.rows)-m.height) +} + // MoveDown moves the selection down by any number of rows. // It can not go below the last row. func (m *Model) MoveDown(n int) { + // 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 if m.cursor < len(m.rows) { m.SetCursor(m.cursor + n) - m.YOffset = m.YOffset + n - m.table.Offset(m.YOffset) + + // TODO I need height minus margin and padding... How many rows should + // be visible? Does lip gloss table give us that info since it's the one + // truncating and _would_ have that info? + + // check if we're in the last set of rows before truncation would + // happen. + if m.cursor < len(m.rows)-m.height { + // TODO use maxOffset? + m.YOffset = m.YOffset + n + m.table.Offset(m.YOffset) + } } - // TODO stop setting offset when visible rows less than expected - // TODO we should track visible row indices } // GotoTop moves the selection to the first row. From ee1ba24d7b13906a8bc62b45927288c4c35ca375 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Fri, 20 Sep 2024 11:09:48 -0700 Subject: [PATCH 12/27] refactor(table): use maxYOffset --- table/table.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/table/table.go b/table/table.go index 68d3f531..32ea7d7c 100644 --- a/table/table.go +++ b/table/table.go @@ -381,7 +381,7 @@ func (m *Model) MoveUp(n int) { // only set the offset outside of the last available rows. // TODO use maxYOffset - if m.cursor < len(m.rows)-m.height { + if m.cursor < m.maxYOffset() { m.YOffset = m.YOffset - n m.table.Offset(m.YOffset) } @@ -425,8 +425,7 @@ func (m *Model) MoveDown(n int) { // check if we're in the last set of rows before truncation would // happen. - if m.cursor < len(m.rows)-m.height { - // TODO use maxOffset? + if m.cursor < m.maxYOffset() { m.YOffset = m.YOffset + n m.table.Offset(m.YOffset) } From c436cff1707c1534abb97ac5a570b03efa5a7d54 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Mon, 23 Sep 2024 17:48:28 -0700 Subject: [PATCH 13/27] wip: fix table styles --- table/table.go | 64 ++++++++++++--------------------------------- table/table_test.go | 50 ++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/table/table.go b/table/table.go index 32ea7d7c..5fb4ab05 100644 --- a/table/table.go +++ b/table/table.go @@ -21,7 +21,7 @@ type Model struct { KeyMap KeyMap Help help.Model - YOffset int + yoffset int height int headers []string rows [][]string @@ -129,7 +129,7 @@ func DefaultStyles() Styles { return Styles{ Border: lipgloss.NormalBorder(), BorderHeader: true, - Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + 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), } @@ -285,7 +285,7 @@ func (m *Model) Blur() { // View renders the component. func (m Model) View() string { m.table.StyleFunc(func(row, col int) lipgloss.Style { - if row == header { + if row == table.HeaderRow { return m.styles.Header } if row == m.cursor { @@ -370,43 +370,21 @@ 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) - // TODO should I update YOffset here? +} + +// 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) { - if m.cursor > firstRow { - m.SetCursor(m.cursor - n) - - // only set the offset outside of the last available rows. - // TODO use maxYOffset - if m.cursor < m.maxYOffset() { - m.YOffset = m.YOffset - n - m.table.Offset(m.YOffset) - } + m.SetCursor(m.cursor - n) - } - // check that the table has contents - // if len(m.rows) < 1 { - // return - // } - // firstRow := firstRow - // m.SetCursor(m.cursor - n) - // switch { - // case m.start == firstRow: - // m.YOffset = clamp(m.YOffset, firstRow, m.cursor) - // case m.start < m.height: - // m.YOffset = (clamp(clamp(m.YOffset+n, firstRow, m.cursor), firstRow, m.height)) - // case m.YOffset >= firstRow+1: - // m.YOffset = clamp(m.YOffset+n, firstRow+1, m.height) - // } -} - -// maxYOffset returns the maximum possible value of the y-offset based on the -// table's rows and height. -func (m Model) maxYOffset() int { - return max(0, len(m.rows)-m.height) + // 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. @@ -416,20 +394,10 @@ func (m *Model) MoveDown(n int) { // stop setting the y offset and only move cursor // visible lines after updating viewport - if m.cursor < len(m.rows) { - m.SetCursor(m.cursor + n) - - // TODO I need height minus margin and padding... How many rows should - // be visible? Does lip gloss table give us that info since it's the one - // truncating and _would_ have that info? - - // check if we're in the last set of rows before truncation would - // happen. - if m.cursor < m.maxYOffset() { - m.YOffset = m.YOffset + n - m.table.Offset(m.YOffset) - } - } + m.SetCursor(m.cursor + n) + + m.setYOffset(m.yoffset + n) + m.table.Offset(m.yoffset) } // GotoTop moves the selection to the first row. diff --git a/table/table_test.go b/table/table_test.go index 051bb7ce..4b08d4f0 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -81,7 +81,55 @@ func TestFromValuesWithTabSeparator(t *testing.T) { {"foo,bar,baz", "bar,2"}, } if !reflect.DeepEqual(table.rows, expect) { - t.Fatal("table rows is not equals to the input") + t.Fatal("table rows is not equal to the input") + } +} + +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 + cursor int + expected int + }{ + {"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) { + table := New( + WithRows([]Row{ + {"Foo"}, + {"Bar"}, + {"Baz"}, + }), + ) + table.SetCursor(tc.cursor) + if table.cursor != tc.expected { + t.Fatalf("cursor out of range. 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) + t.Fatalf("%#v\n%s", table.SelectedRow(), table.View()) + if table.cursor != tc.expected { + t.Fatalf("cursor out of range. Should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) + } + }) } } From be32116954c7362d087dcf6efa9d504e5d0f33ff Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 24 Sep 2024 16:57:38 -0700 Subject: [PATCH 14/27] fix(test): update outputs for TestTableAlignment --- table/table_test.go | 12 ++++++++++-- .../testdata/TestTableAlignment/No_border.golden | 9 ++++----- .../TestTableAlignment/With_border.golden | 15 +++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/table/table_test.go b/table/table_test.go index 4b08d4f0..c76c11e5 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -136,7 +136,6 @@ func TestSetCursor(t *testing.T) { func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { s := DefaultStyles() - s.Border = lipgloss.HiddenBorder() s.BorderHeader = false biscuits := New( WithHeight(5), @@ -152,12 +151,20 @@ func TestTableAlignment(t *testing.T) { }), WithStyles(s), ) + + // unset borders for styling + // TODO maybe do this in View or something instead if border type is hidden? + biscuits.table. + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + BorderColumn(false) got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("With border", func(t *testing.T) { biscuits := New( - WithHeight(5), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -168,6 +175,7 @@ func TestTableAlignment(t *testing.T) { {"Tim Tams", "Australia", "No"}, {"Hobnobs", "UK", "Yes"}, }), + WithHeight(10), WithStyles(DefaultStyles()), ) got := ansi.Strip(biscuits.View()) 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 From e242987806d8cc01c1224c41c3841306720967b1 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 24 Sep 2024 16:58:37 -0700 Subject: [PATCH 15/27] refactor(test): update error message for TestSetCursor --- table/table_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/table/table_test.go b/table/table_test.go index c76c11e5..2b17b2d9 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -110,7 +110,7 @@ func TestSetCursor(t *testing.T) { ) table.SetCursor(tc.cursor) if table.cursor != tc.expected { - t.Fatalf("cursor out of range. Should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) + 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) { @@ -125,9 +125,8 @@ func TestSetCursor(t *testing.T) { }), ) table.SetCursor(tc.cursor) - t.Fatalf("%#v\n%s", table.SelectedRow(), table.View()) if table.cursor != tc.expected { - t.Fatalf("cursor out of range. Should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) + t.Fatalf("wrong cursor value, should be %d, got: %d\n%s", tc.expected, table.cursor, table.View()) } }) } From 14927f0b946d5d677a58930ca0d7c84a445a5ba0 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 24 Sep 2024 17:02:26 -0700 Subject: [PATCH 16/27] fix(test): set correct type in comparison for TestFromValuesWithTabSeparator --- table/table_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/table/table_test.go b/table/table_test.go index 2b17b2d9..0b52b947 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -4,7 +4,6 @@ import ( "reflect" "testing" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) @@ -76,12 +75,12 @@ 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 !reflect.DeepEqual(table.rows, expect) { - t.Fatal("table rows is not equal to the input") + t.Fatalf("table rows is not equal to the input. got: %#v, want %#v", table.rows, expect) } } From a0b7e17ab11ca40da47f9a0a96a84e00521e6eb9 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 24 Sep 2024 17:18:26 -0700 Subject: [PATCH 17/27] fix(table): use 0 as first row index (no off by one) --- table/table.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/table/table.go b/table/table.go index 5fb4ab05..f8ce972b 100644 --- a/table/table.go +++ b/table/table.go @@ -11,11 +11,6 @@ import ( "github.com/charmbracelet/lipgloss/table" ) -const ( - header int = 0 - firstRow int = 1 -) - // Model defines a state for the table widget. type Model struct { KeyMap KeyMap @@ -153,7 +148,6 @@ type Option func(*Model) // New creates a new model for the table widget. func New(opts ...Option) Model { m := Model{ - cursor: firstRow, table: table.New(), KeyMap: DefaultKeyMap(), Help: help.New(), From f1f0c4cb16a3bd3d1ed02fbd8fb1d62e1ac3d0d8 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Tue, 24 Sep 2024 17:25:26 -0700 Subject: [PATCH 18/27] chore(deps): use use-lipgloss-table branch of lipgloss --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 28157038..9fd2c852 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.20240914005022-9d06bdda6ba9 + github.com/charmbracelet/x/ansi v0.3.0 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..79b3ed4e 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.20240914005022-9d06bdda6ba9 h1:o1d7vDla50o6tkP4M6h3OhNw76VIX5adAL75K/TqEvE= +github.com/charmbracelet/lipgloss v0.13.1-0.20240914005022-9d06bdda6ba9/go.mod h1:hYiKsC5VQ0PDKkdp2CxqHIlqxBZM2WfZgq/cqj3s7/k= +github.com/charmbracelet/x/ansi v0.3.0 h1:CCsscv7vKC/DNYUYFQNNIOWzrpTUbLXL3d4fdFIQ0WE= +github.com/charmbracelet/x/ansi v0.3.0/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= From cae042849cdbb0e59b963ce8fd7d3b2ddfd4280f Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 10:27:11 -0700 Subject: [PATCH 19/27] chore: tidy --- table/table.go | 14 ++++++++++---- table/table_test.go | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/table/table.go b/table/table.go index f8ce972b..acda9ffc 100644 --- a/table/table.go +++ b/table/table.go @@ -138,11 +138,18 @@ func (m *Model) SetStyles(s Styles) { m.table.BorderHeader(s.BorderHeader) } +// TODO make this able to be set with same no args as lipgloss.BorderForeground +func (m *Model) SetBorder(top, right, bottom, left bool) { + m.table. + BorderTop(top). + BorderRight(right). + BorderBottom(bottom). + BorderLeft(left) +} + // Option is used to set options in New. For example: // -// TODO change this to WithRows as the example instead -// -// 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. @@ -320,7 +327,6 @@ func (m *Model) SetRows(r []Row) { // TODO should we just deprecate the Row type altogether? rows := rowToString(r) m.rows = rows - // TODO test this m.table.ClearRows() m.table.Rows(rows...) } diff --git a/table/table_test.go b/table/table_test.go index 0b52b947..d5e27aec 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -150,8 +150,9 @@ func TestTableAlignment(t *testing.T) { WithStyles(s), ) - // unset borders for styling - // TODO maybe do this in View or something instead if border type is hidden? + // unset borders + // TODO I may need to expose this table OR have a helper func, they + // won't be able to do this outside of the library biscuits.table. BorderTop(false). BorderBottom(false). From b483f6dda9b2fa439b987072fd5e6a6088857a10 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 11:19:45 -0700 Subject: [PATCH 20/27] feat(table): add SetBorder function to set/unset borders --- table/table.go | 90 +++++++++++++++++++++++++++++++++++++++++++-- table/table_test.go | 11 +----- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/table/table.go b/table/table.go index acda9ffc..fd97857f 100644 --- a/table/table.go +++ b/table/table.go @@ -138,13 +138,97 @@ func (m *Model) SetStyles(s Styles) { m.table.BorderHeader(s.BorderHeader) } -// TODO make this able to be set with same no args as lipgloss.BorderForeground -func (m *Model) SetBorder(top, right, bottom, left bool) { +// 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) + 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: diff --git a/table/table_test.go b/table/table_test.go index d5e27aec..d40132ef 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -150,15 +150,8 @@ func TestTableAlignment(t *testing.T) { WithStyles(s), ) - // unset borders - // TODO I may need to expose this table OR have a helper func, they - // won't be able to do this outside of the library - biscuits.table. - BorderTop(false). - BorderBottom(false). - BorderLeft(false). - BorderRight(false). - BorderColumn(false) + // unset borders; hidden border leaves space. + biscuits.SetBorder(false) got := ansi.Strip(biscuits.View()) golden.RequireEqual(t, []byte(got)) }) From c9845d82ef61b75cba85afe8637f094d6f2977ca Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 11:21:46 -0700 Subject: [PATCH 21/27] chore(DONT MERGE): use use-lipgloss-table branch of lipgloss --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9fd2c852..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.1-0.20240914005022-9d06bdda6ba9 - github.com/charmbracelet/x/ansi v0.3.0 + 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 79b3ed4e..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.1-0.20240914005022-9d06bdda6ba9 h1:o1d7vDla50o6tkP4M6h3OhNw76VIX5adAL75K/TqEvE= -github.com/charmbracelet/lipgloss v0.13.1-0.20240914005022-9d06bdda6ba9/go.mod h1:hYiKsC5VQ0PDKkdp2CxqHIlqxBZM2WfZgq/cqj3s7/k= -github.com/charmbracelet/x/ansi v0.3.0 h1:CCsscv7vKC/DNYUYFQNNIOWzrpTUbLXL3d4fdFIQ0WE= -github.com/charmbracelet/x/ansi v0.3.0/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= From add073a3e093e32131057a3436493466cc21787d Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 11:25:27 -0700 Subject: [PATCH 22/27] chore: tidy TODOs --- table/table.go | 1 - 1 file changed, 1 deletion(-) diff --git a/table/table.go b/table/table.go index fd97857f..5322baf2 100644 --- a/table/table.go +++ b/table/table.go @@ -429,7 +429,6 @@ func rowToString(rows []Row) [][]string { } // SetHeaders sets the table headers. -// TODO should this be variadic to match lipgloss table? func (m *Model) SetHeaders(headers ...string) { m.headers = headers m.table.Headers(headers...) From 819e0210bd143ce5766a278be48acee22fdfedd0 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 11:28:34 -0700 Subject: [PATCH 23/27] wip(docs): proposal outline --- proposal.org | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 proposal.org 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~ From e7821ead322ea7be690fb6ca18a8c9b94179f23d Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 11:38:09 -0700 Subject: [PATCH 24/27] feat(table): bring back SetColumn --- table/table.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/table/table.go b/table/table.go index 5322baf2..1330a353 100644 --- a/table/table.go +++ b/table/table.go @@ -405,6 +405,12 @@ func (m *Model) Append(rows ...[]string) { m.table.Rows(m.rows...) } +// SetColumns sets a new columns state. +// Deprecated: use SetHeaders instead. +func (m *Model) SetColumns(c []Column) { + m.SetHeaders(colToString(c)...) +} + // SetRows overwrites existing rows with new ones. func (m *Model) SetRows(r []Row) { // lipgloss' table requires []string, so it's easier to convert these. From 48eb859f2161b897813206abdb0aea6ac97d6507 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 12:40:44 -0700 Subject: [PATCH 25/27] feat(table): Bring back Height and Rows --- table/table.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/table/table.go b/table/table.go index 1330a353..0ae4f57e 100644 --- a/table/table.go +++ b/table/table.go @@ -421,6 +421,12 @@ func (m *Model) SetRows(r []Row) { m.table.Rows(rows...) } +// 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 +} + // rowToString helper to unwrap the Row type. func rowToString(rows []Row) [][]string { var out [][]string @@ -451,6 +457,11 @@ func (m *Model) SetHeight(h int) { m.table.Height(h) } +// Height returns the height of the table, including borders. +func (m Model) Height() int { + return m.height +} + // Cursor returns the index of the selected row. func (m Model) Cursor() int { return m.cursor From 6548f4e5012260621d3bae420f1f4686d2ea6df9 Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 13:41:35 -0700 Subject: [PATCH 26/27] feat(table): add option for custom StyleFunc --- table/table.go | 51 ++++++---- table/table_test.go | 97 ++++++++++++++++++- .../single_cell_styling.golden | 7 ++ .../cell_styling_by_content.golden | 7 ++ .../single_cell_styling.golden | 7 ++ 5 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 table/testdata/TestSetStyleFunc/single_cell_styling.golden create mode 100644 table/testdata/TestWithStyleFunc/cell_styling_by_content.golden create mode 100644 table/testdata/TestWithStyleFunc/single_cell_styling.golden diff --git a/table/table.go b/table/table.go index 0ae4f57e..620631d1 100644 --- a/table/table.go +++ b/table/table.go @@ -16,13 +16,14 @@ type Model struct { KeyMap KeyMap Help help.Model - yoffset int - height int - headers []string - rows [][]string - 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 @@ -138,6 +139,12 @@ func (m *Model) SetStyles(s Styles) { 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: // @@ -311,6 +318,13 @@ func WithStyles(s Styles) Option { } } +// WithStyleFunc sets the table StyleFunc for conditional styling. +func WithStyleFunc(s table.StyleFunc) Option { + return func(m *Model) { + m.SetStyleFunc(s) + } +} + // WithKeyMap sets the key map. func WithKeyMap(km KeyMap) Option { return func(m *Model) { @@ -369,16 +383,19 @@ func (m *Model) Blur() { // View renders the component. func (m Model) View() string { - 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 - }) - + 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() } diff --git a/table/table_test.go b/table/table_test.go index d40132ef..80dcc69b 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -4,6 +4,8 @@ import ( "reflect" "testing" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) @@ -136,7 +138,7 @@ func TestTableAlignment(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}, @@ -174,3 +176,96 @@ func TestTableAlignment(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) } + +func TestSetStyleFunc(t *testing.T) { + t.Run("single cell styling", func(t *testing.T) { + 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"}, + }), + ) + 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) { + 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) { + 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())) + }) +} 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/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/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 From 9516572c1c9c3123dcc39e2810f35c1be413dd0d Mon Sep 17 00:00:00 2001 From: bashbunni Date: Wed, 25 Sep 2024 13:49:38 -0700 Subject: [PATCH 27/27] test(table): failing text alignment --- table/table_test.go | 28 +++++++++++++++++++ .../change_column_text_alignment.golden | 7 +++++ 2 files changed, 35 insertions(+) create mode 100644 table/testdata/TestWithStyleFunc/change_column_text_alignment.golden diff --git a/table/table_test.go b/table/table_test.go index 80dcc69b..8b856bb0 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -209,6 +209,7 @@ func TestSetStyleFunc(t *testing.T) { func TestWithStyleFunc(t *testing.T) { t.Run("single cell styling", func(t *testing.T) { + // #502 s := DefaultStyles() s.BorderHeader = false biscuits := New( @@ -237,6 +238,7 @@ func TestWithStyleFunc(t *testing.T) { 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"}, @@ -268,4 +270,30 @@ func TestWithStyleFunc(t *testing.T) { })) 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/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 │ +╰──────────────────────┴───────────────────┴───────────╯