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:
@@ -25,8 +25,10 @@ type Reporter struct {
|
|||||||
w io.Writer
|
w io.Writer
|
||||||
tty bool
|
tty bool
|
||||||
width int
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
win map[string]*speedWindow // keyed by Stat.ID
|
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
|
lastLog time.Time // last non-TTY line emitted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ func New(snap func() []download.Stat, human bool) *Reporter {
|
|||||||
w: os.Stdout,
|
w: os.Stdout,
|
||||||
tty: term.IsTerminal(int(os.Stdout.Fd())),
|
tty: term.IsTerminal(int(os.Stdout.Fd())),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
height: 24,
|
||||||
win: map[string]*speedWindow{},
|
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 {
|
if r.tty {
|
||||||
r.width = termWidth(r.width)
|
r.width = termWidth(r.width)
|
||||||
line = clampRunes(line, r.width)
|
r.height = termHeight(r.height)
|
||||||
fmt.Fprint(r.w, "\r"+line+"\x1b[K")
|
r.redraw(r.block(stats), final)
|
||||||
if final {
|
|
||||||
fmt.Fprintln(r.w)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Not a TTY: can't redraw one line, so print sparingly (and always the
|
// Not a TTY: no cursor control, so emit a single line sparingly (and always
|
||||||
// final line) to keep redirected output and logs readable.
|
// 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) {
|
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
|
||||||
fmt.Fprintln(r.w, line)
|
fmt.Fprintln(r.w, line)
|
||||||
r.lastLog = now
|
r.lastLog = now
|
||||||
@@ -165,27 +160,161 @@ func (r *Reporter) lineOne(s download.Stat) string {
|
|||||||
return b.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
|
var totDL, totUL int64
|
||||||
|
stalled := 0
|
||||||
for _, s := range stats {
|
for _, s := range stats {
|
||||||
dl, ul := r.rates(s)
|
dl, ul := r.rates(s)
|
||||||
totDL += dl
|
totDL += dl
|
||||||
totUL += ul
|
totUL += ul
|
||||||
}
|
if r.isStalled(s, dl) {
|
||||||
var b strings.Builder
|
stalled++
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package progress
|
package progress
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestShortNameRune ensures CJK names are truncated on runes, not bytes.
|
||||||
func TestShortNameRune(t *testing.T) {
|
func TestShortNameRune(t *testing.T) {
|
||||||
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
|
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
|
||||||
|
|||||||
Reference in New Issue
Block a user