v0.2.1 tui enhancement and better narrow layouts
This commit is contained in:
13 files changed
+577
-130
No files matched your search
+200
-1
@@ -1,6 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -50,7 +53,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
||||
func selectionChangesAGlyph(t *testing.T, th Theme) {
|
||||
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
|
||||
for name, draw := range map[string]func(r Region, on bool){
|
||||
"choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
|
||||
"radio": func(r Region, on bool) { r.Radio(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
|
||||
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
|
||||
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
|
||||
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
|
||||
@@ -248,3 +251,199 @@ func TestGroupSummarisesWhileFolded(t *testing.T) {
|
||||
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
|
||||
}
|
||||
}
|
||||
|
||||
// A radio row too wide for its region shows the chosen option and those
|
||||
// around it, a '…' on each side where others are hidden, and reports the
|
||||
// columns drawn
|
||||
func TestRadioKeepsTheChosenInView(t *testing.T) {
|
||||
options := []string{"alpha", "beta", "gamma", "delta"}
|
||||
for _, c := range []struct {
|
||||
chosen, w int
|
||||
want string
|
||||
}{
|
||||
{3, 40, "○ alpha ○ beta ○ gamma ● delta"},
|
||||
{3, 30, "… ○ beta ○ gamma ● delta"},
|
||||
{3, 16, "… ● delta"},
|
||||
{3, 7, "… ● de…"},
|
||||
{0, 17, "● alpha …"},
|
||||
{1, 19, "○ alpha ● beta …"},
|
||||
{1, 25, "… ● beta ○ gamma …"},
|
||||
} {
|
||||
r := canvas(c.w, 1, MonoTheme)
|
||||
if n := r.Radio(0, 0, options, c.chosen, MonoTheme); row(r, 0) != c.want || n != min(c.w, RuneLen(c.want)) {
|
||||
t.Errorf("%d chosen in %d columns: %q, %d drawn; want %q", c.chosen, c.w, row(r, 0), n, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A field's view pulls back when its text shrinks, so no column is left
|
||||
// blank while text is hidden, and a muted '…' marks text out of view, never
|
||||
// under the cursor
|
||||
func TestTextInputPullsBackAndMarksHiddenText(t *testing.T) {
|
||||
f := NewTextFieldState(strings.Repeat("abcdefghij", 2))
|
||||
draw := func(focused bool) string {
|
||||
r := canvas(10, 1, MonoTheme)
|
||||
r.TextInput(f, "", focused, MonoTheme)
|
||||
return row(r, 0)
|
||||
}
|
||||
if got := draw(true); got != "…cdefghij" {
|
||||
t.Fatalf("the cursor after 20 in 10 columns: %q", got)
|
||||
}
|
||||
for range 8 {
|
||||
f.HandleKey(terminal.KeyBackspace, 0, 0)
|
||||
}
|
||||
if got := draw(true); got != "…efghijab" {
|
||||
t.Fatalf("12 left: %q, scroll %d", got, f.Scroll)
|
||||
}
|
||||
f.HandleKey(terminal.KeyHome, 0, 0)
|
||||
if got := draw(true); got != "abcdefghi…" {
|
||||
t.Fatalf("the cursor at the start: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A field's label leaves the value at least half the row; label width 0
|
||||
// stacks the label on its own row, whole, and the value under it
|
||||
func TestFieldLeavesTheValueHalfTheRow(t *testing.T) {
|
||||
for _, w := range []int{12, 30, 80} {
|
||||
r := canvas(w, 3, MonoTheme)
|
||||
if v, rows := r.Field(0, 40, Field{Label: "password_file"}, false, MonoTheme); v.W < (w-3)/2 || rows != 1 {
|
||||
t.Errorf("%d columns: value %d wide, %d rows", w, v.W, rows)
|
||||
}
|
||||
r = canvas(w, 3, MonoTheme)
|
||||
v, rows := r.Field(0, 0, Field{Label: "password_file"}, false, MonoTheme)
|
||||
if label := Truncate("password_file", w-2); row(r, 0) != " "+label || v.Y != 1 || v.W != w-4 || rows != 2 {
|
||||
t.Errorf("%d columns stacked: %q, value at row %d, %d wide, %d rows", w, row(r, 0), v.Y, v.W, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text wraps at each line break, keeping an empty line, and a word longer
|
||||
// than the line breaks after a '.', ',', ':', '/', '[' or ']'
|
||||
func TestWrapTextBreaksAtLinesAndMarks(t *testing.T) {
|
||||
for text, want := range map[string][]string{
|
||||
"ab\ncd ef\n\ngh": {"ab", "cd ef", "", "gh"},
|
||||
"key: /etc/logwisp/very/long/path.pem": {"key:", "/etc/logwisp/very/", "long/path.pem"},
|
||||
} {
|
||||
if got := WrapText(text, map[bool]int{true: 5, false: 20}[strings.HasPrefix(text, "ab")]); !slices.Equal(got, want) {
|
||||
t.Errorf("%q: %q, want %q", text, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping is linear in the lines: 4000 lines allocate under 1 MiB
|
||||
func TestWrapTextIsLinearInLines(t *testing.T) {
|
||||
text := strings.Repeat("x\n", 4000)
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
lines := WrapText(text, 80)
|
||||
runtime.ReadMemStats(&after)
|
||||
if len(lines) != 4001 || after.TotalAlloc-before.TotalAlloc > 1<<20 {
|
||||
t.Fatalf("%d lines, %d bytes allocated", len(lines), after.TotalAlloc-before.TotalAlloc)
|
||||
}
|
||||
}
|
||||
|
||||
// Where the hint column would be narrow, the list shows names alone and the
|
||||
// cursor's hint wraps below them, in rows Rows counts; wide, hints sit beside
|
||||
func TestNarrowOptionListShowsTheCursorsHint(t *testing.T) {
|
||||
s := NewOptionListState([]Option{{Name: "pipe", Hint: "standard input to standard output"}, {Name: "tail", Hint: "follow files"}})
|
||||
s.Cursor = 1
|
||||
for w, want := range map[int][]string{
|
||||
60: {"/ type to filter", " pipe standard input to standard output", "▸ tail follow files"},
|
||||
20: {"/ type to filter", " pipe", "▸ tail", "", " follow files"},
|
||||
} {
|
||||
r := canvas(w, s.Rows(w), MonoTheme)
|
||||
r.OptionList(s, MonoTheme)
|
||||
var got []string
|
||||
for y := range r.H {
|
||||
got = append(got, row(r, y))
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%d columns: %q, want %q", w, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A menu has no filter: a key picks its option, and typing filters nothing
|
||||
func TestMenuPicksByKey(t *testing.T) {
|
||||
s := NewOptionListState([]Option{{Name: "run", Key: 'r'}, {Name: "command", Key: 'c'}})
|
||||
s.Menu = true
|
||||
if i, ok := s.Pick('c'); !ok || i != 1 {
|
||||
t.Fatalf("c: %d, %v", i, ok)
|
||||
}
|
||||
if _, ok := s.Pick('x'); ok || s.HandleKey(terminal.KeyRune, 'x', 0) || s.Paste("r") || s.Filter.Value() != "" {
|
||||
t.Fatalf("x picked or filtered: %q", s.Filter.Value())
|
||||
}
|
||||
r := canvas(20, s.Rows(20), MonoTheme)
|
||||
r.OptionList(s, MonoTheme)
|
||||
if r.H != 2 || row(r, 0) != "▸ r run" || row(r, 1) != " c command" {
|
||||
t.Fatalf("%d rows: %q %q", r.H, row(r, 0), row(r, 1))
|
||||
}
|
||||
}
|
||||
|
||||
// A frame's inside is the region within its border and padding, so a frame
|
||||
// sized to what it holds ends on it; the border takes the glyphs' line type
|
||||
func TestFrameHoldsItsInside(t *testing.T) {
|
||||
ascii := MonoTheme
|
||||
ascii.Glyphs = GlyphsASCII
|
||||
for _, c := range []struct {
|
||||
th Theme
|
||||
want [3]string
|
||||
}{{MonoTheme, [3]string{"┌─ T ───┐", "│ abcde │", "└───────┘"}}, {ascii, [3]string{"+- T ---+", "| abcde |", "+-------+"}}} {
|
||||
r := canvas(9, 3, c.th)
|
||||
in := r.Frame("T", c.th)
|
||||
in.TextStyled(0, 0, "abcde", c.th.Text)
|
||||
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != c.want || in.W != 5 || in.H != 1 {
|
||||
t.Errorf("%q, inside %dx%d", got, in.W, in.H)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rule keeps its title; the hint at its end goes first when both do not fit
|
||||
func TestRuleKeepsTheTitleBeforeTheHint(t *testing.T) {
|
||||
for w, want := range map[int]string{40: "── file · source ────────────── ⏎ edit ─", 24: "── file · source ───────"} {
|
||||
r := canvas(w, 1, MonoTheme)
|
||||
if r.Rule(0, "file · source", "⏎ edit", MonoTheme); row(r, 0) != want {
|
||||
t.Errorf("%d columns: %q", w, row(r, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wires join where they meet: each cell draws the arms laid through it,
|
||||
// whatever order they were laid in
|
||||
func TestWiresJoinWhereTheyMeet(t *testing.T) {
|
||||
g := NewWires(5, 3)
|
||||
g.V(4, 1, 2)
|
||||
g.H(1, 0, 4)
|
||||
g.V(2, 0, 2)
|
||||
g.H(9, 0, 9) // off the grid
|
||||
for line, want := range map[LineType][3]string{LineSingle: {" │", "──┼─┐", " │ │"}, LineASCII: {" |", "--+-+", " | |"}} {
|
||||
r := canvas(5, 3, MonoTheme)
|
||||
r.DrawWires(g, line, MonoTheme.Border)
|
||||
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != want {
|
||||
t.Errorf("%d: %q, want %q", line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A window shows the content's rows from the scroll offset, and fills what
|
||||
// the content does not reach; a window with no columns draws nothing
|
||||
func TestWindowShowsTheRowsInView(t *testing.T) {
|
||||
r := canvas(6, 3, MonoTheme)
|
||||
var v ViewportScroll
|
||||
v.SetDimensions(10, r.H)
|
||||
v.EnsureRange(7, 1)
|
||||
content := func(c Region) {
|
||||
for y := range c.H {
|
||||
c.TextStyled(0, y, fmt.Sprintf("row %d", y), MonoTheme.Text)
|
||||
}
|
||||
}
|
||||
r.Window(10, &v, MonoTheme.Text, content)
|
||||
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 5", "row 6", "row 7"} {
|
||||
t.Fatalf("rows 5-7: %q", got)
|
||||
}
|
||||
r.Window(2, &v, MonoTheme.Text, content)
|
||||
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 0", "row 1", ""} {
|
||||
t.Fatalf("two rows of content: %q", got)
|
||||
}
|
||||
r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge
|
||||
}
|
||||
Reference in new issue
Block a user