progress: show multiple downloads as a bar table

lineMany crammed every download onto one line as name+percent, dropping
the speed column that answers "which one is stalled" and producing ugly
double brackets for names like "[Gecko]". render N>1 as a redrawn block:
one row each (name, bar, percent, DL speed, ETA/stalled), a header with
the active count and totals, falling back to a single summary line when
the table would overflow the window. one download keeps its dashboard.
This commit is contained in:
2026-06-20 19:45:49 +09:00
parent ee66235c36
commit 16cbb97748
2 changed files with 269 additions and 35 deletions

View File

@@ -25,9 +25,11 @@ type Reporter struct {
w io.Writer
tty bool
width int
height int
win map[string]*speedWindow // keyed by Stat.ID
lastLog time.Time // last non-TTY line emitted
win map[string]*speedWindow // keyed by Stat.ID
prevLines int // lines drawn in the previous TTY frame, redrawn in place
lastLog time.Time // last non-TTY line emitted
}
// logEvery bounds how often progress is printed when output is not a TTY, so a
@@ -62,6 +64,7 @@ func New(snap func() []download.Stat, human bool) *Reporter {
w: os.Stdout,
tty: term.IsTerminal(int(os.Stdout.Fd())),
width: 80,
height: 24,
win: map[string]*speedWindow{},
}
}
@@ -103,29 +106,21 @@ func (r *Reporter) render(final bool) {
}
}
var line string
switch len(stats) {
case 0:
if !final && r.tty {
return // nothing active; leave the line as-is
}
case 1:
line = r.lineOne(stats[0])
default:
line = r.lineMany(stats)
}
if r.tty {
r.width = termWidth(r.width)
line = clampRunes(line, r.width)
fmt.Fprint(r.w, "\r"+line+"\x1b[K")
if final {
fmt.Fprintln(r.w)
}
r.height = termHeight(r.height)
r.redraw(r.block(stats), final)
return
}
// Not a TTY: can't redraw one line, so print sparingly (and always the
// final line) to keep redirected output and logs readable.
// Not a TTY: no cursor control, so emit a single line sparingly (and always
// the final one) to keep redirected output and logs readable.
var line string
switch {
case len(stats) == 1:
line = r.lineOne(stats[0])
case len(stats) > 1:
line = r.summary(stats)
}
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
fmt.Fprintln(r.w, line)
r.lastLog = now
@@ -165,27 +160,161 @@ func (r *Reporter) lineOne(s download.Stat) string {
return b.String()
}
func (r *Reporter) lineMany(stats []download.Stat) string {
// nameW is the fixed rune width of the name column in the table, matching
// shortName's cap so a padded name lines the columns up.
const nameW = 20
// block builds the lines of the live display: the single-download dashboard for
// one download, a name/bar/speed/ETA table for several, or a one-line summary
// when the table would not fit the window.
func (r *Reporter) block(stats []download.Stat) []string {
switch {
case len(stats) == 0:
return nil
case len(stats) == 1:
return []string{r.lineOne(stats[0])}
case len(stats)+1 > r.height: // header + one row each would overflow the screen
return []string{r.summary(stats)}
default:
out := make([]string, 0, len(stats)+1)
out = append(out, r.summary(stats))
for _, s := range stats {
out = append(out, r.tableRow(s))
}
return out
}
}
// redraw repaints the block in place: move to the top of the previous frame,
// clear it, and print the new lines clamped to the terminal width. A stray line
// from stderr that lands in the block is painted over on the next tick.
func (r *Reporter) redraw(lines []string, final bool) {
if r.prevLines > 0 {
if r.prevLines > 1 {
fmt.Fprintf(r.w, "\x1b[%dA", r.prevLines-1) // up to the first line
}
fmt.Fprint(r.w, "\r\x1b[J") // column 0, clear to end of screen
}
for i, ln := range lines {
if i > 0 {
fmt.Fprint(r.w, "\n")
}
fmt.Fprint(r.w, clampRunes(ln, r.width))
}
r.prevLines = len(lines)
if final && len(lines) > 0 {
fmt.Fprintln(r.w) // leave the cursor below the block for the OK/FAIL lines
r.prevLines = 0
}
}
// summary is the header/aggregate line: the active count and total down/up
// speed, plus a stalled count when any download has gone quiet.
func (r *Reporter) summary(stats []download.Stat) string {
if len(stats) == 0 {
return ""
}
var totDL, totUL int64
stalled := 0
for _, s := range stats {
dl, ul := r.rates(s)
totDL += dl
totUL += ul
}
var b strings.Builder
fmt.Fprintf(&b, "[%d active DL:%s UL:%s]", len(stats), speed(totDL, r.human), speed(totUL, r.human))
for i, s := range stats {
if i >= 4 {
fmt.Fprintf(&b, "[+%d]", len(stats)-i)
break
}
if s.Total > 0 {
fmt.Fprintf(&b, "[%s %d%%]", shortName(s.Name), percent(s.Completed, s.Total))
} else {
fmt.Fprintf(&b, "[%s %s]", shortName(s.Name), humanSize(s.Completed, r.human))
if r.isStalled(s, dl) {
stalled++
}
}
return b.String()
line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats), speed(totDL, r.human), speed(totUL, r.human))
if stalled > 0 {
line += fmt.Sprintf(" (%d stalled)", stalled)
}
return line
}
// tableRow renders one download as "name |bar| pct metric tail": the bar and
// percent for progress, then the field that answers "stalled or fast" (DL speed
// or seeding) and a tail (ETA, upload rate, or a stalled flag).
func (r *Reporter) tableRow(s download.Stat) string {
dl, ul := r.rates(s)
metric, tail := r.rowMetric(s, dl, ul)
return fmt.Sprintf(" %s %s %s %-9s %s", padRune(shortName(s.Name), nameW), barOf(s), pctField(s), metric, tail)
}
// rowMetric returns a table row's two trailing fields for the download's state:
// the primary metric (DL speed, "seeding", "done", "failed") and a tail (ETA,
// upload rate, or a "stalled" flag).
func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (metric, tail string) {
switch s.Status {
case download.Seeding:
return "seeding", "UL:" + speed(ul, r.human)
case download.Complete:
return "done", ""
case download.Errored:
return "failed", ""
default:
metric = "DL:" + speed(dl, r.human)
switch {
case dl > 0 && s.Total > 0:
eta := time.Duration(float64(s.Total-s.Completed)/float64(dl)) * time.Second
tail = secfmt(eta)
case r.isStalled(s, dl):
tail = "stalled"
}
return metric, tail
}
}
// isStalled reports an active download that has gone a while with no new bytes,
// so the table can flag it rather than leave the eye to read DL:0B. A download
// too young to have a rate yet is not stalled.
func (r *Reporter) isStalled(s download.Stat, dl int64) bool {
if s.Status != download.Active || dl > 0 {
return false
}
w := r.win[s.ID]
if w == nil || len(w.samples) < 2 {
return false
}
return w.samples[len(w.samples)-1].t.Sub(w.samples[0].t) >= 4*time.Second
}
// barOf renders a fixed-width progress bar. A seeding or complete download is
// full; an unknown total (no Content-Length, or pre-metadata) shows empty.
func barOf(s download.Stat) string {
const cells = 12
k := 0
switch {
case s.Status == download.Seeding || s.Status == download.Complete:
k = cells
case s.Total > 0:
k = int(int64(cells) * s.Completed / s.Total)
if k > cells {
k = cells
}
}
return "|" + strings.Repeat("#", k) + strings.Repeat("-", cells-k) + "|"
}
// pctField is the percent column: a number, full for seeding/complete, or "--"
// when the total is unknown.
func pctField(s download.Stat) string {
switch {
case s.Status == download.Seeding || s.Status == download.Complete:
return "100%"
case s.Total > 0:
return fmt.Sprintf("%3d%%", percent(s.Completed, s.Total))
default:
return " --"
}
}
// padRune right-pads s with spaces to w runes (it is never longer, having been
// through shortName) so table columns line up on rune count.
func padRune(s string, w int) string {
if n := utf8.RuneCountInString(s); n < w {
return s + strings.Repeat(" ", w-n)
}
return s
}
// add records a sample and drops any that have fallen out of the window. A
@@ -272,3 +401,13 @@ func termWidth(prev int) int {
}
return 80
}
func termHeight(prev int) int {
if _, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil && h > 0 {
return h
}
if prev > 0 {
return prev
}
return 24
}