diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 4b3ce9a7d..8ab154d83 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,10 +1,17 @@ name: linux -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: name: build runs-on: [ubuntu-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: @@ -30,6 +37,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 aa9e74188..7d6d3310d 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,10 +1,17 @@ name: macos -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: name: build runs-on: [macos-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: @@ -30,6 +37,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/webasm.yml b/.github/workflows/webasm.yml index 56eb4ecd6..276a274b6 100644 --- a/.github/workflows/webasm.yml +++ b/.github/workflows/webasm.yml @@ -1,10 +1,17 @@ name: webasm -on: [push] + +on: + pull_request: + push: + branches: + - main + jobs: build: 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..26bb5f7ea 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,10 +1,15 @@ name: windows -on: [push] +on: + pull_request: + push: + branches: + - main jobs: build: name: build runs-on: [windows-latest] strategy: + fail-fast: false matrix: go: ["stable", "oldstable"] steps: @@ -27,6 +32,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 }} 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/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/_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() diff --git a/cell.go b/cell.go index 30e23ba00..e2ae256aa 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 @@ -46,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 @@ -56,18 +53,23 @@ 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 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..a3c26a329 --- /dev/null +++ b/cell_bench_test.go @@ -0,0 +1,56 @@ +// 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 BenchmarkCellBufferPutCurrent(b *testing.B) { + benchCellBufferPut(b, "current", false, func(cb *CellBuffer, x, y int, str string, style Style) (string, int) { + return cb.Put(x, y, str, style) + }) +} + +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, sanitize bool, 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), sanitizeContent: sanitize} + style := Style{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + cb.cells[0] = cell{} + _, _ = put(cb, 0, 0, tc.str, style) + } + }) + } +} 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/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) } } 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/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} diff --git a/go.mod b/go.mod index 860c1e9ee..a2c804ea7 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/gdamore/tcell/v3 -go 1.24.0 +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.3.0 - 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 + github.com/lucasb-eyer/go-colorful v1.4.0 + 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 73a77bdf8..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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.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/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/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= @@ -22,20 +24,20 @@ 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.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= -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.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= 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.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= diff --git a/input.go b/input.go index 7dc1bd65a..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 { @@ -769,6 +779,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 @@ -1179,7 +1196,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/input_test.go b/input_test.go index 91541a70e..439f3d422 100644 --- a/input_test.go +++ b/input_test.go @@ -823,3 +823,65 @@ 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". +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")) + + got := firstKey(evch) + 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")) + + got := firstKey(evch) + 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()) + } +} 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/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 2060ae471..fa6acf343 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. @@ -16,6 +16,7 @@ package tcell import ( "strings" + "unicode/utf8" "github.com/gdamore/tcell/v3/color" ) @@ -42,6 +43,44 @@ 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)) + 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.WriteString(s[i : i+size]) + i += size + } + 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 @@ -201,7 +240,7 @@ func (s Style) GetAttributes() AttrMask { func (s Style) Url(url string) Style { s2 := s - s2.url = &urlInfo{url: url} + s2.url = &urlInfo{url: stripOSCControlsIfNeeded(url)} if s.url != nil { s2.url.id = s.url.id } @@ -215,7 +254,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=" + stripOSCControlsIfNeeded(id), } if s.url != nil { s2.url.url = s.url.url diff --git a/style_test.go b/style_test.go index 3c9e561d2..a7dee8ebb 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" ) @@ -93,3 +94,98 @@ 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) + } + + 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) + } +} + +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) + } +} + +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") +} diff --git a/tscreen.go b/tscreen.go index 6aeaed267..02758fab3 100644 --- a/tscreen.go +++ b/tscreen.go @@ -74,6 +74,24 @@ 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) +} + +// 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, @@ -119,7 +137,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 @@ -134,7 +152,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 +226,7 @@ type tScreen struct { termName string termVers string term string // value from $TERM + altScreen bool inlineResize bool haveMouse bool haveMouseSgr bool @@ -218,6 +237,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 +1261,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. @@ -1272,7 +1295,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 { @@ -1330,8 +1362,15 @@ 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 os.Getenv("TCELL_ALTSCREEN") != "disable" { + if t.useAltScreen() { t.Print(t.restoreTitle) t.Print(clear) t.Print(exitCA) @@ -1371,9 +1410,9 @@ func (t *tScreen) GetCells() *CellBuffer { func (t *tScreen) SetTitle(title string) { t.Lock() - t.title = title + t.title = stripOSCControlsIfNeeded(title) if t.setTitle != "" && t.running { - t.Printf(t.setTitle, title) + t.Printf(t.setTitle, t.title) } t.Unlock() } @@ -1402,7 +1441,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, stripOSCControlsIfNeeded(title), stripOSCControlsIfNeeded(body)) t.Unlock() } diff --git a/tscreen_test.go b/tscreen_test.go index 0e87bd21d..56b5a98e6 100644 --- a/tscreen_test.go +++ b/tscreen_test.go @@ -15,7 +15,9 @@ package tcell import ( + "bytes" "runtime" + "strings" "testing" "time" @@ -52,6 +54,177 @@ 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") + } +} + +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)) + 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) { @@ -118,3 +291,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) + } +} 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 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), 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. 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 0763bbc8c..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,35 +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 - 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 @@ -1795,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] } } } @@ -1830,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]) @@ -1853,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) { @@ -1950,10 +2104,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 +2122,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 +2165,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 +2194,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 +2205,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 +2236,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) { @@ -2479,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 new file mode 100644 index 000000000..bbb5768b7 --- /dev/null +++ b/vt/emulate_internal_test.go @@ -0,0 +1,190 @@ +// 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) +} + +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/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 } diff --git a/vt/tests/key_test.go b/vt/tests/key_test.go index c3b7b16d6..597258623 100644 --- a/vt/tests/key_test.go +++ b/vt/tests/key_test.go @@ -244,8 +244,9 @@ 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(100 * time.Millisecond) @@ -253,7 +254,7 @@ func TestKeyRepeat(t *testing.T) { term.KeyRelease(vt.KeyX) term.KeyRelease(vt.KeyCapsLock) - CheckRead(t, term, "xxxx") // 0 ms, 50 ms, 75 ms, 100 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 @@ -282,17 +284,18 @@ 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) - 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, "ZZ") // 0 ms, 50 ms } // TestKeyRepeatNoAlt ensures that alt keys do not repeat @@ -302,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) @@ -312,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. @@ -322,17 +326,18 @@ 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) - 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 } // TestKeyRepeatShiftRelease ensures that releasing shift breaks repeat. @@ -342,17 +347,18 @@ 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) 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, "@2") // 0 ms, 50 ms } func TestKeyRepeatCursor(t *testing.T) { @@ -361,8 +367,9 @@ 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(100 * time.Millisecond) @@ -370,7 +377,7 @@ func TestKeyRepeatCursor(t *testing.T) { 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") } func TestKeyWin32(t *testing.T) { @@ -481,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_" @@ -498,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) } @@ -515,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_" 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) +} diff --git a/wscreen.go b/wscreen.go index cdb32a129..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 } @@ -45,9 +49,15 @@ func NewTerminfoScreenFromTty(_ tty.Tty, _ ...TerminfoScreenOption) (Screen, err 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 (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