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 2, 2022
1 parent d4d5e08 commit 008e45c
Show file tree
Hide file tree
Showing 64 changed files with 12,914 additions and 264 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)
}
}
74 changes: 74 additions & 0 deletions pkg/backend/display/internal/terminal/mock.go
@@ -0,0 +1,74 @@
package terminal

import (
"io"
"sync"
)

type MockTerminal struct {
m sync.Mutex

width, height int
info Info

keys chan string

dest io.Writer
}

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

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
}
183 changes: 183 additions & 0 deletions pkg/backend/display/internal/terminal/term.go
@@ -0,0 +1,183 @@
package terminal

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

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

type Terminal interface {
io.WriteCloser

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
save *term.State

out io.Writer
in cancelreader.CancelReader
}

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

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

inFile, err := cancelreader.NewReader(in)
if err != nil {
return nil, ErrNotATerminal
}

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)

save, err := term.MakeRaw(outFd)
if err != nil {
return nil, fmt.Errorf("enabling raw mode: %w", err)
}

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

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

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

func (t *terminal) Write(b []byte) (int, error) {
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) {
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 008e45c

Please sign in to comment.