diff --git a/README.md b/README.md index 00fa6ab8a..ebf4fd5cd 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,50 @@ gum pager < README.md Shell running gum pager +#### Progress + +Show progress with just plain text or a progress bar given a limit.
+ +Show progress of something which has a determined length. +```bash +# this example is only meant for illustration purpose. +readarray files < <(find "$HOME" -type f 2>/dev/null) + +<<<"${files[@]}" $gum progress -o --limit ${#files[@]} --title 'checking...' | while read -r file; do + md5sum "$file" >> /tmp/cksums.txt 2>/dev/null +done +``` + +Given no limit progress is printed as text only. +```bash +find / -type d 2> /dev/null | gum progress --show-output > /tmp/dump.txt +``` + +Use a custom format. +```bash +find / -type d 2> /dev/null | gum progress -f '{Iter} ~ {Elapsed}' -o > /tmp/dump.txt +``` + +Using a different progress indicator + +```bash +{ + sleep 2s + echo ":step:Long process 1 completed" + + sleep 2s + echo ":step:Long process 2 completed" + + sleep 2s + echo ":step:Long process 3 completed" +} | gum progress -l 3 --progress-indicator ':step:' -o --hide-progress-indicator +``` + +**Note:** when using a --progress-indicator != '\n' (the default) with output +going to the terminal (using -o/--output not piped) +the lines will still be printed line wise. This has no influence on the +measurement of progress! + #### Spin Display a spinner while running a script or command. The spinner will diff --git a/go.mod b/go.mod index e831afc5b..33c2ea3b2 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/dlclark/regexp2 v1.8.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 016d3c6cf..f56853027 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/glamour v0.6.1-0.20230531150759-6d5b52861a9d h1:S4Ejl/M2VrryIgDrDbiuvkwMUDa67/t/H3Wz3i2/vUw= github.com/charmbracelet/glamour v0.6.1-0.20230531150759-6d5b52861a9d/go.mod h1:swCB3CXFsh22H1ESDYdY1tirLiNqCziulDyJ1B6Nt7Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw= diff --git a/gum.go b/gum.go index 2808c764a..65cab376d 100644 --- a/gum.go +++ b/gum.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/gum/log" "github.com/charmbracelet/gum/man" "github.com/charmbracelet/gum/pager" + "github.com/charmbracelet/gum/progress" "github.com/charmbracelet/gum/spin" "github.com/charmbracelet/gum/style" "github.com/charmbracelet/gum/table" @@ -137,6 +138,12 @@ type Gum struct { // Pager pager.Options `cmd:"" help:"Scroll through a file"` + // Progress provides a shell script interface for the progress bubble. + // https://github.com/charmbracelet/bubbles/tree/master/progress + // + // On top ... when no limit is set some other progress information is displayed. + Progress progress.Options `cmd:"" help:"Show progressbar"` + // Spin provides a shell script interface for the spinner bubble. // https://github.com/charmbracelet/bubbles/tree/master/spinner // diff --git a/progress/barinfo.go b/progress/barinfo.go new file mode 100644 index 000000000..05b36dd62 --- /dev/null +++ b/progress/barinfo.go @@ -0,0 +1,64 @@ +package progress + +import ( + "math" + "time" +) + +type barInfo struct { + iter uint + title string + limit uint + incrementTs []time.Time +} + +func newBarInfo(title string, limit uint) *barInfo { + info := &barInfo{ + title: title, + limit: limit, + incrementTs: make([]time.Time, 0, limit), + } + info.incrementTs = append(info.incrementTs, time.Now()) + return info +} + +func (self *barInfo) Update(progressAmount uint) { + self.iter += progressAmount + + now := time.Now() + for i := uint(0); i < progressAmount; i++ { + self.incrementTs = append(self.incrementTs, now) + } +} + +func (self *barInfo) Elapsed() time.Duration { + return time.Now().Sub(self.incrementTs[0]).Truncate(time.Second) +} + +func (self *barInfo) Pct() int { + pct := math.Round(safeDivide(float64(self.iter), float64(self.limit)) * 100) + return int(pct) +} + +func (self *barInfo) Avg() time.Duration { + if len(self.incrementTs) < 2 { + return time.Now().Sub(self.incrementTs[0]) + } + var sum time.Duration + for i := 1; i < len(self.incrementTs); i++ { + sum += self.incrementTs[i].Sub(self.incrementTs[i-1]) + } + return (sum / time.Duration(self.iter)) +} + +func (self *barInfo) Eta() time.Duration { + if self.iter >= self.limit { + return 0 + } + avg := self.Avg() + if avg == 0 { + return 0 + } + + return time.Duration(self.limit-self.iter) * avg +} diff --git a/progress/command.go b/progress/command.go new file mode 100644 index 000000000..ceb03372e --- /dev/null +++ b/progress/command.go @@ -0,0 +1,71 @@ +// Package progress provides a simple progress indicator +// for tracking the progress for input provided via stdin. +// +// It shows a progress bar when the limit is known and some simple stats when not. +// +// ------------------------------------ +// #!/bin/bash +// +// urls=( +// +// "http://example.com/file1.txt" +// "http://example.com/file2.txt" +// "http://example.com/file3.txt" +// +// ) +// +// for url in "${urls[@]}"; do +// +// wget -q -nc "$url" +// echo "Downloaded: $url" +// +// done | gum progress --show-output --limit ${#urls[@]} +// ------------------------------------ +package progress + +import ( + "bufio" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" +) + +func (o Options) GetFormatString() string { + if o.Format != "" { + return o.Format + } + + switch { + case o.Limit == 0 && o.Title == "": + return "[Elapsed ~ {Elapsed}] Iter {Iter}" + case o.Limit == 0 && o.Title != "": + return "[Elapsed ~ {Elapsed}] Iter {Iter} ~ {Title}" + case o.Limit > 0 && o.Title == "": + return "{Bar} {Pct}" + case o.Limit > 0 && o.Title != "": + return "{Title} ~ {Bar} {Pct}" + default: + return "{Iter}" + } +} + +func (o Options) Run() error { + m := &model{ + reader: bufio.NewReader(os.Stdin), + output: o.ShowOutput, + isTTY: isatty.IsTerminal(os.Stdout.Fd()), + progressIndicator: o.ProgressIndicator, + hideProgressIndicator: o.HideProgressIndicator, + + bfmt: newBarFormatter(o.GetFormatString(), o.ProgressColor), + binfo: newBarInfo(o.TitleStyle.ToLipgloss().Render(o.Title), o.Limit), + } + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run progress: %w", err) + } + + return m.err +} diff --git a/progress/format.go b/progress/format.go new file mode 100644 index 000000000..694e98918 --- /dev/null +++ b/progress/format.go @@ -0,0 +1,107 @@ +package progress + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/lipgloss" +) + +type barFormatter struct { + pbar progress.Model + numBars int + tplstr string +} + +var barPlaceholderRe = regexp.MustCompile(`{\s*Bar\s*}`) +var nonBarPlaceholderRe = regexp.MustCompile(`{\s*(Title|Elapsed|Iter|Avg|Pct|Eta|Remaining|Limit)\s*}`) + +func newBarFormatter(tplstr string, barColor string) *barFormatter { + var bar progress.Model + if barColor != "" { + bar = progress.New(progress.WithoutPercentage(), progress.WithSolidFill(barColor)) + } else { + bar = progress.New(progress.WithoutPercentage()) + } + barfmt := &barFormatter{ + pbar: bar, + tplstr: tplstr, + numBars: len(barPlaceholderRe.FindAllString(tplstr, -1)), + } + return barfmt +} + +func (self *barFormatter) Render(info *barInfo, maxWidth int) string { + rendered := nonBarPlaceholderRe.ReplaceAllStringFunc(self.tplstr, func(s string) string { + switch strings.TrimSpace(s[1 : len(s)-1]) { + case "Title": + return info.title + case "Iter": + return fmt.Sprint(info.iter) + case "Limit": + if info.limit == 0 { + return s + } + return fmt.Sprint(info.limit) + case "Elapsed": + return info.Elapsed().String() + case "Pct": + if info.limit == 0 { + return s + } + return fmt.Sprintf("%d%%", info.Pct()) + case "Avg": + return info.Avg().Round(time.Second).String() + case "Remaining": + if info.limit == 0 { + return s + } + return info.Eta().Round(time.Second).String() + case "Eta": + if info.limit == 0 { + return s + } + return time.Now().Add(info.Eta()).Format(time.TimeOnly) + default: + return "" + } + }) + + if info.limit > 0 && self.numBars > 0 { + self.pbar.Width = max(0, (maxWidth-lipgloss.Width(rendered))/int(self.numBars)) + bar := self.pbar.ViewAs(safeDivide(float64(info.iter), float64(info.limit))) + rendered = barPlaceholderRe.ReplaceAllLiteralString(rendered, bar) + } + return rendered +} + +func min(a, b uint) uint { + if a < b { + return a + } + return b +} + +func minI(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func safeDivide(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b +} diff --git a/progress/options.go b/progress/options.go new file mode 100644 index 000000000..4bf467113 --- /dev/null +++ b/progress/options.go @@ -0,0 +1,18 @@ +package progress + +import ( + "github.com/charmbracelet/gum/style" +) + +type Options struct { + Title string `help:"Text to display to user while spinning" env:"GUM_PROGRESS_TITLE"` + TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_PROGRESS_TITLE_"` + Format string `short:"f" help:"What format to use for rendering the bar. Choose from: {Iter}, {Elapsed}, {Title} and {Avg} or see --limit for more options. Unknown options remain untouched." envprefix:"GUM_PROGRESS_FORMAT"` + ProgressColor string `help:"Set the color for the progress" envprefix:"GUM_PROGRESS_PROGRESS_COLOR"` + + ProgressIndicator string `help:"What indicator to use for counting progress" default:"\n" env:"GUM_PROGRESS_PROGRESS_INDICATOR"` + HideProgressIndicator bool `help:"Don't show the --progress-indicator in the output. Only makes sense in combination with --show-output" default:"false"` + ShowOutput bool `short:"o" help:"Print what gum reads" default:"false"` + + Limit uint `short:"l" help:"Species how many items there are (enables {Bar}, {Limit}, {Remaining}, {Eta} and {Pct} to be used in --format)"` +} diff --git a/progress/progress.go b/progress/progress.go new file mode 100644 index 000000000..050645dc0 --- /dev/null +++ b/progress/progress.go @@ -0,0 +1,146 @@ +package progress + +import ( + "bytes" + "fmt" + "io" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type model struct { + /// where to read the content from (stdin) + reader io.Reader + /// should the content read from stdin be printed out + output bool + /// offset in model.buff where to write to next + offset int + /// buffer for storing content read from stdin + buff [1024]byte + /// tells if the program's output (stdout) is a tty + isTTY bool + + /// what string in the content indicates progress + progressIndicator string + /// should the progress indicator be printed out when model.output is set to true + hideProgressIndicator bool + + /// stores metadata of the progress + binfo *barInfo + /// renderer for the progress depending on what format was specified + bfmt *barFormatter + + /// stores the width of the terminal received via tea.Msg + width int + + /// stores the error that occured so that it can later be communicated + /// to the user + err error +} + +type progressMsg uint +type finishedMsg struct{} +type tickMsg struct{} + +func (m *model) readUntilProgressOrEOF() tea.Cmd { + return func() tea.Msg { + amountRead, err := m.reader.Read(m.buff[m.offset:]) + if err != nil { + if err == io.EOF { + return finishedMsg{} + } + m.err = fmt.Errorf("failed to read the input: %w\n", err) + return tea.Quit + } + + read := m.buff[m.offset : m.offset+amountRead] + progress := bytes.Count(read, []byte(m.progressIndicator)) + if m.hideProgressIndicator { + read = bytes.ReplaceAll(read, []byte(m.progressIndicator), []byte{}) + copy(m.buff[m.offset:], read) + } + m.offset += len(read) + return progressMsg(progress) + } +} + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg{} + }) +} + +func (m *model) Init() tea.Cmd { + return tea.Batch(m.readUntilProgressOrEOF(), tick()) +} + +func (m *model) View() string { + padding := 2 + rendered := m.bfmt.Render(m.binfo, max(0, m.width-(padding*2))) + + return lipgloss.NewStyle(). + PaddingLeft(padding). + PaddingRight(padding). + MaxWidth(m.width). + Width(m.width). + Render(rendered) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + case tickMsg: + return m, tick() + case finishedMsg: + if m.output && m.offset > 0 && m.isTTY { + return m, tea.Batch(tea.Println(string(m.buff[:m.offset])), tea.Quit) + } + return m, tea.Quit + case progressMsg: + var cmd tea.Cmd + switch { + // given that we have something to print, and stdout is a tty the + // user sees both this model and the output in a terminal. Therefore + // we should/can use tea.Println so that rendering is correct (here meaining + // that the model and the output don't become intermingled). The output + // will go to stderr as configured in command.go but that does not matter. + case m.output && m.offset > 0 && m.isTTY: + // tea.Println always adds a new-line at the end so we can only print + // full lines otherwise the added \n cut's the string that we want to print + end := bytes.LastIndexByte(m.buff[:m.offset], '\n') + if end < 0 { + cmd = m.readUntilProgressOrEOF() + break + } + + start := end + if m.buff[max(0, end-1)] == '\r' { + start = end - 1 + } + + toPrint := m.buff[:start] + remaining := m.buff[end+1 : m.offset] + cmd = tea.Batch(m.readUntilProgressOrEOF(), tea.Println(string(toPrint))) + copy(m.buff[:], remaining) + m.offset = len(remaining) + // we have some output that we want to print but it is most likely + // being piped to another process thus we can just print + // the message to stdout for the other process to read. + case m.output && m.offset > 0: + os.Stdout.Write(m.buff[:m.offset]) + m.offset = 0 + cmd = m.readUntilProgressOrEOF() + // nothing to print here + default: + cmd = m.readUntilProgressOrEOF() + m.offset = 0 + } + m.binfo.Update(uint(msg)) + return m, cmd + } + return m, nil +}