@ -0,0 +1,39 @@ | |||
package main | |||
import ( | |||
"git.crumpington.com/public/tui" | |||
) | |||
func main() { | |||
tui.Start() | |||
defer tui.Stop() | |||
c := tui.GetMenu( | |||
"Menu", | |||
"Some long or short text.", | |||
"aa", "Accessory long text description with some other stuff. Whatever.", | |||
"b", "Bass", | |||
"c", "Case", | |||
"e", "Email address\n\naddr") | |||
tui.PrintString("Got %s", c) | |||
tui.GetString("...") | |||
tui.PrintLine() | |||
tui.GetString("What would you like? ") | |||
tui.Clear() | |||
tui.GetString("...") | |||
tui.Clear() | |||
tui.PrintBoxed("the rain in spain falls mainly in the plane. The rain in spain falls mainlyx in the plane.") | |||
tui.GetString("...") | |||
tui.Clear() | |||
tui.PrintTextWithPrefix(" ", "the rain in spain falls mainly in the plane. The rain in spain fallsxxx mainly in the plane.") | |||
tui.GetString("...") | |||
tui.Clear() | |||
i := tui.GetInt("an integer: ") | |||
tui.PrintTextWithPrefix("", "Got %d", i) | |||
} |
@ -0,0 +1,5 @@ | |||
module git.crumpington.com/public/tui | |||
go 1.16 | |||
require golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 |
@ -0,0 +1,4 @@ | |||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= | |||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |||
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs= | |||
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
@ -0,0 +1,25 @@ | |||
package tui | |||
import "fmt" | |||
// String prompts a user for a string and returns the result. | |||
func GetString(prompt string) string { | |||
t.SetPrompt(prompt) | |||
s, err := t.ReadLine() | |||
must(err) | |||
return s | |||
} | |||
func GetInt(prompt string) int { | |||
for { | |||
s := GetString(prompt) | |||
value := int(0) | |||
n, err := fmt.Sscanf(s, "%d", &value) | |||
if n != 1 || err != nil { | |||
continue | |||
} | |||
return value | |||
} | |||
} |
@ -0,0 +1,61 @@ | |||
package tui | |||
import "strings" | |||
func GetMenu( | |||
title string, | |||
text string, | |||
items ...string, // key -> value pairs | |||
) string { | |||
keys := map[string]bool{} | |||
keyWidth := 0 | |||
for i := 0; i < len(items); i += 2 { | |||
key := items[i] | |||
keyLen := len([]rune(key)) | |||
keys[items[i]] = true | |||
if keyLen > keyWidth { | |||
keyWidth = keyLen | |||
} | |||
} | |||
drawMenu := func() { | |||
Clear() | |||
PrintBoxed(title) | |||
PrintString("┆") | |||
if text != "" { | |||
PrintTextWithPrefix("│ ", text) | |||
PrintString("│") | |||
} | |||
w, _ := termSize() | |||
valWidth := w - keyWidth - 5 | |||
for i := 0; i < len(items); i += 2 { | |||
key := items[i] | |||
keyLen := len([]rune(key)) | |||
text := items[i+1] | |||
lines, _ := wrapText(text, valWidth) | |||
indent := keyWidth - keyLen | |||
// First line. | |||
sIndent := strings.Repeat(" ", indent) | |||
PrintString("│ %s%s %s", key, sIndent, lines[0]) | |||
// Additional lines. | |||
for _, line := range lines[1:] { | |||
sIndent := strings.Repeat(" ", keyWidth) | |||
PrintString("│ %s %s", sIndent, line) | |||
} | |||
} | |||
PrintString("│") | |||
} | |||
for { | |||
drawMenu() | |||
in := GetString("╰─┄ ") | |||
if _, ok := keys[in]; ok { | |||
return in | |||
} | |||
} | |||
} |
@ -0,0 +1,103 @@ | |||
package tui | |||
import ( | |||
"bytes" | |||
"fmt" | |||
) | |||
// Clear clears the screen. | |||
func Clear() { | |||
_, h := termSize() | |||
buf := make([]byte, 2*h) | |||
for i := range buf { | |||
buf[i] = '\n' | |||
} | |||
mustWrite(buf) | |||
} | |||
// Line prints a line across the screen followed by a newline. | |||
func PrintLine() { | |||
w, _ := termSize() | |||
runes := make([]rune, w) | |||
for i := range runes { | |||
runes[i] = '━' | |||
} | |||
runes[0] = '┅' | |||
runes[len(runes)-2] = '┅' | |||
runes[len(runes)-1] = '\n' | |||
buf := []byte(string(runes)) | |||
mustWrite(buf) | |||
} | |||
// Prints a box around text followed by a newline. | |||
func PrintBoxed(s string, args ...interface{}) { | |||
if len(args) > 0 { | |||
s = fmt.Sprintf(s, args...) | |||
} | |||
w, _ := termSize() | |||
lines, textWidth := wrapText(s, w-4) | |||
if w < textWidth+4 { | |||
w = textWidth + 4 | |||
} | |||
buf := &bytes.Buffer{} | |||
// Top of box. | |||
buf.Write([]byte("╭")) | |||
for i := 0; i < w-2; i++ { | |||
buf.Write([]byte("─")) | |||
} | |||
buf.Write([]byte("╮\n")) | |||
// Lines. | |||
for _, line := range lines { | |||
buf.Write([]byte("│ ")) | |||
buf.Write([]byte(line)) | |||
for i := len([]rune(line)); i < w-4; i++ { | |||
buf.Write([]byte(" ")) | |||
} | |||
buf.Write([]byte(" │\n")) | |||
} | |||
// Bottom of box. | |||
buf.Write([]byte("╰")) | |||
for i := 0; i < w-2; i++ { | |||
buf.Write([]byte("─")) | |||
} | |||
buf.Write([]byte("╯\n")) | |||
mustWrite(buf.Bytes()) | |||
} | |||
// Print a string to the terminal followed by a newline. No wrapping. | |||
func PrintString(s string, args ...interface{}) { | |||
if len(args) > 0 { | |||
s = fmt.Sprintf(s, args...) | |||
} | |||
mustWrite([]byte(s + "\n")) | |||
} | |||
// Format and wrap text, print to screen with given prefix on each wrapped | |||
// line. | |||
func PrintTextWithPrefix(prefix, s string, args ...interface{}) { | |||
if len(args) > 0 { | |||
s = fmt.Sprintf(s, args...) | |||
} | |||
indent := len([]rune(prefix)) | |||
//sIndent := strings.Repeat(" ", indent) | |||
w, _ := termSize() | |||
lines, _ := wrapText(s, w-1-indent) | |||
for _, line := range lines { | |||
PrintString(prefix + line) | |||
} | |||
} | |||
func PrintText(s string, args ...interface{}) { | |||
PrintTextWithPrefix("", s, args...) | |||
} |
@ -0,0 +1,134 @@ | |||
package tui | |||
import ( | |||
"strings" | |||
"unicode" | |||
) | |||
// Split text into words or newlines. | |||
func splitText(s string) [][]rune { | |||
s = strings.TrimSpace(s) | |||
r := []rune(s) | |||
out := [][]rune{} | |||
nextWord := func() []rune { | |||
for i := range r { | |||
if unicode.IsSpace(r[i]) { | |||
ret := r[:i] | |||
r = r[i:] | |||
return ret | |||
} | |||
} | |||
ret := r | |||
r = r[:0] | |||
return ret | |||
} | |||
nextSpaces := func() []rune { | |||
for i := range r { | |||
if !unicode.IsSpace(r[i]) { | |||
ret := r[:i] | |||
r = r[i:] | |||
return ret | |||
} | |||
} | |||
// Code should never reach these lines. | |||
ret := r | |||
r = r[:0] | |||
return ret | |||
} | |||
for len(r) > 0 { | |||
word := nextWord() | |||
if len(word) != 0 { | |||
out = append(out, word) | |||
} | |||
if len(r) == 0 { | |||
break | |||
} | |||
spaces := nextSpaces() | |||
count := 0 | |||
for _, x := range spaces { | |||
if x == '\n' { | |||
count++ | |||
} | |||
if count > 1 { | |||
break | |||
} | |||
} | |||
if count > 1 { | |||
out = append(out, []rune{'\n'}) | |||
} | |||
} | |||
return out | |||
} | |||
func normalizeText(s string) string { | |||
out := make([]rune, 0, len(s)) | |||
parts := splitText(s) | |||
prevWasWord := false | |||
for _, p := range parts { | |||
if p[0] == '\n' { | |||
out = append(out, '\n', '\n') | |||
prevWasWord = false | |||
} else { | |||
if prevWasWord { | |||
out = append(out, ' ') | |||
} | |||
out = append(out, p...) | |||
prevWasWord = true | |||
} | |||
} | |||
return string(out) | |||
} | |||
func wrapText(s string, w int) ([]string, int) { | |||
maxLine := 0 | |||
parts := splitText(s) | |||
nextLine := func() (string, int) { | |||
if parts[0][0] == '\n' { | |||
parts = parts[1:] | |||
return "", 0 | |||
} | |||
words := append([]string{}, string(parts[0])) | |||
length := len(parts[0]) | |||
parts = parts[1:] | |||
for len(parts) > 0 { | |||
p := parts[0] | |||
if p[0] == '\n' { | |||
break | |||
} | |||
if length+len(p)+1 > w { | |||
break | |||
} | |||
length += len(p) + 1 | |||
words = append(words, string(p)) | |||
parts = parts[1:] | |||
} | |||
return strings.Join(words, " "), length | |||
} | |||
lines := make([]string, 0, 2) | |||
for len(parts) > 0 { | |||
line, lineLen := nextLine() | |||
if lineLen > maxLine { | |||
maxLine = lineLen | |||
} | |||
lines = append(lines, line) | |||
} | |||
return lines, maxLine | |||
} |
@ -0,0 +1,77 @@ | |||
package tui | |||
import ( | |||
"reflect" | |||
"testing" | |||
) | |||
func TestNormalizeText(t *testing.T) { | |||
type TestCase struct { | |||
In string | |||
Out string | |||
} | |||
cases := []TestCase{ | |||
{"a", "a"}, | |||
{" a\n", "a"}, | |||
{" a\nbc\n\nd ", "a bc\n\nd"}, | |||
{" a\n b\n \n\n\tc d ", "a b\n\nc d"}, | |||
} | |||
for _, tc := range cases { | |||
out := normalizeText(tc.In) | |||
if out != tc.Out { | |||
t.Fatal(out, tc.Out) | |||
} | |||
} | |||
} | |||
func TestWrapText(t *testing.T) { | |||
type TestCase struct { | |||
In string | |||
W int | |||
Lines []string | |||
Width int | |||
} | |||
cases := []TestCase{ | |||
{ | |||
In: "The rain in spain falls mainly in the plane.", | |||
W: 9, | |||
Lines: []string{ | |||
"The rain", | |||
"in spain", | |||
"falls", | |||
"mainly in", | |||
"the", | |||
"plane.", | |||
}, | |||
Width: 9, | |||
}, { | |||
In: "The\n\nrain in spain falls mainly in the plane.", | |||
W: 9, | |||
Lines: []string{ | |||
"The", | |||
"", | |||
"rain in", | |||
"spain", | |||
"falls", | |||
"mainly in", | |||
"the", | |||
"plane.", | |||
}, | |||
Width: 9, | |||
}, | |||
} | |||
for _, tc := range cases { | |||
lines, width := wrapText(tc.In, tc.W) | |||
if !reflect.DeepEqual(lines, tc.Lines) { | |||
t.Fatalf("%#v %#v", lines, tc.Lines) | |||
} | |||
if width != tc.Width { | |||
t.Fatal(width, tc.Width) | |||
} | |||
} | |||
} |
@ -0,0 +1,48 @@ | |||
package tui | |||
import ( | |||
"io" | |||
"os" | |||
"sync" | |||
"golang.org/x/term" | |||
) | |||
var ( | |||
lock = sync.Mutex{} | |||
t *term.Terminal | |||
oldState *term.State | |||
) | |||
func Start() { | |||
lock.Lock() | |||
defer lock.Unlock() | |||
if t != nil { | |||
return | |||
} | |||
screen := struct { | |||
io.Reader | |||
io.Writer | |||
}{os.Stdin, os.Stdout} | |||
var err error | |||
oldState, err = term.MakeRaw(int(os.Stdin.Fd())) | |||
if err != nil { | |||
panic(err) | |||
} | |||
t = term.NewTerminal(screen, "") | |||
} | |||
func Stop() { | |||
lock.Lock() | |||
defer lock.Unlock() | |||
if t == nil { | |||
return | |||
} | |||
term.Restore(int(os.Stdin.Fd()), oldState) | |||
t = nil | |||
oldState = nil | |||
} |
@ -0,0 +1,23 @@ | |||
package tui | |||
import ( | |||
"os" | |||
"golang.org/x/term" | |||
) | |||
func must(err error) { | |||
if err != nil { | |||
panic(err) | |||
} | |||
} | |||
func termSize() (w, h int) { | |||
w, h, _ = term.GetSize(int(os.Stdin.Fd())) | |||
return w, h | |||
} | |||
func mustWrite(buf []byte) { | |||
_, err := t.Write(buf) | |||
must(err) | |||
} |