diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cbc9aa4..63ed01f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,5 +37,5 @@ updates: labels: - "dependencies" commit-message: - prefix: "feat" + prefix: "chore" include: "scope" diff --git a/.golangci.yml b/.golangci.yml index 7c0a115..be61d89 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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$ diff --git a/README.md b/README.md index 07eb177..1ea295c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Taskfile.yaml b/Taskfile.yaml index 3507203..e53ff53 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,6 +1,6 @@ # https://taskfile.dev -version: '3' +version: "3" tasks: lint: @@ -12,3 +12,8 @@ tasks: desc: Run tests cmds: - go test ./... {{.CLI_ARGS}} + + test:table: + desc: Run tests with table test + cmds: + - go test ./table {{.CLI_ARGS}} diff --git a/cursor/cursor.go b/cursor/cursor.go index 824b450..7e168fd 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -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{} } diff --git a/cursor/cursor_test.go b/cursor/cursor_test.go new file mode 100644 index 0000000..c526c4a --- /dev/null +++ b/cursor/cursor_test.go @@ -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() +} diff --git a/go.mod b/go.mod index 2f022a5..0069833 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250516180252-2c4751e06ce4 github.com/charmbracelet/x/ansi v0.8.0 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a github.com/dustin/go-humanize v1.0.1 @@ -19,7 +19,7 @@ require ( require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -28,5 +28,5 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 5d01a66..676e209 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,12 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 h1:RvpXiXuPAuaKCHPCsE/lK5+zztnNDTSCa0CpeeIKdDU= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc= -github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= -github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 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/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250516180252-2c4751e06ce4 h1:7UOIuPdCkW6TEElQT52ACjBs51yJMM6KxvSjhnGVO/M= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250516180252-2c4751e06ce4/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= @@ -45,5 +45,5 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/list/list.go b/list/list.go index 9ce24bc..22c532f 100644 --- a/list/list.go +++ b/list/list.go @@ -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() { diff --git a/table/table_test.go b/table/table_test.go index bd1ebdd..d0e1c26 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -3,39 +3,41 @@ package table import ( "fmt" "image/color" + "reflect" "testing" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/table" + "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) -// Reusable inputs - -var niceMargins = lipgloss.NewStyle().Padding(0, 1) -var headers = []string{"Rank", "City", "Country", "Population"} -var rows = [][]string{ - {"1", "Tokyo", "Japan", "37,274,000"}, - {"2", "Delhi", "India", "32,065,760"}, - {"3", "Shanghai", "China", "28,516,904"}, - {"4", "Dhaka", "Bangladesh", "22,478,116"}, - {"5", "São Paulo", "Brazil", "22,429,800"}, - {"6", "Mexico City", "Mexico", "22,085,140"}, - {"7", "Cairo", "Egypt", "21,750,020"}, - {"8", "Beijing", "China", "21,333,332"}, - {"9", "Mumbai", "India", "20,961,472"}, - {"10", "Osaka", "Japan", "19,059,856"}, - {"11", "Chongqing", "China", "16,874,740"}, - {"12", "Karachi", "Pakistan", "16,839,950"}, - {"13", "Istanbul", "Turkey", "15,636,243"}, - {"14", "Kinshasa", "DR Congo", "15,628,085"}, - {"15", "Lagos", "Nigeria", "15,387,639"}, - {"16", "Buenos Aires", "Argentina", "15,369,919"}, -} +var ( + niceMargins = lipgloss.NewStyle().Padding(0, 1) + headers = []string{"Rank", "City", "Country", "Population"} + rows = [][]string{ + {"1", "Tokyo", "Japan", "37,274,000"}, + {"2", "Delhi", "India", "32,065,760"}, + {"3", "Shanghai", "China", "28,516,904"}, + {"4", "Dhaka", "Bangladesh", "22,478,116"}, + {"5", "São Paulo", "Brazil", "22,429,800"}, + {"6", "Mexico City", "Mexico", "22,085,140"}, + {"7", "Cairo", "Egypt", "21,750,020"}, + {"8", "Beijing", "China", "21,333,332"}, + {"9", "Mumbai", "India", "20,961,472"}, + {"10", "Osaka", "Japan", "19,059,856"}, + {"11", "Chongqing", "China", "16,874,740"}, + {"12", "Karachi", "Pakistan", "16,839,950"}, + {"13", "Istanbul", "Turkey", "15,636,243"}, + {"14", "Kinshasa", "DR Congo", "15,628,085"}, + {"15", "Lagos", "Nigeria", "15,387,639"}, + {"16", "Buenos Aires", "Argentina", "15,369,919"}, + } +) // Tests -func TestNew(t *testing.T) { +func TestNewBash(t *testing.T) { headers := []string{"Rank", "City", "Country", "Population"} rows := [][]string{ {"1", "Tokyo", "Japan", "37,274,000"}, @@ -63,6 +65,63 @@ func TestNew(t *testing.T) { }) } +func TestModel_FromValues(t *testing.T) { + table := New( + WithHeaders("Foo", "Bar"), + WithRows( + []string{"foo1", "bar1"}, + []string{"foo2", "bar2"}, + []string{"foo3", "bar3"}, + )) + + 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.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows) + } +} + +func TestModel_FromValues_WithTabSeparator(t *testing.T) { + table := New( + WithHeaders("Foo", "Bar"), + WithRows( + []string{"foo1.", "bar1"}, + []string{"foo,bar,baz", "bar,2"}, + ), + ) + + if len(table.rows) != 2 { + t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows)) + } + + expect := [][]string{ + {"foo1.", "bar1"}, + {"foo,bar,baz", "bar,2"}, + } + if !reflect.DeepEqual(table.rows, expect) { + t.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows) + } + t.Run("new with options", func(t *testing.T) { + tb := New( + WithHeaders(headers...), + WithRows(rows...), + WithHeight(10), + ) + tb.View() + }) + t.Run("new, no options", func(t *testing.T) { + tb := New().SetHeaders(headers...).SetRows(rows...) + tb.View() + }) +} + func TestTableAlignment(t *testing.T) { headers := []string{ "Name", @@ -109,7 +168,6 @@ func TestOverwriteStyles(t *testing.T) { name string styles Styles }{ - {"clear styles", Styles{ Selected: lipgloss.NewStyle(), Header: lipgloss.NewStyle(), @@ -121,7 +179,6 @@ func TestOverwriteStyles(t *testing.T) { Cell: niceMargins, }}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tb := New( @@ -136,6 +193,51 @@ func TestOverwriteStyles(t *testing.T) { } } +func TestModel_RenderRow(t *testing.T) { + tests := []struct { + name string + table *Model + expected string + }{ + { + name: "simple row", + table: New( + WithRows([]string{"Foooooo", "Baaaaar", "Baaaaaz"}), + WithHeaders("col1", "col2", "col3"), + WithStyles(Styles{Cell: lipgloss.NewStyle()}), + ), + expected: "Foooooo Baaaaar Baaaaaz ", + }, + { + name: "simple row with truncations", + table: New( + WithRows([]string{"Foooooooooo", "Baaaaaaaaar", "Quuuuuuuuux"}), + WithHeaders("col1", "col2", "col3"), + WithStyles(Styles{Cell: lipgloss.NewStyle()}), + ), + expected: "Foooooooo…Baaaaaaaa…Quuuuuuuu…", + }, + { + name: "simple row avoiding truncations", + table: New( + WithRows([]string{"Fooooooooo", "Baaaaaaaar", "Quuuuuuuux"}), + WithHeaders("col1", "col2", "col3"), + WithStyles(Styles{Cell: lipgloss.NewStyle()}), + ), + expected: "FoooooooooBaaaaaaaarQuuuuuuuux", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := tc.table.table.Render() + if actual != tc.expected { + t.Fatalf("\n\nWant: \n%s\n\nGot: \n%s\n", tc.expected, actual) + } + }) + } +} + func TestSetStyles(t *testing.T) { tests := []struct { name string @@ -338,9 +440,9 @@ func TestOverwriteStylesFromLipgloss(t *testing.T) { // Examples func ExampleOption() { - var niceMargins = lipgloss.NewStyle().Padding(0, 1) - var headers = []string{"Rank", "City", "Country", "Population"} - var rows = [][]string{ + niceMargins := lipgloss.NewStyle().Padding(0, 1) + headers := []string{"Rank", "City", "Country", "Population"} + rows := [][]string{ {"1", "Tokyo", "Japan", "37,274,000"}, {"2", "Delhi", "India", "32,065,760"}, {"3", "Shanghai", "China", "28,516,904"}, @@ -377,9 +479,9 @@ func ExampleOption() { } func ExampleModel_SetRows() { - var niceMargins = lipgloss.NewStyle().Padding(0, 1) - var headers = []string{"Rank", "City", "Country", "Population"} - var rows = [][]string{ + niceMargins := lipgloss.NewStyle().Padding(0, 1) + headers := []string{"Rank", "City", "Country", "Population"} + rows := [][]string{ {"1", "Tokyo", "Japan", "37,274,000"}, {"2", "Delhi", "India", "32,065,760"}, {"3", "Shanghai", "China", "28,516,904"}, @@ -413,3 +515,387 @@ func ExampleModel_SetRows() { //│ …   …   …   …  │ //└───────────────────────────────────────────┘ } + +func TestCursorNavigation(t *testing.T) { + tests := map[string]struct { + rows [][]string + action func(*Model) + want int + }{ + "New": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + }, + action: func(_ *Model) {}, + want: 0, + }, + "MoveDown": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.MoveDown(2) + }, + want: 2, + }, + "MoveUp": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.MoveUp(2) + }, + want: 1, + }, + "GotoBottom": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.GotoBottom() + }, + want: 3, + }, + "GotoTop": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.GotoTop() + }, + want: 0, + }, + "SetCursor": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.SetCursor(2) + }, + want: 2, + }, + "MoveDown with overflow": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.MoveDown(5) + }, + want: 3, + }, + "MoveUp with overflow": { + rows: [][]string{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.MoveUp(5) + }, + want: 0, + }, + "Blur does not stop movement": { + rows: [][]string{ + {"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( + WithHeaders("col1", "col2", "col3"), + 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(WithHeaders("col1", "col2", "col3")) + + if len(table.rows) != 0 { + t.Fatalf("want 0, got %d", len(table.rows)) + } + + table.SetRows([]string{"r1", "r2"}) + + if len(table.rows) != 2 { + t.Fatalf("want 2, got %d", len(table.rows)) + } + + want := []string{"r1", "r2"} + if !reflect.DeepEqual(table.rows, want) { + t.Fatalf("\n\nwant %v\n\ngot %v", want, table.rows) + } +} + +func TestModel_SetHeaders(t *testing.T) { + table := New() + + if len(table.headers) != 0 { + t.Fatalf("want 0, got %d", len(table.headers)) + } + + table.SetHeaders("Foo", "Bar") + + if len(table.headers) != 2 { + t.Fatalf("want 2, got %d", len(table.headers)) + } + + want := []string{"Foo", "Bar"} + if !reflect.DeepEqual(table.headers, want) { + t.Fatalf("\n\nwant %v\n\ngot %v", want, table.headers) + } +} + +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( + WithHeaders("Name"), + WithRows( + []string{"Chocolate Digestives"}, + ), + ) + }, + }, + "Multiple rows and columns": { + modelFunc: func() *Model { + return New( + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"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), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + WithStyles(s), + ) + }, + }, + "No padding": { + modelFunc: func() *Model { + s := DefaultStyles() + s.Header = lipgloss.NewStyle() + s.Cell = lipgloss.NewStyle() + + return New( + WithHeight(10), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"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( + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"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( + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + WithStyles(Styles{ + Cell: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()), + }), + ) + }, + }, + "Manual height greater than rows": { + modelFunc: func() *Model { + return New( + WithHeight(6), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + ) + }, + }, + "Manual height less than rows": { + modelFunc: func() *Model { + return New( + WithHeight(2), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"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), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"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), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + ) + }, + skip: true, + }, + "Modified viewport height": { + modelFunc: func() *Model { + m := New( + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + ) + + 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), + WithHeaders("Name", "Country of Origin", "Dunk-able"), + WithRows( + []string{"Chocolate Digestives", "UK", "Yes"}, + []string{"Tim Tams", "Australia", "No"}, + []string{"Hobnobs", "UK", "Yes"}, + ), + ) + + tableView := ansi.Strip(table.View()) + got := boxStyle.Render(tableView) + + golden.RequireEqual(t, []byte(got)) +} diff --git a/table/testdata/TestModel_View/Bordered_cells.golden b/table/testdata/TestModel_View/Bordered_cells.golden new file mode 100644 index 0000000..71e9598 --- /dev/null +++ b/table/testdata/TestModel_View/Bordered_cells.golden @@ -0,0 +1,21 @@ +Name Country of Orig…Dunk-able +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Chocolate Digestives ││UK ││Yes │ +└─────────────────────────┘└────────────────┘└────────────┘ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Tim Tams ││Australia ││No │ +└─────────────────────────┘└────────────────┘└────────────┘ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Hobnobs ││UK ││Yes │ +└─────────────────────────┘└────────────────┘└────────────┘ + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Bordered_headers.golden b/table/testdata/TestModel_View/Bordered_headers.golden new file mode 100644 index 0000000..0e260ba --- /dev/null +++ b/table/testdata/TestModel_View/Bordered_headers.golden @@ -0,0 +1,23 @@ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Name ││Country of Orig…││Dunk-able │ +└─────────────────────────┘└────────────────┘└────────────┘ +Chocolate Digestives UK Yes +Tim Tams Australia No +Hobnobs UK Yes + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Empty.golden b/table/testdata/TestModel_View/Empty.golden new file mode 100644 index 0000000..7b05080 --- /dev/null +++ b/table/testdata/TestModel_View/Empty.golden @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/table/testdata/TestModel_View/Extra_padding.golden b/table/testdata/TestModel_View/Extra_padding.golden new file mode 100644 index 0000000..d6f6b76 --- /dev/null +++ b/table/testdata/TestModel_View/Extra_padding.golden @@ -0,0 +1,14 @@ + + +  Name     Country of Orig…    Dunk-able    + + + + +  Chocolate Digestives     UK     Yes    + + + + +  Tim Tams     Australia     No    + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden b/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden new file mode 100644 index 0000000..f89de1d --- /dev/null +++ b/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden @@ -0,0 +1,6 @@ + Name   Country of Orig…  Dunk-able   + Chocolate Digestives   UK   Yes   + Tim Tams   Australia   No   + Hobnobs   UK   Yes   + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_height_less_than_rows.golden b/table/testdata/TestModel_View/Manual_height_less_than_rows.golden new file mode 100644 index 0000000..83bded1 --- /dev/null +++ b/table/testdata/TestModel_View/Manual_height_less_than_rows.golden @@ -0,0 +1,2 @@ + Name   Country of Orig…  Dunk-able   + Chocolate Digestives   UK   Yes   \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden b/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden new file mode 100644 index 0000000..a1c06fe --- /dev/null +++ b/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden @@ -0,0 +1,21 @@ + Name   Country of Orig…  Dunk-able   + Chocolate Digestives   UK   Yes   + Tim Tams   Australia   No   + Hobnobs   UK   Yes   + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_width_less_than_columns.golden b/table/testdata/TestModel_View/Manual_width_less_than_columns.golden new file mode 100644 index 0000000..d2bc25b --- /dev/null +++ b/table/testdata/TestModel_View/Manual_width_less_than_columns.golden @@ -0,0 +1,21 @@ + 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/TestModel_View/Modified_viewport_height.golden b/table/testdata/TestModel_View/Modified_viewport_height.golden new file mode 100644 index 0000000..98a44ee --- /dev/null +++ b/table/testdata/TestModel_View/Modified_viewport_height.golden @@ -0,0 +1,3 @@ + Name   Country of Orig…  Dunk-able   + Chocolate Digestives   UK   Yes   + Tim Tams   Australia   No   \ No newline at end of file diff --git a/table/testdata/TestModel_View/Multiple_rows_and_columns.golden b/table/testdata/TestModel_View/Multiple_rows_and_columns.golden new file mode 100644 index 0000000..d1dffc5 --- /dev/null +++ b/table/testdata/TestModel_View/Multiple_rows_and_columns.golden @@ -0,0 +1,21 @@ + Name   Country of Orig…  Dunk-able   + Chocolate Digestives   UK   Yes   + Tim Tams   Australia   No   + Hobnobs   UK   Yes   + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/No_padding.golden b/table/testdata/TestModel_View/No_padding.golden new file mode 100644 index 0000000..f748746 --- /dev/null +++ b/table/testdata/TestModel_View/No_padding.golden @@ -0,0 +1,10 @@ +Name Country of Orig…Dunk-able +Chocolate Digestives UK Yes +Tim Tams Australia No +Hobnobs UK Yes + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Single_row_and_column.golden b/table/testdata/TestModel_View/Single_row_and_column.golden new file mode 100644 index 0000000..36d5110 --- /dev/null +++ b/table/testdata/TestModel_View/Single_row_and_column.golden @@ -0,0 +1,21 @@ + Name   + Chocolate Digestives   + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View_CenteredInABox.golden b/table/testdata/TestModel_View_CenteredInABox.golden new file mode 100644 index 0000000..59d9c70 --- /dev/null +++ b/table/testdata/TestModel_View_CenteredInABox.golden @@ -0,0 +1,8 @@ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ Name Country of Orig… Dunk-able │ +│ Chocolate Digestives UK Yes │ +│ Tim Tams Australia No │ +│ Hobnobs UK Yes │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/textarea/textarea.go b/textarea/textarea.go index 26d3eef..0db907c 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -743,7 +743,7 @@ func (m *Model) deleteWordLeft() { // Linter note: it's critical that we acquire the initial cursor position // here prior to altering it via SetCursor() below. As such, moving this // call into the corresponding if clause does not apply here. - oldCol := m.col //nolint:ifshort + oldCol := m.col m.SetCursorColumn(m.col - 1) for unicode.IsSpace(m.value[m.row][m.col]) { @@ -1383,14 +1383,13 @@ func (m Model) placeholderView() string { case i == 0: // first character of first line as cursor with character m.virtualCursor.TextStyle = styles.computedPlaceholder() - m.virtualCursor.SetChar(string(plines[0][0])) + + ch, rest, _, _ := uniseg.FirstGraphemeClusterInString(plines[0], 0) + m.virtualCursor.SetChar(ch) s.WriteString(lineStyle.Render(m.virtualCursor.View())) // the rest of the first line - placeholderTail := plines[0][1:] - gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))) - renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap) - s.WriteString(lineStyle.Render(renderedPlaceholder)) + s.WriteString(lineStyle.Render(styles.computedPlaceholder().Render(rest))) // remaining lines case len(plines) > i: // current line placeholder text diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index 774067b..d1ccad5 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -1671,6 +1671,26 @@ func TestView(t *testing.T) { `), }, }, + { + name: "placeholder chinese character", + modelFunc: func(m Model) Model { + m.Placeholder = "输入消息..." + m.ShowLineNumbers = true + m.SetWidth(20) + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 输入消息... + > + > + > + > + > + + `), + }, + }, } for _, tt := range tests { diff --git a/textinput/textinput.go b/textinput/textinput.go index 539c174..acf966c 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -397,7 +397,7 @@ func (m *Model) deleteWordBackward() { // Linter note: it's critical that we acquire the initial cursor position // here prior to altering it via SetCursor() below. As such, moving this // call into the corresponding if clause does not apply here. - oldPos := m.pos //nolint:ifshort + oldPos := m.pos m.SetCursor(m.pos - 1) for unicode.IsSpace(m.value[m.pos]) {