feat(table): gum table for tabular data

This commit is contained in:
Maas Lalani 2022-09-30 12:09:55 -04:00
parent a82d5af1e8
commit bdd86d5fbc
13 changed files with 362 additions and 20 deletions

2
go.mod
View file

@ -6,7 +6,7 @@ require (
github.com/alecthomas/kong v0.6.1
github.com/alecthomas/mango-kong v0.1.0
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a
github.com/charmbracelet/bubbletea v0.22.2-0.20221006105051-f406999cba69
github.com/charmbracelet/bubbletea v0.22.2-0.20221007181357-2696b2f3399f
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.6.0
github.com/mattn/go-runewidth v0.0.14

14
go.sum
View file

@ -13,21 +13,14 @@ github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZs
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og=
github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
github.com/charmbracelet/bubbles v0.14.1-0.20220926062606-e857875f2a75 h1:rjgidQdLMPMe7wQOs+ki2cD69keSKd8nZFPGIDxv4ZI=
github.com/charmbracelet/bubbles v0.14.1-0.20220926062606-e857875f2a75/go.mod h1:5rZgJTHmgWISQnxnzzIJtQt3GC1bfJfNmr4SEtRDtTQ=
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a h1:/prXWlDbR4CWT1FaTvkU8WhLfpZv3eOrN9PtL8oDdDU=
github.com/charmbracelet/bubbles v0.14.1-0.20221006154229-d1775121146a/go.mod h1:5rZgJTHmgWISQnxnzzIJtQt3GC1bfJfNmr4SEtRDtTQ=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24=
github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0=
github.com/charmbracelet/bubbletea v0.22.2-0.20221006105051-f406999cba69 h1:GpZktjqyEQjuvFtFb0UlMlbZCOwOhk/bpKb6+quLz+E=
github.com/charmbracelet/bubbletea v0.22.2-0.20221006105051-f406999cba69/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
github.com/charmbracelet/bubbletea v0.22.2-0.20221007181357-2696b2f3399f h1:mbSd0Sdm2wXUWtXJ81o86G9V+9IhddX0qQcGK6bMbKo=
github.com/charmbracelet/bubbletea v0.22.2-0.20221007181357-2696b2f3399f/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
@ -58,7 +51,6 @@ github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.1-0.20220205060214-77e2058169ab h1:m7QFONkzLK0fVXCjwX5tANcnj1yXxTnYQtnfJiY3tcA=
@ -70,7 +62,6 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
@ -101,7 +92,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

25
gum.go
View file

@ -11,8 +11,10 @@ import (
"github.com/charmbracelet/gum/input"
"github.com/charmbracelet/gum/join"
"github.com/charmbracelet/gum/man"
"github.com/charmbracelet/gum/pager"
"github.com/charmbracelet/gum/spin"
"github.com/charmbracelet/gum/style"
"github.com/charmbracelet/gum/table"
"github.com/charmbracelet/gum/write"
)
@ -100,6 +102,12 @@ type Gum struct {
//
Join join.Options `cmd:"" help:"Join text vertically or horizontally"`
// Pager provides a shell script interface for the viewport bubble.
// https://github.com/charmbracelet/bubbles/tree/master/viewport
//
// It allows the user to scroll through content like a pager.
Pager pager.Options `cmd:"" help:"Scroll through a file"`
// Spin provides a shell script interface for the spinner bubble.
// https://github.com/charmbracelet/bubbles/tree/master/spinner
//
@ -142,6 +150,23 @@ type Gum struct {
//
Style style.Options `cmd:"" help:"Apply coloring, borders, spacing to text"`
// Table provides a shell script interface for the table bubble.
// https://github.com/charmbracelet/bubbles/tree/master/table
//
// It is useful to render tabular (CSV) data in a terminal and allows
// the user to select a row from the table.
//
// Let's render a table of gum flavors:
//
// $ gum table <<< "Flavor,Price\nStrawberry,$0.50\nBanana,$0.99\nCherry,$0.75"
//
// Flavor Price
// Strawberry $0.50
// Banana $0.99
// Cherry $0.75
//
Table table.Options `cmd:"" help:"Render a table of data"`
// Write provides a shell script interface for the text area bubble.
// https://github.com/charmbracelet/bubbles/tree/master/textarea
//

View file

@ -10,13 +10,8 @@ import (
// Read reads input from an stdin pipe.
func Read() (string, error) {
stat, err := os.Stdin.Stat()
if err != nil {
return "", fmt.Errorf("failed to stat stdin: %w", err)
}
if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
return "", nil
if IsEmpty() {
return "", fmt.Errorf("stdin is empty")
}
reader := bufio.NewReader(os.Stdin)
@ -35,3 +30,17 @@ func Read() (string, error) {
return b.String(), nil
}
// IsEmpty returns whether stdin is empty.
func IsEmpty() bool {
stat, err := os.Stdin.Stat()
if err != nil {
return true
}
if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
return true
}
return false
}

42
pager/command.go Normal file
View file

@ -0,0 +1,42 @@
package pager
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/gum/internal/stdin"
)
// Run provides a shell script interface for the viewport bubble.
// https://github.com/charmbracelet/bubbles/viewport
func (o Options) Run() error {
vp := viewport.New(o.Style.Width, o.Style.Height)
vp.Style = o.Style.ToLipgloss()
var err error
if o.Content == "" {
o.Content, err = stdin.Read()
if err != nil {
return err
}
}
renderer, err := glamour.NewTermRenderer(
glamour.WithWordWrap(80),
)
if err != nil {
return err
}
md, err := renderer.Render(o.Content)
vp.SetContent(md)
model := model{
viewport: vp,
helpStyle: o.HelpStyle.ToLipgloss(),
}
if err != nil {
return err
}
return tea.NewProgram(model, tea.WithAltScreen()).Start()
}

10
pager/options.go Normal file
View file

@ -0,0 +1,10 @@
package pager
import "github.com/charmbracelet/gum/style"
// Options are the options for the pager.
type Options struct {
Style style.Styles `embed:"" help:"Style the pager" envprefix:"GUM_PAGER_"`
HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_"`
Content string `arg:"" optional:"" help:"Display content to scroll"`
}

39
pager/pager.go Normal file
View file

@ -0,0 +1,39 @@
// Package pager provides a pager (similar to less) for the terminal.
//
// $ cat file.txt | gum page
package pager
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
viewport viewport.Model
helpStyle lipgloss.Style
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 1
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
}
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.viewport.View() + "\n" + m.helpStyle.Render("↑/↓: Navigate • q: Quit")
}

5
table/comma.csv Normal file
View file

@ -0,0 +1,5 @@
Bubble Gum,Price,Ingredients
Strawberry,$0.88,"Water,Sugar"
Guava,$1.00,"Guava Flavoring,Food Coloring,Xanthan Gum"
Orange,$0.99,"Sugar,Dextrose,Glucose"
Cinnamon,$0.50,"Cin""na""mon"
1 Bubble Gum Price Ingredients
2 Strawberry $0.88 Water,Sugar
3 Guava $1.00 Guava Flavoring,Food Coloring,Xanthan Gum
4 Orange $0.99 Sugar,Dextrose,Glucose
5 Cinnamon $0.50 Cin"na"mon

111
table/command.go Normal file
View file

@ -0,0 +1,111 @@
package table
import (
"encoding/csv"
"fmt"
"os"
"strings"
"github.com/alecthomas/kong"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/mattn/go-runewidth"
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/style"
)
// Run provides a shell script interface for rendering tabular data (CSV)
func (o Options) Run() error {
var reader *csv.Reader
if o.File != "" {
file, err := os.OpenFile(o.File, os.O_RDONLY, 0600)
if err != nil {
return fmt.Errorf("could not find file at path %s", o.File)
}
reader = csv.NewReader(file)
} else {
if stdin.IsEmpty() {
return fmt.Errorf("no data provided")
}
reader = csv.NewReader(os.Stdin)
}
separatorRunes := []rune(o.Separator)
if len(separatorRunes) != 1 {
return fmt.Errorf("separator must be single character")
}
reader.Comma = separatorRunes[0]
var columnNames []string
var err error
// If no columns are provided we'll use the first row of the CSV as the
// column names.
if len(o.Columns) <= 0 {
columnNames, err = reader.Read()
if err != nil {
return fmt.Errorf("unable to parse columns")
}
} else {
columnNames = o.Columns
}
data, err := reader.ReadAll()
if err != nil {
return fmt.Errorf("invalid data provided")
}
var columns []table.Column
for i, title := range columnNames {
width := runewidth.StringWidth(title)
if len(o.Widths) > i {
width = o.Widths[i]
}
columns = append(columns, table.Column{
Title: title,
Width: width,
})
}
defaultStyles := table.DefaultStyles()
styles := table.Styles{
Cell: defaultStyles.Cell.Inherit(o.CellStyle.ToLipgloss()),
Header: defaultStyles.Header.Inherit(o.HeaderStyle.ToLipgloss()),
Selected: defaultStyles.Selected.Inherit(o.SelectedStyle.ToLipgloss()),
}
var rows []table.Row
for _, row := range data {
rows = append(rows, table.Row(row))
}
table := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(o.Height),
table.WithRows(rows),
table.WithStyles(styles),
)
tm, err := tea.NewProgram(model{table: table}, tea.WithOutput(os.Stderr)).StartReturningModel()
if err != nil {
return fmt.Errorf("failed to start tea program: %w", err)
}
if tm == nil {
return fmt.Errorf("failed to get selection")
}
m := tm.(model)
fmt.Println(strings.Join([]string(m.selected), string(o.Separator)))
return nil
}
// BeforeReset hook. Used to unclutter style flags.
func (o Options) BeforeReset(ctx *kong.Context) error {
style.HideFlags(ctx)
return nil
}

