diff --git a/cmd/oras/internal/display/progress/humanize.go b/cmd/oras/internal/display/progress/humanize.go index 3e80a20b5..d769c83a5 100644 --- a/cmd/oras/internal/display/progress/humanize.go +++ b/cmd/oras/internal/display/progress/humanize.go @@ -33,7 +33,7 @@ type bytes struct { func ToBytes(sizeInBytes int64) bytes { f := float64(sizeInBytes) if f < base { - return bytes{f, "B"} + return bytes{f, units[0]} } e := math.Floor(math.Log(f) / math.Log(base)) p := f / math.Pow(base, e) diff --git a/cmd/oras/internal/display/progress/manager.go b/cmd/oras/internal/display/progress/manager.go index 7ecca7bcc..b8b4ffd59 100644 --- a/cmd/oras/internal/display/progress/manager.go +++ b/cmd/oras/internal/display/progress/manager.go @@ -38,7 +38,7 @@ type Manager interface { Close() error } -const bufFlushDuration = 100 * time.Millisecond +const bufFlushDuration = 500 * time.Millisecond type manager struct { status []*status diff --git a/cmd/oras/internal/display/progress/status.go b/cmd/oras/internal/display/progress/status.go index 1c00d5d33..3aba785d5 100644 --- a/cmd/oras/internal/display/progress/status.go +++ b/cmd/oras/internal/display/progress/status.go @@ -27,7 +27,8 @@ import ( ) const ( - barLength = 40 + barLength = 20 + speedLength = 8 zeroDuration = "0s" // default zero value of time.Duration.String() zeroStatus = "loading status..." zeroDigest = " └─ loading digest..." @@ -35,22 +36,25 @@ const ( // status is used as message to update progress view. type status struct { - done bool // done is true when the end time is set - prompt string - descriptor ocispec.Descriptor - offset int64 - total bytes + done bool // done is true when the end time is set + prompt string + descriptor ocispec.Descriptor + offset int64 + total bytes + lastOffset int64 + lastRenderTime time.Time startTime time.Time endTime time.Time mark spinner - lock sync.RWMutex + lock sync.Mutex } // newStatus generates a base empty status. func newStatus() *status { return &status{ - offset: -1, + offset: -1, + lastRenderTime: time.Now(), } } @@ -85,8 +89,8 @@ func (s *status) isZero() bool { // String returns human-readable TTY strings of the status. func (s *status) String(width int) (string, string) { - s.lock.RLock() - defer s.lock.RUnlock() + s.lock.Lock() + defer s.lock.Unlock() if s.isZero() { return zeroStatus, zeroDigest @@ -103,33 +107,35 @@ func (s *status) String(width int) (string, string) { name = s.descriptor.MediaType } - // format: [left--------------------------------][margin][right---------------------------------] - // mark(1) bar(42) action(<10) name(<126) size_per_size(<=11) percent(8) time(>=6) + // format: [left--------------------------------------------][margin][right---------------------------------] + // mark(1) bar(22) speed(8) action(<=11) name(<126) size_per_size(<=13) percent(8) time(>=6) // └─ digest(72) + var offset string + switch percent { + case 1: // 100%, show exact size + offset = fmt.Sprint(s.total.size) + default: // 0% ~ 99%, show 2-digit precision + offset = fmt.Sprintf("%.2f", RoundTo(s.total.size*percent)) + } + right := fmt.Sprintf(" %s/%v %s %6.2f%% %6s", offset, s.total.size, s.total.unit, percent*100, s.durationString()) + lenRight := utf8.RuneCountInString(right) + var left string lenLeft := 0 if !s.done { lenBar := int(percent * barLength) bar := fmt.Sprintf("[%s%s]", aec.Inverse.Apply(strings.Repeat(" ", lenBar)), strings.Repeat(".", barLength-lenBar)) - mark := s.mark.symbol() - left = fmt.Sprintf("%c %s %s %s", mark, bar, s.prompt, name) - // bar + wrapper(2) + space(1) = len(bar) + 3 - lenLeft = barLength + 3 + speed := s.calculateSpeed() + speedStr := fmt.Sprintf("%v%s/s", speed.size, speed.unit) + left = fmt.Sprintf("%c %s(%*s) %s %s", s.mark.symbol(), bar, speedLength, speedStr, s.prompt, name) + // bar + wrapper(2) + space(1) + speed + wrapper(2) = len(bar) + len(speed) + 5 + lenLeft = barLength + speedLength + 5 } else { left = fmt.Sprintf("√ %s %s", s.prompt, name) } // mark(1) + space(1) + prompt + space(1) + name = len(prompt) + len(name) + 3 lenLeft += utf8.RuneCountInString(s.prompt) + utf8.RuneCountInString(name) + 3 - var offset string - switch percent { - case 1: // 100%, show exact size - offset = fmt.Sprint(s.total.size) - default: // 0% ~ 99%, show 2-digit precision - offset = fmt.Sprintf("%.2f", RoundTo(s.total.size*percent)) - } - right := fmt.Sprintf(" %s/%v %s %6.2f%% %6s", offset, s.total.size, s.total.unit, percent*100, s.durationString()) - lenRight := utf8.RuneCountInString(right) lenMargin := width - lenLeft - lenRight if lenMargin < 0 { // hide partial name with one space left @@ -139,6 +145,19 @@ func (s *status) String(width int) (string, string) { return fmt.Sprintf("%s%s%s", left, strings.Repeat(" ", lenMargin), right), fmt.Sprintf(" └─ %s", s.descriptor.Digest.String()) } +// calculateSpeed calculates the speed of the progress and update last status. +// caller must hold the lock. +func (s *status) calculateSpeed() bytes { + now := time.Now() + secondsTaken := now.Sub(s.lastRenderTime).Seconds() + bytes := float64(s.offset - s.lastOffset) + + s.lastOffset = s.offset + s.lastRenderTime = now + + return ToBytes(int64(bytes / secondsTaken)) +} + // durationString returns a viewable TTY string of the status with duration. func (s *status) durationString() string { if s.startTime.IsZero() { diff --git a/cmd/oras/internal/display/progress/status_test.go b/cmd/oras/internal/display/progress/status_test.go index 03dac6585..25033beb6 100644 --- a/cmd/oras/internal/display/progress/status_test.go +++ b/cmd/oras/internal/display/progress/status_test.go @@ -45,12 +45,12 @@ func Test_status_String(t *testing.T) { }) // full name statusStr, digestStr := s.String(120) - if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m........................................]", s.prompt, s.descriptor.MediaType, "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { + if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m....................]", s.prompt, s.descriptor.MediaType, "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { t.Error(err) } // partial name statusStr, digestStr = s.String(console.MinWidth) - if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m........................................]", s.prompt, "appli.", "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { + if err := testutils.OrderedMatch(statusStr+digestStr, " [\x1b[7m\x1b[0m....................]", s.prompt, "application/vnd.", "0.00/2 B", "0.00%", s.descriptor.Digest.String()); err != nil { t.Error(err) }