375 lines
8.5 KiB
Go
375 lines
8.5 KiB
Go
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)
|
|
} |