From a5107063102ac298a8312ac1d60f9600a80e8f6f Mon Sep 17 00:00:00 2001 From: jdl Date: Wed, 24 Mar 2021 12:21:06 +0100 Subject: [PATCH] WIP --- example/simple/main.go | 39 ++++++++++++ go.mod | 5 ++ go.sum | 4 ++ input.go | 25 ++++++++ menu.go | 61 +++++++++++++++++++ output.go | 103 +++++++++++++++++++++++++++++++ stringutil.go | 134 +++++++++++++++++++++++++++++++++++++++++ stringutil_test.go | 77 +++++++++++++++++++++++ tui.go | 48 +++++++++++++++ util.go | 23 +++++++ 10 files changed, 519 insertions(+) create mode 100644 example/simple/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 input.go create mode 100644 menu.go create mode 100644 output.go create mode 100644 stringutil.go create mode 100644 stringutil_test.go create mode 100644 tui.go create mode 100644 util.go diff --git a/example/simple/main.go b/example/simple/main.go new file mode 100644 index 0000000..bec1050 --- /dev/null +++ b/example/simple/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d65e5a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.crumpington.com/public/tui + +go 1.16 + +require golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02a0d0b --- /dev/null +++ b/go.sum @@ -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= diff --git a/input.go b/input.go new file mode 100644 index 0000000..01ee162 --- /dev/null +++ b/input.go @@ -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 + } +} diff --git a/menu.go b/menu.go new file mode 100644 index 0000000..07b4edd --- /dev/null +++ b/menu.go @@ -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 + } + } +} diff --git a/output.go b/output.go new file mode 100644 index 0000000..490f2b3 --- /dev/null +++ b/output.go @@ -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...) +} diff --git a/stringutil.go b/stringutil.go new file mode 100644 index 0000000..df9f5b4 --- /dev/null +++ b/stringutil.go @@ -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 +} diff --git a/stringutil_test.go b/stringutil_test.go new file mode 100644 index 0000000..4bcc40e --- /dev/null +++ b/stringutil_test.go @@ -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) + } + } +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..b1c655f --- /dev/null +++ b/tui.go @@ -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 +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..b4da78d --- /dev/null +++ b/util.go @@ -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) +}