From 5aaa3e93c1ef035b0760e5c1b678b4f168f5d8de Mon Sep 17 00:00:00 2001 From: greatroar <61184462+greatroar@users.noreply.github.com> Date: Sun, 29 Aug 2021 14:54:15 +0200 Subject: [PATCH 1/2] internal/ui/termstatus: Optimize and publish Truncate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit name old time/op new time/op delta TruncateASCII-8 347ns ± 1% 69ns ± 1% -80.02% (p=0.000 n=9+10) TruncateUnicode-8 447ns ± 3% 348ns ± 1% -22.04% (p=0.000 n=10+10) --- internal/ui/termstatus/status.go | 24 ++++++++++++--------- internal/ui/termstatus/status_test.go | 30 ++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index e275f5b7d..ce6593f37 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -8,6 +8,7 @@ import ( "io" "os" "strings" + "unicode" "golang.org/x/crypto/ssh/terminal" "golang.org/x/text/width" @@ -280,7 +281,7 @@ func (t *Terminal) Errorf(msg string, args ...interface{}) { // Truncate s to fit in width (number of terminal cells) w. // If w is negative, returns the empty string. -func truncate(s string, w int) string { +func Truncate(s string, w int) string { if len(s) < w { // Since the display width of a character is at most 2 // and all of ASCII (single byte per rune) has width 1, @@ -289,16 +290,11 @@ func truncate(s string, w int) string { } for i, r := range s { - // Determine width of the rune. This cannot be determined without - // knowing the terminal font, so let's just be careful and treat - // all ambigous characters as full-width, i.e., two cells. - wr := 2 - switch width.LookupRune(r).Kind() { - case width.Neutral, width.EastAsianNarrow: - wr = 1 + w-- + if r > unicode.MaxASCII && wideRune(r) { + w-- } - w -= wr if w < 0 { return s[:i] } @@ -307,6 +303,14 @@ func truncate(s string, w int) string { return s } +// Guess whether r would occupy two terminal cells instead of one. +// This cannot be determined exactly without knowing the terminal font, +// so we treat all ambigous runes as full-width, i.e., two cells. +func wideRune(r rune) bool { + kind := width.LookupRune(r).Kind() + return kind != width.Neutral && kind != width.EastAsianNarrow +} + // SetStatus updates the status lines. func (t *Terminal) SetStatus(lines []string) { if len(lines) == 0 { @@ -328,7 +332,7 @@ func (t *Terminal) SetStatus(lines []string) { for i, line := range lines { line = strings.TrimRight(line, "\n") if width > 0 { - line = truncate(line, width-2) + line = Truncate(line, width-2) } lines[i] = line + "\n" } diff --git a/internal/ui/termstatus/status_test.go b/internal/ui/termstatus/status_test.go index d22605e31..ce18f42e6 100644 --- a/internal/ui/termstatus/status_test.go +++ b/internal/ui/termstatus/status_test.go @@ -19,13 +19,14 @@ func TestTruncate(t *testing.T) { {"foo", 0, ""}, {"foo", -1, ""}, {"Löwen", 4, "Löwe"}, - {"あああああああああ/data", 10, "あああああ"}, - {"あああああああああ/data", 11, "あああああ"}, + {"あああああ/data", 7, "あああ"}, + {"あああああ/data", 10, "あああああ"}, + {"あああああ/data", 11, "あああああ/"}, } for _, test := range tests { t.Run("", func(t *testing.T) { - out := truncate(test.input, test.width) + out := Truncate(test.input, test.width) if out != test.output { t.Fatalf("wrong output for input %v, width %d: want %q, got %q", test.input, test.width, test.output, out) @@ -33,3 +34,26 @@ func TestTruncate(t *testing.T) { }) } } + +func benchmarkTruncate(b *testing.B, s string, w int) { + for i := 0; i < b.N; i++ { + Truncate(s, w) + } +} + +func BenchmarkTruncateASCII(b *testing.B) { + s := "This is an ASCII-only status message...\r\n" + benchmarkTruncate(b, s, len(s)-1) +} + +func BenchmarkTruncateUnicode(b *testing.B) { + s := "Hello World or Καλημέρα κόσμε or こんにちは 世界" + w := 0 + for _, r := range s { + w++ + if wideRune(r) { + w++ + } + } + benchmarkTruncate(b, s, w-1) +} From 7f0aa49f45742baaff195587cc3e4f820c6f42db Mon Sep 17 00:00:00 2001 From: greatroar <61184462+greatroar@users.noreply.github.com> Date: Sun, 29 Aug 2021 14:55:33 +0200 Subject: [PATCH 2/2] cmd/restic: Streamline progress printing * PrintProgress no longer does unnecessary Sprintf calls, and performs fewer allocations in general * newProgressMax's callback checks whether the terminal supports line updates once instead of once per call * the callback looks up the terminal width once per call instead of twice (on Windows) * the status shortening now uses the Unicode-aware version from internal/ui/termstatus (future-proofing) --- cmd/restic/cleanup.go | 2 +- cmd/restic/cmd_prune.go | 12 ----------- cmd/restic/global.go | 48 ++++++++++++----------------------------- cmd/restic/progress.go | 36 ++++++++++++++++++++++++++----- 4 files changed, 46 insertions(+), 52 deletions(-) diff --git a/cmd/restic/cleanup.go b/cmd/restic/cleanup.go index 738ec590b..67a007d59 100644 --- a/cmd/restic/cleanup.go +++ b/cmd/restic/cleanup.go @@ -58,7 +58,7 @@ func RunCleanupHandlers() { func CleanupHandler(c <-chan os.Signal) { for s := range c { debug.Log("signal %v received, cleaning up", s) - Warnf("%ssignal %v received, cleaning up\n", ClearLine(), s) + Warnf("%ssignal %v received, cleaning up\n", clearLine(0), s) code := 0 diff --git a/cmd/restic/cmd_prune.go b/cmd/restic/cmd_prune.go index e944c686a..d621afdad 100644 --- a/cmd/restic/cmd_prune.go +++ b/cmd/restic/cmd_prune.go @@ -119,18 +119,6 @@ func verifyPruneOptions(opts *PruneOptions) error { return nil } -func shortenStatus(maxLength int, s string) string { - if len(s) <= maxLength { - return s - } - - if maxLength < 3 { - return s[:maxLength] - } - - return s[:maxLength-3] + "..." -} - func runPrune(opts PruneOptions, gopts GlobalOptions) error { err := verifyPruneOptions(&opts) if err != nil { diff --git a/cmd/restic/global.go b/cmd/restic/global.go index 42da399d0..70ec84058 100644 --- a/cmd/restic/global.go +++ b/cmd/restic/global.go @@ -197,16 +197,21 @@ func restoreTerminal() { } // ClearLine creates a platform dependent string to clear the current -// line, so it can be overwritten. ANSI sequences are not supported on -// current windows cmd shell. -func ClearLine() string { - if runtime.GOOS == "windows" { - if w := stdoutTerminalWidth(); w > 0 { - return strings.Repeat(" ", w-1) + "\r" - } - return "" +// line, so it can be overwritten. +// +// w should be the terminal width, or 0 to let clearLine figure it out. +func clearLine(w int) string { + if runtime.GOOS != "windows" { + return "\x1b[2K" } - return "\x1b[2K" + + // ANSI sequences are not supported on Windows cmd shell. + if w <= 0 { + if w = stdoutTerminalWidth(); w <= 0 { + return "" + } + } + return strings.Repeat(" ", w-1) + "\r" } // Printf writes the message to the configured stdout stream. @@ -247,31 +252,6 @@ func Verboseff(format string, args ...interface{}) { } } -// PrintProgress wraps fmt.Printf to handle the difference in writing progress -// information to terminals and non-terminal stdout -func PrintProgress(format string, args ...interface{}) { - var ( - message string - carriageControl string - ) - message = fmt.Sprintf(format, args...) - - if !(strings.HasSuffix(message, "\r") || strings.HasSuffix(message, "\n")) { - if stdoutCanUpdateStatus() { - carriageControl = "\r" - } else { - carriageControl = "\n" - } - message = fmt.Sprintf("%s%s", message, carriageControl) - } - - if stdoutCanUpdateStatus() { - message = fmt.Sprintf("%s%s", ClearLine(), message) - } - - fmt.Print(message) -} - // Warnf writes the message to the configured stderr stream. func Warnf(format string, args ...interface{}) { _, err := fmt.Fprintf(globalOptions.stderr, format, args...) diff --git a/cmd/restic/progress.go b/cmd/restic/progress.go index 0c2a24271..26a694c25 100644 --- a/cmd/restic/progress.go +++ b/cmd/restic/progress.go @@ -4,9 +4,11 @@ import ( "fmt" "os" "strconv" + "strings" "time" "github.com/restic/restic/internal/ui/progress" + "github.com/restic/restic/internal/ui/termstatus" ) // calculateProgressInterval returns the interval configured via RESTIC_PROGRESS_FPS @@ -32,6 +34,7 @@ func newProgressMax(show bool, max uint64, description string) *progress.Counter return nil } interval := calculateProgressInterval(show) + canUpdateStatus := stdoutCanUpdateStatus() return progress.New(interval, max, func(v uint64, max uint64, d time.Duration, final bool) { var status string @@ -42,13 +45,36 @@ func newProgressMax(show bool, max uint64, description string) *progress.Counter formatDuration(d), formatPercent(v, max), v, max, description) } - if w := stdoutTerminalWidth(); w > 0 { - status = shortenStatus(w, status) - } - - PrintProgress("%s", status) + printProgress(status, canUpdateStatus) if final { fmt.Print("\n") } }) } + +func printProgress(status string, canUpdateStatus bool) { + w := stdoutTerminalWidth() + if w > 0 { + if w < 3 { + status = termstatus.Truncate(status, w) + } else { + status = termstatus.Truncate(status, w-3) + "..." + } + } + + var carriageControl, clear string + + if canUpdateStatus { + clear = clearLine(w) + } + + if !(strings.HasSuffix(status, "\r") || strings.HasSuffix(status, "\n")) { + if canUpdateStatus { + carriageControl = "\r" + } else { + carriageControl = "\n" + } + } + + _, _ = os.Stdout.Write([]byte(clear + status + carriageControl)) +}