19
table/example.csv Normal file
View file

@ -0,0 +1,19 @@
Bubble Gum Flavor,Price
Strawberry,$0.99
Cherry,$0.50
Banana,$0.75
Orange,$0.25
Lemon,$0.50
Lime,$0.50
Grape,$0.50
Watermelon,$0.50
Pineapple,$0.50
Blueberry,$0.50
Raspberry,$0.50
Cranberry,$0.50
Peach,$0.50
Apple,$0.50
Mango,$0.50
Pomegranate,$0.50
Coconut,$0.50
Cinnamon,$0.50
1 Bubble Gum Flavor Price
2 Strawberry $0.99
3 Cherry $0.50
4 Banana $0.75
5 Orange $0.25
6 Lemon $0.50
7 Lime $0.50
8 Grape $0.50
9 Watermelon $0.50
10 Pineapple $0.50
11 Blueberry $0.50
12 Raspberry $0.50
13 Cranberry $0.50
14 Peach $0.50
15 Apple $0.50
16 Mango $0.50
17 Pomegranate $0.50
18 Coconut $0.50
19 Cinnamon $0.50

19
table/invalid.csv Normal file
View file

@ -0,0 +1,19 @@
Bubble Gum Flavor
Strawberry,$0.99
Cherry,$0.50
Banana,$0.75
Orange
Lemon,$0.50
Lime,$0.50
Grape,$0.50
Watermelon,$0.50
Pineapple,$0.50
Blueberry,$0.50
Raspberry,$0.50
Cranberry,$0.50
Peach,$0.50
Apple,$0.50
Mango,$0.50
Pomegranate,$0.50
Coconut,$0.50
Cinnamon,$0.50
1 Bubble Gum Flavor
2 Strawberry,$0.99
3 Cherry,$0.50
4 Banana,$0.75
5 Orange
6 Lemon,$0.50
7 Lime,$0.50
8 Grape,$0.50
9 Watermelon,$0.50
10 Pineapple,$0.50
11 Blueberry,$0.50
12 Raspberry,$0.50
13 Cranberry,$0.50
14 Peach,$0.50
15 Apple,$0.50
16 Mango,$0.50
17 Pomegranate,$0.50
18 Coconut,$0.50
19 Cinnamon,$0.50

