Merge branch 'v2-exp' into v2-table

This commit is contained in:
Andrey Nering
2025-05-21 14:08:35 -03:00
26 changed files with 805 additions and 61 deletions
+1 -1
View File
@@ -37,5 +37,5 @@ updates:
labels:
- "dependencies"
commit-message:
prefix: "feat"
prefix: "chore"
include: "scope"
-8
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$
+1 -3
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
+6 -1
View File
@@ -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
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
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()
}
+3 -3
View File
@@ -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 -6
View File
@@ -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=
+1
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() {
+517 -31
View File
@@ -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))
}
+21
View File
@@ -0,0 +1,21 @@
Name Country of Orig…Dunk-able
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Chocolate Digestives ││UK ││Yes │
└─────────────────────────┘└────────────────┘└────────────┘
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Tim Tams ││Australia ││No │
└─────────────────────────┘└────────────────┘└────────────┘
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Hobnobs ││UK ││Yes │
└─────────────────────────┘└────────────────┘└────────────┘
+23
View File
@@ -0,0 +1,23 @@
┌─────────────────────────┐┌────────────────┐┌────────────┐
│Name ││Country of Orig…││Dunk-able │
└─────────────────────────┘└────────────────┘└────────────┘
Chocolate Digestives UK Yes
Tim Tams Australia No
Hobnobs UK Yes
+20
View File
@@ -0,0 +1,20 @@
+14
View File
@@ -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
View File
@@ -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  
+8
View File
@@ -0,0 +1,8 @@
┌────────────────────────────────────────────────────────────────────────────────┐
│ Name Country of Orig… Dunk-able │
│ Chocolate Digestives UK Yes │
│ Tim Tams Australia No │
│ Hobnobs UK Yes │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────┘
+5 -6
View File
@@ -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
+20
View File
@@ -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 {
+1 -1
View File
@@ -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]) {