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
+#### 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
+}