From 32786f7764ce58acb5da8319912459043e2bbb73 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 14:34:10 -0300 Subject: [PATCH 1/9] feat(spin): --show-stdout --show-stderr (#774) closes #362 --- spin/command.go | 3 ++- spin/options.go | 4 +++- spin/spin.go | 20 +++++++++++++------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/spin/command.go b/spin/command.go index a792b3f9b..20b31fa7a 100644 --- a/spin/command.go +++ b/spin/command.go @@ -24,7 +24,8 @@ func (o Options) Run() error { title: o.TitleStyle.ToLipgloss().Render(o.Title), command: o.Command, align: o.Align, - showOutput: o.ShowOutput && isTTY, + showStdout: (o.ShowOutput || o.ShowStdout) && isTTY, + showStderr: (o.ShowOutput || o.ShowStderr) && isTTY, showError: o.ShowError, isTTY: isTTY, } diff --git a/spin/options.go b/spin/options.go index e0b32085d..5243eab7d 100644 --- a/spin/options.go +++ b/spin/options.go @@ -10,8 +10,10 @@ import ( type Options struct { Command []string `arg:"" help:"Command to run"` - ShowOutput bool `help:"Show or pipe output of command during execution" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"` + ShowOutput bool `help:"Show or pipe output of command during execution (shows both STDOUT and STDERR)" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"` ShowError bool `help:"Show output of command only if the command fails" default:"false" env:"GUM_SPIN_SHOW_ERROR"` + ShowStdout bool `help:"Show STDOUT output" default:"false" env:"GUM_SPIN_SHOW_STDOUT"` + ShowStderr bool `help:"Show STDERR errput" default:"false" env:"GUM_SPIN_SHOW_STDERR"` Spinner string `help:"Spinner type" short:"s" type:"spinner" enum:"line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger" default:"dot" env:"GUM_SPIN_SPINNER"` SpinnerStyle style.Styles `embed:"" prefix:"spinner." set:"defaultForeground=212" envprefix:"GUM_SPIN_SPINNER_"` Title string `help:"Text to display to user while spinning" default:"Loading..." env:"GUM_SPIN_TITLE"` diff --git a/spin/spin.go b/spin/spin.go index 1a3880080..cb9a1aa56 100644 --- a/spin/spin.go +++ b/spin/spin.go @@ -37,7 +37,8 @@ type model struct { stdout string stderr string output string - showOutput bool + showStdout bool + showStderr bool showError bool } @@ -106,8 +107,16 @@ func (m model) View() string { return m.title } - if m.quitting && m.showOutput { - return strings.TrimPrefix(errbuf.String()+"\n"+outbuf.String(), "\n") + var out string + if m.showStderr { + out += errbuf.String() + } + if m.showStdout { + out += outbuf.String() + } + + if m.quitting && out != "" { + return out } var header string @@ -116,10 +125,7 @@ func (m model) View() string { } else { header = m.title + " " + m.spinner.View() } - if !m.showOutput { - return header - } - return header + errbuf.String() + "\n" + outbuf.String() + return header + "\n" + out } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { From 64d69eb59bed32206a3c1f8bafada435aa1a4911 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 16:59:14 -0300 Subject: [PATCH 2/9] feat(version): adds command to check current gum version (#775) * feat(version): adds command to check current gum version closes #352 Signed-off-by: Carlos Alexandro Becker * Update version/command.go Co-authored-by: Gareth Jones --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Gareth Jones --- go.mod | 1 + go.sum | 2 ++ gum.go | 11 +++++++++++ main.go | 1 + version/command.go | 25 +++++++++++++++++++++++++ version/options.go | 6 ++++++ 6 files changed, 46 insertions(+) create mode 100644 version/command.go create mode 100644 version/options.go diff --git a/go.mod b/go.mod index 14d781e2b..dc913fdf8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/charmbracelet/gum go 1.21 require ( + github.com/Masterminds/semver/v3 v3.3.1 github.com/alecthomas/kong v1.6.0 github.com/alecthomas/mango-kong v0.1.0 github.com/charmbracelet/bubbles v0.20.0 diff --git a/go.sum b/go.sum index a1c5b5bfc..8c72ddd1f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= diff --git a/gum.go b/gum.go index 7a59f2077..41913141f 100644 --- a/gum.go +++ b/gum.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/gum/spin" "github.com/charmbracelet/gum/style" "github.com/charmbracelet/gum/table" + "github.com/charmbracelet/gum/version" "github.com/charmbracelet/gum/write" ) @@ -214,4 +215,14 @@ type Gum struct { // $ gum log --level info "Hello, world!" // Log log.Options `cmd:"" help:"Log messages to output"` + + // VersionCheck provides a command that checks if the current gum version + // matches a given semantic version constraint. + // + // It can be used to check that a minimum gum version is installed in a + // script. + // + // $ gum version-check '~> 0.15' + // + VersionCheck version.Options `cmd:"" help:"Semver check current gum version"` } diff --git a/main.go b/main.go index 75741e2cd..18fa9f087 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,7 @@ func main() { }), kong.Vars{ "version": version, + "versionNumber": Version, "defaultHeight": "0", "defaultWidth": "0", "defaultAlign": "left", diff --git a/version/command.go b/version/command.go new file mode 100644 index 000000000..5102b3469 --- /dev/null +++ b/version/command.go @@ -0,0 +1,25 @@ +package version + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/alecthomas/kong" +) + +// Run check that a given version matches a semantic version constraint. +func (o Options) Run(ctx *kong.Context) error { + c, err := semver.NewConstraint(o.Constraint) + if err != nil { + return fmt.Errorf("could not parse range %s: %w", o.Constraint, err) + } + current := ctx.Model.Vars()["versionNumber"] + v, err := semver.NewVersion(current) + if err != nil { + return fmt.Errorf("could not parse version %s: %w", current, err) + } + if !c.Check(v) { + return fmt.Errorf("gum version %q is not within given range %q", current, o.Constraint) + } + return nil +} diff --git a/version/options.go b/version/options.go new file mode 100644 index 000000000..2dbb19d95 --- /dev/null +++ b/version/options.go @@ -0,0 +1,6 @@ +package version + +// Options is the set of options that can be used with version. +type Options struct { + Constraint string `arg:"" help:"Semantic version constraint"` +} From f230a3d5fc491b1464a8f847cad4725878dcf257 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 16:59:32 -0300 Subject: [PATCH 3/9] feat(filter): allow to focus out of filter (#776) - esc focus out of the filter - esc with filter blurred quits - g/G/j/k navigation when filter is blurred closes #201 --- choose/choose.go | 1 - filter/filter.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/choose/choose.go b/choose/choose.go index ff6cf5e33..6594676ba 100644 --- a/choose/choose.go +++ b/choose/choose.go @@ -39,7 +39,6 @@ func defaultKeymap() keymap { ), End: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G", "end"), ), ToggleAll: key.NewBinding( key.WithKeys("a", "A", "ctrl+a"), diff --git a/filter/filter.go b/filter/filter.go index 23c0006eb..84f2b943c 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -30,6 +30,18 @@ func defaultKeymap() keymap { Up: key.NewBinding( key.WithKeys("up", "ctrl+k", "ctrl+p"), ), + NDown: key.NewBinding( + key.WithKeys("j"), + ), + NUp: key.NewBinding( + key.WithKeys("k"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("G", "end"), + ), ToggleAndNext: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "toggle"), @@ -50,6 +62,14 @@ func defaultKeymap() keymap { key.WithHelp("ctrl+a", "select all"), key.WithDisabled(), ), + FocusInSearch: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + FocusOutSearch: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "blur search"), + ), Quit: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "quit"), @@ -66,8 +86,14 @@ func defaultKeymap() keymap { } type keymap struct { + FocusInSearch, + FocusOutSearch, Down, Up, + NDown, + NUp, + Home, + End, ToggleAndNext, ToggleAndPrevious, ToggleAll, @@ -87,6 +113,8 @@ func (k keymap) ShortHelp() []key.Binding { key.WithKeys("up", "down"), key.WithHelp("↓↑", "navigate"), ), + k.FocusInSearch, + k.FocusOutSearch, k.ToggleAndNext, k.ToggleAll, k.Submit, @@ -262,6 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: km := m.keymap switch { + case key.Matches(msg, km.FocusInSearch): + m.textinput.Focus() + case key.Matches(msg, km.FocusOutSearch): + m.textinput.Blur() case key.Matches(msg, km.Quit): m.quitting = true return m, tea.Quit @@ -272,10 +304,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true m.submitted = true return m, tea.Quit - case key.Matches(msg, km.Down): + case key.Matches(msg, km.Down, km.NDown): m.CursorDown() - case key.Matches(msg, km.Up): + case key.Matches(msg, km.Up, km.NUp): m.CursorUp() + case key.Matches(msg, km.Home): + m.cursor = 0 + m.viewport.GotoTop() + case key.Matches(msg, km.End): + m.cursor = len(m.choices) - 1 + m.viewport.GotoBottom() case key.Matches(msg, km.ToggleAndNext): if m.limit == 1 { break // no op @@ -344,6 +382,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + m.keymap.FocusInSearch.SetEnabled(!m.textinput.Focused()) + m.keymap.FocusOutSearch.SetEnabled(m.textinput.Focused()) + m.keymap.NUp.SetEnabled(!m.textinput.Focused()) + m.keymap.NDown.SetEnabled(!m.textinput.Focused()) + m.keymap.Home.SetEnabled(!m.textinput.Focused()) + m.keymap.End.SetEnabled(!m.textinput.Focused()) + // It's possible that filtering items have caused fewer matches. So, ensure // that the selected index is within the bounds of the number of matches. m.cursor = clamp(0, len(m.matches)-1, m.cursor) From 966237b378e5fe17065fa7e9d9713f400dc3077e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 16:59:44 -0300 Subject: [PATCH 4/9] feat(filter): allow to pre-select items with --selected (#777) closes #593 --- filter/command.go | 23 +++++++++++++++++++---- filter/options.go | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/filter/command.go b/filter/command.go index dfc827465..c933a05b9 100644 --- a/filter/command.go +++ b/filter/command.go @@ -86,7 +86,7 @@ func (o Options) Run() error { km.ToggleAll.SetEnabled(true) } - p := tea.NewProgram(model{ + m := model{ choices: o.Options, indicator: o.Indicator, matches: matches, @@ -112,14 +112,29 @@ func (o Options) Run() error { showHelp: o.ShowHelp, keymap: km, help: help.New(), - }, options...) + } + + for _, s := range o.Selected { + if o.NoLimit || o.Limit > 1 { + m.selected[s] = struct{}{} + } + } + + if len(o.Selected) > 0 { + for i, match := range matches { + if match.Str == o.Selected[0] { + m.cursor = i + break + } + } + } - tm, err := p.Run() + tm, err := tea.NewProgram(m, options...).Run() if err != nil { return fmt.Errorf("unable to run filter: %w", err) } - m := tm.(model) + m = tm.(model) if !m.submitted { return errors.New("nothing selected") } diff --git a/filter/options.go b/filter/options.go index 3cdefe8fe..7fab9009b 100644 --- a/filter/options.go +++ b/filter/options.go @@ -15,6 +15,7 @@ type Options struct { Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"` NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"` SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` + Selected []string `help:"Options that should start as selected (selects all if given '*')" default:"" env:"GUM_FILTER_SELECTED"` ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_FILTER_SHOW_HELP"` Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"" default:"true" group:"Selection"` SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"` From 0e501ea47f97fbd4900bf850c246ede0d19301a4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 16:59:59 -0300 Subject: [PATCH 5/9] feat(filter): --select-if-one returns if single match (#778) closes #311 Signed-off-by: Carlos Alexandro Becker --- filter/command.go | 34 ++++++++++++---------------------- internal/tty/tty.go | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 internal/tty/tty.go diff --git a/filter/command.go b/filter/command.go index c933a05b9..c2a46cfeb 100644 --- a/filter/command.go +++ b/filter/command.go @@ -13,8 +13,7 @@ import ( "github.com/charmbracelet/gum/internal/files" "github.com/charmbracelet/gum/internal/stdin" "github.com/charmbracelet/gum/internal/timeout" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/term" + "github.com/charmbracelet/gum/internal/tty" "github.com/sahilm/fuzzy" ) @@ -44,11 +43,6 @@ func (o Options) Run() error { return errors.New("no options provided, see `gum filter --help`") } - if o.SelectIfOne && len(o.Options) == 1 { - fmt.Println(o.Options[0]) - return nil - } - ctx, cancel := timeout.Context(o.Timeout) defer cancel() @@ -74,11 +68,16 @@ func (o Options) Run() error { matches = matchAll(o.Options) } - km := defaultKeymap() - if o.NoLimit { o.Limit = len(o.Options) } + + if o.SelectIfOne && len(matches) == 1 { + tty.Println(matches[0].Str) + return nil + } + + km := defaultKeymap() if o.NoLimit || o.Limit > 1 { km.Toggle.SetEnabled(true) km.ToggleAndPrevious.SetEnabled(true) @@ -138,30 +137,21 @@ func (o Options) Run() error { if !m.submitted { return errors.New("nothing selected") } - isTTY := term.IsTerminal(os.Stdout.Fd()) // allSelections contains values only if limit is greater // than 1 or if flag --no-limit is passed, hence there is // no need to further checks if len(m.selected) > 0 { - o.checkSelected(m, isTTY) + o.checkSelected(m) } else if len(m.matches) > m.cursor && m.cursor >= 0 { - if isTTY { - fmt.Println(m.matches[m.cursor].Str) - } else { - fmt.Println(ansi.Strip(m.matches[m.cursor].Str)) - } + tty.Println(m.matches[m.cursor].Str) } return nil } -func (o Options) checkSelected(m model, isTTY bool) { +func (o Options) checkSelected(m model) { for k := range m.selected { - if isTTY { - fmt.Println(k) - } else { - fmt.Println(ansi.Strip(k)) - } + tty.Println(k) } } diff --git a/internal/tty/tty.go b/internal/tty/tty.go new file mode 100644 index 000000000..75b8237d7 --- /dev/null +++ b/internal/tty/tty.go @@ -0,0 +1,24 @@ +// Package tty provides tty-aware printing. +package tty + +import ( + "fmt" + "os" + "sync" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" +) + +var isTTY = sync.OnceValue(func() bool { + return term.IsTerminal(os.Stdout.Fd()) +}) + +// Println handles println, striping ansi sequences if stdout is not a tty. +func Println(s string) { + if isTTY() { + fmt.Println(s) + return + } + fmt.Println(ansi.Strip(s)) +} From 6d405c49b1b929b771cda5fe939cf5900e392b70 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 13 Dec 2024 17:03:42 -0300 Subject: [PATCH 6/9] feat(choose,filter): --input-delimiter --output-delimiter (#779) * feat(choose,filter): --input-delimiter --output-delimiter allows to change how content from stdin is used, and how results are printed. one could get around it piping into and from `tr`, but results aren't quite right, especially when `tr '\n' ','` for example, as it'll add an extra `,` in the end of the string. This makes things a bit cleaner, hopefully. closes #274 * fix: use new tty pkg --- choose/command.go | 19 ++++++------------- choose/options.go | 2 ++ filter/command.go | 6 ++++-- filter/options.go | 2 ++ 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/choose/command.go b/choose/command.go index ea9faf7f3..92a57526d 100644 --- a/choose/command.go +++ b/choose/command.go @@ -13,9 +13,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/gum/internal/stdin" "github.com/charmbracelet/gum/internal/timeout" + "github.com/charmbracelet/gum/internal/tty" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/term" ) // Run provides a shell script interface for choosing between different through @@ -31,7 +30,7 @@ func (o Options) Run() error { if input == "" { return errors.New("no options provided, see `gum choose --help`") } - o.Options = strings.Split(input, "\n") + o.Options = strings.Split(input, o.InputDelimiter) } if o.SelectIfOne && len(o.Options) == 1 { @@ -146,19 +145,13 @@ func (o Options) Run() error { return m.items[i].order < m.items[j].order }) } - var s strings.Builder + + var out []string for _, item := range m.items { if item.selected { - s.WriteString(item.text) - s.WriteRune('\n') + out = append(out, item.text) } } - - if term.IsTerminal(os.Stdout.Fd()) { - fmt.Print(s.String()) - } else { - fmt.Print(ansi.Strip(s.String())) - } - + tty.Println(strings.Join(out, o.OutputDelimiter)) return nil } diff --git a/choose/options.go b/choose/options.go index ec4413401..4777ef0a7 100644 --- a/choose/options.go +++ b/choose/options.go @@ -22,6 +22,8 @@ type Options struct { UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"` Selected []string `help:"Options that should start as selected (selects all if given '*')" default:"" env:"GUM_CHOOSE_SELECTED"` SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` + InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"` + OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"` CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"` diff --git a/filter/command.go b/filter/command.go index c2a46cfeb..2c6b9b0ed 100644 --- a/filter/command.go +++ b/filter/command.go @@ -33,7 +33,7 @@ func (o Options) Run() error { if len(o.Options) == 0 { if input, _ := stdin.ReadStrip(); input != "" { - o.Options = strings.Split(input, "\n") + o.Options = strings.Split(input, o.InputDelimiter) } else { o.Options = files.List() } @@ -151,7 +151,9 @@ func (o Options) Run() error { } func (o Options) checkSelected(m model) { + out := []string{} for k := range m.selected { - tty.Println(k) + out = append(out, k) } + tty.Println(strings.Join(out, o.OutputDelimiter)) } diff --git a/filter/options.go b/filter/options.go index 7fab9009b..b439b372e 100644 --- a/filter/options.go +++ b/filter/options.go @@ -38,6 +38,8 @@ type Options struct { Fuzzy bool `help:"Enable fuzzy matching; otherwise match from start of word" default:"true" env:"GUM_FILTER_FUZZY" negatable:""` FuzzySort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:""` Timeout time.Duration `help:"Timeout until filter command aborts" default:"0s" env:"GUM_FILTER_TIMEOUT"` + InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_FILTER_INPUT_DELIMITER"` + OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_FILTER_OUTPUT_DELIMITER"` // Deprecated: use [FuzzySort]. This will be removed at some point. Sort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:"" hidden:""` From 2e321f57e245147fe55ee58ef9c046db91b76e17 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 17 Dec 2024 09:47:31 -0300 Subject: [PATCH 7/9] feat(choose): label delimiters (#783) I'm not sure if I like this impl, but it does work. closes #406 --- choose/command.go | 22 ++++++++++++++++++++-- choose/options.go | 1 + go.mod | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/choose/command.go b/choose/command.go index 92a57526d..285eb16d8 100644 --- a/choose/command.go +++ b/choose/command.go @@ -3,6 +3,7 @@ package choose import ( "errors" "fmt" + "maps" "os" "slices" "sort" @@ -33,8 +34,25 @@ func (o Options) Run() error { o.Options = strings.Split(input, o.InputDelimiter) } + // normalize options into a map + options := map[string]string{} + for _, opt := range o.Options { + if o.LabelDelimiter == "" { + options[opt] = opt + continue + } + label, value, ok := strings.Cut(opt, o.LabelDelimiter) + if !ok { + return fmt.Errorf("invalid option format: %q", opt) + } + options[label] = value + } + if o.LabelDelimiter != "" { + o.Options = slices.Collect(maps.Keys(options)) + } + if o.SelectIfOne && len(o.Options) == 1 { - fmt.Println(o.Options[0]) + fmt.Println(options[o.Options[0]]) return nil } @@ -149,7 +167,7 @@ func (o Options) Run() error { var out []string for _, item := range m.items { if item.selected { - out = append(out, item.text) + out = append(out, options[item.text]) } } tty.Println(strings.Join(out, o.OutputDelimiter)) diff --git a/choose/options.go b/choose/options.go index 4777ef0a7..0f21a4352 100644 --- a/choose/options.go +++ b/choose/options.go @@ -24,6 +24,7 @@ type Options struct { SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"` OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"` + LabelDelimiter string `help:"Allows to set a delimiter, so options can be set as label:value" default:"" env:"GUM_CHOOSE_LABEL_DELIMITER"` CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"` diff --git a/go.mod b/go.mod index dc913fdf8..9614f9379 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/charmbracelet/gum -go 1.21 +go 1.23.0 require ( github.com/Masterminds/semver/v3 v3.3.1 From 4cedf9fca0082fc78f381cb78f619fbc51e271df Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 17 Dec 2024 13:56:19 -0300 Subject: [PATCH 8/9] feat: --no-strip-ansi (#784) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Sridaran Thoniyil --- choose/command.go | 2 +- choose/options.go | 1 + filter/command.go | 2 +- filter/options.go | 1 + format/command.go | 2 +- format/options.go | 2 ++ input/command.go | 4 +-- input/options.go | 1 + internal/stdin/stdin.go | 54 ++++++++++++++++++++++++++++++++++------- style/command.go | 2 +- style/options.go | 7 +++--- write/command.go | 2 +- write/options.go | 1 + 13 files changed, 62 insertions(+), 19 deletions(-) diff --git a/choose/command.go b/choose/command.go index 285eb16d8..9a621a273 100644 --- a/choose/command.go +++ b/choose/command.go @@ -27,7 +27,7 @@ func (o Options) Run() error { ) if len(o.Options) <= 0 { - input, _ := stdin.ReadStrip() + input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)) if input == "" { return errors.New("no options provided, see `gum choose --help`") } diff --git a/choose/options.go b/choose/options.go index 0f21a4352..e5ea6b25f 100644 --- a/choose/options.go +++ b/choose/options.go @@ -25,6 +25,7 @@ type Options struct { InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"` OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"` LabelDelimiter string `help:"Allows to set a delimiter, so options can be set as label:value" default:"" env:"GUM_CHOOSE_LABEL_DELIMITER"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_CHOOSE_STRIP_ANSI"` CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"` diff --git a/filter/command.go b/filter/command.go index 2c6b9b0ed..12ad497e8 100644 --- a/filter/command.go +++ b/filter/command.go @@ -32,7 +32,7 @@ func (o Options) Run() error { v := viewport.New(o.Width, o.Height) if len(o.Options) == 0 { - if input, _ := stdin.ReadStrip(); input != "" { + if input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); input != "" { o.Options = strings.Split(input, o.InputDelimiter) } else { o.Options = files.List() diff --git a/filter/options.go b/filter/options.go index b439b372e..07c50ce72 100644 --- a/filter/options.go +++ b/filter/options.go @@ -40,6 +40,7 @@ type Options struct { Timeout time.Duration `help:"Timeout until filter command aborts" default:"0s" env:"GUM_FILTER_TIMEOUT"` InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_FILTER_INPUT_DELIMITER"` OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_FILTER_OUTPUT_DELIMITER"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FILTER_STRIP_ANSI"` // Deprecated: use [FuzzySort]. This will be removed at some point. Sort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:"" hidden:""` diff --git a/format/command.go b/format/command.go index 4cea2d9ff..3217f0e8a 100644 --- a/format/command.go +++ b/format/command.go @@ -24,7 +24,7 @@ func (o Options) Run() error { if len(o.Template) > 0 { input = strings.Join(o.Template, "\n") } else { - input, _ = stdin.ReadStrip() + input, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) } switch o.Type { diff --git a/format/options.go b/format/options.go index 6f36dcdba..ee87f9534 100644 --- a/format/options.go +++ b/format/options.go @@ -6,5 +6,7 @@ type Options struct { Theme string `help:"Glamour theme to use for markdown formatting" default:"pink" env:"GUM_FORMAT_THEME"` Language string `help:"Programming language to parse code" short:"l" default:"" env:"GUM_FORMAT_LANGUAGE"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FORMAT_STRIP_ANSI"` + Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"` } diff --git a/input/command.go b/input/command.go index 05dc1e357..7d96e72b4 100644 --- a/input/command.go +++ b/input/command.go @@ -17,7 +17,7 @@ import ( // https://github.com/charmbracelet/bubbles/textinput func (o Options) Run() error { if o.Value == "" { - if in, _ := stdin.ReadStrip(); in != "" { + if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" { o.Value = in } } @@ -25,7 +25,7 @@ func (o Options) Run() error { i := textinput.New() if o.Value != "" { i.SetValue(o.Value) - } else if in, _ := stdin.ReadStrip(); in != "" { + } else if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" { i.SetValue(in) } i.Focus() diff --git a/input/options.go b/input/options.go index 2df85e738..7463adbb0 100644 --- a/input/options.go +++ b/input/options.go @@ -22,4 +22,5 @@ type Options struct { Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"` HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"` Timeout time.Duration `help:"Timeout until input aborts" default:"0s" env:"GUM_INPUT_TIMEOUT"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_INPUT_STRIP_ANSI"` } diff --git a/internal/stdin/stdin.go b/internal/stdin/stdin.go index a5cebdb51..42b016285 100644 --- a/internal/stdin/stdin.go +++ b/internal/stdin/stdin.go @@ -10,16 +10,54 @@ import ( "github.com/charmbracelet/x/ansi" ) +type options struct { + ansiStrip bool + singleLine bool +} + +// Option is a read option. +type Option func(*options) + +// StripANSI optionally strips ansi sequences. +func StripANSI(b bool) Option { + return func(o *options) { + o.ansiStrip = b + } +} + +// SingleLine reads a single line. +func SingleLine(b bool) Option { + return func(o *options) { + o.singleLine = b + } +} + // Read reads input from an stdin pipe. -func Read() (string, error) { +func Read(opts ...Option) (string, error) { if IsEmpty() { return "", fmt.Errorf("stdin is empty") } + options := options{} + for _, opt := range opts { + opt(&options) + } + reader := bufio.NewReader(os.Stdin) var b strings.Builder - for { + if options.singleLine { + line, _, err := reader.ReadLine() + if err != nil { + return "", fmt.Errorf("failed to read line: %w", err) + } + _, err = b.Write(line) + if err != nil { + return "", fmt.Errorf("failed to write: %w", err) + } + } + + for !options.singleLine { r, _, err := reader.ReadRune() if err != nil && err == io.EOF { break @@ -30,13 +68,11 @@ func Read() (string, error) { } } - return strings.TrimSpace(b.String()), nil -} - -// ReadStrip reads input from an stdin pipe and strips ansi sequences. -func ReadStrip() (string, error) { - s, err := Read() - return ansi.Strip(s), err + s := strings.TrimSpace(b.String()) + if options.ansiStrip { + return ansi.Strip(s), nil + } + return s, nil } // IsEmpty returns whether stdin is empty. diff --git a/style/command.go b/style/command.go index be04c8453..0263211dc 100644 --- a/style/command.go +++ b/style/command.go @@ -20,7 +20,7 @@ func (o Options) Run() error { if len(o.Text) > 0 { text = strings.Join(o.Text, "\n") } else { - text, _ = stdin.ReadStrip() + text, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) if text == "" { return errors.New("no input provided, see `gum style --help`") } diff --git a/style/options.go b/style/options.go index cd2251d97..67545e5d5 100644 --- a/style/options.go +++ b/style/options.go @@ -2,9 +2,10 @@ package style // Options is the customization options for the style command. type Options struct { - Text []string `arg:"" optional:"" help:"Text to which to apply the style"` - Trim bool `help:"Trim whitespaces on every input line" default:"false"` - Style StylesNotHidden `embed:""` + Text []string `arg:"" optional:"" help:"Text to which to apply the style"` + Trim bool `help:"Trim whitespaces on every input line" default:"false"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_STYLE_STRIP_ANSI"` + Style StylesNotHidden `embed:""` } // Styles is a flag set of possible styles. diff --git a/write/command.go b/write/command.go index 34669cb8e..548cd9567 100644 --- a/write/command.go +++ b/write/command.go @@ -17,7 +17,7 @@ import ( // Run provides a shell script interface for the text area bubble. // https://github.com/charmbracelet/bubbles/textarea func (o Options) Run() error { - in, _ := stdin.ReadStrip() + in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)) if in != "" && o.Value == "" { o.Value = strings.ReplaceAll(in, "\r", "") } diff --git a/write/options.go b/write/options.go index 96ae7a876..16653eb43 100644 --- a/write/options.go +++ b/write/options.go @@ -21,6 +21,7 @@ type Options struct { ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_WRITE_SHOW_HELP"` CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_WRITE_CURSOR_MODE"` Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_WRITE_TIMEOUT"` + StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_WRITE_STRIP_ANSI"` BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"` From 0b89ff82d4cdcd59e5303df5fb5cc05fc83cc270 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 17 Dec 2024 14:17:43 -0300 Subject: [PATCH 9/9] feat: yes|gum confirm (#772) * feat: yes|gum confirm Signed-off-by: Carlos Alexandro Becker * fix: rebase on main --------- Signed-off-by: Carlos Alexandro Becker --- confirm/command.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/confirm/command.go b/confirm/command.go index ac151ad72..103fbad16 100644 --- a/confirm/command.go +++ b/confirm/command.go @@ -7,12 +7,25 @@ import ( "github.com/charmbracelet/bubbles/help" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/internal/stdin" "github.com/charmbracelet/gum/internal/timeout" ) +var errNotConfirmed = errors.New("not confirmed") + // Run provides a shell script interface for prompting a user to confirm an // action with an affirmative or negative answer. func (o Options) Run() error { + line, err := stdin.Read(stdin.SingleLine(true)) + if err == nil { + switch line { + case "yes", "y": + return nil + default: + return errNotConfirmed + } + } + ctx, cancel := timeout.Context(o.Timeout) defer cancel() @@ -52,5 +65,5 @@ func (o Options) Run() error { return nil } - return errors.New("not confirmed") + return errNotConfirmed }