From bdd86d5fbcb0b97b146611c0725d2b630317e83b Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Fri, 30 Sep 2022 12:09:55 -0400 Subject: [PATCH] feat(table): gum table for tabular data --- go.mod | 2 +- go.sum | 14 +---- gum.go | 25 +++++++++ internal/stdin/stdin.go | 23 ++++++--- pager/command.go | 42 +++++++++++++++ pager/options.go | 10 ++++ pager/pager.go | 39 ++++++++++++++ table/comma.csv | 5 ++ table/command.go | 111 ++++++++++++++++++++++++++++++++++++++++ table/example.csv | 19 +++++++ table/invalid.csv | 19 +++++++ table/options.go | 15 ++++++ table/table.go | 58 +++++++++++++++++++++ 13 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 pager/command.go create mode 100644 pager/options.go create mode 100644 pager/pager.go create mode 100644 table/comma.csv create mode 100644 table/command.go create mode 100644 table/example.csv create mode 100644 table/invalid.csv create mode 100644 table/options.go create mode 100644 table/table.go diff --git a/go.mod b/go.mod index e610cc9..b76db07 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7aef442..31fab29 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/gum.go b/gum.go index b7233c3..a330b6d 100644 --- a/gum.go +++ b/gum.go @@ -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 // diff --git a/internal/stdin/stdin.go b/internal/stdin/stdin.go index b495f1d..8178241 100644 --- a/internal/stdin/stdin.go +++ b/internal/stdin/stdin.go @@ -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 +} diff --git a/pager/command.go b/pager/command.go new file mode 100644 index 0000000..01388b3 --- /dev/null +++ b/pager/command.go @@ -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() +} diff --git a/pager/options.go b/pager/options.go new file mode 100644 index 0000000..808dbf5 --- /dev/null +++ b/pager/options.go @@ -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"` +} diff --git a/pager/pager.go b/pager/pager.go new file mode 100644 index 0000000..f82b0ca --- /dev/null +++ b/pager/pager.go @@ -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") +} diff --git a/table/comma.csv b/table/comma.csv new file mode 100644 index 0000000..8c208f6 --- /dev/null +++ b/table/comma.csv @@ -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" \ No newline at end of file diff --git a/table/command.go b/table/command.go new file mode 100644 index 0000000..364fa83 --- /dev/null +++ b/table/command.go @@ -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 +} diff --git a/table/example.csv b/table/example.csv new file mode 100644 index 0000000..4938bf9 --- /dev/null +++ b/table/example.csv @@ -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 diff --git a/table/invalid.csv b/table/invalid.csv new file mode 100644 index 0000000..6cc64fa --- /dev/null +++ b/table/invalid.csv @@ -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 diff --git a/table/options.go b/table/options.go new file mode 100644 index 0000000..b2112c3 --- /dev/null +++ b/table/options.go @@ -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:""` +} diff --git a/table/table.go b/table/table.go new file mode 100644 index 0000000..4c5929a --- /dev/null +++ b/table/table.go @@ -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() +}