diff --git a/internal/fetch/progress.go b/internal/fetch/progress.go index fd86d4a..b55eb18 100644 --- a/internal/fetch/progress.go +++ b/internal/fetch/progress.go @@ -4,49 +4,38 @@ import ( "fmt" "io" "strconv" - "sync" "time" "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/progress" ) -// progressBar is a wrapper around an io.Reader that displays a progress bar -// to stderr. When reading is complete, the Close method MUST be called. +// progressBar wraps a progress.Bar with fetch-specific close behavior +// (native progress emission and download summary). type progressBar struct { - r io.Reader - printer *core.Printer - bytesRead int64 - totalBytes int64 - chRead chan int64 - start time.Time - wg sync.WaitGroup + bar *progress.Bar + printer *core.Printer } func newProgressBar(r io.Reader, p *core.Printer, totalBytes int64) *progressBar { - pr := &progressBar{ - r: r, - printer: p, - totalBytes: totalBytes, - chRead: make(chan int64, 1), - start: time.Now(), + var onRender func(int64) + if core.IsStdoutTerm { + onRender = func(pct int64) { + emitProgress(1, int(pct), p) + } + } + return &progressBar{ + bar: progress.NewBar(r, p, totalBytes, onRender), + printer: p, } - pr.wg.Add(1) - go pr.renderLoop() - return pr } func (pb *progressBar) Read(p []byte) (int, error) { - n, err := pb.r.Read(p) - if n > 0 { - pb.chRead <- int64(n) - } - return n, err + return pb.bar.Read(p) } func (pb *progressBar) Close(path string, err error) { - // Close the reader channel and wait for the loop to exit. - close(pb.chRead) - pb.wg.Wait() + bytesRead, elapsed := pb.bar.Stop() p := pb.printer @@ -60,122 +49,37 @@ func (pb *progressBar) Close(path string, err error) { p.WriteString("\n\n") } else { // Replace the progress bar with a summary. - writeFinalProgress(p, pb.bytesRead, time.Since(pb.start), 32, path) + writeFinalProgress(p, bytesRead, elapsed, 32, path) } p.Flush() } -func (pb *progressBar) renderLoop() { - defer pb.wg.Done() - - lastUpdateTime := pb.start - var chTimeout <-chan time.Time - for { - select { - case <-chTimeout: - chTimeout = nil - case n, ok := <-pb.chRead: - if !ok { - // Reader channel has been closed, exit. - pb.render() - return - } - pb.bytesRead += n - - if chTimeout != nil { - // We're waiting on a timeout to re-render. - continue - } - - // Check if enough time has passed since the last - // render. If not, set a timeout and continue. - now := time.Now() - dur := lastUpdateTime.Add(100 * time.Millisecond).Sub(now) - if dur > 0 { - chTimeout = time.After(dur) - continue - } - lastUpdateTime = now - } - - pb.render() - } +// progressSpinner wraps a progress.Spinner with fetch-specific close behavior +// (native progress emission and download summary). +type progressSpinner struct { + spinner *progress.Spinner + printer *core.Printer } -func (pb *progressBar) render() { - const barWidth = 30 - percentage := pb.bytesRead * 100 / pb.totalBytes - completedWidth := min(barWidth*percentage/100, barWidth) - - p := pb.printer - - // Render native progress bar. +func newProgressSpinner(r io.Reader, p *core.Printer) *progressSpinner { + var onStart func() if core.IsStdoutTerm { - emitProgress(1, int(percentage), p) - } - - p.WriteString("\r") - - p.Set(core.Bold) - p.WriteString("[") - p.Set(core.Green) - for range completedWidth { - p.WriteString("=") - } - p.Reset() - for range barWidth - completedWidth { - p.WriteString(" ") - } - p.Set(core.Bold) - p.WriteString("] ") - - pctStr := strconv.FormatInt(percentage, 10) - for i := len(pctStr); i < 3; i++ { - p.WriteString(" ") + onStart = func() { + emitProgress(3, 0, p) + } } - p.WriteString(pctStr) - p.WriteString("%") - p.Reset() - - p.WriteString(" (") - size := formatSize(pb.bytesRead) - for range 7 - len(size) { - p.WriteString(" ") + return &progressSpinner{ + spinner: progress.NewSpinner(r, p, onStart), + printer: p, } - p.WriteString(size) - p.WriteString(" / ") - p.WriteString(formatSize(pb.totalBytes)) - p.WriteString(")") - p.Flush() -} - -// progressSpinner is a wrapper around an io.Reader that displays a progress -// spinner to stderr. When reading is complete, the Close method MUST be called. -type progressSpinner struct { - r io.Reader - printer *core.Printer - bytesRead int64 - chRead chan int64 - position int64 - wg sync.WaitGroup - start time.Time } -func newProgressSpinner(r io.Reader, p *core.Printer) *progressSpinner { - ps := &progressSpinner{ - r: r, - printer: p, - chRead: make(chan int64, 1), - start: time.Now(), - } - ps.wg.Add(1) - go ps.renderLoop() - return ps +func (ps *progressSpinner) Read(p []byte) (int, error) { + return ps.spinner.Read(p) } func (ps *progressSpinner) Close(path string, err error) { - close(ps.chRead) - ps.wg.Wait() + bytesRead, elapsed := ps.spinner.Stop() p := ps.printer @@ -188,86 +92,11 @@ func (ps *progressSpinner) Close(path string, err error) { p.WriteString("\n\n") } else { // Replace the progress spinner with a summary. - writeFinalProgress(p, ps.bytesRead, time.Since(ps.start), 20, path) + writeFinalProgress(p, bytesRead, elapsed, 20, path) } p.Flush() } -func (ps *progressSpinner) Read(p []byte) (int, error) { - n, err := ps.r.Read(p) - if n > 0 { - ps.chRead <- int64(n) - } - return n, err -} - -func (ps *progressSpinner) renderLoop() { - defer ps.wg.Done() - - // Render native progress bar. - if core.IsStdoutTerm { - emitProgress(3, 0, ps.printer) - } - - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ticker.C: - ps.render() - ps.position++ - case n, ok := <-ps.chRead: - if !ok { - // Reader channel has been closed, exit. - ps.render() - return - } - ps.bytesRead += n - } - } -} - -func (ps *progressSpinner) render() { - const width = 20 - - var value string - var offset int - position := ps.position % (width * 2) - if position < width { - value = "=>" - offset = int(position) - } else { - value = "<=" - offset = int(width*2 - position - 1) - } - - p := ps.printer - p.WriteString("\r") - p.Set(core.Bold) - p.WriteString("[") - for range offset { - p.WriteString(" ") - } - p.Set(core.Green) - p.WriteString(value) - p.Reset() - for range width - offset - 1 { - p.WriteString(" ") - } - p.Set(core.Bold) - p.WriteString("]") - p.Reset() - - p.WriteString(" ") - size := formatSize(ps.bytesRead) - for range 7 - len(size) { - p.WriteString(" ") - } - p.WriteString(size) - - p.Flush() -} - type progressStatic struct { r io.Reader printer *core.Printer @@ -299,25 +128,6 @@ func (ps *progressStatic) Close(path string, err error) { ps.printer.Flush() } -// formatSize converts bytes to a human-readable string. -func formatSize(bytes int64) string { - const units = "KMGTPE" - const unit = 1024 - if bytes < unit { - return strconv.FormatInt(bytes, 10) + "B" - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= 1000; n /= unit { - div *= unit - exp++ - } - value := float64(bytes) / float64(div) - if exp >= len(units) { - return "NaN" - } - return strconv.FormatFloat(value, 'f', 1, 64) + string(units[exp]) + "B" -} - func formatDuration(d time.Duration) string { switch { case d < time.Second: @@ -338,7 +148,7 @@ func writeFinalProgress(p *core.Printer, bytesRead int64, dur time.Duration, toC p.WriteString("Downloaded ") p.Set(core.Bold) - p.WriteString(formatSize(bytesRead)) + p.WriteString(progress.FormatSize(bytesRead)) p.Reset() p.WriteString(" in ") p.Set(core.Italic) diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..81bd528 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,264 @@ +package progress + +import ( + "io" + "strconv" + "sync" + "time" + + "github.com/ryanfowler/fetch/internal/core" +) + +// FormatSize converts bytes to a human-readable string. +func FormatSize(bytes int64) string { + const units = "KMGTPE" + const unit = 1024 + if bytes < unit { + return strconv.FormatInt(bytes, 10) + "B" + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= 1000; n /= unit { + div *= unit + exp++ + } + value := float64(bytes) / float64(div) + if exp >= len(units) { + return "NaN" + } + return strconv.FormatFloat(value, 'f', 1, 64) + string(units[exp]) + "B" +} + +// Bar wraps an io.Reader and displays a progress bar to stderr. When reading +// is complete, the Stop method must be called. +type Bar struct { + r io.Reader + printer *core.Printer + bytesRead int64 + totalBytes int64 + chRead chan int64 + start time.Time + wg sync.WaitGroup + onRender func(percentage int64) +} + +// NewBar returns a new Bar that wraps r and displays a progress bar to stderr +// via p. The onRender callback, if non-nil, is called on each render with the +// current completion percentage. +func NewBar(r io.Reader, p *core.Printer, totalBytes int64, onRender func(percentage int64)) *Bar { + b := &Bar{ + r: r, + printer: p, + totalBytes: totalBytes, + chRead: make(chan int64, 1), + start: time.Now(), + onRender: onRender, + } + b.wg.Add(1) + go b.renderLoop() + return b +} + +func (b *Bar) Read(p []byte) (int, error) { + n, err := b.r.Read(p) + if n > 0 { + b.chRead <- int64(n) + } + return n, err +} + +// Stop signals the render loop to exit and waits for it to finish. It returns +// the total bytes read and the elapsed duration. +func (b *Bar) Stop() (bytesRead int64, elapsed time.Duration) { + close(b.chRead) + b.wg.Wait() + return b.bytesRead, time.Since(b.start) +} + +func (b *Bar) renderLoop() { + defer b.wg.Done() + + lastRenderTime := b.start + var chTimeout <-chan time.Time + for { + select { + case <-chTimeout: + chTimeout = nil + case n, ok := <-b.chRead: + if !ok { + b.render() + return + } + b.bytesRead += n + + if chTimeout != nil { + continue + } + + now := time.Now() + dur := lastRenderTime.Add(100 * time.Millisecond).Sub(now) + if dur > 0 { + chTimeout = time.After(dur) + continue + } + lastRenderTime = now + } + + b.render() + } +} + +func (b *Bar) render() { + const barWidth = 30 + percentage := b.bytesRead * 100 / b.totalBytes + completedWidth := min(barWidth*percentage/100, barWidth) + + if b.onRender != nil { + b.onRender(percentage) + } + + p := b.printer + + p.WriteString("\r") + + p.Set(core.Bold) + p.WriteString("[") + p.Set(core.Green) + for range completedWidth { + p.WriteString("=") + } + p.Reset() + for range barWidth - completedWidth { + p.WriteString(" ") + } + p.Set(core.Bold) + p.WriteString("] ") + + pctStr := strconv.FormatInt(percentage, 10) + for i := len(pctStr); i < 3; i++ { + p.WriteString(" ") + } + p.WriteString(pctStr) + p.WriteString("%") + p.Reset() + + p.WriteString(" (") + size := FormatSize(b.bytesRead) + for range 7 - len(size) { + p.WriteString(" ") + } + p.WriteString(size) + p.WriteString(" / ") + p.WriteString(FormatSize(b.totalBytes)) + p.WriteString(")") + p.Flush() +} + +// Spinner wraps an io.Reader and displays a bouncing spinner to stderr. When +// reading is complete, the Stop method must be called. +type Spinner struct { + r io.Reader + printer *core.Printer + bytesRead int64 + chRead chan int64 + position int64 + start time.Time + wg sync.WaitGroup + onStart func() +} + +// NewSpinner returns a new Spinner that wraps r and displays a bouncing +// spinner to stderr via p. The onStart callback, if non-nil, is called once +// at the beginning of the render loop. +func NewSpinner(r io.Reader, p *core.Printer, onStart func()) *Spinner { + s := &Spinner{ + r: r, + printer: p, + chRead: make(chan int64, 1), + start: time.Now(), + onStart: onStart, + } + s.wg.Add(1) + go s.renderLoop() + return s +} + +func (s *Spinner) Read(p []byte) (int, error) { + n, err := s.r.Read(p) + if n > 0 { + s.chRead <- int64(n) + } + return n, err +} + +// Stop signals the render loop to exit and waits for it to finish. It returns +// the total bytes read and the elapsed duration. +func (s *Spinner) Stop() (bytesRead int64, elapsed time.Duration) { + close(s.chRead) + s.wg.Wait() + return s.bytesRead, time.Since(s.start) +} + +func (s *Spinner) renderLoop() { + defer s.wg.Done() + + if s.onStart != nil { + s.onStart() + } + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.render() + s.position++ + case n, ok := <-s.chRead: + if !ok { + s.render() + return + } + s.bytesRead += n + } + } +} + +func (s *Spinner) render() { + const width = 20 + + var value string + var offset int + position := s.position % (width * 2) + if position < width { + value = "=>" + offset = int(position) + } else { + value = "<=" + offset = int(width*2 - position - 1) + } + + p := s.printer + p.WriteString("\r") + p.Set(core.Bold) + p.WriteString("[") + for range offset { + p.WriteString(" ") + } + p.Set(core.Green) + p.WriteString(value) + p.Reset() + for range width - offset - 1 { + p.WriteString(" ") + } + p.Set(core.Bold) + p.WriteString("]") + p.Reset() + + p.WriteString(" ") + size := FormatSize(s.bytesRead) + for range 7 - len(size) { + p.WriteString(" ") + } + p.WriteString(size) + + p.Flush() +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..5ff055e --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,217 @@ +package progress + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/ryanfowler/fetch/internal/core" +) + +func testPrinter() *core.Printer { + return core.NewHandle(core.ColorOff).Stderr() +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0B"}, + {1, "1B"}, + {512, "512B"}, + {1023, "1023B"}, + {1024, "1.0KB"}, + {1536, "1.5KB"}, + {10240, "10.0KB"}, + {1048576, "1.0MB"}, + {1572864, "1.5MB"}, + {1073741824, "1.0GB"}, + {1099511627776, "1.0TB"}, + {1125899906842624, "1.0PB"}, + {1152921504606846976, "1.0EB"}, + } + + for _, tt := range tests { + got := FormatSize(tt.bytes) + if got != tt.want { + t.Errorf("FormatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + } +} + +func TestFormatSizeBoundaries(t *testing.T) { + // Values just under and at unit boundaries. + tests := []struct { + name string + bytes int64 + want string + }{ + {"just under 1KB", 1023, "1023B"}, + {"exactly 1KB", 1024, "1.0KB"}, + {"just under 1MB", 1048575, "1.0MB"}, + {"exactly 1MB", 1048576, "1.0MB"}, + {"999KB", 999 * 1024, "999.0KB"}, + {"1000KB promotes to MB", 1000 * 1024, "1.0MB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatSize(tt.bytes) + if got != tt.want { + t.Errorf("FormatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + }) + } +} + +func TestBarReadPassthrough(t *testing.T) { + data := []byte("hello, world!") + r := bytes.NewReader(data) + p := testPrinter() + + bar := NewBar(r, p, int64(len(data)), nil) + + got, err := io.ReadAll(bar) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if !bytes.Equal(got, data) { + t.Errorf("Read data = %q, want %q", got, data) + } + + bytesRead, elapsed := bar.Stop() + if bytesRead != int64(len(data)) { + t.Errorf("bytesRead = %d, want %d", bytesRead, len(data)) + } + if elapsed < 0 { + t.Error("elapsed should be non-negative") + } +} + +func TestBarReadLargeData(t *testing.T) { + data := bytes.Repeat([]byte("x"), 100_000) + r := bytes.NewReader(data) + p := testPrinter() + + bar := NewBar(r, p, int64(len(data)), nil) + + got, err := io.ReadAll(bar) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if len(got) != len(data) { + t.Errorf("Read %d bytes, want %d", len(got), len(data)) + } + + bytesRead, _ := bar.Stop() + if bytesRead != int64(len(data)) { + t.Errorf("bytesRead = %d, want %d", bytesRead, len(data)) + } +} + +func TestBarOnRenderCallback(t *testing.T) { + data := bytes.Repeat([]byte("x"), 1024) + r := bytes.NewReader(data) + p := testPrinter() + + var called bool + onRender := func(pct int64) { + called = true + if pct < 0 || pct > 100 { + t.Errorf("percentage out of range: %d", pct) + } + } + + bar := NewBar(r, p, int64(len(data)), onRender) + io.ReadAll(bar) + bar.Stop() + + if !called { + t.Error("onRender callback was never called") + } +} + +func TestSpinnerReadPassthrough(t *testing.T) { + data := []byte("spinner test data") + r := bytes.NewReader(data) + p := testPrinter() + + spinner := NewSpinner(r, p, nil) + + got, err := io.ReadAll(spinner) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if !bytes.Equal(got, data) { + t.Errorf("Read data = %q, want %q", got, data) + } + + bytesRead, elapsed := spinner.Stop() + if bytesRead != int64(len(data)) { + t.Errorf("bytesRead = %d, want %d", bytesRead, len(data)) + } + if elapsed < 0 { + t.Error("elapsed should be non-negative") + } +} + +func TestSpinnerOnStartCallback(t *testing.T) { + data := []byte("test") + r := bytes.NewReader(data) + p := testPrinter() + + var called bool + onStart := func() { + called = true + } + + spinner := NewSpinner(r, p, onStart) + io.ReadAll(spinner) + spinner.Stop() + + if !called { + t.Error("onStart callback was never called") + } +} + +func TestBarEmptyRead(t *testing.T) { + r := strings.NewReader("") + p := testPrinter() + + bar := NewBar(r, p, 1, nil) + + got, err := io.ReadAll(bar) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty read, got %d bytes", len(got)) + } + + bytesRead, _ := bar.Stop() + if bytesRead != 0 { + t.Errorf("bytesRead = %d, want 0", bytesRead) + } +} + +func TestSpinnerEmptyRead(t *testing.T) { + r := strings.NewReader("") + p := testPrinter() + + spinner := NewSpinner(r, p, nil) + + got, err := io.ReadAll(spinner) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty read, got %d bytes", len(got)) + } + + bytesRead, _ := spinner.Stop() + if bytesRead != 0 { + t.Errorf("bytesRead = %d, want 0", bytesRead) + } +} diff --git a/internal/update/progress.go b/internal/update/progress.go index b6561c3..99dbfee 100644 --- a/internal/update/progress.go +++ b/internal/update/progress.go @@ -2,258 +2,67 @@ package update import ( "io" - "strconv" - "sync" - "time" + "strings" "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/progress" ) // updateProgress wraps an io.ReadCloser and displays a progress bar to stderr. type updateProgress struct { - rc io.ReadCloser - printer *core.Printer - bytesRead int64 - totalBytes int64 - chRead chan int64 - wg sync.WaitGroup + bar *progress.Bar + rc io.ReadCloser + printer *core.Printer } func newUpdateProgress(rc io.ReadCloser, p *core.Printer, totalBytes int64) *updateProgress { - up := &updateProgress{ - rc: rc, - printer: p, - totalBytes: totalBytes, - chRead: make(chan int64, 1), + return &updateProgress{ + bar: progress.NewBar(rc, p, totalBytes, nil), + rc: rc, + printer: p, } - up.wg.Add(1) - go up.renderLoop() - return up } func (up *updateProgress) Read(p []byte) (int, error) { - n, err := up.rc.Read(p) - if n > 0 { - up.chRead <- int64(n) - } - return n, err + return up.bar.Read(p) } func (up *updateProgress) Close() error { err := up.rc.Close() - close(up.chRead) - up.wg.Wait() - up.clearLine() + up.bar.Stop() + clearLine(up.printer, 60) return err } -func (up *updateProgress) renderLoop() { - defer up.wg.Done() - - start := time.Now() - var chTimeout <-chan time.Time - for { - select { - case <-chTimeout: - chTimeout = nil - case n, ok := <-up.chRead: - if !ok { - up.render() - return - } - up.bytesRead += n - - if chTimeout != nil { - continue - } - - dur := time.Until(start.Add(100 * time.Millisecond)) - if dur > 0 { - chTimeout = time.After(dur) - continue - } - start = time.Now() - } - - up.render() - } -} - -func (up *updateProgress) render() { - const barWidth = 30 - percentage := up.bytesRead * 100 / up.totalBytes - completedWidth := min(barWidth*percentage/100, barWidth) - - p := up.printer - - p.WriteString("\r") - - p.Set(core.Bold) - p.WriteString("[") - p.Set(core.Green) - for range completedWidth { - p.WriteString("=") - } - p.Reset() - for range barWidth - completedWidth { - p.WriteString(" ") - } - p.Set(core.Bold) - p.WriteString("] ") - - pctStr := strconv.FormatInt(percentage, 10) - for i := len(pctStr); i < 3; i++ { - p.WriteString(" ") - } - p.WriteString(pctStr) - p.WriteString("%") - p.Reset() - - p.WriteString(" (") - size := updateFormatSize(up.bytesRead) - for range 7 - len(size) { - p.WriteString(" ") - } - p.WriteString(size) - p.WriteString(" / ") - p.WriteString(updateFormatSize(up.totalBytes)) - p.WriteString(")") - p.Flush() -} - -func (up *updateProgress) clearLine() { - p := up.printer - p.WriteString("\r") - for range 60 { - p.WriteString(" ") - } - p.WriteString("\r") - p.Flush() -} - // updateSpinner wraps an io.ReadCloser and displays a bouncing spinner to stderr. type updateSpinner struct { - rc io.ReadCloser - printer *core.Printer - bytesRead int64 - chRead chan int64 - position int64 - wg sync.WaitGroup + spinner *progress.Spinner + rc io.ReadCloser + printer *core.Printer } func newUpdateSpinner(rc io.ReadCloser, p *core.Printer) *updateSpinner { - us := &updateSpinner{ + return &updateSpinner{ + spinner: progress.NewSpinner(rc, p, nil), rc: rc, printer: p, - chRead: make(chan int64, 1), } - us.wg.Add(1) - go us.renderLoop() - return us } func (us *updateSpinner) Read(p []byte) (int, error) { - n, err := us.rc.Read(p) - if n > 0 { - us.chRead <- int64(n) - } - return n, err + return us.spinner.Read(p) } func (us *updateSpinner) Close() error { err := us.rc.Close() - close(us.chRead) - us.wg.Wait() - us.clearLine() + us.spinner.Stop() + clearLine(us.printer, 40) return err } -func (us *updateSpinner) renderLoop() { - defer us.wg.Done() - - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ticker.C: - us.render() - us.position++ - case n, ok := <-us.chRead: - if !ok { - us.render() - return - } - us.bytesRead += n - } - } -} - -func (us *updateSpinner) render() { - const width = 20 - - var value string - var offset int - position := us.position % (int64(width) * 2) - if position < int64(width) { - value = "=>" - offset = int(position) - } else { - value = "<=" - offset = int(int64(width)*2 - position - 1) - } - - p := us.printer - p.WriteString("\r") - p.Set(core.Bold) - p.WriteString("[") - for range offset { - p.WriteString(" ") - } - p.Set(core.Green) - p.WriteString(value) - p.Reset() - for range width - offset - 1 { - p.WriteString(" ") - } - p.Set(core.Bold) - p.WriteString("]") - p.Reset() - - p.WriteString(" ") - size := updateFormatSize(us.bytesRead) - for range 7 - len(size) { - p.WriteString(" ") - } - p.WriteString(size) - - p.Flush() -} - -func (us *updateSpinner) clearLine() { - p := us.printer +func clearLine(p *core.Printer, width int) { p.WriteString("\r") - for range 40 { - p.WriteString(" ") - } + p.WriteString(strings.Repeat(" ", width)) p.WriteString("\r") p.Flush() } - -// updateFormatSize converts bytes to a human-readable string. -func updateFormatSize(bytes int64) string { - const units = "KMGTPE" - const unit = 1024 - if bytes < unit { - return strconv.FormatInt(bytes, 10) + "B" - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= 1000; n /= unit { - div *= unit - exp++ - } - value := float64(bytes) / float64(div) - if exp >= len(units) { - return "NaN" - } - return strconv.FormatFloat(value, 'f', 1, 64) + string(units[exp]) + "B" -}