WIP
parent
0045deac04
commit
a510706310
|
@ -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)
|
||||||
|
}
|
Reference in New Issue