diff --git a/util/terminal/output.go b/util/terminal/output.go index a5f37b9..662506f 100644 --- a/util/terminal/output.go +++ b/util/terminal/output.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "regexp" "strconv" "strings" "sync" @@ -20,15 +21,17 @@ type verboseWriter struct { buf bytes.Buffer lines []string - lineHeight int - termWidth int - overflow int + lineHeight int + termWidth int + screenHeight int lastUpdate time.Time sync.Mutex } +var ansiControlSequence = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) + // NewVerboseWriter creates a new verbose writer. // A verbose writer pipes the input received to the stdout while tailing the specified lines. // Calling `Close` when done is recommended to clear the last uncleared output. @@ -109,6 +112,8 @@ func (v *verboseWriter) sanitizeLine(line string) string { } } + line = normalizeDisplayText(line) + return "> " + line } @@ -117,15 +122,10 @@ func (v *verboseWriter) printScreen() error { return err } - v.overflow = 0 + v.screenHeight = 0 for _, line := range v.lines { line = v.sanitizeLine(line) - if len(line) > v.termWidth { - v.overflow += len(line) / v.termWidth - if len(line)%v.termWidth == 0 { - v.overflow -= 1 - } - } + v.screenHeight += countDisplayLines(line, v.termWidth) line = color.HiBlackString(line) fmt.Println(line) } @@ -133,9 +133,10 @@ func (v *verboseWriter) printScreen() error { } func (v *verboseWriter) clearScreen() { - for i := 0; i < len(v.lines)+v.overflow; i++ { + for i := 0; i < v.screenHeight; i++ { ClearLine() } + v.screenHeight = 0 } func (v *verboseWriter) updateTerm() error { @@ -160,3 +161,29 @@ func (v *verboseWriter) updateTerm() error { return nil } + +func countDisplayLines(line string, termWidth int) int { + if termWidth <= 0 { + termWidth = 80 + } + + visibleWidth := len([]rune(normalizeDisplayText(line))) + if visibleWidth == 0 { + return 1 + } + + return ((visibleWidth - 1) / termWidth) + 1 +} + +func normalizeDisplayText(line string) string { + line = ansiControlSequence.ReplaceAllString(line, "") + line = strings.ReplaceAll(line, "\r", "") + line = strings.ReplaceAll(line, "\n", "") + line = strings.Map(func(r rune) rune { + if r < 32 && r != '\t' { + return -1 + } + return r + }, line) + return line +} diff --git a/util/terminal/output_test.go b/util/terminal/output_test.go new file mode 100644 index 0000000..ff0f6c1 --- /dev/null +++ b/util/terminal/output_test.go @@ -0,0 +1,33 @@ +package terminal + +import "testing" + +func TestCountDisplayLines(t *testing.T) { + tests := []struct { + name string + line string + termWidth int + want int + }{ + {name: "short line", line: "> short", termWidth: 80, want: 1}, + {name: "wrapped line", line: "> 12345678901", termWidth: 10, want: 2}, + {name: "exact width", line: "> 12345678", termWidth: 10, want: 1}, + {name: "carriage return ignored", line: "> abc\rdef", termWidth: 80, want: 1}, + {name: "ansi ignored", line: "> \x1b[90mabcdef\x1b[0m", termWidth: 4, want: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countDisplayLines(tt.line, tt.termWidth); got != tt.want { + t.Fatalf("countDisplayLines(%q, %d) = %d, want %d", tt.line, tt.termWidth, got, tt.want) + } + }) + } +} + +func TestNormalizeDisplayText(t *testing.T) { + input := "\x1b[90mhello\r\n\x07world\x1b[0m" + if got, want := normalizeDisplayText(input), "helloworld"; got != want { + t.Fatalf("normalizeDisplayText(%q) = %q, want %q", input, got, want) + } +}