ref: c75a034d25ca87f3d209a8e82c743b8a7e96573b
parent: 043449fe7dc8e5608920889a83d35444f9b2bffb
author: Runxi Yu <me@runxiyu.org>
date: Sun Mar 8 10:15:38 EDT 2026
internal/progress: Add progress meter
--- /dev/null
+++ b/internal/progress/constants.go
@@ -1,0 +1,11 @@
+package progress
+
+import "time"
+
+const (
+ // DefaultDelay is the default delayed-progress interval.
+ DefaultDelay = time.Second
+
+ updateInterval = time.Second
+ throughputInterval = 500 * time.Millisecond
+)
--- /dev/null
+++ b/internal/progress/consume.go
@@ -1,0 +1,15 @@
+package progress
+
+import "time"
+
+func (meter *Meter) consumeUpdateTick(now time.Time) bool {+ if now.Before(meter.nextUpdateAt) {+ return false
+ }
+
+ for !now.Before(meter.nextUpdateAt) {+ meter.nextUpdateAt = meter.nextUpdateAt.Add(updateInterval)
+ }
+
+ return true
+}
--- /dev/null
+++ b/internal/progress/counters.go
@@ -1,0 +1,14 @@
+package progress
+
+import "fmt"
+
+func (meter *Meter) renderCounters() string {+ if meter.total > 0 {+ percent := int(meter.lastDone * 100 / meter.total)
+ meter.lastPercent = percent
+
+ return fmt.Sprintf("%3d%% (%d/%d)%s", percent, meter.lastDone, meter.total, meter.throughputSuffix)+ }
+
+ return fmt.Sprintf("%d%s", meter.lastDone, meter.throughputSuffix)+}
--- /dev/null
+++ b/internal/progress/humanize.go
@@ -1,0 +1,21 @@
+package progress
+
+import "fmt"
+
+func humanizeBytes(n uint64) string {+ const unit = 1024
+ if n < unit {+ return fmt.Sprintf("%d B", n)+ }
+
+ value := float64(n)
+ units := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}+ for i := 0; i < len(units); i++ {+ value /= unit
+ if value < unit || i == len(units)-1 {+ return fmt.Sprintf("%.2f %s", value, units[i])+ }
+ }
+
+ return fmt.Sprintf("%d B", n)+}
--- /dev/null
+++ b/internal/progress/meter.go
@@ -1,0 +1,30 @@
+package progress
+
+import (
+ "io"
+ "time"
+)
+
+// Meter renders one in-place progress line.
+type Meter struct {+ writer io.Writer
+ flush func() error
+
+ title string
+ total uint64
+ delay time.Duration
+ sparse bool
+ throughput bool
+
+ startedAt time.Time
+ nextUpdateAt time.Time
+ nextThroughput time.Time
+
+ lastDone uint64
+ lastBytes uint64
+ lastPercent int
+ lastCounterW int
+ sawValue bool
+
+ throughputSuffix string
+}
--- /dev/null
+++ b/internal/progress/new.go
@@ -1,0 +1,22 @@
+package progress
+
+import "time"
+
+// New creates one progress meter.
+func New(opts Options) *Meter {+ now := time.Now()
+
+ return &Meter{+ writer: opts.Writer,
+ flush: opts.Flush,
+ title: opts.Title,
+ total: opts.Total,
+ delay: max(opts.Delay, time.Duration(0)),
+ sparse: opts.Sparse,
+ throughput: opts.Throughput,
+ startedAt: now,
+ nextUpdateAt: now.Add(updateInterval),
+ nextThroughput: now.Add(throughputInterval),
+ lastPercent: -1,
+ }
+}
--- /dev/null
+++ b/internal/progress/options.go
@@ -1,0 +1,22 @@
+package progress
+
+import (
+ "io"
+ "time"
+)
+
+// Options configures one progress meter.
+type Options struct {+ Writer io.Writer
+ Flush func() error
+
+ Title string
+ Total uint64
+
+ // Delay suppresses progress output until Delay has elapsed since Start.
+ Delay time.Duration
+ // Sparse forces one final 100% line at Stop when the caller sampled updates.
+ Sparse bool
+ // Throughput appends ", <total> | <rate>/s" and refreshes rate every 500ms.
+ Throughput bool
+}
--- /dev/null
+++ b/internal/progress/refresh.go
@@ -1,0 +1,25 @@
+package progress
+
+import "time"
+
+func (meter *Meter) refreshThroughput(now time.Time) {+ if !meter.throughput {+ return
+ }
+
+ if meter.nextThroughput.After(now) && meter.throughputSuffix != "" {+ return
+ }
+
+ for !now.Before(meter.nextThroughput) {+ meter.nextThroughput = meter.nextThroughput.Add(throughputInterval)
+ }
+
+ elapsed := now.Sub(meter.startedAt)
+ if elapsed <= 0 {+ return
+ }
+
+ rate := uint64(float64(meter.lastBytes) / elapsed.Seconds())
+ meter.throughputSuffix = ", " + humanizeBytes(meter.lastBytes) + " | " + humanizeBytes(rate) + "/s"
+}
--- /dev/null
+++ b/internal/progress/render.go
@@ -1,0 +1,34 @@
+package progress
+
+import (
+ "strings"
+ "time"
+
+ "codeberg.org/lindenii/furgit/internal/utils"
+)
+
+func (meter *Meter) render(now time.Time, eol string) {+ if meter.delay > 0 && now.Sub(meter.startedAt) < meter.delay && eol == "\r" {+ return
+ }
+
+ meter.refreshThroughput(now)
+
+ counters := meter.renderCounters()
+ clear := 0
+ if len(counters) < meter.lastCounterW {+ clear = meter.lastCounterW - len(counters) + 1
+ }
+ meter.lastCounterW = len(counters)
+
+ line := meter.title + ": " + counters
+ if clear > 0 {+ line += strings.Repeat(" ", clear)+ }
+ line += eol
+
+ utils.BestEffortFprintf(meter.writer, "%s", line)
+ if meter.flush != nil {+ _ = meter.flush()
+ }
+}
--- /dev/null
+++ b/internal/progress/set.go
@@ -1,0 +1,30 @@
+package progress
+
+import "time"
+
+// Set records current progress and renders when percent changed or the 1s tick
+// elapsed.
+func (meter *Meter) Set(done uint64, bytes uint64) {+ meter.lastDone = done
+ meter.lastBytes = bytes
+ meter.sawValue = true
+
+ if meter.writer == nil {+ return
+ }
+
+ now := time.Now()
+ forced := meter.consumeUpdateTick(now)
+
+ percentChanged := false
+ if meter.total > 0 {+ percent := int(done * 100 / meter.total)
+ percentChanged = percent != meter.lastPercent
+ }
+
+ if !percentChanged && !forced {+ return
+ }
+
+ meter.render(now, "\r")
+}
--- /dev/null
+++ b/internal/progress/stop.go
@@ -1,0 +1,20 @@
+package progress
+
+import "time"
+
+// Stop forces the final progress line and appends ", <msg>.".
+func (meter *Meter) Stop(msg string) {+ if !meter.sawValue || meter.writer == nil {+ return
+ }
+
+ if msg == "" {+ msg = "done"
+ }
+
+ if meter.sparse && meter.total > 0 && meter.lastDone != meter.total {+ meter.lastDone = meter.total
+ }
+
+ meter.render(time.Now(), ", "+msg+".\n")
+}
--
⑨