v0.5.0 key releases: key model, protocol source, console sources (#12)

* key model for releases: KeyAction, SetKeyReport, focus and key report events, hold pairing

A key event now carries its Action: KeyPress, KeyRepeat or KeyRelease. Every
terminal still reports presses only, so Action stays KeyPress and every event
is what v0.4.3 sent, until a game asks with SetKeyReport. The ask names the
sources releases may come from (KeySourceConsole, KeySourceProtocol); an
EventKeyReport answers each ask with the level reached and its source. No
source exists yet, so every ask answers presses and nothing is written.

KeyHolds pairs a release with its press by physical key, so a release carries
what its press carried, and turns a source's lost reports into Lost releases.
It is exported for hosts that read native key events (KeySourceHost).

The reader sends keys through the level in force: at presses a repeat is a
press and a release is dropped; at releases a key with no physical key behind
it is a tap. Focus reports end every hold, Lost, before EventFocus; they are
read only once a source turns them on. Mouse events gain a Touch bit for hosts.

Terminal gains SetKeyReport, so this is v0.5.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zCVjspRDuEuMrDTumVhp

* protocol source: releases from the terminal's key event protocol, Alacritty's

SetKeyReport with KeySourceProtocol asks the terminal whether it has the key
event protocol (CSI ? u, fenced by a DA1 request), pushes its flags (all five,
CSI > 31 u) and asks what they came to before reporting releases. A terminal
must confirm event types, every key as an escape code and the text keys type;
one that answers DA1 alone, takes the push without them, or answers nothing
within two seconds stays at presses, and is not asked again. Detection goes by
these answers only, never by TERM or the terminal's name.

While the flags are pushed, key reports are decoded into the events a legacy
terminal sends for the same keys, so a game can keep the protocol on for its
whole run: Shift goes into the character, Ctrl with a character into its
control key, Alt stays a modifier; Super, the locks, lone modifiers, media keys
and F13 and up report nothing. Ctrl with a character that has no control key
keeps ModCtrl. Releases pair with their presses through KeyHolds; a key the
terminal names none for (text from an input method) is a tap.

Focus reports come on with the protocol and end every hold, Lost, as focus
leaves. Going back to presses releases what is held, pops the flags and stops
focus reports, decoding until the terminal confirms the pop. Fini pops the
flags before the alternate screen goes, and EmergencyReset when they are
pushed. Fini now stops the input reader without holding the terminal's lock,
since the reader writes its requests through it.

Checked against Alacritty 0.13.2 on Xvfb with xdotool: 58 key combinations
read identically with the protocol on and off (Ctrl+1 keeps ModCtrl), every
press got its release, Shift released first still released 'A', focus loss
and a release swallowed by Alacritty's Vi mode ended holds Lost. Inside tmux
3.4 the ask came to presses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zCVjspRDuEuMrDTumVhp

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LixenandClaude Opus 5.5 authored and GitHub committed 2026-10-21 03:59:00 -04:00
1 parent a8805d9be1
commit 97f66b1f1c
10 files changed
+1311 -18

No files matched your search

+67 -1
View File
@@ -224,7 +224,8 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
- `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...),
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
(`ModShift`, `ModAlt`, `ModCtrl`)
(`ModShift`, `ModAlt`, `ModCtrl`), `Action` (`KeyPress`, and `KeyRepeat` and
`KeyRelease` while releases are reported; see below)
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
`MouseAction` (press/release/move/drag), modifiers. Enable via
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
@@ -232,6 +233,8 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
- `EventPaste` — `Text` holds a whole paste, as sent, control characters
included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste
arrives as keys, its newlines pressing Enter.
- `EventKeyReport` — the outcome of a `SetKeyReport` ask: `Report` and `Source`
- `EventFocus` — `Focused`, while releases come through the terminal's protocol
- `EventError`, `EventClosed`
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
@@ -244,6 +247,69 @@ On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
draws and reads keys on the terminal.
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
### Key releases
A terminal reports presses: a key held down arrives as more presses at the
keyboard's repeat rate, and nothing says when it comes up. A game that needs
holds asks for releases:
```go
term.SetKeyReport(terminal.KeyReportReleases, terminal.KeySourceConsole|terminal.KeySourceProtocol)
```
The ask returns at once. An `EventKeyReport` follows with what the terminal
reports now (`Report`) and where releases come from (`Source`), so a game
learns the outcome of every ask and can fall back, to taps for one, where
there are none. `SetKeyReport(terminal.KeyReportPresses, 0)` gives the source
back. Until a game asks for releases, nothing is sent to the terminal or
switched, and every key event is a press, as before.
While releases are reported:
- Each key event carries its `Action`: `KeyPress`, `KeyRepeat` while held, and
`KeyRelease`. A release carries what its press carried, paired by physical
key, so letting go of Shift first still releases `'A'`, not `'a'`.
- A key event the terminal names no physical key for (text from an input
method, a key its protocol leaves out) can't be held: its release follows
at once.
- Every hold ends. Focus leaving, the console switching away, a change of
source and `Fini` release the keys held with `Lost` set, since their own
releases won't arrive. A press of a key already held means its release was
lost (a terminal's own mode can swallow one): the old hold ends, `Lost`,
before the new press.
Releases come from the first of these sources a game allows that the
terminal has:
- `KeySourceProtocol`: the terminal's key event protocol, CSI u with event
types. The package asks the terminal whether it has the protocol (`CSI ? u`,
fenced by a DA1 request every terminal answers), pushes its flags and
confirms what they came to before reporting releases; it goes by the
terminal's answers, never by `TERM` or the terminal's name. Alacritty 0.13
and later is the reference (Enter, Tab and Backspace release from 0.15).
Other terminals that answer the same way (foot, Ghostty, WezTerm with its
opt-in setting, recent Konsole) should work but are untested. Works over ssh.
tmux, screen and mosh don't pass the protocol through, and VTE terminals and
xterm don't have it; there the answer is presses after one round trip. The
flags are popped when the game goes back to presses, at `Fini`, and in
`EmergencyReset`.
With the protocol on, every key arrives as an escape code, and each press
reads as the key a legacy terminal sends for it reads, so a game can keep
the protocol on for its whole run: Shift goes into the character, Ctrl with
a character becomes its control key, and Alt stays a modifier. Ctrl with a
character that has no control key (Ctrl+1) keeps `ModCtrl`, which a legacy
terminal can't send. Escape no longer waits out the ESC timeout. Lone
modifier keys, media keys and F13 and up report nothing. Alacritty reports no
keys while its own Vi mode, search or hints run; a key held then ends on its
next press, or as focus leaves.
`KeyHolds` is the pairing on its own, for a host that reads its platform's key
events and builds `Event`s itself (`KeySourceHost`): it gives each release its
press's key and turns the contradictions above into `Lost` releases.
A Terminal over a caller's backend (`NewWithBackend`) reports presses.
## WASM
WASM builds bridge to xterm.js via JS globals:
+3
View File
@@ -160,6 +160,9 @@ func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
}
}
// keySources: the key protocol, which the terminal is asked about
func (b *unixBackend) keySources() KeySource { return KeySourceProtocol }
func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
b.resizeStopCh = make(chan struct{})
b.resizeDoneCh = make(chan struct{})
+2
View File
@@ -6,6 +6,8 @@
// the terminal's own default colors
// - Double-buffered output with cell-level diffing
// - Raw input parsing with escape sequence handling and bracketed paste
// - Key releases on request, through the terminal's key event protocol
// (Alacritty's), and presses on terminals without it
// - Drawing on /dev/tty when stdin or stdout is redirected (Unix)
// - Resize detection (SIGWINCH on Unix, callback on WASM)
// - Clean terminal restoration on exit/panic
+103 -15
View File
@@ -16,10 +16,12 @@ type EventType uint8
const (
EventKey EventType = iota
EventResize
EventPaste // Bracketed paste, Text holds it
EventMouse // SGR mouse reporting
EventError // Read error
EventClosed // Input closed
EventPaste // Bracketed paste, Text holds it
EventMouse // SGR mouse reporting
EventError // Read error
EventClosed // Input closed
EventFocus // The terminal gained or lost focus, while it reports releases through its protocol
EventKeyReport // What a SetKeyReport ask came to: Report through Source
)
// Event represents a terminal input event
@@ -28,16 +30,22 @@ type Event struct {
Key Key
Rune rune
Modifiers Modifier
Width int // For EventResize
Height int // For EventResize
Err error // For EventError
Text string // For EventPaste: the pasted bytes as sent, controls included
Action KeyAction // For EventKey: KeyPress unless the terminal reports releases
Lost bool // For a KeyRelease the package made for a hold whose own release can't arrive
Width int // For EventResize
Height int // For EventResize
Err error // For EventError
Text string // For EventPaste: the pasted bytes as sent, controls included
Focused bool // For EventFocus: whether the terminal now has focus
Report KeyReport // For EventKeyReport: what the terminal now reports of each key
Source KeySource // For EventKeyReport: the source releases come from, zero for presses
// Mouse event fields
MouseX int
MouseY int
MouseBtn MouseButton
MouseAction MouseAction
Touch bool // A touch, from a host that reads one; terminals report none
}
// inputReader handles raw stdin parsing
@@ -60,6 +68,34 @@ type inputReader struct {
// An over-long CSI sequence is being dropped up to its final byte
dropCSI bool
// Key reporting asks: the sources the backend has, the latest ask waiting
// for the reader, and how the reader writes to the terminal, false once it
// is closing; pushed is whether the protocol's flags are pushed after seq
sources KeySource
asks chan keyAsk
write func(seq []byte, pushed bool) bool
// Key reporting, the reader's own: the ask it works toward and whether it
// is still to be answered, what the terminal reports of each key and the
// source releases come from, whether focus reports are parsed, and the
// keys a source reports held down
want keyAsk
answer bool
report KeyReport
source KeySource
focus bool
holds KeyHolds
// The protocol: an exchange in flight and when it is given up, the flags
// the terminal reported in it (-1 for none), whether the terminal has the
// protocol (-1 no, 0 not known, 1 yes), and whether key reports are
// decoded, from the push until the terminal has the pop
exchange protoExchange
deadline time.Time
protoReported int
protoHas int8
decoding bool
mu sync.Mutex
running bool
}
@@ -69,6 +105,7 @@ func newInputReader(backend Backend) *inputReader {
return &inputReader{
backend: backend,
eventCh: make(chan Event, 256),
asks: make(chan keyAsk, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
buf: make([]byte, 0, 256),
@@ -128,9 +165,12 @@ func (r *inputReader) readLoop() {
}()
for {
r.serviceKeys()
// Blocking read from backend
data, err := r.backend.Read(r.stopCh)
if err != nil {
r.holds.ReleaseAll(r.sendEvent)
r.sendEvent(Event{Type: EventError, Err: err})
return
}
@@ -147,11 +187,12 @@ func (r *inputReader) readLoop() {
// Timeout (Unix poll) or empty read: a pending ESC, or two, was
// pressed alone; inside a paste it is text
if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting {
r.sendEvent(ev)
r.sendKey(0, ev)
r.buf = r.buf[:0]
}
select {
case <-r.stopCh:
r.holds.ReleaseAll(r.sendEvent)
r.sendEvent(Event{Type: EventClosed})
return
default:
@@ -226,7 +267,7 @@ func (r *inputReader) parseInput(data []byte) int {
// Fast path: printable ASCII
if b >= 0x20 && b < 0x7f {
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
i++
continue
}
@@ -245,8 +286,10 @@ func (r *inputReader) parseInput(data []byte) int {
}
// Only emit if not a swallowed unknown sequence
if ev.Key != KeyNone || ev.Type != EventKey {
if ev.Type != EventKey {
r.sendEvent(ev)
} else if ev.Key != KeyNone {
r.sendKey(0, ev)
}
i += consumed
continue
@@ -254,14 +297,14 @@ func (r *inputReader) parseInput(data []byte) int {
// Control characters
if b < 0x20 {
r.sendEvent(r.parseControl(b))
r.sendKey(0, r.parseControl(b))
i++
continue
}
// DEL
if b == 0x7f {
r.sendEvent(Event{Type: EventKey, Key: KeyBackspace})
r.sendKey(0, Event{Type: EventKey, Key: KeyBackspace})
i++
continue
}
@@ -281,7 +324,7 @@ func (r *inputReader) parseInput(data []byte) int {
}
rn, size := decodeRune(data[i:])
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rn})
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: rn})
i += size
continue
}
@@ -432,6 +475,25 @@ func (r *inputReader) parseCSI(data []byte) (int, Event) {
return csiMaxLen, Event{Type: EventKey, Key: KeyNone}
case data[2] == '<' && (data[n-1] == 'M' || data[n-1] == 'm'):
return n, parseSGRMouse(data[3:n-1], data[n-1])
case r.focus && n == 3 && (data[2] == 'I' || data[2] == 'O'):
// Holds end as focus leaves, since their releases go elsewhere
if data[2] == 'O' {
r.holds.ReleaseAll(r.sendEvent)
}
return n, Event{Type: EventFocus, Focused: data[2] == 'I'}
case data[2] == '?' && data[n-1] == 'u':
// The protocol's flags in force, answering a query
if v, ok := parseKeyParams(data[3 : n-1]); ok {
r.protoReply(int(max(v[0][0], 0)))
}
return n, Event{Type: EventKey, Key: KeyNone}
case data[2] == '?' && data[n-1] == 'c':
// Device attributes, which close an exchange: the terminal has
// answered all asked before them
r.exchangeDone(true)
return n, Event{Type: EventKey, Key: KeyNone}
case r.decoding && r.protoKey(data[2:n-1], data[n-1]):
return n, Event{Type: EventKey, Key: KeyNone}
}
if key, mod, ok := lookupCSI(data[2:n]); ok {
@@ -448,7 +510,13 @@ func (r *inputReader) parseSS3(data []byte) (int, Event) {
return 0, Event{}
}
if key, mod, ok := lookupSS3(data[2:3]); ok {
return 3, Event{Type: EventKey, Key: key, Modifiers: mod}
ev := Event{Type: EventKey, Key: key, Modifiers: mod}
if r.decoding && key != KeyRune {
// A terminal reporting by the protocol names this key too
r.sendKey(protoNamed+uint32(key), ev)
return 3, Event{Type: EventKey, Key: KeyNone}
}
return 3, ev
}
// Unknown SS3 - consume to prevent garbage
return 3, Event{Type: EventKey, Key: KeyNone}
@@ -626,6 +694,26 @@ func parseSGRParams(data []byte) (btn, x, y int, ok bool) {
return btn, x, y, true
}
// sendKey sends a key event at the level the terminal reports. code names the
// physical key the event came from, zero for one the terminal names none for:
// at releases such a key can't be held, so its release follows at once.
func (r *inputReader) sendKey(code uint32, ev Event) {
switch {
case r.report == KeyReportPresses:
if ev.Action != KeyRelease {
ev.Action = KeyPress
r.sendEvent(ev)
}
case code == 0:
ev.Action = KeyPress
r.sendEvent(ev)
ev.Action = KeyRelease
r.sendEvent(ev)
default:
r.holds.Report(code, ev, r.sendEvent)
}
}
// sendEvent sends an event to the channel, non-blocking
func (r *inputReader) sendEvent(ev Event) {
select {
+26 -1
View File
@@ -90,6 +90,31 @@ const (
ModCtrl Modifier = 1 << 2
)
// KeyAction is what happened to a key. Only a terminal reporting releases (see
// SetKeyReport) tells them apart; elsewhere every key event is a press, and a
// key held down arrives as more presses at the keyboard's repeat rate.
type KeyAction uint8
const (
KeyPress KeyAction = iota // the key went down
KeyRepeat // the keyboard repeated a key held down
KeyRelease // the key came up
)
// String returns the action's name
func (a KeyAction) String() string {
switch a {
case KeyPress:
return "Press"
case KeyRepeat:
return "Repeat"
case KeyRelease:
return "Release"
default:
return "None"
}
}
// escapeSequence maps escape sequences to keys
// Key: sequence after ESC [ (e.g., "A" for up arrow)
type escapeSequence struct {
@@ -392,4 +417,4 @@ func lookupSS3(seq []byte) (Key, Modifier, bool) {
return s.key, s.mod, true
}
return KeyNone, ModNone, false
}
}
+407
View File
@@ -0,0 +1,407 @@
package terminal
import (
"strconv"
"time"
)
// The terminal's key event protocol (CSI u with progressive enhancement, as
// Alacritty 0.13 and later implement it): asked for flags, the terminal
// reports every key as an escape code with its event type, press, repeat or
// release, and the text it types.
const (
protoDisambiguate = 1 << iota // Escape, Alt and Ctrl keys as escape codes
protoEventTypes // repeats and releases
protoAlternates // the shifted key beside the key
protoAllKeys // text keys as escape codes too, so they release
protoText // the text a key types, in its code
// protoFlags are the flags pushed, and protoNeed those a terminal must
// confirm before the reader decodes by them: the text keeps every layout's
// characters as the terminal types them
protoFlags = protoDisambiguate | protoEventTypes | protoAlternates | protoAllKeys | protoText
protoNeed = protoEventTypes | protoAllKeys | protoText
)
var (
csiKeyQuery = []byte("\x1b[?u") // the flags in force, answered CSI ? flags u
csiKeyPush = []byte("\x1b[>" + strconv.Itoa(protoFlags) + "u") // over the flags in force
csiKeyPop = []byte("\x1b[<u") // back to the flags before the push
csiDA1 = []byte("\x1b[c") // primary device attributes, which every terminal answers
csiFocusOn = []byte("\x1b[?1004h")
csiFocusOff = []byte("\x1b[?1004l")
)
// protoTimeout bounds an exchange with the terminal; one that answers nothing
// by then has no protocol to use
var protoTimeout = 2 * time.Second
// protoExchange is a request to the terminal awaiting its answer, which the
// DA1 reply after it closes: terminals answer in order
type protoExchange uint8
const (
exchangeNone protoExchange = iota
exchangeProbe // does the terminal have the protocol?
exchangePush // pushed protoFlags: what did they come to?
exchangePop // popped: reports go on until the terminal has it
)
// keyAsk is what a SetKeyReport call asked for
type keyAsk struct {
report KeyReport
allow KeySource
}
// keySourcer is a backend that has sources of key releases. Only the
// package's own are: a Terminal over a caller's backend reports presses.
type keySourcer interface {
keySources() KeySource
}
// backendKeySources is what b offers beyond presses
func backendKeySources(b Backend) KeySource {
if s, ok := b.(keySourcer); ok {
return s.keySources()
}
return 0
}
// askKeys hands an ask to the reader; with no source, every ask comes to
// presses at once
func (r *inputReader) askKeys(report KeyReport, allow KeySource) {
if r.sources == 0 {
r.sendEvent(Event{Type: EventKeyReport, Report: KeyReportPresses})
return
}
a := keyAsk{report, allow & r.sources}
for {
select {
case r.asks <- a:
return
default:
}
select { // an ask the reader has not taken yet gives way to this one
case <-r.asks:
default:
}
}
}
// serviceKeys takes the latest ask, gives up on an exchange the terminal has
// not answered in time, and moves toward the ask; the reader calls it between
// reads
func (r *inputReader) serviceKeys() {
select {
case a := <-r.asks:
r.want, r.answer = a, true
default:
}
if r.exchange != exchangeNone && time.Now().After(r.deadline) {
r.exchangeDone(false)
}
r.reconcile()
}
// target is the source the ask wants of those to be had, the one in use while
// it is still allowed
func (r *inputReader) target() KeySource {
a := r.want
switch {
case a.report == KeyReportPresses:
return 0
case r.source != 0 && a.allow&r.source != 0:
return r.source
case a.allow&KeySourceProtocol != 0 && r.protoHas >= 0:
return KeySourceProtocol
}
return 0
}
// reconcile moves toward the ask while no exchange is in flight, and answers
// it once there
func (r *inputReader) reconcile() {
for r.answer && r.exchange == exchangeNone {
switch target := r.target(); {
case target == r.source:
r.answer = false
r.sendEvent(Event{Type: EventKeyReport, Report: r.report, Source: r.source})
case r.source != 0:
r.leave()
case r.protoHas == 0:
r.request(exchangeProbe, false, csiKeyQuery, csiDA1)
default:
r.decoding = true
r.request(exchangePush, true, csiKeyPush, csiKeyQuery, csiDA1)
}
}
}
// request writes an exchange's requests and waits for the answer; pushed is
// whether the protocol's flags are pushed once they are written. A terminal
// closing takes no requests, and the protocol is then none.
func (r *inputReader) request(x protoExchange, pushed bool, seqs ...[]byte) {
var seq []byte
for _, s := range seqs {
seq = append(seq, s...)
}
if r.write == nil || !r.write(seq, pushed) {
r.protoHas, r.decoding = -1, false
return
}
r.exchange, r.deadline, r.protoReported = x, time.Now().Add(protoTimeout), -1
}
// protoReply takes the flags a terminal reports in force
func (r *inputReader) protoReply(flags int) {
if r.exchange != exchangeNone {
r.protoReported = flags
}
}
// exchangeDone closes the exchange in flight: answered by the DA1 reply after
// it, or not in time
func (r *inputReader) exchangeDone(answered bool) {
x := r.exchange
if x == exchangeNone {
return
}
r.exchange = exchangeNone
switch x {
case exchangeProbe:
r.protoHas = -1
if answered && r.protoReported >= 0 {
r.protoHas = 1
}
case exchangePush:
if answered && r.protoReported&protoNeed == protoNeed {
// Keys held across the switch were reported as presses; their next
// repeat starts their hold
r.holds = KeyHolds{}
r.source, r.report, r.focus = KeySourceProtocol, KeyReportReleases, true
r.write(csiFocusOn, true)
} else {
r.protoHas = -1
r.request(exchangePop, false, csiKeyPop, csiDA1)
}
case exchangePop:
r.decoding = false
}
r.reconcile()
}
// leave gives up the source in use, ending its holds first
func (r *inputReader) leave() {
r.holds.ReleaseAll(r.sendEvent)
source := r.source
r.source, r.report, r.focus = 0, KeyReportPresses, false
if source == KeySourceProtocol {
r.request(exchangePop, false, csiKeyPop, csiFocusOff, csiDA1)
}
}
// protoNamed sets the codes of keys the protocol reports in their legacy form
// (arrows, Home, F1, ...) apart from Unicode code points
const protoNamed = 0x110000
// tildeKeys are the keys reported as CSI number ; modifiers ~
var tildeKeys = map[int32]Key{
1: KeyHome, 2: KeyInsert, 3: KeyDelete, 4: KeyEnd, 5: KeyPageUp, 6: KeyPageDown, 7: KeyHome, 8: KeyEnd,
11: KeyF1, 12: KeyF2, 13: KeyF3, 14: KeyF4, 15: KeyF5, 17: KeyF6, 18: KeyF7, 19: KeyF8,
20: KeyF9, 21: KeyF10, 23: KeyF11, 24: KeyF12,
}
// letterKeys are the keys reported as CSI 1 ; modifiers letter
var letterKeys = map[byte]Key{
'A': KeyUp, 'B': KeyDown, 'C': KeyRight, 'D': KeyLeft, 'H': KeyHome, 'F': KeyEnd,
'P': KeyF1, 'Q': KeyF2, 'S': KeyF4, // F3 as CSI R would read as a cursor position report
}
// Keypad keys by protocol code: those that type a character, and those that
// move as their main-block twins with Num Lock off
var (
keypadRunes = map[int32]rune{
57399: '0', 57400: '1', 57401: '2', 57402: '3', 57403: '4', 57404: '5', 57405: '6', 57406: '7',
57407: '8', 57408: '9', 57409: '.', 57410: '/', 57411: '*', 57412: '-', 57413: '+', 57415: '=',
57416: ',',
}
keypadKeys = map[int32]Key{
57414: KeyEnter, 57417: KeyLeft, 57418: KeyRight, 57419: KeyUp, 57420: KeyDown, 57421: KeyPageUp,
57422: KeyPageDown, 57423: KeyHome, 57424: KeyEnd, 57425: KeyInsert, 57426: KeyDelete,
}
)
// keyParams are a key report's parameters: up to three fields split by ';',
// each of subfields split by ':', an absent one -1
type keyParams [3][8]int32
// parseKeyParams reads a key report's parameters; ok is false for anything
// else, a private marker or a value past Unicode among them
func parseKeyParams(p []byte) (v keyParams, ok bool) {
for i := range v {
for j := range v[i] {
v[i][j] = -1
}
}
f, s := 0, 0
for _, b := range p {
switch {
case b >= '0' && b <= '9':
n := max(v[f][s], 0)*10 + int32(b-'0')
if n > 0x10ffff {
return v, false
}
v[f][s] = n
case b == ':':
if s++; s == len(v[f]) {
return v, false
}
case b == ';':
if f, s = f+1, 0; f == len(v) {
return v, false
}
default:
return v, false
}
}
return v, true
}
// protoKey decodes a key the protocol reports, CSI params final, and sends what
// it means at the level in force. It is false for a sequence that is no key
// report, which the legacy tables then read.
//
// Each press reads as the key a legacy terminal sends for it reads (Alacritty's
// encoding is the reference), so a game sees the same keys with the protocol
// on: Shift goes into the character, Ctrl with a character becomes its control
// key, Alt stays a modifier, and Super and the locks are not reported. Keys a
// legacy terminal sends nothing for (lone modifiers, media keys, F13 and up)
// are dropped.
func (r *inputReader) protoKey(params []byte, final byte) bool {
v, ok := parseKeyParams(params)
if !ok {
return false
}
num, mods := v[0][0], max(v[1][0], 1)-1
action := KeyPress
switch v[1][1] {
case 2:
action = KeyRepeat
case 3:
action = KeyRelease
}
ev := Event{Type: EventKey, Action: action}
var code uint32
switch final {
case 'u':
if num < 0 {
return false
}
var text []rune
for _, c := range v[2] {
if c >= 0 && printable(rune(c)) {
text = append(text, rune(c))
}
}
if !protoCodeKey(num, v[0][1], mods, text, &ev) {
// A key with no legacy key, or text with no key: its text is taps
for _, t := range text {
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: t})
}
return true
}
code = uint32(num)
if ev.Key == KeyRune && len(text) > 1 {
defer func() {
for _, t := range text[1:] {
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: t})
}
}()
}
case '~':
if ev.Key = tildeKeys[num]; ev.Key == KeyNone {
return true // F13 and up, keypad Begin: no legacy key
}
ev.Modifiers = keyModifiers(mods)
code = protoNamed + uint32(ev.Key)
default:
if num > 1 {
return false
}
if ev.Key = letterKeys[final]; ev.Key == KeyNone {
return final == 'E' // keypad Begin
}
ev.Modifiers = keyModifiers(mods)
code = protoNamed + uint32(ev.Key)
}
r.sendKey(code, ev)
return true
}
// protoCodeKey reads the key a CSI u report's code names into ev, as a legacy
// terminal reports it: false for a code no legacy key has
func protoCodeKey(num, shifted, mods int32, text []rune, ev *Event) bool {
alt := Modifier(0)
if mods&2 != 0 {
alt = ModAlt
}
shift, ctrl := mods&1 != 0, mods&4 != 0
switch {
case num == 27:
ev.Key, ev.Modifiers = KeyEscape, alt
case num == 13:
ev.Key, ev.Modifiers = KeyEnter, alt
case num == 9 && shift:
ev.Key, ev.Modifiers = KeyBacktab, ModShift|alt
case num == 9:
ev.Key, ev.Modifiers = KeyTab, alt
case num == 127, num == 8:
ev.Key, ev.Modifiers = KeyBackspace, alt
case keypadKeys[num] != KeyNone:
ev.Key = keypadKeys[num]
if ev.Key == KeyEnter {
ev.Modifiers = alt
} else {
ev.Modifiers = keyModifiers(mods)
}
default:
ch, ok := keypadRunes[num]
if !ok {
ch = rune(num)
}
if !ok && (num >= 0xe000 && num <= 0xf8ff || !printable(ch)) {
return false // the protocol's own keys, and codes no key types
}
if shift && shifted > 0 {
ch = rune(shifted)
}
if ctrl {
if k, ok := ControlKey(ch); ok {
ev.Key, ev.Modifiers = k, alt
return true
}
if k, ok := ControlKey(rune(num)); ok {
ev.Key, ev.Modifiers = k, alt
return true
}
}
if len(text) > 0 {
ch = text[0]
}
ev.Key, ev.Rune, ev.Modifiers = KeyRune, ch, alt
if ctrl {
ev.Modifiers |= ModCtrl
}
}
return true
}
// keyModifiers are the protocol's modifier bits a legacy key reports
func keyModifiers(mods int32) Modifier {
return Modifier(mods & int32(ModShift|ModAlt|ModCtrl))
}
// printable is whether r types something: no control character, no surrogate
func printable(r rune) bool {
return r >= 0x20 && (r < 0x7f || r > 0x9f) && (r < 0xd800 || r > 0xdfff) && r <= 0x10ffff
}
+360
View File
@@ -0,0 +1,360 @@
package terminal
import (
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
)
// decoded is what a reader decoding the key protocol sends for input at the
// report level given
func decoded(report KeyReport, in string) []Event {
r := &inputReader{eventCh: make(chan Event, 256), decoding: true, report: report}
r.parseInput([]byte(in))
close(r.eventCh)
var events []Event
for ev := range r.eventCh {
events = append(events, ev)
}
return events
}
// Every press the protocol reports reads as the key a legacy terminal sends for
// it reads, Alacritty's encoding of each the reference, so a game sees the
// same keys with the protocol on
func TestProtocolPressesReadAsLegacyKeys(t *testing.T) {
for _, c := range []struct{ name, legacy, report string }{
{"a", "a", "\x1b[97;1;97u"},
{"shift+a", "A", "\x1b[97:65;2;65u"},
{"ctrl+a", "\x01", "\x1b[97;5u"},
{"alt+a", "\x1ba", "\x1b[97;3;97u"},
{"ctrl+alt+a", "\x1b\x01", "\x1b[97;7u"},
{"ctrl+shift+a", "\x01", "\x1b[97:65;6u"},
{"alt+shift+a", "\x1bA", "\x1b[97:65;4;65u"},
{"super+a", "a", "\x1b[97;9;97u"},
{"caps lock a", "A", "\x1b[97;65;65u"},
{"shift+1", "!", "\x1b[49:33;2;33u"},
{"é", "é", "\x1b[233;1;233u"},
{"shift+é", "É", "\x1b[233:201;2;201u"},
{"altgr+q", "@", "\x1b[64;1;64u"},
{"ctrl+[", "\x1b", "\x1b[91;5u"},
{"ctrl+h", "\x08", "\x1b[104;5u"},
{"ctrl+shift+2", "\x00", "\x1b[50:64;6u"},
{"ctrl+/", "\x1f", "\x1b[47;5u"},
{"space", " ", "\x1b[32;1;32u"},
{"ctrl+space", "\x00", "\x1b[32;5u"},
{"alt+space", "\x1b ", "\x1b[32;3;32u"},
{"enter", "\r", "\x1b[13u"},
{"shift+enter", "\r", "\x1b[13;2u"},
{"alt+enter", "\x1b\r", "\x1b[13;3u"},
{"tab", "\t", "\x1b[9u"},
{"ctrl+tab", "\t", "\x1b[9;5u"},
{"shift+tab", "\x1b[Z", "\x1b[9;2u"},
{"alt+shift+tab", "\x1b\x1b[Z", "\x1b[9;4u"},
{"backspace", "\x7f", "\x1b[127u"},
{"ctrl+backspace", "\x08", "\x1b[127;5u"},
{"alt+backspace", "\x1b\x7f", "\x1b[127;3u"},
{"escape", "\x1b", "\x1b[27u"},
{"shift+escape", "\x1b", "\x1b[27;2u"},
{"alt+escape", "\x1b\x1b", "\x1b[27;3u"},
{"up", "\x1b[A", "\x1b[A"},
{"shift+up", "\x1b[1;2A", "\x1b[1;2A"},
{"ctrl+alt+left", "\x1b[1;7D", "\x1b[1;7D"},
{"home", "\x1b[H", "\x1b[H"},
{"end", "\x1b[F", "\x1b[F"},
{"f1", "\x1bOP", "\x1b[P"},
{"ctrl+f1", "\x1b[1;5P", "\x1b[1;5P"},
{"f3", "\x1bOR", "\x1b[13~"},
{"f4", "\x1bOS", "\x1b[S"},
{"f5", "\x1b[15~", "\x1b[15~"},
{"shift+f12", "\x1b[24;2~", "\x1b[24;2~"},
{"insert", "\x1b[2~", "\x1b[2~"},
{"delete", "\x1b[3~", "\x1b[3~"},
{"ctrl+delete", "\x1b[3;5~", "\x1b[3;5~"},
{"page up", "\x1b[5~", "\x1b[5~"},
{"page down", "\x1b[6~", "\x1b[6~"},
{"keypad 1", "1", "\x1b[57400;1;49u"},
{"keypad enter", "\r", "\x1b[57414u"},
{"keypad left", "\x1b[D", "\x1b[57417u"},
{"keypad shift+home", "\x1b[1;2H", "\x1b[57423;2u"},
{"left shift alone", "", "\x1b[57441;2u"},
{"caps lock alone", "", "\x1b[57358u"},
{"f13", "", "\x1b[57376u"},
{"volume", "", "\x1b[57439u"},
{"keypad begin", "", "\x1b[E\x1b[57427~"},
{"compose ü", "ü", "\x1b[0;;252u"},
} {
// An ESC or two alone read once input pauses
want, _ := parsed(c.legacy)
if ev, ok := loneEscape[c.legacy]; ok {
want = []Event{ev}
}
if got := decoded(KeyReportPresses, c.report); !slices.Equal(got, want) {
t.Errorf("%s: %q reads as %+v, want %+v as %q reads", c.name, c.report, got, want, c.legacy)
}
}
// Ctrl with a character no control key has keeps Ctrl, which a legacy
// terminal can't send
if got, want := decoded(KeyReportPresses, "\x1b[49;5;49u"), (Event{Type: EventKey, Key: KeyRune, Rune: '1', Modifiers: ModCtrl}); len(got) != 1 || got[0] != want {
t.Errorf("ctrl+1 reads as %+v, want %+v", got, want)
}
}
// At releases each key reports its press, repeats and release, paired by the
// key: a release carries its press's key whatever was let go first; modifier
// keys alone report nothing; text with no key behind it is a tap
func TestProtocolReportsHolds(t *testing.T) {
ev := func(k Key, r rune, m Modifier, a KeyAction) Event {
return Event{Type: EventKey, Key: k, Rune: r, Modifiers: m, Action: a}
}
got := decoded(KeyReportReleases, strings.Join([]string{
"\x1b[57441;2u", "\x1b[97:65;2;65u", // Shift down, a down: 'A'
"\x1b[57441;1:3u", "\x1b[97;1:2;97u", // Shift up, a repeats as 'a'
"\x1b[97;1:3u", // a up: 'A' comes up
"\x1b[A", "\x1b[1;1:2A", "\x1b[1;1:3A", // Up down, repeat, up
"\x1b[13u", "\x1b[13;1:3u", // Enter
"\x1b[0;;252u", // composed text
"\x1b[98;1:3u", // a release with no press
"\x1b[3;5~", "\x1b[3;1:3~", // Ctrl+Delete down, Delete up after Ctrl
}, ""))
want := []Event{
ev(KeyRune, 'A', 0, KeyPress), ev(KeyRune, 'a', 0, KeyRepeat), ev(KeyRune, 'A', 0, KeyRelease),
ev(KeyUp, 0, 0, KeyPress), ev(KeyUp, 0, 0, KeyRepeat), ev(KeyUp, 0, 0, KeyRelease),
ev(KeyEnter, 0, 0, KeyPress), ev(KeyEnter, 0, 0, KeyRelease),
ev(KeyRune, 'ü', 0, KeyPress), ev(KeyRune, 'ü', 0, KeyRelease),
ev(KeyDelete, 0, ModCtrl, KeyPress), ev(KeyDelete, 0, ModCtrl, KeyRelease),
}
if !slices.Equal(got, want) {
t.Fatalf("got %+v\nwant %+v", got, want)
}
}
// fakeKeyTerm is a terminal answering the key protocol's requests as Alacritty
// does: CSI ? u with the flags in force, which a push and a pop change, and
// DA1. Without the protocol it answers DA1 alone; silent, nothing. grant is
// the flags it takes of a push, all when zero.
type fakeKeyTerm struct {
fakeBackend
has, silent bool
grant int
stack []int
}
var keyRequest = regexp.MustCompile(`\x1b\[(\?u|>(\d*)u|<u|c)`)
func (f *fakeKeyTerm) keySources() KeySource { return KeySourceProtocol }
func (f *fakeKeyTerm) Write(p []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
f.out.Write(p)
for _, m := range keyRequest.FindAllSubmatch(p, -1) {
switch req := string(m[1]); {
case f.silent:
case req == "c":
f.chunks = append(f.chunks, "\x1b[?62;22c")
case !f.has:
case req == "?u":
flags := 0
if len(f.stack) > 0 {
flags = f.stack[len(f.stack)-1]
}
f.chunks = append(f.chunks, "\x1b[?"+strconv.Itoa(flags)+"u")
case req == "<u":
if len(f.stack) > 0 {
f.stack = f.stack[:len(f.stack)-1]
}
default:
flags, _ := strconv.Atoi(string(m[2]))
if f.grant != 0 {
flags &= f.grant
}
f.stack = append(f.stack, flags)
}
}
return nil
}
// typed hands the reader input as though the terminal sent it
func (f *fakeKeyTerm) typed(in string) {
f.mu.Lock()
f.chunks = append(f.chunks, in)
f.mu.Unlock()
}
// pushed is how many pushes the terminal holds
func (f *fakeKeyTerm) pushed() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.stack)
}
// poll is the terminal's next event, failing after two seconds
func poll(t *testing.T, term Terminal) Event {
t.Helper()
got := make(chan Event, 1)
go func() { got <- term.PollEvent() }()
select {
case ev := <-got:
return ev
case <-time.After(3 * time.Second):
t.Fatal("no event")
return Event{}
}
}
// A terminal with the protocol is asked about it, pushed the flags and asked
// what they came to before releases are reported; focus is reported with them,
// and they end, Lost, as focus leaves. Going back to presses releases what is
// held, pops the flags and stops focus reports, and Fini pops them too.
func TestProtocolSourceComesAndGoes(t *testing.T) {
f := &fakeKeyTerm{has: true}
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
term.SetKeyReport(KeyReportReleases, KeySourceConsole|KeySourceProtocol)
if ev := poll(t, term); ev != (Event{Type: EventKeyReport, Report: KeyReportReleases, Source: KeySourceProtocol}) {
t.Fatalf("asked for releases: %+v", ev)
}
waitFor(t, "focus reports", func() bool { return strings.Contains(f.written(), "\x1b[?1004h") })
out := f.written()
if i := strings.Index(out, "\x1b[?u\x1b[c\x1b[>31u\x1b[?u\x1b[c\x1b[?1004h"); i < 0 {
t.Fatalf("requests %q", out)
}
w := Event{Type: EventKey, Key: KeyRune, Rune: 'w'}
f.typed("\x1b[119;1;119u\x1b[O\x1b[I\x1b[119;1;119u")
lost := w
lost.Action, lost.Lost = KeyRelease, true
for _, want := range []Event{w, lost, {Type: EventFocus}, {Type: EventFocus, Focused: true}, w} {
if ev := poll(t, term); ev != want {
t.Fatalf("got %+v, want %+v", ev, want)
}
}
term.SetKeyReport(KeyReportPresses, 0)
for _, want := range []Event{lost, {Type: EventKeyReport, Report: KeyReportPresses}} {
if ev := poll(t, term); ev != want {
t.Fatalf("back to presses: %+v, want %+v", ev, want)
}
}
waitFor(t, "the pop", func() bool { return strings.Contains(f.written(), "\x1b[<u\x1b[?1004l\x1b[c") })
if n := f.pushed(); n != 0 {
t.Fatalf("%d pushes left", n)
}
// Asked again, the protocol is pushed without asking whether the terminal
// has it, and Fini pops it before the screen it was pushed on goes
term.SetKeyReport(KeyReportReleases, KeySourceProtocol)
if ev := poll(t, term); ev.Source != KeySourceProtocol {
t.Fatalf("asked again: %+v", ev)
}
term.Fini()
out = f.written()
if n := strings.Count(out, "\x1b[?u"); n != 3 {
t.Fatalf("asked %d times, want a probe and two pushes: %q", n, out)
}
if i := strings.LastIndex(out, "\x1b[<u\x1b[?1004l"); i < 0 || i > strings.LastIndex(out, "\x1b[?1049l") {
t.Fatalf("Fini left the flags pushed: %q", out)
}
}
// A terminal without the protocol, one that takes the push without the flags
// releases need, and one that answers nothing all come to presses; the first
// two are asked once only
func TestProtocolMissingIsPresses(t *testing.T) {
defer func(d time.Duration) { protoTimeout = d }(protoTimeout)
protoTimeout = 100 * time.Millisecond
for _, c := range []struct {
name string
term *fakeKeyTerm
probes int
}{
{"legacy", &fakeKeyTerm{}, 1},
{"partial", &fakeKeyTerm{has: true, grant: protoDisambiguate | protoEventTypes}, 2},
{"silent", &fakeKeyTerm{silent: true}, 1},
} {
term := newTerm(c.term, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
for range 2 {
term.SetKeyReport(KeyReportReleases, KeySourceProtocol)
if ev := poll(t, term); ev != (Event{Type: EventKeyReport, Report: KeyReportPresses}) {
t.Fatalf("%s: %+v", c.name, ev)
}
}
term.Fini()
out := c.term.written()
if n := strings.Count(out, "\x1b[?u"); n != c.probes {
t.Errorf("%s: asked %d times, want %d: %q", c.name, n, c.probes, out)
}
if strings.Count(out, "\x1b[>") != strings.Count(out, "\x1b[<u") || c.term.pushed() != 0 {
t.Errorf("%s: flags left pushed: %q", c.name, out)
}
}
}
// decodedIn is what a reader decoding the protocol at releases sends for input
// arriving in chunks, and how many bytes it holds back
func decodedIn(chunks ...string) (events []Event, held int) {
r := &inputReader{eventCh: make(chan Event, 4096), decoding: true, report: KeyReportReleases}
r.pasteMode.Store(true)
var buf []byte
for _, c := range chunks {
buf = append(buf, c...)
buf = buf[r.parseInput(buf):]
}
close(r.eventCh)
for ev := range r.eventCh {
events = append(events, ev)
}
return events, len(buf)
}
// Whatever a terminal reporting by the protocol sends, however reads split it,
// the reader sends the same events, holds back no more than a sequence, and
// never sends a release before a press of the key it carries
func FuzzProtocolNeverStalls(f *testing.F) {
for _, s := range []string{"\x1b[97:65;2;65u\x1b[97;1:3u", "\x1b[A\x1b[1;1:3A", "\x1b[0;;252:253u", "\x1b[57441;2u\x1b[3;5:2~", "\x1b[O\x1b[13;1:3u", "\x1b[99;5:3u"} {
f.Add(s, uint(len(s)/2))
}
f.Fuzz(func(t *testing.T, in string, cut uint) {
cut %= uint(len(in)) + 1
whole, held := decodedIn(in)
if split, _ := decodedIn(in[:cut], in[cut:]); !slices.Equal(whole, split) {
t.Fatalf("%q: whole %+v, split at %d %+v", in, whole, cut, split)
}
if held > csiMaxLen+1 {
t.Fatalf("%q: holds %d bytes", in, held)
}
if _, held := decodedIn(in, "\x7f\x7f\x7f\x7f\x1b[201~"); held != 0 {
t.Fatalf("%q: stalled holding %d bytes", in, held)
}
type id struct {
k Key
r rune
m Modifier
}
down := map[id]int{}
for _, ev := range whole {
if ev.Type != EventKey {
continue
}
k := id{ev.Key, ev.Rune, ev.Modifiers}
switch ev.Action {
case KeyPress:
down[k]++
case KeyRelease:
if down[k]--; down[k] < 0 {
t.Fatalf("%q: %+v released before its press", in, ev)
}
}
}
})
}
+134
View File
@@ -0,0 +1,134 @@
package terminal
import (
"slices"
"strings"
)
// KeyReport is how much a terminal reports of each key
type KeyReport uint8
const (
KeyReportPresses KeyReport = iota // presses; a key held down repeats as more presses
KeyReportReleases // presses, repeats and releases, each with its KeyAction
)
// String returns the report's name
func (r KeyReport) String() string {
if r == KeyReportReleases {
return "Releases"
}
return "Presses"
}
// KeySource is where key releases come from (bitmask). SetKeyReport tries the
// sources a game allows in the order below; a terminal with none of them
// reports presses, as every terminal does until a game asks.
type KeySource uint8
const (
// KeySourceConsole is the kernel console's own keyboard, switched to raw
// keycodes while releases are asked for: the Linux console on the VT the
// game runs on, and FreeBSD's vt(4). Never over ssh, where the keyboard is
// the far terminal's.
KeySourceConsole KeySource = 1 << iota
// KeySourceProtocol is the terminal's key event protocol (CSI u with event
// types), found by asking the terminal whether it has one: Alacritty 0.13
// and later, and any terminal that answers the same. Over ssh too.
KeySourceProtocol
// KeySourceHost is a host that reads its platform's own key events, such as
// a window or a browser, and builds Events itself; no Terminal reports it.
KeySourceHost
)
// String returns the sources' names, joined by |
func (s KeySource) String() string {
var names []string
for _, n := range []struct {
s KeySource
name string
}{{KeySourceConsole, "Console"}, {KeySourceProtocol, "Protocol"}, {KeySourceHost, "Host"}} {
if s&n.s != 0 {
names = append(names, n.name)
}
}
if len(names) == 0 {
return "None"
}
return strings.Join(names, "|")
}
// maxHolds bounds the keys KeyHolds keeps down at once, past any keyboard's
// rollover, so a source that never releases can't grow it without end
const maxHolds = 32
// KeyHolds pairs key releases with their presses. A source names each physical
// key by a code of its own (a console keycode, a protocol key number, a host's
// keycode) and reports presses, repeats and releases of it; KeyHolds hands each
// on as an Event, a release carrying what its press carried, so letting go of
// Shift first still releases 'A', not 'a'. The zero value is ready to use. It
// is not safe for concurrent use.
type KeyHolds struct {
held []heldKey // oldest press first
}
type heldKey struct {
code uint32
press Event
}
// Held reports whether the key code names is down
func (h *KeyHolds) Held(code uint32) bool {
return h.find(code) >= 0
}
func (h *KeyHolds) find(code uint32) int {
return slices.IndexFunc(h.held, func(k heldKey) bool { return k.code == code })
}
// Report hands on one report of the key code names through send: ev is the key
// as the source reads it, its Action set. A press starts a hold and a release
// ends it, carrying the press's Key, Rune and Modifiers; a repeat passes as it
// is. A report the holds contradict means the source lost one, as a terminal's
// own mode swallows keys: a press of a key held first ends that hold with a
// Lost release, a repeat of a key not held is a press, and a release of a key
// not held is dropped. A source that repeats a held key as more presses, as the
// consoles do, asks Held to tell its repeats apart.
func (h *KeyHolds) Report(code uint32, ev Event, send func(Event)) {
i := h.find(code)
switch {
case ev.Action == KeyRelease:
if i >= 0 {
send(h.end(i, false))
}
return
case ev.Action == KeyRepeat && i >= 0:
send(ev)
return
case i >= 0:
send(h.end(i, true))
case len(h.held) == maxHolds:
send(h.end(0, true))
}
ev.Action = KeyPress
h.held = append(h.held, heldKey{code, ev})
send(ev)
}
// ReleaseAll ends every hold, sending a Lost release for each, oldest first:
// focus left, the console switched away, or releases stopped
func (h *KeyHolds) ReleaseAll(send func(Event)) {
for len(h.held) > 0 {
send(h.end(0, true))
}
}
// end removes hold i and returns its release
func (h *KeyHolds) end(i int, lost bool) Event {
ev := h.held[i].press
h.held = slices.Delete(h.held, i, i+1)
ev.Action, ev.Lost = KeyRelease, lost
return ev
}
+142
View File
@@ -0,0 +1,142 @@
package terminal
import (
"slices"
"testing"
"time"
)
// A release carries what its press carried, whatever the source read when the
// key came up; a report the holds contradict is taken as one the source lost
func TestKeyHoldsPairReleasesWithPresses(t *testing.T) {
press := func(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
as := func(ev Event, a KeyAction, lost bool) Event { ev.Action, ev.Lost = a, lost; return ev }
var h KeyHolds
var got []Event
send := func(ev Event) { got = append(got, ev) }
report := func(code uint32, ev Event, a KeyAction) { h.Report(code, as(ev, a, false), send) }
report(30, press('A'), KeyPress) // Shift+a
report(30, press('A'), KeyRepeat) // held
report(30, press('a'), KeyRelease) // Shift came up first
report(31, press('s'), KeyRelease) // never pressed
report(32, press('d'), KeyRepeat) // its press was lost
report(32, press('d'), KeyPress) // and so was its release
report(33, press('f'), KeyPress)
h.ReleaseAll(send) // focus left
want := []Event{
press('A'), as(press('A'), KeyRepeat, false), as(press('A'), KeyRelease, false),
press('d'), as(press('d'), KeyRelease, true), press('d'),
press('f'), as(press('d'), KeyRelease, true), as(press('f'), KeyRelease, true),
}
if !slices.Equal(got, want) {
t.Fatalf("got %+v\nwant %+v", got, want)
}
if h.Held(32) || h.Held(33) || len(h.held) != 0 {
t.Fatalf("holds left after ReleaseAll: %+v", h.held)
}
}
// A source that never releases can't hold more keys down than maxHolds: the
// oldest hold ends to make room
func TestKeyHoldsStayBounded(t *testing.T) {
var h KeyHolds
var lost []rune
for code := range uint32(maxHolds + 2) {
h.Report(code, Event{Type: EventKey, Key: KeyRune, Rune: 'a' + rune(code)}, func(ev Event) {
if ev.Action == KeyRelease && ev.Lost {
lost = append(lost, ev.Rune)
}
})
}
if len(h.held) != maxHolds || !slices.Equal(lost, []rune{'a', 'b'}) || !h.Held(maxHolds+1) {
t.Fatalf("%d held, lost %q", len(h.held), lost)
}
}
// Focus reports are read only while asked for; as focus leaves, the keys held
// are released first, marked Lost, since their own releases go elsewhere
func TestFocusEndsHolds(t *testing.T) {
r := &inputReader{eventCh: make(chan Event, 16)}
if r.parseInput([]byte("\x1b[O\x1b[Ix")); len(r.eventCh) != 1 || (<-r.eventCh) != key('x') {
t.Fatal("focus reports read while not asked for")
}
r.focus = true
w := Event{Type: EventKey, Key: KeyRune, Rune: 'w'}
r.holds.Report(17, w, func(Event) {})
r.parseInput([]byte("\x1b[O\x1b[I"))
close(r.eventCh)
var got []Event
for ev := range r.eventCh {
got = append(got, ev)
}
w.Action, w.Lost = KeyRelease, true
if want := []Event{w, {Type: EventFocus}, {Type: EventFocus, Focused: true}}; !slices.Equal(got, want) {
t.Fatalf("got %+v, want %+v", got, want)
}
}
// A terminal with no source of releases answers every ask with presses and
// sends the terminal nothing for it
func TestKeyReportWithoutSourceIsPresses(t *testing.T) {
f := &fakeBackend{}
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
defer term.Fini()
waitFor(t, "Init's output", func() bool { return f.written() != "" })
before := f.written()
if err := term.SetKeyReport(KeyReportReleases+1, KeySourceProtocol); err == nil {
t.Fatal("an unknown report was taken")
}
for _, report := range []KeyReport{KeyReportReleases, KeyReportPresses} {
if err := term.SetKeyReport(report, KeySourceConsole|KeySourceProtocol); err != nil {
t.Fatal(err)
}
got := make(chan Event, 1)
go func() { got <- term.PollEvent() }()
select {
case ev := <-got:
if ev != (Event{Type: EventKeyReport, Report: KeyReportPresses}) {
t.Fatalf("asked for %v: %+v", report, ev)
}
case <-time.After(2 * time.Second):
t.Fatalf("asked for %v: no answer", report)
}
}
time.Sleep(20 * time.Millisecond)
if got := f.written(); got != before {
t.Fatalf("asks wrote %q", got[len(before):])
}
}
// At presses a repeat is one more press and a release is dropped, as on any
// terminal; at releases a key the terminal names no physical key for is a tap,
// its release at once, and a named one is held until its own release
func TestKeysAtEachLevel(t *testing.T) {
as := func(ev Event, a KeyAction) Event { ev.Action = a; return ev }
for _, c := range []struct {
report KeyReport
want []Event
}{
{KeyReportPresses, []Event{key('x'), key('w'), key('w')}},
{KeyReportReleases, []Event{key('x'), as(key('x'), KeyRelease), key('w'), as(key('w'), KeyRepeat), as(key('w'), KeyRelease)}},
} {
r := &inputReader{eventCh: make(chan Event, 16), report: c.report}
r.sendKey(0, key('x'))
r.sendKey(17, key('w'))
r.sendKey(17, as(key('w'), KeyRepeat))
r.sendKey(17, as(key('w'), KeyRelease))
close(r.eventCh)
var got []Event
for ev := range r.eventCh {
got = append(got, ev)
}
if !slices.Equal(got, c.want) {
t.Errorf("%v: got %+v, want %+v", c.report, got, c.want)
}
}
}
+67 -1
View File
@@ -1,6 +1,7 @@
package terminal
import (
"fmt"
"io"
"os"
"sync"
@@ -97,6 +98,16 @@ type Terminal interface {
// SetPasteMode turns bracketed paste on or off: on, a paste arrives as
// one EventPaste rather than as keys, so its newlines don't press Enter
SetPasteMode(on bool) error
// SetKeyReport asks for keys to be reported at level report. Releases come
// through the first source in allow the terminal has, the console before
// the protocol; KeyReportPresses gives up the source in use. It returns at
// once, and an EventKeyReport follows with what the terminal reports now,
// so a game learns the outcome of every ask; asks the reader has not yet
// acted on are answered together, by the latest. A change of source ends
// the holds it leaves with Lost releases. Until an ask for releases, nothing
// is sent to the terminal or switched.
SetKeyReport(report KeyReport, allow KeySource) error
}
// ResizeEvent represents a terminal resize
@@ -121,6 +132,7 @@ type termImpl struct {
finalized bool
mouseMode MouseMode
paste bool
keyProto bool // the key protocol's flags are pushed
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
// stops reading holds that goroutine rather than whoever draws. queued is what
@@ -198,6 +210,8 @@ func (t *termImpl) Init() error {
// Create input reader wrapping backend
t.input = newInputReader(t.backend)
t.input.sources = backendKeySources(t.backend)
t.input.write = t.keyWrite
// Set resize handler on backend
t.backend.SetResizeHandler(func(w, h int) {
@@ -271,10 +285,20 @@ func (t *termImpl) Fini() {
w.Write(csiPasteOff)
}
// Stop handlers
// Key reports as the terminal had them, before the screen they were pushed on goes
if t.keyProto {
w.Write(csiKeyPop)
w.Write(csiFocusOff)
t.keyProto = false
keyProtoPushed.Store(false)
}
t.mu.Unlock()
// Stop handlers; the reader may be waiting on mu to write, and finds Fini begun
if t.input != nil {
t.input.stop()
}
t.mu.Lock()
// Show cursor
w.Write(csiCursorShow)
@@ -576,6 +600,44 @@ func (t *termImpl) SetPasteMode(on bool) error {
return nil
}
// keyProtoPushed is whether a Terminal has the key protocol's flags pushed, for
// EmergencyReset to pop
var keyProtoPushed atomic.Bool
// keyWrite queues seq, the input reader's request, for the terminal unless Fini
// has begun; pushed is whether the protocol's flags are pushed once it is
// written, which Fini and EmergencyReset undo
func (t *termImpl) keyWrite(seq []byte, pushed bool) bool {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return false
}
t.releaseLocked()
t.output.buf.Write(seq)
t.sendLocked()
// A terminal that takes a request for its own (an old st restores the
// cursor on CSI ? u) has moved it as far as the next frame knows
t.output.invalidateCursor()
t.keyProto = pushed
keyProtoPushed.Store(pushed)
return true
}
// SetKeyReport hands the ask to the input reader, which answers it
func (t *termImpl) SetKeyReport(report KeyReport, allow KeySource) error {
if report > KeyReportReleases {
return fmt.Errorf("terminal: unknown key report %d", report)
}
t.mu.Lock()
ready := t.initialized && !t.finalized
t.mu.Unlock()
if ready {
t.input.askKeys(report, allow)
}
return nil
}
// EmergencyReset attempts to restore terminal to sane state
// Call this from panic recovery if Fini() cannot be called normally. Given a
// redirected stdout, it writes to the controlling terminal instead.
@@ -593,6 +655,10 @@ func EmergencyReset(w io.Writer) {
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
w.Write(csiPasteOff)
if keyProtoPushed.Load() {
w.Write(csiKeyPop)
w.Write(csiFocusOff)
}
// Write sequences to provided writer
w.Write(csiCursorShow)