diff --git a/progress/progress.go b/progress/progress.go index 5c3c97c..f7d9d01 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -1,7 +1,8 @@ -// Package progress renders a live, single-line status display. One goroutine ticks -// once a second, pulls a snapshot of the running downloads, and derives speeds -// from the change since the previous tick. There are no locks here: the data -// arrives by value through the snapshot function. +// Package progress renders a live status display: a summary line over one +// aligned row per download, collapsing to the summary alone when the rows would +// not fit. One goroutine ticks once a second, pulls a snapshot of the running +// downloads, and derives speeds from the change since the previous tick. There +// are no locks here: the data arrives by value through the snapshot function. package progress import ( @@ -9,6 +10,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "time" "unicode/utf8" @@ -131,7 +133,7 @@ func (r *Reporter) lineOne(s download.Stat) string { dl, ul := r.rates(s) seeding := s.Status == download.Seeding var b strings.Builder - fmt.Fprintf(&b, "[%s ", shortName(s.Name)) + fmt.Fprintf(&b, "[%s ", elide(s.Name, nameW)) // While seeding, report the share ratio in place of the size block and drop // the DL: field; otherwise show completed/total (or bare completed). if seeding { @@ -160,23 +162,29 @@ func (r *Reporter) lineOne(s 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. +// elide'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. +// one download, a summary + column-header + one row each 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 + case len(stats)+3 > r.height: + // The block is repainted in place by moving the cursor up over it, which + // breaks if printing the last row reaches the screen's bottom row and + // scrolls the frame out from under the cursor. term height counts the row + // the shell prompt occupies, so a summary + header + one row each + // (len+2 lines) must still leave the bottom row free: fall back to the + // single summary line once len+2 would fill to the bottom. return []string{r.summary(stats)} default: - out := make([]string, 0, len(stats)+1) - out = append(out, r.summary(stats)) + out := make([]string, 0, len(stats)+2) + out = append(out, r.summary(stats), tableHeader()) for _, s := range stats { out = append(out, r.tableRow(s)) } @@ -207,70 +215,134 @@ func (r *Reporter) redraw(lines []string, final bool) { } } -// summary is the header/aggregate line: the active count and total down/up -// speed, plus a stalled count when any download has gone quiet. +// summary is the header/aggregate line: a count of downloads in each state, the +// total down/up speed, and a stalled count when any download has gone quiet. The +// state breakdown replaces a single "active" tally so seeding and queued +// downloads are not lumped in with the ones actually moving bytes. func (r *Reporter) summary(stats []download.Stat) string { if len(stats) == 0 { return "" } var totDL, totUL int64 - stalled, waiting := 0, 0 + var downloading, seeding, queued, stalled int for _, s := range stats { dl, ul := r.rates(s) totDL += dl totUL += ul - if s.Status == download.Waiting { - waiting++ - continue - } - if r.isStalled(s, dl) { - stalled++ + switch s.Status { + case download.Waiting: + queued++ + case download.Seeding: + seeding++ + default: + downloading++ + if r.isStalled(s, dl) { + stalled++ + } } } - line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats)-waiting, humanSize(totDL, r.human), humanSize(totUL, r.human)) - if waiting > 0 { - line += fmt.Sprintf(" (%d waiting)", waiting) + parts := []string{fmt.Sprintf("%d downloading", downloading)} + if seeding > 0 { + parts = append(parts, fmt.Sprintf("%d seeding", seeding)) } + if queued > 0 { + parts = append(parts, fmt.Sprintf("%d queued", queued)) + } + line := strings.Join(parts, " · ") + fmt.Sprintf(" DL %s UL %s", r.rateStr(totDL), r.rateStr(totUL)) 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) +// tableHeader names the columns once so the rows can drop the per-field DL:/UL: +// labels. It uses tableRow's exact format string so the headings line up over +// their columns. SIZE is completed/total; CN is the connection/peer count and SD +// the connected seeders — the fields that tell a stalled download (no peers) +// from a slow one (peers, no speed). +func tableHeader() string { + return fmt.Sprintf("%s %4s %-13s %-8s %3s %3s %s", padRune("NAME", nameW), "%", "SIZE", "RATE", "CN", "SD", "ETA") } -// 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) { +// tableRow renders one download as aligned columns under tableHeader: +// "name pct size rate cn sd tail". The name is middle-elided to keep its +// distinguishing tail; percent, rate and the peer counts are fixed-width so the +// digits scan straight down; size is completed/total; the rate column carries +// the download speed or a state word (seeding/queued/...), and the tail carries +// the ETA, the share ratio while seeding, or a stalled flag. +func (r *Reporter) tableRow(s download.Stat) string { + dl, ul := r.rates(s) + rate, tail := r.rowMetric(s, dl, ul) + cn, sd := peerFields(s) + row := fmt.Sprintf("%s %4s %-13s %-8s %3s %3s %s", padRune(elide(s.Name, nameW), nameW), pctField(s), sizeField(s, r.human), rate, cn, sd, tail) + // A queued row's trailing SIZE/CN/SD/ETA columns are blank; drop the trailing + // spaces so the line ends where its content does. + return strings.TrimRight(row, " ") +} + +// sizeField is the absolute-progress column: completed/total (e.g. +// "950MiB/5.0GiB"), just the completed bytes when the total is unknown (an HTTP +// stream with no Content-Length, or a pre-metadata magnet), or blank for a +// download that has not started. +func sizeField(s download.Stat, human bool) string { + switch { + case s.Status == download.Waiting: + return "" + case s.Total > 0: + return humanSize(s.Completed, human) + "/" + humanSize(s.Total, human) + case s.Completed > 0: + return humanSize(s.Completed, human) + default: + return "" + } +} + +// peerFields formats the connection (CN) and seeder (SD) columns. SD is blank +// for HTTP, which has no seeders, and both are blank for a download with no live +// connections (queued, done, failed) so the columns aren't noise where they +// carry no meaning. +func peerFields(s download.Stat) (cn, sd string) { + switch s.Status { + case download.Active, download.Seeding: + cn = strconv.Itoa(s.Conns) + if s.IsBT { + sd = strconv.Itoa(s.Seeders) + } + } + return cn, sd +} + +// rowMetric returns a table row's rate column and its tail for the download's +// state: the rate is the download speed or the state word (seeding/queued/done/ +// failed); the tail is an ETA, the share ratio while seeding (the seed-stop +// criterion, more telling than an idle upload rate), or a "stalled" flag. +func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (rate, tail string) { switch s.Status { case download.Waiting: return "queued", "" case download.Seeding: - return "seeding", "UL:" + humanSize(ul, r.human) + return "seeding", fmt.Sprintf("r%.2f", ratio(s.Uploaded, s.Completed)) case download.Complete: return "done", "" case download.Errored: return "failed", "" default: - metric = "DL:" + humanSize(dl, r.human) + rate = r.rateStr(dl) switch { case dl > 0 && s.Total > 0: tail = secfmt(etaDuration(s.Total-s.Completed, dl)) case r.isStalled(s, dl): tail = "stalled" } - return metric, tail + return rate, tail } } +// rateStr formats a bytes/sec speed for display, e.g. "388KiB/s". +func (r *Reporter) rateStr(bps int64) string { + return humanSize(bps, r.human) + "/s" +} + // maxETASeconds caps an ETA before it becomes a Duration: a near-stalled download // yields an astronomically large seconds value, and time.Duration(secs) * // time.Second would overflow int64 and wrap to a bogus tiny ETA. secfmt renders @@ -299,23 +371,6 @@ func (r *Reporter) isStalled(s download.Stat, dl int64) bool { 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 { @@ -394,14 +449,24 @@ func ratio(uploaded, completed int64) float64 { return float64(uploaded) / float64(completed) } -func shortName(s string) string { - const max = 20 - // Truncate on runes, not bytes, so a multibyte name (CJK, emoji) is never - // cut mid-rune. - if utf8.RuneCountInString(s) > max { - return string([]rune(s)[:max-1]) + "~" +// elide shortens s to at most w runes, keeping the head and the tail joined by a +// single "…". Download names often share a long prefix (a release group, a +// series) and differ only at the end (an episode or volume number), so dropping +// the middle keeps what tells two downloads apart — where a leading-only +// truncation would collapse them to the same string. A name within w prints +// whole. It works on runes, never bytes, so a multibyte glyph (CJK, emoji) is +// never split. +func elide(s string, w int) string { + r := []rune(s) + if len(r) <= w { + return s } - return s + if w <= 1 { + return string(r[:w]) + } + head := w / 2 // the w-1 kept runes split around one "…", + tail := w - 1 - head // head taking the extra rune when w is even + return string(r[:head]) + "…" + string(r[len(r)-tail:]) } // clampRunes limits a line to width runes (not bytes) so multibyte glyphs are diff --git a/progress/progress_test.go b/progress/progress_test.go index 0be521d..3f14cd2 100644 --- a/progress/progress_test.go +++ b/progress/progress_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/hanbok/got/download" ) @@ -113,25 +114,6 @@ 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) @@ -144,22 +126,53 @@ func TestPctField(t *testing.T) { } } -// TestRowMetric: the two trailing fields per download state. +// TestRowMetric: the rate column and trailing field 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 rate, tail := r.rowMetric(download.Stat{Status: download.Active, Total: 100, Completed: 50}, 10, 0); !strings.HasSuffix(rate, "/s") || tail == "" { + t.Errorf("active downloading: rate=%q tail=%q, want a …/s rate + an ETA", rate, 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 rate, tail := r.rowMetric(download.Stat{Status: download.Seeding, Completed: 1000, Uploaded: 1500}, 0, 0); rate != "seeding" || tail != "r1.50" { + t.Errorf("seeding: rate=%q tail=%q, want seeding + r1.50 (share ratio)", rate, tail) } - if m, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); m != "done" { - t.Errorf("complete metric=%q, want done", m) + if rate, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); rate != "done" { + t.Errorf("complete rate=%q, want done", rate) } } -// TestBlockTableAndFallback: several downloads render as header+rows when they -// fit, and collapse to a single summary line when the window is too short. +// TestPeerFields: CN (connections/peers) and SD (seeders) show for active and +// seeding downloads; SD is blank for HTTP; both blank when there are no live +// connections (queued). +func TestPeerFields(t *testing.T) { + if cn, sd := peerFields(download.Stat{Status: download.Active, IsBT: true, Conns: 18, Seeders: 4}); cn != "18" || sd != "4" { + t.Errorf("active torrent: cn=%q sd=%q, want 18 / 4", cn, sd) + } + if cn, sd := peerFields(download.Stat{Status: download.Active, IsBT: false, Conns: 5}); cn != "5" || sd != "" { + t.Errorf("active http: cn=%q sd=%q, want 5 / blank", cn, sd) + } + if cn, sd := peerFields(download.Stat{Status: download.Waiting, Conns: 0}); cn != "" || sd != "" { + t.Errorf("queued: cn=%q sd=%q, want blank / blank", cn, sd) + } +} + +// TestSizeField: completed/total when the total is known, just the completed +// bytes when it is not, and blank for a queued download. +func TestSizeField(t *testing.T) { + if got := sizeField(download.Stat{Status: download.Active, Completed: 512, Total: 2048}, false); got != "512B/2048B" { + t.Errorf("known total = %q, want 512B/2048B", got) + } + if got := sizeField(download.Stat{Status: download.Active, Completed: 512, Total: -1}, false); got != "512B" { + t.Errorf("unknown total = %q, want 512B", got) + } + if got := sizeField(download.Stat{Status: download.Waiting, Total: 2048}, false); got != "" { + t.Errorf("queued = %q, want blank", got) + } +} + +// TestBlockTableAndFallback: several downloads render as summary+header+rows when +// they fit, and collapse to a single summary line when the window is too short. +// The fallback must leave the terminal's bottom row free (the stacked-summary +// redraw fix): summary+header+2 rows is 4 lines, so it needs height >= 5. func TestBlockTableAndFallback(t *testing.T) { r := newReporter(true) stats := []download.Stat{ @@ -167,10 +180,14 @@ func TestBlockTableAndFallback(t *testing.T) { {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) + if got := r.block(stats); len(got) != 4 { // summary + header + 2 rows + t.Errorf("table block = %d lines, want 4:\n%v", len(got), got) } - r.height = 2 // header + 2 rows = 3 > 2 + r.height = 5 // 4 lines fit with the bottom row left free + if got := r.block(stats); len(got) != 4 { + t.Errorf("just-fits block = %d lines, want 4:\n%v", len(got), got) + } + r.height = 4 // one short: would fill to the bottom row, so fall back if got := r.block(stats); len(got) != 1 { t.Errorf("overflow block = %d lines, want 1 summary:\n%v", len(got), got) } @@ -191,32 +208,50 @@ func TestRenderRedraw(t *testing.T) { } r.render(false) first := buf.String() - for _, want := range []string{"2 active", "alpha", "seeding", "|"} { + for _, want := range []string{"downloading", "seeding", "alpha", "NAME"} { if !strings.Contains(first, want) { t.Fatalf("first frame missing %q:\n%q", want, first) } } + if strings.Contains(first, "|") { + t.Errorf("first frame should carry no progress bar:\n%q", 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[3A") { // up prevLines-1 = 4-1 (summary+header+2 rows) + t.Errorf("second frame should move cursor up 3 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 - got := shortName(name) - runes := []rune(got) - if len(runes) != 20 { - t.Errorf("shortName rune count = %d, want 20", len(runes)) +// TestElideKeepsTail: names sharing a long prefix but differing at the end stay +// distinct — the failure the old leading truncation caused — and the result is +// capped at the column width on runes with the distinguishing tail preserved. +func TestElideKeepsTail(t *testing.T) { + a := elide("[Group] Long Common Series Name vol.1", nameW) + b := elide("[Group] Long Common Series Name vol.2", nameW) + if a == b { + t.Errorf("names differing only in the tail collapsed: %q == %q", a, b) } - if runes[len(runes)-1] != '~' { - t.Errorf("expected trailing ~, got %q", got) + if n := utf8.RuneCountInString(a); n != nameW { + t.Errorf("elide rune count = %d, want %d (%q)", n, nameW, a) + } + if !strings.Contains(a, "…") { + t.Errorf("expected a middle ellipsis: %q", a) + } + if !strings.HasSuffix(a, "vol.1") { + t.Errorf("expected the distinguishing tail kept: %q", a) + } +} + +// TestElideRune ensures CJK names are elided on runes, not bytes. +func TestElideRune(t *testing.T) { + name := strings.Repeat("あ", 30) // 30 runes, 90 bytes + if n := utf8.RuneCountInString(elide(name, nameW)); n != nameW { + t.Errorf("elide rune count = %d, want %d", n, nameW) } }