package tui import ( "fmt" "runtime" "slices" "strings" "testing" "github.com/lixenwraith/color" "github.com/lixenwraith/terminal" ) // canvas returns a w x h region filled with th's panel func canvas(w, h int, th Theme) Region { r := NewRegion(make([]terminal.Cell, w*h), w, 0, 0, w, h) r.FillStyle(th.Text) return r } // row returns the runes of row y, trailing spaces trimmed func row(r Region, y int) string { var b strings.Builder for x := 0; x < r.W; x++ { b.WriteRune(r.Cells[y*r.TotalW+x].Rune) } return strings.TrimRight(b.String(), " ") } // A transparent background keeps the kind of the one beneath it: text drawn, // or a style put, on the terminal's own background or a palette color stays // on it func TestTransparentBackgroundKeepsItsKind(t *testing.T) { for _, panel := range []Style{MonoTheme.Text, {Bg: color.RGB{R: 4}, Attr: terminal.AttrBg256}} { r := canvas(4, 1, Theme{Text: panel}) r.Text(0, 0, "ab", color.RGB{R: 1}, color.RGB{}, terminal.AttrBold) if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor { t.Errorf("on %+v: %+v", panel, c) } if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr&terminal.AttrBgColor|terminal.AttrFgDefault|terminal.AttrReverse { t.Errorf("%+v on reverse: %+v", panel, on) } } } // A reverse cursor row is one bar: its pointer, key, name, hint and blanks // take one style, whatever each draws in off the row, where they keep it func TestReverseCursorRowIsOneStyle(t *testing.T) { s := NewOptionListState([]Option{{Name: "file", Hint: "rotating files", Key: 'f'}, {Name: "tcp", Key: 't'}}) s.Menu = true for name, th := range map[string]Theme{"16 colors": Theme16, "mono": MonoTheme} { r := canvas(40, 2, th) r.OptionList(s, th) for x, c := range r.Cells[:r.W] { if first := r.Cells[0]; c.Fg != first.Fg || c.Bg != first.Bg || c.Attrs != first.Attrs { t.Errorf("%s %q: column %d %+v, column 0 %+v", name, row(r, 0), x, c, first) break } } if c, a := r.Cells[r.W+2], th.Accent; c.Rune != 't' || c.Fg != a.Fg || c.Attrs&^terminal.AttrBgColor != a.Attr { t.Errorf("%s %q: the key off the cursor row %+v, want %+v", name, row(r, 1), c, a) } } } // Every widget marks its selection with a glyph, so it reads with no color func TestSelectionChangesAGlyph(t *testing.T) { for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} { selectionChangesAGlyph(t, th) } } 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){ "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) }, "option list": func(r Region, on bool) { options.Cursor = map[bool]int{true: 1}[on] r.OptionList(options, th) }, } { off, on := canvas(20, 3, th), canvas(20, 3, th) draw(off, false) draw(on, true) if row(off, 0)+row(off, 1)+row(off, 2) == row(on, 0)+row(on, 1)+row(on, 2) { t.Errorf("%s: %q either way", name, row(on, 0)+"|"+row(on, 1)) } } } // The 16-color theme names only ANSI colors and the terminal's own, which a // text console shows as it shows everything else; ThemeFor picks it func TestTheme16NamesOnlyANSIColors(t *testing.T) { ansiOrOwn := func(c color.RGB, attr, palette, own terminal.Attr, transparent bool) bool { return attr&own != 0 || attr&palette != 0 && c.R < 16 || transparent && c == (color.RGB{}) && attr&(palette|own) == 0 } for _, s := range []Style{Theme16.Text, Theme16.Muted, Theme16.Accent, Theme16.Selected, Theme16.Input, Theme16.Cursor, Theme16.Error, Theme16.Border} { if !ansiOrOwn(s.Fg, s.Attr, terminal.AttrFg256, terminal.AttrFgDefault, false) || !ansiOrOwn(s.Bg, s.Attr, terminal.AttrBg256, terminal.AttrBgDefault, true) { t.Errorf("%+v", s) } } if ThemeFor(terminal.ColorMode16).Muted != Theme16.Muted || ThemeFor(terminal.ColorModeNone).Muted != MonoTheme.Muted || ThemeFor(terminal.ColorModeTrueColor).Muted != DefaultTheme.Muted { t.Error("ThemeFor") } } // The option list matches name or hint regardless of case, a changed filter // puts the cursor on the first match, and the cursor stays on the matches func TestOptionListFilters(t *testing.T) { s := NewOptionListState([]Option{{Name: "file", Hint: "tail files"}, {Name: "Console", Hint: "stdin or stdout"}, {Name: "tcp", Hint: "listen on TCP"}, {Name: "http", Hint: "Serve HTTP"}}) for range 5 { s.HandleKey(terminal.KeyDown, 0, 0) } if i, _ := s.Chosen(); i != 3 { t.Fatalf("past the end: %d", i) } s.Paste("t") if m := s.Matches(); len(m) != 4 || s.Cursor != 0 { t.Fatalf("t: %v cursor %d", m, s.Cursor) } s.HandleKey(terminal.KeyDown, 0, 0) if s.HandleKey(terminal.KeyLeft, 0, 0); s.Cursor != 1 { t.Fatalf("filter cursor moved: list cursor %d", s.Cursor) } if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "t" || s.Cursor != 1 { t.Fatalf("nothing before the filter cursor: %q cursor %d", s.Filter.Value(), s.Cursor) } s.HandleKey(terminal.KeyRight, 0, 0) if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "" || s.Cursor != 0 { t.Fatalf("erased: %q cursor %d", s.Filter.Value(), s.Cursor) } for filter, want := range map[string]int{"TCP": 2, "cons": 1, "serve h": 3} { s.Filter.SetValue(filter) if m := s.Matches(); len(m) != 1 || m[0] != want { t.Errorf("%s: %v", filter, m) } } s.Filter.SetValue("udp") if _, ok := s.Chosen(); ok { t.Fatal("udp matched") } } // A field holds no control character, which the terminal or a file it is // saved to would act on: pasted line breaks and tabs become spaces, other // controls, pasted or typed, are dropped func TestFieldHoldsNoControls(t *testing.T) { var f TextFieldState f.Paste("a\x1b[31mb\r\nc\td\u009b") f.HandleKey(terminal.KeyRune, 0x9b, 0) f.HandleKey(terminal.KeyRune, 0x85, 0) if got := f.Value(); got != "a[31mb c d" { t.Fatalf("%q", got) } } // A field whose text the caller replaced draws and takes input without // panicking: the cursor stays within the text func TestTextInputSurvivesReplacedText(t *testing.T) { f := NewTextFieldState("abcdefghij") canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme) f.Text = []rune("ab") canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme) f.Cursor = 9 f.Paste("x") type press struct { key terminal.Key mod terminal.Modifier } ctrl := terminal.ModCtrl for cursor, keys := range map[int][]press{ 9: {{terminal.KeyBackspace, 0}, {terminal.KeyCtrlW, 0}, {terminal.KeyCtrlU, 0}, {terminal.KeyLeft, ctrl}, {terminal.KeyRight, 0}}, -1: {{terminal.KeyDelete, 0}, {terminal.KeyDelete, ctrl}, {terminal.KeyRight, ctrl}, {terminal.KeyLeft, 0}, {terminal.KeyCtrlK, 0}}, } { for _, k := range keys { f.Text, f.Cursor = []rune("a b"), cursor f.HandleKey(k.key, 0, k.mod) } } f.Text, f.Cursor = []rune("a b"), -1 f.HandleKey(terminal.KeyCtrlK, 0, 0) if got := f.Value(); got != "" { t.Fatalf("%q", got) } } // A typed field takes only its runes, typed or pasted func TestTypedFieldTakesItsRunes(t *testing.T) { f := TextFieldState{Accept: AcceptInteger} for _, r := range "4a2" { f.HandleKey(terminal.KeyRune, r, 0) } f.Paste("1 0x8") if got := f.Value(); got != "42108" { t.Fatalf("%q", got) } } // Tab and Shift+Tab move focus around the ring, wrapping either way func TestFocusRingWraps(t *testing.T) { f := Focus{Len: 3} f.HandleKey(terminal.KeyBacktab, terminal.ModShift) back := f.Index f.HandleKey(terminal.KeyTab, terminal.ModShift) shifted := f.Index for range 2 { f.HandleKey(terminal.KeyTab, 0) } if back != 2 || shifted != 1 || f.Index != 0 || f.HandleKey(terminal.KeyEnter, 0) { t.Fatalf("back %d, shifted %d, then %d", back, shifted, f.Index) } } // A field shows its error whether or not it has focus, its help only with // focus, and its value beside the label column func TestFieldShowsErrorOrHelp(t *testing.T) { f := Field{Label: "port", Required: true, Help: "listen port"} r := canvas(30, 2, DefaultTheme) value, rows := r.Field(0, 6, f, false, DefaultTheme) if rows != 1 || value.X != 9 || row(r, 0) != " port *" { t.Fatalf("unfocused: %d rows, value at %d, %q", rows, value.X, row(r, 0)) } r = canvas(30, 2, DefaultTheme) if _, rows = r.Field(0, 6, f, true, DefaultTheme); rows != 2 || row(r, 1) != " listen port" { t.Fatalf("focused: %d rows, %q", rows, row(r, 1)) } f.Error = "port: want 1-65535" r = canvas(30, 2, DefaultTheme) if _, rows = r.Field(0, 6, f, false, DefaultTheme); rows != 2 || !strings.HasSuffix(row(r, 1), "want 1-65535") { t.Fatalf("error: %d rows, %q", rows, row(r, 1)) } } // An empty input shows its default, muted, with the cursor over it; typed // text replaces it func TestTextInputShowsTheDefaultUntilTyped(t *testing.T) { var f TextFieldState r := canvas(10, 1, DefaultTheme) r.TextInput(&f, "8080", true, DefaultTheme) if c := r.Cells[1]; row(r, 0) != "8080" || c.Fg != DefaultTheme.Muted.Fg || r.Cells[0].Bg != DefaultTheme.Cursor.Bg { t.Fatalf("empty: %q %+v", row(r, 0), c) } f.HandleKey(terminal.KeyRune, '9', 0) r.TextInput(&f, "8080", true, DefaultTheme) if c := r.Cells[0]; row(r, 0) != "9" || c.Fg != DefaultTheme.Text.Fg || c.Bg != DefaultTheme.Input.Bg { t.Fatalf("typed: %q %+v", row(r, 0), c) } } // A choice moves with Left and Right, or h and l, and stops at either end func TestChoiceStopsAtTheEnds(t *testing.T) { i, _ := StepChoice(terminal.KeyRune, 'l', 0, 2) j, moved := StepChoice(terminal.KeyRight, 0, i, 2) k, _ := StepChoice(terminal.KeyLeft, 0, j, 2) if i != 1 || j != 1 || moved || k != 0 { t.Fatalf("%d %d %v %d", i, j, moved, k) } } // A folded group says what it holds; an open one shows it instead func TestGroupSummarisesWhileFolded(t *testing.T) { folded, open := canvas(30, 1, MonoTheme), canvas(30, 1, MonoTheme) folded.Group(0, "tls", "on, pinned", false, false, MonoTheme) open.Group(0, "tls", "on, pinned", true, false, MonoTheme) if row(folded, 0) != " ▸ tls on, pinned" || row(open, 0) != " ▾ tls" { 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 } // A label is drawn a column a rune, so one holding runes of more than a byte // keeps them together: each widget draws s as many times as it is given it func TestLabelsKeepTheirRunesTogether(t *testing.T) { const s = "● é · ñ" for _, c := range []struct { name string n int draw func(r Region) }{ {"pane", 1, func(r Region) { r.Pane(PaneOpts{Title: s}) }}, {"modal", 2, func(r Region) { r.Modal(ModalOpts{Title: s, Hint: s}) }}, {"divider", 1, func(r Region) { r.Divider(0, s, LineSingle, color.RGB{}) }}, {"tabs", 2, func(r Region) { r.TabBar(0, []string{s, s}, 0, DefaultTabBarOpts()) }}, {"table", 2, func(r Region) { r.Table([]string{s}, [][]string{{s}}, TableOpts{}) }}, {"form", 1, func(r Region) { r.Form(NewFormState(s), FormOpts{}) }}, {"field", 1, func(r Region) { r.TextField(NewTextFieldState(""), TextFieldOpts{Placeholder: s}) }}, {"confirm", 4, func(r Region) { r.ConfirmDialog(NewConfirmState(true), ConfirmOpts{Title: s, Message: s, YesLabel: s, NoLabel: s}) }}, {"alert", 3, func(r Region) { r.AlertDialog(AlertOpts{Title: s, Message: s, Button: s}) }}, {"progress", 1, func(r Region) { r.ProgressOverlay(DefaultProgressOpts(s, "", ProgressSpinner)) }}, } { r := canvas(48, 14, MonoTheme) c.draw(r) rows := make([]string, r.H) for y := range rows { rows[y] = row(r, y) } if got := strings.Count(strings.Join(rows, "\n"), s); got != c.n { t.Errorf("%s: %d of %d, rows %q", c.name, got, c.n, rows) } } }