Add termstatus

This commit is contained in:
Alexander Neumann 2018-04-22 11:57:11 +02:00
parent 9fac2ca832
commit 1af96fc6dd
4 changed files with 479 additions and 0 deletions

View File

@ -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}
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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
}