mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-16 01:43:21 +00:00
Merge branch 'v2-exp' into v2-table
This commit is contained in:
@@ -37,5 +37,5 @@ updates:
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "feat"
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -10,11 +10,9 @@
|
||||
[](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
|
||||
|
||||
+6
-1
@@ -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}}
|
||||
|
||||
+3
-1
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+517
-31
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Name Country of Orig…Dunk-able
|
||||
┌─────────────────────────┐┌────────────────┐┌────────────┐
|
||||
│Chocolate Digestives ││UK ││Yes │
|
||||
└─────────────────────────┘└────────────────┘└────────────┘
|
||||
┌─────────────────────────┐┌────────────────┐┌────────────┐
|
||||
│Tim Tams ││Australia ││No │
|
||||
└─────────────────────────┘└────────────────┘└────────────┘
|
||||
┌─────────────────────────┐┌────────────────┐┌────────────┐
|
||||
│Hobnobs ││UK ││Yes │
|
||||
└─────────────────────────┘└────────────────┘└────────────┘
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
┌─────────────────────────┐┌────────────────┐┌────────────┐
|
||||
│Name ││Country of Orig…││Dunk-able │
|
||||
└─────────────────────────┘└────────────────┘└────────────┘
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
|
||||
Name Country of Orig… Dunk-able
|
||||
|
||||
|
||||
|
||||
|
||||
Chocolate Digestives UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
Tim Tams Australia No
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Name Country of Orig… Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Name Country of Orig… Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
@@ -0,0 +1,21 @@
|
||||
Name Country of Orig… Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Name Country of Origin Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Name Country of Orig… Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
@@ -0,0 +1,21 @@
|
||||
Name Country of Orig… Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
Name Country of Orig…Dunk-able
|
||||
Chocolate Digestives UK Yes
|
||||
Tim Tams Australia No
|
||||
Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Name
|
||||
Chocolate Digestives
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Name Country of Orig… Dunk-able │
|
||||
│ Chocolate Digestives UK Yes │
|
||||
│ Tim Tams Australia No │
|
||||
│ Hobnobs UK Yes │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user