progress: redesign the multi-download table
Drop the stacked progress bars for an aligned column table under a header. Middle-elide names so downloads sharing a long prefix stay distinct (a leading truncation rendered them identically), drop the per-row DL:/UL: labels, and break the summary into downloading/seeding/ queued counts. Add SIZE (completed/total) and CN/SD (peer/seeder) columns and show the seed share ratio in place of an idle upload rate. Also fix the in-place redraw: the block could fill the pane's bottom row and scroll it out from under the cursor, stranding a summary line each tick. Reserve that row so the table falls back to the summary line instead.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user