diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index 7a0c6cfb1..870fb9977 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -21,10 +21,14 @@ type Terminal struct { msg chan message status chan status canUpdateStatus bool - clearLines clearLinesFunc -} -type clearLinesFunc func(wr io.Writer, fd uintptr, n int) + // will be closed when the goroutine which runs Run() terminates, so it'll + // yield a default value immediately + closed chan struct{} + + clearCurrentLine func(io.Writer, uintptr) + moveCursorUp func(io.Writer, uintptr, int) +} type message struct { line string @@ -53,6 +57,7 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal { buf: bytes.NewBuffer(nil), msg: make(chan message), status: make(chan status), + closed: make(chan struct{}), } if disableStatus { @@ -63,7 +68,8 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal { // only use the fancy status code when we're running on a real terminal. t.canUpdateStatus = true t.fd = d.Fd() - t.clearLines = clearLines(wr, t.fd) + t.clearCurrentLine = clearCurrentLine(wr, t.fd) + t.moveCursorUp = moveCursorUp(wr, t.fd) } return t @@ -72,6 +78,7 @@ func New(wr io.Writer, errWriter io.Writer, disableStatus bool) *Terminal { // Run updates the screen. It should be run in a separate goroutine. When // ctx is cancelled, the status lines are cleanly removed. func (t *Terminal) Run(ctx context.Context) { + defer close(t.closed) if t.canUpdateStatus { t.run(ctx) return @@ -80,23 +87,13 @@ func (t *Terminal) Run(ctx context.Context) { t.runWithoutStatus(ctx) } -func countLines(buf []byte) int { - lines := 0 - sc := bufio.NewScanner(bytes.NewReader(buf)) - for sc.Scan() { - lines++ - } - return lines -} - type stringWriter interface { WriteString(string) (int, error) } // run listens on the channels and updates the terminal screen. func (t *Terminal) run(ctx context.Context) { - statusBuf := bytes.NewBuffer(nil) - statusLines := 0 + var status []string for { select { case <-ctx.Done(): @@ -104,12 +101,7 @@ func (t *Terminal) run(ctx context.Context) { // ignore all messages, do nothing, we are in the background process group continue } - t.undoStatus(statusLines) - - err := t.wr.Flush() - if err != nil { - fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) - } + t.undoStatus(len(status)) return @@ -118,14 +110,14 @@ func (t *Terminal) run(ctx context.Context) { // ignore all messages, do nothing, we are in the background process group continue } - t.undoStatus(statusLines) + t.clearCurrentLine(t.wr, t.fd) var dst io.Writer if msg.err { dst = t.errWriter // assume t.wr and t.errWriter are different, so we need to - // flush the removal of the status lines first. + // flush clearing the current line err := t.wr.Flush() if err != nil { fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) @@ -146,10 +138,7 @@ func (t *Terminal) run(ctx context.Context) { continue } - _, err = t.wr.Write(statusBuf.Bytes()) - if err != nil { - fmt.Fprintf(os.Stderr, "write failed: %v\n", err) - } + t.writeStatus(status) err = t.wr.Flush() if err != nil { @@ -161,27 +150,40 @@ func (t *Terminal) run(ctx context.Context) { // ignore all messages, do nothing, we are in the background process group continue } - t.undoStatus(statusLines) - statusBuf.Reset() - for _, line := range stat.lines { - statusBuf.WriteString(line) - } - statusLines = len(stat.lines) - - _, err := t.wr.Write(statusBuf.Bytes()) - if err != nil { - fmt.Fprintf(os.Stderr, "write failed: %v\n", err) - } - - err = t.wr.Flush() - if err != nil { - fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) - } + status = status[:0] + status = append(status, stat.lines...) + t.writeStatus(status) } } } +func (t *Terminal) writeStatus(status []string) { + for _, line := range status { + t.clearCurrentLine(t.wr, t.fd) + + _, err := t.wr.WriteString(line) + if err != nil { + fmt.Fprintf(os.Stderr, "write failed: %v\n", err) + } + + // flush is needed so that the current line is updated + err = t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } + } + + if len(status) > 0 { + t.moveCursorUp(t.wr, t.fd, len(status)-1) + } + + err := t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } +} + // runWithoutStatus listens on the channels and just prints out the messages, // without status lines. func (t *Terminal) runWithoutStatus(ctx context.Context) { @@ -227,12 +229,27 @@ func (t *Terminal) runWithoutStatus(ctx context.Context) { } func (t *Terminal) undoStatus(lines int) { - if lines == 0 { - return + for i := 0; i < lines; i++ { + t.clearCurrentLine(t.wr, t.fd) + + _, err := t.wr.WriteRune('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "write failed: %v\n", err) + } + + // flush is needed so that the current line is updated + err = t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } } - lines-- - t.clearLines(t.wr, t.fd, lines) + t.moveCursorUp(t.wr, t.fd, lines) + + err := t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } } // Print writes a line to the terminal. @@ -242,7 +259,10 @@ func (t *Terminal) Print(line string) { line += "\n" } - t.msg <- message{line: line} + select { + case t.msg <- message{line: line}: + case <-t.closed: + } } // Printf uses fmt.Sprintf to write a line to the terminal. @@ -258,7 +278,10 @@ func (t *Terminal) Error(line string) { line += "\n" } - t.msg <- message{line: line, err: true} + select { + case t.msg <- message{line: line, err: true}: + case <-t.closed: + } } // Errorf uses fmt.Sprintf to write an error line to the terminal. @@ -294,5 +317,8 @@ func (t *Terminal) SetStatus(lines []string) { last := len(lines) - 1 lines[last] = strings.TrimRight(lines[last], "\n") - t.status <- status{lines: lines} + select { + case t.status <- status{lines: lines}: + case <-t.closed: + } } diff --git a/internal/ui/termstatus/terminal_posix.go b/internal/ui/termstatus/terminal_posix.go index 6b86e0d43..c16a2d989 100644 --- a/internal/ui/termstatus/terminal_posix.go +++ b/internal/ui/termstatus/terminal_posix.go @@ -1,33 +1,36 @@ package termstatus import ( + "bytes" "fmt" "io" "os" ) const ( - posixMoveCursorHome = "\r" - posixMoveCursorUp = "\x1b[1A" - posixClearLine = "\x1b[2K" + posixControlMoveCursorHome = "\r" + posixControlMoveCursorUp = "\x1b[1A" + posixControlClearLine = "\x1b[2K" ) -// posixClearLines will clear the current line and the n lines above. -// Afterwards the cursor is positioned at the start of the first cleared line. -func posixClearLines(wr io.Writer, fd uintptr, n int) { +// posixClearCurrentLine removes all characters from the current line and resets the +// cursor position to the first column. +func posixClearCurrentLine(wr io.Writer, fd uintptr) { // clear current line - _, err := wr.Write([]byte(posixMoveCursorHome + posixClearLine)) + _, err := wr.Write([]byte(posixControlMoveCursorHome + posixControlClearLine)) if err != nil { fmt.Fprintf(os.Stderr, "write failed: %v\n", err) return } +} - for ; n > 0; n-- { - // clear current line and move on line up - _, err := wr.Write([]byte(posixMoveCursorUp + posixClearLine)) - if err != nil { - fmt.Fprintf(os.Stderr, "write failed: %v\n", err) - return - } +// posixMoveCursorUp moves the cursor to the line n lines above the current one. +func posixMoveCursorUp(wr io.Writer, fd uintptr, n int) { + data := []byte(posixControlMoveCursorHome) + data = append(data, bytes.Repeat([]byte(posixControlMoveCursorUp), n)...) + _, err := wr.Write(data) + if err != nil { + fmt.Fprintf(os.Stderr, "write failed: %v\n", err) + return } } diff --git a/internal/ui/termstatus/terminal_unix.go b/internal/ui/termstatus/terminal_unix.go index 52db49a17..c9f47f242 100644 --- a/internal/ui/termstatus/terminal_unix.go +++ b/internal/ui/termstatus/terminal_unix.go @@ -10,10 +10,15 @@ import ( isatty "github.com/mattn/go-isatty" ) -// clearLines will clear the current line and the n lines above. Afterwards the -// cursor is positioned at the start of the first cleared line. -func clearLines(wr io.Writer, fd uintptr) clearLinesFunc { - return posixClearLines +// clearCurrentLine removes all characters from the current line and resets the +// cursor position to the first column. +func clearCurrentLine(wr io.Writer, fd uintptr) func(io.Writer, uintptr) { + return posixClearCurrentLine +} + +// moveCursorUp moves the cursor to the line n lines above the current one. +func moveCursorUp(wr io.Writer, fd uintptr) func(io.Writer, uintptr, int) { + return posixMoveCursorUp } // canUpdateStatus returns true if status lines can be printed, the process diff --git a/internal/ui/termstatus/terminal_windows.go b/internal/ui/termstatus/terminal_windows.go index 56910c67e..5a46169c9 100644 --- a/internal/ui/termstatus/terminal_windows.go +++ b/internal/ui/termstatus/terminal_windows.go @@ -8,11 +8,29 @@ import ( "unsafe" ) -// clearLines clears the current line and n lines above it. -func clearLines(wr io.Writer, fd uintptr) clearLinesFunc { +// clearCurrentLine removes all characters from the current line and resets the +// cursor position to the first column. +func clearCurrentLine(wr io.Writer, fd uintptr) func(io.Writer, uintptr) { // easy case, the terminal is cmd or psh, without redirection if isWindowsTerminal(fd) { - return windowsClearLines + return windowsClearCurrentLine + } + + // check if the output file type is a pipe (0x0003) + if getFileType(fd) != fileTypePipe { + // return empty func, update state is not possible on this terminal + return func(io.Writer, uintptr) {} + } + + // assume we're running in mintty/cygwin + return posixClearCurrentLine +} + +// moveCursorUp moves the cursor to the line n lines above the current one. +func moveCursorUp(wr io.Writer, fd uintptr) func(io.Writer, uintptr, int) { + // easy case, the terminal is cmd or psh, without redirection + if isWindowsTerminal(fd) { + return windowsMoveCursorUp } // check if the output file type is a pipe (0x0003) @@ -22,7 +40,7 @@ func clearLines(wr io.Writer, fd uintptr) clearLinesFunc { } // assume we're running in mintty/cygwin - return posixClearLines + return posixMoveCursorUp } var kernel32 = syscall.NewLazyDLL("kernel32.dll") @@ -60,22 +78,27 @@ type ( } ) -// windowsClearLines clears the current line and n lines above it. -func windowsClearLines(wr io.Writer, fd uintptr, n int) { +// windowsClearCurrentLine removes all characters from the current line and +// resets the cursor position to the first column. +func windowsClearCurrentLine(wr io.Writer, fd uintptr) { var info consoleScreenBufferInfo procGetConsoleScreenBufferInfo.Call(fd, uintptr(unsafe.Pointer(&info))) - for i := 0; i <= n; i++ { - // clear the line - cursor := coord{ - x: info.window.left, - y: info.cursorPosition.y - short(i), - } - var count, w dword - count = dword(info.size.x) - procFillConsoleOutputAttribute.Call(fd, uintptr(info.attributes), uintptr(count), *(*uintptr)(unsafe.Pointer(&cursor)), uintptr(unsafe.Pointer(&w))) - procFillConsoleOutputCharacter.Call(fd, uintptr(' '), uintptr(count), *(*uintptr)(unsafe.Pointer(&cursor)), uintptr(unsafe.Pointer(&w))) + // clear the line + cursor := coord{ + x: info.window.left, + y: info.cursorPosition.y, } + var count, w dword + count = dword(info.size.x) + procFillConsoleOutputAttribute.Call(fd, uintptr(info.attributes), uintptr(count), *(*uintptr)(unsafe.Pointer(&cursor)), uintptr(unsafe.Pointer(&w))) + procFillConsoleOutputCharacter.Call(fd, uintptr(' '), uintptr(count), *(*uintptr)(unsafe.Pointer(&cursor)), uintptr(unsafe.Pointer(&w))) +} + +// windowsMoveCursorUp moves the cursor to the line n lines above the current one. +func windowsMoveCursorUp(wr io.Writer, fd uintptr, n int) { + var info consoleScreenBufferInfo + procGetConsoleScreenBufferInfo.Call(fd, uintptr(unsafe.Pointer(&info))) // move cursor up by n lines and to the first column info.cursorPosition.y -= short(n)