diff options
| author | 2026-06-08 06:24:06 +0000 | |
|---|---|---|
| committer | 2026-06-08 06:24:06 +0000 | |
| commit | e4cf4b6152bf05387828dda44bd73d7ec12a7ed6 (patch) | |
| tree | 157e92eaabd9c910f9a83b53dc9c26754dcce12d /internal/progress | |
| parent | common/iowrap: Remove in favor of lgo (diff) | |
| signature | No signature | |
internal/progress: Add
Diffstat (limited to 'internal/progress')
| -rw-r--r-- | internal/progress/doc.go | 2 | ||||
| -rw-r--r-- | internal/progress/meter.go | 130 | ||||
| -rw-r--r-- | internal/progress/render.go | 79 |
3 files changed, 211 insertions, 0 deletions
diff --git a/internal/progress/doc.go b/internal/progress/doc.go new file mode 100644 index 00000000..964ebdec --- /dev/null +++ b/internal/progress/doc.go @@ -0,0 +1,2 @@ +// Package progress supplies meters intended to be used on sideband 2. +package progress diff --git a/internal/progress/meter.go b/internal/progress/meter.go new file mode 100644 index 00000000..0e4138de --- /dev/null +++ b/internal/progress/meter.go @@ -0,0 +1,130 @@ +package progress + +import ( + "time" + + "lindenii.org/go/lgo/intconv" + "lindenii.org/go/lgo/iowrap" +) + +const ( + updateInterval = time.Second + throughputInterval = 500 * time.Millisecond +) + +// Meter renders one in-place progress line. +type Meter struct { + writer iowrap.WriteFlusher + + 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 +} + +// New creates one progress meter. +func New(opts Options) *Meter { + now := time.Now() + + return &Meter{ + writer: opts.Writer, + 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, + } +} + +// Options configures one progress meter. +type Options struct { + Writer iowrap.WriteFlusher + + 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 +} + +// 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, err := intconv.Uint64ToInt(done * 100 / meter.total) + if err != nil { + return // TODO + } + + percentChanged = percent != meter.lastPercent + } + + if !percentChanged && !forced { + return + } + + meter.render(now, "\r") +} + +// 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") +} + +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 +} diff --git a/internal/progress/render.go b/internal/progress/render.go new file mode 100644 index 00000000..f51852eb --- /dev/null +++ b/internal/progress/render.go @@ -0,0 +1,79 @@ +package progress + +import ( + "fmt" + "strings" + "time" + + "lindenii.org/go/furgit/internal/utils" + "lindenii.org/go/lgo/fmt/humanize" + "lindenii.org/go/lgo/intconv" +) + +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() + + clear1 := 0 + if len(counters) < meter.lastCounterW { + clear1 = meter.lastCounterW - len(counters) + 1 + } + + meter.lastCounterW = len(counters) + + line := meter.title + ": " + counters + if clear1 > 0 { + line += strings.Repeat(" ", clear1) + } + + line += eol + + utils.BestEffortFprintf(meter.writer, "%s", line) + + if meter.writer != nil { + _ = meter.writer.Flush() + } +} + +func (meter *Meter) renderCounters() string { + if meter.total > 0 { + u, err := intconv.Uint64ToInt(meter.lastDone * 100 / meter.total) + if err != nil { + return "overflow" + // TODO + } + + meter.lastPercent = u + + return fmt.Sprintf("%3d%% (%d/%d)%s", meter.lastPercent, meter.lastDone, meter.total, meter.throughputSuffix) + } + + return fmt.Sprintf("%d%s", meter.lastDone, meter.throughputSuffix) +} + +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 = ", " + humanize.Bytes(meter.lastBytes) + " | " + humanize.Bytes(rate) + "/s" +} |
