From b1e7a8656a7b5be93fdfbb03dd3c89effaf9ff06 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 08:23:03 +0200 Subject: [PATCH] Upstream schollz/progressbar state of the touched files before 74081543ba --- progressbar.go | 1653 +++++++++++++++++++++++++++++++++++++++++++ progressbar_test.go | 1343 +++++++++++++++++++++++++++++++++++ 2 files changed, 2996 insertions(+) create mode 100644 progressbar.go create mode 100644 progressbar_test.go diff --git a/progressbar.go b/progressbar.go new file mode 100644 index 0000000..4060005 --- /dev/null +++ b/progressbar.go @@ -0,0 +1,1653 @@ +package progressbar + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/mitchellh/colorstring" + "github.com/rivo/uniseg" + "golang.org/x/term" +) + +// ProgressBar is a thread-safe, simple +// progress bar +type ProgressBar struct { + state state + config config + lock sync.Mutex +} + +// State is the basic properties of the bar +type State struct { + Max int64 + CurrentNum int64 + CurrentPercent float64 + CurrentBytes float64 + SecondsSince float64 + SecondsLeft float64 + KBsPerSecond float64 + Description string +} + +type state struct { + currentNum int64 + currentPercent int + lastPercent int + currentSaucerSize int + isAltSaucerHead bool + + lastShown time.Time + startTime time.Time // time when the progress bar start working + + counterTime time.Time + counterNumSinceLast int64 + counterLastTenRates []float64 + counterRateTimes []time.Time + spinnerIdx int // the index of spinner + + maxLineWidth int + currentBytes float64 + startingBytes float64 // units already done before the bar started; excluded from the rate + finished bool + exit bool // Progress bar exit halfway + + details []string // details to show,only used when detail row is set to more than 0 + + rendered string +} + +type config struct { + max int64 // max number of the counter + maxHumanized string + maxHumanizedSuffix string + width int + writer io.Writer + theme Theme + renderWithBlankState bool + description string + iterationString string + ignoreLength bool // ignoreLength if max bytes not known + + // whether the output is expected to contain color codes + colorCodes bool + // custom colors to use for colorCodes + customColors map[string]string + // color to apply to spinner + spinnerColorCode string + + // show rate of change in kB/sec or MB/sec + showBytes bool + // show the iterations per second + showIterationsPerSecond bool + showIterationsCount bool + + // whether the progress bar should show the total bytes (e.g. 23/24 or 23/-, vs. just 23). + showTotalBytes bool + + // whether the progress bar should show elapsed time. + // always enabled if predictTime is true. + elapsedTime bool + + showElapsedTimeOnFinish bool + + // whether the progress bar should attempt to predict the finishing + // time of the progress based on the start time and the average + // number of seconds between increments. + predictTime bool + + // minimum time to wait in between updates + throttleDuration time.Duration + + // rateAveragingWindow, when greater than zero, averages the rate (and thus + // the predicted time remaining) over the samples collected within this + // trailing time window instead of over the last few samples. A longer + // window yields a smoother, more stable estimate for long-running tasks + // whose rate fluctuates. + rateAveragingWindow time.Duration + + // clear bar once finished + clearOnFinish bool + + // spinnerType should be a number between 0-75 + spinnerType int + + // spinnerTypeOptionUsed remembers if the spinnerType was changed manually + spinnerTypeOptionUsed bool + + // spinnerChangeInterval the change interval of spinner + // if set this attribute to 0, the spinner only change when renderProgressBar was called + // for example, each time when Add() was called,which will call renderProgressBar function + spinnerChangeInterval time.Duration + + // spinner represents the spinner as a slice of string + spinner []string + + // fullWidth specifies whether to measure and set the bar to a specific width + fullWidth bool + + // invisible doesn't render the bar at all, useful for debugging + invisible bool + + onCompletion func() + + // whether the render function should make use of ANSI codes to reduce console I/O + useANSICodes bool + + // whether to use the IEC units (e.g. MiB) instead of the default SI units (e.g. MB) + useIECUnits bool + + // showDescriptionAtLineEnd specifies whether description should be written at line end instead of line start + showDescriptionAtLineEnd bool + + // specifies how many rows of details to show,default value is 0 and no details will be shown + maxDetailRow int + + stdBuffer bytes.Buffer +} + +// Theme defines the elements of the bar +type Theme struct { + Saucer string + AltSaucerHead string + SaucerHead string + SaucerPadding string + BarStart string + BarEnd string + + // BarStartFilled is used after the Bar starts filling, if set. Otherwise, it defaults to BarStart. + BarStartFilled string + + // BarEndFilled is used once the Bar finishes, if set. Otherwise, it defaults to BarEnd. + BarEndFilled string +} + +var ( + // ThemeDefault is given by default (if not changed with OptionSetTheme), and it looks like "|████ |". + ThemeDefault = Theme{Saucer: "█", SaucerPadding: " ", BarStart: "|", BarEnd: "|"} + + // ThemeASCII is a predefined Theme that uses ASCII symbols. It looks like "[===>...]". + // Configure it with OptionSetTheme(ThemeASCII). + ThemeASCII = Theme{ + Saucer: "=", + SaucerHead: ">", + SaucerPadding: ".", + BarStart: "[", + BarEnd: "]", + } + + // ThemeUnicode is a predefined Theme that uses Unicode characters, displaying a graphic bar. + // It looks like "" (rendering will depend on font being used). + // It requires special symbols usually found in "nerd fonts" [2], or in Fira Code [1], and other sources. + // Configure it with OptionSetTheme(ThemeUnicode). + // + // [1] https://github.com/tonsky/FiraCode + // [2] https://www.nerdfonts.com/ + ThemeUnicode = Theme{ + Saucer: "\uEE04", //  + SaucerHead: "\uEE04", //  + SaucerPadding: "\uEE01", //  + BarStart: "\uEE00", //  + BarStartFilled: "\uEE03", //  + BarEnd: "\uEE02", //  + BarEndFilled: "\uEE05", //  + } +) + +// Option is the type all options need to adhere to +type Option func(p *ProgressBar) + +// OptionSetWidth sets the width of the bar +func OptionSetWidth(s int) Option { + return func(p *ProgressBar) { + p.config.width = s + } +} + +// OptionSetSpinnerChangeInterval sets the spinner change interval +// the spinner will change according to this value. +// By default, this value is 100 * time.Millisecond +// If you don't want to let this progressbar update by specified time interval +// you can set this value to zero, then the spinner will change each time rendered, +// such as when Add() or Describe() was called +func OptionSetSpinnerChangeInterval(interval time.Duration) Option { + return func(p *ProgressBar) { + p.config.spinnerChangeInterval = interval + } +} + +// OptionSpinnerType sets the type of spinner used for indeterminate bars +func OptionSpinnerType(spinnerType int) Option { + return func(p *ProgressBar) { + p.config.spinnerTypeOptionUsed = true + p.config.spinnerType = spinnerType + } +} + +// OptionSpinnerCustom sets the spinner used for indeterminate bars to the passed +// slice of string +func OptionSpinnerCustom(spinner []string) Option { + return func(p *ProgressBar) { + p.config.spinner = spinner + } +} + +// OptionSetTheme sets the elements the bar is constructed with. +// There are two pre-defined themes you can use: ThemeASCII and ThemeUnicode. +func OptionSetTheme(t Theme) Option { + return func(p *ProgressBar) { + p.config.theme = t + } +} + +// OptionSetVisibility sets the visibility +func OptionSetVisibility(visibility bool) Option { + return func(p *ProgressBar) { + p.config.invisible = !visibility + } +} + +// OptionFullWidth sets the bar to be full width +func OptionFullWidth() Option { + return func(p *ProgressBar) { + p.config.fullWidth = true + } +} + +// OptionSetWriter sets the output writer (defaults to os.StdOut) +func OptionSetWriter(w io.Writer) Option { + return func(p *ProgressBar) { + p.config.writer = w + } +} + +// OptionSetRenderBlankState sets whether or not to render a 0% bar on construction +func OptionSetRenderBlankState(r bool) Option { + return func(p *ProgressBar) { + p.config.renderWithBlankState = r + } +} + +// OptionSetDescription sets the description of the bar to render in front of it +func OptionSetDescription(description string) Option { + return func(p *ProgressBar) { + p.config.description = description + } +} + +// OptionEnableColorCodes enables or disables support for color codes +// using mitchellh/colorstring +func OptionEnableColorCodes(colorCodes bool) Option { + return func(p *ProgressBar) { + p.config.colorCodes = colorCodes + } +} + +// OptionSetCutomColorCodes overrides DefaultColors for color codes +// using mitchellh/colorstring.Colorize in func customColorstringColor +func OptionSetCustomColorCodes(customColors map[string]string) Option { + return func(p *ProgressBar) { + p.config.customColors = customColors + } +} + +// OptionSetSpinnerColorCode sets color code for spinner +func OptionSetSpinnerColorCode(colorCode string) Option { + return func(p *ProgressBar) { + p.config.spinnerColorCode = colorCode + } +} + +// OptionSetElapsedTime will enable elapsed time. Always enabled if OptionSetPredictTime is true. +func OptionSetElapsedTime(elapsedTime bool) Option { + return func(p *ProgressBar) { + p.config.elapsedTime = elapsedTime + } +} + +// OptionSetPredictTime will also attempt to predict the time remaining. +func OptionSetPredictTime(predictTime bool) Option { + return func(p *ProgressBar) { + p.config.predictTime = predictTime + } +} + +// OptionSetRateAveragingWindow sets the trailing time window used to average +// the rate of progress. By default the bar averages over its last few samples, +// which can make the reported rate and predicted time remaining jumpy for +// long-running tasks. Passing a window (for example, time.Minute) averages +// over every sample collected within that trailing window instead, producing a +// steadier estimate. A window of zero (the default) keeps the original +// behavior. +func OptionSetRateAveragingWindow(window time.Duration) Option { + return func(p *ProgressBar) { + p.config.rateAveragingWindow = window + } +} + +// OptionShowCount will also print current count out of total +func OptionShowCount() Option { + return func(p *ProgressBar) { + p.config.showIterationsCount = true + } +} + +// OptionShowIts will also print the iterations/second +func OptionShowIts() Option { + return func(p *ProgressBar) { + p.config.showIterationsPerSecond = true + } +} + +// OptionShowElapsedTimeOnFinish will keep the display of elapsed time on finish. +func OptionShowElapsedTimeOnFinish() Option { + return func(p *ProgressBar) { + p.config.showElapsedTimeOnFinish = true + } +} + +// OptionShowTotalBytes will keep the display of total bytes. +func OptionShowTotalBytes(flag bool) Option { + return func(p *ProgressBar) { + p.config.showTotalBytes = flag + } +} + +// OptionSetItsString sets what's displayed for iterations a second. The default is "it" which would display: "it/s" +func OptionSetItsString(iterationString string) Option { + return func(p *ProgressBar) { + p.config.iterationString = iterationString + } +} + +// OptionThrottle will wait the specified duration before updating again. The default +// duration is 0 seconds. +func OptionThrottle(duration time.Duration) Option { + return func(p *ProgressBar) { + p.config.throttleDuration = duration + } +} + +// OptionClearOnFinish will clear the bar once its finished. +func OptionClearOnFinish() Option { + return func(p *ProgressBar) { + p.config.clearOnFinish = true + } +} + +// OptionOnCompletion will invoke cmpl function once its finished +func OptionOnCompletion(cmpl func()) Option { + return func(p *ProgressBar) { + p.config.onCompletion = cmpl + } +} + +// OptionShowBytes will update the progress bar +// configuration settings to display/hide kBytes/Sec +func OptionShowBytes(val bool) Option { + return func(p *ProgressBar) { + p.config.showBytes = val + } +} + +// OptionUseANSICodes will use more optimized terminal i/o. +// +// Only useful in environments with support for ANSI escape sequences. +func OptionUseANSICodes(val bool) Option { + return func(p *ProgressBar) { + p.config.useANSICodes = val + } +} + +// OptionUseIECUnits will enable IEC units (e.g. MiB) instead of the default +// SI units (e.g. MB). +func OptionUseIECUnits(val bool) Option { + return func(p *ProgressBar) { + p.config.useIECUnits = val + } +} + +// OptionShowDescriptionAtLineEnd defines whether description should be written at line end instead of line start +func OptionShowDescriptionAtLineEnd() Option { + return func(p *ProgressBar) { + p.config.showDescriptionAtLineEnd = true + } +} + +// OptionSetMaxDetailRow sets the max row of details +// the row count should be less than the terminal height, otherwise it will not give you the output you want +func OptionSetMaxDetailRow(row int) Option { + return func(p *ProgressBar) { + p.config.maxDetailRow = row + } +} + +// OptionSetStartingBytes seeds the bar at an already-completed position, for +// resuming a partial transfer. The percentage and ETA start from num, but those +// bytes are excluded from the reported rate — analogous to Python tqdm's +// initial=. Without this, the only way to reflect prior progress is Set(num), +// which feeds the jump into the rolling-rate window and inflates the speed. +func OptionSetStartingBytes(num int64) Option { + return func(p *ProgressBar) { + p.state.currentNum = num + p.state.currentBytes = float64(num) + p.state.startingBytes = float64(num) + } +} + +// NewOptions constructs a new instance of ProgressBar, with any options you specify +func NewOptions(max int, options ...Option) *ProgressBar { + return NewOptions64(int64(max), options...) +} + +// NewOptions64 constructs a new instance of ProgressBar, with any options you specify +func NewOptions64(max int64, options ...Option) *ProgressBar { + b := ProgressBar{ + state: state{ + startTime: time.Time{}, + lastShown: time.Time{}, + counterTime: time.Time{}, + }, + config: config{ + writer: os.Stdout, + theme: ThemeDefault, + iterationString: "it", + width: 40, + max: max, + throttleDuration: 0 * time.Nanosecond, + elapsedTime: max == -1, + predictTime: true, + spinnerType: 9, + invisible: false, + spinnerChangeInterval: 100 * time.Millisecond, + showTotalBytes: true, + }, + } + + for _, o := range options { + o(&b) + } + + if b.config.spinnerType < 0 || b.config.spinnerType > 75 { + panic("invalid spinner type, must be between 0 and 75") + } + + if b.config.maxDetailRow < 0 { + panic("invalid max detail row, must be greater than 0") + } + + // ignoreLength if max bytes not known + if b.config.max == -1 { + b.lengthUnknown() + } + + b.config.maxHumanized, b.config.maxHumanizedSuffix = humanizeBytes(float64(b.config.max), + b.config.useIECUnits) + + if b.config.renderWithBlankState { + b.RenderBlank() + } + + // if the render time interval attribute is set + if b.config.spinnerChangeInterval != 0 && !b.config.invisible && b.config.ignoreLength { + go func() { + ticker := time.NewTicker(b.config.spinnerChangeInterval) + defer ticker.Stop() + + for range ticker.C { + if b.IsFinished() { + return + } + if b.IsStarted() { + b.lock.Lock() + b.render() + b.lock.Unlock() + } + } + }() + } + + return &b +} + +func getBasicState() state { + now := time.Now() + return state{ + startTime: now, + lastShown: now, + counterTime: now, + } +} + +// New returns a new ProgressBar +// with the specified maximum +func New(max int) *ProgressBar { + return NewOptions(max) +} + +// DefaultBytes provides a progressbar to measure byte +// throughput with recommended defaults. +// Set maxBytes to -1 to use as a spinner. +func DefaultBytes(maxBytes int64, description ...string) *ProgressBar { + desc := "" + if len(description) > 0 { + desc = description[0] + } + return NewOptions64( + maxBytes, + OptionSetDescription(desc), + OptionSetWriter(os.Stderr), + OptionShowBytes(true), + OptionShowTotalBytes(true), + OptionSetWidth(10), + OptionThrottle(65*time.Millisecond), + OptionShowCount(), + OptionOnCompletion(func() { + fmt.Fprint(os.Stderr, "\n") + }), + OptionSpinnerType(14), + OptionFullWidth(), + OptionSetRenderBlankState(true), + ) +} + +// DefaultBytesSilent is the same as DefaultBytes, but does not output anywhere. +// String() can be used to get the output instead. +func DefaultBytesSilent(maxBytes int64, description ...string) *ProgressBar { + // Mostly the same bar as DefaultBytes + + desc := "" + if len(description) > 0 { + desc = description[0] + } + return NewOptions64( + maxBytes, + OptionSetDescription(desc), + OptionSetWriter(io.Discard), + OptionShowBytes(true), + OptionShowTotalBytes(true), + OptionSetWidth(10), + OptionThrottle(65*time.Millisecond), + OptionShowCount(), + OptionSpinnerType(14), + OptionFullWidth(), + ) +} + +// Default provides a progressbar with recommended defaults. +// Set max to -1 to use as a spinner. +func Default(max int64, description ...string) *ProgressBar { + desc := "" + if len(description) > 0 { + desc = description[0] + } + return NewOptions64( + max, + OptionSetDescription(desc), + OptionSetWriter(os.Stderr), + OptionSetWidth(10), + OptionShowTotalBytes(true), + OptionThrottle(65*time.Millisecond), + OptionShowCount(), + OptionShowIts(), + OptionOnCompletion(func() { + fmt.Fprint(os.Stderr, "\n") + }), + OptionSpinnerType(14), + OptionFullWidth(), + OptionSetRenderBlankState(true), + ) +} + +// DefaultSilent is the same as Default, but does not output anywhere. +// String() can be used to get the output instead. +func DefaultSilent(max int64, description ...string) *ProgressBar { + // Mostly the same bar as Default + + desc := "" + if len(description) > 0 { + desc = description[0] + } + return NewOptions64( + max, + OptionSetDescription(desc), + OptionSetWriter(io.Discard), + OptionSetWidth(10), + OptionShowTotalBytes(true), + OptionThrottle(65*time.Millisecond), + OptionShowCount(), + OptionShowIts(), + OptionSpinnerType(14), + OptionFullWidth(), + ) +} + +// String returns the current rendered version of the progress bar. +// It will never return an empty string while the progress bar is running. +func (p *ProgressBar) String() string { + return p.state.rendered +} + +// RenderBlank renders the current bar state, you can use this to render a 0% state +func (p *ProgressBar) RenderBlank() error { + p.lock.Lock() + defer p.lock.Unlock() + + if p.config.invisible { + return nil + } + if p.state.currentNum == 0 { + p.state.lastShown = time.Time{} + } + return p.render() +} + +// StartWithoutRender will start the progress bar without rendering it +// this method is created for the use case where you want to start the progress +// but don't want to render it immediately. +// If you want to start the progress and render it immediately, use RenderBlank instead, +// or maybe you can use Add to start it automatically, but it will make the time calculation less precise. +func (p *ProgressBar) StartWithoutRender() { + p.lock.Lock() + defer p.lock.Unlock() + + if p.IsStarted() { + return + } + + p.state.startTime = time.Now() + // the counterTime should be set to the current time + p.state.counterTime = time.Now() +} + +// Reset will reset the clock that is used +// to calculate current time and the time left. +func (p *ProgressBar) Reset() { + p.lock.Lock() + defer p.lock.Unlock() + + p.state = getBasicState() +} + +// Finish will fill the bar to full +func (p *ProgressBar) Finish() error { + p.lock.Lock() + p.state.currentNum = p.config.max + if !p.config.ignoreLength { + p.state.currentBytes = float64(p.config.max) + } + p.lock.Unlock() + return p.Add(0) +} + +// Exit will exit the bar to keep current state +func (p *ProgressBar) Exit() error { + p.lock.Lock() + defer p.lock.Unlock() + + p.state.exit = true + if p.config.onCompletion != nil { + p.config.onCompletion() + } + return nil +} + +// Add will add the specified amount to the progressbar +func (p *ProgressBar) Add(num int) error { + return p.Add64(int64(num)) +} + +// Set will set the bar to a current number +func (p *ProgressBar) Set(num int) error { + return p.Set64(int64(num)) +} + +// Set64 will set the bar to a current number +func (p *ProgressBar) Set64(num int64) error { + p.lock.Lock() + toAdd := num - int64(p.state.currentBytes) + p.lock.Unlock() + return p.Add64(toAdd) +} + +// Add64 will add the specified amount to the progressbar +func (p *ProgressBar) Add64(num int64) error { + if p.config.invisible { + return nil + } + p.lock.Lock() + defer p.lock.Unlock() + + if p.state.exit { + return nil + } + + // error out since OptionSpinnerCustom will always override a manually set spinnerType + if p.config.spinnerTypeOptionUsed && len(p.config.spinner) > 0 { + return errors.New("OptionSpinnerType and OptionSpinnerCustom cannot be used together") + } + + if p.config.max == 0 { + return errors.New("max must be greater than 0") + } + + if p.state.currentNum < p.config.max { + if p.config.ignoreLength { + p.state.currentNum = (p.state.currentNum + num) % p.config.max + } else { + p.state.currentNum += num + } + } + + p.state.currentBytes += float64(num) + + if p.state.counterTime.IsZero() { + p.state.counterTime = time.Now() + } + + // reset the countdown timer every second to take rolling average + p.state.counterNumSinceLast += num + if time.Since(p.state.counterTime).Seconds() > 0.5 { + now := time.Now() + rate := float64(p.state.counterNumSinceLast) / time.Since(p.state.counterTime).Seconds() + p.state.counterLastTenRates = append(p.state.counterLastTenRates, rate) + p.state.counterRateTimes = append(p.state.counterRateTimes, now) + p.state.counterLastTenRates, p.state.counterRateTimes = trimRateSamples( + p.state.counterLastTenRates, p.state.counterRateTimes, p.config.rateAveragingWindow, now) + p.state.counterTime = now + p.state.counterNumSinceLast = 0 + } + + percent := float64(p.state.currentNum) / float64(p.config.max) + p.state.currentSaucerSize = int(percent * float64(p.config.width)) + p.state.currentPercent = int(percent * 100) + updateBar := p.state.currentPercent != p.state.lastPercent && p.state.currentPercent > 0 + + p.state.lastPercent = p.state.currentPercent + if p.state.currentNum > p.config.max { + return errors.New("current number exceeds max") + } + + // always update if show bytes/second or its/second + if updateBar || p.config.showIterationsPerSecond || p.config.showIterationsCount { + return p.render() + } + + return nil +} + +// AddDetail adds a detail to the progress bar. Only used when maxDetailRow is set to a value greater than 0 +func (p *ProgressBar) AddDetail(detail string) error { + if p.config.maxDetailRow == 0 { + return errors.New("maxDetailRow is set to 0, cannot add detail") + } + if p.IsFinished() { + return errors.New("cannot add detail to a finished progress bar") + } + + p.lock.Lock() + defer p.lock.Unlock() + if p.state.details == nil { + // if we add a detail before the first add, it will be weird that we have detail but don't have the progress bar in the top. + // so when we add the first detail, we will render the progress bar first. + if err := p.render(); err != nil { + return err + } + } + p.state.details = append(p.state.details, detail) + if len(p.state.details) > p.config.maxDetailRow { + p.state.details = p.state.details[1:] + } + if err := p.renderDetails(); err != nil { + return err + } + return nil +} + +// renderDetails renders the details of the progress bar +func (p *ProgressBar) renderDetails() error { + if p.config.invisible { + return nil + } + if p.state.finished { + return nil + } + if p.config.maxDetailRow == 0 { + return nil + } + + b := strings.Builder{} + b.WriteString("\n") + + // render the details row + for _, detail := range p.state.details { + b.WriteString(fmt.Sprintf("\u001B[K\r%s\n", detail)) + } + // add empty lines to fill the maxDetailRow + for i := len(p.state.details); i < p.config.maxDetailRow; i++ { + b.WriteString("\u001B[K\n") + } + + // move the cursor up to the start of the details row + b.WriteString(fmt.Sprintf("\u001B[%dF", p.config.maxDetailRow+1)) + + writeString(p.config, b.String()) + + return nil +} + +// Clear erases the progress bar from the current line +func (p *ProgressBar) Clear() error { + return clearProgressBar(p.config, p.state) +} + +// Describe will change the description shown before the progress, which +// can be changed on the fly (as for a slow running process). +func (p *ProgressBar) Describe(description string) { + p.lock.Lock() + defer p.lock.Unlock() + p.config.description = description + if p.config.invisible { + return + } + // if already finished, re-render with the new description + if p.state.finished && !p.config.clearOnFinish && !p.config.useANSICodes { + clearProgressBar(p.config, p.state) + io.Copy(p.config.writer, &p.config.stdBuffer) + renderProgressBar(p.config, &p.state) + return + } + p.render() +} + +// New64 returns a new ProgressBar +// with the specified maximum +func New64(max int64) *ProgressBar { + return NewOptions64(max) +} + +// GetMax returns the max of a bar +func (p *ProgressBar) GetMax() int { + p.lock.Lock() + defer p.lock.Unlock() + + return int(p.config.max) +} + +// GetMax64 returns the current max +func (p *ProgressBar) GetMax64() int64 { + p.lock.Lock() + defer p.lock.Unlock() + + return p.config.max +} + +// ChangeMax takes in a int +// and changes the max value +// of the progress bar +func (p *ProgressBar) ChangeMax(newMax int) { + p.ChangeMax64(int64(newMax)) +} + +// ChangeMax64 is basically +// the same as ChangeMax, +// but takes in a int64 +// to avoid casting +func (p *ProgressBar) ChangeMax64(newMax int64) { + p.lock.Lock() + + p.config.max = newMax + + if p.config.showBytes { + p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max), + p.config.useIECUnits) + } + + if newMax == -1 { + p.lengthUnknown() + } else { + p.lengthKnown(newMax) + } + p.lock.Unlock() // so p.Add can lock + + p.Add(0) // re-render +} + +// AddMax takes in a int +// and adds it to the max +// value of the progress bar +func (p *ProgressBar) AddMax(added int) { + p.AddMax64(int64(added)) +} + +// AddMax64 is basically +// the same as AddMax, +// but takes in a int64 +// to avoid casting +func (p *ProgressBar) AddMax64(added int64) { + p.lock.Lock() + + p.config.max += added + + if p.config.showBytes { + p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max), + p.config.useIECUnits) + } + + if p.config.max == -1 { + p.lengthUnknown() + } else { + p.lengthKnown(p.config.max) + } + p.lock.Unlock() // so p.Add can lock + + p.Add(0) // re-render +} + +// IsFinished returns true if progress bar is completed +func (p *ProgressBar) IsFinished() bool { + p.lock.Lock() + defer p.lock.Unlock() + + return p.state.finished +} + +// IsStarted returns true if progress bar is started +func (p *ProgressBar) IsStarted() bool { + return !p.state.startTime.IsZero() +} + +// render renders the progress bar, updating the maximum +// rendered line width. this function is not thread-safe, +// so it must be called with an acquired lock. +func (p *ProgressBar) render() error { + // make sure that the rendering is not happening too quickly + // but always show if the currentNum reaches the max + if !p.IsStarted() { + p.state.startTime = time.Now() + } else if time.Since(p.state.lastShown).Nanoseconds() < p.config.throttleDuration.Nanoseconds() && + p.state.currentNum < p.config.max { + return nil + } + + if !p.config.useANSICodes { + // first, clear the existing progress bar, if not yet finished. + if !p.state.finished { + err := clearProgressBar(p.config, p.state) + if err != nil { + return err + } + } + } + + // check if the progress bar is finished + if !p.state.finished && p.state.currentNum >= p.config.max { + p.state.finished = true + if !p.config.clearOnFinish { + io.Copy(p.config.writer, &p.config.stdBuffer) + renderProgressBar(p.config, &p.state) + } + if p.config.maxDetailRow > 0 { + p.renderDetails() + // put the cursor back to the last line of the details + var lastDetailLength int + if len(p.state.details) == 0 { + lastDetailLength = 0 + } else { + lastDetailLength = len(p.state.details[len(p.state.details)-1]) + } + writeString(p.config, fmt.Sprintf("\u001B[%dB\r\u001B[%dC", p.config.maxDetailRow, lastDetailLength)) + } + if p.config.onCompletion != nil { + p.config.onCompletion() + } + } + if p.state.finished { + // when using ANSI codes we don't pre-clean the current line + if p.config.useANSICodes && p.config.clearOnFinish { + err := clearProgressBar(p.config, p.state) + if err != nil { + return err + } + } + return nil + } + + // then, re-render the current progress bar + io.Copy(p.config.writer, &p.config.stdBuffer) + w, err := renderProgressBar(p.config, &p.state) + if err != nil { + return err + } + + if w > p.state.maxLineWidth { + p.state.maxLineWidth = w + } + + p.state.lastShown = time.Now() + + return nil +} + +// lengthUnknown sets the progress bar to ignore the length +func (p *ProgressBar) lengthUnknown() { + p.config.ignoreLength = true + p.config.max = int64(p.config.width) +} + +// lengthKnown sets the progress bar to do not ignore the length +func (p *ProgressBar) lengthKnown(max int64) { + p.config.ignoreLength = false + p.config.max = max +} + +// State returns the current state +func (p *ProgressBar) State() State { + p.lock.Lock() + defer p.lock.Unlock() + s := State{} + s.CurrentNum = p.state.currentNum + s.Max = p.config.max + if p.config.ignoreLength { + s.Max = -1 + } + s.CurrentPercent = float64(p.state.currentNum) / float64(p.config.max) + s.CurrentBytes = p.state.currentBytes + if p.IsStarted() { + s.SecondsSince = time.Since(p.state.startTime).Seconds() + } else { + s.SecondsSince = 0 + } + + if p.state.currentNum > 0 { + s.SecondsLeft = s.SecondsSince / float64(p.state.currentNum) * (float64(p.config.max) - float64(p.state.currentNum)) + } + s.KBsPerSecond = (float64(p.state.currentBytes) - p.state.startingBytes) / 1024.0 / s.SecondsSince + s.Description = p.config.description + return s +} + +// StartHTTPServer starts an HTTP server dedicated to serving progress bar updates. This allows you to +// display the status in various UI elements, such as an OS status bar with an `xbar` extension. +// When the progress bar is finished, call `server.Shutdown()` or `server.Close()` to shut it down manually. +// +// hostPort specifies the address and port to bind the server to, for example, "0.0.0.0:19999". +func (p *ProgressBar) StartHTTPServer(hostPort string) *http.Server { + mux := http.NewServeMux() + + // register routes + mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + bs, _ := json.Marshal(p.State()) + w.Write(bs) + }) + + mux.HandleFunc("/desc", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + state := p.State() + fmt.Fprintf(w, + "%d/%d, %.2f%%, %s left", + state.CurrentNum, state.Max, state.CurrentPercent*100, + (time.Second * time.Duration(state.SecondsLeft)).String(), + ) + }) + + // create the server instance + server := &http.Server{ + Addr: hostPort, + Handler: mux, + } + + // start the server in a goroutine and ignore errors + go func() { + defer func() { + if err := recover(); err != nil { + fmt.Println("encounter panic: ", err) + } + }() + + _ = server.ListenAndServe() + }() + + // return the server instance for use by the caller + return server +} + +// use customstring.Colorize to customize color palette +func customColorstringColor(col map[string]string, str string) string { + cs := colorstring.Colorize{Colors: col, Reset: true} + return cs.Color(str) +} + +// regex matching ansi escape codes +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func getStringWidth(c config, str string) int { + if c.colorCodes { + // convert any color codes in the progress bar into the respective ANSI codes + // if customColors have been option set - create cs with Colorize + if len(c.customColors) > 0 { + str = customColorstringColor(c.customColors, str) + } else { + str = colorstring.Color(str) + } + } + + // the width of the string, if printed to the console + // does not include the carriage return character + cleanString := strings.Replace(str, "\r", "", -1) + + if c.colorCodes { + // the ANSI codes for the colors do not take up space in the console output, + // so they do not count towards the output string width + cleanString = ansiRegex.ReplaceAllString(cleanString, "") + } + + // get the amount of runes in the string instead of the + // character count of the string, as some runes span multiple characters. + // see https://stackoverflow.com/a/12668840/2733724 + stringWidth := uniseg.StringWidth(cleanString) + return stringWidth +} + +func renderDeterminateProgressBar(c config, s *state, bar, stats, leftBrac, rightBrac string) string { + if rightBrac == "" { + str := fmt.Sprintf("%4d%% %s %s", + s.currentPercent, + bar, + stats) + if (s.currentPercent == 100 && c.showElapsedTimeOnFinish) || c.elapsedTime { + str = fmt.Sprintf("%s [%s]", str, leftBrac) + } + + if c.showDescriptionAtLineEnd { + return fmt.Sprintf("\r%s %s ", str, c.description) + } + return fmt.Sprintf("\r%s%s ", c.description, str) + } + + if s.currentPercent == 100 { + str := fmt.Sprintf("%4d%% %s %s", + s.currentPercent, + bar, + stats) + + if c.showElapsedTimeOnFinish { + str = fmt.Sprintf("%s [%s]", str, leftBrac) + } + + if c.showDescriptionAtLineEnd { + return fmt.Sprintf("\r%s %s", str, c.description) + } + return fmt.Sprintf("\r%s%s", c.description, str) + } + + str := fmt.Sprintf("%4d%% %s %s [%s:%s]", + s.currentPercent, + bar, + stats, + leftBrac, + rightBrac) + + if c.showDescriptionAtLineEnd { + return fmt.Sprintf("\r%s %s", str, c.description) + } + return fmt.Sprintf("\r%s%s", c.description, str) +} + +func fitProgressBarWidth(c config, s *state, barStart, barEnd, stats, leftBrac, rightBrac string) int { + if c.fullWidth || c.ignoreLength || c.width <= 0 { + return c.width + } + + terminalWidth, err := termWidth(c.writer) + if err != nil || terminalWidth <= 0 { + return c.width + } + + bar := barStart + strings.Repeat(c.theme.SaucerPadding, c.width) + barEnd + lineWidth := getStringWidth(c, renderDeterminateProgressBar(c, s, bar, stats, leftBrac, rightBrac)) + if lineWidth <= terminalWidth { + return c.width + } + + fitWidth := c.width - (lineWidth - terminalWidth) + if fitWidth < 0 { + return 0 + } + return fitWidth +} + +func renderProgressBar(c config, s *state) (int, error) { + var sb strings.Builder + + averageRate := average(s.counterLastTenRates) + if len(s.counterLastTenRates) == 0 || s.finished { + // if no average samples, or if finished, + // then average rate should be the total rate + if t := time.Since(s.startTime).Seconds(); t > 0 { + averageRate = (s.currentBytes - s.startingBytes) / t + } else { + averageRate = 0 + } + } + + // show iteration count in "current/total" iterations format + if c.showIterationsCount { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(", ") + } + if !c.ignoreLength { + if c.showBytes { + currentHumanize, currentSuffix := humanizeBytes(s.currentBytes, c.useIECUnits) + if currentSuffix == c.maxHumanizedSuffix { + if c.showTotalBytes { + sb.WriteString(fmt.Sprintf("%s/%s%s", + currentHumanize, c.maxHumanized, c.maxHumanizedSuffix)) + } else { + sb.WriteString(fmt.Sprintf("%s%s", + currentHumanize, c.maxHumanizedSuffix)) + } + } else if c.showTotalBytes { + sb.WriteString(fmt.Sprintf("%s%s/%s%s", + currentHumanize, currentSuffix, c.maxHumanized, c.maxHumanizedSuffix)) + } else { + sb.WriteString(fmt.Sprintf("%s%s", currentHumanize, currentSuffix)) + } + } else if c.showTotalBytes { + sb.WriteString(fmt.Sprintf("%.0f/%d", s.currentBytes, c.max)) + } else { + sb.WriteString(fmt.Sprintf("%.0f", s.currentBytes)) + } + } else { + if c.showBytes { + currentHumanize, currentSuffix := humanizeBytes(s.currentBytes, c.useIECUnits) + sb.WriteString(fmt.Sprintf("%s%s", currentHumanize, currentSuffix)) + } else if c.showTotalBytes { + sb.WriteString(fmt.Sprintf("%.0f/%s", s.currentBytes, "-")) + } else { + sb.WriteString(fmt.Sprintf("%.0f", s.currentBytes)) + } + } + } + + // show rolling average rate + if c.showBytes && averageRate > 0 && !math.IsInf(averageRate, 1) { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(", ") + } + currentHumanize, currentSuffix := humanizeBytes(averageRate, c.useIECUnits) + sb.WriteString(fmt.Sprintf("%s%s/s", currentHumanize, currentSuffix)) + } + + // show iterations rate + if c.showIterationsPerSecond { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(", ") + } + if averageRate > 1 { + sb.WriteString(fmt.Sprintf("%0.0f %s/s", averageRate, c.iterationString)) + } else if averageRate*60 > 1 { + sb.WriteString(fmt.Sprintf("%0.0f %s/min", 60*averageRate, c.iterationString)) + } else { + sb.WriteString(fmt.Sprintf("%0.0f %s/hr", 3600*averageRate, c.iterationString)) + } + } + if sb.Len() > 0 { + sb.WriteString(")") + } + + leftBrac, rightBrac, saucer, saucerHead := "", "", "", "" + barStart, barEnd := c.theme.BarStart, c.theme.BarEnd + if s.finished && c.theme.BarEndFilled != "" { + barEnd = c.theme.BarEndFilled + } + + // show time prediction in "current/total" seconds format + switch { + case c.predictTime: + rightBracNum := (time.Duration((1/averageRate)*(float64(c.max)-float64(s.currentNum))) * time.Second) + if rightBracNum.Seconds() < 0 { + rightBracNum = 0 * time.Second + } + rightBrac = rightBracNum.String() + fallthrough + case c.elapsedTime || c.showElapsedTimeOnFinish: + leftBrac = (time.Duration(time.Since(s.startTime).Seconds()) * time.Second).String() + } + + if c.fullWidth && !c.ignoreLength { + width, err := termWidth(c.writer) + if err != nil { + width = 80 + } + + amend := 1 // an extra space at eol + switch { + case leftBrac != "" && rightBrac != "": + amend = 4 // space, square brackets and colon + case leftBrac != "" && rightBrac == "": + amend = 4 // space and square brackets and another space + case leftBrac == "" && rightBrac != "": + amend = 3 // space and square brackets + } + if c.showDescriptionAtLineEnd { + amend += 1 // another space + } + + c.width = width - getStringWidth(c, c.description) - 10 - amend - sb.Len() - len(leftBrac) - len(rightBrac) + s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width)) + } + if (s.currentSaucerSize > 0 || s.currentPercent > 0) && c.theme.BarStartFilled != "" { + barStart = c.theme.BarStartFilled + } + c.width = fitProgressBarWidth(c, s, barStart, barEnd, sb.String(), leftBrac, rightBrac) + if !c.ignoreLength { + s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width)) + } + if s.currentSaucerSize > 0 { + if c.ignoreLength { + saucer = strings.Repeat(c.theme.SaucerPadding, s.currentSaucerSize-1) + } else { + saucer = strings.Repeat(c.theme.Saucer, s.currentSaucerSize-1) + } + + // Check if an alternate saucer head is set for animation + if c.theme.AltSaucerHead != "" && s.isAltSaucerHead { + saucerHead = c.theme.AltSaucerHead + s.isAltSaucerHead = false + } else if c.theme.SaucerHead == "" || s.currentSaucerSize == c.width { + // use the saucer for the saucer head if it hasn't been set + // to preserve backwards compatibility + saucerHead = c.theme.Saucer + } else { + saucerHead = c.theme.SaucerHead + s.isAltSaucerHead = true + } + } + + /* + Progress Bar format + Description % |------ | (kb/s) (iteration count) (iteration rate) (predict time) + + or if showDescriptionAtLineEnd is enabled + % |------ | (kb/s) (iteration count) (iteration rate) (predict time) Description + */ + + repeatAmount := c.width - s.currentSaucerSize + if repeatAmount < 0 { + repeatAmount = 0 + } + + str := "" + + if c.ignoreLength { + selectedSpinner := spinners[c.spinnerType] + if len(c.spinner) > 0 { + selectedSpinner = c.spinner + } + + var spinner string + if c.spinnerChangeInterval != 0 { + // if the spinner is changed according to an interval, calculate it + spinner = selectedSpinner[int(math.Round(math.Mod(float64(time.Since(s.startTime).Nanoseconds()/c.spinnerChangeInterval.Nanoseconds()), float64(len(selectedSpinner)))))] + } else { + // if the spinner is changed according to the number render was called + spinner = selectedSpinner[s.spinnerIdx] + s.spinnerIdx = (s.spinnerIdx + 1) % len(selectedSpinner) + } + // if set add spinner color code + if c.spinnerColorCode != "" && c.colorCodes { + spinner = "[" + c.spinnerColorCode + "]" + spinner + "[reset]" + } + if c.elapsedTime { + if c.showDescriptionAtLineEnd { + str = fmt.Sprintf("\r%s %s [%s] %s ", + spinner, + sb.String(), + leftBrac, + c.description) + } else { + str = fmt.Sprintf("\r%s %s %s [%s] ", + spinner, + c.description, + sb.String(), + leftBrac) + } + } else { + if c.showDescriptionAtLineEnd { + str = fmt.Sprintf("\r%s %s %s ", + spinner, + sb.String(), + c.description) + } else { + str = fmt.Sprintf("\r%s %s %s ", + spinner, + c.description, + sb.String()) + } + } + } else if rightBrac == "" { + bar := barStart + saucer + saucerHead + strings.Repeat(c.theme.SaucerPadding, repeatAmount) + barEnd + str = renderDeterminateProgressBar(c, s, bar, sb.String(), leftBrac, rightBrac) + } else { + bar := barStart + saucer + saucerHead + strings.Repeat(c.theme.SaucerPadding, repeatAmount) + barEnd + str = renderDeterminateProgressBar(c, s, bar, sb.String(), leftBrac, rightBrac) + } + + if c.colorCodes { + // convert any color codes in the progress bar into the respective ANSI codes + // if customColors have been option set - create cs with Colorize + if len(c.customColors) > 0 { + str = customColorstringColor(c.customColors, str) + } else { + str = colorstring.Color(str) + } + } + + if c.useANSICodes { + str = str + "\033[0K" + } + + s.rendered = str + + return getStringWidth(c, str), writeString(c, str) +} + +func clearProgressBar(c config, s state) error { + if s.maxLineWidth == 0 { + return nil + } + if c.useANSICodes { + // write the "clear current line" ANSI escape sequence + return writeString(c, "\033[2K\r") + } + // fill the empty content + // to overwrite the progress bar and jump + // back to the beginning of the line + if runtime.GOOS == "windows" { + return writeString(c, "\r") + } + str := fmt.Sprintf("\r%s\r", strings.Repeat(" ", s.maxLineWidth)) + return writeString(c, str) + // the following does not show correctly if the previous line is longer than subsequent line + // return writeString(c, "\r") +} + +func writeString(c config, str string) error { + if _, err := io.WriteString(c.writer, str); err != nil { + return err + } + + if f, ok := c.writer.(*os.File); ok { + // ignore any errors in Sync(), as stdout + // can't be synced on some operating systems + // like Debian 9 (Stretch) + f.Sync() + } + + return nil +} + +// Reader is the progressbar io.Reader struct +type Reader struct { + io.Reader + bar *ProgressBar +} + +// NewReader return a new Reader with a given progress bar. +func NewReader(r io.Reader, bar *ProgressBar) Reader { + return Reader{ + Reader: r, + bar: bar, + } +} + +// Read will read the data and add the number of bytes to the progressbar +func (r *Reader) Read(p []byte) (n int, err error) { + n, err = r.Reader.Read(p) + r.bar.Add(n) + return +} + +// Close the reader when it implements io.Closer +func (r *Reader) Close() (err error) { + if closer, ok := r.Reader.(io.Closer); ok { + err = closer.Close() + } + r.bar.Finish() + return +} + +// Write implement io.Writer +func (p *ProgressBar) Write(b []byte) (n int, err error) { + n = len(b) + err = p.Add(n) + return +} + +// Read implement io.Reader +func (p *ProgressBar) Read(b []byte) (n int, err error) { + n = len(b) + err = p.Add(n) + return +} + +func (p *ProgressBar) Close() (err error) { + err = p.Finish() + return +} + +// maxLegacyRateSamples is the number of recent rate samples averaged when no +// rate-averaging window is configured (see OptionSetRateAveragingWindow). +const maxLegacyRateSamples = 10 + +// trimRateSamples discards rate samples that fall outside the averaging window. +// rates and times are parallel slices ordered oldest to newest, with times[i] +// recording when rates[i] was collected. When window is greater than zero, only +// samples collected within the trailing window relative to now are kept (the +// most recent sample is always retained). Otherwise it keeps at most the last +// maxLegacyRateSamples samples. +func trimRateSamples(rates []float64, times []time.Time, window time.Duration, now time.Time) ([]float64, []time.Time) { + if window > 0 { + cutoff := now.Add(-window) + drop := 0 + for drop < len(times)-1 && times[drop].Before(cutoff) { + drop++ + } + return rates[drop:], times[drop:] + } + if len(rates) > maxLegacyRateSamples { + return rates[len(rates)-maxLegacyRateSamples:], times[len(times)-maxLegacyRateSamples:] + } + return rates, times +} + +func average(xs []float64) float64 { + total := 0.0 + for _, v := range xs { + total += v + } + return total / float64(len(xs)) +} + +func humanizeBytes(s float64, iec bool) (string, string) { + sizes := []string{" B", " kB", " MB", " GB", " TB", " PB", " EB"} + base := 1000.0 + + if iec { + sizes = []string{" B", " KiB", " MiB", " GiB", " TiB", " PiB", " EiB"} + base = 1024.0 + } + + if s < 10 { + return fmt.Sprintf("%2.0f", s), sizes[0] + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f" + if val < 10 { + f = "%.1f" + } + + return fmt.Sprintf(f, val), suffix +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} + +// termWidth function returns the visible width of the current terminal +// and can be redefined for testing +var termWidth = func(w io.Writer) (width int, err error) { + if f, ok := w.(*os.File); ok { + width, _, err = term.GetSize(int(f.Fd())) + if err == nil { + return width, nil + } + } else { + err = errors.New("output is not a *os.File") + } + return 0, err +} + +func shouldCacheOutput(pb *ProgressBar) bool { + return !pb.state.finished && !pb.state.exit && !pb.config.invisible +} + +func Bprintln(pb *ProgressBar, a ...interface{}) (int, error) { + pb.lock.Lock() + defer pb.lock.Unlock() + if !shouldCacheOutput(pb) { + return fmt.Fprintln(pb.config.writer, a...) + } else { + return fmt.Fprintln(&pb.config.stdBuffer, a...) + } +} + +func Bprintf(pb *ProgressBar, format string, a ...interface{}) (int, error) { + pb.lock.Lock() + defer pb.lock.Unlock() + if !shouldCacheOutput(pb) { + return fmt.Fprintf(pb.config.writer, format, a...) + } else { + return fmt.Fprintf(&pb.config.stdBuffer, format, a...) + } +} diff --git a/progressbar_test.go b/progressbar_test.go new file mode 100644 index 0000000..acc7bbe --- /dev/null +++ b/progressbar_test.go @@ -0,0 +1,1343 @@ +package progressbar + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/chengxilo/virtualterm" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + termWidth = func(w io.Writer) (int, error) { + return 0, os.ErrPermission + } + os.Exit(m.Run()) +} + +func BenchmarkRender(b *testing.B) { + bar := NewOptions64(100000000, + OptionSetWriter(os.Stderr), + OptionShowIts(), + ) + for i := 0; i < b.N; i++ { + bar.Add(1) + } +} + +func ExampleProgressBar() { + bar := New(100) + bar.Add(10) + // Output: + // 10% |████ | [0s:0s] +} + +func ExampleProgressBar_Set() { + bar := New(100) + bar.Set(10) + // Output: + // 10% |████ | [0s:0s] +} + +func ExampleProgressBar_Set64() { + bar := New(100) + bar.Set64(10) + // Output: + // 10% |████ | [0s:0s] +} + +func ExampleProgressBar_basic() { + bar := NewOptions(100, OptionSetWidth(10)) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + // Output: + // 10% |█ | [1s:9s] +} + +func ExampleProgressBar_invisible() { + bar := NewOptions(100, OptionSetWidth(10), OptionSetRenderBlankState(true), OptionSetVisibility(false)) + bar.Reset() + fmt.Println("hello, world") + time.Sleep(1 * time.Second) + bar.Add(10) + // Output: + // hello, world +} + +func ExampleOptionThrottle() { + bar := NewOptions(100, OptionSetWidth(10), OptionThrottle(100*time.Millisecond)) + bar.Reset() + bar.Add(5) + time.Sleep(150 * time.Millisecond) + bar.Add(5) + bar.Add(10) + // Output: + // 10% |█ | [0s:1s] +} + +func ExampleOptionClearOnFinish() { + bar := NewOptions(100, OptionSetWidth(10), OptionClearOnFinish()) + bar.Reset() + bar.Finish() + fmt.Println("Finished") + // Output: + // Finished +} + +func TestSpinnerClearOnFinish(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(-1, OptionSetWidth(100), OptionShowCount(), OptionShowBytes(true), OptionShowIts(), OptionClearOnFinish(), OptionSetWriter(&buf)) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + time.Sleep(1 * time.Second) + bar.Finish() + result, _ := virtualterm.Process(buf.String()) + expect := " " + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func ExampleProgressBar_Finish() { + bar := NewOptions(100, OptionSetWidth(10), OptionShowCount(), OptionShowBytes(true), OptionShowIts()) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Finish() + // Output: + // 100% |██████████| (100/100 B, 100 B/s, 100 it/s) +} + +func TestSpinnerFinish(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(-1, OptionSetWidth(100), OptionShowCount(), OptionShowBytes(true), OptionShowIts(), OptionSetWriter(&buf)) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + time.Sleep(1 * time.Second) + bar.Finish() + result, err := virtualterm.Process(buf.String()) + if err != nil { + t.Error(err) + } + // the "\r \r" + expect := "| (10 B, 5 B/s, 5 it/s) [2s] " + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func Example_xOutOfY() { + bar := NewOptions(100, OptionSetPredictTime(true)) + + for i := 0; i < 100; i++ { + bar.Add(1) + time.Sleep(1 * time.Millisecond) + } +} + +func ExampleOptionShowIts_count() { + bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionShowCount()) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + // Output: + // 10% |█ | (10/100, 10 it/s) [1s:9s] +} + +func ExampleOptionShowIts() { + bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionSetPredictTime(false)) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + // Output: + // 10% |█ | (10 it/s) +} + +func ExampleOptionShowCount_minuscule() { + bar := NewOptions(10000, OptionSetWidth(10), OptionShowCount(), OptionSetPredictTime(false)) + bar.Add(1) + // Output: + // 0% | | (1/10000) +} + +func ExampleOptionSetPredictTime() { + bar := NewOptions(100, OptionSetWidth(10), OptionSetPredictTime(false)) + _ = bar.Add(10) + // Output: + // 10% |█ | +} + +func ExampleOptionShowDescriptionAtLineEnd() { + bar := NewOptions(100, OptionSetWidth(10), OptionShowDescriptionAtLineEnd(), OptionSetDescription("hello")) + _ = bar.Add(10) + // Output: + // 10% |█ | [0s:0s] hello +} + +func ExampleOptionShowDescriptionAtLineEnd_spinner() { + bar := NewOptions(-1, OptionSetWidth(10), OptionShowDescriptionAtLineEnd(), OptionSetDescription("hello")) + _ = bar.Add(1) + // Output: + // | [0s] hello +} + +func ExampleDefault() { + bar := Default(100) + for i := 0; i < 50; i++ { + bar.Add(1) + time.Sleep(10 * time.Millisecond) + } + // Output: + // +} + +func ExampleProgressBar_ChangeMax() { + bar := NewOptions(100, OptionSetWidth(10), OptionSetPredictTime(false)) + bar.ChangeMax(50) + bar.Add(50) + // Output: + // 100% |██████████| +} + +func ExampleProgressBar_AddMax() { + bar := NewOptions(50, OptionSetWidth(10), OptionSetPredictTime(false)) + bar.AddMax(50) + bar.Add(100) + // Output: + // 100% |██████████| +} + +func ExampleOptionShowIts_spinner() { + /* + Spinner test with iteration count and iteration rate + */ + vt := virtualterm.NewDefault() + bar := NewOptions(-1, + OptionSetWidth(10), + OptionShowIts(), + OptionShowCount(), + OptionSetWriter(&vt), + ) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(5) + bar.lock.Lock() + s, err := vt.String() + bar.lock.Unlock() + if err != nil { + log.Fatal(err) + } + fmt.Print(s) + // Output: + // - (5/-, 5 it/s) [1s] +} + +func TestSpinnerType(t *testing.T) { + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetDescription("indeterminate spinner"), + OptionShowIts(), + OptionShowCount(), + OptionSpinnerType(9), + ) + bar.Reset() + for i := 0; i < 10; i++ { + time.Sleep(120 * time.Millisecond) + err := bar.Add(1) + if err != nil { + t.Errorf("Successfully tested one spinner option can be used.") + } + } + if false { + t.Errorf("error") + } +} + +func TestSpinnerCustom(t *testing.T) { + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetDescription("indeterminate spinner"), + OptionShowIts(), + OptionShowCount(), + OptionSpinnerCustom([]string{"🐰", "🐰", "🥕", "🥕"}), + ) + bar.Reset() + for i := 0; i < 10; i++ { + time.Sleep(120 * time.Millisecond) + err := bar.Add(1) + if err != nil { + t.Errorf("Successfully tested one spinner option can be used.") + } + } + if false { + t.Errorf("error") + } +} + +func TestSpinnerTypeAndCustom(t *testing.T) { + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetDescription("indeterminate spinner"), + OptionShowIts(), + OptionShowCount(), + OptionSpinnerCustom([]string{"🐰", "🐰", "🥕", "🥕"}), + OptionSpinnerType(9), + ) + bar.Reset() + for i := 0; i < 10; i++ { + time.Sleep(120 * time.Millisecond) + err := bar.Add(1) + if err == nil { + t.Errorf("Successfully tested both spinner options cannot be used together.") + } + } +} + +func Test_IsFinished(t *testing.T) { + isCalled := false + bar := NewOptions(72, OptionOnCompletion(func() { + isCalled = true + })) + + // Test1: If bar is not fully completed. + bar.Add(5) + if bar.IsFinished() || isCalled { + t.Errorf("Successfully tested bar is not yet finished.") + } + + // Test2: Bar fully completed. + bar.Add(67) + if !bar.IsFinished() || !isCalled { + t.Errorf("Successfully tested bar is finished.") + } + + // Test3: If increases maximum bytes error should be thrown and + // bar finished will remain false. + bar.Reset() + err := bar.Add(73) + if err == nil || bar.IsFinished() { + t.Errorf("Successfully got error when bytes increases max bytes, bar finished: %v", bar.IsFinished()) + } +} + +func ExampleOptionShowBytes_spinner() { + /* + Spinner test with iterations and count + */ + buf := strings.Builder{} + bar := NewOptions(-1, + OptionSetWidth(10), + OptionShowBytes(true), + OptionSetWriter(&buf), + ) + + bar.Reset() + time.Sleep(1 * time.Second) + // since 10 is the width and we don't know the max bytes + // it will do a infinite scrolling. + bar.Add(11) + bar.lock.Lock() + result, _ := virtualterm.Process(buf.String()) + bar.lock.Unlock() + fmt.Print(result) + // Output: + // - (11 B/s) [1s] +} + +func TestBarSlowAdd(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionSetWriter(&buf)) + bar.Reset() + time.Sleep(3 * time.Second) + bar.Add(1) + if !strings.Contains(buf.String(), "1%") { + t.Errorf("wrong string: %s", buf.String()) + } + if !strings.Contains(buf.String(), "20 it/min") { + t.Errorf("wrong string: %s", buf.String()) + } + if !strings.Contains(buf.String(), "[3s:") { + t.Errorf("wrong string: %s", buf.String()) + } + // Output: + // 1% | | (20 it/min) [3s:4m57s] +} + +func TestBarSmallBytes(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions64(100000000, OptionShowBytes(true), OptionShowCount(), OptionSetWidth(10), OptionSetWriter(&buf)) + for i := 1; i < 10; i++ { + time.Sleep(100 * time.Millisecond) + bar.Add(1000) + } + if !strings.Contains(buf.String(), "9.0 kB/100 MB") { + t.Errorf("wrong string: %s", buf.String()) + } + for i := 1; i < 10; i++ { + time.Sleep(10 * time.Millisecond) + bar.Add(1000000) + } + if !strings.Contains(buf.String(), "9.0/100 MB") { + t.Errorf("wrong string: %s", buf.String()) + } +} + +func TestBarFastBytes(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions64(1e8, OptionShowBytes(true), OptionShowCount(), OptionSetWidth(10), OptionSetWriter(&buf)) + bar.StartWithoutRender() + time.Sleep(time.Millisecond) + bar.Add(1e7) + if !strings.Contains(buf.String(), " GB/s)") { + t.Errorf("wrong string: %s", buf.String()) + } +} + +func TestBar(t *testing.T) { + bar := New(0) + if err := bar.Add(1); err == nil { + t.Error("should have an error for 0 bar") + } + bar = New(10) + if err := bar.Add(11); err == nil { + t.Error("should have an error for adding > bar") + } +} + +func TestState(t *testing.T) { + bar := NewOptions(100, OptionSetWidth(10)) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + s := bar.State() + if s.CurrentPercent != 0.1 { + t.Error(s) + } +} + +func TestStartingBytes(t *testing.T) { + bar := NewOptions64(100, OptionSetWidth(10), OptionSetStartingBytes(80)) + s := bar.State() + if s.CurrentBytes != 80 { + t.Errorf("expected CurrentBytes 80, got %v", s.CurrentBytes) + } + if s.CurrentPercent != 0.8 { + t.Errorf("expected CurrentPercent 0.8, got %v", s.CurrentPercent) + } + + // Transfer 10 more over ~0.2s: the rate must reflect only those 10 units, + // not the 80 seeded ones. Without the offset the fallback rate would be + // 90/0.2 = 450/s; with it, ~50/s. + bar = NewOptions64(100, OptionSetWidth(10), OptionSetStartingBytes(80), OptionSetWriter(io.Discard)) + bar.Add(0) // start the clock at the seeded position + time.Sleep(200 * time.Millisecond) + bar.Add(10) + s = bar.State() + if rate := s.KBsPerSecond * 1024; rate > 200 { + t.Errorf("rate should exclude seeded bytes, got %v/s", rate) + } +} + +func ExampleOptionSetRenderBlankState() { + NewOptions(10, OptionSetWidth(10), OptionSetRenderBlankState(true)) + // Output: + // 0% | | [0s:0s] +} + +func TestBasicSets(t *testing.T) { + b := NewOptions( + 999, + OptionSetWidth(888), + OptionSetRenderBlankState(true), + OptionSetWriter(io.Discard), // suppressing output for this test + ) + + tc := b.config + + if tc.max != 999 { + t.Errorf("Expected %s to be %d, instead I got %d\n%+v", "max", 999, tc.max, b) + } + + if tc.width != 888 { + t.Errorf("Expected %s to be %d, instead I got %d\n%+v", "width", 999, tc.max, b) + } + + if !tc.renderWithBlankState { + t.Errorf("Expected %s to be %t, instead I got %t\n%+v", "renderWithBlankState", true, tc.renderWithBlankState, b) + } +} + +func TestOptionSetTheme(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions( + 10, + OptionSetTheme( + Theme{Saucer: "#", SaucerPadding: "-", + BarStart: ">", BarEnd: "<"}), + OptionSetWidth(10), + OptionSetWriter(&buf), + ) + bar.RenderBlank() + result := strings.TrimSpace(buf.String()) + expect := "0% >----------<" + if !strings.Contains(result, expect) { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + buf.Reset() + + bar.Add(5) + result = strings.TrimSpace(buf.String()) + expect = "50% >#####-----< [0s:0s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + buf.Reset() + + bar.Finish() + result = strings.TrimSpace(buf.String()) + expect = "100% >##########<" + if !strings.Contains(result, expect) { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestOptionSetThemeFilled(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions( + 10, + OptionSetTheme( + Theme{Saucer: "#", SaucerPadding: "-", + BarStart: ">", BarStartFilled: "]", + BarEnd: "<", BarEndFilled: "["}), + OptionSetWidth(10), + OptionSetWriter(&buf), + ) + bar.RenderBlank() + result := strings.TrimSpace(buf.String()) + expect := "0% >----------<" + if !strings.Contains(result, expect) { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + buf.Reset() + + bar.Add(5) + result = strings.TrimSpace(buf.String()) + expect = "50% ]#####-----< [0s:0s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + buf.Reset() + + bar.Finish() + result = strings.TrimSpace(buf.String()) + expect = "100% ]##########[" + if !strings.Contains(result, expect) { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +// TestOptionSetPredictTime ensures that when predict time is turned off, the progress +// bar is showing the total steps completed of the given max, otherwise the predicted +// time in seconds is specified. +func TestOptionSetPredictTime(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions( + 10, + OptionSetPredictTime(false), + OptionSetWidth(10), + OptionSetWriter(&buf), + ) + + _ = bar.Add(2) + result := strings.TrimSpace(buf.String()) + expect := "20% |██ |" + + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + + bar.Reset() + bar.config.predictTime = true + buf.Reset() + + _ = bar.Add(7) + result = strings.TrimSpace(buf.String()) + expect = "70% |███████ | [0s:0s]" + + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestOptionSetElapsedTime_spinner(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetWriter(&buf), + OptionShowIts(), + OptionShowCount(), + OptionSetElapsedTime(false), + ) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(5) + bar.lock.Lock() + result, err := virtualterm.Process(buf.String()) + bar.lock.Unlock() + result = strings.TrimSpace(result) + if err != nil { + t.Fatal(err) + } + expect := "- (5/-, 5 it/s)" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestOptionSetElapsedTime(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions( + 10, + OptionSetElapsedTime(false), + OptionSetPredictTime(false), + OptionSetWidth(10), + OptionSetWriter(&buf), + ) + + _ = bar.Add(2) + result := strings.TrimSpace(buf.String()) + expect := "20% |██ |" + + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } + + bar.Reset() + bar.config.elapsedTime = true + buf.Reset() + + _ = bar.Add(7) + result = strings.TrimSpace(buf.String()) + expect = "70% |███████ | [0s]" + + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestShowElapsedTimeOnFinish(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(10, + OptionShowElapsedTimeOnFinish(), + OptionSetWidth(10), + OptionSetWriter(&buf), + ) + bar.Reset() + time.Sleep(3 * time.Second) + bar.Add(10) + result := strings.TrimSpace(buf.String()) + expect := "100% |██████████| [3s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestSpinnerState(t *testing.T) { + bar := NewOptions( + -1, + OptionSetWidth(100), + ) + bar.Reset() + time.Sleep(1 * time.Second) + bar.Add(10) + + state := bar.State() + if state.Max != -1 { + t.Errorf("Max mismatched gotMax %d wantMax %d", state.Max, -1) + } + if state.CurrentNum != 10 { + t.Errorf("Number mismatched gotNum %d wantNum %d", state.CurrentNum, 10) + } + if state.CurrentBytes != 10.0 { + t.Errorf("Number of bytes mismatched gotBytes %f wantBytes %f", state.CurrentBytes, 10.0) + } + if state.CurrentPercent != 0.1 { + t.Errorf("Percent of bar mismatched got %f want %f", state.CurrentPercent, 0.1) + } + + kbPerSec := fmt.Sprintf("%2.2f", state.KBsPerSecond) + if kbPerSec != "0.01" { + t.Errorf("Speed mismatched got %s want %s", kbPerSec, "0.01") + } +} + +func TestReaderToBuffer(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" + req, err := http.NewRequest("GET", urlToGet, nil) + assert.Nil(t, err) + resp, err := http.DefaultClient.Do(req) + assert.Nil(t, err) + defer resp.Body.Close() + + buf := new(bytes.Buffer) + bar := NewOptions(int(resp.ContentLength), OptionShowBytes(true), OptionShowCount()) + out := io.MultiWriter(buf, bar) + _, err = io.Copy(out, resp.Body) + assert.Nil(t, err) + + md5, err := md5sum(buf) + assert.Nil(t, err) + assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) +} + +func TestReaderToFile(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" + req, err := http.NewRequest("GET", urlToGet, nil) + assert.Nil(t, err) + resp, err := http.DefaultClient.Do(req) + assert.Nil(t, err) + defer resp.Body.Close() + + f, err := os.CreateTemp("", "progressbar_testfile") + if err != nil { + t.Fatal() + } + defer os.Remove(f.Name()) + defer f.Close() + + realStdout := os.Stdout + defer func() { os.Stdout = realStdout }() + r, fakeStdout, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = fakeStdout + + bar := DefaultBytes(resp.ContentLength) + out := io.MultiWriter(f, bar) + _, err = io.Copy(out, resp.Body) + assert.Nil(t, err) + f.Sync() + f.Seek(0, 0) + + if err := fakeStdout.Close(); err != nil { + t.Fatal(err) + } + + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + + if err := r.Close(); err != nil { + t.Fatal(err) + } + + assert.Equal(t, "", string(b)) + + md5, err := md5sum(f) + assert.Nil(t, err) + assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) +} + +func TestReaderToFileUnknownLength(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" + req, err := http.NewRequest("GET", urlToGet, nil) + assert.Nil(t, err) + resp, err := http.DefaultClient.Do(req) + assert.Nil(t, err) + defer resp.Body.Close() + + f, err := os.CreateTemp("", "progressbar_testfile") + if err != nil { + t.Fatal() + } + defer os.Remove(f.Name()) + defer f.Close() + + realStdout := os.Stdout + defer func() { os.Stdout = realStdout }() + r, fakeStdout, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = fakeStdout + + bar := DefaultBytes(-1, " downloading") + out := io.MultiWriter(f, bar) + _, err = io.Copy(out, resp.Body) + assert.Nil(t, err) + f.Sync() + f.Seek(0, 0) + + if err := fakeStdout.Close(); err != nil { + t.Fatal(err) + } + + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + + if err := r.Close(); err != nil { + t.Fatal(err) + } + + assert.Equal(t, "", string(b)) + + md5, err := md5sum(f) + assert.Nil(t, err) + assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) +} + +func TestConcurrency(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions( + 1000, + OptionSetWriter(&buf), + ) + var wg sync.WaitGroup + for i := 0; i < 900; i++ { + wg.Add(1) + go func(b *ProgressBar, wg *sync.WaitGroup) { + bar.Add(1) + wg.Done() + }(bar, &wg) + } + wg.Wait() + result := bar.state.currentNum + expect := int64(900) + assert.Equal(t, expect, result) +} + +func TestIterationNames(t *testing.T) { + b := Default(20) + tc := b.config + + // Checking for the default iterations per second or "it/s" + if tc.iterationString != "it" { + t.Errorf("Expected %s to be %s, instead I got %s", "iterationString", "it", tc.iterationString) + } + + // Change the default "it/s" to provide context, downloads per second or "dl/s" + b = NewOptions(20, OptionSetItsString("dl")) + tc = b.config + + if tc.iterationString != "dl" { + t.Errorf("Expected %s to be %s, instead I got %s", "iterationString", "dl", tc.iterationString) + } +} + +func md5sum(r io.Reader) (string, error) { + hash := md5.New() + _, err := io.Copy(hash, r) + return hex.EncodeToString(hash.Sum(nil)), err +} + +func TestProgressBar_Describe(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(100, OptionSetWidth(10), OptionSetWriter(&buf)) + bar.Describe("performing axial adjustments") + bar.Add(10) + result := buf.String() + expect := "" + + "\rperforming axial adjustments 0% | | [0s:0s]" + + "\r \r" + + "\rperforming axial adjustments 10% |█ | [0s:0s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestProgressBar_DescribeAfterFinish(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(100, + OptionSetDescription("Uploading files"), + OptionShowCount(), + OptionSetWriter(&buf), + OptionSetWidth(10), + ) + for i := 0; i < 100; i++ { + bar.Add(1) + } + bar.Describe("Upload complete") + result := buf.String() + assert.Contains(t, result, "Upload complete") +} + +func TestRenderBlankStateWithThrottle(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(100, OptionSetWidth(10), OptionSetRenderBlankState(true), OptionThrottle(time.Millisecond), OptionSetWriter(&buf)) + result := strings.TrimSpace(buf.String()) + expect := "0% | | [0s:0s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestOptionFullWidth(t *testing.T) { + var tests = []struct { + opts []Option + expected string + }{ + { // 1 + []Option{}, + "" + + "\r 10% |██████ | [1s:9s]" + + "\r \r" + + "\r 100% |██████████████████████████████████████████████████████████████| ", + }, + { // 2 + []Option{OptionSetDescription("Progress:")}, + "" + + "\rProgress: 10% |█████ | [1s:9s]" + + "\r \r" + + "\rProgress: 100% |█████████████████████████████████████████████████████| ", + }, + { // 3 + []Option{OptionSetDescription("<1/5>"), OptionShowDescriptionAtLineEnd()}, + "" + + "\r 10% |█████ | [1s:9s] <1/5>" + + "\r \r" + + "\r 100% |████████████████████████████████████████████████████████| <1/5>", + }, + { // 4 + []Option{OptionSetPredictTime(false)}, + "" + + "\r 10% |██████ | " + + "\r \r" + + "\r 100% |█████████████████████████████████████████████████████████████████████| ", + }, + { // 5 + []Option{OptionSetPredictTime(false), OptionShowElapsedTimeOnFinish()}, + "" + + "\r 10% |██████ | " + + "\r \r" + + "\r 100% |████████████████████████████████████████████████████████████████| [2s] ", + }, + { // 6 + []Option{OptionSetPredictTime(false), OptionSetElapsedTime(false)}, + "" + + "\r 10% |██████ | " + + "\r \r" + + "\r 100% |█████████████████████████████████████████████████████████████████████| ", + }, + { // 7 + []Option{OptionShowIts()}, + "" + + "\r 10% |█████ | (10 it/s) [1s:9s]" + + "\r \r" + + "\r 100% |█████████████████████████████████████████████████████| (50 it/s)", + }, + { // 8 + []Option{OptionShowCount()}, + "" + + "\r 10% |█████ | (10/100) [1s:9s]" + + "\r \r" + + "\r 100% |█████████████████████████████████████████████████████| (100/100)", + }, + { // 9 + []Option{OptionShowIts(), OptionShowCount(), OptionShowElapsedTimeOnFinish()}, + "" + + "\r 10% |████ | (10/100, 10 it/s) [1s:9s]" + + "\r \r" + + "\r 100% |████████████████████████████████████████████| (100/100, 50 it/s) [2s]", + }, + { // 10 + []Option{OptionSetDescription("Progress:"), OptionShowIts(), OptionShowCount()}, + "" + + "\rProgress: 10% |███ | (10/100, 10 it/s) [1s:9s]" + + "\r \r" + + "\rProgress: 100% |███████████████████████████████████| (100/100, 50 it/s)", + }, + { // 11 + []Option{OptionSetDescription("<3/5>"), OptionShowIts(), OptionShowCount(), OptionShowElapsedTimeOnFinish(), OptionShowDescriptionAtLineEnd()}, + "" + + "\r 10% |███ | (10/100, 10 it/s) [1s:9s] <3/5>" + + "\r \r" + + "\r 100% |██████████████████████████████████████| (100/100, 50 it/s) [2s] <3/5>", + }, + { // 12 + []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false)}, + "" + + "\r 10% |█████ | (10/100, 10 it/s) " + + "\r \r" + + "\r 100% |███████████████████████████████████████████████████| (100/100, 50 it/s) ", + }, + { // 13 + []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false), OptionShowElapsedTimeOnFinish()}, + "" + + "\r 10% |████ | (10/100, 10 it/s) " + + "\r \r" + + "\r 100% |██████████████████████████████████████████████| (100/100, 50 it/s) [2s] ", + }, + { // 14 + []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false), OptionSetElapsedTime(false)}, + "" + + "\r 10% |█████ | (10/100, 10 it/s) " + + "\r \r" + + "\r 100% |███████████████████████████████████████████████████| (100/100, 50 it/s) ", + }, + } + + for i, test := range tests { + test := test + t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) { + t.Parallel() + buf := strings.Builder{} + bar := NewOptions(100, append(test.opts, []Option{OptionFullWidth(), OptionSetWriter(&buf)}...)...) + bar.StartWithoutRender() + time.Sleep(1 * time.Second) + bar.Add(10) + time.Sleep(1 * time.Second) + bar.Add(90) + assert.Equal(t, test.expected, buf.String()) + }) + } +} + +func TestFixedWidthBarFitsTerminal(t *testing.T) { + oldTermWidth := termWidth + termWidth = func(w io.Writer) (int, error) { + return 50, nil + } + defer func() { + termWidth = oldTermWidth + }() + + buf := strings.Builder{} + bar := NewOptions( + 100, + OptionSetWriter(&buf), + OptionSetDescription("really-long-file-name.bin "), + OptionSetWidth(20), + OptionShowCount(), + OptionSetPredictTime(false), + ) + bar.Add(1) + + assert.LessOrEqual(t, getStringWidth(bar.config, bar.String()), 50) + assert.NotContains(t, bar.String(), "| |") +} + +func TestHumanizeBytesSI(t *testing.T) { + amount, suffix := humanizeBytes(float64(12.34)*1000*1000, false) + assert.Equal(t, "12 MB", fmt.Sprintf("%s%s", amount, suffix)) + + amount, suffix = humanizeBytes(float64(56.78)*1000*1000*1000, false) + assert.Equal(t, "57 GB", fmt.Sprintf("%s%s", amount, suffix)) +} + +func TestHumanizeBytesIEC(t *testing.T) { + amount, suffix := humanizeBytes(float64(12.34)*1024*1024, true) + assert.Equal(t, "12 MiB", fmt.Sprintf("%s%s", amount, suffix)) + + amount, suffix = humanizeBytes(float64(56.78)*1024*1024*1024, true) + assert.Equal(t, "57 GiB", fmt.Sprintf("%s%s", amount, suffix)) +} + +func TestProgressBar_StartWithoutRender(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(100, OptionSetWriter(&buf)) + time.Sleep(1 * time.Second) + bar.StartWithoutRender() + time.Sleep(1 * time.Second) + bar.Add(10) + result := strings.TrimSpace(buf.String()) + expect := "10% |████ | [1s:9s]" + if result != expect { + t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) + } +} + +func TestOptionSetSpinnerChangeInterval(t *testing.T) { + interval := 1000 * time.Millisecond + vt := virtualterm.NewDefault() + actuals := make([]string, 0, 8) + expecteds := []string{ + "◐ test [0s]", + "◓ test [1s]", + "◑ test [2s]", + "◒ test [3s]", + "◐ test [4s]", + "◓ test [5s]", + "◑ test [6s]", + "◒ test [7s]", + } + bar := NewOptions(-1, + OptionSetDescription("test"), + OptionSpinnerType(7), + OptionSetWriter(&vt), + OptionSetSpinnerChangeInterval(interval)) + bar.Add(1) + for i := 0; i < 8; i++ { + bar.lock.Lock() + s, _ := vt.String() + bar.lock.Unlock() + s = strings.TrimSpace(s) + actuals = append(actuals, s) + // sleep 50 ms more to make sure to go to next interval each time + time.Sleep(1050 * time.Millisecond) + } + for i := range actuals { + assert.Equal(t, expecteds[i], actuals[i]) + } +} + +func TestOptionSetSpinnerChangeIntervalZero(t *testing.T) { + vt := virtualterm.NewDefault() + bar := NewOptions(-1, + OptionSetDescription("test"), + OptionSpinnerType(7), + OptionSetWriter(&vt), + OptionSetSpinnerChangeInterval(0)) + actuals := make([]string, 0, 5) + expected := []string{ + "◐ test [0s]", + "◓ test [1s]", + "◑ test [2s]", + "◒ test [3s]", + "◐ test [4s]", + } + for i := 0; i < 5; i++ { + bar.Add(1) + bar.lock.Lock() + s, _ := vt.String() + bar.lock.Unlock() + _ = strings.TrimSpace(s) + } + for i := range actuals { + assert.Equal(t, expected[i], actuals[i]) + } +} + +func TestOptionShowTotalFalseDeterminate(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions64( + 100000000, + OptionShowBytes(true), + OptionShowCount(), + OptionSetWidth(10), + OptionShowTotalBytes(false), + OptionSetWriter(&buf), + ) + + bar.Add(10000) + if !strings.Contains(buf.String(), "10 kB, ") { + t.Errorf("wrong string: %s", buf.String()) + } +} + +func TestOptionShowTotalFalseIndeterminate(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetDescription("indeterminate spinner"), + OptionShowIts(), + OptionShowCount(), + OptionSpinnerType(9), + OptionShowTotalBytes(false), + OptionSetWriter(&buf), + ) + bar.Add(10) + if !strings.Contains(buf.String(), "10, ") { + t.Errorf("wrong string: %s", buf.String()) + } +} + +func TestOptionShowTotalTrueIndeterminate(t *testing.T) { + buf := strings.Builder{} + bar := NewOptions(-1, + OptionSetWidth(10), + OptionSetDescription("indeterminate spinner"), + OptionShowIts(), + OptionShowCount(), + OptionSpinnerType(9), + OptionShowTotalBytes(true), + OptionSetWriter(&buf), + ) + bar.Add(10) + if !strings.Contains(buf.String(), "10/-, ") { + t.Errorf("wrong string: %s", buf.String()) + } +} + +func freeTestHTTPAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for free port: %v", err) + } + defer listener.Close() + + return listener.Addr().String() +} + +func getHTTPWithRetry(t *testing.T, url string) *http.Response { + t.Helper() + + var lastErr error + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + return resp + } + lastErr = err + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("GET %s: %v", url, lastErr) + return nil +} + +func TestStartHTTPServer(t *testing.T) { + bar := Default(10, "test") + bar.Add(1) + + hostPort := freeTestHTTPAddr(t) + svr := bar.StartHTTPServer(hostPort) + + // check plain text + resp := getHTTPWithRetry(t, fmt.Sprintf("http://%s/desc", hostPort)) + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Error(err) + } + if string(got) != "1/10, 10.00%, 0s left" { + t.Errorf("wrong string: %s", string(got)) + } + + // check json + resp = getHTTPWithRetry(t, fmt.Sprintf("http://%s/state", hostPort)) + got, err = io.ReadAll(resp.Body) + if err != nil { + t.Error(err) + } + var result State + err = json.Unmarshal(got, &result) + if err != nil { + t.Error(err) + } + if result.Max != bar.State().Max || result.CurrentNum != bar.State().CurrentNum { + t.Errorf("wrong state: %v", result) + } + + // shutdown server + err = svr.Shutdown(context.Background()) + if err != nil { + t.Errorf("shutdown server failed: %v", err) + } + + // start new bar server + bar = Default(10, "test") + bar.Add(1) + svr = bar.StartHTTPServer(hostPort) + err = svr.Close() + if err != nil { + t.Errorf("shutdown server failed: %v", err) + } +} + +func TestTrimRateSamples(t *testing.T) { + base := time.Now() + times := []time.Time{ + base.Add(-10 * time.Second), + base.Add(-5 * time.Second), + base.Add(-2 * time.Second), + base, + } + rates := []float64{1, 2, 3, 4} + + clone := func() ([]float64, []time.Time) { + return append([]float64{}, rates...), append([]time.Time{}, times...) + } + + // A 6s window keeps only the samples collected within the last 6 seconds. + r, tt := clone() + gotRates, gotTimes := trimRateSamples(r, tt, 6*time.Second, base) + if len(gotRates) != 3 || gotRates[0] != 2 { + t.Errorf("window trim: expected 3 samples starting at 2, got %v", gotRates) + } + if len(gotTimes) != len(gotRates) { + t.Errorf("window trim: rates/times length mismatch: %d vs %d", len(gotRates), len(gotTimes)) + } + + // A window shorter than the gap between samples keeps only the newest sample. + r, tt = clone() + gotRates, _ = trimRateSamples(r, tt, time.Nanosecond, base) + if len(gotRates) != 1 || gotRates[0] != 4 { + t.Errorf("tiny window: expected only the newest sample 4, got %v", gotRates) + } + + // Without a window, at most maxLegacyRateSamples samples are kept. + manyRates := make([]float64, maxLegacyRateSamples+5) + manyTimes := make([]time.Time, maxLegacyRateSamples+5) + for i := range manyRates { + manyRates[i] = float64(i) + manyTimes[i] = base.Add(time.Duration(i) * time.Second) + } + gotRates, _ = trimRateSamples(manyRates, manyTimes, 0, base) + if len(gotRates) != maxLegacyRateSamples { + t.Errorf("legacy trim: expected %d samples, got %d", maxLegacyRateSamples, len(gotRates)) + } + if gotRates[0] != 5 { + t.Errorf("legacy trim: expected to keep the newest samples, first kept = %v", gotRates[0]) + } +} + +func TestRateAveragingWindow(t *testing.T) { + forceSamples := func(bar *ProgressBar, n int) { + for i := 0; i < n; i++ { + // backdate the counter so each Add records a new rate sample + bar.state.counterTime = time.Now().Add(-time.Second) + if err := bar.Add(1); err != nil { + t.Fatal(err) + } + } + } + + // Without a window the number of retained rate samples is capped. + legacy := NewOptions(1000000, OptionSetWriter(io.Discard)) + forceSamples(legacy, maxLegacyRateSamples+15) + if got := len(legacy.state.counterLastTenRates); got != maxLegacyRateSamples { + t.Errorf("legacy: expected %d retained rate samples, got %d", maxLegacyRateSamples, got) + } + + // With a window covering the whole test, every sample is retained, so the + // count exceeds the legacy cap. + windowed := NewOptions(1000000, OptionSetWriter(io.Discard), OptionSetRateAveragingWindow(time.Hour)) + forceSamples(windowed, maxLegacyRateSamples+15) + if got := len(windowed.state.counterLastTenRates); got <= maxLegacyRateSamples { + t.Errorf("windowed: expected more than %d retained samples, got %d", maxLegacyRateSamples, got) + } +}