From 16cbb9774865df1e56d4decd66bac53b4f639f44 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Sat, 20 Jun 2026 19:45:49 +0900 Subject: [PATCH] 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. --- progress/progress.go | 209 +++++++++++++++++++++++++++++++------- progress/progress_test.go | 95 +++++++++++++++++ 2 files changed, 269 insertions(+), 35 deletions(-) diff --git a/progress/progress.go b/progress/progress.go index ee3e263..a35600c 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -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 +} diff --git a/progress/progress_test.go b/progress/progress_test.go index 820e81a..0be521d 100644 --- a/progress/progress_test.go +++ b/progress/progress_test.go @@ -1,6 +1,7 @@ package progress import ( + "bytes" "strings" "testing" "time" @@ -112,6 +113,100 @@ func TestLineOneCNSD(t *testing.T) { } } +func TestBarOf(t *testing.T) { + tests := []struct { + name string + s download.Stat + want string + }{ + {"empty", download.Stat{Status: download.Active, Total: 100, Completed: 0}, "|------------|"}, + {"half", download.Stat{Status: download.Active, Total: 100, Completed: 50}, "|######------|"}, + {"full by complete", download.Stat{Status: download.Complete}, "|############|"}, + {"full by seeding", download.Stat{Status: download.Seeding}, "|############|"}, + {"unknown total is empty", download.Stat{Status: download.Active, Total: -1, Completed: 9}, "|------------|"}, + } + for _, tc := range tests { + if got := barOf(tc.s); got != tc.want { + t.Errorf("%s: barOf = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestPctField(t *testing.T) { + if got := pctField(download.Stat{Status: download.Active, Total: 100, Completed: 50}); got != " 50%" { + t.Errorf("active 50%% = %q, want ' 50%%'", got) + } + if got := pctField(download.Stat{Status: download.Seeding}); got != "100%" { + t.Errorf("seeding = %q, want 100%%", got) + } + if got := pctField(download.Stat{Status: download.Active, Total: -1}); got != " --" { + t.Errorf("unknown total = %q, want ' --'", got) + } +} + +// TestRowMetric: the two trailing fields per download state. +func TestRowMetric(t *testing.T) { + r := newReporter(true) + if m, tail := r.rowMetric(download.Stat{Status: download.Active, Total: 100, Completed: 50}, 10, 0); !strings.HasPrefix(m, "DL:") || tail == "" { + t.Errorf("active downloading: metric=%q tail=%q, want DL:… + an ETA", m, tail) + } + if m, tail := r.rowMetric(download.Stat{Status: download.Seeding}, 0, 128<<10); m != "seeding" || !strings.HasPrefix(tail, "UL:") { + t.Errorf("seeding: metric=%q tail=%q, want seeding + UL:…", m, tail) + } + if m, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); m != "done" { + t.Errorf("complete metric=%q, want done", m) + } +} + +// TestBlockTableAndFallback: several downloads render as header+rows when they +// fit, and collapse to a single summary line when the window is too short. +func TestBlockTableAndFallback(t *testing.T) { + r := newReporter(true) + stats := []download.Stat{ + {ID: "a", Name: "a", Status: download.Active, Total: 100, Completed: 50}, + {ID: "b", Name: "b", Status: download.Active, Total: 100, Completed: 10}, + } + r.height = 24 + if got := r.block(stats); len(got) != 3 { // header + 2 rows + t.Errorf("table block = %d lines, want 3:\n%v", len(got), got) + } + r.height = 2 // header + 2 rows = 3 > 2 + if got := r.block(stats); len(got) != 1 { + t.Errorf("overflow block = %d lines, want 1 summary:\n%v", len(got), got) + } +} + +// TestRenderRedraw: the TTY path draws the block, then on the next frame moves +// the cursor up over the previous frame and clears it before repainting. +func TestRenderRedraw(t *testing.T) { + snap := []download.Stat{ + {ID: "a", Name: "alpha", IsBT: true, Status: download.Active, Total: 100, Completed: 50}, + {ID: "b", Name: "beta", IsBT: true, Status: download.Seeding, Total: 100, Completed: 100, Uploaded: 200}, + } + var buf bytes.Buffer + r := &Reporter{ + snap: func() []download.Stat { return snap }, + human: true, w: &buf, tty: true, width: 200, height: 24, + win: map[string]*speedWindow{}, + } + r.render(false) + first := buf.String() + for _, want := range []string{"2 active", "alpha", "seeding", "|"} { + if !strings.Contains(first, want) { + t.Fatalf("first frame missing %q:\n%q", want, first) + } + } + buf.Reset() + r.render(false) + second := buf.String() + if !strings.Contains(second, "\x1b[2A") { // up prevLines-1 = 3-1 + t.Errorf("second frame should move cursor up 2 lines:\n%q", second) + } + if !strings.Contains(second, "\x1b[J") { + t.Errorf("second frame should clear to end of screen:\n%q", second) + } +} + // TestShortNameRune ensures CJK names are truncated on runes, not bytes. func TestShortNameRune(t *testing.T) { name := strings.Repeat("あ", 30) // 30 runes, 90 bytes