// Copyright (C) 2022-2023 Luke Shumaker // // SPDX-License-Identifier: GPL-2.0-or-later package textui import ( "context" "fmt" "time" "git.lukeshu.com/go/typedsync" "github.com/datawire/dlib/dlog" ) type Stats interface { comparable fmt.Stringer } // Progress helps display to the user the ongoing progress of a long // task. // // There are few usage requirements to watch out for: // // - .Set() must have been called at least once before you call // .Done(). The easiest way to ensure this is to call .Set right // after creating the progress, or right before calling .Done(). I // advise against counting on a loop to have called .Set() at least // once. type Progress[T Stats] struct { ctx context.Context //nolint:containedctx // captured for separate goroutine lvl dlog.LogLevel interval time.Duration cancel context.CancelFunc done chan struct{} cur typedsync.Value[T] oldStat T oldLine string // This isn't a functional part, but is useful for helping us // to detect misuse. last time.Time } func NewProgress[T Stats](ctx context.Context, lvl dlog.LogLevel, interval time.Duration) *Progress[T] { ctx, cancel := context.WithCancel(ctx) ret := &Progress[T]{ ctx: ctx, lvl: lvl, interval: interval, cancel: cancel, done: make(chan struct{}), } return ret } // Set update the Progress. Rate-limiting prevents this from being // expensive, or from spamming the user; it is reasonably safe to call // .Set in a tight inner loop. // // It is safe to call Set concurrently. func (p *Progress[T]) Set(val T) { if _, hadOld := p.cur.Swap(val); !hadOld { go p.run() } } // Done closes the Progress; it flushes out one last status update (if // nescessary), and releases resources associated with the Progress. // // It is safe to call Done multiple times, or concurrently. // // It will panic if Done is called without having called Set at least // once. func (p *Progress[T]) Done() { p.cancel() if _, started := p.cur.Load(); !started { panic("textui.Progress: .Done called without ever calling .Set") } <-p.done } func (p *Progress[T]) flush(force bool) { // Check how long it's been since we last printed something. // If this grows too big, it probably means that either the // program deadlocked or that we forgot to call .Done(). now := time.Now() if !p.last.IsZero() && now.Sub(p.last) > Tunable(2*time.Minute) { dlog.Error(p.ctx, "stale Progress") panic("stale Progress") } // Load the data to print. cur, ok := p.cur.Load() if !ok { panic("should not happen") } if !force && cur == p.oldStat { return } defer func() { p.oldStat = cur }() // Format the data as text. line := cur.String() if !force && line == p.oldLine { return } defer func() { p.oldLine = line }() // Print. dlog.Log(p.ctx, p.lvl, line) p.last = now } func (p *Progress[T]) run() { p.flush(true) ticker := time.NewTicker(p.interval) for { select { case <-p.ctx.Done(): ticker.Stop() p.flush(false) close(p.done) return case <-ticker.C: p.flush(false) } } }