Files
photocli/cmd/photoscli/progress.go
T
Ein Anderssono 2e73d01b40
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled
v0.5.0: manifests, filters, logging, docs
2026-06-15 00:00:06 +02:00

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