Merge branch 'master' into v2-exp-table-tests

This commit is contained in:
Andrey Nering 2025-05-16 14:23:52 -03:00
commit dffbe595a1
21 changed files with 884 additions and 43 deletions

View File

@ -37,5 +37,5 @@ updates:
labels:
- "dependencies"
commit-message:
prefix: "feat"
prefix: "chore"
include: "scope"

View File

@ -30,10 +30,6 @@ linters:
generated: lax
presets:
- common-false-positives
paths:
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
@ -43,7 +39,3 @@ formatters:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@ -10,11 +10,9 @@
[![Go ReportCard](https://goreportcard.com/badge/charmbracelet/bubbles)](https://goreportcard.com/report/charmbracelet/bubbles)
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea)
applications. These components are used in production in [Glow][glow],
[Charm][charm] and [many other applications][otherstuff].
applications. These components are used in production in [Glow][glow], and [many other applications][otherstuff].
[glow]: https://github.com/charmbracelet/glow
[charm]: https://github.com/charmbracelet/charm
[otherstuff]: https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild
## Spinner

View File

@ -184,11 +184,13 @@ func (m *Model) BlinkCmd() tea.Cmd {
m.blinkTag++
blinkMsg := BlinkMsg{id: m.id, tag: m.blinkTag}
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return BlinkMsg{id: m.id, tag: m.blinkTag}
return blinkMsg
}
return blinkCanceled{}
}

50
cursor/cursor_test.go Normal file
View File

@ -0,0 +1,50 @@
package cursor
import (
"sync"
"testing"
"time"
)
// TestBlinkCmdDataRace tests for a race on [Cursor.blinkTag].
//
// The original [Model.BlinkCmd] implementation returned a closure over the pointer receiver:
//
// return func() tea.Msg {
// defer cancel()
// <-ctx.Done()
// if ctx.Err() == context.DeadlineExceeded {
// return BlinkMsg{id: m.id, tag: m.blinkTag}
// }
// return blinkCanceled{}
// }
//
// A race on “m.blinkTag” will occur if:
// 1. [Model.BlinkCmd] is called e.g. by calling [Model.Focus] from
// ["github.com/charmbracelet/bubbletea".Model.Update];
// 2. ["github.com/charmbracelet/bubbletea".handleCommands] is kept sufficiently busy that it does not recieve and
// execute the [Model.BlinkCmd] e.g. by other long running command or commands;
// 3. at least [Mode.BlinkSpeed] time elapses;
// 4. [Model.BlinkCmd] is called again;
// 5. ["github.com/charmbracelet/bubbletea".handleCommands] gets around to receiving and executing the original
// closure.
//
// Even if this did not formally race, the value of the tag fetched would be semantically incorrect (likely being the
// current value rather than the value at the time the closure was created).
func TestBlinkCmdDataRace(t *testing.T) {
m := New()
cmd := m.BlinkCmd()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
time.Sleep(m.BlinkSpeed * 3)
cmd()
}()
go func() {
defer wg.Done()
time.Sleep(m.BlinkSpeed * 2)
m.BlinkCmd()
}()
wg.Wait()
}

View File

