diff --git a/Makefile b/Makefile index e6cf6b2..593885a 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,10 @@ example-pagination: example-simplest: @go run ./examples/simplest/*.go +.PHONY: example-simplest +example-sorting: + @go run ./examples/sorting/*.go + .PHONY: test test: @go test -race -cover ./table diff --git a/README.md b/README.md index 3fc01a8..516ec58 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ Can make rows selectable, and fetch the current selections. Pagination can be set with a given page size, which automatically generates a simple footer to show the current page and total pages. +Columns can be sorted in either ascending or descending order. Multiple columns +can be specified in a row. If multiple columns are specified, first the table +is sorted by the first specified column, then each group within that column is +sorted in smaller and smaller groups. [See the sorting example](examples/sorting/main.go) +for more information. + +If a feature is confusing to use or could use a better example, please feel free +to open an issue. + ## Defining table data A table is defined by a list of `Column` values that define the columns in the diff --git a/examples/features/main.go b/examples/features/main.go index 16436ff..6e9333e 100644 --- a/examples/features/main.go +++ b/examples/features/main.go @@ -47,7 +47,11 @@ type Model struct { func NewModel() Model { columns := []table.Column{ - table.NewColumn(columnKeyID, "ID", 5).WithStyle(lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("#88f"))), + table.NewColumn(columnKeyID, "ID", 5).WithStyle( + lipgloss.NewStyle(). + Faint(true). + Foreground(lipgloss.Color("#88f")). + Align(lipgloss.Left)), table.NewColumn(columnKeyName, "Name", 10), table.NewColumn(columnKeyDescription, "Description", 30), table.NewColumn(columnKeyCount, "#", 5), @@ -74,13 +78,13 @@ func NewModel() Model { columnKeyCount: table.NewStyledCell(0, lipgloss.NewStyle().Faint(true)), }), table.NewRow(table.RowData{ - columnKeyID: "2pg", + columnKeyID: "spg", columnKeyName: "Page 2", columnKeyDescription: "Second page", columnKeyCount: 2, }), table.NewRow(table.RowData{ - columnKeyID: "2pg2", + columnKeyID: "spg2", columnKeyName: "Page 2.1", columnKeyDescription: "Second page again", columnKeyCount: 4, @@ -103,7 +107,8 @@ func NewModel() Model { WithKeyMap(keys). WithStaticFooter("Footer!"). WithPageSize(3). - WithSelectedText(" ", "✓"), + WithSelectedText(" ", "✓"). + SortByAsc(columnKeyID), } model.updateFooter() diff --git a/examples/sorting/main.go b/examples/sorting/main.go new file mode 100644 index 0000000..d115455 --- /dev/null +++ b/examples/sorting/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "log" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/evertras/bubble-table/table" +) + +const ( + columnKeyName = "name" + columnKeyType = "type" + columnKeyWins = "element" +) + +type Model struct { + simpleTable table.Model + + columnSortKey string + sortDirection string +} + +func NewModel() Model { + return Model{ + simpleTable: table.New([]table.Column{ + table.NewColumn(columnKeyName, "Name", 13), + table.NewColumn(columnKeyType, "Type", 13), + table.NewColumn(columnKeyWins, "Wins", 5), + }).WithRows([]table.Row{ + table.NewRow(table.RowData{ + columnKeyName: "ピカピカ", + columnKeyType: "Pikachu", + columnKeyWins: 4, + }), + table.NewRow(table.RowData{ + columnKeyName: "Alphonse", + columnKeyType: "Pikachu", + columnKeyWins: 13, + }), + table.NewRow(table.RowData{ + columnKeyName: "Burninator", + columnKeyType: "Charmander", + columnKeyWins: 8, + }), + table.NewRow(table.RowData{ + columnKeyName: "Dihydrogen Monoxide", + columnKeyType: "Squirtle", + columnKeyWins: 31, + }), + }), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + m.simpleTable, cmd = m.simpleTable.Update(msg) + cmds = append(cmds, cmd) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + cmds = append(cmds, tea.Quit) + + case "n": + m.columnSortKey = columnKeyName + m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey) + + case "t": + m.columnSortKey = columnKeyType + // Within the same type, order each by wins + m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey).ThenSortByDesc(columnKeyWins) + + case "w": + m.columnSortKey = columnKeyWins + m.simpleTable = m.simpleTable.SortByDesc(m.columnSortKey) + } + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + body := strings.Builder{} + + body.WriteString("A sorted simple default table\nSort by (n)ame, (t)ype, or (w)ins\nCurrently sorting by: " + m.columnSortKey + "\nPress q or ctrl+c to quit\n\n") + + body.WriteString(m.simpleTable.View()) + + return body.String() +} + +func main() { + p := tea.NewProgram(NewModel()) + + if err := p.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/table/data.go b/table/data.go new file mode 100644 index 0000000..44dc613 --- /dev/null +++ b/table/data.go @@ -0,0 +1,64 @@ +package table + +import "time" + +// This is just a bunch of data type checks, so... no linting here +// nolint: cyclop +func asInt(data interface{}) (int64, bool) { + switch val := data.(type) { + case int: + return int64(val), true + + case int8: + return int64(val), true + + case int16: + return int64(val), true + + case int32: + return int64(val), true + + case int64: + return val, true + + case uint: + return int64(val), true + + case uint8: + return int64(val), true + + case uint16: + return int64(val), true + + case uint32: + return int64(val), true + + case uint64: + return int64(val), true + + case time.Duration: + return int64(val), true + + case StyledCell: + return asInt(val.Data) + } + + return 0, false +} + +func asNumber(data interface{}) (float64, bool) { + switch val := data.(type) { + case float32: + return float64(val), true + + case float64: + return val, true + + case StyledCell: + return asNumber(val.Data) + } + + intVal, isInt := asInt(data) + + return float64(intVal), isInt +} diff --git a/table/data_test.go b/table/data_test.go new file mode 100644 index 0000000..5b78776 --- /dev/null +++ b/table/data_test.go @@ -0,0 +1,43 @@ +package table + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAsInt(t *testing.T) { + check := func(data interface{}, isInt bool, expectedValue int64) { + val, ok := asInt(data) + assert.Equal(t, isInt, ok) + assert.Equal(t, expectedValue, val) + } + + check(3, true, 3) + check(3.3, false, 0) + check(int8(3), true, 3) + check(int16(3), true, 3) + check(int32(3), true, 3) + check(int64(3), true, 3) + check(uint(3), true, 3) + check(uint8(3), true, 3) + check(uint16(3), true, 3) + check(uint32(3), true, 3) + check(uint64(3), true, 3) + check(StyledCell{Data: 3}, true, 3) + check(time.Duration(3), true, 3) +} + +func TestAsNumber(t *testing.T) { + check := func(data interface{}, isFloat bool, expectedValue float64) { + val, ok := asNumber(data) + assert.Equal(t, isFloat, ok) + assert.InDelta(t, expectedValue, val, 0.001) + } + + check(uint32(3), true, 3) + check(3.3, true, 3.3) + check(float32(3.3), true, 3.3) + check(StyledCell{Data: 3.3}, true, 3.3) +} diff --git a/table/model.go b/table/model.go index 17da4b6..b4ea53e 100644 --- a/table/model.go +++ b/table/model.go @@ -40,6 +40,10 @@ type Model struct { pageSize int currentPage int + // Sorting + sortOrder []sortColumn + sortedRows []Row + // Internal cached calculations for reference totalWidth int } diff --git a/table/options.go b/table/options.go index 99e5f0a..455bd7c 100644 --- a/table/options.go +++ b/table/options.go @@ -14,6 +14,7 @@ func (m Model) HeaderStyle(style lipgloss.Style) Model { // WithRows sets the rows to show as data in the table. func (m Model) WithRows(rows []Row) Model { m.rows = rows + m.updateSortedRows() return m } diff --git a/table/row.go b/table/row.go index eb59e13..5c7b31d 100644 --- a/table/row.go +++ b/table/row.go @@ -44,7 +44,7 @@ func (r Row) WithStyle(style lipgloss.Style) Row { // nolint: cyclop func (m Model) renderRow(rowIndex int, last bool) string { numColumns := len(m.columns) - row := m.rows[rowIndex] + row := m.sortedRows[rowIndex] highlighted := rowIndex == m.rowCursorIndex columnStrings := []string{} diff --git a/table/sort.go b/table/sort.go new file mode 100644 index 0000000..4ffebae --- /dev/null +++ b/table/sort.go @@ -0,0 +1,158 @@ +package table + +import ( + "fmt" + "sort" +) + +type sortDirection int + +const ( + sortDirectionAsc sortDirection = iota + sortDirectionDesc +) + +type sortColumn struct { + columnKey string + direction sortDirection +} + +func (m Model) SortByAsc(columnKey string) Model { + m.sortOrder = []sortColumn{ + { + columnKey: columnKey, + direction: sortDirectionAsc, + }, + } + + m.updateSortedRows() + + return m +} + +func (m Model) SortByDesc(columnKey string) Model { + m.sortOrder = []sortColumn{ + { + columnKey: columnKey, + direction: sortDirectionDesc, + }, + } + + m.updateSortedRows() + + return m +} + +func (m Model) ThenSortByAsc(columnKey string) Model { + m.sortOrder = append([]sortColumn{ + { + columnKey: columnKey, + direction: sortDirectionAsc, + }, + }, m.sortOrder...) + + m.updateSortedRows() + + return m +} + +func (m Model) ThenSortByDesc(columnKey string) Model { + m.sortOrder = append([]sortColumn{ + { + columnKey: columnKey, + direction: sortDirectionDesc, + }, + }, m.sortOrder...) + + m.updateSortedRows() + + return m +} + +type sortableTable struct { + rows []Row + byColumn sortColumn +} + +func (s *sortableTable) Len() int { + return len(s.rows) +} + +func (s *sortableTable) Swap(i, j int) { + old := s.rows[i] + s.rows[i] = s.rows[j] + s.rows[j] = old +} + +func (s *sortableTable) extractString(i int, column string) string { + iData, exists := s.rows[i].Data[column] + + if !exists { + return "" + } + + switch iData := iData.(type) { + case StyledCell: + return fmt.Sprintf("%v", iData.Data) + + case string: + return iData + + default: + return fmt.Sprintf("%v", iData) + } +} + +func (s *sortableTable) extractNumber(i int, column string) (float64, bool) { + iData, exists := s.rows[i].Data[column] + + if !exists { + return 0, false + } + + return asNumber(iData) +} + +func (s *sortableTable) Less(first, second int) bool { + firstNum, firstNumIsValid := s.extractNumber(first, s.byColumn.columnKey) + secondNum, secondNumIsValid := s.extractNumber(second, s.byColumn.columnKey) + + if firstNumIsValid && secondNumIsValid { + if s.byColumn.direction == sortDirectionAsc { + return firstNum < secondNum + } + + return firstNum > secondNum + } + + firstVal := s.extractString(first, s.byColumn.columnKey) + secondVal := s.extractString(second, s.byColumn.columnKey) + + if s.byColumn.direction == sortDirectionAsc { + return firstVal < secondVal + } + + return firstVal > secondVal +} + +func (m *Model) updateSortedRows() { + if len(m.sortOrder) == 0 { + m.sortedRows = m.rows + + return + } + + m.sortedRows = make([]Row, len(m.rows)) + copy(m.sortedRows, m.rows) + + for _, byColumn := range m.sortOrder { + sorted := &sortableTable{ + rows: m.sortedRows, + byColumn: byColumn, + } + + sort.Stable(sorted) + + m.sortedRows = sorted.rows + } +} diff --git a/table/sort_test.go b/table/sort_test.go new file mode 100644 index 0000000..3631951 --- /dev/null +++ b/table/sort_test.go @@ -0,0 +1,141 @@ +package table + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestSortSingleColumnAscAndDesc(t *testing.T) { + const idColKey = "id" + + rows := []Row{ + NewRow(RowData{idColKey: "b"}), + NewRow(RowData{idColKey: NewStyledCell("c", lipgloss.NewStyle().Bold(true))}), + NewRow(RowData{idColKey: "a"}), + // Missing data + NewRow(RowData{}), + } + + model := New([]Column{ + NewColumn(idColKey, "ID", 3), + }).WithRows(rows).SortByAsc(idColKey) + + assertOrder := func(expectedList []string) { + for index, expected := range expectedList { + idVal, ok := model.sortedRows[index].Data[idColKey] + + if expected != "" { + assert.True(t, ok) + } else { + assert.False(t, ok) + + continue + } + + switch idVal := idVal.(type) { + case string: + assert.Equal(t, expected, idVal) + + case StyledCell: + assert.Equal(t, expected, idVal.Data) + + default: + assert.Fail(t, "Unknown type") + } + } + } + + assert.Len(t, model.sortedRows, len(rows)) + assertOrder([]string{"", "a", "b", "c"}) + + model = model.SortByDesc(idColKey) + + assertOrder([]string{"c", "b", "a", ""}) +} + +func TestSortSingleColumnIntsAsc(t *testing.T) { + const idColKey = "id" + + rows := []Row{ + NewRow(RowData{idColKey: 13}), + NewRow(RowData{idColKey: NewStyledCell(1, lipgloss.NewStyle().Bold(true))}), + NewRow(RowData{idColKey: 2}), + } + + model := New([]Column{ + NewColumn(idColKey, "ID", 3), + }).WithRows(rows).SortByAsc(idColKey) + + assertOrder := func(expectedList []int) { + for index, expected := range expectedList { + idVal, ok := model.sortedRows[index].Data[idColKey] + + assert.True(t, ok) + + switch idVal := idVal.(type) { + case int: + assert.Equal(t, expected, idVal) + + case StyledCell: + assert.Equal(t, expected, idVal.Data) + + default: + assert.Fail(t, "Unknown type") + } + } + } + + assert.Len(t, model.sortedRows, len(rows)) + assertOrder([]int{1, 2, 13}) +} + +func TestSortTwoColumnsAscDescMix(t *testing.T) { + const ( + nameKey = "name" + scoreKey = "score" + ) + + makeRow := func(name string, score int) Row { + return NewRow(RowData{ + nameKey: name, + scoreKey: score, + }) + } + + model := New([]Column{ + NewColumn(nameKey, "Name", 8), + NewColumn(scoreKey, "Score", 8), + }).WithRows([]Row{ + makeRow("c", 50), + makeRow("a", 75), + makeRow("b", 101), + makeRow("a", 100), + }).SortByAsc(nameKey).ThenSortByDesc(scoreKey) + + assertVals := func(index int, name string, score int) { + actualName, ok := model.sortedRows[index].Data[nameKey].(string) + assert.True(t, ok) + + actualScore, ok := model.sortedRows[index].Data[scoreKey].(int) + assert.True(t, ok) + + assert.Equal(t, name, actualName) + assert.Equal(t, score, actualScore) + } + + assert.Len(t, model.sortedRows, 4) + + assertVals(0, "a", 100) + assertVals(1, "a", 75) + assertVals(2, "b", 101) + assertVals(3, "c", 50) + + model = model.SortByDesc(nameKey).ThenSortByAsc(scoreKey) + + assertVals(0, "c", 50) + assertVals(1, "b", 101) + assertVals(2, "a", 75) + assertVals(3, "a", 100) +} diff --git a/table/view_test.go b/table/view_test.go index 3f71af8..1aff0b7 100644 --- a/table/view_test.go +++ b/table/view_test.go @@ -101,6 +101,26 @@ func TestSingleColumnView(t *testing.T) { assert.Equal(t, expectedTable, rendered) } +func TestSingleColumnViewSorted(t *testing.T) { + model := New([]Column{ + NewColumn("id", "ID", 4), + }).WithRows([]Row{ + NewRow(RowData{"id": "1"}), + NewRow(RowData{"id": "2"}), + }).SortByDesc("id") + + const expectedTable = `┏━━━━┓ +┃ ID┃ +┣━━━━┫ +┃ 2┃ +┃ 1┃ +┗━━━━┛` + + rendered := model.View() + + assert.Equal(t, expectedTable, rendered) +} + func TestSingleRowView(t *testing.T) { model := New([]Column{ NewColumn("1", "1", 4),