From fc6019e84071e1ffb3257aa7a81b18272baf22c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 24 Nov 2022 19:05:46 +0100 Subject: [PATCH] WIP use virtual terminal processing on Windows --- password.go | 3 +- renderer.go | 5 +- survey.go | 50 ++++++- terminal/cursor.go | 3 - terminal/cursor_windows.go | 164 --------------------- terminal/display.go | 10 ++ terminal/display_posix.go | 13 -- terminal/display_windows.go | 31 ---- terminal/output.go | 10 +- terminal/output_windows.go | 253 ++------------------------------- terminal/runereader_windows.go | 16 ++- terminal/syscall_windows.go | 39 ----- 12 files changed, 91 insertions(+), 506 deletions(-) delete mode 100644 terminal/cursor_windows.go delete mode 100644 terminal/display_posix.go delete mode 100644 terminal/display_windows.go delete mode 100644 terminal/syscall_windows.go diff --git a/password.go b/password.go index 96a2ae89..008f2095 100644 --- a/password.go +++ b/password.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/AlecAivazis/survey/v2/core" - "github.com/AlecAivazis/survey/v2/terminal" ) /* @@ -48,7 +47,7 @@ func (p *Password) Prompt(config *PromptConfig) (interface{}, error) { return "", err } - if _, err := fmt.Fprint(terminal.NewAnsiStdout(p.Stdio().Out), userOut); err != nil { + if _, err := fmt.Fprint(p.Stdio().Out, userOut); err != nil { return "", err } diff --git a/renderer.go b/renderer.go index a16207de..0e1fb8a0 100644 --- a/renderer.go +++ b/renderer.go @@ -3,6 +3,7 @@ package survey import ( "bytes" "fmt" + "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" "golang.org/x/term" @@ -59,7 +60,7 @@ func (r *Renderer) Error(config *PromptConfig, invalid error) error { } // send the message to the user - if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil { + if _, err := fmt.Fprint(r.stdio.Out, userOut); err != nil { return err } @@ -90,7 +91,7 @@ func (r *Renderer) Render(tmpl string, data interface{}) error { } // print the summary - if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil { + if _, err := fmt.Fprint(r.stdio.Out, userOut); err != nil { return err } diff --git a/survey.go b/survey.go index aad73bbe..953b117d 100644 --- a/survey.go +++ b/survey.go @@ -10,16 +10,12 @@ import ( "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/mattn/go-colorable" ) // DefaultAskOptions is the default options on ask, using the OS stdio. func defaultAskOptions() *AskOptions { return &AskOptions{ - Stdio: terminal.Stdio{ - In: os.Stdin, - Out: os.Stdout, - Err: os.Stderr, - }, PromptConfig: PromptConfig{ PageSize: 7, HelpInput: "?", @@ -154,7 +150,7 @@ type AskOptions struct { func WithStdio(in terminal.FileReader, out terminal.FileWriter, err io.Writer) AskOpt { return func(options *AskOptions) error { options.Stdio.In = in - options.Stdio.Out = out + options.Stdio.Out = colorableOut(out) options.Stdio.Err = err return nil } @@ -323,6 +319,17 @@ func Ask(qs []*Question, response interface{}, opts ...AskOpt) error { } } + // fallback values in case [WithStdio] was not used + if options.Stdio.In == nil { + options.Stdio.In = os.Stdin + } + if options.Stdio.Out == nil { + options.Stdio.Out = colorableOut(os.Stdout) + } + if options.Stdio.Err == nil { + options.Stdio.Err = os.Stderr + } + // if we weren't passed a place to record the answers if response == nil { // we can't go any further @@ -472,3 +479,34 @@ func computeCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswe return offset } + +// colorableOut transforms a file writer into one where it is safe to write ANSI escape codes to. +func colorableOut(w terminal.FileWriter) terminal.FileWriter { + if f, ok := w.(*os.File); ok { + out := colorable.NewColorable(f) + if f, ok := out.(*os.File); ok { + // Most cases will end up here: the writer is either + // 1. the original os.Stdout; or + // 2. the original os.Stdout with virtual terminal processing enabled on Windows. + return f + } + // If we have reached this point, the resulting writer is a Windows-specific writer that + // converts ANSI escape codes to Console API calls, and we need to wrap it in an extra + // type to preserve the original file descriptor. + return &writerWithFd{ + Writer: out, + orig: f, + } + } + return w +} + +// writerWithFd implements a [terminal.FileWriter] +type writerWithFd struct { + io.Writer + orig *os.File +} + +func (w writerWithFd) Fd() uintptr { + return w.orig.Fd() +} diff --git a/terminal/cursor.go b/terminal/cursor.go index 75117e08..2b1ca771 100644 --- a/terminal/cursor.go +++ b/terminal/cursor.go @@ -1,6 +1,3 @@ -//go:build !windows -// +build !windows - package terminal import ( diff --git a/terminal/cursor_windows.go b/terminal/cursor_windows.go deleted file mode 100644 index c2645919..00000000 --- a/terminal/cursor_windows.go +++ /dev/null @@ -1,164 +0,0 @@ -package terminal - -import ( - "bytes" - "syscall" - "unsafe" -) - -var COORDINATE_SYSTEM_BEGIN Short = 0 - -// shared variable to save the cursor location from CursorSave() -var cursorLoc Coord - -type Cursor struct { - In FileReader - Out FileWriter -} - -func (c *Cursor) Up(n int) error { - return c.cursorMove(0, n) -} - -func (c *Cursor) Down(n int) error { - return c.cursorMove(0, -1*n) -} - -func (c *Cursor) Forward(n int) error { - return c.cursorMove(n, 0) -} - -func (c *Cursor) Back(n int) error { - return c.cursorMove(-1*n, 0) -} - -// save the cursor location -func (c *Cursor) Save() error { - loc, err := c.Location(nil) - if err != nil { - return err - } - cursorLoc = *loc - return nil -} - -func (c *Cursor) Restore() error { - handle := syscall.Handle(c.Out.Fd()) - // restore it to the original position - _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursorLoc)))) - return normalizeError(err) -} - -func (cur Coord) CursorIsAtLineEnd(size *Coord) bool { - return cur.X == size.X -} - -func (cur Coord) CursorIsAtLineBegin() bool { - return cur.X == 0 -} - -func (c *Cursor) cursorMove(x int, y int) error { - handle := syscall.Handle(c.Out.Fd()) - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return err - } - - var cursor Coord - cursor.X = csbi.cursorPosition.X + Short(x) - cursor.Y = csbi.cursorPosition.Y + Short(y) - - _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) - return normalizeError(err) -} - -func (c *Cursor) NextLine(n int) error { - if err := c.Up(n); err != nil { - return err - } - return c.HorizontalAbsolute(0) -} - -func (c *Cursor) PreviousLine(n int) error { - if err := c.Down(n); err != nil { - return err - } - return c.HorizontalAbsolute(0) -} - -// for comparability purposes between windows -// in windows we don't have to print out a new line -func (c *Cursor) MoveNextLine(cur *Coord, terminalSize *Coord) error { - return c.NextLine(1) -} - -func (c *Cursor) HorizontalAbsolute(x int) error { - handle := syscall.Handle(c.Out.Fd()) - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return err - } - - var cursor Coord - cursor.X = Short(x) - cursor.Y = csbi.cursorPosition.Y - - if csbi.size.X < cursor.X { - cursor.X = csbi.size.X - } - - _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) - return normalizeError(err) -} - -func (c *Cursor) Show() error { - handle := syscall.Handle(c.Out.Fd()) - - var cci consoleCursorInfo - if _, _, err := procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))); normalizeError(err) != nil { - return err - } - cci.visible = 1 - - _, _, err := procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) - return normalizeError(err) -} - -func (c *Cursor) Hide() error { - handle := syscall.Handle(c.Out.Fd()) - - var cci consoleCursorInfo - if _, _, err := procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))); normalizeError(err) != nil { - return err - } - cci.visible = 0 - - _, _, err := procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) - return normalizeError(err) -} - -func (c *Cursor) Location(buf *bytes.Buffer) (*Coord, error) { - handle := syscall.Handle(c.Out.Fd()) - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return nil, err - } - - return &csbi.cursorPosition, nil -} - -func (c *Cursor) Size(buf *bytes.Buffer) (*Coord, error) { - handle := syscall.Handle(c.Out.Fd()) - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return nil, err - } - // windows' coordinate system begins at (0, 0) - csbi.size.X-- - csbi.size.Y-- - return &csbi.size, nil -} diff --git a/terminal/display.go b/terminal/display.go index 0f014b13..9bf6cd61 100644 --- a/terminal/display.go +++ b/terminal/display.go @@ -1,5 +1,10 @@ package terminal +import ( + "fmt" + "io" +) + type EraseLineMode int const ( @@ -7,3 +12,8 @@ const ( ERASE_LINE_START ERASE_LINE_ALL ) + +func EraseLine(out io.Writer, mode EraseLineMode) error { + _, err := fmt.Fprintf(out, "\x1b[%dK", mode) + return err +} diff --git a/terminal/display_posix.go b/terminal/display_posix.go deleted file mode 100644 index fbd1b794..00000000 --- a/terminal/display_posix.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !windows -// +build !windows - -package terminal - -import ( - "fmt" -) - -func EraseLine(out FileWriter, mode EraseLineMode) error { - _, err := fmt.Fprintf(out, "\x1b[%dK", mode) - return err -} diff --git a/terminal/display_windows.go b/terminal/display_windows.go deleted file mode 100644 index fc9db9f7..00000000 --- a/terminal/display_windows.go +++ /dev/null @@ -1,31 +0,0 @@ -package terminal - -import ( - "syscall" - "unsafe" -) - -func EraseLine(out FileWriter, mode EraseLineMode) error { - handle := syscall.Handle(out.Fd()) - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return err - } - - var w uint32 - var x Short - cursor := csbi.cursorPosition - switch mode { - case ERASE_LINE_END: - x = csbi.size.X - case ERASE_LINE_START: - x = 0 - case ERASE_LINE_ALL: - cursor.X = 0 - x = csbi.size.X - } - - _, _, err := procFillConsoleOutputCharacter.Call(uintptr(handle), uintptr(' '), uintptr(x), uintptr(*(*int32)(unsafe.Pointer(&cursor))), uintptr(unsafe.Pointer(&w))) - return normalizeError(err) -} diff --git a/terminal/output.go b/terminal/output.go index 29102420..7a28c585 100644 --- a/terminal/output.go +++ b/terminal/output.go @@ -7,14 +7,16 @@ import ( "io" ) -// NewAnsiStdout returns special stdout, which converts escape sequences to Windows API calls -// on Windows environment. +// NewAnsiStdout returns a writer connected to standard output that interprets ANSI escape codes to in a platform-agnostic way. +// +// Deprecated: use the mattn/go-colorable module instead of this method. func NewAnsiStdout(out FileWriter) io.Writer { return out } -// NewAnsiStderr returns special stderr, which converts escape sequences to Windows API calls -// on Windows environment. +// NewAnsiStdout returns a writer connected to standard error that interprets ANSI escape codes to in a platform-agnostic way. +// +// Deprecated: use the mattn/go-colorable module instead of this method. func NewAnsiStderr(out FileWriter) io.Writer { return out } diff --git a/terminal/output_windows.go b/terminal/output_windows.go index eaf5c434..67e3ffb5 100644 --- a/terminal/output_windows.go +++ b/terminal/output_windows.go @@ -1,253 +1,28 @@ package terminal import ( - "bytes" - "fmt" "io" - "strconv" - "strings" - "syscall" - "unsafe" + "os" - "github.com/mattn/go-isatty" + "github.com/mattn/go-colorable" ) -const ( - foregroundBlue = 0x1 - foregroundGreen = 0x2 - foregroundRed = 0x4 - foregroundIntensity = 0x8 - foregroundMask = (foregroundRed | foregroundBlue | foregroundGreen | foregroundIntensity) - backgroundBlue = 0x10 - backgroundGreen = 0x20 - backgroundRed = 0x40 - backgroundIntensity = 0x80 - backgroundMask = (backgroundRed | backgroundBlue | backgroundGreen | backgroundIntensity) -) - -type Writer struct { - out FileWriter - handle syscall.Handle - orgAttr word -} - +// NewAnsiStdout returns a writer connected to standard output that interprets ANSI escape codes to in a platform-agnostic way. +// +// Deprecated: use the mattn/go-colorable module instead of this method. func NewAnsiStdout(out FileWriter) io.Writer { - var csbi consoleScreenBufferInfo - if !isatty.IsTerminal(out.Fd()) { - return out + if f, ok := out.(*os.File); ok { + return colorable.NewColorable(f) } - handle := syscall.Handle(out.Fd()) - procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - return &Writer{out: out, handle: handle, orgAttr: csbi.attributes} + return out } +// NewAnsiStdout returns a writer connected to standard error that interprets ANSI escape codes to in a platform-agnostic way. +// +// Deprecated: use the mattn/go-colorable module instead of this method. func NewAnsiStderr(out FileWriter) io.Writer { - var csbi consoleScreenBufferInfo - if !isatty.IsTerminal(out.Fd()) { - return out - } - handle := syscall.Handle(out.Fd()) - procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - return &Writer{out: out, handle: handle, orgAttr: csbi.attributes} -} - -func (w *Writer) Write(data []byte) (n int, err error) { - r := bytes.NewReader(data) - - for { - var ch rune - var size int - ch, size, err = r.ReadRune() - if err != nil { - if err == io.EOF { - err = nil - } - return - } - n += size - - switch ch { - case '\x1b': - size, err = w.handleEscape(r) - n += size - if err != nil { - return - } - default: - _, err = fmt.Fprint(w.out, string(ch)) - if err != nil { - return - } - } - } -} - -func (w *Writer) handleEscape(r *bytes.Reader) (n int, err error) { - buf := make([]byte, 0, 10) - buf = append(buf, "\x1b"...) - - var ch rune - var size int - // Check '[' continues after \x1b - ch, size, err = r.ReadRune() - if err != nil { - if err == io.EOF { - err = nil - } - fmt.Fprint(w.out, string(buf)) - return - } - n += size - if ch != '[' { - fmt.Fprint(w.out, string(buf)) - return - } - - // Parse escape code - var code rune - argBuf := make([]byte, 0, 10) - for { - ch, size, err = r.ReadRune() - if err != nil { - if err == io.EOF { - err = nil - } - fmt.Fprint(w.out, string(buf)) - return - } - n += size - if ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') { - code = ch - break - } - argBuf = append(argBuf, string(ch)...) - } - - err = w.applyEscapeCode(buf, string(argBuf), code) - return -} - -func (w *Writer) applyEscapeCode(buf []byte, arg string, code rune) error { - c := &Cursor{Out: w.out} - - switch arg + string(code) { - case "?25h": - return c.Show() - case "?25l": - return c.Hide() - } - - if code >= 'A' && code <= 'G' { - if n, err := strconv.Atoi(arg); err == nil { - switch code { - case 'A': - return c.Up(n) - case 'B': - return c.Down(n) - case 'C': - return c.Forward(n) - case 'D': - return c.Back(n) - case 'E': - return c.NextLine(n) - case 'F': - return c.PreviousLine(n) - case 'G': - return c.HorizontalAbsolute(n) - } - } - } - - switch code { - case 'm': - return w.applySelectGraphicRendition(arg) - default: - buf = append(buf, string(code)...) - _, err := fmt.Fprint(w.out, string(buf)) - return err - } -} - -// Original implementation: https://github.com/mattn/go-colorable -func (w *Writer) applySelectGraphicRendition(arg string) error { - if arg == "" { - _, _, err := procSetConsoleTextAttribute.Call(uintptr(w.handle), uintptr(w.orgAttr)) - return normalizeError(err) - } - - var csbi consoleScreenBufferInfo - if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(w.handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { - return err - } - attr := csbi.attributes - - for _, param := range strings.Split(arg, ";") { - n, err := strconv.Atoi(param) - if err != nil { - continue - } - - switch { - case n == 0 || n == 100: - attr = w.orgAttr - case 1 <= n && n <= 5: - attr |= foregroundIntensity - case 30 <= n && n <= 37: - attr = (attr & backgroundMask) - if (n-30)&1 != 0 { - attr |= foregroundRed - } - if (n-30)&2 != 0 { - attr |= foregroundGreen - } - if (n-30)&4 != 0 { - attr |= foregroundBlue - } - case 40 <= n && n <= 47: - attr = (attr & foregroundMask) - if (n-40)&1 != 0 { - attr |= backgroundRed - } - if (n-40)&2 != 0 { - attr |= backgroundGreen - } - if (n-40)&4 != 0 { - attr |= backgroundBlue - } - case 90 <= n && n <= 97: - attr = (attr & backgroundMask) - attr |= foregroundIntensity - if (n-90)&1 != 0 { - attr |= foregroundRed - } - if (n-90)&2 != 0 { - attr |= foregroundGreen - } - if (n-90)&4 != 0 { - attr |= foregroundBlue - } - case 100 <= n && n <= 107: - attr = (attr & foregroundMask) - attr |= backgroundIntensity - if (n-100)&1 != 0 { - attr |= backgroundRed - } - if (n-100)&2 != 0 { - attr |= backgroundGreen - } - if (n-100)&4 != 0 { - attr |= backgroundBlue - } - } - } - - _, _, err := procSetConsoleTextAttribute.Call(uintptr(w.handle), uintptr(attr)) - return normalizeError(err) -} - -func normalizeError(err error) error { - if syserr, ok := err.(syscall.Errno); ok && syserr == 0 { - return nil + if f, ok := out.(*os.File); ok { + return colorable.NewColorable(f) } - return err + return out } diff --git a/terminal/runereader_windows.go b/terminal/runereader_windows.go index 791092f5..d93f1ab4 100644 --- a/terminal/runereader_windows.go +++ b/terminal/runereader_windows.go @@ -1,6 +1,7 @@ package terminal import ( + "bufio" "bytes" "syscall" "unsafe" @@ -50,15 +51,24 @@ type keyEventRecord struct { } type runeReaderState struct { - term uint32 + term uint32 + reader *bufio.Reader + buf *bytes.Buffer } func newRuneReaderState(input FileReader) runeReaderState { - return runeReaderState{} + buf := new(bytes.Buffer) + return runeReaderState{ + reader: bufio.NewReader(&BufferedReader{ + In: input, + Buffer: buf, + }), + buf: buf, + } } func (rr *RuneReader) Buffer() *bytes.Buffer { - return nil + return rr.state.buf } func (rr *RuneReader) SetTermMode() error { diff --git a/terminal/syscall_windows.go b/terminal/syscall_windows.go deleted file mode 100644 index 63b85d4c..00000000 --- a/terminal/syscall_windows.go +++ /dev/null @@ -1,39 +0,0 @@ -package terminal - -import ( - "syscall" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") - procSetConsoleTextAttribute = kernel32.NewProc("SetConsoleTextAttribute") - procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") - procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW") - procGetConsoleCursorInfo = kernel32.NewProc("GetConsoleCursorInfo") - procSetConsoleCursorInfo = kernel32.NewProc("SetConsoleCursorInfo") -) - -type wchar uint16 -type dword uint32 -type word uint16 - -type smallRect struct { - left Short - top Short - right Short - bottom Short -} - -type consoleScreenBufferInfo struct { - size Coord - cursorPosition Coord - attributes word - window smallRect - maximumWindowSize Coord -} - -type consoleCursorInfo struct { - size dword - visible int32 -}