v0.4.0: scroll log, worker slots, disk skip, color gradient, parallel export
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type progressBar struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
width int
|
||||
termH int
|
||||
start time.Time
|
||||
errors []string
|
||||
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) addError(filename string, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.errors = append(p.errors, fmt.Sprintf(" \u274c %s: %v", filename, err))
|
||||
}
|
||||
|
||||
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 (p *progressBar) flushErrors() {
|
||||
for _, e := range p.errors {
|
||||
fmt.Fprintln(p.w, e)
|
||||
}
|
||||
p.errors = nil
|
||||
}
|
||||
|
||||
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 > 7 {
|
||||
idx = 7
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user