// Copyright 2026 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tcell
import (
"bytes"
"runtime"
"strings"
"sync"
"testing"
"time"
gencoding "github.com/gdamore/encoding"
"github.com/gdamore/tcell/v3/tty"
"github.com/gdamore/tcell/v3/vt"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// This just offers some very basic tests that do not require a full mock.
// drainInput just does a very primitive sleep to allow input to drain.
// We need this because otherwise the application will close too soon before
// consuming characters from input, including sequences that are returned in
// response to queries.
func drainInput() {
time.Sleep(time.Millisecond * 30)
}
// TestInitScreen just tries to initialize the default screen.
// It requires a working tty.
func TestInitScreen(t *testing.T) {
s, err := NewTerminfoScreen()
if err != nil {
t.Skip("failed to get screen", err)
}
if err := s.Init(); err != nil {
t.Skip("failed to initialize screen", err)
}
defer s.Fini()
if s.CharacterSet() != "UTF-8" {
t.Fatalf("Character Set (%v) not UTF-8", s.CharacterSet())
}
drainInput()
}
type spyTty struct {
vt.MockTerm
writes bytes.Buffer
}
func (t *spyTty) Write(b []byte) (int, error) {
_, _ = t.writes.Write(b)
return t.MockTerm.Write(b)
}
func (t *spyTty) Output() string {
return t.writes.String()
}
func TestOptAltScreenDisable(t *testing.T) {
t.Setenv("TCELL_ALTSCREEN", "")
tty := &spyTty{MockTerm: vt.NewMockTerm()}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
s.Fini()
out := tty.Output()
if strings.Contains(out, enterCA) {
t.Fatalf("alternate screen enter escape was emitted")
}
if strings.Contains(out, exitCA) {
t.Fatalf("alternate screen exit escape was emitted")
}
}
func TestOptAltScreenDefault(t *testing.T) {
t.Setenv("TCELL_ALTSCREEN", "")
tty := &spyTty{MockTerm: vt.NewMockTerm()}
s, err := NewTerminfoScreenFromTty(tty)
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
s.Fini()
out := tty.Output()
if !strings.Contains(out, enterCA) {
t.Fatalf("alternate screen enter escape was not emitted")
}
if !strings.Contains(out, exitCA) {
t.Fatalf("alternate screen exit escape was not emitted")
}
}
func TestOptEscapeTimeout(t *testing.T) {
tscreen := &tScreen{escapeTimeout: platformEscapeTimeout()}
OptEscapeTimeout(125 * time.Millisecond).apply(tscreen)
if got := tscreen.escapeTimeout; got != 125*time.Millisecond {
t.Fatalf("option timeout = %v, want %v", got, 125*time.Millisecond)
}
OptEscapeTimeout(0).apply(tscreen)
if got := tscreen.escapeTimeout; got != 125*time.Millisecond {
t.Fatalf("zero option changed timeout to %v, want %v", got, 125*time.Millisecond)
}
// A valid environment override lets users tune applications that do not
// explicitly pass the option.
t.Setenv("TCELL_ESCDELAY", "175")
tscreen.applyEnvironmentOverrides()
if got := tscreen.escapeTimeout; got != 175*time.Millisecond {
t.Fatalf("environment timeout = %v, want %v", got, 175*time.Millisecond)
}
}
func TestMainLoopEscapeTimeout(t *testing.T) {
evch := make(chan Event, 2)
tscreen := &tScreen{
keyQ: make(chan []byte, 2),
quit: make(chan struct{}),
resizeQ: make(chan bool),
input: newInputParser(evch),
decoder: transform.Nop,
}
stopQ := make(chan struct{})
done := make(chan struct{})
tscreen.wg.Add(1)
go func() {
tscreen.mainLoop(stopQ)
close(done)
}()
tscreen.keyQ <- []byte{'\x1b'}
deadline := time.After(time.Second)
for tscreen.input.WaitDuration() != platformEscapeTimeout() {
select {
case <-deadline:
t.Fatal("input timeout was not scheduled")
default:
time.Sleep(time.Millisecond)
}
}
// Refresh the parser timestamp before the first timer fires. This verifies
// that mainLoop re-arms its timer when parsing remains incomplete.
time.Sleep(platformEscapeTimeout() / 2)
tscreen.input.l.Lock()
tscreen.input.keyTime = time.Now()
tscreen.input.l.Unlock()
select {
case ev := <-evch:
if key, ok := ev.(*EventKey); !ok || key.Key() != KeyEscape {
t.Fatalf("expired ESC event = %T %v, want KeyEscape", ev, ev)
}
case <-time.After(platformEscapeTimeout() * 4):
t.Fatal("bare ESC did not expire")
}
// Verify that the loop clears its timer after a complete key is parsed.
tscreen.keyQ <- []byte{'A'}
select {
case ev := <-evch:
if key, ok := ev.(*EventKey); !ok || key.Str() != "A" {
t.Fatalf("plain key event = %T %v, want A", ev, ev)
}
case <-time.After(time.Second):
t.Fatal("plain key was not processed")
}
close(stopQ)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("input main loop did not stop")
}
}
// TestMainLoopKittyEscapeTimeout measures, through the real mainLoop event
// delivery path, how long a bare Escape takes to arrive when the negotiated
// keyboard protocol is not the legacy one. Terminals reach this state either
// by advertising the capability or, as WezTerm does, by having the terminal
// profile force it on. It then verifies that an escape sequence which has
// already begun still gets the longer inter-byte budget.
func TestMainLoopKittyEscapeTimeout(t *testing.T) {
evch := make(chan Event, 4)
tscreen := &tScreen{
keyQ: make(chan []byte, 2),
quit: make(chan struct{}),
resizeQ: make(chan bool),
input: newInputParser(evch),
decoder: transform.Nop,
haveKittyKbd: true,
}
// Same wiring engageLocked performs, so the mapping from the negotiated
// capability to the parser is exercised rather than assumed.
tscreen.input.SetKeyboardProtocol(tscreen.keyboardProtocol())
if tscreen.keyboardProtocol() != KittyKeyboard {
t.Fatalf("keyboard protocol = %v, want %v", tscreen.keyboardProtocol(), KittyKeyboard)
}
stopQ := make(chan struct{})
done := make(chan struct{})
tscreen.wg.Add(1)
go func() {
tscreen.mainLoop(stopQ)
close(done)
}()
// A lone ESC with nothing behind it must resolve on the bare-ESC deadline.
// The bound matches the one TestMainLoopEscapeTimeout already allows, which
// leaves a slow machine plenty of room while still catching a regression to
// the sequence deadline, since that one cannot deliver before a full second.
tooSlow := platformEscapeTimeout() * 4
start := time.Now()
tscreen.keyQ <- []byte{'\x1b'}
select {
case ev := <-evch:
elapsed := time.Since(start)
if key, ok := ev.(*EventKey); !ok || key.Key() != KeyEscape {
t.Fatalf("expired ESC event = %T %v, want KeyEscape", ev, ev)
}
if elapsed > tooSlow {
t.Fatalf("bare ESC took %v, want no more than %v", elapsed, tooSlow)
}
if elapsed < platformEscapeTimeout()/2 {
t.Fatalf("bare ESC took %v, want at least %v", elapsed, platformEscapeTimeout()/2)
}
case <-time.After(escapeSequenceTimeout * 2):
t.Fatal("bare ESC did not expire")
}
// An escape sequence that has already started keeps the longer budget, so a
// stall past the bare-ESC deadline must not split it into a bare Escape.
tscreen.keyQ <- []byte{'\x1b', '['}
deadline := time.After(time.Second)
for tscreen.input.WaitDuration() != escapeSequenceTimeout {
select {
case <-deadline:
t.Fatal("CSI timeout was not scheduled")
default:
time.Sleep(time.Millisecond)
}
}
time.Sleep(platformEscapeTimeout() * 2)
select {
case ev := <-evch:
t.Fatalf("started sequence expired at the bare-ESC deadline: %v", ev)
default:
}
tscreen.keyQ <- []byte{'A'}
select {
case ev := <-evch:
if key, ok := ev.(*EventKey); !ok || key.Key() != KeyUp {
t.Fatalf("delayed sequence event = %T %v, want KeyUp", ev, ev)
}
case <-time.After(time.Second):
t.Fatal("delayed sequence was not processed")
}
close(stopQ)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("input main loop did not stop")
}
}
func TestFiniPreventsSetStyleMutation(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt)
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
before := StyleDefault.Foreground(ColorRed)
after := StyleDefault.Foreground(ColorBlue)
scr.SetStyle(before)
scr.Fini()
scr.SetStyle(after)
ts.Lock()
defer ts.Unlock()
if !ts.fini {
t.Fatal("screen was not marked finished")
}
if ts.style != before {
t.Fatal("SetStyle mutated the screen after Fini")
}
}
func TestInitWhileInitializedFailsWithoutReplacingEventQ(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
scr, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
eventQ := scr.EventQ()
if err := scr.Init(); err == nil {
t.Fatal("second Init succeeded while screen was initialized")
}
if scr.EventQ() != eventQ {
t.Fatal("failed Init replaced the event queue")
}
}
func TestInitAfterFiniFails(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
scr, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
scr.Fini()
if err := scr.Init(); err == nil {
t.Fatal("Init succeeded after Fini")
}
}
func TestConcurrentFiniIsIdempotent(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
scr, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
scr.Fini()
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("concurrent Fini calls did not return")
}
}
func TestConcurrentInitAndFini(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
scr, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
start := make(chan struct{})
finiDone := make(chan struct{})
initDone := make(chan error, 1)
go func() {
<-start
scr.Fini()
close(finiDone)
}()
go func() {
<-start
initDone <- scr.Init()
}()
close(start)
select {
case err := <-initDone:
if err == nil {
t.Fatal("concurrent Init succeeded")
}
case <-time.After(time.Second):
t.Fatal("concurrent Init did not return")
}
select {
case <-finiDone:
case <-time.After(time.Second):
t.Fatal("concurrent Fini did not return")
}
}
func TestFiniPreventsFurtherTerminalMutation(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
scr, err := NewTerminfoScreenFromTty(tty)
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
scr.ShowCursor(1, 1)
scr.SetCursorStyle(CursorStyleSteadyBlock, ColorRed)
scr.EnableMouse()
scr.EnablePaste()
scr.EnableFocus()
scr.Fini()
beforeOutput := tty.Output()
ts.Lock()
beforeCursorX, beforeCursorY := ts.cursorx, ts.cursory
beforeCursorStyle, beforeCursorColor := ts.cursorStyle, ts.cursorColor
beforeMouseFlags := ts.mouseFlags
beforePasteEnabled, beforeFocusEnabled := ts.pasteEnabled, ts.focusEnabled
ts.Unlock()
scr.ShowCursor(2, 1)
scr.SetCursorStyle(CursorStyleBlinkingBar, ColorBlue)
scr.EnableMouse(MouseMotionEvents)
scr.DisableMouse()
scr.EnablePaste()
scr.DisablePaste()
scr.EnableFocus()
scr.DisableFocus()
scr.SetSize(20, 10)
if err := scr.Suspend(); err != nil {
t.Fatalf("Suspend after Fini failed: %v", err)
}
if err := scr.Resume(); err != nil {
t.Fatalf("Resume after Fini failed: %v", err)
}
if err := scr.Beep(); err != nil {
t.Fatalf("Beep after Fini failed: %v", err)
}
scr.SetTitle("after")
scr.SetClipboard([]byte("after"))
scr.GetClipboard()
scr.ShowNotification("after", "after")
if got := tty.Output(); got != beforeOutput {
t.Fatal("terminal output changed after Fini")
}
ts.Lock()
defer ts.Unlock()
if ts.cursorx != beforeCursorX || ts.cursory != beforeCursorY {
t.Fatal("cursor position changed after Fini")
}
if ts.cursorStyle != beforeCursorStyle || ts.cursorColor != beforeCursorColor {
t.Fatal("cursor style changed after Fini")
}
if ts.mouseFlags != beforeMouseFlags {
t.Fatal("mouse flags changed after Fini")
}
if ts.pasteEnabled != beforePasteEnabled {
t.Fatal("paste state changed after Fini")
}
if ts.focusEnabled != beforeFocusEnabled {
t.Fatal("focus state changed after Fini")
}
}
func TestFilterEventsStopsWhileEventQBlocked(t *testing.T) {
ts := &tScreen{
quit: make(chan struct{}),
eventQ: make(chan Event),
}
inQ := ts.filterEvents()
inQ <- NewEventResize(1, 1)
done := make(chan struct{})
go func() {
ts.eventWg.Wait()
close(done)
}()
select {
case <-done:
t.Fatal("filterEvents exited before shutdown")
case <-time.After(10 * time.Millisecond):
}
close(ts.quit)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("filterEvents did not stop after shutdown")
}
}
func TestOptAdvancedKeys(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptAdvancedKeys(true))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if !ts.advancedKeys {
t.Fatal("advanced keys option was not applied")
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
if !ts.input.advanced {
t.Fatal("advanced keys option was not propagated to input parser")
}
}
func TestOptKeyboardProtocol(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptKeyboardProtocol(KittyKeyboard))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if !ts.forceKbd || ts.forcedKbd != KittyKeyboard {
t.Fatalf("forced keyboard protocol = (%v, %v), want (true, %v)", ts.forceKbd, ts.forcedKbd, KittyKeyboard)
}
}
func TestOptNegotiation(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptNegotiation(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if ts.negotiate {
t.Fatal("negotiation option was not applied")
}
}
func TestSetSizeTakesScreenLock(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
ts := &tScreen{tty: mt, w: 8, h: 2}
done := make(chan struct{})
ts.Lock()
go func() {
ts.SetSize(8, 2)
close(done)
}()
select {
case <-done:
ts.Unlock()
t.Fatal("SetSize returned while the screen lock was held")
case <-time.After(10 * time.Millisecond):
}
ts.Unlock()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("SetSize did not return after the screen lock was released")
}
}
func TestOptControlStringLimit(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptControlStringLimit(4096))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if ts.controlStringLimit != 4096 {
t.Fatalf("control string limit = %d, want %d", ts.controlStringLimit, 4096)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
if ts.input.controlStringMax != 4096 {
t.Fatalf("input parser control string limit = %d, want %d", ts.input.controlStringMax, 4096)
}
}
func TestOptControlStringLimitUnlimited(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptControlStringLimit(0))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
bs, ok := scr.(*baseScreen)
if !ok {
t.Fatalf("expected *baseScreen, got %T", scr)
}
ts, ok := bs.screenImpl.(*tScreen)
if !ok {
t.Fatalf("expected *tScreen, got %T", bs.screenImpl)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
payload := bytes.Repeat([]byte{'x'}, defaultControlStringLimit+1)
ts.input.ScanUTF8(append([]byte("\x1b]"), payload...))
if ts.input.state != istOsc {
t.Fatalf("parser state = %v, want %v", ts.input.state, istOsc)
}
if !bytes.Equal(ts.input.strBuf, payload) {
t.Fatalf("string buffer length = %d, want %d", len(ts.input.strBuf), len(payload))
}
}
func TestOptSanitizeContent(t *testing.T) {
t.Run("disabled by default", func(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt)
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
scr.PutStr(0, 0, "\x1bA\x07B")
if got, _, _ := scr.Get(0, 0); !strings.Contains(got, "\x1b") {
t.Fatalf("expected control bytes to remain when sanitizer is disabled, got %q", got)
}
if got, _, _ := scr.Get(1, 0); !strings.Contains(got, "\x07") {
t.Fatalf("expected control bytes to remain when sanitizer is disabled, got %q", got)
}
})
t.Run("enabled", func(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})
scr, err := NewTerminfoScreenFromTty(mt, OptSanitizeContent(true))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
scr.PutStr(0, 0, "\x1bA\x07B")
if got, _, _ := scr.Get(0, 0); got != "A" {
t.Fatalf("unexpected sanitized cell content at 0,0: %q", got)
}
if got, _, _ := scr.Get(1, 0); got != "B" {
t.Fatalf("unexpected sanitized cell content at 1,0: %q", got)
}
})
}
func TestNewScreenSanitizeContentOption(t *testing.T) {
scr, err := NewScreen(OptSanitizeContent(true))
if err != nil {
t.Skipf("failed to get screen: %v", err)
}
if err := scr.Init(); err != nil {
t.Skipf("failed to initialize screen: %v", err)
}
defer scr.Fini()
scr.PutStr(0, 0, "\x1bA\x07B")
if got, _, _ := scr.Get(0, 0); got != "A" {
t.Fatalf("unexpected sanitized cell content at 0,0: %q", got)
}
if got, _, _ := scr.Get(1, 0); got != "B" {
t.Fatalf("unexpected sanitized cell content at 1,0: %q", got)
}
}
func TestNewScreenShimScreen(t *testing.T) {
_, scr := NewMockScreen(t)
ShimScreen(scr)
got, err := NewScreen()
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if got != scr {
t.Fatalf("unexpected shimmed screen: got %T, want %T", got, scr)
}
}
func TestUnlockRegionRedrawsUntouchedBlankCell(t *testing.T) {
mt, scr := NewMockScreen(t, vt.MockOptSize{X: 1, Y: 1})
defer scr.Fini()
mt.Backend().Put(vt.Coord{X: 0, Y: 0}, vt.Cell{C: "X", S: vt.BaseStyle, W: 1})
scr.LockRegion(0, 0, 1, 1, true)
scr.LockRegion(0, 0, 1, 1, false)
scr.Show()
if got := mt.GetCell(vt.Coord{X: 0, Y: 0}); got.C != " " || got.W != 1 {
t.Fatalf("unlocking an untouched blank cell should repaint a space, got %#v", got)
}
}
// After the screen is cleared, the terminal shows blank cells, not spaces.
// Some terminals treat the two differently. kitty, for example, draws a
// private use symbol such as a Nerd Font icon over two cells only if an
// actual space follows it. So the first frame after clearing the screen
// must write the spaces it puts, both at startup and after resuming.
func TestSpacesAreDrawnAfterClearingTheScreen(t *testing.T) {
t.Setenv("LC_ALL", "en_US.UTF-8")
frame := func(scr Screen) {
scr.Clear()
scr.Put(0, 0, "\uf313", StyleDefault)
scr.Put(1, 0, " ", StyleDefault)
scr.Put(2, 0, "a", StyleDefault)
scr.Show()
}
expectSpaceAfterSymbol := func(t *testing.T, mt vt.MockTerm) {
t.Helper()
if got := mt.GetCell(vt.Coord{X: 1}); got.C != " " {
t.Fatalf("terminal shows %q after the symbol, want a space", got.C)
}
}
t.Run("startup", func(t *testing.T) {
mt, scr := NewMockScreen(t, vt.MockOptSize{X: 3, Y: 1})
defer scr.Fini()
frame(scr)
expectSpaceAfterSymbol(t, mt)
})
t.Run("resume", func(t *testing.T) {
mt, scr := NewMockScreen(t, vt.MockOptSize{X: 3, Y: 1})
defer scr.Fini()
frame(scr)
if err := scr.Suspend(); err != nil {
t.Fatalf("failed to suspend screen: %v", err)
}
if err := scr.Resume(); err != nil {
t.Fatalf("failed to resume screen: %v", err)
}
frame(scr)
expectSpaceAfterSymbol(t, mt)
})
}
// Redrawing an unchanged wide character after Clear, as immediate-mode
// apps do every frame, must not send it again; a changed one must be sent.
func TestWideCharAfterClear(t *testing.T) {
t.Setenv("LC_ALL", "en_US.UTF-8")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 2})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
frame := func(wide string) string {
before := len(tty.Output())
s.Clear()
s.Put(0, 0, wide, StyleDefault)
s.Put(2, 0, "a", StyleDefault)
s.Show()
return tty.Output()[before:]
}
frame("你")
if out := frame("你"); strings.Contains(out, "你") {
t.Fatalf("unchanged wide character was sent again: %q", out)
}
if out := frame("好"); !strings.Contains(out, "好") {
t.Fatalf("changed wide character was not sent: %q", out)
}
if got := tty.GetCell(vt.Coord{}).C; got != "好" {
t.Fatalf("terminal shows %q, want %q", got, "好")
}
if got := tty.GetCell(vt.Coord{X: 2}).C; got != "a" {
t.Fatalf("terminal shows %q after the wide character, want %q", got, "a")
}
}
// Content the UTF-8 fast path must not emit raw still goes through the
// encoder and comes out as "?". Put strips SUB, but Fill does not.
func TestUnencodableContentDrawsFallback(t *testing.T) {
t.Setenv("LANG", "en_US.UTF-8")
t.Setenv("LC_ALL", "en_US.UTF-8")
for _, tc := range []struct {
str string
draw func(Screen)
}{
{"\xff", func(s Screen) { s.Put(0, 0, "\xff", StyleDefault) }},
{"\xe2\x82", func(s Screen) { s.Put(0, 0, "\xe2\x82", StyleDefault) }},
{"\x1a", func(s Screen) { s.Fill('\x1a', StyleDefault) }},
} {
str := tc.str
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
if s.CharacterSet() != "UTF-8" {
t.Fatalf("Character Set (%v) not UTF-8", s.CharacterSet())
}
tc.draw(s)
s.Show()
s.Fini()
if out := tty.Output(); strings.Contains(out, str) {
t.Errorf("%q was emitted raw: %q", str, out)
}
if got := string(tty.GetCell(vt.Coord{}).C); got != "?" {
t.Errorf("%q drew %q, want %q", str, got, "?")
}
}
}
func TestIsASCIISuperset(t *testing.T) {
for _, tc := range []struct {
name string
enc encoding.Encoding
want bool
}{
{"ASCII", gencoding.ASCII, true},
{"ISO8859-1", gencoding.ISO8859_1, true},
{"KOI8-R", charmap.KOI8R, true},
{"Shift_JIS", japanese.ShiftJIS, true},
{"GB18030", simplifiedchinese.GB18030, true},
{"EBCDIC", gencoding.EBCDIC, false},
{"HZ-GB2312", simplifiedchinese.HZGB2312, false}, // "~" encodes as "~~"
} {
if got := isASCIISuperset(tc.enc); got != tc.want {
t.Errorf("%s: isASCIISuperset = %v, want %v", tc.name, got, tc.want)
}
}
}
// On an ASCII screen, ASCII skips the encoder but must come out exactly as
// the encoder would emit it.
func TestASCIIScreenDrawsLikeEncoder(t *testing.T) {
t.Setenv("LC_ALL", "C")
if getCharset() != "US-ASCII" {
t.Skip("no ASCII locale on this platform") // Windows and Plan 9 are always UTF-8
}
for _, tc := range []struct {
str, want string
draw func(Screen)
}{
{"x", "x", func(s Screen) { s.Put(0, 0, "x", StyleDefault) }},
{"é", "?", func(s Screen) { s.Put(0, 0, "é", StyleDefault) }},
{"─", "\x0eq\x0f", func(s Screen) { s.Put(0, 0, "─", StyleDefault) }},
{"\x1a", "?", func(s Screen) { s.Fill('\x1a', StyleDefault) }},
} {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
if s.CharacterSet() != "US-ASCII" {
t.Fatalf("Character Set (%v) not US-ASCII", s.CharacterSet())
}
tc.draw(s)
s.Show()
s.Fini()
if out := tty.Output(); !strings.Contains(out, "\x1b[1;1H\x1b[m"+tc.want) {
t.Errorf("%q: want %q emitted, got %q", tc.str, tc.want, out)
}
}
}
func TestOSC8ControlsAreStrippedFromOutput(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
style := StyleDefault.
Url("http://exa\x07mple.com/\x1b\\path").
UrlId("id\x00\x1f\x7f\x80\x9fend")
s.PutStrStyled(0, 0, "X", style)
s.Show()
out := tty.Output()
const prefix = "\x1b]8;id=idend;"
_, link, ok := strings.Cut(out, prefix)
if !ok {
t.Fatalf("missing OSC 8 link open sequence in output: %q", out)
}
link, _, ok = strings.Cut(link, "\x1b\\")
if !ok {
t.Fatalf("missing OSC 8 terminator in output: %q", out)
}
if link != "http://example.com/\\path" {
t.Fatalf("unexpected emitted URL payload: %q", link)
}
if _, afterLink, ok := strings.Cut(out, "X"); !ok || !strings.Contains(afterLink, "\x1b]8;;\x1b\\") {
t.Fatalf("missing OSC 8 link close sequence after linked content: %q", out)
}
for i := 0; i < len(link); i++ {
c := link[i]
if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) {
t.Fatalf("control characters survived in emitted URL payload: %q", link)
}
}
}
func TestOSC8IdWithoutUrlDoesNotEmitClose(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
tty.writes.Reset()
s.PutStrStyled(0, 0, "X", StyleDefault.UrlId("orphan"))
s.Show()
if out := tty.Output(); strings.Contains(out, "\x1b]8;;\x1b\\") {
t.Fatalf("unexpected OSC 8 close sequence for id-only style: %q", out)
}
}
func TestKeyboardProtocol(t *testing.T) {
tests := []struct {
name string
setup func(*tScreen)
want KeyProtocol
}{
{
name: "Legacy",
want: LegacyKeyboard,
},
{
name: "Xterm",
setup: func(s *tScreen) {
s.haveXTermKbd = true
},
want: XTermKeyboard,
},
{
name: "Kitty",
setup: func(s *tScreen) {
s.haveKittyKbd = true
},
want: KittyKeyboard,
},
{
name: "Win32",
setup: func(s *tScreen) {
s.haveWin32Kbd = true
},
want: Win32Keyboard,
},
{
name: "KittyBeforeXterm",
setup: func(s *tScreen) {
s.haveKittyKbd = true
s.haveXTermKbd = true
},
want: KittyKeyboard,
},
{
name: "Win32BeforeKittyAndXterm",
setup: func(s *tScreen) {
s.haveWin32Kbd = true
s.haveKittyKbd = true
s.haveXTermKbd = true
},
want: Win32Keyboard,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &tScreen{}
if tt.setup != nil {
tt.setup(s)
}
if got := s.KeyboardProtocol(); got != tt.want {
t.Fatalf("KeyboardProtocol() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplyKnownTerminalProfile(t *testing.T) {
tests := []struct {
name string
goos string
termProgram string
wantKnown bool
wantInitted bool
wantMouse bool
wantMouseSgr bool
wantKittyKbd bool
wantWin32Kbd bool
term string
wantLegacy bool
}{
{
name: "AppleTerminal",
goos: "darwin",
term: "xterm-256color",
termProgram: "Apple_Terminal",
wantKnown: true,
wantMouse: true,
wantMouseSgr: true,
},
{
name: "LocalWindowsWezTerm",
goos: "windows",
term: "wezterm",
termProgram: "WezTerm",
wantKnown: true,
wantInitted: true,
wantMouse: true,
wantMouseSgr: true,
wantWin32Kbd: true,
},
{
name: "LocalUnixWezTerm",
goos: "linux",
term: "wezterm",
termProgram: "WezTerm",
wantKnown: true,
wantInitted: true,
wantMouse: true,
wantMouseSgr: true,
wantKittyKbd: true,
},
{
name: "UnknownTerminal",
goos: "linux",
term: "xterm-256color",
termProgram: "Other",
},
{
name: "St",
goos: "linux",
term: "st",
wantKnown: true,
wantLegacy: true,
wantMouse: true,
wantMouseSgr: true,
},
{
name: "St256Color",
goos: "linux",
term: "st-256color",
wantKnown: true,
wantLegacy: true,
wantMouse: true,
wantMouseSgr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &tScreen{}
if got := s.applyKnownTerminalProfile(tt.goos, tt.term, tt.termProgram); got != tt.wantKnown {
t.Fatalf("applyKnownTerminalProfile() = %v, want %v", got, tt.wantKnown)
}
if s.initted != tt.wantInitted {
t.Fatalf("initted = %v, want %v", s.initted, tt.wantInitted)
}
if s.haveMouse != tt.wantMouse {
t.Fatalf("haveMouse = %v, want %v", s.haveMouse, tt.wantMouse)
}
if s.haveMouseSgr != tt.wantMouseSgr {
t.Fatalf("haveMouseSgr = %v, want %v", s.haveMouseSgr, tt.wantMouseSgr)
}
if s.haveKittyKbd != tt.wantKittyKbd {
t.Fatalf("haveKittyKbd = %v, want %v", s.haveKittyKbd, tt.wantKittyKbd)
}
if s.haveWin32Kbd != tt.wantWin32Kbd {
t.Fatalf("haveWin32Kbd = %v, want %v", s.haveWin32Kbd, tt.wantWin32Kbd)
}
if s.legacy != tt.wantLegacy {
t.Fatalf("legacy = %v, want %v", s.legacy, tt.wantLegacy)
}
})
}
}
func TestStProfileSkipsUnsupportedOperations(t *testing.T) {
t.Setenv("TERM", "st-256color")
t.Setenv("TERM_PROGRAM", "")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
s.EnableMouse()
s.EnableFocus()
out := tty.Output()
for _, seq := range []string{
requestWindowSize,
vt.PmResizeReports.Query(),
vt.PmMouseButton.Query(),
vt.PmMouseSgr.Query(),
vt.PmWin32Input.Query(),
queryKittyKbd,
queryXTermKbd,
requestExtAttr,
// st does not implement pixel-precision (SGR-pixel) mouse
// reporting, so neither the enable nor the disable sequence for
// mode 1016 should ever be sent to it.
vt.PmMouseSgrPixel.Enable(),
vt.PmMouseSgrPixel.Disable(),
} {
if strings.Contains(out, seq) {
t.Fatalf("st profile emitted unsupported sequence %q", seq)
}
}
if !strings.Contains(out, requestPrimaryDA) {
t.Fatal("st profile did not emit primary DA")
}
// st does implement ordinary SGR mouse (1006) and focus (1004)
// reporting, so tcell should enable them rather than suppressing them.
for _, seq := range []string{
vt.PmMouseButton.Enable(),
vt.PmMouseSgr.Enable(),
vt.PmFocusReports.Enable(),
} {
if !strings.Contains(out, seq) {
t.Fatalf("st profile did not emit expected sequence %q", seq)
}
}
name, version := s.Terminal()
if name != "st" || version != "" {
t.Fatalf("Terminal() = %q, %q, want %q, %q", name, version, "st", "")
}
tScreen := s.(*baseScreen).screenImpl.(*tScreen)
start := len(tty.Output())
tScreen.emitUnderline(UnderlineStyleCurly, ColorDefault)
if got := tty.Output()[start:]; got != underline {
t.Fatalf("st underline escape = %q, want %q", got, underline)
}
start = len(tty.Output())
s.SetTitle("title")
s.GetClipboard()
s.ShowNotification("title", "body")
if got, want := tty.Output()[start:], "\x1b]2;title\x1b\\"; got != want {
t.Fatalf("st title, clipboard and notification escapes = %q, want %q", got, want)
}
}
func TestAppleTerminalProfileSkipsStartupQueries(t *testing.T) {
t.Setenv("TERM_PROGRAM", "Apple_Terminal")
t.Setenv("TERM_PROGRAM_VERSION", "999")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
out := tty.Output()
for _, seq := range []string{
vt.PmResizeReports.Query(),
vt.PmMouseButton.Query(),
vt.PmMouseSgr.Query(),
vt.PmWin32Input.Query(),
queryKittyKbd,
queryXTermKbd,
requestExtAttr,
} {
if strings.Contains(out, seq) {
t.Fatalf("Apple Terminal profile emitted startup query %q", seq)
}
}
if !strings.Contains(out, requestPrimaryDA) {
t.Fatal("Apple Terminal profile did not emit primary DA")
}
name, version := s.Terminal()
if name != "Terminal.app" || version != "999" {
t.Fatalf("Terminal() = %q, %q, want %q, %q", name, version, "Terminal.app", "999")
}
}
func TestKeyboardProbePolicy(t *testing.T) {
tests := []struct {
name string
goos string
wantWindowSizeQuery bool
wantXTermQuery bool
}{
{
name: "Unix",
goos: "linux",
wantWindowSizeQuery: true,
wantXTermQuery: true,
},
{
name: "Windows",
goos: "windows",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := useVTWindowSizeQuery(tt.goos); got != tt.wantWindowSizeQuery {
t.Fatalf("useVTWindowSizeQuery() = %v, want %v", got, tt.wantWindowSizeQuery)
}
if got := useXTermKeyboardQuery(tt.goos); got != tt.wantXTermQuery {
t.Fatalf("useXTermKeyboardQuery() = %v, want %v", got, tt.wantXTermQuery)
}
})
}
}
func TestKeyboardProtocolHelpers(t *testing.T) {
tests := []struct {
name string
value string
want KeyProtocol
wantValid bool
}{
{name: "legacy", value: "legacy", want: LegacyKeyboard, wantValid: true},
{name: "kitty", value: "kitty", want: KittyKeyboard, wantValid: true},
{name: "win32", value: "win32", want: Win32Keyboard, wantValid: true},
{name: "xterm", value: "xterm", want: XTermKeyboard, wantValid: true},
{name: "invalid", value: "bogus"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseKeyboardProtocol(tt.value)
if got != tt.want || ok != tt.wantValid {
t.Fatalf("parseKeyboardProtocol(%q) = (%v, %v), want (%v, %v)", tt.value, got, ok, tt.want, tt.wantValid)
}
})
}
if validKeyboardProtocol(KeyProtocol(99)) {
t.Fatal("invalid keyboard protocol was accepted")
}
}
func TestForceKeyboardProtocol(t *testing.T) {
s := &tScreen{
haveKittyKbd: true,
haveWin32Kbd: true,
haveXTermKbd: true,
}
if s.forceKeyboardProtocol(KeyProtocol(99)) {
t.Fatal("invalid keyboard protocol was forced")
}
if s.forceKbd {
t.Fatal("invalid keyboard protocol changed override state")
}
if !s.forceKeyboardProtocol(XTermKeyboard) {
t.Fatal("valid keyboard protocol was not forced")
}
s.applyKeyboardProtocolOverride()
if s.haveKittyKbd || s.haveWin32Kbd || !s.haveXTermKbd {
t.Fatalf("forced XTerm protocol state = kitty:%v win32:%v xterm:%v", s.haveKittyKbd, s.haveWin32Kbd, s.haveXTermKbd)
}
}
func TestApplyEnvironmentOverrides(t *testing.T) {
tests := []struct {
name string
keyboardProtocol string
negotiate string
mouse string
startForceKbd bool
startForcedKbd KeyProtocol
startNegotiate bool
wantForceKbd bool
wantForcedKbd KeyProtocol
wantNegotiate bool
wantMouseOff bool
}{
{
name: "defaults",
startNegotiate: true,
wantNegotiate: true,
wantForcedKbd: LegacyKeyboard,
},
{
name: "force all",
keyboardProtocol: "win32",
negotiate: "disable",
mouse: "disable",
startNegotiate: true,
wantForceKbd: true,
wantForcedKbd: Win32Keyboard,
wantMouseOff: true,
},
{
name: "auto resets options",
keyboardProtocol: "auto",
negotiate: "auto",
startForceKbd: true,
startForcedKbd: KittyKeyboard,
wantNegotiate: true,
wantForcedKbd: KittyKeyboard,
},
{
name: "invalid leaves options",
keyboardProtocol: "bogus",
negotiate: "bogus",
startForceKbd: true,
startForcedKbd: XTermKeyboard,
startNegotiate: false,
wantForceKbd: true,
wantForcedKbd: XTermKeyboard,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TCELL_KEYBOARD_PROTOCOL", tt.keyboardProtocol)
t.Setenv("TCELL_NEGOTIATE", tt.negotiate)
t.Setenv("TCELL_MOUSE", tt.mouse)
s := &tScreen{
forceKbd: tt.startForceKbd,
forcedKbd: tt.startForcedKbd,
negotiate: tt.startNegotiate,
}
s.applyEnvironmentOverrides()
if s.forceKbd != tt.wantForceKbd {
t.Fatalf("forceKbd = %v, want %v", s.forceKbd, tt.wantForceKbd)
}
if s.forcedKbd != tt.wantForcedKbd {
t.Fatalf("forcedKbd = %v, want %v", s.forcedKbd, tt.wantForcedKbd)
}
if s.negotiate != tt.wantNegotiate {
t.Fatalf("negotiate = %v, want %v", s.negotiate, tt.wantNegotiate)
}
if s.mouseDisabled != tt.wantMouseOff {
t.Fatalf("mouseDisabled = %v, want %v", s.mouseDisabled, tt.wantMouseOff)
}
})
}
}
func TestForcedKeyboardProtocolSkipsKeyboardQueries(t *testing.T) {
t.Setenv("TERM_PROGRAM", "")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptKeyboardProtocol(KittyKeyboard), OptAdvancedKeys(true), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
out := tty.Output()
for _, seq := range []string{vt.PmWin32Input.Query(), queryKittyKbd, queryXTermKbd} {
if strings.Contains(out, seq) {
t.Fatalf("forced keyboard protocol emitted competing query %q", seq)
}
}
for _, seq := range []string{vt.PmResizeReports.Query(), vt.PmMouseButton.Query(), vt.PmMouseSgr.Query(), requestExtAttr} {
if !strings.Contains(out, seq) {
t.Fatalf("forced keyboard protocol suppressed unrelated query %q", seq)
}
}
if !strings.Contains(out, enableKittyKbdAdv) {
t.Fatalf("forced Kitty protocol did not emit advanced enable sequence")
}
}
func TestNegotiationDisabledSkipsStartupQueries(t *testing.T) {
t.Setenv("TERM_PROGRAM", "")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
out := tty.Output()
for _, seq := range []string{
requestWindowSize,
vt.PmResizeReports.Query(),
vt.PmMouseButton.Query(),
vt.PmMouseSgr.Query(),
vt.PmWin32Input.Query(),
queryKittyKbd,
queryXTermKbd,
requestExtAttr,
requestPrimaryDA,
} {
if strings.Contains(out, seq) {
t.Fatalf("disabled negotiation emitted startup query %q", seq)
}
}
}
func TestMouseDisabledPreventsEnablement(t *testing.T) {
t.Setenv("TCELL_MOUSE", "disable")
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
s.EnableMouse()
out := tty.Output()
for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseDrag.Enable(), vt.PmMouseMotion.Enable(), vt.PmMouseSgr.Enable()} {
if strings.Contains(out, seq) {
t.Fatalf("disabled mouse emitted enable sequence %q", seq)
}
}
}
func TestMouseWithoutSgrPreventsEnablement(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
ts := s.(*baseScreen).screenImpl.(*tScreen)
ts.haveMouse = true
ts.haveMouseSgr = false
s.EnableMouse()
out := tty.Output()
for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseDrag.Enable(), vt.PmMouseMotion.Enable(), vt.PmMouseSgr.Enable()} {
if strings.Contains(out, seq) {
t.Fatalf("mouse without SGR support emitted enable sequence %q", seq)
}
}
}
func TestProcessInitQKeyboardProtocol(t *testing.T) {
tests := []struct {
name string
evs []Event
want KeyProtocol
}{
{
name: "Legacy",
want: LegacyKeyboard,
},
{
name: "Xterm",
evs: []Event{&eventXTermKbdMode{Mode: XtermKbdModeExt}},
want: XTermKeyboard,
},
{
name: "Kitty",
evs: []Event{&eventKittyKbdMode{Mode: KittyKbdModeBase}},
want: KittyKeyboard,
},
{
name: "Win32",
evs: []Event{&eventPrivateMode{Mode: vt.PmWin32Input, Status: vt.ModeOff}},
want: Win32Keyboard,
},
{
name: "KittyPreferredOverXterm",
evs: []Event{
&eventXTermKbdMode{Mode: XtermKbdModeExt},
&eventKittyKbdMode{Mode: KittyKbdModeBase},
},
want: KittyKeyboard,
},
{
name: "Win32PreferredOverKittyAndXterm",
evs: []Event{
&eventXTermKbdMode{Mode: XtermKbdModeExt},
&eventKittyKbdMode{Mode: KittyKbdModeBase},
&eventPrivateMode{Mode: vt.PmWin32Input, Status: vt.ModeOff},
},
want: Win32Keyboard,
},
{
name: "UnchangeableWin32IsIgnored",
evs: []Event{&eventPrivateMode{Mode: vt.PmWin32Input, Status: vt.ModeNA}},
want: LegacyKeyboard,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &tScreen{
initQ: make(chan Event, len(tt.evs)+1),
}
for _, ev := range tt.evs {
s.initQ <- ev
}
s.initQ <- &eventPrimaryAttributes{}
s.Lock()
s.processInitQ()
s.Unlock()
if got := s.KeyboardProtocol(); got != tt.want {
t.Fatalf("KeyboardProtocol() = %v, want %v", got, tt.want)
}
})
}
}
// TestInitScreenStdio just tries to initialize the default screen using standard I/O.
// It requires a working tty.
func TestInitScreenStdio(t *testing.T) {
tty, err := tty.NewStdIoTty()
if err != nil {
t.Skip("maybe stdin is not a tty?")
return
}
s, err := NewTerminfoScreenFromTty(tty)
if err := s.Init(); err != nil {
t.Skip("failed to initialize screen", err)
tty.Close()
return
}
defer s.Fini()
if s.CharacterSet() != "UTF-8" {
t.Fatalf("Character Set (%v) not UTF-8", s.CharacterSet())
}
drainInput()
}
func TestNotDevNull(t *testing.T) {
tty, err := tty.NewDevTtyFromDev("/dev/null")
if err == nil {
tty.Close()
t.Error("open /dev/null as tty should not have passed")
}
}
func TestNoColorEnv(t *testing.T) {
t.Setenv("NO_COLOR", "1")
s, err := NewTerminfoScreen()
if err != nil {
t.Skip("failed to get screen")
}
if err := s.Init(); err != nil {
t.Skip("failed to initialize screen", err)
}
defer s.Fini()
if s.Colors() != 0 {
t.Errorf("screen should not have color but had %d", s.Colors())
}
drainInput()
}
func NewMockScreen(t *testing.T, opts ...vt.MockOpt) (vt.MockTerm, Screen) {
t.Helper()
if runtime.GOOS == "js" {
t.Skip("not supported on webasm")
return nil, nil
}
mt := vt.NewMockTerm(opts...)
scr, err := NewTerminfoScreenFromTty(mt)
if err != nil {
t.Fatalf("failed to get terminal: %v", err)
return nil, nil
}
if err = scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
return nil, nil
}
return mt, scr
}
func TestSetTitleStripsOSCControls(t *testing.T) {
mt := vt.NewMockTerm(vt.MockOptSize{X: 80, Y: 24})
scr, err := NewTerminfoScreenFromTty(mt)
if err != nil {
t.Fatalf("failed to get terminal: %v", err)
}
scr.SetTitle("good\x07title\x1b\\end")
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
if got := mt.GetTitle(); got != "goodtitle\\end" {
t.Fatalf("title not sanitized: %q", got)
}
}
func TestShowNotificationStripsOSCControls(t *testing.T) {
mt := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 80, Y: 24})}
scr, err := NewTerminfoScreenFromTty(mt)
if err != nil {
t.Fatalf("failed to get terminal: %v", err)
}
if err := scr.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer scr.Fini()
before := mt.Output()
scr.ShowNotification("tit\x07le", "bo\x1b\\dy")
delta := mt.Output()[len(before):]
if strings.Contains(delta, "tit\x07le") || strings.Contains(delta, "bo\x1b\\dy") {
t.Fatalf("notification payload still contains control characters: %q", delta)
}
if !strings.Contains(delta, "title") || !strings.Contains(delta, "bo\\dy") {
t.Fatalf("notification payload missing sanitized strings: %q", delta)
}
}
// A Show on a suspended screen must return without emitting anything: the
// terminal has been handed back to the caller on disengage, and the released
// cell buffer disagrees with the screen's remembered width and height, which
// used to make the draw scan loop spin forever — while holding the screen
// lock, so a concurrent Resume would deadlock. Show runs in a goroutine so
// that a regression fails the test instead of hanging the suite.
func TestShowWhileSuspendedEmitsNothing(t *testing.T) {
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 80, Y: 24})}
s, err := NewTerminfoScreenFromTty(tty)
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
if err := s.Suspend(); err != nil {
t.Fatalf("failed to suspend screen: %v", err)
}
baseline := len(tty.Output())
done := make(chan struct{})
go func() {
s.Show()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Show hung on a suspended screen")
}
if emitted := tty.Output()[baseline:]; emitted != "" {
t.Fatalf("Show on a suspended screen emitted %q", emitted)
}
if err := s.Resume(); err != nil {
t.Fatalf("failed to resume screen: %v", err)
}
s.Fini()
}
func TestScreenFillArea(t *testing.T) {
_, s := NewMockScreen(t, vt.MockOptSize{X: 20, Y: 10})
defer s.Fini()
style := StyleDefault.Foreground(ColorGreen).Background(ColorBlack)
s.FillArea(2, 3, 4, 2, '*', style)
// Inside the region.
if got, gotStyle, _ := s.Get(3, 3); got != "*" || gotStyle != style {
t.Errorf("filled cell wrong: got %q style=%v", got, gotStyle)
}
if got, _, _ := s.Get(5, 4); got != "*" {
t.Errorf("bottom-right corner not filled: got %q", got)
}
// Just outside the region should still be blank.
if got, _, _ := s.Get(6, 3); got != " " {
t.Errorf("cell past the region should be blank, got %q", got)
}
if got, _, _ := s.Get(3, 5); got != " " {
t.Errorf("cell below the region should be blank, got %q", got)
}
// Coordinates that overflow the screen must not panic and must be clipped.
s.FillArea(18, 8, 100, 100, '#', style)
if got, _, _ := s.Get(19, 9); got != "#" {
t.Errorf("overflowing fill should paint in-bounds cells, got %q", got)
}
}
type fakeConsoleMouse struct {
startErr error
started int
stopped int
flags MouseFlags
sizes []tty.WindowSize
}
func (m *fakeConsoleMouse) Start(flags MouseFlags) error {
if m.startErr != nil {
return m.startErr
}
m.started++
m.flags = flags
return nil
}
func (m *fakeConsoleMouse) Stop() {
m.stopped++
}
func (m *fakeConsoleMouse) SetSize(w, h int) {
m.sizes = append(m.sizes, tty.WindowSize{Width: w, Height: h})
}
func withConsoleMouseFactory(t *testing.T, f consoleMouseFactory) {
t.Helper()
old := newConsoleMouse
newConsoleMouse = f
t.Cleanup(func() { newConsoleMouse = old })
}
func TestNativeConsoleMouseSuppressesVTMouseEnable(t *testing.T) {
fm := &fakeConsoleMouse{}
withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {
return fm, nil
})
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
tty.writes.Reset()
s.EnableMouse(MouseButtonEvents)
if fm.started != 1 {
t.Fatalf("native console mouse was not started: %d", fm.started)
}
if fm.flags != MouseButtonEvents {
t.Fatalf("native console mouse got flags %x", fm.flags)
}
out := tty.Output()
for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseDrag.Enable(), vt.PmMouseMotion.Enable(), vt.PmMouseSgr.Enable(), vt.PmMouseSgrPixel.Enable()} {
if strings.Contains(out, seq) {
t.Fatalf("native console mouse emitted VT mouse enable sequence %q", seq)
}
}
}
func TestNativeConsoleMouseFailureFallsBackToVTMouse(t *testing.T) {
withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {
return nil, errNoConsoleMouse
})
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
tty.writes.Reset()
s.EnableMouse(MouseButtonEvents)
out := tty.Output()
for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseSgr.Enable()} {
if !strings.Contains(out, seq) {
t.Fatalf("native console mouse failure did not fall back to VT sequence %q", seq)
}
}
}
func TestNativeConsoleMouseStopsOnDisable(t *testing.T) {
fm := &fakeConsoleMouse{}
withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {
return fm, nil
})
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
s.EnableMouse(MouseMotionEvents)
s.DisableMouse()
if fm.started != 1 {
t.Fatalf("native console mouse starts = %d", fm.started)
}
if fm.stopped != 1 {
t.Fatalf("native console mouse stops = %d", fm.stopped)
}
}
func TestMouseDisabledPreventsNativeConsoleMouse(t *testing.T) {
t.Setenv("TCELL_MOUSE", "disable")
fm := &fakeConsoleMouse{}
withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {
return fm, nil
})
tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}
s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))
if err != nil {
t.Fatalf("failed to get screen: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("failed to initialize screen: %v", err)
}
defer s.Fini()
s.EnableMouse()
if fm.started != 0 {
t.Fatalf("native console mouse started despite TCELL_MOUSE=disable")
}
}