From 2ff708fa855ede31801c303f9832c9a9f934abb3 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 29 Mar 2026 12:16:11 -0700 Subject: [PATCH 01/32] chore: update Go toolchain and CI matrix --- .github/workflows/linux.yml | 1 + .github/workflows/macos.yml | 1 + .github/workflows/webasm.yml | 1 + .github/workflows/windows.yml | 1 + AGENTS.md | 1 + go.mod | 2 +- 6 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 4b3ce9a7d..f954fa845 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -5,6 +5,7 @@ jobs: name: build runs-on: [ubuntu-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index aa9e74188..b48486563 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -5,6 +5,7 @@ jobs: name: build runs-on: [macos-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: diff --git a/.github/workflows/webasm.yml b/.github/workflows/webasm.yml index 56eb4ecd6..88417bcc0 100644 --- a/.github/workflows/webasm.yml +++ b/.github/workflows/webasm.yml @@ -5,6 +5,7 @@ jobs: name: build runs-on: [ubuntu-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 726bbb72b..4c70c337e 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -5,6 +5,7 @@ jobs: name: build runs-on: [windows-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..11d8d9f6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Use the `gopls` MCP server by default when working on Go code in this repository, unless I explicitly ask you not to. diff --git a/go.mod b/go.mod index 860c1e9ee..b9314641f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gdamore/tcell/v3 -go 1.24.0 +go 1.25.0 require ( github.com/gdamore/encoding v1.0.1 From 9b89b307319cf72e8bc0f4c21d061d7191db5f9a Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 11:49:24 -0700 Subject: [PATCH 02/32] fix(vt): synchronize emulator and mock state --- vt/emulate.go | 105 ++++++++++++++++++++++++++++++++++++++++---------- vt/mock.go | 24 +++++++++++- 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/vt/emulate.go b/vt/emulate.go index 0763bbc8c..514b081f0 100644 --- a/vt/emulate.go +++ b/vt/emulate.go @@ -220,6 +220,7 @@ type emulator struct { savedPos Coord // saved via DECSC saved savedCursor // data saved by save cursor (DECSC) sendLock sync.Mutex // ensures that send data cannot be intermixed + modeLock sync.RWMutex // protects localModes/ansiModes and related derived state tabStops []Col // tab stops, ordered. if nil every 8th position is used lastIndex int // index of last cell written + 1 (for grapheme clustering) (zero means none) cells []Cell // content of cells, we have to maintain our own copy (backend might or might not) @@ -1950,10 +1951,10 @@ func (em *emulator) softReset() { em.appKeyPad = false em.be.Reset() // start by resetting all modes - for am := range em.ansiModes { + for _, am := range em.ansiModeKeys() { em.setAnsiMode(am, ModeOff) // NB: No effect for non-changeable modes } - for pm := range em.localModes { + for _, pm := range em.privateModeKeys() { em.setPrivateMode(pm, ModeOff) // NB: No effect for non-changeable modes } // and set any that should reset on (auto-margin) @@ -1968,6 +1969,28 @@ func (em *emulator) softReset() { em.eraseAll() } +func (em *emulator) ansiModeKeys() []AnsiMode { + em.modeLock.RLock() + defer em.modeLock.RUnlock() + + keys := make([]AnsiMode, 0, len(em.ansiModes)) + for am := range em.ansiModes { + keys = append(keys, am) + } + return keys +} + +func (em *emulator) privateModeKeys() []PrivateMode { + em.modeLock.RLock() + defer em.modeLock.RUnlock() + + keys := make([]PrivateMode, 0, len(em.localModes)) + for pm := range em.localModes { + keys = append(keys, pm) + } + return keys +} + // sendDA ends the primary device attributes. func (em *emulator) sendDA() { buf := &bytes.Buffer{} @@ -1989,20 +2012,27 @@ func (em *emulator) setAnsiMode(mode AnsiMode, ms ModeStatus) { if !ms.Changeable() { return } + em.modeLock.Lock() + defer em.modeLock.Unlock() if old, ok := em.ansiModes[mode]; ok && old.Changeable() { em.ansiModes[mode] = ms } } func (em *emulator) getAnsiMode(mode AnsiMode) ModeStatus { + em.modeLock.RLock() + defer em.modeLock.RUnlock() return em.ansiModes[mode] } // getPrivateMode returns the value of a DEC private mode. func (em *emulator) getPrivateMode(pm PrivateMode) ModeStatus { + em.modeLock.RLock() if ms, ok := em.localModes[pm]; ok { + em.modeLock.RUnlock() return ms } + em.modeLock.RUnlock() return em.be.GetPrivateMode(pm) } @@ -2011,19 +2041,10 @@ func (em *emulator) updateMouseReporting() { if !ok { return } - if em.localModes[PmMouseButton] == ModeOn { - em.mouseReports = MouseButtons - if em.localModes[PmMouseMotion] == ModeOn { - em.mouseReports = MouseMotion - } else if em.localModes[PmMouseDrag] == ModeOn { - em.mouseReports = MouseDrag - } - } else if em.localModes[PmMouseX10] == ModeOn { - em.mouseReports = MouseButtons - } else { - em.mouseReports = MouseDisabled - } - mi.SetMouse(em.mouseReports) + em.modeLock.RLock() + report := em.mouseReportingLocked() + em.modeLock.RUnlock() + mi.SetMouse(report) } // setPrivateMode sets the DEC private mode. @@ -2031,18 +2052,30 @@ func (em *emulator) setPrivateMode(pm PrivateMode, ms ModeStatus) { if !ms.Changeable() { return } - if old, ok := em.localModes[pm]; ok && old.Changeable() { + em.modeLock.Lock() + old, ok := em.localModes[pm] + if ok && old.Changeable() { em.localModes[pm] = ms + + var ( + setMouse bool + report MouseReporting + setCursor bool + cursor CursorStyle + ) + switch pm { case PmMouseButton, PmMouseDrag, PmMouseMotion, PmMouseSgr, PmMouseSgrPixel, PmMouseX10: - em.updateMouseReporting() + report = em.mouseReportingLocked() + setMouse = true case PmShowCursor: if ms == ModeOn { em.cursor = em.cursor.Show() } else { em.cursor = em.cursor.Hide() } - em.be.SetCursor(em.cursor) + cursor = em.cursor + setCursor = true case PmBlinkCursor: if ms == ModeOn { em.cursor = em.cursor.Blink() @@ -2050,13 +2083,45 @@ func (em *emulator) setPrivateMode(pm PrivateMode, ms ModeStatus) { em.cursor = em.cursor.Steady() } - em.be.SetCursor(em.cursor) + cursor = em.cursor + setCursor = true + } + em.modeLock.Unlock() + + if setMouse { + if mi, ok := em.be.(Mouser); ok { + mi.SetMouse(report) + } + } + if setCursor { + em.be.SetCursor(cursor) } - } else if em.be.GetPrivateMode(pm).Changeable() { + return + } + em.modeLock.Unlock() + + if em.be.GetPrivateMode(pm).Changeable() { _ = em.be.SetPrivateMode(pm, ms) } } +func (em *emulator) mouseReportingLocked() MouseReporting { + switch { + case em.localModes[PmMouseButton] == ModeOn: + em.mouseReports = MouseButtons + if em.localModes[PmMouseMotion] == ModeOn { + em.mouseReports = MouseMotion + } else if em.localModes[PmMouseDrag] == ModeOn { + em.mouseReports = MouseDrag + } + case em.localModes[PmMouseX10] == ModeOn: + em.mouseReports = MouseButtons + default: + em.mouseReports = MouseDisabled + } + return em.mouseReports +} + // SendRaw allows raw data to be sent to the application. // This is done in a thread-safe way, so that content is not intermingled. func (em *emulator) SendRaw(b []byte) { diff --git a/vt/mock.go b/vt/mock.go index 6b47cf81b..156013ea1 100644 --- a/vt/mock.go +++ b/vt/mock.go @@ -313,8 +313,19 @@ type mockBackend struct { lock sync.Mutex } -func (mb *mockBackend) GetSize() Coord { mb.checkSize(); return mb.size } -func (mb *mockBackend) Beep() { mb.bells++ } +func (mb *mockBackend) GetSize() Coord { + mb.lock.Lock() + defer mb.lock.Unlock() + mb.checkSize() + return mb.size +} + +func (mb *mockBackend) Beep() { + mb.lock.Lock() + mb.bells++ + mb.lock.Unlock() +} + func (mb *mockBackend) SetMouse(MouseReporting) {} func (mb *mockBackend) GetPrivateMode(pm PrivateMode) ModeStatus { @@ -478,6 +489,7 @@ func (mb *mockBackend) checkSize() { mb.size = size mb.pos.X = min(mb.pos.X, size.X-1) mb.pos.Y = min(mb.pos.Y, size.Y-1) + mb.resized = false } func (mb *mockBackend) RaiseResize() { @@ -583,21 +595,29 @@ func (mb *mockBackend) Buffering(bool) {} // SetCursor is used to set how the cursor is displayed. func (mb *mockBackend) SetCursor(cs CursorStyle) { + mb.lock.Lock() + defer mb.lock.Unlock() mb.cursor = cs } // GetCursor returns the current cursor style. func (mb *mockBackend) GetCursor() CursorStyle { + mb.lock.Lock() + defer mb.lock.Unlock() return mb.cursor } // SetClipboard sets the current clipboard contents. func (mb *mockBackend) SetClipboard(data []byte) { + mb.lock.Lock() + defer mb.lock.Unlock() mb.clipboard = data } // GetClipboard gets the current clipboard contents. func (mb *mockBackend) GetClipboard() []byte { + mb.lock.Lock() + defer mb.lock.Unlock() return mb.clipboard } From 64ee32ae27cb8980dac8de70da83d6941bb83681 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 12:20:07 -0700 Subject: [PATCH 03/32] test(vt): avoid repeat timing boundary flake --- vt/tests/key_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/vt/tests/key_test.go b/vt/tests/key_test.go index c3b7b16d6..c819418cf 100644 --- a/vt/tests/key_test.go +++ b/vt/tests/key_test.go @@ -248,12 +248,12 @@ func TestKeyRepeat(t *testing.T) { term.SetRepeat(time.Millisecond*50, time.Millisecond*25) term.KeyPress(vt.KeyX) - time.Sleep(100 * time.Millisecond) + time.Sleep(90 * time.Millisecond) term.KeyPress(vt.KeyX) term.KeyRelease(vt.KeyX) term.KeyRelease(vt.KeyCapsLock) - CheckRead(t, term, "xxxx") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "xxx") // 0 ms, 50 ms, 75 ms } // TestKeyRepeatDisabled tests simple key repeat @@ -287,12 +287,12 @@ func TestKeyRepeatCapsLock(t *testing.T) { term.KeyPress(vt.KeyCapsLock) term.KeyPress(vt.KeyZ) - time.Sleep(100 * time.Millisecond) + time.Sleep(90 * time.Millisecond) term.KeyPress(vt.KeyZ) term.KeyRelease(vt.KeyZ) term.KeyRelease(vt.KeyCapsLock) - CheckRead(t, term, "ZZZZ") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "ZZZ") // 0 ms, 50 ms, 75 ms } // TestKeyRepeatNoAlt ensures that alt keys do not repeat @@ -327,12 +327,12 @@ func TestKeyRepeatShift(t *testing.T) { term.KeyPress(vt.KeyLShift) term.KeyPress(vt.Key1) - time.Sleep(100 * time.Millisecond) + time.Sleep(90 * time.Millisecond) term.KeyPress(vt.Key1) term.KeyRelease(vt.Key1) term.KeyRelease(vt.KeyLShift) - CheckRead(t, term, "!!!!") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "!!!") // 0 ms, 50 ms, 75 ms } // TestKeyRepeatShiftRelease ensures that releasing shift breaks repeat. @@ -348,11 +348,11 @@ func TestKeyRepeatShiftRelease(t *testing.T) { term.KeyPress(vt.KeyLShift) term.KeyPress(vt.Key2) term.KeyRelease(vt.KeyLShift) - time.Sleep(100 * time.Millisecond) + time.Sleep(90 * time.Millisecond) term.KeyPress(vt.Key2) term.KeyRelease(vt.Key2) - CheckRead(t, term, "@222") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "@22") // 0 ms, 50 ms, 75 ms } func TestKeyRepeatCursor(t *testing.T) { @@ -365,12 +365,12 @@ func TestKeyRepeatCursor(t *testing.T) { term.SetRepeat(time.Millisecond*50, time.Millisecond*25) term.KeyPress(vt.KeyRight) - time.Sleep(100 * time.Millisecond) + time.Sleep(90 * time.Millisecond) term.KeyPress(vt.KeyRight) term.KeyRelease(vt.KeyRight) term.KeyRelease(vt.KeyRight) - CheckRead(t, term, "\x1b[C\x1b[C\x1b[C\x1b[C") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "\x1b[C\x1b[C\x1b[C") // 0 ms, 50 ms, 75 ms } func TestKeyWin32(t *testing.T) { From 4aedf0352eb20beffad42354adfc5875fa38870d Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 13:02:12 -0700 Subject: [PATCH 04/32] test(vt): cover emulator mode helpers --- vt/emulate_internal_test.go | 133 ++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 vt/emulate_internal_test.go diff --git a/vt/emulate_internal_test.go b/vt/emulate_internal_test.go new file mode 100644 index 000000000..69a0eda16 --- /dev/null +++ b/vt/emulate_internal_test.go @@ -0,0 +1,133 @@ +// 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 vt + +import ( + "slices" + "testing" + + "github.com/gdamore/tcell/v3/color" +) + +type noMouseBackend struct { + mb *mockBackend +} + +func (n noMouseBackend) GetPrivateMode(pm PrivateMode) ModeStatus { return n.mb.GetPrivateMode(pm) } +func (n noMouseBackend) SetPrivateMode(pm PrivateMode, status ModeStatus) error { + return n.mb.SetPrivateMode(pm, status) +} +func (n noMouseBackend) GetSize() Coord { return n.mb.GetSize() } +func (n noMouseBackend) Colors() int { return n.mb.Colors() } +func (n noMouseBackend) Put(pos Coord, cell Cell) { n.mb.Put(pos, cell) } +func (n noMouseBackend) GetPosition() Coord { return n.mb.GetPosition() } +func (n noMouseBackend) SetPosition(pos Coord) { n.mb.SetPosition(pos) } +func (n noMouseBackend) Reset() { n.mb.Reset() } +func (n noMouseBackend) RaiseResize() { n.mb.RaiseResize() } +func (n noMouseBackend) Buffering(enabled bool) { n.mb.Buffering(enabled) } +func (n noMouseBackend) SetCursor(cs CursorStyle) { n.mb.SetCursor(cs) } + +func TestEmulatorModeHelpers(t *testing.T) { + mb := NewMockBackend().(*mockBackend) + em := NewEmulator(mb).(*emulator) + + ansiKeys := em.ansiModeKeys() + if !slices.Contains(ansiKeys, AmNewLineMode) { + t.Fatalf("expected AmNewLineMode in ansi mode keys, got %v", ansiKeys) + } + + privateKeys := em.privateModeKeys() + if !slices.Contains(privateKeys, PmAutoMargin) { + t.Fatalf("expected PmAutoMargin in private mode keys, got %v", privateKeys) + } + + em.setAnsiMode(AmNewLineMode, ModeOn) + if got := em.getAnsiMode(AmNewLineMode); got != ModeOn { + t.Fatalf("setAnsiMode did not update changeable mode: got %v", got) + } + + em.setAnsiMode(AmNewLineMode, ModeNA) + if got := em.getAnsiMode(AmNewLineMode); got != ModeOn { + t.Fatalf("setAnsiMode changed mode for non-changeable status: got %v", got) + } + + em.ansiModes[AmInsertReplace] = ModeOnLocked + em.setAnsiMode(AmInsertReplace, ModeOff) + if got := em.getAnsiMode(AmInsertReplace); got != ModeOnLocked { + t.Fatalf("setAnsiMode changed locked mode: got %v", got) + } + + em.setAnsiMode(AnsiMode(9999), ModeOn) + if _, ok := em.ansiModes[AnsiMode(9999)]; ok { + t.Fatalf("setAnsiMode created an unknown mode entry") + } +} + +func TestEmulatorUpdateMouseReporting(t *testing.T) { + mb := NewMockBackend().(*mockBackend) + em := NewEmulator(mb).(*emulator) + + em.localModes[PmMouseButton] = ModeOn + em.updateMouseReporting() + if em.mouseReports != MouseButtons { + t.Fatalf("expected mouse buttons reporting, got %v", em.mouseReports) + } + + em.localModes[PmMouseDrag] = ModeOn + em.updateMouseReporting() + if em.mouseReports != MouseDrag { + t.Fatalf("expected mouse drag reporting, got %v", em.mouseReports) + } + + em.localModes[PmMouseMotion] = ModeOn + em.updateMouseReporting() + if em.mouseReports != MouseMotion { + t.Fatalf("expected mouse motion reporting, got %v", em.mouseReports) + } + + em.localModes[PmMouseButton] = ModeOff + em.localModes[PmMouseDrag] = ModeOff + em.localModes[PmMouseMotion] = ModeOff + em.localModes[PmMouseX10] = ModeOn + em.updateMouseReporting() + if em.mouseReports != MouseButtons { + t.Fatalf("expected mouse X10 reporting to map to buttons, got %v", em.mouseReports) + } + + em.localModes[PmMouseX10] = ModeOff + em.updateMouseReporting() + if em.mouseReports != MouseDisabled { + t.Fatalf("expected mouse reporting disabled, got %v", em.mouseReports) + } + + noMouse := NewEmulator(noMouseBackend{mb: NewMockBackend().(*mockBackend)}).(*emulator) + noMouse.updateMouseReporting() +} + +func TestMockBackendHelpers(t *testing.T) { + mb := NewMockBackend().(*mockBackend) + + style := BaseStyle.WithFg(color.Red).WithBg(color.Blue) + mb.SetStyle(style) + if mb.style != style { + t.Fatalf("SetStyle did not update backend style") + } + + mb.SetMouse(MouseMotion) + mb.Buffering(true) + mb.Buffering(false) + + MockOptNoBlit{}.SetMockOpt(mb) +} From 94ae18d881434db2cce8c34d3fcd3ab8638add45 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 12:20:37 -0700 Subject: [PATCH 05/32] Add screen option to disable alt screen --- tscreen.go | 20 ++++++++++++++--- tscreen_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ wscreen.go | 6 +++-- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/tscreen.go b/tscreen.go index 6aeaed267..33f17ee3e 100644 --- a/tscreen.go +++ b/tscreen.go @@ -74,6 +74,15 @@ func (o OptTerm) apply(t *tScreen) { t.term = string(o) } +// OptAltScreen controls whether the alternate screen buffer is used. +// The default is true. The TCELL_ALTSCREEN=disable environment override +// is still honored. +type OptAltScreen bool + +func (o OptAltScreen) apply(t *tScreen) { + t.altScreen = bool(o) +} + // Some terminal escapes that are basically universal. // We would really like to be able to use private mode queries for some of // these but generally we've found that support for queries is not always present, @@ -134,7 +143,7 @@ const ( // is presumed, at least on UNIX hosts. (Windows hosts will typically fail this // call altogether.) func NewTerminfoScreenFromTty(tty Tty, opts ...TerminfoScreenOption) (Screen, error) { - t := &tScreen{tty: tty} + t := &tScreen{tty: tty, altScreen: true} t.prepareCursorStyles() t.prepareExtendedOSC() @@ -208,6 +217,7 @@ type tScreen struct { termName string termVers string term string // value from $TERM + altScreen bool inlineResize bool haveMouse bool haveMouseSgr bool @@ -218,6 +228,10 @@ type tScreen struct { sync.Mutex } +func (t *tScreen) useAltScreen() bool { + return t.altScreen && os.Getenv("TCELL_ALTSCREEN") != "disable" +} + func (t *tScreen) Init() error { if e := t.initialize(); e != nil { return e @@ -1238,7 +1252,7 @@ func (t *tScreen) engage() error { t.Print(requestPrimaryDA) // NB: MUST BE LAST } t.processInitQ() - if os.Getenv("TCELL_ALTSCREEN") != "disable" { + if t.useAltScreen() { // Technically this may not be right, but every terminal we know about // (even Wyse 60) uses this to enter the alternate screen buffer, and // possibly save and restore the window title and/or icon. @@ -1331,7 +1345,7 @@ func (t *tScreen) disengage() { t.Print(disableXTermKbd) } // t.Print(t.disableCsiU) - if os.Getenv("TCELL_ALTSCREEN") != "disable" { + if t.useAltScreen() { t.Print(t.restoreTitle) t.Print(clear) t.Print(exitCA) diff --git a/tscreen_test.go b/tscreen_test.go index 0e87bd21d..17c65543b 100644 --- a/tscreen_test.go +++ b/tscreen_test.go @@ -15,7 +15,9 @@ package tcell import ( + "bytes" "runtime" + "strings" "testing" "time" @@ -52,6 +54,64 @@ func TestInitScreen(t *testing.T) { 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") + } +} + // TestInitScreenStdio just tries to initialize the default screen using standard I/O. // It requires a working tty. func TestInitScreenStdio(t *testing.T) { diff --git a/wscreen.go b/wscreen.go index cdb32a129..53dbfc064 100644 --- a/wscreen.go +++ b/wscreen.go @@ -45,9 +45,11 @@ func NewTerminfoScreenFromTty(_ tty.Tty, _ ...TerminfoScreenOption) (Screen, err type TerminfoScreenOption interface{ apply(*wScreen) } type OptColors int type OptTerm string +type OptAltScreen bool -func (OptColors) apply(*wScreen) {} -func (OptTerm) apply(*wScreen) {} +func (OptColors) apply(*wScreen) {} +func (OptTerm) apply(*wScreen) {} +func (OptAltScreen) apply(*wScreen) {} type wScreen struct { w, h int From 6f49191a73389163224fca82cf6b88d37969f8a4 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 13:50:30 -0700 Subject: [PATCH 06/32] test(vt): avoid caps lock repeat timing flake --- vt/tests/key_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vt/tests/key_test.go b/vt/tests/key_test.go index c819418cf..7b547ba83 100644 --- a/vt/tests/key_test.go +++ b/vt/tests/key_test.go @@ -282,8 +282,9 @@ func TestKeyRepeatCapsLock(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Use a large repeat interval so the test is not sitting on a timing boundary. + // We only need to prove that caps lock does not emit its own repeat. + term.SetRepeat(time.Millisecond*50, time.Millisecond*250) term.KeyPress(vt.KeyCapsLock) term.KeyPress(vt.KeyZ) @@ -292,7 +293,7 @@ func TestKeyRepeatCapsLock(t *testing.T) { term.KeyRelease(vt.KeyZ) term.KeyRelease(vt.KeyCapsLock) - CheckRead(t, term, "ZZZ") // 0 ms, 50 ms, 75 ms + CheckRead(t, term, "ZZ") // 0 ms, 50 ms } // TestKeyRepeatNoAlt ensures that alt keys do not repeat From 3cc2acf2b66891d29f39c7eb80357767ee377576 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:39:24 +0000 Subject: [PATCH 07/32] chore(deps): bump github.com/lucasb-eyer/go-colorful from 1.3.0 to 1.4.0 Bumps [github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) from 1.3.0 to 1.4.0. - [Release notes](https://github.com/lucasb-eyer/go-colorful/releases) - [Changelog](https://github.com/lucasb-eyer/go-colorful/blob/master/CHANGELOG.md) - [Commits](https://github.com/lucasb-eyer/go-colorful/compare/v1.3.0...v1.4.0) --- updated-dependencies: - dependency-name: github.com/lucasb-eyer/go-colorful dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b9314641f..0225c33a0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/gdamore/encoding v1.0.1 - github.com/lucasb-eyer/go-colorful v1.3.0 + github.com/lucasb-eyer/go-colorful v1.4.0 github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 diff --git a/go.sum b/go.sum index 73a77bdf8..1e464d68c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= From a9202caf5dc8696c8f0332885d6f3bb38a4edd84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:53:00 +0000 Subject: [PATCH 08/32] chore(deps): bump golang.org/x/text from 0.33.0 to 0.35.0 Bumps [golang.org/x/text](https://github.com/golang/text) from 0.33.0 to 0.35.0. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.33.0...v0.35.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-version: 0.35.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0225c33a0..e0a6f4f14 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,5 @@ require ( github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 - golang.org/x/text v0.33.0 + golang.org/x/text v0.35.0 ) diff --git a/go.sum b/go.sum index 1e464d68c..f38321eed 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 193aaa7320bc21fb3f3484a33e523908476267be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:04:46 +0000 Subject: [PATCH 09/32] chore(deps): bump codecov/codecov-action from 5 to 6 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/linux.yml | 2 +- .github/workflows/macos.yml | 2 +- .github/workflows/windows.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index f954fa845..9a88e4318 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -31,6 +31,6 @@ jobs: run: script -q -e -c "go test -coverpkg=github.com/gdamore/tcell/v3/... -covermode=count -coverprofile=coverage.txt ./..." - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index b48486563..e202c046b 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -31,6 +31,6 @@ jobs: run: script -q /dev/null go test -coverpkg="github.com/gdamore/tcell/v3/..." -covermode=count -coverprofile="coverage.txt" ./... - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 4c70c337e..598da6844 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -28,6 +28,6 @@ jobs: run: go test -coverpkg="github.com/gdamore/tcell/v3/..." -covermode=count -coverprofile="coverage.txt" ./... - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} From 062febff9efbd14858bbae0feb1019d87ce36be8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:03:42 +0000 Subject: [PATCH 10/32] chore(deps): bump golang.org/x/sys from 0.41.0 to 0.42.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.41.0 to 0.42.0. - [Commits](https://github.com/golang/sys/compare/v0.41.0...v0.42.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e0a6f4f14..2ea1c4fa8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/gdamore/encoding v1.0.1 github.com/lucasb-eyer/go-colorful v1.4.0 github.com/rivo/uniseg v0.4.7 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.42.0 golang.org/x/term v0.40.0 golang.org/x/text v0.35.0 ) diff --git a/go.sum b/go.sum index f38321eed..00b20a20c 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From 1ce3aa543dafb84255dfa1243c1d329303f0cb21 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 14:19:09 -0700 Subject: [PATCH 11/32] test(vt): avoid Windows repeat timing flake --- vt/tests/key_test.go | 66 ++++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/vt/tests/key_test.go b/vt/tests/key_test.go index 7b547ba83..597258623 100644 --- a/vt/tests/key_test.go +++ b/vt/tests/key_test.go @@ -244,16 +244,17 @@ func TestKeyRepeat(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the initial repeat quick, but make later repeats very slow so the test + // does not depend on sub-100ms timer precision. + term.SetRepeat(time.Millisecond*50, time.Second) term.KeyPress(vt.KeyX) - time.Sleep(90 * time.Millisecond) + time.Sleep(100 * time.Millisecond) term.KeyPress(vt.KeyX) term.KeyRelease(vt.KeyX) term.KeyRelease(vt.KeyCapsLock) - CheckRead(t, term, "xxx") // 0 ms, 50 ms, 75 ms + CheckRead(t, term, "xx") } // TestKeyRepeatDisabled tests simple key repeat @@ -263,8 +264,9 @@ func TestKeyRepeatDisabled(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the initial repeat quick, but make later repeats very slow so the test + // only needs to distinguish repeated from non-repeated input. + term.SetRepeat(time.Millisecond*50, time.Second) WriteF(t, term, "\x1b[?8l") term.KeyPress(vt.KeyX) @@ -272,7 +274,7 @@ func TestKeyRepeatDisabled(t *testing.T) { term.KeyPress(vt.KeyX) term.KeyRelease(vt.KeyX) - CheckRead(t, term, "x") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "x") } // TestKeyRepeatCapsLock ensures that caps lock does not repeat @@ -303,8 +305,9 @@ func TestKeyRepeatNoAlt(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the initial repeat quick, but make later repeats very slow so the test + // does not sit on a timer boundary. + term.SetRepeat(time.Millisecond*50, time.Second) term.KeyPress(vt.KeyLAlt) term.KeyPress(vt.KeyZ) @@ -313,7 +316,7 @@ func TestKeyRepeatNoAlt(t *testing.T) { term.KeyRelease(vt.KeyZ) term.KeyRelease(vt.KeyLAlt) - CheckRead(t, term, "\x1bz") // 0 ms, 50 ms, 75 ms, 100 ms + CheckRead(t, term, "\x1bz") } // TestKeyRepeatShift ensures that shifted keys still work as long as repeat is held down. @@ -323,8 +326,9 @@ func TestKeyRepeatShift(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Use a large repeat interval so the test is not sitting on a timing boundary. + // We only need to prove that shifted keys keep their shifted value while repeating. + term.SetRepeat(time.Millisecond*50, time.Millisecond*250) term.KeyPress(vt.KeyLShift) term.KeyPress(vt.Key1) @@ -333,7 +337,7 @@ func TestKeyRepeatShift(t *testing.T) { term.KeyRelease(vt.Key1) term.KeyRelease(vt.KeyLShift) - CheckRead(t, term, "!!!") // 0 ms, 50 ms, 75 ms + CheckRead(t, term, "!!") // 0 ms, 50 ms } // TestKeyRepeatShiftRelease ensures that releasing shift breaks repeat. @@ -343,8 +347,9 @@ func TestKeyRepeatShiftRelease(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Use a large repeat interval so the test is not sitting on a timing boundary. + // We only need to prove that releasing shift changes the repeated value. + term.SetRepeat(time.Millisecond*50, time.Millisecond*250) term.KeyPress(vt.KeyLShift) term.KeyPress(vt.Key2) @@ -353,7 +358,7 @@ func TestKeyRepeatShiftRelease(t *testing.T) { term.KeyPress(vt.Key2) term.KeyRelease(vt.Key2) - CheckRead(t, term, "@22") // 0 ms, 50 ms, 75 ms + CheckRead(t, term, "@2") // 0 ms, 50 ms } func TestKeyRepeatCursor(t *testing.T) { @@ -362,16 +367,17 @@ func TestKeyRepeatCursor(t *testing.T) { MustStart(t, term) - // these are unreasonable repeat rates, but its somewhere to start - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the initial repeat quick, but make later repeats very slow so the test + // does not depend on coarse timer behavior. + term.SetRepeat(time.Millisecond*50, time.Second) term.KeyPress(vt.KeyRight) - time.Sleep(90 * time.Millisecond) + time.Sleep(100 * time.Millisecond) term.KeyPress(vt.KeyRight) term.KeyRelease(vt.KeyRight) term.KeyRelease(vt.KeyRight) - CheckRead(t, term, "\x1b[C\x1b[C\x1b[C") // 0 ms, 50 ms, 75 ms + CheckRead(t, term, "\x1b[C\x1b[C") } func TestKeyWin32(t *testing.T) { @@ -482,13 +488,15 @@ func TestKeyWin32NoRepeat(t *testing.T) { term := vt.NewMockTerm() defer MustClose(t, term) - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the first repeat available, but avoid asserting an exact number of + // repeats from short sleeps on platforms with coarse timers. + term.SetRepeat(time.Millisecond*50, time.Second) MustStart(t, term) WriteF(t, term, "%s", vt.PmWin32Input.Enable()) WriteF(t, term, "%s", vt.PmAutoRepeat.Enable()) term.KeyPress(vt.KeyPause) - time.Sleep(time.Millisecond * 150) + time.Sleep(time.Millisecond * 100) term.KeyPress(vt.KeyPause) term.KeyRelease(vt.KeyPause) want := "\x1b[19;57414;0;1;0;1_\x1b[19;57414;0;0;0;1_" @@ -499,16 +507,18 @@ func TestKeyWin32Repeat(t *testing.T) { term := vt.NewMockTerm() defer MustClose(t, term) - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the first repeat available, but avoid asserting an exact number of + // repeats from short sleeps on platforms with coarse timers. + term.SetRepeat(time.Millisecond*50, time.Second) MustStart(t, term) WriteF(t, term, "%s", vt.PmWin32Input.Enable()) WriteF(t, term, "%s", vt.PmAutoRepeat.Enable()) term.KeyPress(vt.KeyA) - time.Sleep(time.Millisecond * 150) + time.Sleep(time.Millisecond * 100) term.KeyPress(vt.KeyA) term.KeyRelease(vt.KeyA) - want := "\x1b[65;30;97;1;0;1_\x1b[65;30;97;1;0;5_\x1b[65;30;97;0;0;1_" + want := "\x1b[65;30;97;1;0;1_\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_" CheckRead(t, term, want) } @@ -516,13 +526,15 @@ func TestKeyWin32NoRepeatMode(t *testing.T) { term := vt.NewMockTerm() defer MustClose(t, term) - term.SetRepeat(time.Millisecond*50, time.Millisecond*25) + // Keep the first repeat available, but make later repeats slow so this only + // verifies that auto-repeat mode suppresses repeat events. + term.SetRepeat(time.Millisecond*50, time.Second) MustStart(t, term) WriteF(t, term, "%s", vt.PmWin32Input.Enable()) WriteF(t, term, "%s", vt.PmAutoRepeat.Disable()) term.KeyPress(vt.KeyB) - time.Sleep(time.Millisecond * 150) + time.Sleep(time.Millisecond * 100) term.KeyPress(vt.KeyB) term.KeyRelease(vt.KeyB) want := "\x1b[66;48;98;1;0;1_\x1b[66;48;98;0;0;1_" From bea6721549ceb25695fc81f375aae2368517c38f Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Thu, 2 Apr 2026 17:01:54 -0700 Subject: [PATCH 12/32] fix(tty): flush input before start (fixes #1045) --- tty/nonblock_bsd.go | 7 +++++++ tty/nonblock_unix.go | 7 +++++++ tty/stdin_unix.go | 4 ++++ tty/tty_unix.go | 5 +++++ 4 files changed, 23 insertions(+) diff --git a/tty/nonblock_bsd.go b/tty/nonblock_bsd.go index 273f99853..48fd23a1c 100644 --- a/tty/nonblock_bsd.go +++ b/tty/nonblock_bsd.go @@ -41,3 +41,10 @@ func tcSetBufParams(fd int, vMin uint8, vTime uint8) error { } return nil } + +// tcFlushInput discards any queued input before the caller starts reading from +// the tty. This avoids stale bytes, such as delayed mouse reports, from being +// delivered to the next foreground application. +func tcFlushInput(fd int) error { + return unix.IoctlSetPointerInt(fd, unix.TIOCFLUSH, unix.TCIFLUSH) +} diff --git a/tty/nonblock_unix.go b/tty/nonblock_unix.go index 8af12df6f..1dc1db2ef 100644 --- a/tty/nonblock_unix.go +++ b/tty/nonblock_unix.go @@ -39,3 +39,10 @@ func tcSetBufParams(fd int, vMin uint8, vTime uint8) error { } return nil } + +// tcFlushInput discards any queued input before the caller starts reading from +// the tty. This avoids stale bytes, such as delayed mouse reports, from being +// delivered to the next foreground application. +func tcFlushInput(fd int) error { + return unix.IoctlSetInt(fd, unix.TCFLSH, unix.TCIFLUSH) +} diff --git a/tty/stdin_unix.go b/tty/stdin_unix.go index 140b7d6e8..3a101af7f 100644 --- a/tty/stdin_unix.go +++ b/tty/stdin_unix.go @@ -71,6 +71,10 @@ func (tty *stdIoTty) Start() error { if err != nil { return err } + if err = tcFlushInput(tty.fd); err != nil { + _ = term.Restore(tty.fd, saved) + return err + } tty.saved = saved tty.started = true diff --git a/tty/tty_unix.go b/tty/tty_unix.go index 3a7d800c6..e2dc49363 100644 --- a/tty/tty_unix.go +++ b/tty/tty_unix.go @@ -85,6 +85,11 @@ func (tty *devTty) Start() error { tty.f.Close() return err } + if err = tcFlushInput(tty.fd); err != nil { + _ = term.Restore(tty.fd, saved) + tty.f.Close() + return err + } tty.saved = saved tty.started = true From dfd71855b72b79b72aa1caec2966e7edeaca3c10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:25:21 +0000 Subject: [PATCH 13/32] chore(deps): bump golang.org/x/term from 0.40.0 to 0.41.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.40.0 to 0.41.0. - [Commits](https://github.com/golang/term/compare/v0.40.0...v0.41.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.41.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2ea1c4fa8..0b1f4e70d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.42.0 - golang.org/x/term v0.40.0 + golang.org/x/term v0.41.0 golang.org/x/text v0.35.0 ) diff --git a/go.sum b/go.sum index 00b20a20c..fad699df2 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= From 0387a59c936a0a38221463b0e485e7e4e531b274 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sat, 4 Apr 2026 08:32:28 -0700 Subject: [PATCH 14/32] ci: only run the CI on main and on PRs --- .github/workflows/linux.yml | 8 +++++++- .github/workflows/macos.yml | 8 +++++++- .github/workflows/webasm.yml | 8 +++++++- .github/workflows/windows.yml | 6 +++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 9a88e4318..8ab154d83 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,5 +1,11 @@ name: linux -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: name: build diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index e202c046b..7d6d3310d 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,5 +1,11 @@ name: macos -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: name: build diff --git a/.github/workflows/webasm.yml b/.github/workflows/webasm.yml index 88417bcc0..276a274b6 100644 --- a/.github/workflows/webasm.yml +++ b/.github/workflows/webasm.yml @@ -1,5 +1,11 @@ name: webasm -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: name: build diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 598da6844..26bb5f7ea 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,5 +1,9 @@ name: windows -on: [push] +on: + pull_request: + push: + branches: + - main jobs: build: name: build From 14b46da5bfa690f37fff16f0ba53a1e87ebe6008 Mon Sep 17 00:00:00 2001 From: Noboru Saito Date: Tue, 7 Apr 2026 08:19:50 +0900 Subject: [PATCH 15/32] fix(mouse): work around Ghostty false motion press bug Ghostty may emit mouse motion events that incorrectly indicate a button is pressed even when no corresponding press occurred. Ignore those packets to avoid creating a synthetic button-down state. --- input.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/input.go b/input.go index 7dc1bd65a..4d859fb9b 100644 --- a/input.go +++ b/input.go @@ -769,6 +769,13 @@ func (ip *inputParser) handleMouse(mode rune, params []int) { } case 'M': + if btn&0x20 != 0 && button != ButtonNone && (ip.btnsDown&button) == 0 { + // Ghostty may send out motion signals that indicate a button has + // been pressed, even when the button is not actually pressed. + // Do not create a synthetic button-down state from these packets. + button = ip.btnsDown + break + } // record this press ip.btnsDown |= button // and use the full set so can see chords From c5170080c23ce9a21b921871a4abef33ecbaa4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=9C=E6=AC=A2?= Date: Thu, 9 Apr 2026 16:43:05 +0800 Subject: [PATCH 16/32] Fix expected bell count in beep_test.go Fix the failure message in demos/beep TestBeep to match the actual assertion. The test checks that the bell count is 3, but the error message currently says "!= 2". This does not affect test behavior, but it makes failures misleading and harder to debug. --- demos/beep/beep_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/beep/beep_test.go b/demos/beep/beep_test.go index 40f68d2c2..fbf7d73fb 100644 --- a/demos/beep/beep_test.go +++ b/demos/beep/beep_test.go @@ -61,6 +61,6 @@ func TestBeep(t *testing.T) { wg.Wait() if cnt := mt.Bells(); cnt != 3 { - t.Errorf("incorrect bell count %d != 2", cnt) + t.Errorf("incorrect bell count %d != 3", cnt) } } From 21c482b0a66e3c1f8c2714597a1a4ce0534eab60 Mon Sep 17 00:00:00 2001 From: CatsDeservePets <145048791+CatsDeservePets@users.noreply.github.com> Date: Mon, 6 Apr 2026 02:10:45 +0200 Subject: [PATCH 17/32] style: unify OSC spelling in comments --- input.go | 2 +- tscreen.go | 2 +- vt/backend.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/input.go b/input.go index 4d859fb9b..7937aa51c 100644 --- a/input.go +++ b/input.go @@ -1186,7 +1186,7 @@ type eventPrimaryAttributes struct { Greek bool // Greek (DA 23) Turkish bool // Turkish (DA 24) Latin2 bool // ISO Latin-2 (DA 42) - Clipboard bool // OSC-52 support (DA 52) + Clipboard bool // OSC 52 support (DA 52) } // eventTermName is for extended attributes diff --git a/tscreen.go b/tscreen.go index 33f17ee3e..f06705922 100644 --- a/tscreen.go +++ b/tscreen.go @@ -128,7 +128,7 @@ const ( requestPrimaryDA = "\x1b[c" // Request primary device attributes requestExtAttr = "\x1b[>q" // Request extended attribute (emulator name and version) setClipboard = "\x1b]52;c;%s\x1b\\" // Clipboard content is base64 - notifyDesktop9 = "\x1b]9;%[2]s\x1b\\" // Args are title, body (but osc 9 only has body) + notifyDesktop9 = "\x1b]9;%[2]s\x1b\\" // Args are title, body (but OSC 9 only has body) notifyDesktop777 = "\x1b]777;notify;%s;%s\x1b\\" // Most commonly supported queryKittyKbd = "\x1b[?u" // Query for Kitty keyboard support enableKittyKbd = "\x1b[=1u" // Technically this pushes diff --git a/vt/backend.go b/vt/backend.go index def844658..f7f5624b6 100644 --- a/vt/backend.go +++ b/vt/backend.go @@ -79,7 +79,7 @@ type Resizer interface { NotifyResize(chan<- bool) } -// Titler adds support for setting the window title. (Typically this is OSC2.) +// Titler adds support for setting the window title. (Typically this is OSC 2.) // Note that for security reasons we only support setting this. // We don't bother with icon titles, since few terminal emulators support it, and it // would be hard for us to do this in any portable fashion. From 1f1348ab6cd6cdadf029e1bdf8bac2265b70c23b Mon Sep 17 00:00:00 2001 From: ModeEngage Date: Sat, 11 Apr 2026 10:18:26 -0400 Subject: [PATCH 18/32] fix(input): handle ESC during CSI and SS3 parse states per ECMA-48 Properly check for ESC (`0x1B`) to prevent erroneously falling into the 'bad parse' branch of `intCsi` and `intSs3`. --- input.go | 14 +++++++++-- input_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/input.go b/input.go index 7937aa51c..32e0452e6 100644 --- a/input.go +++ b/input.go @@ -546,7 +546,12 @@ func (ip *inputParser) scan() { // the terminal to the host (queries in the other direction can use it.) // However, this is only true if the first parameter does not have a "?", // because it *does* collide with DEC private mode queries otherwise. - if r >= 0x30 && r <= 0x3F { // parameter bytes + if r == '\x1b' { + // Per ECMA-48 §5.3.1, ESC restarts the escape + // sequence machine from any intermediate state. + ip.state = istEsc + ip.escChar = 0 + } else if r >= 0x30 && r <= 0x3F { // parameter bytes ip.csiParams = append(ip.csiParams, byte(r)) } else if r == '$' && len(ip.csiParams) > 0 && ip.csiParams[0] != '?' { // rxvt non-standard ip.handleCsi(r, ip.csiParams, ip.csiInterm) @@ -565,7 +570,12 @@ func (ip *inputParser) scan() { case istSs3: // typically application mode keys or older terminals ip.state = istInit // some SS3 sequences (old VTE) encode modifiers here just like CSI - if r >= 0x30 && r <= 0x3F { + if r == '\x1b' { + // Per ECMA-48 §5.3.1, ESC restarts the escape + // sequence machine from any intermediate state. + ip.state = istEsc + ip.escChar = 0 + } else if r >= 0x30 && r <= 0x3F { ip.csiParams = append(ip.csiParams, byte(r)) ip.state = istSs3 } else if k, ok := ss3Keys[r]; ok { diff --git a/input_test.go b/input_test.go index 91541a70e..1a9b9af71 100644 --- a/input_test.go +++ b/input_test.go @@ -823,3 +823,69 @@ func TestKeyboardMode(t *testing.T) { }) } } + +// TestEscDuringCsiResetsParser verifies that an ESC byte received while +// the parser is in CSI state correctly transitions to escape state per +// ECMA-48 §5.3.1, rather than being swallowed as a "bad parse". +func TestEscDuringCsiResetsParser(t *testing.T) { + evch := make(chan Event, 10) + ip := newInputParser(evch) + + // Feed a partial CSI (ESC [) followed immediately by a new escape + // sequence for Down arrow (ESC [ B). Without the fix, the second + // ESC would be swallowed and 'B' would be emitted as a literal key. + ip.ScanUTF8([]byte("\x1b[\x1b[B")) + + var got *EventKey + for { + select { + case ev := <-evch: + if kev, ok := ev.(*EventKey); ok { + got = kev + } + continue + case <-time.After(100 * time.Millisecond): + } + break + } + + if got == nil { + t.Fatal("expected a key event, got none") + } + if got.Key() != KeyDown { + t.Errorf("expected KeyDown, got key=%v str=%q mod=%v", got.Key(), got.Str(), got.Modifiers()) + } +} + +// TestEscDuringSs3ResetsParser verifies that an ESC byte received while +// the parser is accumulating SS3 parameters correctly transitions to +// escape state per ECMA-48 §5.3.1. +func TestEscDuringSs3ResetsParser(t *testing.T) { + evch := make(chan Event, 10) + ip := newInputParser(evch) + + // Feed a partial SS3 with parameter (ESC O 1) followed immediately + // by a new escape sequence for Down arrow (ESC [ B). Without the + // fix, the ESC would be lost inside the SS3 parameter accumulation. + ip.ScanUTF8([]byte("\x1bO1\x1b[B")) + + var got *EventKey + for { + select { + case ev := <-evch: + if kev, ok := ev.(*EventKey); ok { + got = kev + } + continue + case <-time.After(100 * time.Millisecond): + } + break + } + + if got == nil { + t.Fatal("expected a key event, got none") + } + if got.Key() != KeyDown { + t.Errorf("expected KeyDown, got key=%v str=%q mod=%v", got.Key(), got.Str(), got.Modifiers()) + } +} From 73733d6a0206142c554433d7404148dd2da20a29 Mon Sep 17 00:00:00 2001 From: ModeEngage Date: Sat, 11 Apr 2026 10:47:18 -0400 Subject: [PATCH 19/32] test: use firstKey helper to capture first event, not last The drain loop in the ESC-during-CSI/SS3 tests was keeping the last EventKey, which could be overwritten by the escTimeout timer firing during the 100ms drain window. Capture the first EventKey instead. --- input_test.go | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/input_test.go b/input_test.go index 1a9b9af71..439f3d422 100644 --- a/input_test.go +++ b/input_test.go @@ -824,6 +824,26 @@ func TestKeyboardMode(t *testing.T) { } } +// firstKey drains the event channel and returns the first EventKey received, +// or nil if none arrives within 100ms. +func firstKey(evch chan Event) *EventKey { + var got *EventKey + for { + select { + case ev := <-evch: + if got == nil { + if kev, ok := ev.(*EventKey); ok { + got = kev + } + } + continue + case <-time.After(100 * time.Millisecond): + } + break + } + return got +} + // TestEscDuringCsiResetsParser verifies that an ESC byte received while // the parser is in CSI state correctly transitions to escape state per // ECMA-48 §5.3.1, rather than being swallowed as a "bad parse". @@ -836,19 +856,7 @@ func TestEscDuringCsiResetsParser(t *testing.T) { // ESC would be swallowed and 'B' would be emitted as a literal key. ip.ScanUTF8([]byte("\x1b[\x1b[B")) - var got *EventKey - for { - select { - case ev := <-evch: - if kev, ok := ev.(*EventKey); ok { - got = kev - } - continue - case <-time.After(100 * time.Millisecond): - } - break - } - + got := firstKey(evch) if got == nil { t.Fatal("expected a key event, got none") } @@ -869,19 +877,7 @@ func TestEscDuringSs3ResetsParser(t *testing.T) { // fix, the ESC would be lost inside the SS3 parameter accumulation. ip.ScanUTF8([]byte("\x1bO1\x1b[B")) - var got *EventKey - for { - select { - case ev := <-evch: - if kev, ok := ev.(*EventKey); ok { - got = kev - } - continue - case <-time.After(100 * time.Millisecond): - } - break - } - + got := firstKey(evch) if got == nil { t.Fatal("expected a key event, got none") } From e384873b2f2c6dcfb58b301c0c5e22fdfb0f8f13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:31:47 +0000 Subject: [PATCH 20/32] chore(deps): bump golang.org/x/sys from 0.42.0 to 0.43.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.42.0 to 0.43.0. - [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0b1f4e70d..7e87d79e0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/gdamore/encoding v1.0.1 github.com/lucasb-eyer/go-colorful v1.4.0 github.com/rivo/uniseg v0.4.7 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.41.0 golang.org/x/text v0.35.0 ) diff --git a/go.sum b/go.sum index fad699df2..59c6aa811 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From d053f9e77616f178f8dc805c6616cf2d4f6e4357 Mon Sep 17 00:00:00 2001 From: Tubbles Date: Fri, 17 Apr 2026 18:16:15 +0200 Subject: [PATCH 21/32] feat(events): add Unwrap() to EventError Expose the underlying error payload so callers can use errors.Is / errors.As to match against sentinel values such as io.EOF. Assisted-by: Claude:claude-opus-4-7 --- errors.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/errors.go b/errors.go index 4bf5d062f..db17b735f 100644 --- a/errors.go +++ b/errors.go @@ -50,6 +50,13 @@ func (ev *EventError) Error() string { return ev.err.Error() } +// Unwrap exposes the underlying error payload so callers can use +// errors.Is / errors.As to match against sentinel values such as +// io.EOF. +func (ev *EventError) Unwrap() error { + return ev.err +} + // NewEventError creates an ErrorEvent with the given error payload. func NewEventError(err error) *EventError { ev := &EventError{err: err} From 5a901900421880e6ee82053051c505553a752972 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:55:45 +0000 Subject: [PATCH 22/32] chore(deps): bump golang.org/x/text from 0.35.0 to 0.36.0 Bumps [golang.org/x/text](https://github.com/golang/text) from 0.35.0 to 0.36.0. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7e87d79e0..6f016add4 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,5 @@ require ( github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.43.0 golang.org/x/term v0.41.0 - golang.org/x/text v0.35.0 + golang.org/x/text v0.36.0 ) diff --git a/go.sum b/go.sum index 59c6aa811..4798abe0f 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 759b3fdc00f8d88b3aa9ad2923b38ebc643204f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:51:10 +0000 Subject: [PATCH 23/32] chore(deps): bump golang.org/x/term from 0.41.0 to 0.42.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.41.0 to 0.42.0. - [Commits](https://github.com/golang/term/compare/v0.41.0...v0.42.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6f016add4..f3643677c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.43.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 ) diff --git a/go.sum b/go.sum index 4798abe0f..04271358c 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= From 14f3ad493cf3e039835fc1e23256f95c8e2b73df Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 19 Apr 2026 11:24:28 -0700 Subject: [PATCH 24/32] Move away from uniseg to faster clipperhouse/displaywidth Use an intern string cache to reduce allocations. This has improved correctness, and substantial performance improvements. --- .superset/config.json | 7 + cell.go | 20 +- cell_bench_test.go | 85 ++++++++ eastasian.go | 15 +- go.mod | 4 +- go.sum | 6 +- internal/widthutil/widthutil.go | 31 +++ vt/cache_sweep_test.go | 103 ++++++++++ vt/coverage_test.go | 343 ++++++++++++++++++++++++++++++++ vt/emulate.go | 265 ++++++++++++++++++------ vt/emulate_bench_test.go | 82 ++++++++ vt/emulate_internal_test.go | 75 ++++++- vt/width.go | 19 ++ width_bench_test.go | 55 +++++ 14 files changed, 1017 insertions(+), 93 deletions(-) create mode 100644 .superset/config.json create mode 100644 cell_bench_test.go create mode 100644 internal/widthutil/widthutil.go create mode 100644 vt/cache_sweep_test.go create mode 100644 vt/coverage_test.go create mode 100644 vt/emulate_bench_test.go create mode 100644 vt/width.go create mode 100644 width_bench_test.go diff --git a/.superset/config.json b/.superset/config.json new file mode 100644 index 000000000..8308bd2f4 --- /dev/null +++ b/.superset/config.json @@ -0,0 +1,7 @@ +{ + "setup": [ + "go mod download" + ], + "teardown": [], + "run": [] +} \ No newline at end of file diff --git a/cell.go b/cell.go index 30e23ba00..eb2e92f76 100644 --- a/cell.go +++ b/cell.go @@ -1,4 +1,4 @@ -// Copyright 2025 The TCell Authors +// 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. @@ -14,10 +14,6 @@ package tcell -import ( - "github.com/rivo/uniseg" -) - type cell struct { currStr string lastStr string @@ -60,14 +56,12 @@ func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) { if x >= 0 && y >= 0 && x < cb.w && y < cb.h { var cl string c := &cb.cells[(y*cb.w)+x] - state := -1 - for width == 0 && str != "" { - var g string - g, str, width, state = uniseg.FirstGraphemeClusterInString(str, state) - cl += g - if g == "" { - break - } + g := textWidthOptions.StringGraphemes(str) + for width == 0 && g.Next() { + cluster := g.Value() + cl += cluster + width = g.Width() + str = str[len(cluster):] } // Wide characters: we want to mark the "wide" cells diff --git a/cell_bench_test.go b/cell_bench_test.go new file mode 100644 index 000000000..43d94f68a --- /dev/null +++ b/cell_bench_test.go @@ -0,0 +1,85 @@ +// 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 ( + "strings" + "testing" +) + +var mixedCellStream = strings.Repeat("Hello, 世界 👩‍🚀 e\u0301 ", 8) + +func BenchmarkCellBufferPutCurrent(b *testing.B) { + benchCellBufferPut(b, "current", func(cb *CellBuffer, x, y int, str string, style Style) (string, int) { + return cb.Put(x, y, str, style) + }) +} + +func BenchmarkCellBufferPutStreamCurrent(b *testing.B) { + benchCellBufferPutStream(b, "current", func(cb *CellBuffer, x int, y int, str string, style Style) (string, int) { + return cb.Put(x, y, str, style) + }) +} + +func benchCellBufferPut(b *testing.B, name string, put func(*CellBuffer, int, int, string, Style) (string, int)) { + cases := []struct { + name string + str string + }{ + {name: "ascii", str: "Hello, terminal"}, + {name: "combining", str: "e\u0301"}, + {name: "wide", str: "宽"}, + {name: "emoji", str: "👩‍🚀"}, + } + + for _, tc := range cases { + b.Run(name+"/"+tc.name, func(b *testing.B) { + cb := &CellBuffer{w: 8, h: 1, cells: make([]cell, 8)} + style := Style{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + cb.cells[0] = cell{} + _, _ = put(cb, 0, 0, tc.str, style) + } + }) + } +} + +func benchCellBufferPutStream(b *testing.B, name string, put func(*CellBuffer, int, int, string, Style) (string, int)) { + b.Helper() + + b.Run(name+"/mixed-stream", func(b *testing.B) { + cb := &CellBuffer{w: 128, h: 1, cells: make([]cell, 128)} + style := Style{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for j := range cb.cells { + cb.cells[j] = cell{} + } + x := 0 + rest := mixedCellStream + for rest != "" && x < cb.w { + var width int + rest, width = put(cb, x, 0, rest, style) + if width == 0 { + break + } + x += width + } + } + }) +} diff --git a/eastasian.go b/eastasian.go index cd4450849..dd3abc0d0 100644 --- a/eastasian.go +++ b/eastasian.go @@ -1,4 +1,4 @@ -// Copyright 2025 The TCell Authors +// 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. @@ -15,16 +15,7 @@ package tcell import ( - "os" - "strings" - - "github.com/rivo/uniseg" + "github.com/gdamore/tcell/v3/internal/widthutil" ) -func init() { - if rw := strings.ToLower(os.Getenv("RUNEWIDTH_EASTASIAN")); rw == "1" || rw == "true" || rw == "yes" { - uniseg.EastAsianAmbiguousWidth = 2 - } else { - uniseg.EastAsianAmbiguousWidth = 1 - } -} +var textWidthOptions = widthutil.Options() diff --git a/go.mod b/go.mod index f3643677c..a2c804ea7 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/gdamore/tcell/v3 go 1.25.0 require ( + github.com/clipperhouse/displaywidth v0.11.0 github.com/gdamore/encoding v1.0.1 github.com/lucasb-eyer/go-colorful v1.4.0 - github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 ) + +require github.com/clipperhouse/uax29/v2 v2.7.0 diff --git a/go.sum b/go.sum index 04271358c..005885270 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/internal/widthutil/widthutil.go b/internal/widthutil/widthutil.go new file mode 100644 index 000000000..7ab693181 --- /dev/null +++ b/internal/widthutil/widthutil.go @@ -0,0 +1,31 @@ +// 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 widthutil + +import ( + "os" + "strings" + + "github.com/clipperhouse/displaywidth" +) + +// Options returns the display-width options derived from the user's +// environment. We preserve the historical RUNEWIDTH_EASTASIAN toggle. +func Options() displaywidth.Options { + if rw := strings.ToLower(os.Getenv("RUNEWIDTH_EASTASIAN")); rw == "1" || rw == "true" || rw == "yes" { + return displaywidth.Options{EastAsianWidth: true} + } + return displaywidth.Options{} +} diff --git a/vt/cache_sweep_test.go b/vt/cache_sweep_test.go new file mode 100644 index 000000000..b28524122 --- /dev/null +++ b/vt/cache_sweep_test.go @@ -0,0 +1,103 @@ +// 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 vt + +import ( + "fmt" + "testing" + "unicode/utf8" +) + +type benchRuneCache struct { + entries []benchRuneCacheEntry + n int +} + +type benchRuneCacheEntry struct { + r rune + s string +} + +func newBenchRuneCache(size int) *benchRuneCache { + return &benchRuneCache{entries: make([]benchRuneCacheEntry, size)} +} + +func (c *benchRuneCache) stringFor(r rune) string { + if r < utf8.RuneSelf { + return asciiRuneStrings[r] + } + + for i := 0; i < c.n; i++ { + if c.entries[i].r == r { + return c.entries[i].s + } + } + + s := string(r) + if c.n < len(c.entries) { + c.n++ + } + copy(c.entries[1:c.n], c.entries[:c.n-1]) + c.entries[0] = benchRuneCacheEntry{r: r, s: s} + return s +} + +var sweepMixedRunes32 = []rune{ + '\u0416', '\u0414', '\u042E', '\u042F', + '\u041F', '\u041B', '\u0424', '\u042B', + '\u042D', '\u0411', '\u0413', '\u0428', + '\u00E9', '\u00F6', '\u00FC', '\u00F1', + '\u00E7', '\u00F8', '\u00E5', '\u00DF', + '\u0142', '\u0111', '\u0127', '\u0131', + '\U0001F600', '\U0001F680', '\U0001F9EA', '\U0001F525', + '\U0001F355', '\U0001F389', '\U0001F4BB', '\U0001F4E6', +} + +var sweepMixedRunes64 = append(append([]rune{}, sweepMixedRunes32...), + '\u0105', '\u0107', '\u0119', '\u0123', + '\u0135', '\u0137', '\u0144', '\u015B', + '\u015F', '\u0163', '\u0165', '\u017A', + '\U0001F31F', '\U0001F4A1', '\U0001F4AF', '\U0001F680', + '\U0001F9F0', '\U0001F9D1', '\U0001F9EB', '\U0001FAE0', + '\u250C', '\u2510', '\u2514', '\u2518', + '\u2500', '\u2502', '\u251C', '\u2524', + '\u252C', '\u2534', '\u253C', '\u2571', +) + +func BenchmarkRuneStringCacheSweep(b *testing.B) { + for _, tc := range []struct { + name string + seq []rune + }{ + {name: "mixed32", seq: sweepMixedRunes32}, + {name: "mixed64", seq: sweepMixedRunes64}, + } { + b.Run(tc.name, func(b *testing.B) { + for _, size := range []int{8, 16, 32, 64} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + cache := newBenchRuneCache(size) + for _, r := range tc.seq { + _ = cache.stringFor(r) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = cache.stringFor(tc.seq[i%len(tc.seq)]) + } + }) + } + }) + } +} diff --git a/vt/coverage_test.go b/vt/coverage_test.go new file mode 100644 index 000000000..0de65d12c --- /dev/null +++ b/vt/coverage_test.go @@ -0,0 +1,343 @@ +// 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 vt + +import ( + "testing" + + "github.com/gdamore/tcell/v3/color" +) + +func TestCursorStyleHelpers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style CursorStyle + visible bool + blinking bool + }{ + {name: "steady block", style: SteadyBlock, visible: true, blinking: false}, + {name: "steady bar", style: SteadyBar, visible: true, blinking: false}, + {name: "steady underline", style: SteadyUnderline, visible: true, blinking: false}, + {name: "blinking block", style: BlinkingBlock, visible: true, blinking: true}, + {name: "hidden blink", style: BlinkingBar.Hide(), visible: false, blinking: true}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.style.IsVisible(); got != tc.visible { + t.Fatalf("IsVisible() = %v, want %v", got, tc.visible) + } + if got := tc.style.IsBlinking(); got != tc.blinking { + t.Fatalf("IsBlinking() = %v, want %v", got, tc.blinking) + } + + if got := tc.style.Hide().IsVisible(); got { + t.Fatalf("Hide() should clear visibility") + } + if got := tc.style.Show().IsVisible(); !got { + t.Fatalf("Show() should set visibility") + } + if got := tc.style.Blink().IsBlinking(); !got { + t.Fatalf("Blink() should set blinking") + } + if got := tc.style.Steady().IsBlinking(); got { + t.Fatalf("Steady() should clear blinking") + } + }) + } +} + +func TestModeFormatters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pm PrivateMode + enable string + disable string + query string + replyOn string + replyOff string + ansi AnsiMode + ansiEnable string + ansiDisable string + ansiQuery string + ansiReply string + }{ + { + name: "auto margin", + pm: PmAutoMargin, + enable: "\x1b[?7h", + disable: "\x1b[?7l", + query: "\x1b[?7$p", + replyOn: "\x1b[?7;1$y", + replyOff: "\x1b[?7;2$y", + ansi: AmNewLineMode, + ansiEnable: "\x1b[20h", + ansiDisable: "\x1b[20l", + ansiQuery: "\x1b[20$p", + ansiReply: "\x1b[20;1$y", + }, + } + + for _, tc := range tests { + if got := tc.pm.Enable(); got != tc.enable { + t.Fatalf("PrivateMode.Enable() = %q, want %q", got, tc.enable) + } + if got := tc.pm.Disable(); got != tc.disable { + t.Fatalf("PrivateMode.Disable() = %q, want %q", got, tc.disable) + } + if got := tc.pm.Query(); got != tc.query { + t.Fatalf("PrivateMode.Query() = %q, want %q", got, tc.query) + } + if got := tc.pm.Reply(ModeOn); got != tc.replyOn { + t.Fatalf("PrivateMode.Reply(ModeOn) = %q, want %q", got, tc.replyOn) + } + if got := tc.pm.Reply(ModeOff); got != tc.replyOff { + t.Fatalf("PrivateMode.Reply(ModeOff) = %q, want %q", got, tc.replyOff) + } + + if got := tc.ansi.Enable(); got != tc.ansiEnable { + t.Fatalf("AnsiMode.Enable() = %q, want %q", got, tc.ansiEnable) + } + if got := tc.ansi.Disable(); got != tc.ansiDisable { + t.Fatalf("AnsiMode.Disable() = %q, want %q", got, tc.ansiDisable) + } + if got := tc.ansi.Query(); got != tc.ansiQuery { + t.Fatalf("AnsiMode.Query() = %q, want %q", got, tc.ansiQuery) + } + if got := tc.ansi.Reply(ModeOn); got != tc.ansiReply { + t.Fatalf("AnsiMode.Reply(ModeOn) = %q, want %q", got, tc.ansiReply) + } + } +} + +func TestModeStatusChangeable(t *testing.T) { + t.Parallel() + + cases := []struct { + status ModeStatus + want bool + }{ + {ModeNA, false}, + {ModeOn, true}, + {ModeOff, true}, + {ModeOnLocked, false}, + {ModeOffLocked, false}, + } + + for _, tc := range cases { + if got := tc.status.Changeable(); got != tc.want { + t.Fatalf("Changeable(%v) = %v, want %v", tc.status, got, tc.want) + } + } +} + +func TestMockBackendResizeAndBells(t *testing.T) { + t.Parallel() + + mb := NewMockBackend(MockOptSize{X: 2, Y: 2}, MockOptColors(0)).(*mockBackend) + + if got := mb.Bells(); got != 0 { + t.Fatalf("initial bells = %d, want 0", got) + } + mb.Beep() + mb.Beep() + if got := mb.Bells(); got != 2 { + t.Fatalf("bells after beep = %d, want 2", got) + } + + mb.Put(Coord{X: 0, Y: 0}, Cell{C: "x", S: BaseStyle, W: 1}) + mb.SetPosition(Coord{X: 1, Y: 1}) + mb.SetSize(Coord{X: 3, Y: 1}) + if got := mb.GetSize(); got != (Coord{X: 3, Y: 1}) { + t.Fatalf("GetSize() = %v, want {3 1}", got) + } + + if got := mb.GetCell(Coord{X: 0, Y: 0}); got.C != "x" || got.W != 1 { + t.Fatalf("resized cell preserved incorrectly: %#v", got) + } + if got := mb.GetCell(Coord{X: 2, Y: 0}); got.S != BaseStyle { + t.Fatalf("new cell style = %#v, want BaseStyle", got.S) + } +} + +func TestStringCacheHelpers(t *testing.T) { + t.Parallel() + + mb := NewMockBackend(MockOptSize{X: 4, Y: 1}, MockOptColors(0)).(*mockBackend) + em := NewEmulator(mb).(*emulator) + + if got := em.runeString('a'); got != "a" { + t.Fatalf("runeString(ascii) = %q, want %q", got, "a") + } + if got := em.runeString('\u03c0'); got != "π" { + t.Fatalf("runeString(non-ascii) = %q, want %q", got, "π") + } + if got := em.runeString('\u03c0'); got != "π" { + t.Fatalf("runeString cache hit returned %q, want %q", got, "π") + } + + if got := em.clusterString([]byte("e\u0301")); got != "e\u0301" { + t.Fatalf("clusterString(combining) = %q, want %q", got, "e\u0301") + } + if got := em.clusterString([]byte("e\u0301")); got != "e\u0301" { + t.Fatalf("clusterString cache hit returned %q, want %q", got, "e\u0301") + } +} + +func TestEmulatorBellDispatch(t *testing.T) { + t.Parallel() + + mb := NewMockBackend(MockOptSize{X: 4, Y: 1}, MockOptColors(0)).(*mockBackend) + em := NewEmulator(mb).(*emulator) + em.style = BaseStyle.WithFg(color.White).WithBg(color.Black) + em.defaultStyle = em.style + + em.beep() + if got := mb.Bells(); got != 1 { + t.Fatalf("bell count = %d, want 1", got) + } +} + +func TestShouldCheckGrapheme(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prev byte + r rune + want bool + }{ + {name: "crlf", prev: '\r', r: '\n', want: true}, + {name: "ascii false", prev: 'a', r: 'b', want: false}, + {name: "mark", prev: 'e', r: '\u0301', want: true}, + {name: "zwj", prev: 'x', r: '\u200d', want: true}, + {name: "vs16", prev: 'x', r: '\uFE0F', want: true}, + {name: "supplementary vs", prev: 'x', r: '\U000E0100', want: true}, + {name: "plain non-ascii", prev: 'x', r: 'π', want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := shouldCheckGrapheme(tc.prev, tc.r); got != tc.want { + t.Fatalf("shouldCheckGrapheme(%q, %q) = %v, want %v", tc.prev, tc.r, got, tc.want) + } + }) + } +} + +func TestPutRunePaths(t *testing.T) { + newEm := func(width int) *emulator { + em := NewEmulator(NewMockBackend(MockOptSize{X: Col(width), Y: 1}, MockOptColors(0))).(*emulator) + em.style = BaseStyle.WithFg(color.White).WithBg(color.Black) + em.defaultStyle = em.style + em.localModes[PmGraphemeClusters] = ModeOn + em.localModes[PmAutoMargin] = ModeOn + em.cells[0].S = em.style + em.cells[0].W = 1 + return em + } + + t.Run("ascii falls through", func(t *testing.T) { + em := newEm(4) + em.cells[0].C = "a" + em.lastIndex = 1 + em.setPosition(Coord{X: 1, Y: 0}) + + em.putRune('b') + + if got := em.cells[1].C; got != "b" { + t.Fatalf("cell[1].C = %q, want %q", got, "b") + } + if got := em.cells[0].C; got != "a" { + t.Fatalf("cell[0].C = %q, want %q", got, "a") + } + }) + + t.Run("combining mark extends cluster", func(t *testing.T) { + em := newEm(4) + em.cells[0].C = "e" + em.lastIndex = 1 + em.setPosition(Coord{X: 1, Y: 0}) + + em.putRune('\u0301') + + if got := em.cells[0].C; got != "e\u0301" { + t.Fatalf("cell[0].C = %q, want %q", got, "e\u0301") + } + if got := em.cells[0].W; got != 1 { + t.Fatalf("cell[0].W = %d, want 1", got) + } + }) + + t.Run("wide grapheme clears next cell", func(t *testing.T) { + em := newEm(4) + em.cells[0].C = "\u2764" + em.cells[1].C = "x" + em.cells[1].S = em.style + em.cells[1].W = 1 + em.lastIndex = 1 + em.setPosition(Coord{X: 1, Y: 0}) + + em.putRune('\uFE0F') + + if got := em.cells[0].W; got != 2 { + t.Fatalf("cell[0].W = %d, want 2", got) + } + if got := em.cells[1].C; got != "" || em.cells[1].W != 0 { + t.Fatalf("cell[1] not cleared: %#v", em.cells[1]) + } + }) + + t.Run("auto wrap when width reaches margin", func(t *testing.T) { + em := newEm(2) + em.cells[0].C = "a" + em.lastIndex = 1 + em.setPosition(Coord{X: 1, Y: 0}) + + em.putRune('宽') + + if !em.autoWrap { + t.Fatalf("autoWrap = false, want true") + } + }) + + t.Run("grapheme extension at margin preserves wrap", func(t *testing.T) { + em := newEm(2) + em.cells[1].C = "\u2764" + em.cells[1].S = em.style + em.cells[1].W = 1 + em.lastIndex = 2 + em.setPosition(Coord{X: 1, Y: 0}) + + em.putRune('\uFE0F') + + if !em.autoWrap { + t.Fatalf("autoWrap = false, want true") + } + if got := em.getPosition(); got.X != 1 { + t.Fatalf("cursor X = %d, want 1", got.X) + } + }) +} diff --git a/vt/emulate.go b/vt/emulate.go index 514b081f0..b2dd2129b 100644 --- a/vt/emulate.go +++ b/vt/emulate.go @@ -24,9 +24,11 @@ import ( "strconv" "strings" "sync" + "unicode" + "unicode/utf8" + "github.com/clipperhouse/uax29/v2/graphemes" "github.com/gdamore/tcell/v3/color" - "github.com/rivo/uniseg" ) // Emulator is a terminal emulator API. It implements the state machinery @@ -105,6 +107,87 @@ type styleStruct struct { var BaseStyle = &styleStruct{} +var asciiRuneStrings = func() [utf8.RuneSelf]string { + var table [utf8.RuneSelf]string + for i := 0; i < utf8.RuneSelf; i++ { + table[i] = string(rune(i)) + } + return table +}() + +const ( + runeStringCacheSize = 32 + clusterStringCacheSize = 32 + clusterStringCacheMaxLen = 128 +) + +type runeStringCache struct { + entries [runeStringCacheSize]runeStringCacheEntry + n int +} + +type runeStringCacheEntry struct { + r rune + s string +} + +func (c *runeStringCache) stringFor(r rune) string { + for i := 0; i < c.n; i++ { + if c.entries[i].r == r { + return c.entries[i].s + } + } + + s := string(r) + n := c.n + if n < len(c.entries) { + n++ + } + copy(c.entries[1:n], c.entries[:n-1]) + c.entries[0] = runeStringCacheEntry{r: r, s: s} + c.n = n + return s +} + +type clusterStringCache struct { + entries [clusterStringCacheSize]clusterStringCacheEntry + n int +} + +type clusterStringCacheEntry struct { + n int + b [clusterStringCacheMaxLen]byte + s string +} + +func (c *clusterStringCache) stringFor(cluster []byte) string { + if len(cluster) == 0 { + return "" + } + if len(cluster) > clusterStringCacheMaxLen { + return string(cluster) + } + for i := 0; i < c.n; i++ { + e := &c.entries[i] + if e.n == len(cluster) && bytes.Equal(e.b[:e.n], cluster) { + return e.s + } + } + + s := string(cluster) + n := c.n + if n < len(c.entries) { + n++ + } + copy(c.entries[1:n], c.entries[:n-1]) + e := &c.entries[0] + e.n = len(cluster) + copy(e.b[:], cluster) + e.s = s + c.n = n + return s +} + func (ss *styleStruct) Fg() color.Color { return ss.fg } func (ss *styleStruct) Bg() color.Color { return ss.bg } func (ss *styleStruct) Uc() color.Color { return ss.uc } @@ -190,6 +273,7 @@ func NewEmulator(be Backend) Emulator { em.ltMargin = 0 em.rtMargin = em.size.X - 1 em.cells = make([]Cell, int(em.size.X)*int(em.size.Y)) + em.graphemeIter = *graphemes.FromBytes(nil) close(stopQ) em.inb = em.inbInit em.cursor = BlinkingBlock @@ -201,36 +285,39 @@ func NewEmulator(be Backend) Emulator { // a Backend. It implements the common escape sequence handling and high // level functionality that a real terminal emulator, or a mock, would need. type emulator struct { - stopQ chan bool - writeQ chan any // queues data from application to emulator - readQ chan any // queues data from emulator to application - be Backend - inBuf *bytes.Buffer // buffer queued for input - inb func(byte) // input byte function (faster than state switch) - style Style - defaultStyle Style - utfLen int - pos Coord - buffering uint // reference count - number of (re-entrant) buffering calls - autoWrap bool // next character will wrap (auto margin, deferred until char emitted) - sevenOnly bool // only allow 7-bit escapes (needed for KOI8, ShiftJIS, etc.) - appKeyPad bool // use application key pad keys? - name string // name of this emulator (used for extended attributes) - vers string // version string of this emulator (used for extended attributes) - savedPos Coord // saved via DECSC - saved savedCursor // data saved by save cursor (DECSC) - sendLock sync.Mutex // ensures that send data cannot be intermixed - modeLock sync.RWMutex // protects localModes/ansiModes and related derived state - tabStops []Col // tab stops, ordered. if nil every 8th position is used - lastIndex int // index of last cell written + 1 (for grapheme clustering) (zero means none) - cells []Cell // content of cells, we have to maintain our own copy (backend might or might not) - mouseReports MouseReporting // whether we have enabled mouse reports - size Coord // physical window size - topMargin Row // top margin, scrollable region includes this row - botMargin Row // bottom margin, scrollable region includes this row - ltMargin Col // left margin, scrollable region to the right - rtMargin Col // right margin, scrollable region to the left - cursor CursorStyle // current cursor style (visibility, blink, shape) + stopQ chan bool + writeQ chan any // queues data from application to emulator + readQ chan any // queues data from emulator to application + be Backend + inBuf *bytes.Buffer // buffer queued for input + inb func(byte) // input byte function (faster than state switch) + style Style + defaultStyle Style + utfLen int + pos Coord + buffering uint // reference count - number of (re-entrant) buffering calls + autoWrap bool // next character will wrap (auto margin, deferred until char emitted) + sevenOnly bool // only allow 7-bit escapes (needed for KOI8, ShiftJIS, etc.) + appKeyPad bool // use application key pad keys? + name string // name of this emulator (used for extended attributes) + vers string // version string of this emulator (used for extended attributes) + saved savedCursor // data saved by save cursor (DECSC) + sendLock sync.Mutex // ensures that send data cannot be intermixed + modeLock sync.RWMutex // protects localModes/ansiModes and related derived state + tabStops []Col // tab stops, ordered. if nil every 8th position is used + lastIndex int // index of last cell written + 1 (for grapheme clustering) (zero means none) + graphemeBuf []byte // scratch buffer for grapheme clustering checks + graphemeIter graphemes.Iterator[[]byte] + runeStrings runeStringCache + clusterStrings clusterStringCache + cells []Cell // content of cells, we have to maintain our own copy (backend might or might not) + mouseReports MouseReporting // whether we have enabled mouse reports + size Coord // physical window size + topMargin Row // top margin, scrollable region includes this row + botMargin Row // bottom margin, scrollable region includes this row + ltMargin Col // left margin, scrollable region to the right + rtMargin Col // right margin, scrollable region to the left + cursor CursorStyle // current cursor style (visibility, blink, shape) localModes map[PrivateMode]ModeStatus // some modes we handle locally ansiModes map[AnsiMode]ModeStatus // some modes we handle locally @@ -1796,30 +1883,60 @@ func (em *emulator) putRune(r rune) { if lastIdx := em.lastIndex; lastIdx != 0 { lastIdx-- if pm := em.getPrivateMode(PmGraphemeClusters); pm == ModeOn || pm == ModeOnLocked { - // maybe we need to update the last index - str := em.cells[lastIdx].C + string(r) - if cs, rest, width, _ := uniseg.FirstGraphemeClusterInString(str, -1); rest == "" { - // we are adding to a cluster - em.cells[lastIdx].C = cs - em.cells[lastIdx].W = width - col := Col(lastIdx) % dim.X - row := Row(lastIdx / int(dim.X)) - // we may have to move position if this switches to wide, so recalculate expected end - end := (col + Col(width)) % dim.X - if em.getPrivateMode(PmAutoMargin) == ModeOn && end >= dim.X { - em.autoWrap = true + // ASCII-to-ASCII pairs cannot extend a grapheme cluster, except CRLF. + prev := em.cells[lastIdx].C + if len(prev) == 1 && prev[0] < utf8.RuneSelf && !shouldCheckGrapheme(prev[0], r) { + // fall through to the normal single-rune path + } else { + // maybe we need to update the last index + buf := em.graphemeBuf[:0] + need := len(prev) + utf8.UTFMax + if cap(buf) < need { + buf = make([]byte, 0, need) } - if width == 2 && col < dim.X-1 && em.cells[lastIdx+1].W != 0 { - // erase the next cell before putting down a character - em.cells[lastIdx+1].C = "" - em.cells[lastIdx+1].S = em.cells[lastIdx].S - em.cells[lastIdx+1].W = 0 - em.be.Put(Coord{X: col + 1, Y: row}, em.cells[lastIdx+1]) + buf = append(buf, prev...) + buf = utf8.AppendRune(buf, r) + em.graphemeIter.SetText(buf) + if em.graphemeIter.Next() && len(em.graphemeIter.Value()) == len(buf) { + // we are adding to a cluster + cluster := em.graphemeIter.Value() + width := em.cells[lastIdx].W + if w := textWidthOptions.Rune(r); w > width { + width = w + } + if isRegionalIndicator(r) && width < 2 { + width = 2 + } + if r == '\uFE0F' && width < 2 { + width = 2 + } + em.cells[lastIdx].C = em.clusterString(cluster) + em.cells[lastIdx].W = width + col := Col(lastIdx) % dim.X + row := Row(lastIdx / int(dim.X)) + // we may have to move position if this switches to wide, so recalculate expected end + next := col + Col(width) + if em.getPrivateMode(PmAutoMargin) == ModeOn && next >= dim.X { + em.autoWrap = true + } + end := next + if end >= dim.X { + end = dim.X - 1 + } + if width == 2 && col < dim.X-1 && em.cells[lastIdx+1].W != 0 { + // erase the next cell before putting down a character + em.cells[lastIdx+1].C = "" + em.cells[lastIdx+1].S = em.cells[lastIdx].S + em.cells[lastIdx+1].W = 0 + em.be.Put(Coord{X: col + 1, Y: row}, em.cells[lastIdx+1]) + } + // we leave the em.lastIndex for now, we might keep extending this cluster + em.be.Put(Coord{X: col, Y: row}, em.cells[lastIdx]) + em.setPosition(Coord{X: end, Y: row}) + em.graphemeBuf = buf[:0] + return } - // we leave the em.lastIndex for now, we might keep extending this cluster - em.be.Put(Coord{X: col, Y: row}, em.cells[lastIdx]) - em.setPosition(Coord{X: end, Y: row}) - return + em.graphemeBuf = buf[:0] } } } @@ -1831,12 +1948,12 @@ func (em *emulator) putRune(r rune) { autoMargin := em.getPrivateMode(PmAutoMargin) == ModeOn pos := em.getPosition() - w := uniseg.StringWidth(string(r)) + w := textWidthOptions.Rune(r) if autoMargin && pos.X+Col(w) >= dim.X { em.autoWrap = true } index := em.index(pos) - em.cells[index].C = string(r) + em.cells[index].C = em.runeString(r) em.cells[index].S = em.style em.cells[index].W = w em.be.Put(em.pos, em.cells[index]) @@ -1854,6 +1971,42 @@ func (em *emulator) putRune(r rune) { em.moveRightN(Col(w)) } +func (em *emulator) runeString(r rune) string { + if r < utf8.RuneSelf { + return asciiRuneStrings[r] + } + return em.runeStrings.stringFor(r) +} + +func (em *emulator) clusterString(cluster []byte) string { + return em.clusterStrings.stringFor(cluster) +} + +func shouldCheckGrapheme(prev byte, r rune) bool { + if r < utf8.RuneSelf { + return prev == '\r' && r == '\n' + } + + if unicode.Is(unicode.M, r) { + return true + } + if r == '\u200d' { + return true + } + if r >= 0xFE00 && r <= 0xFE0F { + return true + } + if r >= 0xE0100 && r <= 0xE01EF { + return true + } + + return false +} + +func isRegionalIndicator(r rune) bool { + return r >= 0x1F1E6 && r <= 0x1F1FF +} + // eraseCell erases a single cell at the given offset. // It clears attributes, but leaves the colors intact. func (em *emulator) eraseCell(c Coord) { @@ -2544,7 +2697,7 @@ func (em *emulator) MouseEvent(ev MouseEvent) { // to be released. (Please use SGR mode if at all possible.) // Further, this mode is not CSI compliant as the encoded values that arrive ahead of // the final character may be within the range of technically legal CSI final bytes. - if ev.Down == false { + if !ev.Down { ev.Button = NoButton } btn := ev.encodeButton() diff --git a/vt/emulate_bench_test.go b/vt/emulate_bench_test.go new file mode 100644 index 000000000..a4c0644d2 --- /dev/null +++ b/vt/emulate_bench_test.go @@ -0,0 +1,82 @@ +// 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 vt + +import ( + "testing" + + "github.com/gdamore/tcell/v3/color" +) + +func BenchmarkPutRuneCurrent(b *testing.B) { + benchPutRune(b, "current", (*emulator).putRune) +} + +func benchPutRune(b *testing.B, name string, put func(*emulator, rune)) { + cases := []struct { + name string + r rune + seq []rune + }{ + {name: "ascii", r: 'a'}, + {name: "width", r: 'π'}, + {name: "wide", r: '宽'}, + {name: "combining", r: '\u0301'}, + {name: "line", seq: []rune{ + '\u250C', '\u2510', '\u2514', '\u2518', + '\u2500', '\u2502', '\u251C', '\u2524', + '\u252C', '\u2534', '\u253C', '\u256D', + '\u256E', '\u256F', '\u2570', '\u2571', + }}, + {name: "mixed32", seq: []rune{ + '\u0416', '\u0414', '\u042E', '\u042F', + '\u041F', '\u041B', '\u0424', '\u042B', + '\u042D', '\u0411', '\u0413', '\u0428', + '\u00E9', '\u00F6', '\u00FC', '\u00F1', + '\u00E7', '\u00F8', '\u00E5', '\u00DF', + '\u0142', '\u0111', '\u0127', '\u0131', + '\U0001F600', '\U0001F680', '\U0001F9EA', '\U0001F525', + '\U0001F355', '\U0001F389', '\U0001F4BB', '\U0001F4E6', + }}, + {name: "mixed64", seq: sweepMixedRunes64}, + } + + for _, tc := range cases { + b.Run(name+"/"+tc.name, func(b *testing.B) { + em := NewEmulator(NewMockBackend(MockOptSize{X: 8, Y: 1}, MockOptColors(0))).(*emulator) + em.style = BaseStyle.WithFg(color.White).WithBg(color.Black) + em.defaultStyle = em.style + em.localModes[PmGraphemeClusters] = ModeOn + em.localModes[PmAutoMargin] = ModeOn + em.cells[0].S = em.style + em.cells[0].W = 1 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + r := tc.r + if len(tc.seq) != 0 { + r = tc.seq[i%len(tc.seq)] + } + em.pos = Coord{X: 1, Y: 0} + em.lastIndex = 1 + em.autoWrap = false + em.cells[0].C = "e" + em.cells[0].S = em.style + em.cells[0].W = 1 + put(em, r) + } + }) + } +} diff --git a/vt/emulate_internal_test.go b/vt/emulate_internal_test.go index 69a0eda16..bbb5768b7 100644 --- a/vt/emulate_internal_test.go +++ b/vt/emulate_internal_test.go @@ -29,15 +29,15 @@ func (n noMouseBackend) GetPrivateMode(pm PrivateMode) ModeStatus { return n.mb. func (n noMouseBackend) SetPrivateMode(pm PrivateMode, status ModeStatus) error { return n.mb.SetPrivateMode(pm, status) } -func (n noMouseBackend) GetSize() Coord { return n.mb.GetSize() } -func (n noMouseBackend) Colors() int { return n.mb.Colors() } -func (n noMouseBackend) Put(pos Coord, cell Cell) { n.mb.Put(pos, cell) } -func (n noMouseBackend) GetPosition() Coord { return n.mb.GetPosition() } -func (n noMouseBackend) SetPosition(pos Coord) { n.mb.SetPosition(pos) } -func (n noMouseBackend) Reset() { n.mb.Reset() } -func (n noMouseBackend) RaiseResize() { n.mb.RaiseResize() } -func (n noMouseBackend) Buffering(enabled bool) { n.mb.Buffering(enabled) } -func (n noMouseBackend) SetCursor(cs CursorStyle) { n.mb.SetCursor(cs) } +func (n noMouseBackend) GetSize() Coord { return n.mb.GetSize() } +func (n noMouseBackend) Colors() int { return n.mb.Colors() } +func (n noMouseBackend) Put(pos Coord, cell Cell) { n.mb.Put(pos, cell) } +func (n noMouseBackend) GetPosition() Coord { return n.mb.GetPosition() } +func (n noMouseBackend) SetPosition(pos Coord) { n.mb.SetPosition(pos) } +func (n noMouseBackend) Reset() { n.mb.Reset() } +func (n noMouseBackend) RaiseResize() { n.mb.RaiseResize() } +func (n noMouseBackend) Buffering(enabled bool) { n.mb.Buffering(enabled) } +func (n noMouseBackend) SetCursor(cs CursorStyle) { n.mb.SetCursor(cs) } func TestEmulatorModeHelpers(t *testing.T) { mb := NewMockBackend().(*mockBackend) @@ -131,3 +131,60 @@ func TestMockBackendHelpers(t *testing.T) { MockOptNoBlit{}.SetMockOpt(mb) } + +func TestPutRuneGraphemeExtensions(t *testing.T) { + t.Parallel() + + newEm := func() *emulator { + em := NewEmulator(NewMockBackend(MockOptSize{X: 8, Y: 1}, MockOptColors(0))).(*emulator) + em.style = BaseStyle.WithFg(color.White).WithBg(color.Black) + em.defaultStyle = em.style + em.localModes[PmGraphemeClusters] = ModeOn + em.localModes[PmAutoMargin] = ModeOn + em.cells[0].S = em.style + em.cells[0].W = 1 + return em + } + + t.Run("combining", func(t *testing.T) { + em := newEm() + em.cells[0].C = "e" + em.lastIndex = 1 + em.pos = Coord{X: 1, Y: 0} + + em.putRune('\u0301') + + if got := em.cells[0].C; got != "e\u0301" { + t.Fatalf("unexpected cluster: %q", got) + } + if got := em.cells[0].W; got != 1 { + t.Fatalf("unexpected width: got %d, want 1", got) + } + }) + + t.Run("variation-selector", func(t *testing.T) { + em := newEm() + em.cells[0].C = "\u2764" + em.lastIndex = 1 + em.pos = Coord{X: 1, Y: 0} + + em.putRune('\uFE0F') + + if got := em.cells[0].W; got != 2 { + t.Fatalf("unexpected width: got %d, want 2", got) + } + }) + + t.Run("regional-indicator", func(t *testing.T) { + em := newEm() + em.cells[0].C = "\U0001F1E6" + em.lastIndex = 1 + em.pos = Coord{X: 1, Y: 0} + + em.putRune('\U0001F1E7') + + if got := em.cells[0].W; got != 2 { + t.Fatalf("unexpected width: got %d, want 2", got) + } + }) +} diff --git a/vt/width.go b/vt/width.go new file mode 100644 index 000000000..5097b1ef7 --- /dev/null +++ b/vt/width.go @@ -0,0 +1,19 @@ +// 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 vt + +import "github.com/gdamore/tcell/v3/internal/widthutil" + +var textWidthOptions = widthutil.Options() diff --git a/width_bench_test.go b/width_bench_test.go new file mode 100644 index 000000000..e90c60f19 --- /dev/null +++ b/width_bench_test.go @@ -0,0 +1,55 @@ +// 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 ( + "strings" + "testing" + + "github.com/clipperhouse/displaywidth" +) + +var ( + mixedWidthText = strings.Repeat("Hello, 世界 👩‍🚀 e\u0301 — π «ambiguous» ", 8) + ansiWidthText = strings.Repeat("\x1b[31mHello\x1b[0m, 世界 👩‍🚀 e\u0301 \x1b]0;title\x07 ", 8) +) + +func BenchmarkStringWidthMixedCurrent(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = currentMixedWidth(mixedWidthText) + } +} + +func BenchmarkStringWidthANSIControl(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = currentANSIWidth(ansiWidthText) + } +} + +func currentMixedWidth(s string) int { + return displaywidth.Options{EastAsianWidth: true}.String(s) +} + +func currentANSIWidth(s string) int { + return displaywidth.Options{ + EastAsianWidth: true, + ControlSequences: true, + ControlSequences8Bit: true, + }.String(s) +} From 2efac637707b0b3b5ffed095a459147fc251caac Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 19 Apr 2026 16:10:15 -0700 Subject: [PATCH 25/32] demos: count repeat key strokes in mouse demo --- _demos/mouse.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/_demos/mouse.go b/_demos/mouse.go index 7e258eecc..9738f417f 100644 --- a/_demos/mouse.go +++ b/_demos/mouse.go @@ -134,6 +134,8 @@ func main() { lchar := '*' bstr := "" lks := "" + lkey := "" + kcnt := 0 pstr := "" ecnt := 0 pasting := false @@ -180,6 +182,12 @@ func main() { s.Sync() s.Put(w-1, h-1, "R", st) case *tcell.EventKey: + if ev.Name() == lkey { + kcnt++ + } else { + lkey = ev.Name() + kcnt = 1 + } s.Put(w-2, h-2, ev.Str(), st) if pasting { s.Put(w-1, h-1, "P", st) @@ -224,6 +232,11 @@ func main() { s.Clear() } } + if ks := fmt.Sprintf("K%d", kcnt); w >= len(ks) { + s.PutStrStyled(w-len(ks), h-1, ks, st) + } else { + s.PutStrStyled(0, h-1, ks, st) + } lks = ev.Name() case *tcell.EventPaste: pasting = ev.Start() From ade6a4624c4cf32533880aa863e095d8e9108b1a Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 19 Apr 2026 20:27:23 -0700 Subject: [PATCH 26/32] Key mishandling on Windows when using Alacritty or WezTerm (fixes #1062) Essentially we need to force both Kitty and Win32-Input-Mode. We CANNOT rely on negotiation on Windows because for cases other than Windows Terminal, it appears that the VT Input mode is swallowing the escape sequences. This also breaks terminal identification. Again Windows 11 Terminal seems free from this defect. It only relates when running the go program on Windows. Remotely running on UNIX via SSH is fine, and in that case everything works as expected. --- tscreen.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tscreen.go b/tscreen.go index f06705922..3ff2c85bb 100644 --- a/tscreen.go +++ b/tscreen.go @@ -1286,7 +1286,16 @@ func (t *tScreen) engage() error { if t.title != "" && t.setTitle != "" { t.Printf(t.setTitle, t.title) } - // t.Print(t.enableCsiU) + if runtime.GOOS == "windows" { + // This workaround exists because of what we believe to be bugs in the + // interaction between ConPTY, the VT-Input layer, and some terminal emulators + // such as WezTerm. Note that it is *not* needed for Windows Terminal, but + // should be benign there. As another note, we have observed that at least Alacritty + // and WezTerm do not properly handle the primaryDA query on these platforms. + // (WezTerm performs much better when running a remote shell or on macOS.) + t.Print(enableKittyKbd) + t.Print(vt.PmWin32Input.Enable()) + } t.Print(requestWindowSize) if t.inlineResize { @@ -1344,6 +1353,13 @@ func (t *tScreen) disengage() { if t.haveXTermKbd { t.Print(disableXTermKbd) } + + // Hack for Windows. + if runtime.GOOS == "windows" { + t.Print(vt.PmWin32Input.Disable()) + t.Print(disableKittyKbd) + } + // t.Print(t.disableCsiU) if t.useAltScreen() { t.Print(t.restoreTitle) From 7e0c93cccbb637c05bcde94ac9fbf1696b85f6a0 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 19 Apr 2026 22:20:01 -0700 Subject: [PATCH 27/32] Sanitize OSC 8 links (fixes #1061) --- style.go | 18 +++++++++++++++--- style_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/style.go b/style.go index 2060ae471..c2b90c09a 100644 --- a/style.go +++ b/style.go @@ -1,4 +1,4 @@ -// Copyright 2025 The TCell Authors +// 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. @@ -42,6 +42,18 @@ type urlInfo struct { id string } +func stripOSCControls(s string) string { + b := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) { + continue + } + b = append(b, c) + } + return string(b) +} + // StyleDefault represents a default style, based upon the context. // It is the zero value. var StyleDefault Style @@ -201,7 +213,7 @@ func (s Style) GetAttributes() AttrMask { func (s Style) Url(url string) Style { s2 := s - s2.url = &urlInfo{url: url} + s2.url = &urlInfo{url: stripOSCControls(url)} if s.url != nil { s2.url.id = s.url.id } @@ -215,7 +227,7 @@ func (s Style) Url(url string) Style { func (s Style) UrlId(id string) Style { s2 := s s2.url = &urlInfo{ - id: "id=" + id, + id: "id=" + stripOSCControls(id), } if s.url != nil { s2.url.url = s.url.url diff --git a/style_test.go b/style_test.go index 3c9e561d2..3883cccc0 100644 --- a/style_test.go +++ b/style_test.go @@ -93,3 +93,24 @@ func TestStyle(t *testing.T) { t.Errorf("wrong attributes: %v", us.GetAttributes()) } } + +func TestStyleUrlStripsOSCControls(t *testing.T) { + s := StyleDefault. + Url("http://exa\x07mple.com/\x1b\\path"). + UrlId("id\x00\x1f\x7f\x80\x9fend") + + id, url := s.GetUrl() + combined := id + url + for i := 0; i < len(combined); i++ { + c := combined[i] + if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) { + t.Fatalf("control characters survived sanitization: id=%q url=%q", id, url) + } + } + if id != "idend" { + t.Fatalf("unexpected sanitized id: %q", id) + } + if url != "http://example.com/\\path" { + t.Fatalf("unexpected sanitized url: %q", url) + } +} From 29b8586f6141d93aac712fd29fec38224b624625 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 19 Apr 2026 22:31:11 -0700 Subject: [PATCH 28/32] Sanitize OSC 8 links (fixes #1061) --- style.go | 26 ++++++++++++++++++++------ style_test.go | 22 ++++++++++++++++++++++ tscreen_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/style.go b/style.go index c2b90c09a..8aedd97db 100644 --- a/style.go +++ b/style.go @@ -16,6 +16,7 @@ package tcell import ( "strings" + "unicode/utf8" "github.com/gdamore/tcell/v3/color" ) @@ -43,15 +44,28 @@ type urlInfo struct { } func stripOSCControls(s string) string { - b := make([]byte, 0, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + c := s[i] + if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) { + i++ + continue + } + _ = b.WriteByte(c) + i++ + continue + } + if r <= 0x1f || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + i += size continue } - b = append(b, c) + b.WriteString(s[i : i+size]) + i += size } - return string(b) + return b.String() } // StyleDefault represents a default style, based upon the context. diff --git a/style_test.go b/style_test.go index 3883cccc0..ab58ed482 100644 --- a/style_test.go +++ b/style_test.go @@ -16,6 +16,7 @@ package tcell import ( "testing" + "unicode/utf8" "github.com/gdamore/tcell/v3/color" ) @@ -113,4 +114,25 @@ func TestStyleUrlStripsOSCControls(t *testing.T) { if url != "http://example.com/\\path" { t.Fatalf("unexpected sanitized url: %q", url) } + + s = StyleDefault. + Url("http://example.com/\u3042\u009b"). + UrlId("id\u3042\u009bend") + + id, url = s.GetUrl() + combined = id + url + if !utf8.ValidString(combined) { + t.Fatalf("UTF-8 was corrupted during sanitization: id=%q url=%q", id, url) + } + for _, r := range combined { + if r <= 0x1f || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + t.Fatalf("control characters survived UTF-8 sanitization: id=%q url=%q", id, url) + } + } + if id != "id\u3042end" { + t.Fatalf("unexpected sanitized UTF-8 id: %q", id) + } + if url != "http://example.com/\u3042" { + t.Fatalf("unexpected sanitized UTF-8 url: %q", url) + } } diff --git a/tscreen_test.go b/tscreen_test.go index 17c65543b..b774035df 100644 --- a/tscreen_test.go +++ b/tscreen_test.go @@ -112,6 +112,45 @@ func TestOptAltScreenDefault(t *testing.T) { } } +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) + } + 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) + } + } +} + // TestInitScreenStdio just tries to initialize the default screen using standard I/O. // It requires a working tty. func TestInitScreenStdio(t *testing.T) { From 9ae29a0a20a29ed1617f786950879dc1cae6445e Mon Sep 17 00:00:00 2001 From: Antoine Gaudreau Simard Date: Mon, 20 Apr 2026 19:34:08 -0400 Subject: [PATCH 29/32] fix: possible panic in getConsoleInput if no event returned --- tty/tty_win.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tty/tty_win.go b/tty/tty_win.go index 2bf9769d1..495ab8342 100644 --- a/tty/tty_win.go +++ b/tty/tty_win.go @@ -189,6 +189,10 @@ func (w *winTty) getConsoleInput() error { rv, _, er := procGetNumberOfConsoleInputEvents.Call( uintptr(w.in), uintptr(unsafe.Pointer(&nrec))) + if rv == 0 { + return er + } + rec := make([]inputRecord, max(nrec, 1)) rv, _, er = procReadConsoleInput.Call( uintptr(w.in), From 8cf3a1b20f3e69b8334edc03a96b0276611e80b3 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Mon, 20 Apr 2026 21:03:47 -0700 Subject: [PATCH 30/32] Sanitize titles and notifications (fixes #1066) --- style.go | 1 + tscreen.go | 6 +++--- tscreen_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/style.go b/style.go index 8aedd97db..9b99b01d1 100644 --- a/style.go +++ b/style.go @@ -43,6 +43,7 @@ type urlInfo struct { id string } +// stripOSCControls removes control bytes that can terminate OSC payloads early. func stripOSCControls(s string) string { var b strings.Builder b.Grow(len(s)) diff --git a/tscreen.go b/tscreen.go index 3ff2c85bb..18682c32a 100644 --- a/tscreen.go +++ b/tscreen.go @@ -1401,9 +1401,9 @@ func (t *tScreen) GetCells() *CellBuffer { func (t *tScreen) SetTitle(title string) { t.Lock() - t.title = title + t.title = stripOSCControls(title) if t.setTitle != "" && t.running { - t.Printf(t.setTitle, title) + t.Printf(t.setTitle, t.title) } t.Unlock() } @@ -1432,7 +1432,7 @@ func (t *tScreen) HasClipboard() bool { func (t *tScreen) ShowNotification(title string, body string) { t.Lock() - t.Printf(t.notifyDesktop, title, body) + t.Printf(t.notifyDesktop, stripOSCControls(title), stripOSCControls(body)) t.Unlock() } diff --git a/tscreen_test.go b/tscreen_test.go index b774035df..4c41e3fd7 100644 --- a/tscreen_test.go +++ b/tscreen_test.go @@ -217,3 +217,44 @@ func NewMockScreen(t *testing.T, opts ...vt.MockOpt) (vt.MockTerm, Screen) { } 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) + } +} From 35af4bc4a41c4bf0686590212413b403e94f1151 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Tue, 21 Apr 2026 21:01:31 -0700 Subject: [PATCH 31/32] feat: Optionally sanitize content before putting to the terminal (fixes #1067) The security model for tcell is that the application should not give us content that is entirely untrustworthy (meaning the application should perform any data sanitization it needs), but there may be some applications that would prefer that tcell do this for them. This comes at a performance cost for every update to the screen, so its not something that will ever be enabled by default. --- cell.go | 14 +++++++-- cell_bench_test.go | 39 ++++-------------------- screen.go | 10 +++++-- style.go | 16 ++++++++-- style_test.go | 10 +++++++ tscreen.go | 13 ++++++-- tscreen_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++ wscreen.go | 12 ++++++-- 8 files changed, 142 insertions(+), 46 deletions(-) diff --git a/cell.go b/cell.go index eb2e92f76..e2ae256aa 100644 --- a/cell.go +++ b/cell.go @@ -42,9 +42,10 @@ func (c *cell) setDirty(dirty bool) { // // CellBuffer is not thread safe. type CellBuffer struct { - w int - h int - cells []cell + w int + h int + cells []cell + sanitizeContent bool } // Put a single styled grapheme using the given string and style @@ -52,6 +53,13 @@ type CellBuffer struct { // will be displayed, using only the 1 or 2 (depending on width) cells // located at x, y. It returns the rest of the string, and the width used. func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) { + if cb.sanitizeContent { + str = stripOSCControlsIfNeeded(str) + } + return cb.put(x, y, str, style) +} + +func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) { var width int = 0 if x >= 0 && y >= 0 && x < cb.w && y < cb.h { var cl string diff --git a/cell_bench_test.go b/cell_bench_test.go index 43d94f68a..a3c26a329 100644 --- a/cell_bench_test.go +++ b/cell_bench_test.go @@ -15,25 +15,22 @@ package tcell import ( - "strings" "testing" ) -var mixedCellStream = strings.Repeat("Hello, 世界 👩‍🚀 e\u0301 ", 8) - func BenchmarkCellBufferPutCurrent(b *testing.B) { - benchCellBufferPut(b, "current", func(cb *CellBuffer, x, y int, str string, style Style) (string, int) { + benchCellBufferPut(b, "current", false, func(cb *CellBuffer, x, y int, str string, style Style) (string, int) { return cb.Put(x, y, str, style) }) } -func BenchmarkCellBufferPutStreamCurrent(b *testing.B) { - benchCellBufferPutStream(b, "current", func(cb *CellBuffer, x int, y int, str string, style Style) (string, int) { +func BenchmarkCellBufferPutSanitizedCurrent(b *testing.B) { + benchCellBufferPut(b, "sanitized", true, func(cb *CellBuffer, x, y int, str string, style Style) (string, int) { return cb.Put(x, y, str, style) }) } -func benchCellBufferPut(b *testing.B, name string, put func(*CellBuffer, int, int, string, Style) (string, int)) { +func benchCellBufferPut(b *testing.B, name string, sanitize bool, put func(*CellBuffer, int, int, string, Style) (string, int)) { cases := []struct { name string str string @@ -46,7 +43,7 @@ func benchCellBufferPut(b *testing.B, name string, put func(*CellBuffer, int, in for _, tc := range cases { b.Run(name+"/"+tc.name, func(b *testing.B) { - cb := &CellBuffer{w: 8, h: 1, cells: make([]cell, 8)} + cb := &CellBuffer{w: 8, h: 1, cells: make([]cell, 8), sanitizeContent: sanitize} style := Style{} b.ReportAllocs() b.ResetTimer() @@ -57,29 +54,3 @@ func benchCellBufferPut(b *testing.B, name string, put func(*CellBuffer, int, in }) } } - -func benchCellBufferPutStream(b *testing.B, name string, put func(*CellBuffer, int, int, string, Style) (string, int)) { - b.Helper() - - b.Run(name+"/mixed-stream", func(b *testing.B) { - cb := &CellBuffer{w: 128, h: 1, cells: make([]cell, 128)} - style := Style{} - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - for j := range cb.cells { - cb.cells[j] = cell{} - } - x := 0 - rest := mixedCellStream - for rest != "" && x < cb.w { - var width int - rest, width = put(cb, x, 0, rest, style) - if width == 0 { - break - } - x += width - } - } - }) -} diff --git a/screen.go b/screen.go index f8c93e5c4..1d301162c 100644 --- a/screen.go +++ b/screen.go @@ -260,7 +260,8 @@ var overrideScreen chan Screen var overrideOnce sync.Once // NewScreen returns a default Screen suitable for the user's terminal environment. -func NewScreen() (Screen, error) { +// Any options are passed through to NewTerminfoScreen. +func NewScreen(opts ...TerminfoScreenOption) (Screen, error) { // Allow an application (presumably test code) to inject a replacement default // screen. This could also be used to create shims for things like nesting screens. @@ -270,7 +271,7 @@ func NewScreen() (Screen, error) { default: } - if s, e := NewTerminfoScreen(); s != nil { + if s, e := NewTerminfoScreen(opts...); s != nil { return s, nil } else { return nil, e @@ -383,9 +384,12 @@ func (b *baseScreen) PutStrStyled(x int, y int, str string, style Style) { cells := b.GetCells() b.Lock() cols, rows := cells.Size() + if cells.sanitizeContent { + str = stripOSCControlsIfNeeded(str) + } width := 0 for str != "" && x < cols && y < rows { - str, width = cells.Put(x, y, str, style) + str, width = cells.put(x, y, str, style) if width == 0 { break } diff --git a/style.go b/style.go index 9b99b01d1..fa6acf343 100644 --- a/style.go +++ b/style.go @@ -69,6 +69,18 @@ func stripOSCControls(s string) string { return b.String() } +// stripOSCControlsIfNeeded returns the original string when it contains no +// control bytes and only allocates when stripping is required. +func stripOSCControlsIfNeeded(s string) string { + for i := 0; i < len(s); i++ { + c := s[i] + if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) { + return stripOSCControls(s) + } + } + return s +} + // StyleDefault represents a default style, based upon the context. // It is the zero value. var StyleDefault Style @@ -228,7 +240,7 @@ func (s Style) GetAttributes() AttrMask { func (s Style) Url(url string) Style { s2 := s - s2.url = &urlInfo{url: stripOSCControls(url)} + s2.url = &urlInfo{url: stripOSCControlsIfNeeded(url)} if s.url != nil { s2.url.id = s.url.id } @@ -242,7 +254,7 @@ func (s Style) Url(url string) Style { func (s Style) UrlId(id string) Style { s2 := s s2.url = &urlInfo{ - id: "id=" + stripOSCControls(id), + id: "id=" + stripOSCControlsIfNeeded(id), } if s.url != nil { s2.url.url = s.url.url diff --git a/style_test.go b/style_test.go index ab58ed482..66faf005f 100644 --- a/style_test.go +++ b/style_test.go @@ -136,3 +136,13 @@ func TestStyleUrlStripsOSCControls(t *testing.T) { t.Fatalf("unexpected sanitized UTF-8 url: %q", url) } } + +func TestStripOSCControlsIfNeeded(t *testing.T) { + if got := stripOSCControlsIfNeeded("hello world"); got != "hello world" { + t.Fatalf("clean string changed: %q", got) + } + + if got := stripOSCControlsIfNeeded("he\x07llo\x1bworld"); got != "helloworld" { + t.Fatalf("dirty string not stripped correctly: %q", got) + } +} diff --git a/tscreen.go b/tscreen.go index 18682c32a..02758fab3 100644 --- a/tscreen.go +++ b/tscreen.go @@ -83,6 +83,15 @@ func (o OptAltScreen) apply(t *tScreen) { t.altScreen = bool(o) } +// OptSanitizeContent enables stripping control characters from content passed +// to Put and PutStr. This is safer, but a little slower than leaving content +// unsanitized. +type OptSanitizeContent bool + +func (o OptSanitizeContent) apply(t *tScreen) { + t.cells.sanitizeContent = bool(o) +} + // Some terminal escapes that are basically universal. // We would really like to be able to use private mode queries for some of // these but generally we've found that support for queries is not always present, @@ -1401,7 +1410,7 @@ func (t *tScreen) GetCells() *CellBuffer { func (t *tScreen) SetTitle(title string) { t.Lock() - t.title = stripOSCControls(title) + t.title = stripOSCControlsIfNeeded(title) if t.setTitle != "" && t.running { t.Printf(t.setTitle, t.title) } @@ -1432,7 +1441,7 @@ func (t *tScreen) HasClipboard() bool { func (t *tScreen) ShowNotification(title string, body string) { t.Lock() - t.Printf(t.notifyDesktop, stripOSCControls(title), stripOSCControls(body)) + t.Printf(t.notifyDesktop, stripOSCControlsIfNeeded(title), stripOSCControlsIfNeeded(body)) t.Unlock() } diff --git a/tscreen_test.go b/tscreen_test.go index 4c41e3fd7..56b5a98e6 100644 --- a/tscreen_test.go +++ b/tscreen_test.go @@ -112,6 +112,80 @@ func TestOptAltScreenDefault(t *testing.T) { } } +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 TestOSC8ControlsAreStrippedFromOutput(t *testing.T) { tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})} s, err := NewTerminfoScreenFromTty(tty, OptAltScreen(false)) diff --git a/wscreen.go b/wscreen.go index 53dbfc064..45f430b67 100644 --- a/wscreen.go +++ b/wscreen.go @@ -27,10 +27,14 @@ import ( "github.com/gdamore/tcell/v3/tty" ) -// NewTerminfoScreen gets a screen. The options are ignored for this platform. -func NewTerminfoScreen(_ ...TerminfoScreenOption) (Screen, error) { +// NewTerminfoScreen gets a screen. Most options are ignored for this platform, +// but shared CellBuffer options such as content sanitization are honored. +func NewTerminfoScreen(opts ...TerminfoScreenOption) (Screen, error) { t := &wScreen{} t.fallback = make(map[rune]string) + for _, opt := range opts { + opt.apply(t) + } return &baseScreen{screenImpl: t}, nil } @@ -46,10 +50,14 @@ type TerminfoScreenOption interface{ apply(*wScreen) } type OptColors int type OptTerm string type OptAltScreen bool +type OptSanitizeContent bool func (OptColors) apply(*wScreen) {} func (OptTerm) apply(*wScreen) {} func (OptAltScreen) apply(*wScreen) {} +func (o OptSanitizeContent) apply(w *wScreen) { + w.cells.sanitizeContent = bool(o) +} type wScreen struct { w, h int From 8956f2d8305830aeaa33bacf034186812350b31d Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Wed, 22 Apr 2026 10:54:50 -0700 Subject: [PATCH 32/32] test: additional coverage tests --- cell_test.go | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++ style_test.go | 43 ++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 cell_test.go diff --git a/cell_test.go b/cell_test.go new file mode 100644 index 000000000..b8bda1ed3 --- /dev/null +++ b/cell_test.go @@ -0,0 +1,177 @@ +// 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 "testing" + +func TestCellBufferPutAndSanitize(t *testing.T) { + t.Run("unsanitized", func(t *testing.T) { + cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4)} + rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault) + if rest != "\x1bB" { + t.Fatalf("unexpected remainder: %q", rest) + } + if width != 1 { + t.Fatalf("unexpected width: %d", width) + } + if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 { + t.Fatalf("unexpected cell content: %q width=%d", got, gotWidth) + } + }) + + t.Run("sanitized", func(t *testing.T) { + cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4), sanitizeContent: true} + rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault) + if rest != "B" { + t.Fatalf("unexpected sanitized remainder: %q", rest) + } + if width != 1 { + t.Fatalf("unexpected width: %d", width) + } + if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 { + t.Fatalf("unexpected sanitized cell content: %q width=%d", got, gotWidth) + } + }) +} + +func TestCellBufferWideAndColorNone(t *testing.T) { + cb := &CellBuffer{w: 3, h: 1, cells: make([]cell, 3)} + + base := StyleDefault.Foreground(ColorRed).Background(ColorBlue) + cb.Put(0, 0, "a", base) + cb.SetDirty(0, 0, false) + cb.Put(1, 0, "z", base) + cb.SetDirty(1, 0, false) + + reused := Style{fg: ColorNone, bg: ColorNone} + rest, width := cb.Put(0, 0, "你", reused) + if rest != "" { + t.Fatalf("unexpected remainder for wide rune: %q", rest) + } + if width != 2 { + t.Fatalf("unexpected width for wide rune: %d", width) + } + if !cb.Dirty(0, 0) { + t.Fatalf("wide rune should dirty the base cell") + } + if !cb.Dirty(1, 0) { + t.Fatalf("wide rune should dirty the second cell") + } + + got, gotStyle, gotWidth := cb.Get(0, 0) + if got != "你" || gotWidth != 2 { + t.Fatalf("unexpected wide cell content: %q width=%d", got, gotWidth) + } + if gotStyle.GetForeground() != ColorRed || gotStyle.GetBackground() != ColorBlue { + t.Fatalf("ColorNone should preserve existing colors, got fg=%v bg=%v", gotStyle.GetForeground(), gotStyle.GetBackground()) + } +} + +func TestCellBufferDirtyState(t *testing.T) { + cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)} + + cb.cells[0].setDirty(false) + if got := cb.cells[0].lastStr; got != " " { + t.Fatalf("setDirty(false) should normalize empty content to space, got %q", got) + } + if got := cb.cells[0].lastStyle; got != cb.cells[0].currStyle { + t.Fatalf("setDirty(false) should copy style") + } + + cb.cells[0].setDirty(true) + if got := cb.cells[0].lastStr; got != "" { + t.Fatalf("setDirty(true) should clear lastStr, got %q", got) + } + + cb.Put(0, 0, "x", StyleDefault) + cb.SetDirty(0, 0, false) + if cb.Dirty(0, 0) { + t.Fatalf("clean cell should not be dirty") + } + + cb.cells[0].currStyle = cb.cells[0].currStyle.Bold(true) + if !cb.Dirty(0, 0) { + t.Fatalf("style change should make cell dirty") + } + cb.SetDirty(0, 0, false) + cb.cells[0].currStyle = cb.cells[0].lastStyle + cb.cells[0].currStr = "y" + if !cb.Dirty(0, 0) { + t.Fatalf("content change should make cell dirty") + } +} + +func TestCellBufferLockResizeFill(t *testing.T) { + cb := &CellBuffer{w: 2, h: 2, cells: make([]cell, 4)} + cb.Fill('x', StyleDefault) + if got, _, _ := cb.Get(1, 1); got != "x" { + t.Fatalf("unexpected fill content: %q", got) + } + + cb.LockCell(1, 1) + if cb.Dirty(1, 1) { + t.Fatalf("locked cell should not be dirty") + } + cb.UnlockCell(1, 1) + if !cb.Dirty(1, 1) { + t.Fatalf("unlocked cell should be dirty") + } + + cb.Put(0, 0, "ab", StyleDefault) + cb.Resize(3, 1) + if got, _, _ := cb.Get(0, 0); got != "a" { + t.Fatalf("resize should preserve content, got %q", got) + } + if _, _, width := cb.Get(0, 0); width != 1 { + t.Fatalf("unexpected width after resize: %d", width) + } + + cb.Invalidate() + if !cb.Dirty(0, 0) { + t.Fatalf("invalidate should mark content dirty") + } +} + +func TestCellBufferOutOfRangeAndResizeNoop(t *testing.T) { + cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)} + + if rest, width := cb.Put(-1, 0, "x", StyleDefault); rest != "x" || width != 0 { + t.Fatalf("out-of-range put should be a no-op, got rest=%q width=%d", rest, width) + } + + cb.Put(0, 0, "x", StyleDefault.Foreground(ColorRed).Background(ColorBlue)) + cb.SetDirty(0, 0, false) + + if rest, width := cb.Put(0, 0, "", StyleDefault.Foreground(ColorGreen)); rest != "" || width != 0 { + t.Fatalf("empty put should be a no-op, got rest=%q width=%d", rest, width) + } + afterStr, afterStyle, afterWidth := cb.Get(0, 0) + if afterStr != " " || afterStyle.GetForeground() != ColorGreen || afterWidth != 1 { + t.Fatalf("empty put should clear the cell and apply the new style: got=%q/%v/%d", afterStr, afterStyle, afterWidth) + } + if got := cb.Dirty(0, 0); !got { + t.Fatalf("empty put should mark the cell dirty") + } + + cb.LockCell(-1, 0) + cb.LockCell(2, 0) + cb.UnlockCell(-1, 0) + cb.UnlockCell(2, 0) + + cb.Resize(2, 1) + if w, h := cb.Size(); w != 2 || h != 1 { + t.Fatalf("resize no-op changed size to %dx%d", w, h) + } +} diff --git a/style_test.go b/style_test.go index 66faf005f..a7dee8ebb 100644 --- a/style_test.go +++ b/style_test.go @@ -146,3 +146,46 @@ func TestStripOSCControlsIfNeeded(t *testing.T) { t.Fatalf("dirty string not stripped correctly: %q", got) } } + +func TestStripOSCControlsInvalidUTF8(t *testing.T) { + got := stripOSCControls(string([]byte{0xff, 0x1b, 0xfe, 'A'})) + if got != string([]byte{0xff, 0xfe, 'A'}) { + t.Fatalf("unexpected invalid-UTF8 sanitization result: %q", got) + } +} + +func TestStyleHelperMethods(t *testing.T) { + s := StyleDefault. + Dim(true). + StrikeThrough(true). + Underline(true) + if !s.HasDim() { + t.Fatalf("Dim(true) should set dim") + } + if !s.HasStrikeThrough() { + t.Fatalf("StrikeThrough(true) should set strike-through") + } + if !s.HasUnderline() { + t.Fatalf("Underline(true) should set underline") + } + + s = s.Underline(false) + if s.HasUnderline() { + t.Fatalf("Underline(false) should clear underline") + } + + s = s.Attributes(AttrBold | AttrItalic) + if got := s.GetAttributes(); got != AttrBold|AttrItalic { + t.Fatalf("unexpected attributes: %v", got) + } +} + +func TestUnderlineBadTypePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("Underline should panic on unsupported parameter type") + } + }() + + _ = StyleDefault.Underline("bad") +}