master
jdl 2021-03-24 12:21:06 +01:00
parent 0045deac04
commit a510706310
10 changed files with 519 additions and 0 deletions

39
example/simple/main.go Normal file
View File

@ -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)
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module git.crumpington.com/public/tui
go 1.16
require golang.org/x/term v0.0.0-20210317153231-de623e64d2a6

4
go.sum Normal file
View File

@ -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=

25
input.go Normal file
View File

@ -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
}
}

61
menu.go Normal file
View File

@ -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
}
}
}

103
output.go Normal file
View File

@ -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...)
}

134
stringutil.go Normal file
View File

@ -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
}

77
stringutil_test.go Normal file
View File

@ -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)
}
}
}

48
tui.go Normal file
View File

@ -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
}

23
util.go Normal file
View File

@ -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)
}