15
table/options.go Normal file
View file

@ -0,0 +1,15 @@
package table
import "github.com/charmbracelet/gum/style"
// Options is the customization options for the table command.
type Options struct {
Separator string `short:"s" help:"Row separator" default:","`
Columns []string `short:"c" help:"Column names"`
Widths []int `short:"w" help:"Column widths"`
Height int `help:"Table height" default:"10"`
CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL"`
HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER"`
SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED"`
File string `short:"f" help:"file path" default:""`
}

58
table/table.go Normal file
View file

@ -0,0 +1,58 @@
// Package table provides a shell script interface for the table bubble.
// https://github.com/charmbracelet/bubbles/tree/master/table
//
// It is useful to render tabular (CSV) data in a terminal and allows
// the user to select a row from the table.
//
// Let's render a table of gum flavors:
//
// $ gum table <<< "Flavor,Price\nStrawberry,$0.50\nBanana,$0.99\nCherry,$0.75"
//
// Flavor Price
// Strawberry $0.50
// Banana $0.99
// Cherry $0.75
//
package table
import (
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
table table.Model
selected table.Row
quitting bool
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
m.selected = m.table.SelectedRow()
m.quitting = true
return m, tea.Quit
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
}
}
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m model) View() string {
if m.quitting {
return ""
}
return m.table.View()
}