mirror of https://github.com/restic/restic.git
Add termstatus
This commit is contained in:
parent
9fac2ca832
commit
1af96fc6dd
|
@ -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}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue