//go:build linux || freebsd // +build linux freebsd package cli import ( "fmt" "io" "os" "strings" "sync" "time" format "codeberg.org/petrbalvin/goget/internal/format" ) // ProgressBarConfig configures the progress bar type ProgressBarConfig struct { Total int64 Width int Colors bool OnComplete func() Writer io.Writer } // DefaultProgressBarConfig returns the default configuration func DefaultProgressBarConfig() *ProgressBarConfig { return &ProgressBarConfig{ Total: -1, Width: 0, Colors: true, Writer: os.Stderr, } } // ProgressBar represents a visual progress bar type ProgressBar struct { config *ProgressBarConfig current int64 startTime time.Time lastUpdate time.Time barWidth int mu sync.Mutex speedHistory []float64 speedMax float64 } // NewProgressBar creates a new progress bar func NewProgressBar(cfg *ProgressBarConfig) *ProgressBar { if cfg == nil { cfg = DefaultProgressBarConfig() } pb := &ProgressBar{ config: cfg, current: 0, startTime: time.Now(), lastUpdate: time.Now(), barWidth: 40, } if cfg.Width == 0 { tw := getTerminalWidth() pb.barWidth = max(20, tw-45) } else { pb.barWidth = cfg.Width } return pb } // SetTotal sets the total value in a thread-safe way func (pb *ProgressBar) SetTotal(total int64) { pb.mu.Lock() pb.config.Total = total pb.mu.Unlock() } // Update updates progress func (pb *ProgressBar) Update(current int64) { pb.mu.Lock() pb.current = current pb.renderLocked() pb.mu.Unlock() } // Add adds to the current progress func (pb *ProgressBar) Add(n int64) { pb.mu.Lock() pb.current += n pb.renderLocked() pb.mu.Unlock() } // Finish completes the progress bar func (pb *ProgressBar) Finish() { pb.mu.Lock() if pb.config.Total > 0 { pb.current = pb.config.Total } pb.renderFinalLocked() pb.mu.Unlock() if pb.config.OnComplete != nil { pb.config.OnComplete() } } // Cancel cancels the progress bar with error state func (pb *ProgressBar) Cancel() { pb.mu.Lock() pb.renderCancelledLocked() pb.mu.Unlock() } // renderLocked renders the current state; must be called with pb.mu locked func (pb *ProgressBar) renderLocked() { now := time.Now() if now.Sub(pb.lastUpdate) < 50*time.Millisecond { return } pb.lastUpdate = now w := pb.config.Writer total := pb.config.Total current := pb.current elapsed := now.Sub(pb.startTime) // Calculate speed speed := float64(0) if elapsed.Seconds() > 0 { speed = float64(current) / elapsed.Seconds() } // Record speed history (keep last 15 samples) pb.speedHistory = append(pb.speedHistory, speed) if len(pb.speedHistory) > 15 { pb.speedHistory = pb.speedHistory[len(pb.speedHistory)-15:] } // Update max speed if speed > pb.speedMax { pb.speedMax = speed } percent := float64(0) showPercent := total > 0 if showPercent { percent = float64(current) / float64(total) * 100 } var percentStr string if showPercent { percentStr = fmt.Sprintf(" %5.1f%%", percent) } else { percentStr = " ??%" } filled := 0 empty := pb.barWidth if showPercent { filled = int(float64(pb.barWidth) * percent / 100) empty = pb.barWidth - filled } barStart := "" barEnd := "" if pb.config.Colors { barStart = ColorGreen barEnd = ColorReset } bar := barStart + strings.Repeat("█", filled) + barEnd + strings.Repeat("░", empty) currentStr := format.Bytes(current) totalStr := "?" if total > 0 { totalStr = format.Bytes(total) } speedStr := format.Speed(speed) etaStr := "" if showPercent && current < total && speed > 0 { remaining := float64(total-current) / speed etaStr = formatDuration(time.Duration(remaining * float64(time.Second))) } etaPrefix := "" etaSuffix := "" if etaStr != "" && pb.config.Colors { etaPrefix = ColorCyan etaSuffix = ColorReset } // Build speed sparkline sparkline := pb.renderSparkline() var line strings.Builder if pb.config.Colors { line.WriteString(ColorBold) } line.WriteString("[") line.WriteString(bar) line.WriteString("]") if pb.config.Colors { line.WriteString(ColorReset) } line.WriteString(percentStr) line.WriteString(fmt.Sprintf(" %s/%s", currentStr, totalStr)) line.WriteString(fmt.Sprintf(" %s", speedStr)) if sparkline != "" { line.WriteString(fmt.Sprintf(" %s", sparkline)) } if etaStr != "" { line.WriteString(fmt.Sprintf(" %sETA: %s%s", etaPrefix, etaStr, etaSuffix)) } fmt.Fprintf(w, "\r%s\033[K", line.String()) } // renderFinalLocked renders the completed state; must be called with pb.mu locked func (pb *ProgressBar) renderFinalLocked() { w := pb.config.Writer elapsed := time.Since(pb.startTime) prefix := "" suffix := "" if pb.config.Colors { prefix = ColorGreen + ColorBold suffix = ColorReset } line := fmt.Sprintf("%s✓ Download complete%s | %s | %s elapsed", prefix, suffix, format.Bytes(pb.current), formatDuration(elapsed)) fmt.Fprintf(w, "\r%s\n", line) } // renderCancelledLocked renders the cancelled state; must be called with pb.mu locked func (pb *ProgressBar) renderCancelledLocked() { w := pb.config.Writer prefix := "" suffix := "" if pb.config.Colors { prefix = ColorRed + ColorBold suffix = ColorReset } line := fmt.Sprintf("%s✗ Download cancelled%s", prefix, suffix) fmt.Fprintf(w, "\r%s\n", line) } // renderSparkline returns a mini ASCII sparkline of recent speed history. // Uses Unicode block elements: ▁▂▃▄▅▆▇█ func (pb *ProgressBar) renderSparkline() string { if len(pb.speedHistory) < 2 { return "" } chars := []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} maxSpeed := pb.speedMax if maxSpeed == 0 { for _, s := range pb.speedHistory { if s > maxSpeed { maxSpeed = s } } } if maxSpeed == 0 { return "" } // Take up to 10 most recent samples history := pb.speedHistory if len(history) > 10 { history = history[len(history)-10:] } var sparkline strings.Builder for _, speed := range history { ratio := speed / maxSpeed if ratio > 1.0 { ratio = 1.0 } idx := int(ratio * float64(len(chars)-1)) if idx < 0 { idx = 0 } if idx >= len(chars) { idx = len(chars) - 1 } sparkline.WriteString(chars[idx]) } return sparkline.String() } // getTerminalWidth gets the terminal width func getTerminalWidth() int { if ws := os.Getenv("COLUMNS"); ws != "" { if w := parseInt(ws); w > 0 { return w } } return 80 } // parseInt helper func parseInt(s string) int { var n int for _, c := range s { if c >= '0' && c <= '9' { n = n*10 + int(c-'0') } else { break } } return n } // formatDuration formats a duration func formatDuration(d time.Duration) string { d = d.Round(time.Second) h := d / time.Hour d -= h * time.Hour m := d / time.Minute d -= m * time.Minute s := d / time.Second if h > 0 { return fmt.Sprintf("%dh%dm", h, m) } if m > 0 { return fmt.Sprintf("%dm%ds", m, s) } return fmt.Sprintf("%ds", s) } // PrintProgressInfo prints an info message with progress styling func PrintProgressInfo(w io.Writer, msg string) { prefix, suffix := "", "" if isColorTerminal(w) { prefix = ColorCyan + ColorBold suffix = ColorReset } fmt.Fprintf(w, "%s• %s%s\n", prefix, msg, suffix) } // PrintProgressSuccess prints a success message with progress styling func PrintProgressSuccess(w io.Writer, msg string) { prefix, suffix := "", "" if isColorTerminal(w) { prefix = ColorGreen + ColorBold suffix = ColorReset } fmt.Fprintf(w, "%s✓ %s%s\n", prefix, msg, suffix) }