Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cli] Abstract out terminal interactions #11201

Merged
merged 1 commit into from
Nov 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/backend/display/diff_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//nolint:goconst
package display

import (
Expand Down
79 changes: 79 additions & 0 deletions pkg/backend/display/internal/terminal/info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package terminal

import (
"fmt"
"io"

gotty "github.com/ijc/Gotty"
)

type Info interface {
Parse(attr string, params ...interface{}) (string, error)

ClearLine(out io.Writer)
CursorUp(out io.Writer, count int)
CursorDown(out io.Writer, count int)
}

/* Satisfied by gotty.TermInfo as well as noTermInfo from below */
type termInfo interface {
Parse(attr string, params ...interface{}) (string, error)
}

type noTermInfo int // canary used when no terminfo.

func (ti noTermInfo) Parse(attr string, params ...interface{}) (string, error) {
return "", fmt.Errorf("noTermInfo")
}

type info struct {
termInfo
}

var _ = Info(info{})

func OpenInfo(terminal string) Info {
if i, err := gotty.OpenTermInfo(terminal); err == nil {
return info{i}
}
return info{noTermInfo(0)}
}

func (i info) ClearLine(out io.Writer) {
// el2 (clear whole line) is not exposed by terminfo.

// First clear line from beginning to cursor
if attr, err := i.Parse("el1"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[1K")
}
// Then clear line from cursor to end
if attr, err := i.Parse("el"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[K")
}
}

func (i info) CursorUp(out io.Writer, count int) {
if count == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := i.Parse("cuu", count); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[%dA", count)
}
}

func (i info) CursorDown(out io.Writer, count int) {
if count == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := i.Parse("cud", count); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "\x1b[%dB", count)
}
}
80 changes: 80 additions & 0 deletions pkg/backend/display/internal/terminal/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package terminal

import (
"io"
"sync"
)

type MockTerminal struct {
m sync.Mutex

width, height int
raw bool
info Info

keys chan string

dest io.Writer
}

func NewMockTerminal(dest io.Writer, width, height int, raw bool) *MockTerminal {
return &MockTerminal{
width: width,
height: height,
raw: raw,
info: info{noTermInfo(0)},
keys: make(chan string),
dest: dest,
}
}

func (t *MockTerminal) IsRaw() bool {
return t.raw
}

func (t *MockTerminal) Close() error {
close(t.keys)
return nil
}

func (t *MockTerminal) Size() (width, height int, err error) {
t.m.Lock()
defer t.m.Unlock()

return t.width, t.height, nil
}

func (t *MockTerminal) Write(b []byte) (int, error) {
return t.dest.Write(b)
}

func (t *MockTerminal) ClearLine() {
t.info.ClearLine(t)
}

func (t *MockTerminal) CursorUp(count int) {
t.info.CursorUp(t, count)
}

func (t *MockTerminal) CursorDown(count int) {
t.info.CursorDown(t, count)
}

func (t *MockTerminal) ReadKey() (string, error) {
k, ok := <-t.keys
if !ok {
return "", io.EOF
}
return k, nil
}

func (t *MockTerminal) SetSize(width, height int) {
t.m.Lock()
defer t.m.Unlock()

t.width, t.height = width, height
}

func (t *MockTerminal) SendKey(key string) {
t.keys <- key
}
202 changes: 202 additions & 0 deletions pkg/backend/display/internal/terminal/term.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package terminal

import (
"bytes"
"errors"
"fmt"
"io"
"os"

"github.com/muesli/cancelreader"
"golang.org/x/term"
)

type Terminal interface {
io.WriteCloser

IsRaw() bool
Size() (width, height int, err error)

ClearLine()
CursorUp(count int)
CursorDown(count int)

ReadKey() (string, error)
}

var ErrNotATerminal = errors.New("not a terminal")

type terminal struct {
fd int
info Info
raw bool
save *term.State

out io.Writer
in cancelreader.CancelReader
}

func Open(in io.Reader, out io.Writer, raw bool) (Terminal, error) {
type fileLike interface {
Fd() uintptr
}

outFile, ok := out.(fileLike)
if !ok {
return nil, ErrNotATerminal
}
outFd := int(outFile.Fd())

width, height, err := term.GetSize(outFd)
if err != nil {
return nil, fmt.Errorf("getting dimensions: %w", err)
}
if width == 0 || height == 0 {
return nil, fmt.Errorf("unusable dimensions (%v x %v)", width, height)
}

termType := os.Getenv("TERM")
if termType == "" {
termType = "vt102"
}
info := OpenInfo(termType)

var save *term.State
var inFile cancelreader.CancelReader
if raw {
if save, err = term.MakeRaw(outFd); err != nil {
return nil, fmt.Errorf("enabling raw mode: %w", err)
}
if inFile, err = cancelreader.NewReader(in); err != nil {
return nil, ErrNotATerminal
}
}

return &terminal{
fd: outFd,
info: info,
raw: raw,
save: save,
out: out,
in: inFile,
}, nil
}

func (t *terminal) IsRaw() bool {
return t.raw
}

func (t *terminal) Close() error {
t.in.Cancel()
if t.save != nil {
return term.Restore(t.fd, t.save)
}
return nil
}

func (t *terminal) Size() (width, height int, err error) {
return term.GetSize(t.fd)
}

func (t *terminal) Write(b []byte) (int, error) {
if !t.raw {
return t.out.Write(b)
}

written := 0
for {
newline := bytes.IndexByte(b, '\n')
if newline == -1 {
w, err := t.out.Write(b)
written += w
return written, err
}

w, err := t.out.Write(b[:newline])
written += w
if err != nil {
return written, err
}

if _, err = t.out.Write([]byte{'\r', '\n'}); err != nil {
return written, err
}
written++

b = b[newline+1:]
}
}

func (t *terminal) ClearLine() {
t.info.ClearLine(t.out)
}

func (t *terminal) CursorUp(count int) {
t.info.CursorUp(t.out, count)
}

func (t *terminal) CursorDown(count int) {
t.info.CursorDown(t.out, count)
}

func (t *terminal) ReadKey() (string, error) {
if t.in == nil {
return "", io.EOF
}

type stateFunc func(b byte) (stateFunc, string)

var stateIntermediate stateFunc
stateIntermediate = func(b byte) (stateFunc, string) {
if b >= 0x20 && b < 0x30 {
return stateIntermediate, ""
}
switch b {
case 'A':
return nil, "up"
case 'B':
return nil, "down"
default:
return nil, "<control>"
}
}
var stateParameter stateFunc
stateParameter = func(b byte) (stateFunc, string) {
if b >= 0x30 && b < 0x40 {
return stateParameter, ""
}
return stateIntermediate(b)
}
stateBracket := func(b byte) (stateFunc, string) {
if b == '[' {
return stateParameter, ""
}
return nil, "<control>"
}
stateEscape := func(b byte) (stateFunc, string) {
if b == 0x1b {
return stateBracket, ""
}
if b == 3 {
return nil, "ctrl+c"
}
return nil, string([]byte{b})
}

state := stateEscape
for {
var b [1]byte
if _, err := t.in.Read(b[:]); err != nil {
if errors.Is(err, cancelreader.ErrCanceled) {
err = io.EOF
}
return "", err
}

next, key := state(b[0])
if next == nil {
return key, nil
}
state = next
}
}