Skip to content

Commit 3e8abc0

Browse files
committed
feat(progress): add progress cmd
1 parent 6a275b4 commit 3e8abc0

File tree

9 files changed

+460
-0
lines changed

9 files changed

+460
-0
lines changed

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,50 @@ gum pager < README.md
290290

291291
<img src="https://stuff.charm.sh/gum/pager.gif" width="600" alt="Shell running gum pager" />
292292

293+
#### Progress
294+
295+
Show progress with just plain text or a progress bar given a limit. <br>
296+
297+
Show progress of something which has a determined length.
298+
```bash
299+
# this example is only meant for illustration purpose.
300+
readarray files < <(find "$HOME" -type f 2>/dev/null)
301+
302+
<<<"${files[@]}" $gum progress -o --limit ${#files[@]} --title 'checking...' | while read -r file; do
303+
md5sum "$file" >> /tmp/cksums.txt 2>/dev/null
304+
done
305+
```
306+
307+
Given no limit progress is printed as text only.
308+
```bash
309+
find / -type d 2> /dev/null | gum progress --show-output > /tmp/dump.txt
310+
```
311+
312+
Use a custom format.
313+
```bash
314+
find / -type d 2> /dev/null | gum progress -f '{Iter} ~ {Elapsed}' -o > /tmp/dump.txt
315+
```
316+
317+
Using a different progress indicator
318+
319+
```bash
320+
{
321+
sleep 2s
322+
echo ":step:Long process 1 completed"
323+
324+
sleep 2s
325+
echo ":step:Long process 2 completed"
326+
327+
sleep 2s
328+
echo ":step:Long process 3 completed"
329+
} | gum progress -l 3 --progress-indicator ':step:' -o --hide-progress-indicator
330+
```
331+
332+
**Note:** when using a --progress-indicator != '\n' (the default) with output
333+
going to the terminal (using -o/--output not piped)
334+
the lines will still be printed line wise. This has no influence on the
335+
measurement of progress!
336+
293337
#### Spin
294338

295339
Display a spinner while running a script or command. The spinner will

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/atotto/clipboard v0.1.4 // indirect
2323
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2424
github.com/aymerick/douceur v0.2.0 // indirect
25+
github.com/charmbracelet/harmonica v0.2.0 // indirect
2526
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
2627
github.com/dlclark/regexp2 v1.8.1 // indirect
2728
github.com/dustin/go-humanize v1.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt
1818
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
1919
github.com/charmbracelet/glamour v0.6.1-0.20230531150759-6d5b52861a9d h1:S4Ejl/M2VrryIgDrDbiuvkwMUDa67/t/H3Wz3i2/vUw=
2020
github.com/charmbracelet/glamour v0.6.1-0.20230531150759-6d5b52861a9d/go.mod h1:swCB3CXFsh22H1ESDYdY1tirLiNqCziulDyJ1B6Nt7Q=
21+
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
22+
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
2123
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
2224
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
2325
github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw=

