Bump github.com/gdamore/tcell/v3 from 3.3.0 to 3.4.0 (#5618)

Bumps [github.com/gdamore/tcell/v3](https://github.com/gdamore/tcell)
from 3.3.0 to 3.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gdamore/tcell/releases">github.com/gdamore/tcell/v3's
releases</a>.</em></p>
<blockquote>
<h2>Version 3.4.0 Feature Release</h2>
<p>This release has quite a number of bug fixes, but it <em>also</em>
introduces some substantial new capabilities.
Mostly the new support for advanced key reporting, along with backing
support in the mouse demo, and
enhanced configurability and overrides both for the application and for
the user (environment variables).</p>
<p>This also replaces the old WASM terminal with a new version based on
the libghostty implementation. This
should be much faster, and nicer to work with, with a larger set of full
features such as advanced key reporting,
better support for resizing, etc.</p>
<h2>What's Changed</h2>
<ul>
<li>fix: possible panic in getConsoleInput if no event returned by <a
href="https://github.com/AntoineGS"><code>@​AntoineGS</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1068">gdamore/tcell#1068</a></li>
<li>Sanitize titles and notifications (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1066">#1066</a>)
by <a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1069">gdamore/tcell#1069</a></li>
<li>feat: Optionally sanitize content before putting to the terminal
(fix… by <a href="https://github.com/gdamore"><code>@​gdamore</code></a>
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1071">gdamore/tcell#1071</a></li>
<li>feat: Add reporting of keyboard protocol (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/967">#967</a>) by
<a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1073">gdamore/tcell#1073</a></li>
<li>C1 handling improvements by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1074">gdamore/tcell#1074</a></li>
<li>feat(keys): Add support for advanced key reporting by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1075">gdamore/tcell#1075</a></li>
<li>feat(wasm): Introduce ghostty-wasm as backing WASM terminal (fixes
<a href="https://redirect.github.com/gdamore/tcell/issues/7">#7</a>… by
<a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1076">gdamore/tcell#1076</a></li>
<li>fix: close OSC8 hyperlinks (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1078">#1078</a>)
by <a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1080">gdamore/tcell#1080</a></li>
<li>feat: KeyBacktab updates for advanced mode (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1018">#1018</a>)
by <a href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1081">gdamore/tcell#1081</a></li>
<li>feat: support pixel-precision mouse reporting (CSI ?1016h) by <a
href="https://github.com/ImGajeed76"><code>@​ImGajeed76</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1077">gdamore/tcell#1077</a></li>
<li>fix(input): accept Unicode modifyOtherKeys codepoints by <a
href="https://github.com/ayn2op"><code>@​ayn2op</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1083">gdamore/tcell#1083</a></li>
<li>Fix reporting of key releases by <a
href="https://github.com/tihirvon"><code>@​tihirvon</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1088">gdamore/tcell#1088</a></li>
<li>chore(deps): bump golang.org/x/term from 0.42.0 to 0.43.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1085">gdamore/tcell#1085</a></li>
<li>chore(deps): bump golang.org/x/text from 0.36.0 to 0.37.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/gdamore/tcell/pull/1087">gdamore/tcell#1087</a></li>
<li>MInor bug fixes by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1097">gdamore/tcell#1097</a></li>
<li>Preserve orphaned UTF-16 surrogates on Windows by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1104">gdamore/tcell#1104</a></li>
<li>vt: prune tab stops after resize by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1105">gdamore/tcell#1105</a></li>
<li>vt: ignore malformed SGR parameters by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1106">gdamore/tcell#1106</a></li>
<li>Limit inbound control strings by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1107">gdamore/tcell#1107</a></li>
<li>fix: cells should be marked dirty even if they have no content
(fixes… by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1108">gdamore/tcell#1108</a></li>
<li>fix: lock SetSize screen mutations by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1109">gdamore/tcell#1109</a></li>
<li>tscreen: prevent terminal mutation after Fini by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1110">gdamore/tcell#1110</a></li>
<li>Avoid extra keyboard protocols on Windows by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1111">gdamore/tcell#1111</a></li>
<li>[codex] Fix OSC 8 hyperlink state transitions by <a
href="https://github.com/gdamore"><code>@​gdamore</code></a> in <a
href="https://redirect.github.com/gdamore/tcell/pull/1112">gdamore/tcell#1112</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/ImGajeed76"><code>@​ImGajeed76</code></a> made
their first contribution in <a
href="https://redirect.github.com/gdamore/tcell/pull/1077">gdamore/tcell#1077</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/gdamore/tcell/compare/v3.3.0...v3.4.0">https://github.com/gdamore/tcell/compare/v3.3.0...v3.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="c67165c6c2"><code>c67165c</code></a>
[codex] Fix OSC 8 hyperlink state transitions (<a
href="https://redirect.github.com/gdamore/tcell/issues/1112">#1112</a>)</li>
<li><a
href="3f24a1a195"><code>3f24a1a</code></a>
test: Include test case for Apple Terminal</li>
<li><a
href="0af1f67667"><code>0af1f67</code></a>
feat: add terminal override controls</li>
<li><a
href="d3371c05d8"><code>d3371c0</code></a>
fix: remove the 25ms startup delay for primary DA</li>
<li><a
href="7da1880519"><code>7da1880</code></a>
feat: report WezTerm and Terminal.app when using env var detection</li>
<li><a
href="defbc72dec"><code>defbc72</code></a>
fix: avoid extra keyboard protocols on windows and hard code WezTerm
capabili...</li>
<li><a
href="a8373181b0"><code>a837318</code></a>
tscreen: prevent terminal mutation after Fini (<a
href="https://redirect.github.com/gdamore/tcell/issues/1110">#1110</a>)</li>
<li><a
href="d7fff268b3"><code>d7fff26</code></a>
fix: lock SetSize screen mutations (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1098">#1098</a>)
(<a
href="https://redirect.github.com/gdamore/tcell/issues/1109">#1109</a>)</li>
<li><a
href="b91ae6def9"><code>b91ae6d</code></a>
fix: cells should be marked dirty even if they have no content (fixes <a
href="https://redirect.github.com/gdamore/tcell/issues/1089">#1089</a>)
...</li>
<li><a
href="c01e1075d5"><code>c01e107</code></a>
fix: limit inbound control strings (<a
href="https://redirect.github.com/gdamore/tcell/issues/1107">#1107</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/gdamore/tcell/compare/v3.3.0...v3.4.0">compare
view</a></li>
</ul>
</details>
<br />
This commit is contained in:
Stefan Haller 2026-05-26 10:03:03 +02:00 committed by GitHub
commit 8bdbfbd928
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1508 additions and 841 deletions

6
go.mod
View file

@ -13,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.3.0
github.com/gdamore/tcell/v3 v3.4.0
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
@ -68,8 +68,8 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

12
go.sum
View file

@ -33,8 +33,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.3.0 h1:lGo3VwiV8iYMH4TdwBDwEOoDctnOLns6QfrXvZkKNe8=
github.com/gdamore/tcell/v3 v3.3.0/go.mod h1:8CJpEjUiAlFIrs7jUhzobiZsSLBAtXo8Dq9mCTrVnqo=
github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E=
github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@ -167,15 +167,15 @@ golang.org/x/sys v0.45.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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=

View file

@ -45,6 +45,10 @@ the associated lower case rune (e.g. "a", "b", etc.) and `ModCtrl`.
The `KeyBackspace2` key is no longer delivered, but is converted to
`KeyBackspace`. (This resolves some inconsistency around e.g. CTRL-H vs DELETE.)
When advanced key reporting is enabled, Shift-Tab is reported as `KeyTab` with
`ModShift`, not as `KeyBacktab`. Legacy key reporting still reports Shift-Tab
as `KeyBacktab`.
### Termbox Compatibility Removed
The `termbox` compatibility package is removed. Few applications were using it,

View file

@ -11,11 +11,30 @@ GOOS=js GOARCH=wasm go build -o yourfile.wasm
## Additional files
You also need 5 other files in the same directory as the wasm. Four (`tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`) are provided in the `webfiles` directory. The last one, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing
You also need the supporting web files in the same directory as the wasm. The files `tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`, plus the `ghostty-web` directory, are provided in the `webfiles` directory. The last file, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing
```sh
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./
```
The web frontend uses `ghostty-web`. The required browser runtime files are vendored in `webfiles/ghostty-web` and must be copied alongside `tcell.js`; no npm, bundler, or external CDN is required. The vendored `ghostty-web` files are MIT licensed; see `webfiles/ghostty-web/LICENSE`.
```sh
cp -R webfiles/ghostty-web /path/to/dir/to/serve/
```
The vendored `ghostty-web.js` is intentionally browser-only. Its upstream Node `readFile` fallback import is removed so browser-oriented servers and bundlers such as Vite do not try to resolve a Node file-system shim; the bundled code loads `ghostty-vt.wasm` with `fetch`.
For example:
```sh
mkdir -p /tmp/tcell-wasm
cp webfiles/tcell.html webfiles/tcell.js webfiles/termstyle.css webfiles/beep.wav /tmp/tcell-wasm/
cp -R webfiles/ghostty-web /tmp/tcell-wasm/
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /tmp/tcell-wasm/
GOOS=js GOARCH=wasm go build -o /tmp/tcell-wasm/main.wasm ./demos/unicode
python3 -m http.server -d /tmp/tcell-wasm 8080
```
In `tcell.js`, you also need to change the constant
```js
const wasmFilePath = "yourfile.wasm"
@ -54,8 +73,24 @@ It is recommended to use an iframe if you want to embed the app into a webpage:
<iframe src="tcell.html" title="Tcell app"></iframe>
```
### Sizing
By default the web terminal fits itself to the size of the `#terminal` element and reacts to container resizes. The bundled `termstyle.css` makes this full-page by default.
You can override the terminal cell dimensions explicitly in HTML:
```html
<pre id="terminal" data-cols="100" data-rows="30"></pre>
```
If only one of `data-cols` or `data-rows` is set, the other dimension remains reactive.
## Other considerations
### Accessing files
`io.Open(filename)` and other related functions for reading file systems do not work; use `http.Get(filename)` instead.
### Keyboard shortcuts
The browser may reserve some key combinations before JavaScript can see or cancel them. This is especially common for Meta/Command shortcuts on macOS, such as Command-L. Standalone Meta key events can be reported, but Meta-modified key combinations are browser-dependent and should not be relied upon in WASM web mode.

View file

@ -119,6 +119,24 @@ than respecting themes. For other cases, such as typical text apps that
only use a few colors, its more desirable to respect the themes that
the user has established.)
## Terminal Overrides
_Tcell_ normally negotiates terminal capabilities automatically, but some
terminal emulators answer those queries incorrectly. These environment
variables provide user escape hatches when the automatic path is not reliable:
- `TCELL_KEYBOARD_PROTOCOL=auto|legacy|kitty|win32|xterm` forces the keyboard
reporting protocol.
- `TCELL_NEGOTIATE=auto|disable` disables startup capability negotiation when
terminal responses themselves are problematic.
- `TCELL_MOUSE=auto|disable` prevents applications from enabling terminal mouse
reporting.
Applications can also choose a keyboard protocol with `OptKeyboardProtocol` or
disable startup negotiation with `OptNegotiation`. Environment variables take
precedence so users can recover from bad terminal behavior without modifying an
application.
## Performance
Reasonable attempts have been made to minimize sending data to terminals,

View file

@ -25,7 +25,15 @@ type cell struct {
func (c *cell) setDirty(dirty bool) {
if dirty {
c.lastStr = ""
// Empty cells use currStr == "" until they are first drawn, at which
// point SetDirty(false) normalizes them to a space. Using "" as the
// dirty marker for an untouched empty cell would therefore leave
// lastStr == currStr and fail to force a redraw.
if c.currStr == "" {
c.lastStr = " "
} else {
c.lastStr = ""
}
} else {
if c.currStr == "" {
c.currStr = " "
@ -42,9 +50,10 @@ func (c *cell) setDirty(dirty bool) {
//
// CellBuffer is not thread safe.
type CellBuffer struct {
w int
h int
cells []cell
w int
h int
cells []cell
sanitizeContent bool
}
// Put a single styled grapheme using the given string and style
@ -52,6 +61,13 @@ type CellBuffer struct {
// will be displayed, using only the 1 or 2 (depending on width) cells
// located at x, y. It returns the rest of the string, and the width used.
func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) {
if cb.sanitizeContent {
str = stripOSCControlsIfNeeded(str)
}
return cb.put(x, y, str, style)
}
func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) {
var width int = 0
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
var cl string
@ -118,7 +134,7 @@ func (cb *CellBuffer) Size() (int, int) {
// Invalidate marks all characters within the buffer as dirty.
func (cb *CellBuffer) Invalidate() {
for i := range cb.cells {
cb.cells[i].lastStr = ""
cb.cells[i].setDirty(true)
}
}

View file

@ -22,8 +22,8 @@
// There is unfortunately *one* conflict, with aixterm, for CSI-P - which is KeyDelete
// in aixterm, but F1 in others.
//go:build !js && !wasm
// +build !js,!wasm
//go:build (!js && !wasm) || (js && wasm)
// +build !js,!wasm js,wasm
package tcell
@ -59,31 +59,61 @@ const (
istXda // extended device attributes (ESC P Ps ST)
)
// defaultControlStringLimit caps inbound OSC/XDA control-string payloads
// before they can grow without bound while waiting for a string terminator.
const defaultControlStringLimit = 64 * 1024
func newInputParser(eq chan<- Event) *inputParser {
return &inputParser{
evch: eq,
buf: make([]rune, 0, 128),
evch: eq,
buf: make([]rune, 0, 128),
controlStringMax: defaultControlStringLimit,
}
}
type inputParser struct {
buf []rune // bytes to process (ingest data)
utfBuf []byte // accrued UTF8 bytes
strBuf []byte // accrued string data (for ST, OSC, etc.)
csiParams []byte // accrued parameter bytes for CSI (and SS3)
csiInterm []byte // accrued intermediate bytes for CSI
escChar byte // last byte for escape
escaped bool // true if next key should be modified by ESC
btnsDown ButtonMask // mouse buttons down (excludes wheel buttons)
state inputState // tracks processor state
strState inputState // saved str state (needed for ST)
l sync.Mutex // protects local state
evch chan<- Event // where events are routed
rows int // used for clipping mouse coordinates
cols int // used for clipping mouse coordinates
keyTime time.Time // time of last key press / byte ingested
nested *inputParser // for buggy win32-input-mode implementations
surrogate rune // high surrogate pair seen (for Win32 input mode)
buf []rune // bytes to process (ingest data)
utfBuf []byte // accrued UTF8 bytes
strBuf []byte // accrued string data (for ST, OSC, etc.)
csiParams []byte // accrued parameter bytes for CSI (and SS3)
csiInterm []byte // accrued intermediate bytes for CSI
escChar byte // last byte for escape
escaped bool // true if next key should be modified by ESC
btnsDown ButtonMask // mouse buttons down (excludes wheel buttons)
state inputState // tracks processor state
strState inputState // saved str state (needed for ST)
l sync.Mutex // protects local state
evch chan<- Event // where events are routed
rows int // used for clipping mouse coordinates
cols int // used for clipping mouse coordinates
pixelMouse bool // mouse reports in pixels (CSI ?1016h); skip cell clipping
keyTime time.Time // time of last key press / byte ingested
nested *inputParser // for buggy win32-input-mode implementations
surrogate rune // high surrogate pair seen (for Win32 input mode)
advanced bool // use advanced key reporting semantics
controlStringMax int // maximum inbound OSC/XDA payload size; 0 means unlimited
discardString bool // drop the rest of an over-limit OSC/XDA sequence
}
func keyFromInt(n int) (Key, bool) {
if n < 0 || n > 32767 {
return 0, false
}
return Key(n), true
}
func keyFromRune(r rune) (Key, bool) {
if r < 0 || r > 32767 {
return 0, false
}
return Key(r), true
}
func asciiByteFromInt(n int) (byte, bool) {
if n <= 0 || n >= 0x80 {
return 0, false
}
return byte(n), true
}
// Waiting returns true if the processor is waiting for
@ -99,6 +129,21 @@ func (ip *inputParser) Waiting() bool {
return ip.state != istInit
}
// SetPixelMouse toggles whether SGR mouse reports are interpreted as
// pixel coordinates (CSI ?1016h) rather than character cells (CSI ?1006h).
// When enabled, mouse coordinates are not clipped to the screen size.
// The setting is also forwarded to the lazily-created nested parser used
// for win32-input-mode, if one exists, so both stay in sync.
func (ip *inputParser) SetPixelMouse(on bool) {
ip.l.Lock()
ip.pixelMouse = on
nested := ip.nested
ip.l.Unlock()
if nested != nil {
nested.SetPixelMouse(on)
}
}
func (ip *inputParser) SetSize(w, h int) {
if ip.nested != nil {
ip.nested.SetSize(w, h)
@ -116,7 +161,7 @@ func (ip *inputParser) post(ev Event) {
if ip.escaped {
ip.escaped = false
if ke, ok := ev.(*EventKey); ok {
ev = NewEventKey(ke.Key(), ke.Str(), ke.Modifiers()|ModAlt)
ev = ip.newKey(ke.Key(), ke.Str(), ke.Modifiers()|ModAlt, ke.Pressed(), ke.Physical(), ke.Repeat())
}
} else if ke, ok := ev.(*EventKey); ok {
switch ke.Key() {
@ -130,6 +175,31 @@ func (ip *inputParser) post(ev Event) {
ip.evch <- ev
}
func (ip *inputParser) newKey(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) *EventKey {
if ip.advanced {
return NewEventKeyEx(k, str, mod, pressed, physical, repeat)
}
return NewEventKey(k, str, mod)
}
func (ip *inputParser) postKey(k Key, str string, mod ModMask) {
ip.post(ip.newKey(k, str, mod, true, 0, 1))
}
func (ip *inputParser) postKeyEx(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) {
ip.post(ip.newKey(k, str, mod, pressed, physical, repeat))
}
func (ip *inputParser) postControlKey(r rune, mod ModMask) {
if r == 0 {
ip.postKeyEx(KeyRune, " ", mod|ModCtrl, true, Key(' '), 1)
} else if ip.advanced && r >= 1 && r <= 26 {
ip.postKeyEx(KeyRune, string('a'+r-1), mod|ModCtrl, true, Key('a'+r-1), 1)
} else {
ip.postKey(KeyRune, string(r+0x40), mod|ModCtrl)
}
}
type csiParamMode struct {
M rune // Mode
P int // Parameter (first)
@ -358,6 +428,14 @@ var csiUKeys = map[int]keyMap{
57425: {Key: KeyInsert}, // KP_INSERT
57426: {Key: KeyDelete}, // KP_DELETE
// 57427: {Key: KeyBegin}, // KP_BEGIN
57441: {Key: KeyShift}, // LEFT_SHIFT
57442: {Key: KeyCtrl}, // LEFT_CONTROL
57443: {Key: KeyAlt}, // LEFT_ALT
57444: {Key: KeyMeta}, // LEFT_SUPER
57447: {Key: KeyShift}, // RIGHT_SHIFT
57448: {Key: KeyCtrl}, // RIGHT_CONTROL
57449: {Key: KeyAlt}, // RIGHT_ALT
57450: {Key: KeyMeta}, // RIGHT_SUPER
// TODO: Media keys
}
@ -447,7 +525,8 @@ func (ip *inputParser) scan() {
if r >= 0xA0 {
// 8-bit extended Unicode we just treat as such - this will swallow anything else queued up
ip.state = istInit
ip.post(NewEventKey(KeyRune, string(r), ModNone))
physical, _ := keyFromRune(r)
ip.postKeyEx(KeyRune, string(r), ModNone, true, physical, 1)
continue
} else if r >= 0x80 {
// ISO 2022 control chars
@ -463,19 +542,20 @@ func (ip *inputParser) scan() {
ip.state = istEsc
ip.escChar = 0
case '\t':
ip.post(NewEventKey(KeyTab, "", ModNone))
ip.postKey(KeyTab, "", ModNone)
case '\b', '\x7F':
ip.post(NewEventKey(KeyBackspace, "", ModNone))
ip.postKey(KeyBackspace, "", ModNone)
case '\r':
ip.post(NewEventKey(KeyEnter, "", ModNone))
ip.postKey(KeyEnter, "", ModNone)
default:
// Control keys - legacy handling
if r == 0 {
ip.post(NewEventKey(KeyRune, " ", ModCtrl))
ip.postControlKey(r, ModNone)
} else if r < ' ' {
ip.post(NewEventKey(KeyRune, string(r+0x40), ModCtrl))
ip.postControlKey(r, ModNone)
} else {
ip.post(NewEventKey(KeyRune, string(r), ModNone))
physical, _ := keyFromRune(r)
ip.postKeyEx(KeyRune, string(r), ModNone, true, physical, 1)
}
}
case istEsc:
@ -488,6 +568,7 @@ func (ip *inputParser) scan() {
case ']':
ip.state = istOsc
ip.strBuf = nil
ip.discardString = false
ip.escChar = byte(r)
case 'N':
ip.state = istSs2 // no known uses
@ -502,6 +583,7 @@ func (ip *inputParser) scan() {
ip.state = istXda
ip.csiParams = nil
ip.strBuf = nil
ip.discardString = false
ip.escChar = byte(r)
case 'X':
ip.state = istSos
@ -521,7 +603,7 @@ func (ip *inputParser) scan() {
case '\t':
// Linux console only, does not conform to ECMA
ip.state = istInit
ip.post(NewEventKey(KeyBacktab, "", ModNone))
ip.postKey(KeyBacktab, "", ModNone)
default:
if r == '\x1b' {
// leading ESC to capture alt
@ -535,7 +617,8 @@ func (ip *inputParser) scan() {
mod |= ModCtrl
r += 0x60
}
ip.post(NewEventKey(KeyRune, string(r), mod))
physical, _ := keyFromRune(r)
ip.postKeyEx(KeyRune, string(r), mod, true, physical, 1)
}
}
case istCsi:
@ -585,16 +668,16 @@ func (ip *inputParser) scan() {
// parameters that do not match one of these forms, we just discard it.
if len(ip.csiParams) == 0 {
// simple SS3 case
ip.post(NewEventKey(k, "", ModNone))
ip.postKey(k, "", ModNone)
} else if parts := strings.Split(string(ip.csiParams), ";"); len(parts) >= 1 {
// SS3 with modifier (old style). Note old terminfo would declare these as high
// numbered function keys, but we encode as modified since that's how they are entered.
if len(parts) >= 2 {
if m, err := strconv.Atoi(parts[1]); err == nil && (parts[0] == "1" || parts[0] == "") {
ip.post(NewEventKey(k, "", calcModifier(m)))
ip.postKey(k, "", calcModifier(m))
}
} else if m, err := strconv.Atoi(parts[0]); err == nil {
ip.post(NewEventKey(k, "", calcModifier(m)))
ip.postKey(k, "", calcModifier(m))
}
}
}
@ -614,9 +697,16 @@ func (ip *inputParser) scan() {
ip.strState = ip.state
ip.state = istSt
case '\x07':
ip.handleXda(string(ip.strBuf))
if ip.discardString {
ip.discardString = false
ip.state = istInit
} else {
ip.handleXda(string(ip.strBuf))
}
default:
ip.strBuf = append(ip.strBuf, byte(r&0x7f))
if !ip.discardString {
ip.appendStringBytes(byte(r & 0x7f))
}
}
case istOsc: // not sure if used
@ -625,29 +715,42 @@ func (ip *inputParser) scan() {
ip.strState = ip.state
ip.state = istSt
case '\x07':
ip.handleOsc(string(ip.strBuf))
if ip.discardString {
ip.discardString = false
ip.state = istInit
} else {
ip.handleOsc(string(ip.strBuf))
}
default:
ip.strBuf = append(ip.strBuf, byte(r&0x7f))
if !ip.discardString {
ip.appendStringBytes(byte(r & 0x7f))
}
}
case istSt:
if r == '\\' || r == '\x07' {
ip.state = istInit
switch ip.strState {
case istOsc:
ip.handleOsc(string(ip.strBuf))
case istXda:
ip.handleXda(string(ip.strBuf))
case istPm, istApc, istSos, istDcs:
ip.state = istInit
if ip.discardString {
ip.discardString = false
} else {
switch ip.strState {
case istOsc:
ip.handleOsc(string(ip.strBuf))
case istXda:
ip.handleXda(string(ip.strBuf))
case istPm, istApc, istSos, istDcs:
ip.state = istInit
}
}
} else {
ip.strBuf = append(ip.strBuf, '\x1b', byte(r))
if !ip.discardString {
ip.appendStringBytes('\x1b', byte(r))
}
ip.state = ip.strState
}
case istLnx:
// linux console does not follow ECMA
if k, ok := linuxFKeys[r]; ok {
ip.post(NewEventKey(k, "", ModNone))
ip.postKey(k, "", ModNone)
}
ip.state = istInit
}
@ -655,15 +758,25 @@ func (ip *inputParser) scan() {
if ip.state != istInit && time.Since(ip.keyTime) > time.Millisecond*50 {
if ip.state == istEsc {
ip.post(NewEventKey(KeyEscape, "", ModNone))
ip.postKey(KeyEscape, "", ModNone)
} else if ec := ip.escChar; ec != 0 {
ip.post(NewEventKey(KeyRune, string(ec), ModAlt))
ip.postKey(KeyRune, string(ec), ModAlt)
}
// if we take too long between bytes, reset the state machine.
ip.state = istInit
ip.discardString = false
}
}
func (ip *inputParser) appendStringBytes(bs ...byte) {
if ip.controlStringMax > 0 && len(ip.strBuf)+len(bs) > ip.controlStringMax {
ip.strBuf = nil
ip.discardString = true
return
}
ip.strBuf = append(ip.strBuf, bs...)
}
func (ip *inputParser) handleOsc(str string) {
ip.state = istInit
if content, ok := strings.CutPrefix(str, "52;c;"); ok {
@ -717,6 +830,99 @@ func calcModifier(n int) ModMask {
return m
}
func calcWinModifier(n int, advanced bool) ModMask {
m := ModNone
if n&0x010 != 0 {
m |= ModShift
}
if advanced {
// Bits through 0x0100 match Win32 dwControlKeyState. 0x0040 and
// 0x0080 are ScrollLock and CapsLock, not Meta. The 0x0200 and
// 0x0400 bits are tcell extensions used by the WASM browser shim,
// which has Meta keys but no native Win32 bit assignment for them.
if n&0x0008 != 0 {
m |= ModLCtrl
}
if n&0x0004 != 0 {
m |= ModRCtrl
}
if n&0x0002 != 0 {
m |= ModLAlt
}
if n&0x0001 != 0 {
m |= ModRAlt
}
if n&0x0200 != 0 {
m |= ModLMeta
}
if n&0x0400 != 0 {
m |= ModRMeta
}
} else {
if n&0x000c != 0 {
m |= ModCtrl
}
if n&0x0003 != 0 {
m |= ModAlt
}
}
return m
}
func winModifierKey(vk int) (Key, ModMask, bool) {
switch vk {
case 0x10:
return KeyShift, ModShift, true
case 0xa0:
return KeyShift, ModLShift, true
case 0xa1:
return KeyShift, ModRShift, true
case 0x11:
return KeyCtrl, ModCtrl, true
case 0xa2:
return KeyCtrl, ModLCtrl, true
case 0xa3:
return KeyCtrl, ModRCtrl, true
case 0x12:
return KeyAlt, ModAlt, true
case 0xa4:
return KeyAlt, ModLAlt, true
case 0xa5:
return KeyAlt, ModRAlt, true
case 0x5b:
return KeyMeta, ModLMeta, true
case 0x5c:
return KeyMeta, ModRMeta, true
case 0x14:
return KeyCapsLock, ModNone, true
default:
return 0, ModNone, false
}
}
func kittyModifierKey(code int) ModMask {
switch code {
case 57441:
return ModLShift
case 57447:
return ModRShift
case 57442:
return ModLCtrl
case 57448:
return ModRCtrl
case 57443:
return ModLAlt
case 57449:
return ModRAlt
case 57444:
return ModLMeta
case 57450:
return ModRMeta
default:
return ModNone
}
}
func (ip *inputParser) handleMouse(mode rune, params []int) {
// XTerm mouse events only report at most one button at a time,
@ -729,9 +935,15 @@ func (ip *inputParser) handleMouse(mode rune, params []int) {
btn := params[0]
// Some terminals will report mouse coordinates outside the
// screen, especially with click-drag events. Clip the coordinates
// to the screen in that case.
x := max(min(params[1]-1, ip.cols-1), 0)
y := max(min(params[2]-1, ip.rows-1), 0)
// to the screen in that case. In pixel-reporting mode (CSI ?1016h)
// the values are already pixels rather than cells, so skip the clip
// and pass them through unchanged for the application to interpret.
x := params[1] - 1
y := params[2] - 1
if !ip.pixelMouse {
x = max(min(x, ip.cols-1), 0)
y = max(min(y, ip.rows-1), 0)
}
button := ButtonNone
mod := ModNone
@ -827,7 +1039,7 @@ func (ip *inputParser) handleWinKey(P []int) {
for len(P) < 6 {
P = append(P, 0) // ensure sufficient length
}
if P[3] == 0 {
if P[3] == 0 && !ip.advanced {
// key up event ignore ignore
return
}
@ -835,58 +1047,81 @@ func (ip *inputParser) handleWinKey(P []int) {
// these terminals never send ambiguous escapes
ip.escaped = false
if P[0] == 0 && P[1] == 0 && P[2] > 0 && P[2] < 0x80 { // only ASCII in win32-input-mode
if ip.nested == nil {
ip.nested = &inputParser{
evch: ip.evch,
rows: ip.rows,
cols: ip.cols,
if P[0] == 0 && P[1] == 0 { // only ASCII in win32-input-mode
if b, ok := asciiByteFromInt(P[2]); ok {
if ip.nested == nil {
ip.nested = &inputParser{
evch: ip.evch,
rows: ip.rows,
cols: ip.cols,
advanced: ip.advanced,
pixelMouse: ip.pixelMouse,
controlStringMax: ip.controlStringMax,
}
}
ip.nested.ScanUTF8([]byte{b})
return
}
if P[2] > 0 {
ip.nested.ScanUTF8([]byte{byte(P[2])})
}
return
}
key := KeyRune
chr := rune(P[2])
mod := ModNone
rpt := max(1, P[5])
decoded := false
if k1, ok := winKeys[P[0]]; ok {
chr = 0
key = k1
decoded = true
} else if ip.advanced {
if k1, mod1, ok := winModifierKey(P[0]); ok {
key = k1
mod = mod1
chr = 0
decoded = true
}
}
if decoded {
// Already decoded.
} else if chr == 0 && P[0] >= 0x30 && P[0] <= 0x39 {
chr = rune(P[0])
} else if chr < ' ' && P[0] >= 0x41 && P[0] <= 0x5a {
key = Key(P[0])
chr = 0
if ip.advanced {
key = KeyRune
chr = rune(P[0] + 0x20)
} else {
var ok bool
if key, ok = keyFromInt(P[0]); !ok {
return
}
chr = 0
}
} else if chr >= 0xD800 && chr <= 0xDBFF {
// high surrogate pair
if ip.surrogate != 0 {
ip.postKeyEx(KeyRune, string(utf8.RuneError), mod, P[3] != 0, 0, rpt)
}
ip.surrogate = chr
return
} else if chr >= 0xDC00 && chr <= 0xDFFF {
// low surrogate pair
chr = utf16.DecodeRune(ip.surrogate, chr)
} else if P[0] == 0x10 || P[0] == 0x11 || P[0] == 0x12 || P[0] == 0x14 {
// lone modifiers
if ip.surrogate == 0 {
chr = utf8.RuneError
} else {
chr = utf16.DecodeRune(ip.surrogate, chr)
}
} else if ip.surrogate != 0 {
ip.postKeyEx(KeyRune, string(utf8.RuneError), mod, P[3] != 0, 0, rpt)
} else if _, _, ok := winModifierKey(P[0]); ok {
// Lone modifier releases are ignored unless advanced mode is enabled.
ip.surrogate = 0
return
}
ip.surrogate = 0
// Modifiers
if P[4]&0x010 != 0 {
mod |= ModShift
}
if P[4]&0x000c != 0 {
mod |= ModCtrl
}
if P[4]&0x0003 != 0 {
mod |= ModAlt
}
if key == KeyRune && chr > ' ' && mod == ModShift {
mod |= calcWinModifier(P[4], ip.advanced)
if key == KeyRune && chr > ' ' && mod == ModShift && !ip.advanced {
// filter out lone shift for printable chars
mod = ModNone
}
@ -895,13 +1130,18 @@ func (ip *inputParser) handleWinKey(P []int) {
mod = ModNone
}
for range rpt {
if key != KeyRune {
ip.post(NewEventKey(key, "", mod))
} else if chr != 0 {
ip.post(NewEventKey(KeyRune, string(chr), mod))
physical := key
if key == KeyRune && chr != 0 {
physical, _ = keyFromRune(chr)
if ip.advanced && P[0] >= 0x41 && P[0] <= 0x5a {
physical, _ = keyFromInt(P[0] + 0x20)
}
}
if key != KeyRune {
ip.postKeyEx(key, "", mod, P[3] != 0, physical, rpt)
} else if chr != 0 {
ip.postKeyEx(KeyRune, string(chr), mod, P[3] != 0, physical, rpt)
}
}
func (ip *inputParser) handlePrimaryDA(params []int) {
@ -976,7 +1216,6 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
// reset state
ip.state = istInit
var parts []string
var P []int
hasLT := false
hasQM := false
@ -994,16 +1233,54 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
pstr = pstr[1:]
}
pressed := true
repeat := 1
physical := Key(0)
if pstr != "" && pstr[0] >= '0' && pstr[0] <= '9' {
parts = strings.Split(pstr, ";")
var PSubs [][]int
parts := strings.Split(pstr, ";")
for i := range parts {
if parts[i] != "" {
if n, e := strconv.ParseInt(parts[i], 10, 32); e == nil {
subparts := strings.Split(parts[i], ":")
if subparts[0] != "" {
if n, e := strconv.ParseInt(subparts[0], 10, 32); e == nil {
P = append(P, int(n))
} else {
P = append(P, 0)
}
} else {
P = append(P, 0)
}
subs := []int{}
for _, sub := range subparts[1:] {
if sub != "" {
if n, e := strconv.ParseInt(sub, 10, 32); e == nil {
subs = append(subs, int(n))
}
} else {
subs = append(subs, 0)
}
}
PSubs = append(PSubs, subs)
}
if len(PSubs) > 1 && len(PSubs[1]) > 0 {
switch PSubs[1][0] {
case 2:
repeat = 2
case 3:
pressed = false
}
}
if len(PSubs) > 0 && len(PSubs[0]) > 0 {
base := PSubs[0][0]
if baseKey, ok := csiUKeys[base]; ok {
physical = baseKey.Key
if physical == KeyRune && baseKey.Rune != 0 {
physical, _ = keyFromRune(baseKey.Rune)
}
} else if base != 0 {
physical, _ = keyFromInt(base)
}
}
}
var P0 int
@ -1076,10 +1353,13 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
if len(P) > 1 {
mod = calcModifier(P[1])
}
if mod1 := kittyModifierKey(P0); mod1 != ModNone {
mod |= mod1
}
if key != KeyRune {
ip.post(NewEventKey(key, "", mod))
ip.postKeyEx(key, "", mod, pressed, physical, repeat)
} else if chr != 0 {
ip.post(NewEventKey(KeyRune, string(chr), mod))
ip.postKeyEx(KeyRune, string(chr), mod, pressed, physical, repeat)
}
return
}
@ -1114,14 +1394,17 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
if len(P) >= 2 {
mod := calcModifier(P[1])
if ks, ok := csiAllKeys[csiParamMode{M: mode, P: P0}]; ok {
ip.post(NewEventKey(ks.Key, "", mod))
ip.postKeyEx(ks.Key, "", mod, pressed, 0, repeat)
return
}
if P0 == 27 && len(P) > 2 && P[2] > 0 && P[2] <= 0xff {
if P0 == 27 && len(P) > 2 && P[2] > 0 && P[2] <= utf8.MaxRune {
if P[2] < ' ' || P[2] == 0x7F {
ip.post(NewEventKey(Key(P[2]), "", mod))
if key, ok := keyFromInt(P[2]); ok {
ip.postKey(key, "", mod)
}
} else {
ip.post(NewEventKey(KeyRune, string(rune(P[2])), mod))
physical, _ := keyFromRune(rune(P[2]))
ip.postKeyEx(KeyRune, string(rune(P[2])), mod, true, physical, 1)
}
return
}
@ -1135,13 +1418,13 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
} else if mode == 'P' && os.Getenv("TERM") == "aixterm" {
ks.Key = KeyDelete // aixterm hack - conflicts with kitty protocol
}
ip.post(NewEventKey(ks.Key, "", ks.Mod))
ip.postKey(ks.Key, "", ks.Mod)
return
}
// this might have been an SS3 style key with modifiers applied
if k, ok := ss3Keys[mode]; ok && P0 == 1 && len(P) > 1 {
ip.post(NewEventKey(k, "", calcModifier(P[1])))
ip.postKeyEx(k, "", calcModifier(P[1]), pressed, 0, repeat)
return
}
// if we got here we just swallow the unknown sequence

View file

@ -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.
@ -45,9 +45,12 @@ import (
// specific keys.
type EventKey struct {
EventTime
mod ModMask
key Key
str string // string for key, usually just one character, but may be composed sequence
mod ModMask
key Key
physical Key
str string // string for key, usually just one character, but may be composed sequence
pressed bool
repeat int
}
// Str returns the string corresponding to the key press, if it makes sense.
@ -66,6 +69,38 @@ func (ev *EventKey) Key() Key {
return ev.key
}
// Physical returns the physical key that was pressed, when known.
//
// This is different from Key() and Str(), which describe the logical key result
// delivered to the application. For example, on a US keyboard Shift-/ may
// produce Str() == "?", while Physical() reports KeySlash. Most applications
// should use Key() and Str(); Physical is intended for layout-independent uses
// such as keyboard remappers, embedded terminal emulators, and games that care
// about key location rather than the printed character.
//
// For letter keys, compare physical values against the lowercase aliases
// KeyA through KeyZ. The legacy KeyCtrlA through KeyCtrlZ constants occupy
// the same numeric range as Key('A') through Key('Z'), so Key('A') is not a
// physical "A" key identifier.
//
// If the physical key is unknown, this returns zero.
func (ev *EventKey) Physical() Key {
return ev.physical
}
// Pressed returns true for key press events, and false for key release events.
// Legacy keyboard reporting only reports presses.
func (ev *EventKey) Pressed() bool {
return ev.pressed
}
// Repeat returns the repeat count for this key event. Legacy keyboard
// reporting synthesizes repeated key presses as separate events, so this will
// normally be 1.
func (ev *EventKey) Repeat() int {
return ev.repeat
}
// Modifiers returns the modifiers that were present with the key press. Note
// that not all platforms and terminals support this equally well, and some
// cases we will not not know for sure. Hence, applications should avoid
@ -74,6 +109,19 @@ func (ev *EventKey) Modifiers() ModMask {
return ev.mod
}
// KeyProtocol identifies the keyboard reporting protocol that the terminal
// is currently using. More capable protocols allow disambiguating modifier
// combinations, distinguishing key release events, etc.
type KeyProtocol int
// These are the keyboard protocols that tcell can report.
const (
LegacyKeyboard KeyProtocol = iota // basic VT100 style reports
KittyKeyboard // kitty supports events, unambiguous keys modulo left/right modifiers
Win32Keyboard // win32 supports the full feature set
XTermKeyboard // xterm modify other keys, disambiguation only, no release events
)
// KeyNames holds the written names of special keys. Useful to echo back a key
// name, or to look up a key from a string value.
var KeyNames = map[Key]string{
@ -171,6 +219,11 @@ var KeyNames = map[Key]string{
KeyCapsLock: "CapsLock",
KeyScrollLock: "ScrollLock",
KeyNumLock: "NumLock",
KeyShift: "Shift",
KeyCtrl: "Ctrl",
KeyAlt: "Alt",
KeyMeta: "Meta",
KeyHyper: "Hyper",
KeyCtrlA: "Ctrl-A",
KeyCtrlB: "Ctrl-B",
KeyCtrlC: "Ctrl-C",
@ -204,19 +257,49 @@ var KeyNames = map[Key]string{
func (ev *EventKey) Name() string {
s := ""
m := []string{}
if ev.mod&ModShift != 0 {
if ev.mod&modLShift != 0 {
m = append(m, "LeftShift")
}
if ev.mod&modRShift != 0 {
m = append(m, "RightShift")
}
if ev.mod&ModShift != 0 && ev.mod&(modLShift|modRShift) == 0 {
m = append(m, "Shift")
}
if ev.mod&ModAlt != 0 {
if ev.mod&modLAlt != 0 {
m = append(m, "LeftAlt")
}
if ev.mod&modRAlt != 0 {
m = append(m, "RightAlt")
}
if ev.mod&ModAlt != 0 && ev.mod&(modLAlt|modRAlt) == 0 {
m = append(m, "Alt")
}
if ev.mod&ModMeta != 0 {
if ev.mod&modLMeta != 0 {
m = append(m, "LeftMeta")
}
if ev.mod&modRMeta != 0 {
m = append(m, "RightMeta")
}
if ev.mod&ModMeta != 0 && ev.mod&(modLMeta|modRMeta) == 0 {
m = append(m, "Meta")
}
if ev.mod&ModCtrl != 0 {
if ev.mod&modLCtrl != 0 {
m = append(m, "LeftCtrl")
}
if ev.mod&modRCtrl != 0 {
m = append(m, "RightCtrl")
}
if ev.mod&ModCtrl != 0 && ev.mod&(modLCtrl|modRCtrl) == 0 {
m = append(m, "Ctrl")
}
if ev.mod&ModHyper != 0 {
if ev.mod&modLHyper != 0 {
m = append(m, "LeftHyper")
}
if ev.mod&modRHyper != 0 {
m = append(m, "RightHyper")
}
if ev.mod&ModHyper != 0 && ev.mod&(modLHyper|modRHyper) == 0 {
m = append(m, "Hyper")
}
@ -229,6 +312,28 @@ func (ev *EventKey) Name() string {
}
}
if len(m) != 0 {
switch ev.key {
case KeyShift:
if ev.mod&(modLShift|modRShift|ModShift) != 0 {
return strings.Join(m, "+")
}
case KeyCtrl:
if ev.mod&(modLCtrl|modRCtrl|ModCtrl) != 0 {
return strings.Join(m, "+")
}
case KeyAlt:
if ev.mod&(modLAlt|modRAlt|ModAlt) != 0 {
return strings.Join(m, "+")
}
case KeyMeta:
if ev.mod&(modLMeta|modRMeta|ModMeta) != 0 {
return strings.Join(m, "+")
}
case KeyHyper:
if ev.mod&(modLHyper|modRHyper|ModHyper) != 0 {
return strings.Join(m, "+")
}
}
if ev.mod&ModCtrl != 0 && strings.HasPrefix(s, "Ctrl-") {
s = s[5:]
}
@ -242,10 +347,25 @@ func (ev *EventKey) Name() string {
// has more precise information it should set that specifically. Callers
// that aren't sure about modifier state (most) should just pass ModNone.
func NewEventKey(k Key, str string, mod ModMask) *EventKey {
return newEventKey(k, str, mod, true, 0, 1, false)
}
// NewEventKeyEx creates an extended key event with press/release, physical key,
// and repeat metadata. It also uses the newer key normalization rules: ASCII
// control letters are reported as KeyRune plus ModCtrl instead of legacy
// KeyCtrlA through KeyCtrlZ values.
func NewEventKeyEx(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) *EventKey {
return newEventKey(k, str, mod, pressed, physical, repeat, true)
}
func newEventKey(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int, advanced bool) *EventKey {
ch := rune(0)
if len(str) == 1 {
ch = []rune(str)[0]
}
if repeat <= 0 {
repeat = 1
}
if k == KeyRune {
if ch != 0 && (ch < ' ' || ch == 0x7f) {
@ -267,7 +387,7 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
// For legacy reasons, if Ctrl is pressed with an ASCII alphabetic, then we
// emit it as a KeyCtrlXX symbol.
if mod == ModCtrl {
if mod == ModCtrl && !advanced {
// We don't do Ctrl-[ or backslash or those specially.
if ch >= 'A' && ch <= 'Z' { // upper case
k = KeyCtrlA + Key(ch-'A')
@ -280,7 +400,7 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
// Windows reports ModShift for shifted keys. This is inconsistent
// with UNIX, lets harmonize this.
if mod == ModShift && str != "" {
if mod == ModShift && str != "" && !advanced {
mod = ModNone
}
}
@ -290,19 +410,29 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
k = KeyBackspace
}
// Advanced key reporting exposes Shift-Tab directly. Backtab is a legacy
// alias from terminals that cannot distinguish a physical Backtab key.
if k == KeyBacktab && advanced {
k = KeyTab
mod |= ModShift
if physical == 0 || physical == KeyBacktab {
physical = KeyTab
}
}
// Shift-Tab should be Backtab.
if k == KeyTab && (mod&ModShift) != 0 {
if k == KeyTab && (mod&ModShift) != 0 && !advanced {
k = KeyBacktab
mod &^= ModShift
}
ev := &EventKey{key: k, str: str, mod: mod}
ev := &EventKey{key: k, str: str, mod: mod, pressed: pressed, physical: physical, repeat: repeat}
ev.SetEventNow()
return ev
}
// ModMask is a mask of modifier keys. Note that it will not always be
// possible to report modifier keys.
type ModMask int16
type ModMask int32
// These are the modifiers keys that can be sent either with a key press,
// or a mouse event. Note that as of now, due to the confusion associated
@ -318,6 +448,103 @@ const (
ModNone ModMask = 0
)
const (
modLShift ModMask = 1 << (iota + 5)
modRShift
modLCtrl
modRCtrl
modLAlt
modRAlt
modLMeta
modRMeta
modLHyper
modRHyper
)
// These modifiers identify a specific side when the keyboard protocol reports
// one. They include the aggregate modifier bit, so ModLCtrl also satisfies
// checks for ModCtrl.
const (
ModLShift = ModShift | modLShift
ModRShift = ModShift | modRShift
ModLCtrl = ModCtrl | modLCtrl
ModRCtrl = ModCtrl | modRCtrl
ModLAlt = ModAlt | modLAlt
ModRAlt = ModAlt | modRAlt
ModLMeta = ModMeta | modLMeta
ModRMeta = ModMeta | modRMeta
ModLHyper = ModHyper | modLHyper
ModRHyper = ModHyper | modRHyper
)
// These keys are aliases for printable physical keys. They are primarily
// useful with EventKey.Physical, which may report a base key location separately
// from the generated text.
//
// These names identify the unshifted base key on a US-style keyboard layout.
// They do not identify logical characters produced by modifiers or other
// layouts. For example, the physical key named KeySlash may produce "/" or
// "?" on a US keyboard depending on Shift, and may produce different text on
// other layouts. Applications interested in the logical key sequence should
// use EventKey.Key and EventKey.Str instead.
const (
KeySpace Key = ' '
Key0 Key = '0'
Key1 Key = '1'
Key2 Key = '2'
Key3 Key = '3'
Key4 Key = '4'
Key5 Key = '5'
Key6 Key = '6'
Key7 Key = '7'
Key8 Key = '8'
Key9 Key = '9'
KeyGrave Key = '`'
KeyBacktick Key = KeyGrave
KeyMinus Key = '-'
KeyEqual Key = '='
KeyLBrace Key = '['
KeyLBracket Key = KeyLBrace
KeyRBrace Key = ']'
KeyRBracket Key = KeyRBrace
KeyBackslash Key = '\\'
KeySemi Key = ';'
KeySemicolon Key = KeySemi
KeyQuote Key = '\''
KeyApostrophe Key = KeyQuote
KeyComma Key = ','
KeyPeriod Key = '.'
KeySlash Key = '/'
KeyA Key = 'a'
KeyB Key = 'b'
KeyC Key = 'c'
KeyD Key = 'd'
KeyE Key = 'e'
KeyF Key = 'f'
KeyG Key = 'g'
KeyH Key = 'h'
KeyI Key = 'i'
KeyJ Key = 'j'
KeyK Key = 'k'
KeyL Key = 'l'
KeyM Key = 'm'
KeyN Key = 'n'
KeyO Key = 'o'
KeyP Key = 'p'
KeyQ Key = 'q'
KeyR Key = 'r'
KeyS Key = 's'
KeyT Key = 't'
KeyU Key = 'u'
KeyV Key = 'v'
KeyW Key = 'w'
KeyX Key = 'x'
KeyY Key = 'y'
KeyZ Key = 'z'
)
// Key is a generic value for representing keys, and especially special
// keys (function keys, cursor movement keys, etc.) For normal keys, like
// ASCII letters, we use KeyRune, and then expect the application to
@ -351,6 +578,8 @@ const (
KeyCancel
KeyPrint
KeyPause
// KeyBacktab is used for legacy Shift-Tab reporting. In advanced key
// reporting mode, Shift-Tab is reported as KeyTab with ModShift instead.
KeyBacktab
KeyF1
KeyF2
@ -420,6 +649,11 @@ const (
KeyCapsLock
KeyScrollLock
KeyNumLock
KeyShift
KeyCtrl
KeyAlt
KeyMeta
KeyHyper
)
const (
@ -432,6 +666,10 @@ const (
// rune (lower case) and control modifier. If the shift key
// or other modifiers are present then these will *NOT* be reported,
// but reported instead as KeyRune.
//
// Note that these are not reported in advanced key reporting mode.
// Instead, for advanced keys, expect KeyRune and a modifier with the
// associated rune to be sent.
const (
KeyCtrlA Key = iota + 65
KeyCtrlB
@ -466,6 +704,10 @@ const (
// These are the defined ASCII values for key codes. They generally match
// with KeyCtrl values.
//
// Most of these will not be reported in advanced key reporting mode, as they
// are not possible to type directly. Some notable exceptions are KeyESC, KeyBS,
// KeyTAB, and KeyCR, which have aliases below.
const (
KeyNUL Key = iota
KeySOH

View file

@ -49,8 +49,9 @@ func (ev *EventMouse) Modifiers() ModMask {
return ev.mod
}
// Position returns the mouse position in character cells. The origin
// 0, 0 is at the upper left corner.
// Position returns the mouse position. The origin 0, 0 is at the upper
// left corner. The unit is character cells unless the screen was started
// with MousePixelEvents, in which case the unit is terminal pixels.
func (ev *EventMouse) Position() (int, int) {
return ev.x, ev.y
}

View file

@ -249,6 +249,9 @@ type Screen interface {
// supports it. Right now only terminals supporting OSC 777 support this.
ShowNotification(title string, body string)
// KeyboardProtocol returns the keyboard protocol currently in use.
KeyboardProtocol() KeyProtocol
// Terminal returns the terminal name and version if known. If either of these
// are unknown, then empty strings are returned in their place. This is intended
// to facilitate debug, and also applications that wish to enable very specific
@ -260,7 +263,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 +274,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
@ -296,6 +300,15 @@ const (
MouseButtonEvents = MouseFlags(1) // Click events only
MouseDragEvents = MouseFlags(2) // Click-drag events (includes button events)
MouseMotionEvents = MouseFlags(4) // All mouse events (includes click and drag events)
// MousePixelEvents requests that mouse coordinates be reported in
// terminal pixels rather than character cells (xterm SGR-Pixel mode,
// CSI ?1016h). It is a modifier on the other mouse flags: at least one
// of MouseButtonEvents, MouseDragEvents, or MouseMotionEvents must also
// be set for any events to be delivered. When this mode is active,
// EventMouse.Position() returns coordinates in pixels; the application
// is responsible for mapping those to its own grid (e.g. via the
// terminal's reported cell-pixel size).
MousePixelEvents = MouseFlags(8)
)
// CursorStyle represents a given cursor style, which can include the shape and
@ -345,6 +358,7 @@ type screenImpl interface {
GetClipboard()
HasClipboard() bool
ShowNotification(string, string)
KeyboardProtocol() KeyProtocol
Terminal() (string, string)
// Following methods are not part of the Screen api, but are used for interaction with
@ -382,16 +396,19 @@ func (b *baseScreen) Put(x int, y int, str string, style Style) (remain string,
func (b *baseScreen) PutStrStyled(x int, y int, str string, style Style) {
cells := b.GetCells()
b.Lock()
defer b.Unlock()
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
}
x += width
}
defer b.Unlock()
}
func (b *baseScreen) PutStr(x, y int, str string) {

View file

@ -43,6 +43,7 @@ type urlInfo struct {
id string
}
// stripOSCControls removes control bytes that can terminate OSC payloads early.
func stripOSCControls(s string) string {
var b strings.Builder
b.Grow(len(s))
@ -68,6 +69,18 @@ func stripOSCControls(s string) string {
return b.String()
}
// stripOSCControlsIfNeeded returns the original string when it contains no
// control bytes and only allocates when stripping is required.
func stripOSCControlsIfNeeded(s string) string {
for i := 0; i < len(s); i++ {
c := s[i]
if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) {
return stripOSCControls(s)
}
}
return s
}
// StyleDefault represents a default style, based upon the context.
// It is the zero value.
var StyleDefault Style
@ -102,10 +115,15 @@ func (s Style) setAttrs(attrs AttrMask, on bool) Style {
}
// Normal returns the style with all attributes disabled.
// Colors are preserved, as are hyperlinks. (Underline color
// will also be preserved, but no underline is currently shown.
// Apart from color, the underline style is reset as well.)
func (s Style) Normal() Style {
return Style{
fg: s.fg,
bg: s.bg,
fg: s.fg,
bg: s.bg,
ulColor: s.ulColor,
url: s.url,
}
}
@ -227,10 +245,13 @@ func (s Style) GetAttributes() AttrMask {
func (s Style) Url(url string) Style {
s2 := s
s2.url = &urlInfo{url: stripOSCControls(url)}
s2.url = &urlInfo{url: stripOSCControlsIfNeeded(url)}
if s.url != nil {
s2.url.id = s.url.id
}
if s2.url.url == "" && s2.url.id == "" {
s2.url = nil
}
return s2
}
@ -240,12 +261,16 @@ func (s Style) Url(url string) Style {
// were one Url, even if it spans multiple lines.
func (s Style) UrlId(id string) Style {
s2 := s
s2.url = &urlInfo{
id: "id=" + stripOSCControls(id),
s2.url = &urlInfo{}
if id = stripOSCControlsIfNeeded(id); id != "" {
s2.url.id = "id=" + id
}
if s.url != nil {
s2.url.url = s.url.url
}
if s2.url.url == "" && s2.url.id == "" {
s2.url = nil
}
return s2
}

View file

@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !js && !wasm
// +build !js,!wasm
//go:build (!js && !wasm) || (js && wasm)
// +build !js,!wasm js,wasm
package tcell
@ -83,6 +83,53 @@ 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)
}
// OptAdvancedKeys enables richer key reporting where supported. In this mode
// key events may include release state, repeat counts, and physical keys, and
// ASCII control letters are reported as KeyRune with ModCtrl instead of
// KeyCtrlA through KeyCtrlZ. Shift-Tab is reported as KeyTab with ModShift,
// rather than KeyBacktab.
type OptAdvancedKeys bool
func (o OptAdvancedKeys) apply(t *tScreen) {
t.advancedKeys = bool(o)
}
// OptKeyboardProtocol forces the keyboard reporting protocol instead of using
// startup negotiation. The zero value forces legacy keyboard reporting.
type OptKeyboardProtocol KeyProtocol
func (o OptKeyboardProtocol) apply(t *tScreen) {
t.forceKeyboardProtocol(KeyProtocol(o))
}
// OptNegotiation controls whether terminal capabilities are negotiated during
// startup. The default is true.
type OptNegotiation bool
func (o OptNegotiation) apply(t *tScreen) {
t.negotiate = bool(o)
}
// OptControlStringLimit sets the maximum inbound control-string payload size
// accepted from the terminal before the parser drops the sequence. This limits
// OSC and XDA strings, including OSC 52 clipboard strings; OSC 52 is the
// protocol used for writing clipboard data through the terminal. The default is
// 64 KiB; a value of 0 disables the limit.
type OptControlStringLimit int
func (o OptControlStringLimit) apply(t *tScreen) {
t.controlStringLimit = max(int(o), 0)
}
// 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,
@ -132,6 +179,7 @@ const (
notifyDesktop777 = "\x1b]777;notify;%s;%s\x1b\\" // Most commonly supported
queryKittyKbd = "\x1b[?u" // Query for Kitty keyboard support
enableKittyKbd = "\x1b[=1u" // Technically this pushes
enableKittyKbdAdv = "\x1b[=15u" // disambiguation, events, alternate keys, all keys
disableKittyKbd = "\x1b[=0u" // Technically this means pop previous mode
queryXTermKbd = "\x1b[?4m" // Query for XTerm modify other keys support
enableXTermKbd = "\x1b[>4;2m" // Enable modify other keys protocol
@ -143,7 +191,12 @@ 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, altScreen: true}
t := &tScreen{
tty: tty,
altScreen: true,
negotiate: true,
controlStringLimit: defaultControlStringLimit,
}
t.prepareCursorStyles()
t.prepareExtendedOSC()
@ -160,71 +213,77 @@ func NewTerminfoScreenFromTty(tty Tty, opts ...TerminfoScreenOption) (Screen, er
// tScreen represents a screen backed by a terminfo implementation.
type tScreen struct {
tty Tty
h int
w int
fini bool
cells CellBuffer
buffering bool // true if we are collecting writes to buf instead of sending directly to out
buf bytes.Buffer
curstyle Style
style Style
resizeQ chan bool
quit chan struct{}
keyQ chan []byte
cx int
cy int
cls bool // clear screen
cursorx int
cursory int
acs map[rune]string
charset string
encoder transform.Transformer
decoder transform.Transformer
fallback map[rune]string
ncolor int
colors map[color.Color]color.Color
palette []color.Color
truecolor bool
noColor bool
legacy bool
hasClipboard bool // true if OSC 52 reported via DA1
finiOnce sync.Once
enterUrl string
exitUrl string
setWinSize string
cursorStyles map[CursorStyle]string
cursorStyle CursorStyle
cursorColor color.Color
cursorRGB string
cursorFg string
stopQ chan struct{}
eventQ chan Event
initQ chan Event
initted bool
running bool
startTime time.Time
wg sync.WaitGroup
mouseFlags MouseFlags
pasteEnabled bool
focusEnabled bool
setTitle string
saveTitle string
restoreTitle string
title string
setClipboard string
notifyDesktop string
termName string
termVers string
term string // value from $TERM
altScreen bool
inlineResize bool
haveMouse bool
haveMouseSgr bool
haveKittyKbd bool
haveWin32Kbd bool
haveXTermKbd bool
input *inputParser
tty Tty
h int
w int
fini bool
cells CellBuffer
buffering bool // true if we are collecting writes to buf instead of sending directly to out
buf bytes.Buffer
curstyle Style
style Style
resizeQ chan bool
quit chan struct{}
keyQ chan []byte
cx int
cy int
cls bool // clear screen
cursorx int
cursory int
acs map[rune]string
charset string
encoder transform.Transformer
decoder transform.Transformer
fallback map[rune]string
ncolor int
colors map[color.Color]color.Color
palette []color.Color
truecolor bool
noColor bool
legacy bool
hasClipboard bool // true if OSC 52 reported via DA1
finiOnce sync.Once
enterUrl string
exitUrl string
setWinSize string
cursorStyles map[CursorStyle]string
cursorStyle CursorStyle
cursorColor color.Color
cursorRGB string
cursorFg string
stopQ chan struct{}
eventQ chan Event
initQ chan Event
initted bool
running bool
startTime time.Time
wg sync.WaitGroup
mouseFlags MouseFlags
pasteEnabled bool
focusEnabled bool
setTitle string
saveTitle string
restoreTitle string
title string
setClipboard string
notifyDesktop string
termName string
termVers string
term string // value from $TERM
altScreen bool
inlineResize bool
haveMouse bool
haveMouseSgr bool
haveKittyKbd bool
haveWin32Kbd bool
haveXTermKbd bool
forcedKbd KeyProtocol
forceKbd bool
negotiate bool
mouseDisabled bool
advancedKeys bool
controlStringLimit int
input *inputParser
sync.Mutex
}
@ -232,6 +291,69 @@ func (t *tScreen) useAltScreen() bool {
return t.altScreen && os.Getenv("TCELL_ALTSCREEN") != "disable"
}
func validKeyboardProtocol(p KeyProtocol) bool {
switch p {
case LegacyKeyboard, KittyKeyboard, Win32Keyboard, XTermKeyboard:
return true
default:
return false
}
}
func parseKeyboardProtocol(s string) (KeyProtocol, bool) {
switch s {
case "legacy":
return LegacyKeyboard, true
case "kitty":
return KittyKeyboard, true
case "win32":
return Win32Keyboard, true
case "xterm":
return XTermKeyboard, true
default:
return LegacyKeyboard, false
}
}
func (t *tScreen) forceKeyboardProtocol(p KeyProtocol) bool {
if !validKeyboardProtocol(p) {
return false
}
t.forcedKbd = p
t.forceKbd = true
return true
}
func (t *tScreen) applyKeyboardProtocolOverride() {
if !t.forceKbd {
return
}
t.haveKittyKbd = t.forcedKbd == KittyKeyboard
t.haveWin32Kbd = t.forcedKbd == Win32Keyboard
t.haveXTermKbd = t.forcedKbd == XTermKeyboard
}
func (t *tScreen) applyEnvironmentOverrides() {
switch os.Getenv("TCELL_KEYBOARD_PROTOCOL") {
case "auto":
t.forceKbd = false
case "":
default:
if p, ok := parseKeyboardProtocol(os.Getenv("TCELL_KEYBOARD_PROTOCOL")); ok {
t.forceKeyboardProtocol(p)
}
}
switch os.Getenv("TCELL_NEGOTIATE") {
case "auto":
t.negotiate = true
case "disable":
t.negotiate = false
}
t.mouseDisabled = os.Getenv("TCELL_MOUSE") == "disable"
}
func (t *tScreen) Init() error {
if e := t.initialize(); e != nil {
return e
@ -307,11 +429,15 @@ func (t *tScreen) Init() error {
t.legacy = true
}
t.applyEnvironmentOverrides()
t.initted = false
t.quit = make(chan struct{})
t.initQ = make(chan Event, 32)
t.eventQ = make(chan Event, 128)
t.input = newInputParser(t.filterEvents())
t.input.advanced = t.advancedKeys
t.input.controlStringMax = t.controlStringLimit
t.Lock()
t.cx = -1
@ -480,6 +606,9 @@ func (t *tScreen) Fini() {
}
func (t *tScreen) finish() {
t.Lock()
t.fini = true
t.Unlock()
close(t.quit)
t.finalize()
}
@ -664,6 +793,13 @@ func (t *tScreen) emitUrl(u urlInfo) {
}
}
// urlNeedsEmission reports whether a hyperlink transition has any wire effect.
// Url ids can be staged before the Url itself, and id-only transitions have no
// OSC 8 representation of their own.
func urlNeedsEmission(oldUrl, newUrl urlInfo) bool {
return oldUrl != newUrl && (oldUrl.url != "" || newUrl.url != "")
}
func (t *tScreen) drawCell(x, y int) int {
str, style, width := t.cells.Get(x, y)
@ -697,8 +833,8 @@ func (t *tScreen) drawCell(x, y int) int {
if style.url != nil {
newUrl = *style.url
}
// URL string can be long, so don't send it unless we really need to
if newUrl != oldUrl {
// URL string can be long, so don't send it unless we really need to.
if urlNeedsEmission(oldUrl, newUrl) {
t.emitUrl(newUrl)
}
@ -746,6 +882,10 @@ func (t *tScreen) drawCell(x, y int) int {
func (t *tScreen) ShowCursor(x, y int) {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.cursorx = x
t.cursory = y
t.Unlock()
@ -753,6 +893,10 @@ func (t *tScreen) ShowCursor(x, y int) {
func (t *tScreen) SetCursor(cs CursorStyle, cc Color) {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.cursorStyle = cs
t.cursorColor = cc
t.Unlock()
@ -876,6 +1020,10 @@ func (t *tScreen) draw() {
}
}
if t.curstyle.url != nil && t.curstyle.url.url != "" {
t.emitUrl(urlInfo{})
}
// restore the cursor
t.showCursor()
@ -894,6 +1042,10 @@ func (t *tScreen) EnableMouse(flags ...MouseFlags) {
}
t.Lock()
if t.fini {
t.Unlock()
return
}
t.mouseFlags = f
t.enableMouse(f)
t.Unlock()
@ -912,7 +1064,10 @@ func (t *tScreen) enableMouse(f MouseFlags) {
// so we enable the mouse unconditionally unless we get a report
// that says we have mouse, but not SGR mouse. This is suboptimal, but
// a concession forced by the sorry state of terminal emulators.
if t.haveMouse && !t.haveMouseSgr {
if t.mouseDisabled {
f = 0
}
if f != 0 && t.haveMouse && !t.haveMouseSgr {
return
}
@ -921,6 +1076,10 @@ func (t *tScreen) enableMouse(f MouseFlags) {
t.Print(vt.PmMouseDrag.Disable())
t.Print(vt.PmMouseMotion.Disable())
t.Print(vt.PmMouseSgr.Disable())
t.Print(vt.PmMouseSgrPixel.Disable())
pixel := f&MousePixelEvents != 0
t.input.SetPixelMouse(pixel)
if f&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) != 0 {
t.Print(vt.PmMouseButton.Enable())
@ -932,12 +1091,20 @@ func (t *tScreen) enableMouse(f MouseFlags) {
t.Print(vt.PmMouseMotion.Enable())
}
if f&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) != 0 {
t.Print(vt.PmMouseSgr.Enable())
if pixel {
t.Print(vt.PmMouseSgrPixel.Enable())
} else {
t.Print(vt.PmMouseSgr.Enable())
}
}
}
func (t *tScreen) DisableMouse() {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.mouseFlags = 0
t.enableMouse(0)
t.Unlock()
@ -945,6 +1112,10 @@ func (t *tScreen) DisableMouse() {
func (t *tScreen) EnablePaste() {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.pasteEnabled = true
t.enablePasting(true)
t.Unlock()
@ -952,6 +1123,10 @@ func (t *tScreen) EnablePaste() {
func (t *tScreen) DisablePaste() {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.pasteEnabled = false
t.enablePasting(false)
t.Unlock()
@ -971,6 +1146,10 @@ func (t *tScreen) enablePasting(on bool) {
func (t *tScreen) EnableFocus() {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.focusEnabled = true
t.enableFocusReporting()
t.Unlock()
@ -978,6 +1157,10 @@ func (t *tScreen) EnableFocus() {
func (t *tScreen) DisableFocus() {
t.Lock()
if t.fini {
t.Unlock()
return
}
t.focusEnabled = false
t.disableFocusReporting()
t.Unlock()
@ -1189,6 +1372,11 @@ func (t *tScreen) UnregisterRuneFallback(orig rune) {
}
func (t *tScreen) SetSize(w, h int) {
t.Lock()
defer t.Unlock()
if t.fini {
return
}
if t.setWinSize != "" {
t.Printf(t.setWinSize, w, h)
}
@ -1199,24 +1387,86 @@ func (t *tScreen) SetSize(w, h int) {
func (t *tScreen) Resize(int, int, int, int) {}
func (t *tScreen) Suspend() error {
t.disengage()
t.Lock()
if t.fini {
t.Unlock()
return nil
}
finish := t.disengageStart()
t.Unlock()
if finish {
t.disengageFinish()
}
return nil
}
func (t *tScreen) Resume() error {
return t.engage()
t.Lock()
defer t.Unlock()
if t.fini {
return nil
}
return t.engageLocked()
}
func (t *tScreen) Tty() (Tty, bool) {
return t.tty, true
}
func (t *tScreen) applyKnownTerminalProfile(goos, termProgram string) bool {
switch termProgram {
case "Apple_Terminal":
// macOS Terminal.app cannot handle the startup queries, but it does
// support modern mouse reporting.
t.haveMouse = true
t.haveMouseSgr = true
t.termName = "Terminal.app"
t.termVers = os.Getenv("TERM_PROGRAM_VERSION")
return true
case "WezTerm":
// The WezTerm keyboard protocol to use is in theory driven by its
// own configuration, but we have found this unreliable because it
// does not mask unsupported capabilities. Furthermore, on Windows
// builds the kitty protocol implementation is broken, while on other
// builds win32-input-mode is broken. This is a best effort to make
// WezTerm work reasonably; our stronger advice is to choose another
// terminal program altogether. This workaround will probably not
// apply to ssh sessions, as TERM_PROGRAM is not normally propagated.
if goos == "windows" {
t.haveWin32Kbd = true
} else {
t.haveKittyKbd = true
t.haveWin32Kbd = false
}
t.haveMouse = true
t.haveMouseSgr = true
t.initted = true
t.termName = "WezTerm"
t.termVers = os.Getenv("TERM_PROGRAM_VERSION")
return true
}
return false
}
func useVTWindowSizeQuery(goos string) bool {
return goos != "windows"
}
func useXTermKeyboardQuery(goos string) bool {
return goos != "windows"
}
// engage is used to place the terminal in raw mode and establish screen size, etc.
// Think of this is as tcell "engaging" the clutch, as it's going to be driving the
// terminal interface.
func (t *tScreen) engage() error {
t.Lock()
defer t.Unlock()
return t.engageLocked()
}
// engageLocked is engage's implementation when t's lock is already held.
func (t *tScreen) engageLocked() error {
if t.tty == nil {
return ErrNoScreen
}
@ -1234,24 +1484,37 @@ func (t *tScreen) engage() error {
go t.mainLoop(stopQ)
if !t.initted {
t.Print(requestWindowSize)
// macOS Terminal.app is brain damaged
// https://garrett.damore.org/2025/12/macos-terminal-still-missing-mark-apple.html
// Eventually they'll hopefully fix this. As the environment variable
// does not convey by default via ssh, remote sessions might see spurious characters
// emitted during startup. See the blog post for alternatives.
if os.Getenv("TERM_PROGRAM") != "Apple_Terminal" {
if !t.applyKnownTerminalProfile(runtime.GOOS, os.Getenv("TERM_PROGRAM")) && t.negotiate {
if useVTWindowSizeQuery(runtime.GOOS) {
t.Print(requestWindowSize)
}
t.Print(vt.PmResizeReports.Query())
t.Print(vt.PmMouseButton.Query())
t.Print(vt.PmMouseSgr.Query())
t.Print(vt.PmWin32Input.Query())
t.Print(queryKittyKbd)
t.Print(queryXTermKbd)
if !t.forceKbd {
t.Print(vt.PmWin32Input.Query())
t.Print(queryKittyKbd)
if useXTermKeyboardQuery(runtime.GOOS) {
// XTerm's modifyOtherKeys mode is mainly useful for XTerm
// itself, and we do not use it on Windows.
t.Print(queryXTermKbd)
}
}
t.Print(requestExtAttr)
}
t.Print(requestPrimaryDA) // NB: MUST BE LAST
if !t.negotiate {
t.initted = true
} else if !t.initted {
t.Print(requestPrimaryDA) // NB: MUST BE LAST
}
}
t.processInitQ()
t.applyKeyboardProtocolOverride()
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
@ -1264,7 +1527,11 @@ func (t *tScreen) engage() error {
if t.haveWin32Kbd {
t.Print(vt.PmWin32Input.Enable())
} else if t.haveKittyKbd {
t.Print(enableKittyKbd)
if t.advancedKeys {
t.Print(enableKittyKbdAdv)
} else {
t.Print(enableKittyKbd)
}
} else if t.haveXTermKbd {
t.Print(enableXTermKbd)
}
@ -1286,17 +1553,9 @@ func (t *tScreen) engage() error {
if t.title != "" && t.setTitle != "" {
t.Printf(t.setTitle, t.title)
}
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())
if t.negotiate && useVTWindowSizeQuery(runtime.GOOS) {
t.Print(requestWindowSize)
}
t.Print(requestWindowSize)
if t.inlineResize {
t.Print(vt.PmResizeReports.Enable())
@ -1311,11 +1570,19 @@ func (t *tScreen) engage() error {
// can take over the terminal interface. This restores the TTY mode that was
// present when the application was first started.
func (t *tScreen) disengage() {
t.Lock()
finish := t.disengageStart()
t.Unlock()
if finish {
t.disengageFinish()
}
}
// disengageStart begins a disengage operation while t's lock is already held.
// It returns true when disengageFinish must be called after releasing the lock.
func (t *tScreen) disengageStart() bool {
if !t.running {
t.Unlock()
return
return false
}
t.running = false
@ -1327,8 +1594,12 @@ func (t *tScreen) disengage() {
stopQ := t.stopQ
close(stopQ)
_ = t.tty.Drain()
t.Unlock()
return true
}
// disengageFinish completes a disengage operation after disengageStart has
// released the running loops.
func (t *tScreen) disengageFinish() {
// wait for everything to shut down
t.wg.Wait()
@ -1357,7 +1628,6 @@ func (t *tScreen) disengage() {
// Hack for Windows.
if runtime.GOOS == "windows" {
t.Print(vt.PmWin32Input.Disable())
t.Print(disableKittyKbd)
}
// t.Print(t.disableCsiU)
@ -1375,6 +1645,11 @@ func (t *tScreen) disengage() {
// Beep emits a beep to the terminal.
func (t *tScreen) Beep() error {
t.Lock()
defer t.Unlock()
if t.fini {
return nil
}
t.Print(string(byte(7)))
return nil
}
@ -1401,9 +1676,13 @@ func (t *tScreen) GetCells() *CellBuffer {
func (t *tScreen) SetTitle(title string) {
t.Lock()
t.title = title
if t.fini {
t.Unlock()
return
}
t.title = stripOSCControlsIfNeeded(title)
if t.setTitle != "" && t.running {
t.Printf(t.setTitle, title)
t.Printf(t.setTitle, t.title)
}
t.Unlock()
}
@ -1411,6 +1690,10 @@ func (t *tScreen) SetTitle(title string) {
func (t *tScreen) SetClipboard(data []byte) {
// Post binary data to the system clipboard. It might be UTF-8, it might not be.
t.Lock()
if t.fini {
t.Unlock()
return
}
if t.setClipboard != "" {
encoded := base64.StdEncoding.EncodeToString(data)
t.Printf(t.setClipboard, encoded)
@ -1420,6 +1703,10 @@ func (t *tScreen) SetClipboard(data []byte) {
func (t *tScreen) GetClipboard() {
t.Lock()
if t.fini {
t.Unlock()
return
}
if t.setClipboard != "" {
t.Printf(t.setClipboard, "?")
}
@ -1432,7 +1719,11 @@ func (t *tScreen) HasClipboard() bool {
func (t *tScreen) ShowNotification(title string, body string) {
t.Lock()
t.Printf(t.notifyDesktop, title, body)
if t.fini {
t.Unlock()
return
}
t.Printf(t.notifyDesktop, stripOSCControlsIfNeeded(title), stripOSCControlsIfNeeded(body))
t.Unlock()
}
@ -1441,3 +1732,18 @@ func (t *tScreen) Terminal() (string, string) {
defer t.Unlock()
return t.termName, t.termVers
}
func (t *tScreen) KeyboardProtocol() KeyProtocol {
t.Lock()
defer t.Unlock()
if t.haveWin32Kbd {
return Win32Keyboard
}
if t.haveKittyKbd {
return KittyKeyboard
}
if t.haveXTermKbd {
return XTermKeyboard
}
return LegacyKeyboard
}

View file

@ -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),
@ -205,22 +209,15 @@ func (w *winTty) getConsoleInput() error {
case keyEvent:
// we normally only expect to see ascii, but paste data may come in as UTF-16.
wc := rune(binary.LittleEndian.Uint16(ir.data[10:]))
if wc >= 0xD800 && wc <= 0xDBFF {
// if it was a high surrogate, which happens for pasted UTF-16,
// then save it until we get the low and can decode it.
w.surrogate = wc
continue
} else if wc >= 0xDC00 && wc <= 0xDFFF {
wc = utf16.DecodeRune(w.surrogate, wc)
}
w.surrogate = 0
for _, chr := range []byte(string(wc)) {
// We normally expect only to see ASCII (win32-input-mode),
// but apparently pasted data can arrive in UTF-16 here.
select {
case w.buf <- chr:
case <-w.stopQ:
break loop
for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) {
for _, chr := range []byte(string(decoded)) {
// We normally expect only to see ASCII (win32-input-mode),
// but apparently pasted data can arrive in UTF-16 here.
select {
case w.buf <- chr:
case <-w.stopQ:
break loop
}
}
}

47
vendor/github.com/gdamore/tcell/v3/tty/utf16.go generated vendored Normal file
View file

@ -0,0 +1,47 @@
// 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 tty
import (
"unicode/utf16"
"unicode/utf8"
)
// decodeUTF16Rune decodes one UTF-16 code unit at a time while preserving
// malformed input as replacement characters instead of silently discarding it.
func decodeUTF16Rune(surrogate *rune, wc rune) []rune {
switch {
case wc >= 0xD800 && wc <= 0xDBFF:
if *surrogate != 0 {
*surrogate = wc
return []rune{utf8.RuneError}
}
*surrogate = wc
return nil
case wc >= 0xDC00 && wc <= 0xDFFF:
if *surrogate == 0 {
return []rune{utf8.RuneError}
}
decoded := utf16.DecodeRune(*surrogate, wc)
*surrogate = 0
return []rune{decoded}
default:
if *surrogate != 0 {
*surrogate = 0
return []rune{utf8.RuneError, wc}
}
return []rune{wc}
}
}

View file

@ -220,10 +220,25 @@ type Cell struct {
W int // Display width (0, 1, or 2)
}
// EmulatorOpt configures an Emulator.
type EmulatorOpt interface {
setEmulatorOpt(*emulator)
}
// EmulatorOpt8BitControls enables parsing of C1 controls, such as CSI and OSC,
// when they are presented as raw 8-bit bytes or UTF-8 encoded C1 controls.
// The default is to only accept the 7-bit ESC-prefixed forms.
type EmulatorOpt8BitControls struct{}
func (EmulatorOpt8BitControls) setEmulatorOpt(em *emulator) {
em.c1Allowed = true
em.c1Enabled = true
}
// NewEmulator creates an emulator instance on top of the given backend.
// The input is relative to the emulator, so it receives data from the host,
// whereas the emulator sends data to the application through the output.
func NewEmulator(be Backend) Emulator {
func NewEmulator(be Backend, opts ...EmulatorOpt) Emulator {
stopQ := make(chan bool)
defStyle := BaseStyle.WithFg(color.Silver).WithBg(color.Black)
em := &emulator{
@ -249,6 +264,9 @@ func NewEmulator(be Backend) Emulator {
},
mouseReports: MouseDisabled,
}
for _, opt := range opts {
opt.setEmulatorOpt(em)
}
if _, ok := be.(Resizer); ok {
em.localModes[PmResizeReports] = ModeOff
}
@ -297,7 +315,9 @@ type emulator struct {
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.)
c1Allowed bool // allow C1 controls in raw 8-bit and UTF-8 encodings
c1Enabled bool // C1 controls are currently enabled
c1Prefix bool // string parser has seen the first byte of a UTF-8 encoded C1 control
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)
@ -372,13 +392,13 @@ func (em *emulator) inbInit(b byte) {
return
}
// For 8-bit encodings, we treat these as Fe sequences.
// Basically the same as ESC followed by (b - 0x40).
// TODO: condition this so that we do not do this if
// the encoding cannot support it (UTF, 8859, and EUC encodings
// are all fine here, but others like ShiftJIS or KOI8 might not be).
if b >= 0x80 && b <= 0x9F && !em.sevenOnly {
em.inbEsc(b - 0x40)
// For C1 controls, the raw 8-bit form is the same as ESC followed by
// (b - 0x40). This is disabled by default because modern protocols
// generally treat these forms as insecure.
if b >= 0x80 && b <= 0x9f {
if em.c1Enabled {
em.inbEsc(b - 0x40)
}
return
}
@ -529,6 +549,12 @@ func (em *emulator) inbNF(b byte) {
// case "(Q", "(9": // TODO: select G0 as French Canadian
// case "(R", "(f": // TODO: select G0 as French
// case "(Y": // TODO: select G0 as Italian
case " F": // S7C1T - send/use 7-bit C1 controls
em.c1Enabled = false
case " G": // S8C1T - send/use 8-bit C1 controls
if em.c1Allowed {
em.c1Enabled = true
}
}
}
@ -550,8 +576,17 @@ func (em *emulator) inbCSI(b byte) {
// inbOSC handles bytes that are part of on OSC sequences (operating system command).
func (em *emulator) inbOSC(b byte) {
if em.inbStringC1(b, em.processOSC) {
return
}
switch b {
case 0x9c, 0x07:
case 0x9c:
if em.c1Enabled {
em.inb = em.inbInit
em.processOSC()
}
case 0x07:
em.inb = em.inbInit
em.processOSC()
case '\\':
@ -569,8 +604,16 @@ func (em *emulator) inbOSC(b byte) {
// inbStr handles PM, SOS, and any other string we want to consume and discard.
func (em *emulator) inbStr(b byte) {
if em.inbStringC1(b, nil) {
return
}
switch b {
case 0x9c, 0x07:
case 0x9c:
if em.c1Enabled {
em.inb = em.inbInit
}
case 0x07:
em.inb = em.inbInit
case '\\':
if buf := em.inBuf.Bytes(); len(buf) > 0 && buf[len(buf)-1] == 0x1b {
@ -584,6 +627,27 @@ func (em *emulator) inbStr(b byte) {
}
}
func (em *emulator) inbStringC1(b byte, done func()) bool {
if em.c1Prefix {
em.c1Prefix = false
if b >= 0x80 && b <= 0x9f {
if em.c1Enabled && b == 0x9c {
em.inb = em.inbInit
if done != nil {
done()
}
}
return true
}
em.inBuf.WriteByte(0xc2)
}
if b == 0xc2 {
em.c1Prefix = true
return true
}
return false
}
// inbUTF handles continuation bytes for UTF-8 sequences.
func (em *emulator) inbUTF(b byte) {
if b&0xC0 == 0x80 {
@ -595,7 +659,13 @@ func (em *emulator) inbUTF(b byte) {
if err != nil {
em.beep()
} else {
em.putRune(r)
if r >= 0x80 && r <= 0x9f {
if em.c1Enabled {
em.inbEsc(byte(r) - 0x40)
}
} else {
em.putRune(r)
}
}
}
} else {
@ -730,7 +800,7 @@ func (em *emulator) processSgr(str string) {
v, err := strconv.Atoi(word)
if err != nil {
// just swallow it for now
return
continue
}
switch v {
case 0:
@ -2333,6 +2403,7 @@ func (em *emulator) ResizeEvent(size Coord) {
func (em *emulator) applyResize(size Coord) {
// resize clobbers our content, until it is redrawn
em.size = size
em.tabStops = slices.DeleteFunc(em.tabStops, func(x Col) bool { return x >= em.size.X })
// resizing resets the margins
em.topMargin = 0
em.botMargin = em.size.Y - 1

View file

@ -15,7 +15,7 @@
package vt
// BaseKey is the Kitty protocol base key. These are kitty's representation of a scan code.
// As the Kitty protocol is likely the extended keyboard protocol we care about, we use
// As the Kitty protocol is likely the extended keyboard protocol we care most about, we use
// this as the primary reporting mechanism. (It also helps that this may provide an easier
// fallback for implementations that don't have raw scan codes and are willing to assume an
// ANSI layout.)
@ -198,7 +198,7 @@ func (k Key) ScanCode() ScanCode {
}
// WinVK represents a windows virtual key code.
// These are similar to base keys, but a multiple scanned key codes
// These are similar to base keys, but multiple scanned key codes
// may result in the same virtual key. This can also be sensitive to
// the keyboard layout.
type WinVK rune
@ -346,7 +346,7 @@ const (
var baseKeys map[Key]BaseKey
// KittyBase returns the corresponding Kitty "base" key for the given USB cod.
// KittyBase returns the corresponding Kitty "base" key for the given USB code.
// If no corresponding value can be found, then zero is returned. Note that
// some keys (such as F1) are valid, and recognized by Kitty, but do not use the
// base key encoding because they use another reporting format.
@ -462,15 +462,16 @@ func init() {
KeyRAlt: 57449,
KeyRMeta: 57450,
// KeyHiragana: 0, // TBD
// KeyConvert: 0, // TBD
// KeyNonConvert: 0, // TD
// KeyHiragana: 0, // Later
// KeyConvert: 0, // Later
// KeyNonConvert: 0, // Later
// Windows uses a bunch of HID usages from
// the consumer page (0x0c) for media playback, and
// other applications. We just ignore them.
}
// Scan codes used by Windows.
scanCodes = map[Key]ScanCode{
KeyA: 0x1e,
KeyB: 0x30,

View file

@ -247,13 +247,16 @@ func NewMockTerm(opts ...MockOpt) MockTerm {
mt := &mockTerm{}
mt.mb = NewMockBackend(opts...)
var be MockBackend = mt.mb
emOpts := []EmulatorOpt{}
for _, o := range opts {
switch o.(type) {
case MockOptNoBlit:
be = &noMockBlit{be, struct{}{}}
case MockOpt8BitControls:
emOpts = append(emOpts, EmulatorOpt8BitControls{})
}
}
mt.em = NewEmulator(be)
mt.em = NewEmulator(be, emOpts...)
mt.em.SetId("TCellMock", "1.0")
mt.ks = &KeyboardState{}
return mt
@ -644,6 +647,12 @@ type MockOptNoBlit struct{}
func (MockOptNoBlit) SetMockOpt(mb *mockBackend) {}
// MockOpt8BitControls enables raw 8-bit and UTF-8 encoded C1 controls in the
// emulator. The default is to accept only 7-bit ESC-prefixed controls.
type MockOpt8BitControls struct{}
func (MockOpt8BitControls) SetMockOpt(mb *mockBackend) {}
// NewMockBackend returns a MockBackend modified by the given options.
// The default is a fully featured 256-color backend with initial size 80x24.
func NewMockBackend(options ...MockOpt) MockBackend {

View file

@ -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.
@ -19,611 +19,206 @@ package tcell
import (
"errors"
"fmt"
"io"
"sync"
"syscall/js"
"unicode/utf8"
"github.com/gdamore/tcell/v3/tty"
)
// NewTerminfoScreen gets a screen. The options are ignored for this platform.
func NewTerminfoScreen(_ ...TerminfoScreenOption) (Screen, error) {
t := &wScreen{}
t.fallback = make(map[rune]string)
return &baseScreen{screenImpl: t}, nil
}
func NewTerminfoScreenFromTty(_ tty.Tty, _ ...TerminfoScreenOption) (Screen, error) {
// TODO: When we want to support testing webasm, we'll have to change this
// to use a MockTerm. That will be appropriate when we switch to xterm.js.
// The options are ignored for this platform.
return nil, errors.New("not implemented")
}
type TerminfoScreenOption interface{ apply(*wScreen) }
type OptColors int
type OptTerm string
type OptAltScreen bool
func (OptColors) apply(*wScreen) {}
func (OptTerm) apply(*wScreen) {}
func (OptAltScreen) apply(*wScreen) {}
type wScreen struct {
w, h int
style Style
cells CellBuffer
running bool
clear bool
flagsPresent bool
pasteEnabled bool
mouseFlags MouseFlags
cursorStyle CursorStyle
quit chan struct{}
evch chan Event
fallback map[rune]string
finiOnce sync.Once
sync.Mutex
}
func (t *wScreen) Init() error {
t.w, t.h = 80, 24 // default for html as of now
t.evch = make(chan Event, 10)
t.quit = make(chan struct{})
t.Lock()
t.running = true
t.style = StyleDefault
t.cells.Resize(t.w, t.h)
t.Unlock()
js.Global().Set("onKeyEvent", js.FuncOf(t.onKeyEvent))
js.Global().Set("onMouseClick", js.FuncOf(t.unset))
js.Global().Set("onMouseMove", js.FuncOf(t.unset))
js.Global().Set("onFocus", js.FuncOf(t.unset))
// initialize installs the browser-backed TTY used by tScreen on js/wasm.
func (t *tScreen) initialize() error {
if t.tty == nil {
t.tty = newBrowserTty()
}
if t.term == "" {
t.term = "ghostty-truecolor"
}
return nil
}
func (t *wScreen) Fini() {
t.finiOnce.Do(func() {
close(t.quit)
})
}
func (t *wScreen) SetStyle(style Style) {
t.Lock()
t.style = style
t.Unlock()
}
// paletteColor gives a more natural palette color actually matching
// typical XTerm. We might in the future want to permit styling these
// via CSS.
var palette = map[Color]int32{
ColorBlack: 0x000000,
ColorMaroon: 0xcd0000,
ColorGreen: 0x00cd00,
ColorOlive: 0xcdcd00,
ColorNavy: 0x0000ee,
ColorPurple: 0xcd00cd,
ColorTeal: 0x00cdcd,
ColorSilver: 0xe5e5e5,
ColorGray: 0x7f7f7f,
ColorRed: 0xff0000,
ColorLime: 0x00ff00,
ColorYellow: 0xffff00,
ColorBlue: 0x5c5cff,
ColorFuchsia: 0xff00ff,
ColorAqua: 0x00ffff,
ColorWhite: 0xffffff,
}
func paletteColor(c Color) int32 {
if c.IsRGB() {
return int32(c & 0xffffff)
}
if c >= ColorBlack && c <= ColorWhite {
return palette[c]
}
return c.Hex()
}
func (t *wScreen) drawCell(x, y int) int {
str, style, width := t.cells.Get(x, y)
if !t.cells.Dirty(x, y) {
return width
}
if style == StyleDefault {
style = t.style
}
fg, bg := paletteColor(style.fg), paletteColor(style.bg)
if fg == -1 {
fg = 0xe5e5e5
}
if bg == -1 {
bg = 0x000000
}
us, uc := style.ulStyle, paletteColor(style.ulColor)
if uc == -1 {
uc = 0x000000
}
t.cells.SetDirty(x, y, false)
js.Global().Call("drawCell", x, y, str, fg, bg, int(style.attrs), int(us), int(uc))
return width
}
func (t *wScreen) ShowCursor(x, y int) {
t.Lock()
js.Global().Call("showCursor", x, y)
t.Unlock()
}
func (t *wScreen) SetCursor(cs CursorStyle, cc Color) {
if !cc.Valid() {
cc = ColorLightGray
}
t.Lock()
js.Global().Call("setCursorStyle", curStyleClasses[cs], fmt.Sprintf("#%06x", cc.Hex()))
t.Unlock()
}
func (t *wScreen) HideCursor() {
t.ShowCursor(-1, -1)
}
func (t *wScreen) Show() {
t.Lock()
t.resize()
t.draw()
t.Unlock()
}
func (t *wScreen) clearScreen() {
js.Global().Call("clearScreen", t.style.fg.Hex(), t.style.bg.Hex())
t.clear = false
}
func (t *wScreen) draw() {
if t.clear {
t.clearScreen()
}
for y := 0; y < t.h; y++ {
for x := 0; x < t.w; x++ {
width := t.drawCell(x, y)
x += width - 1
}
}
js.Global().Call("show")
}
func (t *wScreen) EnableMouse(flags ...MouseFlags) {
var f MouseFlags
flagsPresent := false
for _, flag := range flags {
f |= flag
flagsPresent = true
}
if !flagsPresent {
f = MouseMotionEvents | MouseDragEvents | MouseButtonEvents
}
t.Lock()
t.mouseFlags = f
t.enableMouse(f)
t.Unlock()
}
func (t *wScreen) enableMouse(f MouseFlags) {
if f&MouseButtonEvents != 0 {
js.Global().Set("onMouseClick", js.FuncOf(t.onMouseEvent))
} else {
js.Global().Set("onMouseClick", js.FuncOf(t.unset))
}
if f&MouseDragEvents != 0 || f&MouseMotionEvents != 0 {
js.Global().Set("onMouseMove", js.FuncOf(t.onMouseEvent))
} else {
js.Global().Set("onMouseMove", js.FuncOf(t.unset))
}
}
func (t *wScreen) DisableMouse() {
t.Lock()
t.mouseFlags = 0
t.enableMouse(0)
t.Unlock()
}
func (t *wScreen) EnablePaste() {
t.Lock()
t.pasteEnabled = true
t.enablePasting(true)
t.Unlock()
}
func (t *wScreen) DisablePaste() {
t.Lock()
t.pasteEnabled = false
t.enablePasting(false)
t.Unlock()
}
func (t *wScreen) enablePasting(on bool) {
if on {
js.Global().Set("onPaste", js.FuncOf(t.onPaste))
} else {
js.Global().Set("onPaste", js.FuncOf(t.unset))
}
}
func (t *wScreen) EnableFocus() {
t.Lock()
js.Global().Set("onFocus", js.FuncOf(t.onFocus))
t.Unlock()
}
func (t *wScreen) DisableFocus() {
t.Lock()
js.Global().Set("onFocus", js.FuncOf(t.unset))
t.Unlock()
}
func (s *wScreen) GetClipboard() {
}
func (s *wScreen) SetClipboard(_ []byte) {
}
func (s *wScreen) HasClipboard() bool {
return false
}
func (t *wScreen) Size() (int, int) {
t.Lock()
w, h := t.w, t.h
t.Unlock()
return w, h
}
// resize does nothing, as asking the web window to resize
// without a specified width or height will cause no change.
func (t *wScreen) resize() {}
func (t *wScreen) Colors() int {
return 16777216 // 256 ^ 3
}
func (t *wScreen) clip(x, y int) (int, int) {
w, h := t.cells.Size()
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x > w-1 {
x = w - 1
}
if y > h-1 {
y = h - 1
}
return x, y
}
func (t *wScreen) postEvent(ev Event) {
select {
case t.evch <- ev:
case <-t.quit:
}
}
func (t *wScreen) onMouseEvent(this js.Value, args []js.Value) any {
mod := ModNone
button := ButtonNone
switch args[2].Int() {
case 0:
if t.mouseFlags&MouseMotionEvents == 0 {
// don't want this event! is a mouse motion event, but user has asked not.
return nil
}
button = ButtonNone
case 1:
button = Button1
case 2:
button = Button3 // Note we prefer to treat right as button 2
case 3:
button = Button2 // And the middle button as button 3
}
if args[3].Bool() { // mod shift
mod |= ModShift
}
if args[4].Bool() { // mod alt
mod |= ModAlt
}
if args[5].Bool() { // mod ctrl
mod |= ModCtrl
}
t.postEvent(NewEventMouse(args[0].Int(), args[1].Int(), button, mod))
return nil
}
func (t *wScreen) onKeyEvent(this js.Value, args []js.Value) any {
key := args[0].String()
// don't accept any modifier keys as their own
if key == "Control" || key == "Alt" || key == "Meta" || key == "Shift" {
return nil
}
mod := ModNone
if args[1].Bool() { // mod shift
mod |= ModShift
}
if args[2].Bool() { // mod alt
mod |= ModAlt
}
if args[3].Bool() { // mod ctrl
mod |= ModCtrl
}
if args[4].Bool() { // mod meta
mod |= ModMeta
}
// next try function keys
if k, ok := WebKeyNames[key]; ok {
t.postEvent(NewEventKey(k, "", mod))
return nil
}
// finally try normal, printable chars
r, _ := utf8.DecodeRuneInString(key)
t.postEvent(NewEventKey(KeyRune, string(r), mod))
return nil
}
func (t *wScreen) onPaste(this js.Value, args []js.Value) any {
t.postEvent(NewEventPaste(args[0].Bool()))
return nil
}
func (t *wScreen) onFocus(this js.Value, args []js.Value) any {
t.postEvent(NewEventFocus(args[0].Bool()))
return nil
}
// unset is a dummy function for js when we want nothing to
// happen when javascript calls a function (for example, when
// mouse input is disabled, when onMouseEvent() is called from
// js, it redirects here and does nothing).
func (t *wScreen) unset(this js.Value, args []js.Value) any {
return nil
}
func (t *wScreen) Sync() {
t.Lock()
t.resize()
t.clear = true
t.cells.Invalidate()
t.draw()
t.Unlock()
}
func (t *wScreen) CharacterSet() string {
func getCharset() string {
return "UTF-8"
}
func (t *wScreen) RegisterRuneFallback(orig rune, fallback string) {
t.Lock()
t.fallback[orig] = fallback
t.Unlock()
type browserTty struct {
mu sync.Mutex
cond *sync.Cond
started bool
drained bool
closed bool
input []byte
resizeQ chan<- bool
writeFunc js.Value
sizeFunc js.Value
closeFuncs []js.Func
}
func (t *wScreen) UnregisterRuneFallback(orig rune) {
t.Lock()
delete(t.fallback, orig)
t.Unlock()
func newBrowserTty() *browserTty {
t := &browserTty{}
t.cond = sync.NewCond(&t.mu)
return t
}
func (t *wScreen) SetSize(w, h int) {
if w == t.w && h == t.h {
return
}
func (t *browserTty) Start() error {
t.mu.Lock()
defer t.mu.Unlock()
t.cells.Invalidate()
t.cells.Resize(w, h)
js.Global().Call("resize", w, h)
t.w, t.h = w, h
t.postEvent(NewEventResize(w, h))
}
func (t *wScreen) Resize(int, int, int, int) {}
// Suspend simply pauses all input and output, and clears the screen.
// There isn't a "default terminal" to go back to.
func (t *wScreen) Suspend() error {
t.Lock()
if !t.running {
t.Unlock()
if t.started {
return nil
}
t.running = false
t.clearScreen()
t.enableMouse(0)
t.enablePasting(false)
js.Global().Set("onKeyEvent", js.FuncOf(t.unset)) // stop key presses
return nil
}
func (t *wScreen) Resume() error {
t.Lock()
if t.running {
return errors.New("already engaged")
global := js.Global()
t.writeFunc = global.Get("tcellWrite")
t.sizeFunc = global.Get("tcellWindowSize")
if t.writeFunc.Type() != js.TypeFunction || t.sizeFunc.Type() != js.TypeFunction {
return errors.New("tcell wasm terminal host is not installed")
}
t.running = true
t.enableMouse(t.mouseFlags)
t.enablePasting(t.pasteEnabled)
onData := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) == 0 {
return nil
}
if args[0].InstanceOf(global.Get("Uint8Array")) {
data := make([]byte, args[0].Get("byteLength").Int())
js.CopyBytesToGo(data, args[0])
t.enqueue(data)
} else {
t.enqueue([]byte(args[0].String()))
}
return nil
})
onResize := js.FuncOf(func(this js.Value, args []js.Value) any {
t.mu.Lock()
resizeQ := t.resizeQ
t.mu.Unlock()
if resizeQ != nil {
select {
case resizeQ <- true:
default:
}
}
return nil
})
t.closeFuncs = []js.Func{onData, onResize}
global.Set("tcellRead", onData)
global.Set("tcellResize", onResize)
js.Global().Set("onKeyEvent", js.FuncOf(t.onKeyEvent))
t.Unlock()
t.started = true
t.drained = false
t.closed = false
return nil
}
func (t *wScreen) Beep() error {
js.Global().Call("beep")
func (t *browserTty) Stop() error {
t.mu.Lock()
t.started = false
t.drained = false
funcs := t.closeFuncs
t.closeFuncs = nil
t.cond.Broadcast()
t.mu.Unlock()
js.Global().Set("tcellRead", js.Undefined())
js.Global().Set("tcellResize", js.Undefined())
for _, fn := range funcs {
fn.Release()
}
return nil
}
func (t *wScreen) Tty() (Tty, bool) {
return nil, false
func (t *browserTty) Drain() error {
t.mu.Lock()
t.input = nil
t.drained = true
t.cond.Broadcast()
t.mu.Unlock()
return nil
}
func (t *wScreen) GetCells() *CellBuffer {
return &t.cells
func (t *browserTty) NotifyResize(resizeQ chan<- bool) {
t.mu.Lock()
t.resizeQ = resizeQ
t.mu.Unlock()
}
func (t *wScreen) EventQ() chan Event {
return t.evch
func (t *browserTty) WindowSize() (tty.WindowSize, error) {
var ws tty.WindowSize
t.mu.Lock()
sizeFunc := t.sizeFunc
t.mu.Unlock()
if sizeFunc.Type() != js.TypeFunction {
ws.Width = 80
ws.Height = 24
return ws, nil
}
size := sizeFunc.Invoke()
ws.Width = size.Get("cols").Int()
ws.Height = size.Get("rows").Int()
ws.PixelWidth = size.Get("pixelWidth").Int()
ws.PixelHeight = size.Get("pixelHeight").Int()
if ws.Width == 0 {
ws.Width = 80
}
if ws.Height == 0 {
ws.Height = 24
}
return ws, nil
}
func (t *wScreen) StopQ() <-chan struct{} {
return t.quit
func (t *browserTty) Read(b []byte) (int, error) {
t.mu.Lock()
defer t.mu.Unlock()
for len(t.input) == 0 && t.started && !t.drained && !t.closed {
t.cond.Wait()
}
if t.closed {
return 0, io.EOF
}
if (!t.started || t.drained) && len(t.input) == 0 {
return 0, io.EOF
}
n := copy(b, t.input)
t.input = t.input[n:]
return n, nil
}
func (t *wScreen) SetTitle(title string) {
js.Global().Call("setTitle", title)
func (t *browserTty) Write(b []byte) (int, error) {
t.mu.Lock()
writeFunc := t.writeFunc
started := t.started
t.mu.Unlock()
if !started || writeFunc.Type() != js.TypeFunction {
return 0, io.ErrClosedPipe
}
data := js.Global().Get("Uint8Array").New(len(b))
js.CopyBytesToJS(data, b)
writeFunc.Invoke(data)
return len(b), nil
}
func (*wScreen) ShowNotification(title string, body string) {}
func (t *browserTty) Close() error {
t.mu.Lock()
if t.closed {
t.mu.Unlock()
return nil
}
t.closed = true
t.started = false
t.drained = false
t.cond.Broadcast()
t.mu.Unlock()
// WebKeyNames maps string names reported from HTML
// (KeyboardEvent.key) to tcell accepted keys.
var WebKeyNames = map[string]Key{
"Enter": KeyEnter,
"Backspace": KeyBackspace,
"Tab": KeyTab,
"Backtab": KeyBacktab,
"Escape": KeyEsc,
"Backspace2": KeyBackspace2,
"Delete": KeyDelete,
"Insert": KeyInsert,
"ArrowUp": KeyUp,
"ArrowDown": KeyDown,
"ArrowLeft": KeyLeft,
"ArrowRight": KeyRight,
"Home": KeyHome,
"End": KeyEnd,
"UpLeft": KeyUpLeft, // not supported by HTML
"UpRight": KeyUpRight, // not supported by HTML
"DownLeft": KeyDownLeft, // not supported by HTML
"DownRight": KeyDownRight, // not supported by HTML
"Center": KeyCenter,
"PgDn": KeyPgDn,
"PgUp": KeyPgUp,
"Clear": KeyClear,
"Exit": KeyExit,
"Cancel": KeyCancel,
"Pause": KeyPause,
"Print": KeyPrint,
"F1": KeyF1,
"F2": KeyF2,
"F3": KeyF3,
"F4": KeyF4,
"F5": KeyF5,
"F6": KeyF6,
"F7": KeyF7,
"F8": KeyF8,
"F9": KeyF9,
"F10": KeyF10,
"F11": KeyF11,
"F12": KeyF12,
"F13": KeyF13,
"F14": KeyF14,
"F15": KeyF15,
"F16": KeyF16,
"F17": KeyF17,
"F18": KeyF18,
"F19": KeyF19,
"F20": KeyF20,
"F21": KeyF21,
"F22": KeyF22,
"F23": KeyF23,
"F24": KeyF24,
"F25": KeyF25,
"F26": KeyF26,
"F27": KeyF27,
"F28": KeyF28,
"F29": KeyF29,
"F30": KeyF30,
"F31": KeyF31,
"F32": KeyF32,
"F33": KeyF33,
"F34": KeyF34,
"F35": KeyF35,
"F36": KeyF36,
"F37": KeyF37,
"F38": KeyF38,
"F39": KeyF39,
"F40": KeyF40,
"F41": KeyF41,
"F42": KeyF42,
"F43": KeyF43,
"F44": KeyF44,
"F45": KeyF45,
"F46": KeyF46,
"F47": KeyF47,
"F48": KeyF48,
"F49": KeyF49,
"F50": KeyF50,
"F51": KeyF51,
"F52": KeyF52,
"F53": KeyF53,
"F54": KeyF54,
"F55": KeyF55,
"F56": KeyF56,
"F57": KeyF57,
"F58": KeyF58,
"F59": KeyF59,
"F60": KeyF60,
"F61": KeyF61,
"F62": KeyF62,
"F63": KeyF63,
"F64": KeyF64,
return t.Stop()
}
var curStyleClasses = map[CursorStyle]string{
CursorStyleDefault: "cursor-blinking-block",
CursorStyleBlinkingBlock: "cursor-blinking-block",
CursorStyleSteadyBlock: "cursor-steady-block",
CursorStyleBlinkingUnderline: "cursor-blinking-underline",
CursorStyleSteadyUnderline: "cursor-steady-underline",
CursorStyleBlinkingBar: "cursor-blinking-bar",
CursorStyleSteadyBar: "cursor-steady-bar",
func (t *browserTty) enqueue(data []byte) {
t.mu.Lock()
if t.started && !t.closed {
t.input = append(t.input, data...)
t.cond.Broadcast()
}
t.mu.Unlock()
}
func (*wScreen) Terminal() (string, string) { return "Tcell-WebASM", "" }

6
vendor/modules.txt vendored
View file

@ -48,7 +48,7 @@ github.com/fatih/color
# github.com/gdamore/encoding v1.0.1
## explicit; go 1.9
github.com/gdamore/encoding
# github.com/gdamore/tcell/v3 v3.3.0
# github.com/gdamore/tcell/v3 v3.4.0
## explicit; go 1.25.0
github.com/gdamore/tcell/v3
github.com/gdamore/tcell/v3/color
@ -184,10 +184,10 @@ golang.org/x/sync/errgroup
golang.org/x/sys/plan9
golang.org/x/sys/unix
golang.org/x/sys/windows
# golang.org/x/term v0.42.0
# golang.org/x/term v0.43.0
## explicit; go 1.25.0
golang.org/x/term
# golang.org/x/text v0.36.0
# golang.org/x/text v0.37.0
## explicit; go 1.25.0
golang.org/x/text/cases
golang.org/x/text/encoding