Files
goget/internal/cli/progress.go
T

358 lines
7.4 KiB
Go

//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)
}