gum.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/charmbracelet/gum/log"
1515
"github.com/charmbracelet/gum/man"
1616
"github.com/charmbracelet/gum/pager"
17+
"github.com/charmbracelet/gum/progress"
1718
"github.com/charmbracelet/gum/spin"
1819
"github.com/charmbracelet/gum/style"
1920
"github.com/charmbracelet/gum/table"
@@ -137,6 +138,12 @@ type Gum struct {
137138
//
138139
Pager pager.Options `cmd:"" help:"Scroll through a file"`
139140

141+
// Progress provides a shell script interface for the progress bubble.
142+
// https://github.com/charmbracelet/bubbles/tree/master/progress
143+
//
144+
// On top ... when no limit is set some other progress information is displayed.
145+
Progress progress.Options `cmd:"" help:"Show progressbar"`
146+
140147
// Spin provides a shell script interface for the spinner bubble.
141148
// https://github.com/charmbracelet/bubbles/tree/master/spinner
142149
//

progress/barinfo.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package progress
2+
3+
import (
4+
"math"
5+
"time"
6+
)
7+
8+
type barInfo struct {
9+
iter uint
10+
title string
11+
limit uint
12+
incrementTs []time.Time
13+
}
14+
15+
func newBarInfo(title string, limit uint) *barInfo {
16+
info := &barInfo{
17+
title: title,
18+
limit: limit,
19+
incrementTs: make([]time.Time, 0, limit),
20+
}
21+
info.incrementTs = append(info.incrementTs, time.Now())
22+
return info
23+
}
24+
25+
func (self *barInfo) Update(progressAmount uint) {
26+
self.iter += progressAmount
27+
28+
now := time.Now()
29+
for i := uint(0); i < progressAmount; i++ {
30+
self.incrementTs = append(self.incrementTs, now)
31+
}
32+
}
33+
34+
func (self *barInfo) Elapsed() time.Duration {
35+
return time.Now().Sub(self.incrementTs[0]).Truncate(time.Second)
36+
}
37+
38+
func (self *barInfo) Pct() int {
39+
pct := math.Round(safeDivide(float64(self.iter), float64(self.limit)) * 100)
40+
return int(pct)
41+
}
42+
43+
func (self *barInfo) Avg() time.Duration {
44+
if len(self.incrementTs) < 2 {
45+
return time.Now().Sub(self.incrementTs[0])
46+
}
47+
var sum time.Duration
48+
for i := 1; i < len(self.incrementTs); i++ {
49+
sum += self.incrementTs[i].Sub(self.incrementTs[i-1])
50+
}
51+
return (sum / time.Duration(self.iter))
52+
}
53+
54+
func (self *barInfo) Eta() time.Duration {
55+
if self.iter >= self.limit {
56+
return 0
57+
}
58+
avg := self.Avg()
59+
if avg == 0 {
60+
return 0
61+
}
62+
63+
return time.Duration(self.limit-self.iter) * avg
64+
}

progress/command.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Package progress provides a simple progress indicator
2+
// for tracking the progress for input provided via stdin.
3+
//
4+
// It shows a progress bar when the limit is known and some simple stats when not.
5+
//
6+
// ------------------------------------
7+
// #!/bin/bash
8+
//
9+
// urls=(
10+
//
11+
// "http://example.com/file1.txt"
12+
// "http://example.com/file2.txt"
13+
// "http://example.com/file3.txt"
14+
//
15+
// )
16+
//
17+
// for url in "${urls[@]}"; do
18+
//
19+
// wget -q -nc "$url"
20+
// echo "Downloaded: $url"
21+
//
22+
// done | gum progress --show-output --limit ${#urls[@]}
23+
// ------------------------------------
24+
package progress
25+
26+
import (
27+
"bufio"
28+
"fmt"
29+
"os"
30+
31+
tea "github.com/charmbracelet/bubbletea"
32+
"github.com/mattn/go-isatty"
33+
)
34+
35+
func (o Options) GetFormatString() string {
36+
if o.Format != "" {
37+
return o.Format
38+
}
39+
40+
switch {
41+
case o.Limit == 0 && o.Title == "":
42+
return "[Elapsed ~ {Elapsed}] Iter {Iter}"
43+
case o.Limit == 0 && o.Title != "":
44+
return "[Elapsed ~ {Elapsed}] Iter {Iter} ~ {Title}"
45+
case o.Limit > 0 && o.Title == "":
46+
return "{Bar} {Pct}"
47+
case o.Limit > 0 && o.Title != "":
48+
return "{Title} ~ {Bar} {Pct}"
49+
default:
50+
return "{Iter}"
51+
}
52+
}
53+
54+
func (o Options) Run() error {
55+
m := &model{
56+
reader: bufio.NewReader(os.Stdin),
57+
output: o.ShowOutput,
58+
isTTY: isatty.IsTerminal(os.Stdout.Fd()),
59+
progressIndicator: o.ProgressIndicator,
60+
hideProgressIndicator: o.HideProgressIndicator,
61+
62+
bfmt: newBarFormatter(o.GetFormatString(), o.ProgressColor),
63+
binfo: newBarInfo(o.TitleStyle.ToLipgloss().Render(o.Title), o.Limit),
64+
}
65+
p := tea.NewProgram(m, tea.WithOutput(os.Stderr))
66+
if _, err := p.Run(); err != nil {
67+
return fmt.Errorf("failed to run progress: %w", err)
68+
}
69+
70+
return m.err
71+
}

progress/format.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package progress
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
"time"
8+
9+
"github.com/charmbracelet/bubbles/progress"
10+
"github.com/charmbracelet/lipgloss"
11+
)
12+
13+
type barFormatter struct {
14+
pbar progress.Model
15+
numBars int
16+
tplstr string
17+
}
18+
19+
var barPlaceholderRe = regexp.MustCompile(`{\s*Bar\s*}`)
20+
var nonBarPlaceholderRe = regexp.MustCompile(`{\s*(Title|Elapsed|Iter|Avg|Pct|Eta|Remaining|Limit)\s*}`)
21+
22+
func newBarFormatter(tplstr string, barColor string) *barFormatter {
23+
var bar progress.Model
24+
if barColor != "" {
25+
bar = progress.New(progress.WithoutPercentage(), progress.WithSolidFill(barColor))
26+
} else {
27+
bar = progress.New(progress.WithoutPercentage())
28+
}
29+
barfmt := &barFormatter{
30+
pbar: bar,
31+
tplstr: tplstr,
32+
numBars: len(barPlaceholderRe.FindAllString(tplstr, -1)),
33+
}
34+
return barfmt
35+
}
36+
37+
func (self *barFormatter) Render(info *barInfo, maxWidth int) string {
38+
rendered := nonBarPlaceholderRe.ReplaceAllStringFunc(self.tplstr, func(s string) string {
39+
switch strings.TrimSpace(s[1 : len(s)-1]) {
40+
case "Title":
41+
return info.title
42+
case "Iter":
43+
return fmt.Sprint(info.iter)
44+
case "Limit":
45+
if info.limit == 0 {
46+
return s
47+
}
48+
return fmt.Sprint(info.limit)
49+
case "Elapsed":
50+
return info.Elapsed().String()
51+
case "Pct":
52+
if info.limit == 0 {
53+
return s
54+
}
55+
return fmt.Sprintf("%d%%", info.Pct())
56+
case "Avg":
57+
return info.Avg().Round(time.Second).String()
58+
case "Remaining":
59+
if info.limit == 0 {
60+
return s
61+
}
62+
return info.Eta().Round(time.Second).String()
63+
case "Eta":
64+
if info.limit == 0 {
65+
return s
66+
}
67+
return time.Now().Add(info.Eta()).Format(time.TimeOnly)
68+
default:
69+
return ""
70+
}
71+
})
72+
73+
if info.limit > 0 && self.numBars > 0 {
74+
self.pbar.Width = max(0, (maxWidth-lipgloss.Width(rendered))/int(self.numBars))
75+
bar := self.pbar.ViewAs(safeDivide(float64(info.iter), float64(info.limit)))
76+
rendered = barPlaceholderRe.ReplaceAllLiteralString(rendered, bar)
77+
}
78+
return rendered
79+
}
80+
81+
func min(a, b uint) uint {
82+
if a < b {
83+
return a
84+
}
85+
return b
86+
}
87+
88+
func minI(a, b int) int {
89+
if a < b {
90+
return a
91+
}
92+
return b
93+
}
94+
95+
func max(a, b int) int {
96+
if a > b {
97+
return a
98+
}
99+
return b
100+
}
101+
102+
func safeDivide(a, b float64) float64 {
103+
if b == 0 {
104+
return 0
105+
}
106+
return a / b
107+
}

progress/options.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package progress
2+
3+
import (
4+
"github.com/charmbracelet/gum/style"
5+
)
6+
7+
type Options struct {
8+
Title string `help:"Text to display to user while spinning" env:"GUM_PROGRESS_TITLE"`
9+
TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_PROGRESS_TITLE_"`
10+
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"`
11+
ProgressColor string `help:"Set the color for the progress" envprefix:"GUM_PROGRESS_PROGRESS_COLOR"`
12+
13+
ProgressIndicator string `help:"What indicator to use for counting progress" default:"\n" env:"GUM_PROGRESS_PROGRESS_INDICATOR"`
14+
HideProgressIndicator bool `help:"Don't show the --progress-indicator in the output. Only makes sense in combination with --show-output" default:"false"`
15+
ShowOutput bool `short:"o" help:"Print what gum reads" default:"false"`
16+
17+
Limit uint `short:"l" help:"Species how many items there are (enables {Bar}, {Limit}, {Remaining}, {Eta} and {Pct} to be used in --format)"`
18+
}

0 commit comments

Comments
 (0)