358 lines
7.4 KiB
Go
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)
|
||
|
|
}
|