@ -691,6 +691,7 @@ func (m *Model) setSize(width, height int) {
m.Help.Width = width
m.FilterInput.SetWidth(width - promptWidth - lipgloss.Width(m.spinnerView()))
m.updatePagination()
m.updateKeybindings()
}
func (m *Model) resetFiltering() {

View File

@ -305,6 +305,11 @@ func (m Model) Columns() []Column {
// SetRows sets a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
if m.cursor > len(m.rows)-1 {
m.cursor = len(m.rows) - 1
}
m.UpdateViewport()
}

View File

@ -1,15 +1,208 @@
package table
import (
"reflect"
"strings"
"testing"
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/viewport"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/golden"
)
func TestFromValues(t *testing.T) {
var testCols = []Column{
{Title: "col1", Width: 10},
{Title: "col2", Width: 10},
{Title: "col3", Width: 10},
}
func TestNew(t *testing.T) {
tests := map[string]struct {
opts []Option
want Model
}{
"Default": {
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
},
},
"WithColumns": {
opts: []Option{
WithColumns([]Column{
{Title: "Foo", Width: 1},
{Title: "Bar", Width: 2},
}),
},
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
cols: []Column{
{Title: "Foo", Width: 1},
{Title: "Bar", Width: 2},
},
},
},
"WithColumns; WithRows": {
opts: []Option{
WithColumns([]Column{
{Title: "Foo", Width: 1},
{Title: "Bar", Width: 2},
}),
WithRows([]Row{
{"1", "Foo"},
{"2", "Bar"},
}),
},
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
cols: []Column{
{Title: "Foo", Width: 1},
{Title: "Bar", Width: 2},
},
rows: []Row{
{"1", "Foo"},
{"2", "Bar"},
},
},
},
"WithHeight": {
opts: []Option{
WithHeight(10),
},
want: Model{
// Default fields
cursor: 0,
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
// Viewport height is 1 less than the provided height when no header is present since lipgloss.Height adds 1
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(9),
),
},
},
"WithWidth": {
opts: []Option{
WithWidth(10),
},
want: Model{
// Default fields
cursor: 0,
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
// Viewport height is 1 less than the provided height when no header is present since lipgloss.Height adds 1
viewport: viewport.New(
viewport.WithWidth(10),
viewport.WithHeight(20),
),
},
},
"WithFocused": {
opts: []Option{
WithFocused(true),
},
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
focus: true,
},
},
"WithStyles": {
opts: []Option{
WithStyles(Styles{}),
},
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
KeyMap: DefaultKeyMap(),
Help: help.New(),
// Modified fields
styles: Styles{},
},
},
"WithKeyMap": {
opts: []Option{
WithKeyMap(KeyMap{}),
},
want: Model{
// Default fields
cursor: 0,
viewport: viewport.New(
viewport.WithWidth(0),
viewport.WithHeight(20),
),
Help: help.New(),
styles: DefaultStyles(),
// Modified fields
KeyMap: KeyMap{},
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
tc.want.UpdateViewport()
got := New(tc.opts...)
if !reflect.DeepEqual(tc.want, got) {
t.Errorf("\n\nwant %v\n\ngot %v", tc.want, got)
}
})
}
}
func TestModel_FromValues(t *testing.T) {
input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, ",")
@ -23,12 +216,12 @@ func TestFromValues(t *testing.T) {
{"foo2", "bar2"},
{"foo3", "bar3"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
if !reflect.DeepEqual(table.rows, expect) {
t.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows)
}
}
func TestFromValuesWithTabSeparator(t *testing.T) {
func TestModel_FromValues_WithTabSeparator(t *testing.T) {
input := "foo1.\tbar1\nfoo,bar,baz\tbar,2"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, "\t")
@ -41,32 +234,12 @@ func TestFromValuesWithTabSeparator(t *testing.T) {
{"foo1.", "bar1"},
{"foo,bar,baz", "bar,2"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
if !reflect.DeepEqual(table.rows, expect) {
t.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows)
}
}
func deepEqual(a, b []Row) bool {
if len(a) != len(b) {
return false
}
for i, r := range a {
for j, f := range r {
if f != b[i][j] {
return false
}
}
}
return true
}
var cols = []Column{
{Title: "col1", Width: 10},
{Title: "col2", Width: 10},
{Title: "col3", Width: 10},
}
func TestRenderRow(t *testing.T) {
func TestModel_RenderRow(t *testing.T) {
tests := []struct {
name string
table *Model
@ -76,7 +249,7 @@ func TestRenderRow(t *testing.T) {
name: "simple row",
table: &Model{
rows: []Row{{"Foooooo", "Baaaaar", "Baaaaaz"}},
cols: cols,
cols: testCols,
styles: Styles{Cell: lipgloss.NewStyle()},
},
expected: "Foooooo Baaaaar Baaaaaz ",
@ -85,7 +258,7 @@ func TestRenderRow(t *testing.T) {
name: "simple row with truncations",
table: &Model{
rows: []Row{{"Foooooooooo", "Baaaaaaaaar", "Quuuuuuuuux"}},
cols: cols,
cols: testCols,
styles: Styles{Cell: lipgloss.NewStyle()},
},
expected: "Foooooooo…Baaaaaaaa…Quuuuuuuu…",
@ -94,7 +267,7 @@ func TestRenderRow(t *testing.T) {
name: "simple row avoiding truncations",
table: &Model{
rows: []Row{{"Fooooooooo", "Baaaaaaaar", "Quuuuuuuux"}},
cols: cols,
cols: testCols,
styles: Styles{Cell: lipgloss.NewStyle()},
},
expected: "FoooooooooBaaaaaaaarQuuuuuuuux",
@ -164,3 +337,432 @@ func ansiStrip(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return ansi.Strip(s)
}
func TestCursorNavigation(t *testing.T) {
tests := map[string]struct {
rows []Row
action func(*Model)
want int
}{
"New": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
},
action: func(_ *Model) {},
want: 0,
},
"MoveDown": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.MoveDown(2)
},
want: 2,
},
"MoveUp": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.cursor = 3
t.MoveUp(2)
},
want: 1,
},
"GotoBottom": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.GotoBottom()
},
want: 3,
},
"GotoTop": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.cursor = 3
t.GotoTop()
},
want: 0,
},
"SetCursor": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.SetCursor(2)
},
want: 2,
},
"MoveDown with overflow": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.MoveDown(5)
},
want: 3,
},
"MoveUp with overflow": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.cursor = 3
t.MoveUp(5)
},
want: 0,
},
"Blur does not stop movement": {
rows: []Row{
{"r1"},
{"r2"},
{"r3"},
{"r4"},
},
action: func(t *Model) {
t.Blur()
t.MoveDown(2)
},
want: 2,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
table := New(WithColumns(testCols), WithRows(tc.rows))
tc.action(&table)
if table.Cursor() != tc.want {
t.Errorf("want %d, got %d", tc.want, table.Cursor())
}
})
}
}
func TestModel_SetRows(t *testing.T) {
table := New(WithColumns(testCols))
if len(table.rows) != 0 {
t.Fatalf("want 0, got %d", len(table.rows))
}
table.SetRows([]Row{{"r1"}, {"r2"}})
if len(table.rows) != 2 {
t.Fatalf("want 2, got %d", len(table.rows))
}
want := []Row{{"r1"}, {"r2"}}
if !reflect.DeepEqual(table.rows, want) {
t.Fatalf("\n\nwant %v\n\ngot %v", want, table.rows)
}
}
func TestModel_SetColumns(t *testing.T) {
table := New()
if len(table.cols) != 0 {
t.Fatalf("want 0, got %d", len(table.cols))
}
table.SetColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})
if len(table.cols) != 2 {
t.Fatalf("want 2, got %d", len(table.cols))
}
want := []Column{{Title: "Foo"}, {Title: "Bar"}}
if !reflect.DeepEqual(table.cols, want) {
t.Fatalf("\n\nwant %v\n\ngot %v", want, table.cols)
}
}
func TestModel_View(t *testing.T) {
tests := map[string]struct {
modelFunc func() Model
skip bool
}{
// TODO(?): should the view/output of empty tables use the same default height? (this has height 21)
"Empty": {
modelFunc: func() Model {
return New()
},
},
"Single row and column": {
modelFunc: func() Model {
return New(
WithColumns([]Column{
{Title: "Name", Width: 25},
}),
WithRows([]Row{
{"Chocolate Digestives"},
}),
)
},
},
"Multiple rows and columns": {
modelFunc: func() Model {
return 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"},
}),
)
},
},
// TODO(fix): since the table height is tied to the viewport height, adding vertical padding to the headers' height directly increases the table height.
"Extra padding": {
modelFunc: func() Model {
s := DefaultStyles()
s.Header = lipgloss.NewStyle().Padding(2, 2)
s.Cell = lipgloss.NewStyle().Padding(2, 2)
return New(
WithHeight(10),
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"},
}),
WithStyles(s),
)
},
},
"No padding": {
modelFunc: func() Model {
s := DefaultStyles()
s.Header = lipgloss.NewStyle()
s.Cell = lipgloss.NewStyle()
return New(
WithHeight(10),
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"},
}),
WithStyles(s),
)
},
},
// TODO(?): the total height is modified with borderd headers, however not with bordered cells. Is this expected/desired?
"Bordered headers": {
modelFunc: func() Model {
return 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"},
}),
WithStyles(Styles{
Header: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()),
}),
)
},
},
// TODO(fix): Headers are not horizontally aligned with cells due to the border adding width to the cells.
"Bordered cells": {
modelFunc: func() Model {
return 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"},
}),
WithStyles(Styles{
Cell: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()),
}),
)
},
},
"Manual height greater than rows": {
modelFunc: func() Model {
return New(
WithHeight(6),
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"},
}),
)
},
},
"Manual height less than rows": {
modelFunc: func() Model {
return New(
WithHeight(2),
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"},
}),
)
},
},
// TODO(fix): spaces are added to the right of the viewport to fill the width, but the headers end as though they are not aware of the width.
"Manual width greater than columns": {
modelFunc: func() Model {
return New(
WithWidth(80),
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"},
}),
)
},
},
// TODO(fix): Setting the table width does not affect the total headers' width. Cells are wrapped.
// Headers are not affected. Truncation/resizing should match lipgloss.table functionality.
"Manual width less than columns": {
modelFunc: func() Model {
return New(
WithWidth(30),
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"},
}),
)
},
skip: true,
},
"Modified viewport height": {
modelFunc: func() Model {
m := 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"},
}),
)
m.viewport.SetHeight(2)
return m
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if tc.skip {
t.Skip()
}
table := tc.modelFunc()
got := ansi.Strip(table.View())
golden.RequireEqual(t, []byte(got))
})
}
}
// TODO: Fix table to make this test will pass.
func TestModel_View_CenteredInABox(t *testing.T) {
t.Skip()
boxStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
Align(lipgloss.Center)
table := New(
WithHeight(6),
WithWidth(80),
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"},
}),
)
tableView := ansi.Strip(table.View())
got := boxStyle.Render(tableView)
golden.RequireEqual(t, []byte(got))
}

View File

@ -0,0 +1,21 @@
Name Country of Orig…Dunk-able
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Chocolate Digestives ││UK ││Yes │
└─────────────────────────┘└────────────────┘└────────────┘
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Tim Tams ││Australia ││No │
└─────────────────────────┘└────────────────┘└────────────┘
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Hobnobs ││UK ││Yes │
└─────────────────────────┘└────────────────┘└────────────┘

View File

@ -0,0 +1,23 @@
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Name ││Country of Orig…││Dunk-able │
└─────────────────────────┘└────────────────┘└────────────┘
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,20 @@

View File

@ -0,0 +1,14 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No

View File

@ -0,0 +1,6 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,2 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes

View File

@ -0,0 +1,21 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,21 @@
Name Country of Origin Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,3 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No

View File

@ -0,0 +1,21 @@
Name Country of Orig… Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,10 @@
Name Country of Orig…Dunk-able
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes

View File

@ -0,0 +1,21 @@
Name
Chocolate Digestives

View File

@ -0,0 +1,8 @@
┌────────────────────────────────────────────────────────────────────────────────┐
│ Name Country of Orig… Dunk-able │
│ Chocolate Digestives UK Yes │
│ Tim Tams Australia No │
│ Hobnobs UK Yes │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────┘