From 4476263d0598a0799b48f75d1bfb394b4dce79f4 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Tue, 10 Oct 2023 10:27:52 -0400 Subject: [PATCH] Feature: Tables (#218) * feat: tables * fix(table): examples * docs(table): `any` -> `string` * chore: go mod tidy * docs(table): update image * fix(table): lint * fix: remove binary * chore: color adjustments to pokemon example (#231) * fix(table): support rendering empty data sets * chore(table): simplify table's data interface * fix(table): correct GoDoc + add doc comments to Data methods --------- Co-authored-by: Christian Rocha Co-authored-by: Christian Muehlhaeuser --- README.md | 53 +- examples/go.mod | 7 +- examples/go.sum | 110 +--- examples/table/chess/main.go | 40 ++ examples/table/demo.tape | 29 + examples/table/languages/main.go | 73 +++ examples/table/mindy/main.go | 65 +++ examples/table/pokemon/main.go | 113 ++++ go.mod | 3 +- go.sum | 7 +- table/rows.go | 113 ++++ table/table.go | 522 ++++++++++++++++++ table/table_test.go | 898 +++++++++++++++++++++++++++++++ table/util.go | 62 +++ unset.go | 2 +- 15 files changed, 1984 insertions(+), 113 deletions(-) create mode 100644 examples/table/chess/main.go create mode 100644 examples/table/demo.tape create mode 100644 examples/table/languages/main.go create mode 100644 examples/table/mindy/main.go create mode 100644 examples/table/pokemon/main.go create mode 100644 table/rows.go create mode 100644 table/table.go create mode 100644 table/table_test.go create mode 100644 table/util.go diff --git a/README.md b/README.md index 2ebabc76..f3aa745a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,6 @@ height := lipgloss.Height(block) w, h := lipgloss.Size(block) ``` - ### Placing Text in Whitespace Sometimes you’ll simply want to place a block of text in whitespace. @@ -411,6 +410,58 @@ block := lipgloss.Place(30, 80, lipgloss.Right, lipgloss.Bottom, fancyStyledPara You can also style the whitespace. For details, see [the docs][docs]. +### Rendering Tables + +Lip Gloss ships with a table rendering sub-package. + +```go +import "github.com/charmbracelet/lipgloss/table" +``` + +Define some rows of data. + +```go +rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, +} +``` + +Use the table package to style and render the table. + +```go +t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). + StyleFunc(func(row, col int) lipgloss.Style { + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + return EvenRowStyle + default: + return OddRowStyle + } + }). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + +// You can also add tables row-by-row +t.Row("English", "You look absolutely fabulous.", "How's it going?") +``` + +Print the table. + +```go +fmt.Println(t) +``` + +![Table Example](https://github.com/charmbracelet/lipgloss/assets/42545625/6e4b70c4-f494-45da-a467-bdd27df30d5d) + +For more on tables see [the docs](https://pkg.go.dev/github.com/charmbracelet/lipgloss?tab=doc). *** diff --git a/examples/go.mod b/examples/go.mod index 40626251..34b54802 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,6 @@ module examples -go 1.17 +go 1.18 replace github.com/charmbracelet/lipgloss => ../ @@ -20,10 +20,11 @@ require ( github.com/caarlos0/sshmarshal v0.1.0 // indirect github.com/charmbracelet/keygen v0.3.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect golang.org/x/crypto v0.1.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.12.0 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index b704e63e..ff1b55fe 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,145 +1,47 @@ -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I= github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= -github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y= github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM= github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg= github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw= github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= -github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= -github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= -github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/table/chess/main.go b/examples/table/chess/main.go new file mode 100644 index 00000000..a69a9bc4 --- /dev/null +++ b/examples/table/chess/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + labelStyle := re.NewStyle().Foreground(lipgloss.Color("241")) + + board := [][]string{ + {"♜", "♞", "♝", "♛", "♚", "♝", "♞", "♜"}, + {"♟", "♟", "♟", "♟", "♟", "♟", "♟", "♟"}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {"♙", "♙", "♙", "♙", "♙", "♙", "♙", "♙"}, + {"♖", "♘", "♗", "♕", "♔", "♗", "♘", "♖"}, + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderRow(true). + BorderColumn(true). + Rows(board...). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 1) + }) + + ranks := labelStyle.Render(strings.Join([]string{" A", "B", "C", "D", "E", "F", "G", "H "}, " ")) + files := labelStyle.Render(strings.Join([]string{" 1", "2", "3", "4", "5", "6", "7", "8 "}, "\n\n ")) + + fmt.Println(lipgloss.JoinVertical(lipgloss.Right, lipgloss.JoinHorizontal(lipgloss.Center, files, t.Render()), ranks) + "\n") +} diff --git a/examples/table/demo.tape b/examples/table/demo.tape new file mode 100644 index 00000000..281690f1 --- /dev/null +++ b/examples/table/demo.tape @@ -0,0 +1,29 @@ +Output table.gif + +Set Height 900 +Set Width 1600 +Set Padding 80 +Set FontSize 42 + +Hide +Type "go build -o table" +Enter +Ctrl+L +Show + +Sleep 0.5s +Type "clear && ./table" +Sleep 0.5s +Enter +Sleep 1s + +Screenshot "table.png" + +Sleep 1s + +Hide +Type "rm table" +Enter +Show + +Sleep 1s diff --git a/examples/table/languages/main.go b/examples/table/languages/main.go new file mode 100644 index 00000000..7e2f07f7 --- /dev/null +++ b/examples/table/languages/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +const ( + purple = lipgloss.Color("99") + gray = lipgloss.Color("245") + lightGray = lipgloss.Color("241") +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + + var ( + // HeaderStyle is the lipgloss style used for the table headers. + HeaderStyle = re.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center) + // CellStyle is the base lipgloss style used for the table rows. + CellStyle = re.NewStyle().Padding(0, 1).Width(14) + // OddRowStyle is the lipgloss style used for odd-numbered table rows. + OddRowStyle = CellStyle.Copy().Foreground(gray) + // EvenRowStyle is the lipgloss style used for even-numbered table rows. + EvenRowStyle = CellStyle.Copy().Foreground(lightGray) + // BorderStyle is the lipgloss style used for the table border. + BorderStyle = lipgloss.NewStyle().Foreground(purple) + ) + + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + {"English", "You look absolutely fabulous.", "How's it going?"}, + } + + t := table.New(). + Border(lipgloss.ThickBorder()). + BorderStyle(BorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + var style lipgloss.Style + + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + style = EvenRowStyle + default: + style = OddRowStyle + } + + // Make the second column a little wider. + if col == 1 { + style = style.Copy().Width(22) + } + + // Arabic is a right-to-left language, so right align the text. + if rows[row-1][0] == "Arabic" && col != 0 { + style = style.Copy().Align(lipgloss.Right) + } + + return style + }). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + fmt.Println(t) +} diff --git a/examples/table/mindy/main.go b/examples/table/mindy/main.go new file mode 100644 index 00000000..f58fff38 --- /dev/null +++ b/examples/table/mindy/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + labelStyle := re.NewStyle().Width(3).Align(lipgloss.Right) + swatchStyle := re.NewStyle().Width(6) + + data := [][]string{} + for i := 0; i < 13; i += 8 { + data = append(data, makeRow(i, i+5)) + } + data = append(data, makeEmptyRow()) + for i := 6; i < 15; i += 8 { + data = append(data, makeRow(i, i+1)) + } + data = append(data, makeEmptyRow()) + for i := 16; i < 231; i += 6 { + data = append(data, makeRow(i, i+5)) + } + data = append(data, makeEmptyRow()) + for i := 232; i < 256; i += 6 { + data = append(data, makeRow(i, i+5)) + } + + t := table.New(). + Border(lipgloss.HiddenBorder()). + Rows(data...). + StyleFunc(func(row, col int) lipgloss.Style { + color := lipgloss.Color(fmt.Sprint(data[row-1][col-col%2])) + switch { + case col%2 == 0: + return labelStyle.Foreground(color) + default: + return swatchStyle.Background(color) + } + }) + + fmt.Println(t) +} + +const rowLength = 12 + +func makeRow(start, end int) []string { + var row []string + for i := start; i <= end; i++ { + row = append(row, fmt.Sprint(i)) + row = append(row, "") + } + for i := len(row); i < rowLength; i++ { + row = append(row, "") + } + return row +} + +func makeEmptyRow() []string { + return makeRow(0, -1) +} diff --git a/examples/table/pokemon/main.go b/examples/table/pokemon/main.go new file mode 100644 index 00000000..45c5c1d1 --- /dev/null +++ b/examples/table/pokemon/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + baseStyle := re.NewStyle().Padding(0, 1) + headerStyle := baseStyle.Copy().Foreground(lipgloss.Color("252")).Bold(true) + selectedStyle := baseStyle.Copy().Foreground(lipgloss.Color("#01BE85")).Background(lipgloss.Color("#00432F")) + typeColors := map[string]lipgloss.Color{ + "Bug": lipgloss.Color("#D7FF87"), + "Electric": lipgloss.Color("#FDFF90"), + "Fire": lipgloss.Color("#FF7698"), + "Flying": lipgloss.Color("#FF87D7"), + "Grass": lipgloss.Color("#75FBAB"), + "Ground": lipgloss.Color("#FF875F"), + "Normal": lipgloss.Color("#929292"), + "Poison": lipgloss.Color("#7D5AFC"), + "Water": lipgloss.Color("#00E2C7"), + } + dimTypeColors := map[string]lipgloss.Color{ + "Bug": lipgloss.Color("#97AD64"), + "Electric": lipgloss.Color("#FCFF5F"), + "Fire": lipgloss.Color("#BA5F75"), + "Flying": lipgloss.Color("#C97AB2"), + "Grass": lipgloss.Color("#59B980"), + "Ground": lipgloss.Color("#C77252"), + "Normal": lipgloss.Color("#727272"), + "Poison": lipgloss.Color("#634BD0"), + "Water": lipgloss.Color("#439F8E"), + } + + headers := []any{"#", "Name", "Type 1", "Type 2", "Japanese", "Official Rom."} + data := [][]string{ + {"1", "Bulbasaur", "Grass", "Poison", "フシギダネ", "Bulbasaur"}, + {"2", "Ivysaur", "Grass", "Poison", "フシギソウ", "Ivysaur"}, + {"3", "Venusaur", "Grass", "Poison", "フシギバナ", "Venusaur"}, + {"4", "Charmander", "Fire", "", "ヒトカゲ", "Hitokage"}, + {"5", "Charmeleon", "Fire", "", "リザード", "Lizardo"}, + {"6", "Charizard", "Fire", "Flying", "リザードン", "Lizardon"}, + {"7", "Squirtle", "Water", "", "ゼニガメ", "Zenigame"}, + {"8", "Wartortle", "Water", "", "カメール", "Kameil"}, + {"9", "Blastoise", "Water", "", "カメックス", "Kamex"}, + {"10", "Caterpie", "Bug", "", "キャタピー", "Caterpie"}, + {"11", "Metapod", "Bug", "", "トランセル", "Trancell"}, + {"12", "Butterfree", "Bug", "Flying", "バタフリー", "Butterfree"}, + {"13", "Weedle", "Bug", "Poison", "ビードル", "Beedle"}, + {"14", "Kakuna", "Bug", "Poison", "コクーン", "Cocoon"}, + {"15", "Beedrill", "Bug", "Poison", "スピアー", "Spear"}, + {"16", "Pidgey", "Normal", "Flying", "ポッポ", "Poppo"}, + {"17", "Pidgeotto", "Normal", "Flying", "ピジョン", "Pigeon"}, + {"18", "Pidgeot", "Normal", "Flying", "ピジョット", "Pigeot"}, + {"19", "Rattata", "Normal", "", "コラッタ", "Koratta"}, + {"20", "Raticate", "Normal", "", "ラッタ", "Ratta"}, + {"21", "Spearow", "Normal", "Flying", "オニスズメ", "Onisuzume"}, + {"22", "Fearow", "Normal", "Flying", "オニドリル", "Onidrill"}, + {"23", "Ekans", "Poison", "", "アーボ", "Arbo"}, + {"24", "Arbok", "Poison", "", "アーボック", "Arbok"}, + {"25", "Pikachu", "Electric", "", "ピカチュウ", "Pikachu"}, + {"26", "Raichu", "Electric", "", "ライチュウ", "Raichu"}, + {"27", "Sandshrew", "Ground", "", "サンド", "Sand"}, + {"28", "Sandslash", "Ground", "", "サンドパン", "Sandpan"}, + } + + CapitalizeHeaders := func(data []any) []any { + for i := range data { + data[i] = strings.ToUpper(data[i].(string)) + } + return data + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(re.NewStyle().Foreground(lipgloss.Color("238"))). + Headers(CapitalizeHeaders(headers)...). + Width(80). + Rows(data...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == 0 { + return headerStyle + } + + if data[row-1][1] == "Pikachu" { + return selectedStyle + } + + even := row%2 == 0 + + switch col { + case 2, 3: // Type 1 + 2 + c := typeColors + if even { + c = dimTypeColors + } + + color := c[fmt.Sprint(data[row-1][col])] + return baseStyle.Copy().Foreground(color) + } + + if even { + return baseStyle.Copy().Foreground(lipgloss.Color("245")) + } + return baseStyle.Copy().Foreground(lipgloss.Color("252")) + }) + fmt.Println(t) +} diff --git a/go.mod b/go.mod index 094f648d..dafbf171 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.15.2 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 ) require ( @@ -15,5 +16,5 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/sys v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 596350b3..2eb3b422 100644 --- a/go.sum +++ b/go.sum @@ -5,7 +5,6 @@ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -15,6 +14,8 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/table/rows.go b/table/rows.go new file mode 100644 index 00000000..afebef11 --- /dev/null +++ b/table/rows.go @@ -0,0 +1,113 @@ +package table + +// Data is the interface that wraps the basic methods of a table model. +type Data interface { + // At returns the contents of the cell at the given index. + At(row, cell int) string + + // Rows returns the number of rows in the table. + Rows() int + + // Columns returns the number of columns in the table. + Columns() int +} + +// StringData is a string-based implementation of the Data interface. +type StringData struct { + rows [][]string + columns int +} + +// NewStringData creates a new StringData with the given number of columns. +func NewStringData(rows ...[]string) *StringData { + m := StringData{columns: 0} + + for _, row := range rows { + m.columns = max(m.columns, len(row)) + m.rows = append(m.rows, row) + } + + return &m +} + +// Append appends the given row to the table. +func (m *StringData) Append(row []string) { + m.columns = max(m.columns, len(row)) + m.rows = append(m.rows, row) +} + +// At returns the contents of the cell at the given index. +func (m *StringData) At(row, cell int) string { + if row >= len(m.rows) || cell >= len(m.rows[row]) { + return "" + } + + return m.rows[row][cell] +} + +// Columns returns the number of columns in the table. +func (m *StringData) Columns() int { + return m.columns +} + +// Item appends the given row to the table. +func (m *StringData) Item(rows ...string) *StringData { + m.columns = max(m.columns, len(rows)) + m.rows = append(m.rows, rows) + return m +} + +// Rows returns the number of rows in the table. +func (m *StringData) Rows() int { + return len(m.rows) +} + +// Filter applies a filter on some data. +type Filter struct { + data Data + filter func(row int) bool +} + +// NewFilter initializes a new Filter. +func NewFilter(data Data) *Filter { + return &Filter{data: data} +} + +// Filter applies the given filter function to the data. +func (m *Filter) Filter(f func(row int) bool) *Filter { + m.filter = f + return m +} + +// Row returns the row at the given index. +func (m *Filter) At(row, cell int) string { + j := 0 + for i := 0; i < m.data.Rows(); i++ { + if m.filter(i) { + if j == row { + return m.data.At(i, cell) + } + + j++ + } + } + + return "" +} + +// Columns returns the number of columns in the table. +func (m *Filter) Columns() int { + return m.data.Columns() +} + +// Rows returns the number of rows in the table. +func (m *Filter) Rows() int { + j := 0 + for i := 0; i < m.data.Rows(); i++ { + if m.filter(i) { + j++ + } + } + + return j +} diff --git a/table/table.go b/table/table.go new file mode 100644 index 00000000..2fdd1e15 --- /dev/null +++ b/table/table.go @@ -0,0 +1,522 @@ +package table + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +// StyleFunc is the style function that determines the style of a Cell. +// +// It takes the row and column of the cell as an input and determines the +// lipgloss Style to use for that cell position. +// +// Example: +// +// t := table.New(). +// Headers("Name", "Age"). +// Row("Kini", 4). +// Row("Eli", 1). +// Row("Iris", 102). +// StyleFunc(func(row, col int) lipgloss.Style { +// switch { +// case row == 0: +// return HeaderStyle +// case row%2 == 0: +// return EvenRowStyle +// default: +// return OddRowStyle +// } +// }) +type StyleFunc func(row, col int) lipgloss.Style + +// DefaultStyles is a TableStyleFunc that returns a new Style with no attributes. +func DefaultStyles(_, _ int) lipgloss.Style { + return lipgloss.NewStyle() +} + +// Table is a type for rendering tables. +type Table struct { + styleFunc StyleFunc + border lipgloss.Border + + borderTop bool + borderBottom bool + borderLeft bool + borderRight bool + borderHeader bool + borderColumn bool + borderRow bool + + borderStyle lipgloss.Style + headers []any + data Data + + width int + height int + offset int + + // widths tracks the width of each column. + widths []int + + // heights tracks the height of each row. + heights []int +} + +// New returns a new Table that can be modified through different +// attributes. +// +// By default, a table has no border, no styling, and no rows. +func New() *Table { + return &Table{ + styleFunc: DefaultStyles, + border: lipgloss.RoundedBorder(), + borderBottom: true, + borderColumn: true, + borderHeader: true, + borderLeft: true, + borderRight: true, + borderTop: true, + data: NewStringData(), + } +} + +// ClearRows clears the table rows. +func (t *Table) ClearRows() *Table { + t.data = nil + return t +} + +// StyleFunc sets the style for a cell based on it's position (row, column). +func (t *Table) StyleFunc(style StyleFunc) *Table { + t.styleFunc = style + return t +} + +// style returns the style for a cell based on it's position (row, column). +func (t *Table) style(row, col int) lipgloss.Style { + if t.styleFunc == nil { + return lipgloss.NewStyle() + } + return t.styleFunc(row, col) +} + +// Data sets the table data. +func (t *Table) Data(data Data) *Table { + t.data = data + return t +} + +// Rows appends rows to the table data. +func (t *Table) Rows(rows ...[]string) *Table { + for _, row := range rows { + switch t.data.(type) { + case *StringData: + t.data.(*StringData).Append(row) + } + } + return t +} + +// Row appends a row to the table data. +func (t *Table) Row(row ...string) *Table { + switch t.data.(type) { + case *StringData: + t.data.(*StringData).Append(row) + } + return t +} + +// Headers sets the table headers. +func (t *Table) Headers(headers ...any) *Table { + t.headers = headers + return t +} + +// Border sets the table border. +func (t *Table) Border(border lipgloss.Border) *Table { + t.border = border + return t +} + +// BorderTop sets the top border. +func (t *Table) BorderTop(v bool) *Table { + t.borderTop = v + return t +} + +// BorderBottom sets the bottom border. +func (t *Table) BorderBottom(v bool) *Table { + t.borderBottom = v + return t +} + +// BorderLeft sets the left border. +func (t *Table) BorderLeft(v bool) *Table { + t.borderLeft = v + return t +} + +// BorderRight sets the right border. +func (t *Table) BorderRight(v bool) *Table { + t.borderRight = v + return t +} + +// BorderHeader sets the header separator border. +func (t *Table) BorderHeader(v bool) *Table { + t.borderHeader = v + return t +} + +// BorderColumn sets the column border separator. +func (t *Table) BorderColumn(v bool) *Table { + t.borderColumn = v + return t +} + +// BorderRow sets the row border separator. +func (t *Table) BorderRow(v bool) *Table { + t.borderRow = v + return t +} + +// BorderStyle sets the style for the table border. +func (t *Table) BorderStyle(style lipgloss.Style) *Table { + t.borderStyle = style + return t +} + +// Width sets the table width, this auto-sizes the columns to fit the width by +// either expanding or contracting the widths of each column as a best effort +// approach. +func (t *Table) Width(w int) *Table { + t.width = w + return t +} + +// Height sets the table height. +func (t *Table) Height(h int) *Table { + t.height = h + return t +} + +// Offset sets the table rendering offset. +func (t *Table) Offset(o int) *Table { + t.offset = o + return t +} + +// String returns the table as a string. +func (t *Table) String() string { + hasHeaders := t.headers != nil && len(t.headers) > 0 + hasRows := t.data != nil && t.data.Rows() > 0 + + if !hasHeaders && !hasRows { + return "" + } + + var s strings.Builder + + // Add empty cells to the headers, until it's the same length as the longest + // row (only if there are at headers in the first place). + if hasHeaders { + for i := len(t.headers); i < t.data.Columns(); i++ { + t.headers = append(t.headers, "") + } + } + + // Initialize the widths. + t.widths = make([]int, max(len(t.headers), t.data.Columns())) + t.heights = make([]int, btoi(hasHeaders)+t.data.Rows()) + + // The style function may affect width of the table. It's possible to set + // the StyleFunc after the headers and rows. Update the widths for a final + // time. + for i, cell := range t.headers { + t.widths[i] = max(t.widths[i], lipgloss.Width(t.style(0, i).Render(fmt.Sprint(cell)))) + t.heights[0] = max(t.heights[0], lipgloss.Height(t.style(0, i).Render(fmt.Sprint(cell)))) + } + + for r := 0; r < t.data.Rows(); r++ { + for i := 0; i < t.data.Columns(); i++ { + cell := t.data.At(r, i) + + rendered := t.style(r+1, i).Render(cell) + t.heights[r+btoi(hasHeaders)] = max(t.heights[r+btoi(hasHeaders)], lipgloss.Height(rendered)) + t.widths[i] = max(t.widths[i], lipgloss.Width(rendered)) + } + } + + // Table Resizing Logic. + // + // Given a user defined table width, we must ensure the table is exactly that + // width. This must account for all borders, column, separators, and column + // data. + // + // In the case where the table is narrower than the specified table width, + // we simply expand the columns evenly to fit the width. + // For example, a table with 3 columns takes up 50 characters total, and the + // width specified is 80, we expand each column by 10 characters, adding 30 + // to the total width. + // + // In the case where the table is wider than the specified table width, we + // _could_ simply shrink the columns evenly but this would result in data + // being truncated (perhaps unnecessarily). The naive approach could result + // in very poor cropping of the table data. So, instead of shrinking columns + // evenly, we calculate the median non-whitespace length of each column, and + // shrink the columns based on the largest median. + // + // For example, + // ┌──────┬───────────────┬──────────┐ + // │ Name │ Age of Person │ Location │ + // ├──────┼───────────────┼──────────┤ + // │ Kini │ 40 │ New York │ + // │ Eli │ 30 │ London │ + // │ Iris │ 20 │ Paris │ + // └──────┴───────────────┴──────────┘ + // + // Median non-whitespace length vs column width of each column: + // + // Name: 4 / 5 + // Age of Person: 2 / 15 + // Location: 6 / 10 + // + // The biggest difference is 15 - 2, so we can shrink the 2nd column by 13. + + width := t.computeWidth() + + if width < t.width && t.width > 0 { + // Table is too narrow, expand the columns evenly until it reaches the + // desired width. + var i int + for width < t.width { + t.widths[i]++ + width++ + i = (i + 1) % len(t.widths) + } + } else if width > t.width && t.width > 0 { + // Table is too wide, calculate the median non-whitespace length of each + // column, and shrink the columns based on the largest difference. + columnMedians := make([]int, len(t.widths)) + for c := range t.widths { + trimmedWidth := make([]int, t.data.Rows()) + for r := 0; r < t.data.Rows(); r++ { + renderedCell := t.style(r+btoi(hasHeaders), c).Render(t.data.At(r, c)) + nonWhitespaceChars := lipgloss.Width(strings.TrimRight(renderedCell, " ")) + trimmedWidth[r] = nonWhitespaceChars + 1 + } + + columnMedians[c] = median(trimmedWidth) + } + + // Find the biggest differences between the median and the column width. + // Shrink the columns based on the largest difference. + differences := make([]int, len(t.widths)) + for i := range t.widths { + differences[i] = t.widths[i] - columnMedians[i] + } + + for width > t.width { + index, _ := largest(differences) + if differences[index] < 1 { + break + } + + shrink := min(differences[index], width-t.width) + t.widths[index] -= shrink + width -= shrink + differences[index] = 0 + } + + // Table is still too wide, begin shrinking the columns based on the + // largest column. + for width > t.width { + index, _ := largest(t.widths) + if t.widths[index] < 1 { + break + } + t.widths[index]-- + width-- + } + } + + if t.borderTop { + s.WriteString(t.constructTopBorder()) + s.WriteString("\n") + } + + if hasHeaders { + s.WriteString(t.constructHeaders()) + s.WriteString("\n") + } + + for r := t.offset; r < t.data.Rows(); r++ { + s.WriteString(t.constructRow(r)) + } + + if t.borderBottom { + s.WriteString(t.constructBottomBorder()) + } + + return lipgloss.NewStyle(). + MaxHeight(t.computeHeight()). + MaxWidth(t.width).Render(s.String()) +} + +// computeWidth computes the width of the table in it's current configuration. +func (t *Table) computeWidth() int { + width := sum(t.widths) + btoi(t.borderLeft) + btoi(t.borderRight) + if t.borderColumn { + width += len(t.widths) - 1 + } + return width +} + +// computeHeight computes the height of the table in it's current configuration. +func (t *Table) computeHeight() int { + hasHeaders := t.headers != nil && len(t.headers) > 0 + return sum(t.heights) - 1 + btoi(hasHeaders) + + btoi(t.borderTop) + btoi(t.borderBottom) + + btoi(t.borderHeader) + t.data.Rows()*btoi(t.borderRow) +} + +// Render returns the table as a string. +func (t *Table) Render() string { + return t.String() +} + +// constructTopBorder constructs the top border for the table given it's current +// border configuration and data. +func (t *Table) constructTopBorder() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.TopLeft)) + } + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.MiddleTop)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.TopRight)) + } + return s.String() +} + +// constructBottomBorder constructs the bottom border for the table given it's current +// border configuration and data. +func (t *Table) constructBottomBorder() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.BottomLeft)) + } + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.MiddleBottom)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.BottomRight)) + } + return s.String() +} + +// constructHeaders constructs the headers for the table given it's current +// header configuration and data. +func (t *Table) constructHeaders() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + for i, header := range t.headers { + s.WriteString(t.style(0, i). + MaxHeight(1). + Width(t.widths[i]). + MaxWidth(t.widths[i]). + Render(runewidth.Truncate(fmt.Sprint(header), t.widths[i], "…"))) + if i < len(t.headers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + } + if t.borderHeader { + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.Right)) + } + s.WriteString("\n") + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.MiddleLeft)) + } + for i := 0; i < len(t.headers); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i]))) + if i < len(t.headers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Middle)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.MiddleRight)) + } + } + if t.borderRight && !t.borderHeader { + s.WriteString(t.borderStyle.Render(t.border.Right)) + } + return s.String() +} + +// constructRow constructs the row for the table given an index and row data +// based on the current configuration. +func (t *Table) constructRow(index int) string { + var s strings.Builder + + hasHeaders := t.headers != nil && len(t.headers) > 0 + height := t.heights[index+btoi(hasHeaders)] + + var cells []string + left := strings.Repeat(t.borderStyle.Render(t.border.Left)+"\n", height) + if t.borderLeft { + cells = append(cells, left) + } + + for c := 0; c < t.data.Columns(); c++ { + cell := t.data.At(index, c) + + cells = append(cells, t.style(index+1, c). + Height(height). + MaxHeight(height). + Width(t.widths[c]). + MaxWidth(t.widths[c]). + Render(runewidth.Truncate(cell, t.widths[c]*height, "…"))) + + if c < t.data.Columns()-1 && t.borderColumn { + cells = append(cells, left) + } + } + + if t.borderRight { + right := strings.Repeat(t.borderStyle.Render(t.border.Right)+"\n", height) + cells = append(cells, right) + } + + for i, cell := range cells { + cells[i] = strings.TrimRight(cell, "\n") + } + + s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...) + "\n") + + if t.borderRow && index < t.data.Rows()-1 { + s.WriteString(t.borderStyle.Render(t.border.MiddleLeft)) + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Middle)) + } + } + s.WriteString(t.borderStyle.Render(t.border.MiddleRight) + "\n") + } + + return s.String() +} diff --git a/table/table_test.go b/table/table_test.go new file mode 100644 index 00000000..4a86f1be --- /dev/null +++ b/table/table_test.go @@ -0,0 +1,898 @@ +package table + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +var TableStyle = func(row, col int) lipgloss.Style { + switch { + case row == 0: + return lipgloss.NewStyle().Padding(0, 1).Align(lipgloss.Center) + case row%2 == 0: + return lipgloss.NewStyle().Padding(0, 1) + default: + return lipgloss.NewStyle().Padding(0, 1) + } +} + +func TestTable(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableEmpty(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL") + + expected := strings.TrimSpace(` +┌──────────┬────────┬──────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼────────┼──────────┤ +└──────────┴────────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableOffset(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?"). + Offset(1) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableBorder(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.DoubleBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +╔══════════╦══════════════╦═══════════╗ +║ LANGUAGE ║ FORMAL ║ INFORMAL ║ +╠══════════╬══════════════╬═══════════╣ +║ Chinese ║ Nǐn hǎo ║ Nǐ hǎo ║ +║ French ║ Bonjour ║ Salut ║ +║ Japanese ║ こんにちは ║ やあ ║ +║ Russian ║ Zdravstvuyte ║ Privet ║ +║ Spanish ║ Hola ║ ¿Qué tal? ║ +╚══════════╩══════════════╩═══════════╝ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableSetRows(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestMoreCellsThanHeaders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestMoreCellsThanHeadersExtra(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet", "Privet", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┬────────┬────────┐ +│ LANGUAGE │ FORMAL │ │ │ │ +├──────────┼──────────────┼───────────┼────────┼────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ │ │ +│ French │ Bonjour │ Salut │ Salut │ │ +│ Japanese │ こんにちは │ やあ │ │ │ +│ Russian │ Zdravstvuyte │ Privet │ Privet │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ │ │ +└──────────┴──────────────┴───────────┴────────┴────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoHeaders(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoColumnSeparators(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌───────────────────────────────────┐ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoColumnSeparatorsWithHeaders(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌───────────────────────────────────┐ +│ LANGUAGE FORMAL INFORMAL │ +├───────────────────────────────────┤ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestBorderColumnsWithExtraRows(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet", "Privet", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌───────────────────────────────────────────────────┐ +│ LANGUAGE FORMAL │ +├───────────────────────────────────────────────────┤ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet Privet Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestNew(t *testing.T) { + table := New() + expected := "" + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableUnsetBorders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false) + + expected := strings.TrimPrefix(` + LANGUAGE │ FORMAL │ INFORMAL +──────────┼──────────────┼─────────── + Chinese │ Nǐn hǎo │ Nǐ hǎo + French │ Bonjour │ Salut + Japanese │ こんにちは │ やあ + Russian │ Zdravstvuyte │ Privet + Spanish │ Hola │ ¿Qué tal? `, "\n") + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", debug(expected), debug(table.String())) + } +} + +func TestTableUnsetHeaderSeparator(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderHeader(false). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false) + + expected := strings.TrimPrefix(` + LANGUAGE │ FORMAL │ INFORMAL + Chinese │ Nǐn hǎo │ Nǐ hǎo + French │ Bonjour │ Salut + Japanese │ こんにちは │ やあ + Russian │ Zdravstvuyte │ Privet + Spanish │ Hola │ ¿Qué tal? `, "\n") + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", debug(expected), debug(table.String())) + } +} + +func TestTableUnsetHeaderSeparatorWithBorder(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderHeader(false) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableRowSeparators(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + BorderRow(true). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +├──────────┼──────────────┼───────────┤ +│ French │ Bonjour │ Salut │ +├──────────┼──────────────┼───────────┤ +│ Japanese │ こんにちは │ やあ │ +├──────────┼──────────────┼───────────┤ +│ Russian │ Zdravstvuyte │ Privet │ +├──────────┼──────────────┼───────────┤ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableHeights(t *testing.T) { + styleFunc := func(row, col int) lipgloss.Style { + if row == 0 { + return lipgloss.NewStyle().Padding(0, 1) + } + if col == 0 { + return lipgloss.NewStyle().Width(18).Padding(1) + } + return lipgloss.NewStyle().Width(25).Padding(1, 2) + } + + rows := [][]string{ + {"Chutar o balde", `Literally translates to "kick the bucket." It's used when someone gives up or loses patience.`}, + {"Engolir sapos", `Literally means "to swallow frogs." It's used to describe someone who has to tolerate or endure unpleasant situations.`}, + {"Arroz de festa", `Literally means "party rice." It´s used to refer to someone who shows up everywhere.`}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(styleFunc). + Headers("EXPRESSION", "MEANING"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────────────┬─────────────────────────┐ +│ EXPRESSION │ MEANING │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Chutar o balde │ Literally translates │ +│ │ to "kick the bucket." │ +│ │ It's used when │ +│ │ someone gives up or │ +│ │ loses patience. │ +│ │ │ +│ │ │ +│ Engolir sapos │ Literally means "to │ +│ │ swallow frogs." It's │ +│ │ used to describe │ +│ │ someone who has to │ +│ │ tolerate or endure │ +│ │ unpleasant │ +│ │ situations. │ +│ │ │ +│ │ │ +│ Arroz de festa │ Literally means │ +│ │ "party rice." It´s │ +│ │ used to refer to │ +│ │ someone who shows up │ +│ │ everywhere. │ +│ │ │ +└──────────────────┴─────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableMultiLineRowSeparator(t *testing.T) { + styleFunc := func(row, col int) lipgloss.Style { + if row == 0 { + return lipgloss.NewStyle().Padding(0, 1) + } + if col == 0 { + return lipgloss.NewStyle().Width(18).Padding(1) + } + return lipgloss.NewStyle().Width(25).Padding(1, 2) + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(styleFunc). + Headers("EXPRESSION", "MEANING"). + BorderRow(true). + Row("Chutar o balde", `Literally translates to "kick the bucket." It's used when someone gives up or loses patience.`). + Row("Engolir sapos", `Literally means "to swallow frogs." It's used to describe someone who has to tolerate or endure unpleasant situations.`). + Row("Arroz de festa", `Literally means "party rice." It´s used to refer to someone who shows up everywhere.`) + + expected := strings.TrimSpace(` +┌──────────────────┬─────────────────────────┐ +│ EXPRESSION │ MEANING │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Chutar o balde │ Literally translates │ +│ │ to "kick the bucket." │ +│ │ It's used when │ +│ │ someone gives up or │ +│ │ loses patience. │ +│ │ │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Engolir sapos │ Literally means "to │ +│ │ swallow frogs." It's │ +│ │ used to describe │ +│ │ someone who has to │ +│ │ tolerate or endure │ +│ │ unpleasant │ +│ │ situations. │ +│ │ │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Arroz de festa │ Literally means │ +│ │ "party rice." It´s │ +│ │ used to refer to │ +│ │ someone who shows up │ +│ │ everywhere. │ +│ │ │ +└──────────────────┴─────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthExpand(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(80). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌────────────────────────┬────────────────────────────┬────────────────────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├────────────────────────┼────────────────────────────┼────────────────────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└────────────────────────┴────────────────────────────┴────────────────────────┘ +`) + + if lipgloss.Width(table.String()) != 80 { + t.Fatalf("expected table width to be 80, got %d", lipgloss.Width(table.String())) + } + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthShrink(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌─────────┬─────────┬────────┐ +│ LANGUAG │ FORMAL │ INFORM │ +├─────────┼─────────┼────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanes │ こんに │ やあ │ +│ Russian │ Zdravst │ Privet │ +│ Spanish │ Hola │ ¿Qué │ +└─────────┴─────────┴────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCrop(t *testing.T) { + rows := [][]string{ + {"Kini", "40", "New York"}, + {"Eli", "30", "London"}, + {"Iris", "20", "Paris"}, + } + + table := New(). + Width(25). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("Name", "Age of Person", "Location"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────┬─────┬──────────┐ +│ Name │ Age │ Location │ +├──────┼─────┼──────────┤ +│ Kini │ 40 │ New York │ +│ Eli │ 30 │ London │ +│ Iris │ 20 │ Paris │ +└──────┴─────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCropExtensive(t *testing.T) { + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + {"English", "You look absolutely fabulous.", "How's it going?"}, + } + + table := New(). + Width(18). + StyleFunc(TableStyle). + Border(lipgloss.ThickBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┏━━━━┳━━━━━┳━━━━━┓ +┃ LA ┃ FOR ┃ INF ┃ +┣━━━━╋━━━━━╋━━━━━┫ +┃ Ch ┃ 您 ┃ 你 ┃ +┃ Ja ┃ こ ┃ や ┃ +┃ Ar ┃ أهل ┃ أهل ┃ +┃ Ru ┃ Здр ┃ При ┃ +┃ Sp ┃ Hol ┃ ¿Qu ┃ +┃ En ┃ You ┃ How ┃ +┗━━━━┻━━━━━┻━━━━━┛ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCropTiny(t *testing.T) { + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + {"English", "You look absolutely fabulous.", "How's it going?"}, + } + + table := New(). + Width(1). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌ +│ +├ +│ +│ +│ +│ +│ +└ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidths(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + BorderLeft(false). + BorderRight(false). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +────────────────────────────── + LANGUAGE FORMAL INFORMAL +────────────────────────────── + Chinese Nǐn hǎo Nǐ hǎo + French Bonjour Salut + Japanese こんに やあ + Russian Zdravst Privet + Spanish Hola ¿Qué tal? +────────────────────────────── +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthShrinkNoBorders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + BorderLeft(false). + BorderRight(false). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +────────────────────────────── + LANGUAGE FORMAL INFORMAL +────────────────────────────── + Chinese Nǐn hǎo Nǐ hǎo + French Bonjour Salut + Japanese こんに やあ + Russian Zdravst Privet + Spanish Hola ¿Qué tal? +────────────────────────────── +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestFilter(t *testing.T) { + data := NewStringData(). + Item("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Item("French", "Bonjour", "Salut"). + Item("Japanese", "こんにちは", "やあ"). + Item("Russian", "Zdravstvuyte", "Privet"). + Item("Spanish", "Hola", "¿Qué tal?") + + filter := NewFilter(data).Filter(func(row int) bool { + return data.At(row, 0) != "French" + }) + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Data(filter) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestFilterInverse(t *testing.T) { + data := NewStringData(). + Item("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Item("French", "Bonjour", "Salut"). + Item("Japanese", "こんにちは", "やあ"). + Item("Russian", "Zdravstvuyte", "Privet"). + Item("Spanish", "Hola", "¿Qué tal?") + + filter := NewFilter(data).Filter(func(row int) bool { + return data.At(row, 0) == "French" + }) + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Data(filter) + + expected := strings.TrimSpace(` +┌──────────┬─────────┬──────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼─────────┼──────────┤ +│ French │ Bonjour │ Salut │ +└──────────┴─────────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func debug(s string) string { + return strings.ReplaceAll(s, " ", ".") +} diff --git a/table/util.go b/table/util.go new file mode 100644 index 00000000..51285338 --- /dev/null +++ b/table/util.go @@ -0,0 +1,62 @@ +package table + +import "golang.org/x/exp/slices" + +// btoi converts a boolean to an integer, 1 if true, 0 if false. +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} + +// max returns the greater of two integers. +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// min returns the greater of two integers. +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// sum returns the sum of all integers in a slice. +func sum(n []int) int { + var sum int + for _, i := range n { + sum += i + } + return sum +} + +// median returns the median of a slice of integers. +func median(n []int) int { + slices.Sort(n) + + if len(n) <= 0 { + return 0 + } + if len(n)%2 == 0 { + h := len(n) / 2 //nolint:gomnd + return (n[h-1] + n[h]) / 2 //nolint:gomnd + } + return n[len(n)/2] +} + +// largest returns the largest element and it's index from a slice of integers. +func largest(n []int) (int, int) { //nolint:unparam + var largest, index int + for i, e := range n { + if n[i] > n[index] { + largest = e + index = i + } + } + return index, largest +} diff --git a/unset.go b/unset.go index f889f9e2..770734c8 100644 --- a/unset.go +++ b/unset.go @@ -287,7 +287,7 @@ func (s Style) UnsetMaxHeight() Style { return s } -// UnsetMaxHeight removes the max height style rule, if set. +// UnsetTabWidth removes the tab width style rule, if set. func (s Style) UnsetTabWidth() Style { delete(s.rules, tabWidthKey) return s