Skip to content

Commit

Permalink
[cli] Abstract out terminal interactions
Browse files Browse the repository at this point in the history
Replace direct interaction with the terminal with an abstraction. This
abstraction is tightly constrained to the capabilities needed for the
CLI's display. Using this abstraction allows for straightforward testing
of the interactive renderers.
  • Loading branch information
pgavlin committed Nov 8, 2022
1 parent ed05626 commit 2c372de
Show file tree
Hide file tree
Showing 106 changed files with 25,087 additions and 273 deletions.
79 changes: 79 additions & 0 deletions pkg/backend/display/internal/terminal/info.go
@@ -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
@@ -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
@@ -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
}
}

0 comments on commit 2c372de

Please sign in to comment.