package main import ( "fmt" "io" "strings" "sync" "time" ) type progressBar struct { mu sync.Mutex w io.Writer width int termH int start time.Time workers int footerLines int scrollSet bool total barLine album barLine workerState []workerSlot } type barLine struct { current int total int label string detail string } type workerSlot struct { filename string size int64 cloud string progress float64 bytesDone int64 bytesTotal int64 speed float64 status string } func newProgressBar(w io.Writer, workers int) *progressBar { fl := workers + 2 return &progressBar{ w: w, width: 80, termH: 24, start: time.Now(), workers: workers, footerLines: fl, workerState: make([]workerSlot, workers), } } func (p *progressBar) setTotal(current, total int, detail string) { p.mu.Lock() defer p.mu.Unlock() p.total = barLine{current: current, total: total, label: "Total", detail: detail} } func (p *progressBar) setAlbum(name string, current, total int) { p.mu.Lock() defer p.mu.Unlock() p.album = barLine{current: current, total: total, label: "Album", detail: name} } func (p *progressBar) setWorker(i int, filename string, size int64, cloud string, status string) { p.mu.Lock() defer p.mu.Unlock() if i >= 0 && i < len(p.workerState) { p.workerState[i].filename = filename p.workerState[i].size = size p.workerState[i].cloud = cloud p.workerState[i].status = status p.workerState[i].progress = 0 p.workerState[i].bytesDone = 0 p.workerState[i].bytesTotal = 0 } } func (p *progressBar) updateWorkerProgress(i int, progress float64, bytesDone, bytesTotal int64) { p.mu.Lock() defer p.mu.Unlock() if i >= 0 && i < len(p.workerState) { p.workerState[i].progress = progress p.workerState[i].bytesDone = bytesDone p.workerState[i].bytesTotal = bytesTotal if bytesDone > 0 && bytesTotal > 0 { p.workerState[i].size = bytesTotal } } } func (p *progressBar) logCompleted(line string) { p.mu.Lock() defer p.mu.Unlock() p.ensureScrollRegion() scrollTop := p.termH - p.footerLines fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s\n", scrollTop, line) p.drawFooterLocked() } func (p *progressBar) draw() { p.mu.Lock() defer p.mu.Unlock() p.ensureScrollRegion() p.drawFooterLocked() } func (p *progressBar) ensureScrollRegion() { w, h := termSize() if w != p.width || h != p.termH || !p.scrollSet { p.width = w p.termH = h scrollTop := p.termH - p.footerLines if scrollTop < 1 { scrollTop = 1 } fmt.Fprintf(p.w, "\x1b[1;%dr", scrollTop) p.scrollSet = true } } func (p *progressBar) drawFooterLocked() { scrollTop := p.termH - p.footerLines if scrollTop < 1 { scrollTop = 1 } elapsed := time.Since(p.start) fmt.Fprintf(p.w, "\x1b[?25l") footerStart := scrollTop + 1 for i := 0; i < p.workers; i++ { row := footerStart + i fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K", row) if i < len(p.workerState) && p.workerState[i].filename != "" { fmt.Fprintf(p.w, "%s", truncateOrPad(renderWorkerLine(p.workerState[i], p.width), p.width)) } else { fmt.Fprintf(p.w, "%s", strings.Repeat(" ", p.width)) } } row := footerStart + p.workers fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.total, elapsed, p.width)) row++ fmt.Fprintf(p.w, "\x1b[%d;1H\r\x1b[2K%s", row, renderLine(p.album, elapsed, p.width)) fmt.Fprintf(p.w, "\x1b[%d;1H", scrollTop) fmt.Fprintf(p.w, "\x1b[?25h") } func (p *progressBar) clear() { p.mu.Lock() defer p.mu.Unlock() fmt.Fprintf(p.w, "\x1b[r") fmt.Fprintf(p.w, "\x1b[?25h") p.scrollSet = false } func renderWorkerLine(ws workerSlot, width int) string { if width <= 0 { width = 80 } parts := []string{} if ws.status == "FAIL" { parts = append(parts, "\u274c") parts = append(parts, ws.filename) } else if ws.status == "skipped" { parts = append(parts, "\u23ed") parts = append(parts, ws.filename) if s := formatSize(ws.size); s != "" { parts = append(parts, s) } } else if ws.cloud == "cloud" && ws.progress > 0 && ws.progress < 1.0 { parts = append(parts, "\u2601") parts = append(parts, ws.filename) barWidth := 20 bar := renderBar(int(ws.progress*100), barWidth) pct := int(ws.progress * 100) parts = append(parts, fmt.Sprintf("[%s] %d%%", bar, pct)) if ws.bytesTotal > 0 { parts = append(parts, fmt.Sprintf("%s/%s", formatSize(ws.bytesDone), formatSize(ws.bytesTotal))) } if ws.speed > 0 { parts = append(parts, formatSpeed(ws.speed)) } } else if ws.cloud == "cloud" { parts = append(parts, "\u2601") parts = append(parts, ws.filename) if s := formatSize(ws.size); s != "" { parts = append(parts, s) } parts = append(parts, "downloaded") if ws.speed > 0 { parts = append(parts, formatSpeed(ws.speed)) } } else { parts = append(parts, "\u2705") parts = append(parts, ws.filename) if s := formatSize(ws.size); s != "" { parts = append(parts, s) } parts = append(parts, "copied") } return strings.Join(parts, " ") } func renderLine(b barLine, elapsed time.Duration, width int) string { if width <= 0 { width = 80 } pct := 0 if b.total > 0 { pct = b.current * 100 / b.total } counter := "" if b.total > 0 { counter = fmt.Sprintf("%d/%d", b.current, b.total) } eta := "" if pct > 0 && pct < 100 && elapsed > 500*time.Millisecond { remaining := elapsed * time.Duration(100-pct) / time.Duration(pct) if remaining > time.Second { eta = formatDuration(remaining) } } right := b.detail if b.label == "Album" && counter != "" && b.detail != "" { right = fmt.Sprintf("%s %s", b.detail, counter) } else if counter != "" && right != "" { right = fmt.Sprintf("%s %s", right, counter) } else if counter != "" { right = counter } if eta != "" { right += " " + eta } labelWidth := 6 pctWidth := 4 gap := 2 rightWidth := runeWidth(right) availableForBar := width - labelWidth - pctWidth - gap - rightWidth - gap if availableForBar < 3 { availableForBar = 3 } if availableForBar > 40 { availableForBar = 40 } bar := renderBar(pct, availableForBar) return fmt.Sprintf("%-6s [%s] %3d%% %s", b.label, bar, pct, right) } func renderBar(pct, barWidth int) string { if barWidth <= 0 { return "" } fraction := float64(pct) / 100.0 filled := fraction * float64(barWidth) fullBlocks := int(filled) partial := filled - float64(fullBlocks) var r, g uint8 if pct <= 50 { r = 255 g = uint8(float64(pct) * 5.1) } else { r = uint8(float64(100-pct) * 5.1) g = 255 } var sb strings.Builder fmt.Fprintf(&sb, "\x1b[38;2;%d;%d;0m", r, g) for i := 0; i < fullBlocks && i < barWidth; i++ { sb.WriteString("\u2588") } if fullBlocks < barWidth { fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"} idx := int(partial * 8) if idx > 0 { sb.WriteString(fracs[idx]) fullBlocks++ } } sb.WriteString("\x1b[0m") for i := fullBlocks; i < barWidth; i++ { sb.WriteString("\u2591") } return sb.String() } func runeWidth(s string) int { w := 0 for _, r := range s { if r >= 0x1100 && (r <= 0x115f || r == 0x2329 || r == 0x232a || (r >= 0x2e80 && r <= 0xa4cf && r != 0x303f) || (r >= 0xac00 && r <= 0xd7a3) || (r >= 0xf900 && r <= 0xfaff) || (r >= 0xfe30 && r <= 0xfe6f) || (r >= 0xff01 && r <= 0xff60) || (r >= 0xffe0 && r <= 0xffe6) || (r >= 0x20000 && r <= 0x2fffd) || (r >= 0x30000 && r <= 0x3fffd)) { w += 2 } else { w += 1 } } return w } func truncateOrPad(s string, width int) string { if width <= 0 { width = 80 } rw := runeWidth(s) if rw > width { runes := []rune(s) for i := range runes { if runeWidth(string(runes[:i+1])) > width-3 { return string(runes[:i]) + "..." } } } return s + strings.Repeat(" ", width-rw) } func formatDuration(d time.Duration) string { d = d.Round(time.Second) if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } m := int(d.Minutes()) s := int(d.Seconds()) % 60 return fmt.Sprintf("%dm%02ds", m, s) } func formatSpeed(bytesPerSec float64) string { if bytesPerSec <= 0 { return "" } const kb = 1024 const mb = kb * 1024 if bytesPerSec >= mb { return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb) } if bytesPerSec >= kb { return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb) } return fmt.Sprintf("%.0f B/s", bytesPerSec) } func formatSize(bytes int64) string { if bytes <= 0 { return "" } const kb = 1024 const mb = kb * 1024 if bytes >= mb { return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) } if bytes >= kb { return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) } return fmt.Sprintf("%d B", bytes) }