* 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.
522 lines
21 KiB
Go
522 lines
21 KiB
Go
package terminal
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/color"
|
|
)
|
|
|
|
// fakeBackend records output and replays input chunks; an empty chunk, or
|
|
// none left, is a poll timing out, and pause blocks a read as wasm's does
|
|
type fakeBackend struct {
|
|
mu sync.Mutex
|
|
out bytes.Buffer
|
|
chunks []string
|
|
pause time.Duration
|
|
gate chan struct{} // when set, a write waits until it is closed
|
|
}
|
|
|
|
func (f *fakeBackend) Init() error { return nil }
|
|
func (f *fakeBackend) Fini() {}
|
|
func (f *fakeBackend) Size() (int, int) { return 1, 1 }
|
|
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
|
|
|
func (f *fakeBackend) Write(p []byte) error {
|
|
if f.gate != nil {
|
|
<-f.gate
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.out.Write(p)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeBackend) Read(stop <-chan struct{}) ([]byte, error) {
|
|
f.mu.Lock()
|
|
if len(f.chunks) > 0 {
|
|
c := f.chunks[0]
|
|
f.chunks = f.chunks[1:]
|
|
f.mu.Unlock()
|
|
if c == "" {
|
|
time.Sleep(5 * time.Millisecond) // a poll timing out
|
|
}
|
|
time.Sleep(f.pause)
|
|
return []byte(c), nil
|
|
}
|
|
f.mu.Unlock()
|
|
select {
|
|
case <-stop:
|
|
case <-time.After(5 * time.Millisecond):
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeBackend) written() string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.out.String()
|
|
}
|
|
|
|
// Each color mode writes each kind of color as such a terminal reads it:
|
|
// 16 colors as SGR 30-37/90-97 and 40-47/100-107, restated from a reset
|
|
// since a console's bright colors set bold, a palette index by its shade,
|
|
// the terminal's own as 39/49 in every mode, no color at all in
|
|
// ColorModeNone, and elsewhere only what changed from one cell to the next
|
|
func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
|
|
red, yellow := color.RGB{R: 205}, color.RGB{R: 255, G: 255}
|
|
for _, c := range []struct {
|
|
mode ColorMode
|
|
cells []Cell
|
|
want string
|
|
}{
|
|
{ColorMode16, []Cell{{Rune: 'x', Fg: red, Bg: yellow}}, "\x1b[0;31;103mx"},
|
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Bg: color.RGB{R: 196}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;91;101mx"},
|
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 244}, Attrs: AttrFg256 | AttrBgDefault | AttrBold}}, "\x1b[0;1;90;49mx"},
|
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Attrs: AttrFg256}}, "\x1b[0;91;40mx\x1b[0;31;40my"},
|
|
{ColorMode256, []Cell{{Rune: 'x', Attrs: AttrFgDefault | AttrBgDefault}}, "\x1b[0;39;49mx"},
|
|
{ColorMode256, []Cell{{Rune: 'x', Fg: color.RGB{R: 1}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Bg: color.RGB{R: 2}, Attrs: AttrFg256 | AttrBg256},
|
|
{Rune: 'z', Fg: color.RGB{R: 3}, Bg: color.RGB{R: 4}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;38;5;1;48;5;16mx\x1b[48;5;2my\x1b[38;5;3;48;5;4mz"},
|
|
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
|
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
|
} {
|
|
o := newOutputBuffer(c.mode)
|
|
o.flush(c.cells, len(c.cells), 1)
|
|
if got, want := o.buf.String(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
|
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A cell is drawn as one glyph: a control character, C0 or C1, a format one
|
|
// (a bidi control, a zero-width space) or a line separator that a pasted
|
|
// value or a log line carried in, or no rune at all, is drawn as a space,
|
|
// never sent to the terminal
|
|
func TestCellsSendNoControlCharacters(t *testing.T) {
|
|
o := newOutputBuffer(ColorMode256)
|
|
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
|
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
|
t.Fatalf("%q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// A cell is one column: a rune a terminal draws in none (a combining mark, a
|
|
// joining jamo), in two (wide, fullwidth, emoji) or in its neighbour's cluster
|
|
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
|
|
// other as itself
|
|
func TestEachCellIsOneColumn(t *testing.T) {
|
|
o := newOutputBuffer(ColorMode256)
|
|
var row []Cell
|
|
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
|
|
row = append(row, Cell{Rune: r})
|
|
}
|
|
o.flush(row, len(row), 1)
|
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
|
|
t.Fatalf("%q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// A frame that changes no cell writes nothing, so drawing an idle screen
|
|
// again costs the terminal no bytes
|
|
func TestUnchangedFrameWritesNothing(t *testing.T) {
|
|
o := newOutputBuffer(ColorMode256)
|
|
frame := []Cell{{Rune: 'a'}, {Rune: 'é'}}
|
|
o.flush(frame, len(frame), 1)
|
|
o.buf.Reset()
|
|
if o.flush(frame, len(frame), 1); o.buf.Len() != 0 {
|
|
t.Fatalf("unchanged frame wrote %q", o.buf.String())
|
|
}
|
|
}
|
|
|
|
// A paste arrives as one event, whatever reads split it into: an escape
|
|
// inside it is text even when a poll times out on it, and a paste the
|
|
// terminal never ends ends when input pauses, whether reads time out or
|
|
// block. With paste mode off, a paste marker is no paste.
|
|
func TestPasteArrivesWhole(t *testing.T) {
|
|
defer func(d time.Duration) { pasteIdle = d }(pasteIdle)
|
|
paste := func(s string) Event { return Event{Type: EventPaste, Text: s} }
|
|
expectEvents(t, map[string]input{
|
|
"split": {chunks: []string{"a\x1b[20", "0~", "\x1b", "", "[A one\ntwo\x1b[2", "01~b"}, want: []Event{key('a'), paste("\x1b[A one\ntwo"), key('b')}},
|
|
"unended": {idle: 20 * time.Millisecond, chunks: []string{"\x1b[200~ab\x1b[2", "", "", "", "", "", "", "", "", "", "q"}, want: []Event{paste("ab\x1b[2"), key('q')}},
|
|
"blocking": {idle: 20 * time.Millisecond, pause: 40 * time.Millisecond, chunks: []string{"\x1b[200~ab", "q"}, want: []Event{paste("ab"), key('q')}},
|
|
"mode off": {off: true, chunks: []string{"\x1b[200~q"}, want: []Event{key('q')}},
|
|
})
|
|
}
|
|
|
|
// ESC ESC is Escape before a paste, in one read or split after the second
|
|
// ESC; before another sequence it is Alt and that key (rxvt's Alt+arrows);
|
|
// alone, once input pauses, Alt+Escape
|
|
func TestDoubleEscape(t *testing.T) {
|
|
esc := Event{Type: EventKey, Key: KeyEscape}
|
|
altEsc := Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
|
|
pasted := Event{Type: EventPaste, Text: "hi"}
|
|
expectEvents(t, map[string]input{
|
|
"one read": {chunks: []string{"\x1b\x1b[200~hi\x1b[201~"}, want: []Event{esc, pasted}},
|
|
"split": {chunks: []string{"\x1b\x1b", "[200~hi\x1b[201~"}, want: []Event{esc, pasted}},
|
|
"alt+up": {chunks: []string{"\x1b\x1b[A"}, want: []Event{{Type: EventKey, Key: KeyUp, Modifiers: ModAlt}}},
|
|
"alone": {chunks: []string{"\x1b\x1b"}, want: []Event{altEsc}},
|
|
})
|
|
}
|
|
|
|
func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
|
|
|
|
// parsed is what a reader sends for input arriving in chunks without pauses,
|
|
// and how many bytes it still holds back for the rest of a sequence
|
|
func parsed(chunks ...string) (events []Event, held int) {
|
|
r := &inputReader{eventCh: make(chan Event, 4096)}
|
|
r.pasteMode.Store(true)
|
|
var buf []byte
|
|
for _, c := range chunks {
|
|
buf = append(buf, c...)
|
|
buf = buf[r.parseInput(buf):]
|
|
}
|
|
close(r.eventCh)
|
|
for ev := range r.eventCh {
|
|
events = append(events, ev)
|
|
}
|
|
return events, len(buf)
|
|
}
|
|
|
|
// Every sequence the key tables hold reads as its key, and the key after it
|
|
// as itself
|
|
func TestKnownSequencesKeepTheirKeys(t *testing.T) {
|
|
for _, s := range csiSequences {
|
|
want := []Event{{Type: EventKey, Key: s.key, Modifiers: s.mod}, key('x')}
|
|
if got, _ := parsed("\x1b[" + s.seq + "x"); !slices.Equal(got, want) {
|
|
t.Errorf("ESC [ %s: got %+v, want %+v", s.seq, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// No input stops the reader: a reply of any length, Alt with Backspace or a
|
|
// character past ASCII, a malformed mouse report, or ESC [ before a byte no
|
|
// sequence holds (typed Alt+[) leave the keys after them read. An unknown or
|
|
// over-long sequence is dropped up to its final byte, by ECMA-48.
|
|
func TestNoInputStopsTheReader(t *testing.T) {
|
|
altBracket := Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt}
|
|
enter := Event{Type: EventKey, Key: KeyEnter}
|
|
for _, c := range []struct {
|
|
name string
|
|
chunks []string
|
|
want []Event
|
|
}{
|
|
{"long reply", []string{"\x1b[?65;1;9;15;18;21;22cx"}, []Event{key('x')}},
|
|
{"split reply", []string{"\x1b[?65;1;9;", "15;18;21;22", "cx"}, []Event{key('x')}},
|
|
{"longest reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen-3) + "zx"}, []Event{key('x')}},
|
|
{"over-long reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + ";2", "3zx"}, []Event{key('x')}},
|
|
{"over-long cut", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + "\rx"}, []Event{enter, key('x')}},
|
|
{"unknown final", []string{"\x1b[2^x"}, []Event{key('x')}},
|
|
{"alt+backspace", []string{"\x1b\x7fx"}, []Event{{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}, key('x')}},
|
|
{"alt+ö", []string{"\x1b\xc3", "\xb6x"}, []Event{{Type: EventKey, Key: KeyRune, Rune: 'ö', Modifiers: ModAlt}, key('x')}},
|
|
{"stray byte", []string{"\x1b\x80x"}, []Event{{Type: EventKey, Key: KeyEscape}, key('x')}},
|
|
{"alt+[ enter", []string{"\x1b[\rx"}, []Event{altBracket, enter, key('x')}},
|
|
{"alt+[ 1 é", []string{"\x1b[1éx"}, []Event{altBracket, key('1'), key('é'), key('x')}},
|
|
{"esc alt+[ enter", []string{"\x1b\x1b[\rx"}, []Event{{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}, key('['), enter, key('x')}},
|
|
{"bad mouse", []string{"\x1b[<0;1;1;1Mx"}, []Event{key('x')}},
|
|
{"mouse", []string{"\x1b[<0;5;3M\x1b[<0;5;3m"}, []Event{
|
|
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionPress},
|
|
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionRelease}}},
|
|
} {
|
|
if got, held := parsed(c.chunks...); !slices.Equal(got, c.want) || held != 0 {
|
|
t.Errorf("%s: got %+v holding %d, want %+v", c.name, got, held, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A mouse report names its button in each bank: the main three, the wheel's four
|
|
// directions, and back and forward among buttons 8 to 11, whose others, which no
|
|
// application could tell apart from the main three before, are dropped
|
|
func TestMouseReportsNameTheirButtons(t *testing.T) {
|
|
at := func(btn MouseButton, action MouseAction, mods Modifier) Event {
|
|
return Event{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: btn, MouseAction: action, Modifiers: mods}
|
|
}
|
|
for _, c := range []struct {
|
|
report string
|
|
want []Event
|
|
}{
|
|
{"\x1b[<20;5;3M", []Event{at(MouseBtnLeft, MouseActionPress, ModShift|ModCtrl)}},
|
|
{"\x1b[<34;5;3M", []Event{at(MouseBtnRight, MouseActionDrag, 0)}},
|
|
{"\x1b[<35;5;3M", []Event{at(MouseBtnNone, MouseActionMove, 0)}},
|
|
{"\x1b[<64;5;3M", []Event{at(MouseBtnWheelUp, MouseActionPress, 0)}},
|
|
{"\x1b[<65;5;3M", []Event{at(MouseBtnWheelDown, MouseActionPress, 0)}},
|
|
{"\x1b[<66;5;3M", []Event{at(MouseBtnWheelLeft, MouseActionPress, 0)}},
|
|
{"\x1b[<75;5;3M", []Event{at(MouseBtnWheelRight, MouseActionPress, ModAlt)}},
|
|
{"\x1b[<128;5;3M\x1b[<128;5;3m", []Event{at(MouseBtnBack, MouseActionPress, 0), at(MouseBtnBack, MouseActionRelease, 0)}},
|
|
{"\x1b[<129;5;3M", []Event{at(MouseBtnForward, MouseActionPress, 0)}},
|
|
{"\x1b[<130;5;3Mx", []Event{key('x')}},
|
|
{"\x1b[<192;5;3Mx", []Event{key('x')}},
|
|
} {
|
|
if got, _ := parsed(c.report); !slices.Equal(got, c.want) {
|
|
t.Errorf("%q: got %+v, want %+v", c.report, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Whatever bytes arrive, however reads split them, the reader sends the same
|
|
// events, holds back no more than ESC and the longest sequence, and holds
|
|
// nothing once four DEL, which end any sequence or character, and a paste
|
|
// end follow
|
|
func FuzzInputNeverStalls(f *testing.F) {
|
|
for _, s := range []string{"\x1b[?65;1;9;15;18;21;22cx", "\x1b\x7fx", "\x1b\xc3\xb6", "\x1b[\rx", "\x1b[<0;1;1;1Mx", "\x1b\x1b[A", "a\x1b[200~\x1b[A\x1b[201~b"} {
|
|
f.Add(s, uint(len(s)/2))
|
|
}
|
|
f.Fuzz(func(t *testing.T, in string, cut uint) {
|
|
cut %= uint(len(in)) + 1
|
|
whole, held := parsed(in)
|
|
if split, _ := parsed(in[:cut], in[cut:]); !slices.Equal(whole, split) {
|
|
t.Fatalf("%q: whole %+v, split at %d %+v", in, whole, cut, split)
|
|
}
|
|
if held > csiMaxLen+1 {
|
|
t.Fatalf("%q: holds %d bytes", in, held)
|
|
}
|
|
if _, held := parsed(in, "\x7f\x7f\x7f\x7f\x1b[201~"); held != 0 {
|
|
t.Fatalf("%q: stalled holding %d bytes", in, held)
|
|
}
|
|
})
|
|
}
|
|
|
|
// input is what a reader is given, in chunks, and the events it must send
|
|
type input struct {
|
|
off bool
|
|
idle, pause time.Duration
|
|
chunks []string
|
|
want []Event
|
|
}
|
|
|
|
func expectEvents(t *testing.T, cases map[string]input) {
|
|
t.Helper()
|
|
for name, c := range cases {
|
|
pasteIdle = cmp.Or(c.idle, time.Second)
|
|
r := newInputReader(&fakeBackend{chunks: c.chunks, pause: c.pause})
|
|
r.pasteMode.Store(!c.off)
|
|
r.start()
|
|
for _, w := range c.want {
|
|
select {
|
|
case ev := <-r.events():
|
|
if ev != w {
|
|
t.Errorf("%s: got %+v, want %+v", name, ev, w)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Errorf("%s: no event, want %+v", name, w)
|
|
}
|
|
}
|
|
r.stop()
|
|
}
|
|
}
|
|
|
|
// Paste mode set on the terminal is what its reader parses by
|
|
func TestPasteModeReachesTheReader(t *testing.T) {
|
|
f := &fakeBackend{}
|
|
term := newTerm(f, ColorMode256)
|
|
if err := term.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer term.Fini()
|
|
term.SetPasteMode(true)
|
|
f.mu.Lock()
|
|
f.chunks = append(f.chunks, "\x1b[200~x\x1b[201~")
|
|
f.mu.Unlock()
|
|
got := make(chan Event, 1)
|
|
go func() { got <- term.PollEvent() }()
|
|
select {
|
|
case ev := <-got:
|
|
if ev.Type != EventPaste || ev.Text != "x" {
|
|
t.Fatalf("%+v", ev)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("no event")
|
|
}
|
|
}
|
|
|
|
// The terminal's own bidi reordering is off while the session runs, paste
|
|
// mode ends with it, and Init clears to the terminal's own background rather
|
|
// than painting it black
|
|
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
|
f := &fakeBackend{}
|
|
term := newTerm(f, ColorModeTrueColor)
|
|
if err := term.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := term.SetPasteMode(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
term.Fini()
|
|
got := f.written()
|
|
if !strings.Contains(got, "\x1b[0m\x1b[49m\x1b[2J") || !strings.Contains(got, "\x1b[?2004h") ||
|
|
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
|
|
t.Fatalf("%q", got)
|
|
}
|
|
if i := strings.Index(got, "\x1b[8l"); i < 0 || !strings.Contains(got[i:], "\x1b[8h") {
|
|
t.Fatalf("bidi reordering not off for the session: %q", got)
|
|
}
|
|
}
|
|
|
|
// A mouse mode change writes the modes dropped as reset and the modes added
|
|
// as set, and sets the highest mode kept again, last, when another was dropped
|
|
// or added under it; SGR is on while any mode is. Whether the terminal keeps
|
|
// one mode (xterm, xterm.js, kitty), lets a set clear the others (alacritty)
|
|
// or keeps each and reports by the highest (VTE, WezTerm, Windows Terminal),
|
|
// it then reports by the highest mode asked for.
|
|
func TestMouseModeChangesKeepReporting(t *testing.T) {
|
|
c, d, m := MouseModeClick, MouseModeDrag, MouseModeMotion
|
|
for _, tc := range []struct {
|
|
from, to MouseMode
|
|
want string // +n sets mode n, -n resets it
|
|
}{
|
|
{0, 0, ""}, {0, c, "+1006 +1000"}, {0, d, "+1006 +1002"}, {0, c | d, "+1006 +1000 +1002"},
|
|
{0, m, "+1006 +1003"}, {0, c | m, "+1006 +1000 +1003"}, {0, d | m, "+1006 +1002 +1003"}, {0, c | d | m, "+1006 +1000 +1002 +1003"},
|
|
{c, 0, "-1000 -1006"}, {c, c, ""}, {c, d, "-1000 +1002"}, {c, c | d, "+1002"},
|
|
{c, m, "-1000 +1003"}, {c, c | m, "+1003"}, {c, d | m, "-1000 +1002 +1003"}, {c, c | d | m, "+1002 +1003"},
|
|
{d, 0, "-1002 -1006"}, {d, c, "-1002 +1000"}, {d, d, ""}, {d, c | d, "+1000 +1002"},
|
|
{d, m, "-1002 +1003"}, {d, c | m, "-1002 +1000 +1003"}, {d, d | m, "+1003"}, {d, c | d | m, "+1000 +1003"},
|
|
{c | d, 0, "-1002 -1000 -1006"}, {c | d, c, "-1002 +1000"}, {c | d, d, "-1000 +1002"}, {c | d, c | d, ""},
|
|
{c | d, m, "-1002 -1000 +1003"}, {c | d, c | m, "-1002 +1003"}, {c | d, d | m, "-1000 +1003"}, {c | d, c | d | m, "+1003"},
|
|
{m, 0, "-1003 -1006"}, {m, c, "-1003 +1000"}, {m, d, "-1003 +1002"}, {m, c | d, "-1003 +1000 +1002"},
|
|
{m, m, ""}, {m, c | m, "+1000 +1003"}, {m, d | m, "+1002 +1003"}, {m, c | d | m, "+1000 +1002 +1003"},
|
|
{c | m, 0, "-1003 -1000 -1006"}, {c | m, c, "-1003 +1000"}, {c | m, d, "-1003 -1000 +1002"}, {c | m, c | d, "-1003 +1002"},
|
|
{c | m, m, "-1000 +1003"}, {c | m, c | m, ""}, {c | m, d | m, "-1000 +1002 +1003"}, {c | m, c | d | m, "+1002 +1003"},
|
|
{d | m, 0, "-1003 -1002 -1006"}, {d | m, c, "-1003 -1002 +1000"}, {d | m, d, "-1003 +1002"}, {d | m, c | d, "-1003 +1000 +1002"},
|
|
{d | m, m, "-1002 +1003"}, {d | m, c | m, "-1002 +1000 +1003"}, {d | m, d | m, ""}, {d | m, c | d | m, "+1000 +1003"},
|
|
{c | d | m, 0, "-1003 -1002 -1000 -1006"}, {c | d | m, c, "-1003 -1002 +1000"}, {c | d | m, d, "-1003 -1000 +1002"}, {c | d | m, c | d, "-1003 +1002"},
|
|
{c | d | m, m, "-1002 -1000 +1003"}, {c | d | m, c | m, "-1002 +1003"}, {c | d | m, d | m, "-1000 +1003"}, {c | d | m, c | d | m, ""},
|
|
} {
|
|
term := newTerm(&fakeBackend{}, ColorMode256)
|
|
term.initialized = true // no writer runs, so what is sent stays queued
|
|
term.SetMouseMode(tc.from)
|
|
before := string(term.queued)
|
|
term.queued = nil
|
|
term.SetMouseMode(tc.to)
|
|
got := string(term.queued)
|
|
|
|
var want strings.Builder
|
|
for _, f := range strings.Fields(tc.want) {
|
|
want.WriteString("\x1b[?" + f[1:] + map[byte]string{'+': "h", '-': "l"}[f[0]])
|
|
}
|
|
if got != want.String() {
|
|
t.Errorf("%03b to %03b: %q, want %q", tc.from, tc.to, got, want.String())
|
|
}
|
|
one, alacritty, each, sgr := reportedMouseMode(before + got)
|
|
if top := highestMouseMode(tc.to); one != top || alacritty != top || each != top || sgr != (tc.to != 0) {
|
|
t.Errorf("%03b to %03b reports by %03b, %03b, %03b, SGR %t; want %03b", tc.from, tc.to, one, alacritty, each, sgr, top)
|
|
}
|
|
}
|
|
}
|
|
|
|
// reportedMouseMode replays mode sets and resets as each kind of terminal
|
|
// keeps them and returns the mode each then reports by: xterm, xterm.js and
|
|
// kitty keep the last mode set, which a reset of any turns off; alacritty's set
|
|
// clears the others and its reset only its own; VTE, WezTerm and Windows
|
|
// Terminal keep each, reporting by the highest
|
|
func reportedMouseMode(out string) (one, alacritty, each MouseMode, sgr bool) {
|
|
modes := map[string]MouseMode{"1000": MouseModeClick, "1002": MouseModeDrag, "1003": MouseModeMotion}
|
|
for _, s := range strings.Split(out, "\x1b[?")[1:] {
|
|
code, set := s[:len(s)-1], strings.HasSuffix(s, "h")
|
|
switch m := modes[code]; {
|
|
case code == "1006":
|
|
sgr = set
|
|
case set:
|
|
one, alacritty, each = m, m, each|m
|
|
default:
|
|
one, alacritty, each = 0, alacritty&^m, each&^m
|
|
}
|
|
}
|
|
return one, alacritty, highestMouseMode(each), sgr
|
|
}
|
|
|
|
func highestMouseMode(m MouseMode) MouseMode {
|
|
for m&(m-1) != 0 {
|
|
m &= m - 1
|
|
}
|
|
return m
|
|
}
|
|
|
|
// A terminal that stops reading holds the write in flight, not whoever draws:
|
|
// Flush returns while it is out, Blocked says how long it has waited, the newest
|
|
// frame held meanwhile is the one drawn once the terminal reads again, and a
|
|
// control sequence asked for after a held frame reaches the terminal after it
|
|
func TestAStoppedTerminalHoldsNoCaller(t *testing.T) {
|
|
f := &fakeBackend{gate: make(chan struct{})}
|
|
term := newTerm(f, ColorMode256)
|
|
if err := term.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitFor(t, "the first write to go out", func() bool { return term.Blocked() > 0 })
|
|
|
|
frame := func(r rune) []Cell { return []Cell{{Rune: r}} }
|
|
returned := make(chan struct{})
|
|
go func() {
|
|
term.Flush(frame('a'), 1, 1)
|
|
term.Flush(frame('b'), 1, 1)
|
|
close(returned)
|
|
}()
|
|
select {
|
|
case <-returned:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Flush waited on a terminal that stopped reading")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
if b := term.Blocked(); b < 10*time.Millisecond {
|
|
t.Fatalf("blocked %v while the write is held", b)
|
|
}
|
|
term.SetCursorVisible(true)
|
|
|
|
close(f.gate)
|
|
waitFor(t, "output to drain", func() bool { return term.Blocked() == 0 && strings.Contains(f.written(), "\x1b[?25h") })
|
|
got := f.written()
|
|
if strings.Contains(got, "a") || !strings.Contains(got, "b") {
|
|
t.Fatalf("drew %q; want the newest held frame only", got)
|
|
}
|
|
if strings.Index(got, "b") > strings.Index(got, "\x1b[?25h") {
|
|
t.Fatalf("the cursor was shown before the frame held ahead of it: %q", got)
|
|
}
|
|
term.Fini()
|
|
}
|
|
|
|
// Fini restores the termios even when the terminal never reads its last
|
|
// sequences: it stops waiting after finiDrainTimeout
|
|
func TestFiniLeavesAStoppedTerminal(t *testing.T) {
|
|
f := &fakeBackend{gate: make(chan struct{})}
|
|
defer close(f.gate)
|
|
term := newTerm(f, ColorMode256)
|
|
if err := term.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { term.Fini(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(finiDrainTimeout + 2*time.Second):
|
|
t.Fatal("Fini waited on a terminal that stopped reading")
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, what string, ok func() bool) {
|
|
t.Helper()
|
|
for deadline := time.Now().Add(2 * time.Second); !ok(); {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// A Terminal built over a caller's backend draws a flushed frame to it
|
|
func TestNewWithBackendFlushesToTheBackend(t *testing.T) {
|
|
f := &fakeBackend{}
|
|
term := NewWithBackend(f, ColorMode256)
|
|
if err := term.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer term.Fini()
|
|
if term.ColorMode() != ColorMode256 {
|
|
t.Fatalf("color mode %v", term.ColorMode())
|
|
}
|
|
term.Flush([]Cell{{Rune: 'x'}}, 1, 1)
|
|
waitFor(t, "the frame to reach the backend", func() bool { return strings.Contains(f.written(), "x") })
|
|
}
|