Files
Lixen bc22a9bf00 v0.6.0 mouse: every wheel direction and side button named; tui: a scrollbar the pointer drives
* mouse: the wheel's four directions and the side buttons

parseSGRMouse read a report's low two bits alone, so a wheel tilted left or
right (66, 67) reported as WheelDown, and a mouse's back and forward buttons
(128, 129) as a left or middle press. It now decodes by bank: bits 0-1 number
a button within the main three and none, the wheel's four directions (bit 6),
or buttons 8 to 11 (bit 7). WheelLeft and WheelRight join MouseButton after
Forward, so every existing value keeps its number; Back and Forward are
reported for buttons 8 and 9; buttons 10 and 11, and a report with both bank
bits set, are dropped as KeyNone. A wheel step is a press with no release in
every direction.

* tui: ScrollThumb and ScrollDrag

ScrollThumb is the thumb ScrollBarStyled draws, its first row and height,
exported so an application hit-tests the bar it drew. ScrollDrag drives a bar
by the pointer, rows counted from the track's top: a press on the thumb takes
hold where it lands, a press on the track moves the thumb's middle there and
takes hold, and each drag keeps the held row under the pointer, clamped at the
track's ends, at the nearest offset when there are fewer offsets than rows.
Each call returns the offset to show.

A table of SGR reports covers each bank and drops the buttons past 9; a
property test over every track of 3 to 10 rows and content up to 30 checks
each press and drag against the thumb ScrollThumb draws.
2026-10-26 20:42:04 -04:00

558 lines
21 KiB
Go

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 press on a scrollbar's track moves the thumb's middle to it and a press on
// the thumb leaves it, either taking hold; a drag keeps the held row under the
// pointer, clamped at the ends, or as near as an offset can put it when there are
// fewer offsets than rows. A bar with no thumb, or a row off it, holds nothing.
func TestScrollDragHoldsTheThumbUnderThePointer(t *testing.T) {
near := func(tops []int, want, got int) bool {
for _, top := range tops {
if max(top-want, want-top) < max(got-want, want-got) {
return false
}
}
return true
}
for trackH := 3; trackH <= 10; trackH++ {
for total := 1; total <= 30; total++ {
for visible := 1; visible <= total+1; visible++ {
var tops []int
for o := 0; o <= total-visible; o++ {
top, _ := ScrollThumb(trackH, o, visible, total)
tops = append(tops, top)
}
for y := -1; y <= trackH; y++ {
var d ScrollDrag
from := (total - visible) / 3
fromTop, h := ScrollThumb(trackH, from, visible, total)
off := d.Press(y, trackH, from, visible, total)
top, _ := ScrollThumb(trackH, off, visible, total)
onThumb := func(top int) bool { return y >= top && y < top+h }
switch {
case h == 0 || y < 0 || y >= trackH:
if d.Active || off != from {
t.Fatalf("%d/%d/%d: a press at %d off the bar holds %v at %d", trackH, visible, total, y, d.Active, off)
}
continue
case onThumb(fromTop):
if off != from {
t.Fatalf("%d/%d/%d: a press on the thumb moved it %d to %d", trackH, visible, total, from, off)
}
case !near(tops, min(max(y-h/2, 0), trackH-h), top) || !onThumb(top) && slices.ContainsFunc(tops, onThumb):
t.Fatalf("%d/%d/%d: a press at %d starts the thumb at %d", trackH, visible, total, y, top)
}
for to := -2; to <= trackH+1; to++ {
got, _ := ScrollThumb(trackH, d.Drag(to, trackH, off, visible, total), visible, total)
if want := min(max(to-d.Grab, 0), trackH-h); !near(tops, want, got) {
t.Fatalf("%d/%d/%d: held at %d and dragged to %d, the thumb starts at %d, want near %d",
trackH, visible, total, d.Grab, to, got, want)
}
}
}
}
}
}
}
// 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)
}
}
}