diff --git a/README.md b/README.md index 58407bd..26dadc1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend_unix.go b/backend_unix.go index ca60140..a2e640a 100644 --- a/backend_unix.go +++ b/backend_unix.go @@ -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{}) diff --git a/doc.go b/doc.go index 50895a3..785e5d0 100644 --- a/doc.go +++ b/doc.go @@ -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 diff --git a/input.go b/input.go index 60770c6..460e60c 100644 --- a/input.go +++ b/input.go @@ -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 { diff --git a/key.go b/key.go index f45c402..555733c 100644 --- a/key.go +++ b/key.go @@ -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 -} \ No newline at end of file +} diff --git a/keyprotocol.go b/keyprotocol.go new file mode 100644 index 0000000..81a99f0 --- /dev/null +++ b/keyprotocol.go @@ -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[= 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 +} diff --git a/keyprotocol_test.go b/keyprotocol_test.go new file mode 100644 index 0000000..13963d7 --- /dev/null +++ b/keyprotocol_test.go @@ -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| 0 { + flags = f.stack[len(f.stack)-1] + } + f.chunks = append(f.chunks, "\x1b[?"+strconv.Itoa(flags)+"u") + case req == " 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[ 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[ 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) + } + } + } + }) +} diff --git a/keyreport.go b/keyreport.go new file mode 100644 index 0000000..8767df9 --- /dev/null +++ b/keyreport.go @@ -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 +} diff --git a/keyreport_test.go b/keyreport_test.go new file mode 100644 index 0000000..048abbf --- /dev/null +++ b/keyreport_test.go @@ -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) + } + } +} diff --git a/terminal.go b/terminal.go index c10b24d..03ce657 100644 --- a/terminal.go +++ b/terminal.go @@ -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)