From 1af96fc6dd782176983386f8fa602552da9f5fbf Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Sun, 22 Apr 2018 11:57:11 +0200 Subject: [PATCH] Add termstatus --- internal/ui/termstatus/status.go | 281 +++++++++++++++++++++ internal/ui/termstatus/terminal_posix.go | 33 +++ internal/ui/termstatus/terminal_unix.go | 34 +++ internal/ui/termstatus/terminal_windows.go | 131 ++++++++++ 4 files changed, 479 insertions(+) create mode 100644 internal/ui/termstatus/status.go create mode 100644 internal/ui/termstatus/terminal_posix.go create mode 100644 internal/ui/termstatus/terminal_unix.go create mode 100644 internal/ui/termstatus/terminal_windows.go diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go new file mode 100644 index 000000000..25fdcc341 --- /dev/null +++ b/internal/ui/termstatus/status.go @@ -0,0 +1,281 @@ +package termstatus + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "strings" +) + +// Terminal is used to write messages and display status lines which can be +// updated. When the output is redirected to a file, the status lines are not +// printed. +type Terminal struct { + wr *bufio.Writer + fd uintptr + errWriter io.Writer + buf *bytes.Buffer + msg chan message + status chan status + canUpdateStatus bool + clearLines clearLinesFunc +} + +type clearLinesFunc func(wr io.Writer, fd uintptr, n int) + +type message struct { + line string + err bool +} + +type status struct { + lines []string +} + +type fder interface { + Fd() uintptr +} + +// New returns a new Terminal for wr. A goroutine is started to update the +// terminal. It is terminated when ctx is cancelled. When wr is redirected to +// a file (e.g. via shell output redirection) or is just an io.Writer (not the +// open *os.File for stdout), no status lines are printed. The status lines and +// normal output (via Print/Printf) are written to wr, error messages are +// written to errWriter. +func New(wr io.Writer, errWriter io.Writer) *Terminal { + t := &Terminal{ + wr: bufio.NewWriter(wr), + errWriter: errWriter, + buf: bytes.NewBuffer(nil), + msg: make(chan message), + status: make(chan status), + } + + if d, ok := wr.(fder); ok && canUpdateStatus(d.Fd()) { + // 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) + } + + return t +} + +// 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) { + if t.canUpdateStatus { + t.run(ctx) + return + } + + 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 + for { + select { + case <-ctx.Done(): + t.undoStatus(statusLines) + + err := t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } + + return + + case msg := <-t.msg: + t.undoStatus(statusLines) + + 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. + err := t.wr.Flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } + } else { + dst = t.wr + } + + var err error + if w, ok := dst.(stringWriter); ok { + _, err = w.WriteString(msg.line) + } else { + _, err = dst.Write([]byte(msg.line)) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "write failed: %v\n", err) + continue + } + + _, 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) + } + + case stat := <-t.status: + 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) + } + } + } +} + +// runWithoutStatus listens on the channels and just prints out the messages, +// without status lines. +func (t *Terminal) runWithoutStatus(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case msg := <-t.msg: + var err error + var flush func() error + + var dst io.Writer + if msg.err { + dst = t.errWriter + } else { + dst = t.wr + flush = t.wr.Flush + } + + if w, ok := dst.(stringWriter); ok { + _, err = w.WriteString(msg.line) + } else { + _, err = dst.Write([]byte(msg.line)) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "write failed: %v\n", err) + } + + if flush == nil { + continue + } + + err = flush() + if err != nil { + fmt.Fprintf(os.Stderr, "flush failed: %v\n", err) + } + + case _ = <-t.status: + // discard status lines + } + } +} + +func (t *Terminal) undoStatus(lines int) { + if lines == 0 { + return + } + + lines-- + t.clearLines(t.wr, t.fd, lines) +} + +// Print writes a line to the terminal. +func (t *Terminal) Print(line string) { + // make sure the line ends with a line break + if line[len(line)-1] != '\n' { + line += "\n" + } + + t.msg <- message{line: line} +} + +// Printf uses fmt.Sprintf to write a line to the terminal. +func (t *Terminal) Printf(msg string, args ...interface{}) { + s := fmt.Sprintf(msg, args...) + t.Print(s) +} + +// Error writes an error to the terminal. +func (t *Terminal) Error(line string) { + // make sure the line ends with a line break + if line[len(line)-1] != '\n' { + line += "\n" + } + + t.msg <- message{line: line, err: true} +} + +// Errorf uses fmt.Sprintf to write an error line to the terminal. +func (t *Terminal) Errorf(msg string, args ...interface{}) { + s := fmt.Sprintf(msg, args...) + t.Error(s) +} + +// SetStatus updates the status lines. +func (t *Terminal) SetStatus(lines []string) { + if len(lines) == 0 { + return + } + + width, _, err := getTermSize(t.fd) + if err != nil || width < 0 { + // use 80 columns by default + width = 80 + } + + // make sure that all lines have a line break and are not too long + for i, line := range lines { + line = strings.TrimRight(line, "\n") + + if len(line) >= width-2 { + line = line[:width-2] + } + line += "\n" + lines[i] = line + } + + // make sure the last line does not have a line break + last := len(lines) - 1 + lines[last] = strings.TrimRight(lines[last], "\n") + + t.status <- status{lines: lines} +} diff --git a/internal/ui/termstatus/terminal_posix.go b/internal/ui/termstatus/terminal_posix.go new file mode 100644 index 000000000..6b86e0d43 --- /dev/null +++ b/internal/ui/termstatus/terminal_posix.go @@ -0,0 +1,33 @@ +package termstatus + +import ( + "fmt" + "io" + "os" +) + +const ( + posixMoveCursorHome = "\r" + posixMoveCursorUp = "\x1b[1A" + posixClearLine = "\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) { + // clear current line + _, err := wr.Write([]byte(posixMoveCursorHome + posixClearLine)) + 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 + } + } +} diff --git a/internal/ui/termstatus/terminal_unix.go b/internal/ui/termstatus/terminal_unix.go new file mode 100644 index 000000000..52db49a17 --- /dev/null +++ b/internal/ui/termstatus/terminal_unix.go @@ -0,0 +1,34 @@ +// +build !windows + +package termstatus + +import ( + "io" + "syscall" + "unsafe" + + 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 +} + +// canUpdateStatus returns true if status lines can be printed, the process +// output is not redirected to a file or pipe. +func canUpdateStatus(fd uintptr) bool { + return isatty.IsTerminal(fd) +} + +// getTermSize returns the dimensions of the given terminal. +// the code is taken from "golang.org/x/crypto/ssh/terminal" +func getTermSize(fd uintptr) (width, height int, err error) { + var dimensions [4]uint16 + + if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd, uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&dimensions)), 0, 0, 0); err != 0 { + return -1, -1, err + } + return int(dimensions[1]), int(dimensions[0]), nil +} diff --git a/internal/ui/termstatus/terminal_windows.go b/internal/ui/termstatus/terminal_windows.go new file mode 100644 index 000000000..56910c67e --- /dev/null +++ b/internal/ui/termstatus/terminal_windows.go @@ -0,0 +1,131 @@ +// +build windows + +package termstatus + +import ( + "io" + "syscall" + "unsafe" +) + +// clearLines clears the current line and n lines above it. +func clearLines(wr io.Writer, fd uintptr) clearLinesFunc { + // easy case, the terminal is cmd or psh, without redirection + if isWindowsTerminal(fd) { + return windowsClearLines + } + + // 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, int) {} + } + + // assume we're running in mintty/cygwin + return posixClearLines +} + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") + +var ( + procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") + procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") + procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW") + procFillConsoleOutputAttribute = kernel32.NewProc("FillConsoleOutputAttribute") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procGetFileType = kernel32.NewProc("GetFileType") +) + +type ( + short int16 + word uint16 + dword uint32 + + coord struct { + x short + y short + } + smallRect struct { + left short + top short + right short + bottom short + } + consoleScreenBufferInfo struct { + size coord + cursorPosition coord + attributes word + window smallRect + maximumWindowSize coord + } +) + +// windowsClearLines clears the current line and n lines above it. +func windowsClearLines(wr io.Writer, fd uintptr, n int) { + 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))) + } + + // move cursor up by n lines and to the first column + info.cursorPosition.y -= short(n) + info.cursorPosition.x = 0 + procSetConsoleCursorPosition.Call(fd, uintptr(*(*int32)(unsafe.Pointer(&info.cursorPosition)))) +} + +// getTermSize returns the dimensions of the given terminal. +// the code is taken from "golang.org/x/crypto/ssh/terminal" +func getTermSize(fd uintptr) (width, height int, err error) { + var info consoleScreenBufferInfo + _, _, e := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, fd, uintptr(unsafe.Pointer(&info)), 0) + if e != 0 { + return 0, 0, error(e) + } + return int(info.size.x), int(info.size.y), nil +} + +// isWindowsTerminal return true if the file descriptor is a windows terminal (cmd, psh). +func isWindowsTerminal(fd uintptr) bool { + var st uint32 + r, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, fd, uintptr(unsafe.Pointer(&st)), 0) + return r != 0 && e == 0 +} + +const fileTypePipe = 0x0003 + +// getFileType returns the file type for the given fd. +// https://msdn.microsoft.com/de-de/library/windows/desktop/aa364960(v=vs.85).aspx +func getFileType(fd uintptr) int { + r, _, e := syscall.Syscall(procGetFileType.Addr(), 1, fd, 0, 0) + if e != 0 { + return 0 + } + return int(r) +} + +// canUpdateStatus returns true if status lines can be printed, the process +// output is not redirected to a file or pipe. +func canUpdateStatus(fd uintptr) bool { + // easy case, the terminal is cmd or psh, without redirection + if isWindowsTerminal(fd) { + return true + } + + // check if the output file type is a pipe (0x0003) + if getFileType(fd) != fileTypePipe { + return false + } + + // assume we're running in mintty/cygwin + return true +}