9 Commits
Author SHA1 Message Date
LixenandClaude Opus 5.5 97f66b1f1c 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>
2026-10-21 03:59:00 -04:00
lixen a8805d9be1 v0.4.3 prevent input stall on long or malformed sequence 2026-10-21 02:44:13 -04:00
Claude bc1021a454 v0.4.2 settle an unchanged row with one compare before per-cell scan 2026-10-19 06:11:59 -04:00
lixen 01a81100bb v0.4.1 export control key 2026-10-16 23:48:40 -04:00
lixen d89e331752 v0.4.0 terminal over caller's backend 2026-10-16 21:43:18 -04:00
lixen b62a7d08d1 v0.3.1 mouse improvement: keep reporting when mode is dropped or added under another 2026-10-15 18:08:23 -04:00
lixen 0919d2a6b1 v0.3.0 write output on its own goroutine, blocked terminal report 2026-10-14 20:38:40 -04:00
lixen c657af606d v0.2.2 tui update, reverse row bar 2026-10-10 18:04:30 -04:00
lixen 7d1c3b24a0 v0.2.1 tui enhancement and better narrow layouts 2026-10-10 11:50:56 -04:00
29 changed files with 2704 additions and 351 deletions

No files matched your search

+100 -16
View File
@@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
Terminal (interface) Terminal (interface)
└── termImpl └── termImpl
├── outputBuffer diffing, ANSI generation, 128KB buffered writer ├── outputBuffer diffing, ANSI generation
├── writeLoop the one goroutine that writes output
├── inputReader escape sequence parser, event channel ├── inputReader escape sequence parser, event channel
└── Backend (interface) └── Backend (interface)
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty ├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty
@@ -34,6 +35,11 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing. Shared code carries no build tags: cell diffing, ANSI generation, escape parsing.
Platform specifics are isolated in the `Backend` implementations. Platform specifics are isolated in the `Backend` implementations.
`NewWithBackend(b, mode)` builds a Terminal over a `Backend` of the caller's
own: a pipe, an in-process emulator or a test standing in for the tty. It
behaves as `New`'s Terminal does, the writer goroutine included; `New` is
`NewWithBackend` over the platform's backend and the detected color mode.
### Rendering pipeline ### Rendering pipeline
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
@@ -46,12 +52,27 @@ passes it to `Flush`. The output buffer diffs against the previously flushed fra
- If the backend size changed between buffer preparation and `Flush`, the frame - If the backend size changed between buffer preparation and `Flush`, the frame
is dropped to prevent resize-race corruption. The next frame (built at the new is dropped to prevent resize-race corruption. The next frame (built at the new
size) renders normally. size) renders normally.
- A cell is one glyph in one column, on a terminal that draws ambiguous-width
runes (box drawing, U+FFFD) narrow: a control or format character in `Rune`
(a pasted escape, a bidi override, a line separator) is drawn as a space,
and a rune a terminal draws in no column, in two or in its neighbour's
cluster (a combining or spacing mark, a wide or emoji rune) as U+FFFD.
`Sync()` clears the screen and invalidates the front buffer, forcing a full `Sync()` clears the screen and invalidates the front buffer, forcing a full
redraw — required after any external process writes to the terminal. redraw — required after any external process writes to the terminal.
Auto-wrap is disabled during the session, making the bottom-right cell writable Output is written on a goroutine of its own, so no call waits on the terminal.
without scroll side effects. A terminal that stops reading (a hidden tab, a multiplexer client that went
away, flow control) holds that goroutine in its write; meanwhile `Flush` keeps
only the newest frame, drawn once the write returns, and control sequences
(cursor, mouse, paste, `Clear`, `Sync`) queue behind it in call order.
`Blocked()` reports how long the write in flight has waited, so a caller can
stop composing frames nobody will see. `Fini` waits up to two seconds for the
restoring sequences, then restores the termios without them.
Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi
reordering are off during the session: the bottom-right cell is writable
without scroll side effects, and right-to-left text stays in its cells.
## Quick start ## Quick start
@@ -170,17 +191,15 @@ Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
destination first and are branch-free in the hot path or LUT-backed; suitable destination first and are branch-free in the hot path or LUT-backed; suitable
for per-cell use at frame rate. for per-cell use at frame rate.
| Function | Operation | Character | - `Blend(dst, src, alpha)`: linear interpolation, standard transparency
|---|---|---| - `Add(dst, src, alpha)`: saturating add, bright accumulation, clips
| `Blend(dst, src, alpha)` | linear interpolation | standard transparency | - `Screen(dst, src, alpha)`: `1-(1-d)(1-s)`, lightens, never clips
| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips | - `Overlay(dst, src, alpha)`: multiply/screen split at 0.5, contrast, keeps dst structure
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips | - `SoftLight(dst, src, intensity)`: Perez soft light, gentle tint/glow
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure | - `Max(dst, src, alpha)`: per-channel max, non-additive highlight
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow | - `Scale(c, factor)`: channel multiply, dim/brighten
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight | - `Grayscale(c)`: Rec. 601 luma, desaturation
| `Scale(c, factor)` | channel multiply | dim/brighten | - `c.Lerp(other, t)`: method on `RGB`, gradients, animation
| `Grayscale(c)` | Rec. 601 luma | desaturation |
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1 `alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1
short-circuits without float math. All float→channel conversions round half-up, short-circuits without float math. All float→channel conversions round half-up,
@@ -205,7 +224,8 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
- `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...), - `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...),
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask `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), - `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
`MouseAction` (press/release/move/drag), modifiers. Enable via `MouseAction` (press/release/move/drag), modifiers. Enable via
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only. `SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
@@ -213,12 +233,13 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
- `EventPaste` — `Text` holds a whole paste, as sent, control characters - `EventPaste` — `Text` holds a whole paste, as sent, control characters
included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste
arrives as keys, its newlines pressing Enter. 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` - `EventError`, `EventClosed`
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle). A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows). ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows).
Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer. Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer.
A cell is drawn as one glyph: a control character in `Rune`, such as a pasted escape, is drawn as a space.
With paste mode off, a paste marker is not a paste. A paste whose end marker never arrives ends once input pauses for a second, so what follows a paste that stalls that long arrives as keys. With paste mode off, a paste marker is not a paste. A paste whose end marker never arrives ends once input pauses for a second, so what follows a paste that stalls that long arrives as keys.
On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
@@ -226,6 +247,69 @@ On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
draws and reads keys on the terminal. draws and reads keys on the terminal.
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`). `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
WASM builds bridge to xterm.js via JS globals: WASM builds bridge to xterm.js via JS globals:
+9 -9
View File
@@ -1,7 +1,7 @@
package terminal package terminal
import ( import (
"bufio" "bytes"
) )
// Pre-allocated ANSI sequence fragments (avoid allocations during render) // Pre-allocated ANSI sequence fragments (avoid allocations during render)
@@ -9,7 +9,7 @@ var (
// CSI sequences // CSI sequences
csi = []byte("\x1b[") csi = []byte("\x1b[")
csiClear = []byte("\x1b[2J\x1b[H") csiClear = []byte("\x1b[2J\x1b[H")
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency) csiRIS = []byte("\x1bc") // Reset to Initial State (emergency)
csiSGR0 = []byte("\x1b[0m") csiSGR0 = []byte("\x1b[0m")
// Cursor control // Cursor control
@@ -17,9 +17,10 @@ var (
csiCursorShow = []byte("\x1b[?25h") csiCursorShow = []byte("\x1b[?25h")
csiCursorPos = []byte("\x1b[") // followed by row;colH csiCursorPos = []byte("\x1b[") // followed by row;colH
// Screen modes // Screen modes; the alternate screen also turns off the terminal's own bidi
csiAltScreenEnter = []byte("\x1b[?1049h") // reordering (ECMA-48 BDSM, mode 8), so right-to-left text keeps its cells
csiAltScreenExit = []byte("\x1b[?1049l") csiAltScreenEnter = []byte("\x1b[?1049h\x1b[8l")
csiAltScreenExit = []byte("\x1b[?1049l\x1b[8h")
// DECAWM: Auto-Wrap Mode // DECAWM: Auto-Wrap Mode
// ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner // ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner
csiAutoWrapOn = []byte("\x1b[?7h") csiAutoWrapOn = []byte("\x1b[?7h")
@@ -48,7 +49,7 @@ var (
// writeInt writes an integer without allocation // writeInt writes an integer without allocation
// Optimized for terminal values (0-255 common, 0-999 typical max) // Optimized for terminal values (0-255 common, 0-999 typical max)
func writeInt(w *bufio.Writer, n int) { func writeInt(w *bytes.Buffer, n int) {
if n < 0 { if n < 0 {
n = 0 n = 0
} }
@@ -67,7 +68,6 @@ func writeInt(w *bufio.Writer, n int) {
w.WriteByte(byte(n%10) + '0') w.WriteByte(byte(n%10) + '0')
return return
} }
// Fallback for >999 (rare)
var buf [5]byte var buf [5]byte
i := 4 i := 4
for n > 0 { for n > 0 {
@@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) {
} }
// writeCursorPos writes cursor positioning sequence (0-indexed input) // writeCursorPos writes cursor positioning sequence (0-indexed input)
func writeCursorPos(w *bufio.Writer, x, y int) { func writeCursorPos(w *bytes.Buffer, x, y int) {
w.Write(csiCursorPos) w.Write(csiCursorPos)
writeInt(w, y+1) writeInt(w, y+1)
w.WriteByte(';') w.WriteByte(';')
@@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) {
} }
// writeCursorForward writes cursor forward N positions // writeCursorForward writes cursor forward N positions
func writeCursorForward(w *bufio.Writer, n int) { func writeCursorForward(w *bytes.Buffer, n int) {
if n <= 0 { if n <= 0 {
return return
} }
+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)) { func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
b.resizeStopCh = make(chan struct{}) b.resizeStopCh = make(chan struct{})
b.resizeDoneCh = make(chan struct{}) b.resizeDoneCh = make(chan struct{})
+26
View File
@@ -0,0 +1,26 @@
package terminal
import "testing"
func TestControlKey(t *testing.T) {
cases := map[rune]Key{
'a': KeyCtrlA, 'G': KeyCtrlG, 'z': KeyCtrlZ,
'h': KeyBackspace, 'i': KeyTab, 'j': KeyEnter, 'm': KeyEnter,
' ': KeyCtrlSpace, '@': KeyCtrlSpace, '2': KeyCtrlSpace,
'[': KeyEscape, '3': KeyEscape,
'\\': KeyCtrlBackslash, '4': KeyCtrlBackslash,
']': KeyCtrlBracketRight, '5': KeyCtrlBracketRight,
'^': KeyCtrlCaret, '6': KeyCtrlCaret,
'_': KeyCtrlUnderscore, '-': KeyCtrlUnderscore, '/': KeyCtrlUnderscore, '7': KeyCtrlUnderscore,
}
for r, want := range cases {
if got, ok := ControlKey(r); !ok || got != want {
t.Errorf("ControlKey(%q) = %v, %v; want %v, true", r, got, ok, want)
}
}
for _, r := range []rune{'1', '8', '.', '~', 'é', 0} {
if _, ok := ControlKey(r); ok {
t.Errorf("ControlKey(%q) ok, want none", r)
}
}
}
+7 -2
View File
@@ -6,6 +6,8 @@
// the terminal's own default colors // the terminal's own default colors
// - Double-buffered output with cell-level diffing // - Double-buffered output with cell-level diffing
// - Raw input parsing with escape sequence handling and bracketed paste // - 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) // - Drawing on /dev/tty when stdin or stdout is redirected (Unix)
// - Resize detection (SIGWINCH on Unix, callback on WASM) // - Resize detection (SIGWINCH on Unix, callback on WASM)
// - Clean terminal restoration on exit/panic // - Clean terminal restoration on exit/panic
@@ -39,8 +41,11 @@
// //
// # Performance // # Performance
// //
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells // Output uses cell-level diffing. Only changed cells generate ANSI sequences.
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls. // Style attributes are coalesced to minimize SGR calls. One goroutine writes
// what is rendered, so a terminal that stops reading never holds the caller:
// Flush keeps the newest frame until the write in flight returns, and Blocked
// reports how long it has waited.
// Input parsing is zero-allocation for common cases. // Input parsing is zero-allocation for common cases.
// //
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences. // This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
+3 -3
View File
@@ -1,9 +1,9 @@
module github.com/lixenwraith/terminal module github.com/lixenwraith/terminal
go 1.27.1 go 1.27.2
require ( require (
github.com/lixenwraith/color v0.1.3 github.com/lixenwraith/color v0.1.3
golang.org/x/sys v0.48.0 golang.org/x/sys v0.49.0
golang.org/x/term v0.46.0 golang.org/x/term v0.47.0
) )
+4
View File
@@ -4,5 +4,9 @@ github.com/lixenwraith/color v0.1.3 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzF
github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU= github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
+207 -71
View File
@@ -20,6 +20,8 @@ const (
EventMouse // SGR mouse reporting EventMouse // SGR mouse reporting
EventError // Read error EventError // Read error
EventClosed // Input closed 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 // Event represents a terminal input event
@@ -28,16 +30,22 @@ type Event struct {
Key Key Key Key
Rune rune Rune rune
Modifiers Modifier Modifiers Modifier
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 Width int // For EventResize
Height int // For EventResize Height int // For EventResize
Err error // For EventError Err error // For EventError
Text string // For EventPaste: the pasted bytes as sent, controls included 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 // Mouse event fields
MouseX int MouseX int
MouseY int MouseY int
MouseBtn MouseButton MouseBtn MouseButton
MouseAction MouseAction MouseAction MouseAction
Touch bool // A touch, from a host that reads one; terminals report none
} }
// inputReader handles raw stdin parsing // inputReader handles raw stdin parsing
@@ -57,6 +65,37 @@ type inputReader struct {
paste []byte paste []byte
pasteSeen time.Time pasteSeen time.Time
// 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 mu sync.Mutex
running bool running bool
} }
@@ -66,6 +105,7 @@ func newInputReader(backend Backend) *inputReader {
return &inputReader{ return &inputReader{
backend: backend, backend: backend,
eventCh: make(chan Event, 256), eventCh: make(chan Event, 256),
asks: make(chan keyAsk, 1),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
doneCh: make(chan struct{}), doneCh: make(chan struct{}),
buf: make([]byte, 0, 256), buf: make([]byte, 0, 256),
@@ -125,9 +165,12 @@ func (r *inputReader) readLoop() {
}() }()
for { for {
r.serviceKeys()
// Blocking read from backend // Blocking read from backend
data, err := r.backend.Read(r.stopCh) data, err := r.backend.Read(r.stopCh)
if err != nil { if err != nil {
r.holds.ReleaseAll(r.sendEvent)
r.sendEvent(Event{Type: EventError, Err: err}) r.sendEvent(Event{Type: EventError, Err: err})
return return
} }
@@ -144,11 +187,12 @@ func (r *inputReader) readLoop() {
// Timeout (Unix poll) or empty read: a pending ESC, or two, was // Timeout (Unix poll) or empty read: a pending ESC, or two, was
// pressed alone; inside a paste it is text // pressed alone; inside a paste it is text
if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting { if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting {
r.sendEvent(ev) r.sendKey(0, ev)
r.buf = r.buf[:0] r.buf = r.buf[:0]
} }
select { select {
case <-r.stopCh: case <-r.stopCh:
r.holds.ReleaseAll(r.sendEvent)
r.sendEvent(Event{Type: EventClosed}) r.sendEvent(Event{Type: EventClosed})
return return
default: default:
@@ -186,6 +230,19 @@ func (r *inputReader) parseInput(data []byte) int {
default: default:
} }
if r.dropCSI {
for i < n && data[i] >= 0x20 && data[i] <= 0x3f {
i++
}
if i < n {
r.dropCSI = false
if data[i] >= 0x40 && data[i] <= 0x7e {
i++
}
}
continue
}
if r.pasting { if r.pasting {
end := bytes.Index(data[i:], pasteEnd) end := bytes.Index(data[i:], pasteEnd)
if end < 0 { if end < 0 {
@@ -210,7 +267,7 @@ func (r *inputReader) parseInput(data []byte) int {
// Fast path: printable ASCII // Fast path: printable ASCII
if b >= 0x20 && b < 0x7f { 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++ i++
continue continue
} }
@@ -229,8 +286,10 @@ func (r *inputReader) parseInput(data []byte) int {
} }
// Only emit if not a swallowed unknown sequence // Only emit if not a swallowed unknown sequence
if ev.Key != KeyNone || ev.Type != EventKey { if ev.Type != EventKey {
r.sendEvent(ev) r.sendEvent(ev)
} else if ev.Key != KeyNone {
r.sendKey(0, ev)
} }
i += consumed i += consumed
continue continue
@@ -238,14 +297,14 @@ func (r *inputReader) parseInput(data []byte) int {
// Control characters // Control characters
if b < 0x20 { if b < 0x20 {
r.sendEvent(r.parseControl(b)) r.sendKey(0, r.parseControl(b))
i++ i++
continue continue
} }
// DEL // DEL
if b == 0x7f { if b == 0x7f {
r.sendEvent(Event{Type: EventKey, Key: KeyBackspace}) r.sendKey(0, Event{Type: EventKey, Key: KeyBackspace})
i++ i++
continue continue
} }
@@ -265,7 +324,7 @@ func (r *inputReader) parseInput(data []byte) int {
} }
rn, size := decodeRune(data[i:]) 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 i += size
continue continue
} }
@@ -321,7 +380,7 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) {
return 0, Event{} return 0, Event{}
case bytes.HasPrefix(rest, pasteStart): case bytes.HasPrefix(rest, pasteStart):
return 1, Event{Type: EventKey, Key: KeyEscape} return 1, Event{Type: EventKey, Key: KeyEscape}
case rest[1] == '[' || rest[1] == 'O': case rest[1] == 'O', rest[1] == '[' && csiLen(rest) != csiTyped:
n, ev := r.parseEscape(rest) n, ev := r.parseEscape(rest)
if n == 0 { if n == 0 {
return 0, Event{} return 0, Event{}
@@ -351,55 +410,98 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) {
return 2, Event{Type: EventKey, Key: KeyRune, Rune: rune(data[1]), Modifiers: ModAlt} return 2, Event{Type: EventKey, Key: KeyRune, Rune: rune(data[1]), Modifiers: ModAlt}
} }
// Alt+Backspace
if data[1] == 0x7f {
return 2, Event{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}
}
// Alt+multibyte character; a byte no character starts with leaves Escape
size := utf8SeqLen(data[1])
if size == 0 {
return 1, Event{Type: EventKey, Key: KeyEscape}
}
if len(data) < 1+size {
return 0, Event{} return 0, Event{}
}
rn, n := decodeRune(data[1:])
return 1 + n, Event{Type: EventKey, Key: KeyRune, Rune: rn, Modifiers: ModAlt}
}
// csiMaxLen bounds a CSI sequence a terminal sends, with ESC [ and its final
// byte; a longer one is dropped up to its final byte
const csiMaxLen = 256
// csiLen results other than a length: more may be arriving, a byte no
// sequence holds came (ESC [ was typed, as Alt+[), or no final byte came
// within csiMaxLen
const (
csiPartial = 0
csiTyped = -1
csiOverlong = -2
)
// csiLen returns the length of the CSI sequence data starts with, or why it
// has none yet. Parameter and intermediate bytes run to a final byte as in
// ECMA-48, but for the Linux console's F1-F5, ESC [ [ A to E.
func csiLen(data []byte) int {
i := 2
if len(data) > 2 && data[2] == '[' {
i = 3
}
for ; i < len(data); i++ {
if i == csiMaxLen {
return csiOverlong
}
switch b := data[i]; {
case b >= 0x40 && b <= 0x7e:
return i + 1
case b < 0x20 || b > 0x3f:
return csiTyped
}
}
return csiPartial
} }
// parseCSI parses CSI sequence without allocation // parseCSI parses CSI sequence without allocation
func (r *inputReader) parseCSI(data []byte) (int, Event) { func (r *inputReader) parseCSI(data []byte) (int, Event) {
if len(data) < 3 { n := csiLen(data)
switch {
case n == csiPartial:
return 0, Event{} return 0, Event{}
case n == csiTyped:
return 2, Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt}
case n == csiOverlong:
r.dropCSI = true
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}
} }
// SGR mouse: ESC [ < Btn ; X ; Y M/m if key, mod, ok := lookupCSI(data[2:n]); ok {
if data[2] == '<' { return n, Event{Type: EventKey, Key: key, Modifiers: mod}
return r.parseSGRMouse(data)
}
end := 2
maxScan := len(data)
if maxScan > 16 {
maxScan = 16
}
for end < maxScan {
b := data[end]
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' {
end++
break
}
if b < 0x20 || b > 0x7e {
return 0, Event{}
}
end++
}
// Check if we found a terminator or ran out of data
if end <= 2 || end > maxScan {
return 0, Event{} // Incomplete
}
// Check last byte is valid terminator
lastByte := data[end-1]
if !((lastByte >= 'A' && lastByte <= 'Z') || (lastByte >= 'a' && lastByte <= 'z') || lastByte == '~') {
return 0, Event{} // Incomplete, no terminator found
}
if key, mod, ok := lookupCSI(data[2:end]); ok {
return end, Event{Type: EventKey, Key: key, Modifiers: mod}
} }
// Unknown but valid CSI syntax - consume and return KeyNone // Unknown but valid CSI syntax - consume and return KeyNone
return end, Event{Type: EventKey, Key: KeyNone} return n, Event{Type: EventKey, Key: KeyNone}
} }
// parseSS3 parses SS3 sequence without allocation, returns length even for unknown sequences // parseSS3 parses SS3 sequence without allocation, returns length even for unknown sequences
@@ -408,7 +510,13 @@ func (r *inputReader) parseSS3(data []byte) (int, Event) {
return 0, Event{} return 0, Event{}
} }
if key, mod, ok := lookupSS3(data[2:3]); ok { 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 // Unknown SS3 - consume to prevent garbage
return 3, Event{Type: EventKey, Key: KeyNone} return 3, Event{Type: EventKey, Key: KeyNone}
@@ -483,31 +591,12 @@ func (r *inputReader) parseControl(b byte) Event {
return Event{Type: EventKey, Key: KeyNone} return Event{Type: EventKey, Key: KeyNone}
} }
// parseSGRMouse parses mouse SGR sequences // parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from
func (r *inputReader) parseSGRMouse(data []byte) (int, Event) { // the parameters and final byte; a malformed one is dropped as KeyNone
// Format: ESC [ < Btn ; X ; Y M/m func parseSGRMouse(params []byte, final byte) Event {
// Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes
if len(data) < 10 {
return 0, Event{}
}
// Find terminator M or m
end := 3
for end < len(data) && end < 32 {
if data[end] == 'M' || data[end] == 'm' {
break
}
end++
}
if end >= len(data) || (data[end] != 'M' && data[end] != 'm') {
return 0, Event{}
}
// Parse: Btn;X;Y
params := data[3:end]
btn, x, y, ok := parseSGRParams(params) btn, x, y, ok := parseSGRParams(params)
if !ok { if !ok {
return 0, Event{} return Event{Type: EventKey, Key: KeyNone}
} }
ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
@@ -541,7 +630,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
ev.MouseBtn = MouseBtnNone // Release with no specific button ev.MouseBtn = MouseBtnNone // Release with no specific button
} }
if data[end] == 'M' { if final == 'M' {
if isMotion { if isMotion {
if ev.MouseBtn != MouseBtnNone { if ev.MouseBtn != MouseBtnNone {
ev.MouseAction = MouseActionDrag ev.MouseAction = MouseActionDrag
@@ -567,7 +656,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
ev.Modifiers |= ModCtrl ev.Modifiers |= ModCtrl
} }
return end + 1, ev return ev
} }
// parseSGRParams extracts btn, x, y from "Btn;X;Y" format // parseSGRParams extracts btn, x, y from "Btn;X;Y" format
@@ -605,6 +694,26 @@ func parseSGRParams(data []byte) (btn, x, y int, ok bool) {
return btn, x, y, true 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 // sendEvent sends an event to the channel, non-blocking
func (r *inputReader) sendEvent(ev Event) { func (r *inputReader) sendEvent(ev Event) {
select { select {
@@ -663,3 +772,30 @@ func decodeRune(data []byte) (rune, int) {
return r, size return r, size
} }
// ControlKey is the key a terminal reads for Ctrl with a printable character: the
// control byte that pair sends, parsed as input parsing reads it. Letters of
// either case, space and '@', '[' '\' ']' '^' '_', '-' and '/' as '_', and the
// digits 2 to 7 have one; ok is false for any other character.
func ControlKey(r rune) (Key, bool) {
var b byte
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
b = byte(r) & 0x1f
case r == ' ', r == '@', r == '2':
b = 0x00
case r == '[', r == '3':
b = 0x1b
case r == '\\', r == '4':
b = 0x1c
case r == ']', r == '5':
b = 0x1d
case r == '^', r == '6':
b = 0x1e
case r == '_', r == '-', r == '/', r == '7':
b = 0x1f
default:
return KeyNone, false
}
return (*inputReader)(nil).parseControl(b).Key, true
}
+25
View File
@@ -90,6 +90,31 @@ const (
ModCtrl Modifier = 1 << 2 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 // escapeSequence maps escape sequences to keys
// Key: sequence after ESC [ (e.g., "A" for up arrow) // Key: sequence after ESC [ (e.g., "A" for up arrow)
type escapeSequence struct { type escapeSequence struct {
+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)
}
}
}
+131 -55
View File
@@ -1,20 +1,23 @@
package terminal package terminal
import ( import (
"bufio" "bytes"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"unsafe"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
) )
// outputBuffer manages double-buffered terminal output with diffing // outputBuffer renders double-buffered terminal output with diffing. It only
// encodes: what an operation renders collects in buf, and termImpl hands it to
// the goroutine that writes it.
type outputBuffer struct { type outputBuffer struct {
front []Cell front []Cell
width int width int
height int height int
colorMode ColorMode colorMode ColorMode
writer *bufio.Writer buf bytes.Buffer
cursorX int cursorX int
cursorY int cursorY int
@@ -27,7 +30,7 @@ type outputBuffer struct {
lastValid bool lastValid bool
} }
// writerAdapter adapts Backend to io.Writer for bufio // writerAdapter adapts Backend to io.Writer
type writerAdapter struct { type writerAdapter struct {
b Backend b Backend
} }
@@ -40,17 +43,10 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// newOutputBuffer creates a new output buffer func newOutputBuffer(colorMode ColorMode) *outputBuffer {
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer { return &outputBuffer{colorMode: colorMode}
// Use 128KB buffer for minimal calls to backend
adapter := writerAdapter{b: backend}
return &outputBuffer{
writer: bufio.NewWriterSize(adapter, 131072),
colorMode: colorMode,
}
} }
// resize updates buffer dimensions
func (o *outputBuffer) resize(width, height int) { func (o *outputBuffer) resize(width, height int) {
size := width * height size := width * height
if cap(o.front) < size { if cap(o.front) < size {
@@ -68,16 +64,31 @@ func (o *outputBuffer) resize(width, height int) {
o.cursorValid = false o.cursorValid = false
} }
// cellEqual compares two cells for equality (standalone for inlining) // cellEqual compares the fields most likely to change first
func cellEqual(a, b Cell) bool { func cellEqual(a, b Cell) bool {
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
return a.Rune == b.Rune && return a.Rune == b.Rune &&
a.Bg == b.Bg && a.Bg == b.Bg &&
a.Fg == b.Fg && a.Fg == b.Fg &&
a.Attrs == b.Attrs a.Attrs == b.Attrs
} }
// flush writes the back buffer to terminal, diffing against front buffer // Cell has no padding, so a row compares as the bytes it occupies; the assertion
// fails to compile should a field change leave padding behind
var _ [0]struct{} = [unsafe.Sizeof(Cell{}) - unsafe.Sizeof(rune(0)) - 2*unsafe.Sizeof(color.RGB{}) - unsafe.Sizeof(Attr(0))]struct{}{}
// rowEqual reports whether two rows of cells hold the same cells, as one memcmp
func rowEqual(a, b []Cell) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
n := len(a) * int(unsafe.Sizeof(Cell{}))
return bytes.Equal(unsafe.Slice((*byte)(unsafe.Pointer(&a[0])), n), unsafe.Slice((*byte)(unsafe.Pointer(&b[0])), n))
}
// flush renders the back buffer into buf, diffing against front buffer
func (o *outputBuffer) flush(cells []Cell, width, height int) { func (o *outputBuffer) flush(cells []Cell, width, height int) {
if width != o.width || height != o.height { if width != o.width || height != o.height {
o.resize(width, height) o.resize(width, height)
@@ -88,18 +99,21 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
return return
} }
w := o.writer w := &o.buf
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
rowStart := y * width rowStart := y * width
// Most rows of a frame are unchanged; one compare settles each of those
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) {
continue
}
// Early termination: find last dirty cell in row (scan backward)
rowEnd := width rowEnd := width
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) { for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
rowEnd-- rowEnd--
} }
if rowEnd == 0 { if rowEnd == 0 {
continue // Entire row unchanged continue
} }
x := 0 x := 0
@@ -111,13 +125,11 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
continue continue
} }
// Found dirty cell - check for small gaps ahead to potentially merge segments
segStart := x segStart := x
segEnd := x + 1 segEnd := x + 1
// Extend segment through small gaps (≤3 unchanged cells) // A gap of up to 3 unchanged cells is rewritten, about the bytes of a cursor move
for segEnd < rowEnd { for segEnd < rowEnd {
// Find gap size
gapStart := segEnd gapStart := segEnd
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) { for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
gapStart++ gapStart++
@@ -125,7 +137,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
gapSize := gapStart - segEnd gapSize := gapStart - segEnd
if gapSize == 0 { if gapSize == 0 {
// No gap, extend to next unchanged
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++ segEnd++
} }
@@ -133,16 +144,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
if gapSize > 3 { if gapSize > 3 {
break // Gap too large, end segment here break
} }
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move // Only a gap in the segment's style: an SGR inside it costs more than the move
gapCompatible := true gapCompatible := true
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment refCell := cells[rowStart+segEnd-1]
for k := 0; k < gapSize; k++ { for k := 0; k < gapSize; k++ {
gCell := cells[rowStart+segEnd+k] gCell := cells[rowStart+segEnd+k]
// Strict equality on style/color to ensure no SGR emission
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs { if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
gapCompatible = false gapCompatible = false
break break
@@ -150,41 +160,30 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
} }
if !gapCompatible { if !gapCompatible {
break // Gap has different style, cheaper to jump break
} }
// Check if there's more dirty content after gap
if gapStart >= rowEnd { if gapStart >= rowEnd {
break // Gap extends to row end break
} }
// Small gap with content after - include gap in segment
segEnd = gapStart segEnd = gapStart
// Continue to find more dirty cells
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) { for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++ segEnd++
} }
} }
// Positions cursor to segment start
o.moveCursorTo(w, segStart, y) o.moveCursorTo(w, segStart, y)
// Write segment [segStart, segEnd)
for sx := segStart; sx < segEnd; sx++ { for sx := segStart; sx < segEnd; sx++ {
cidx := rowStart + sx cidx := rowStart + sx
c := cells[cidx] c := cells[cidx]
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs) o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
if r := c.Rune; r >= ' ' && r < 0x7f {
// A cell holds one glyph, never a control the terminal would act on
r := c.Rune
if unicode.IsControl(r) || !utf8.ValidRune(r) {
r = ' '
}
if r < 0x80 {
w.WriteByte(byte(r)) w.WriteByte(byte(r))
} else { } else {
w.WriteRune(r) w.WriteRune(oneColumn(r))
} }
o.front[cidx] = c o.front[cidx] = c
@@ -197,10 +196,92 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
w.Write(csiSGR0) w.Write(csiSGR0)
o.lastValid = false o.lastValid = false
w.Flush()
} }
// cursorForwardCost returns byte cost of cursor forward sequence // oneColumn is r as a cell draws it: one glyph in one column, where the
// terminal draws ambiguous-width runes (box drawing, U+FFFD) narrow
func oneColumn(r rune) rune {
if uint32(r) < 0x10000 && plain[r>>6]&(1<<(r&63)) != 0 {
return r
}
if !utf8.ValidRune(r) {
return ' '
}
for _, k := range drawnAs {
if unicode.In(r, k.runes...) {
return k.as
}
}
return r
}
// drawnAs is the glyph a cell draws for each kind of rune that would not take
// one column: a space for one a terminal acts on, hides or reorders text by
// (a control, a format character, a line separator), U+FFFD for one it draws
// in none or two or joins to a neighbour (a mark, a wide or emoji rune)
var drawnAs = [...]struct {
as rune
runes []*unicode.RangeTable
}{
{' ', []*unicode.RangeTable{unicode.Cc, unicode.Cf, unicode.Zl, unicode.Zp}},
{utf8.RuneError, []*unicode.RangeTable{wideOrJoining, unicode.Mn, unicode.Me, unicode.Mc}},
}
// plain has a bit set for each BMP rune a cell draws as itself, so most text
// skips drawnAs's searches
var plain = func() (p [0x10000 >> 6]uint64) {
for i := range p {
if i < 0xD800>>6 || i >= 0xE000>>6 {
p[i] = ^uint64(0)
}
}
for _, k := range drawnAs {
for _, t := range k.runes {
for _, r := range t.R16 {
for c := int(r.Lo); c <= int(r.Hi); c += int(r.Stride) {
p[c>>6] &^= 1 << (c & 63)
}
}
}
}
return p
}()
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
// fullwidth and emoji presentation as of Unicode 17, and the letters a
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
// Extend ones); a range spans the unassigned code points between its runes
var wideOrJoining = &unicode.RangeTable{
R16: []unicode.Range16{
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
},
R32: []unicode.Range32{
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
},
}
func cursorForwardCost(n int) int { func cursorForwardCost(n int) int {
if n == 1 { if n == 1 {
return 3 // \x1b[C return 3 // \x1b[C
@@ -208,13 +289,12 @@ func cursorForwardCost(n int) int {
return 3 + digitCount(n) // \x1b[nC return 3 + digitCount(n) // \x1b[nC
} }
// cursorAbsoluteCost returns byte cost of absolute cursor position
func cursorAbsoluteCost(x, y int) int { func cursorAbsoluteCost(x, y int) int {
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1 // \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
return 4 + digitCount(y+1) + digitCount(x+1) return 4 + digitCount(y+1) + digitCount(x+1)
} }
// digitCount returns number of decimal digits in n // digitCount caps at 4: no terminal is 10000 cells across
func digitCount(n int) int { func digitCount(n int) int {
if n < 10 { if n < 10 {
return 1 return 1
@@ -229,7 +309,7 @@ func digitCount(n int) int {
} }
// moveCursorTo positions cursor using most efficient method // moveCursorTo positions cursor using most efficient method
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) { func (o *outputBuffer) moveCursorTo(w *bytes.Buffer, x, y int) {
if o.cursorValid && o.cursorX == x && o.cursorY == y { if o.cursorValid && o.cursorX == x && o.cursorY == y {
return return
} }
@@ -256,11 +336,10 @@ func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
} }
// writeStyleCoalesced emits a single combined SGR sequence when style changes // writeStyleCoalesced emits a single combined SGR sequence when style changes
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, attr Attr) { func (o *outputBuffer) writeStyleCoalesced(w *bytes.Buffer, fg, bg color.RGB, attr Attr) {
if o.colorMode == ColorModeNone { if o.colorMode == ColorModeNone {
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
} }
// Check what changed
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor) fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor) bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
styleAttr := attr & AttrStyle styleAttr := attr & AttrStyle
@@ -310,7 +389,7 @@ var attrCodes = [...]struct {
// writeColor writes one color's SGR parameters, after a ';' when sep, for // writeColor writes one color's SGR parameters, after a ';' when sep, for
// base 30 (foreground) or 40 (background): the terminal's own, a palette // base 30 (foreground) or 40 (background): the terminal's own, a palette
// index, or RGB, as the color mode allows. ColorModeNone writes nothing. // index, or RGB, as the color mode allows. ColorModeNone writes nothing.
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) { func (o *outputBuffer) writeColor(w *bytes.Buffer, c color.RGB, palette, deflt bool, base int, sep bool) {
if o.colorMode == ColorModeNone { if o.colorMode == ColorModeNone {
return return
} }
@@ -348,7 +427,6 @@ func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt b
} }
} }
// forceFullRedraw clears front buffer to force complete redraw
func (o *outputBuffer) forceFullRedraw() { func (o *outputBuffer) forceFullRedraw() {
for i := range o.front { for i := range o.front {
o.front[i] = Cell{Rune: 0} o.front[i] = Cell{Rune: 0}
@@ -357,10 +435,10 @@ func (o *outputBuffer) forceFullRedraw() {
o.cursorValid = false o.cursorValid = false
} }
// clear writes a clear screen with specified background, attr's // clear renders a clear screen with specified background, attr's
// AttrBgDefault or AttrBg256 saying how to read it // AttrBgDefault or AttrBg256 saying how to read it
func (o *outputBuffer) clear(bg color.RGB, attr Attr) { func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
w := o.writer w := &o.buf
w.Write(csiSGR0) w.Write(csiSGR0)
w.Write(csi) w.Write(csi)
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false) o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
@@ -369,14 +447,12 @@ func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
o.lastValid = false o.lastValid = false
o.cursorValid = false o.cursorValid = false
w.Flush()
for i := range o.front { for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor} o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
} }
} }
// invalidateCursor marks cursor position as unknown
func (o *outputBuffer) invalidateCursor() { func (o *outputBuffer) invalidateCursor() {
o.cursorValid = false o.cursorValid = false
} }
+210 -42
View File
@@ -1,10 +1,12 @@
package terminal package terminal
import ( import (
"fmt"
"io" "io"
"os" "os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
"golang.org/x/term" "golang.org/x/term"
@@ -61,10 +63,16 @@ type Terminal interface {
// ColorMode returns detected color capability // ColorMode returns detected color capability
ColorMode() ColorMode ColorMode() ColorMode
// Flush writes cell buffer to terminal // Flush hands a frame to the terminal; cells are row-major: cells[y*width + x].
// Cells are row-major: cells[y*width + x] // It never waits on the terminal: while the last write is still out, the frame
// is held and drawn once that write returns, a newer one replacing it
Flush(cells []Cell, width, height int) Flush(cells []Cell, width, height int)
// Blocked reports how long the terminal has held the write in flight, zero
// while output drains. A terminal that stops reading (a hidden tab, a client
// that went away, flow control) shows here instead of as a caller that waits
Blocked() time.Duration
// Clear fills screen with specified background color // Clear fills screen with specified background color
Clear(bg color.RGB) Clear(bg color.RGB)
@@ -90,6 +98,16 @@ type Terminal interface {
// SetPasteMode turns bracketed paste on or off: on, a paste arrives as // 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 // one EventPaste rather than as keys, so its newlines don't press Enter
SetPasteMode(on bool) error 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 // ResizeEvent represents a terminal resize
@@ -114,8 +132,29 @@ type termImpl struct {
finalized bool finalized bool
mouseMode MouseMode mouseMode MouseMode
paste bool 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
// was rendered and not yet taken; a frame Flush is handed while a write is out
// waits in held, newest first, until it returns. Both are guarded by mu.
queued []byte
writing []byte // owned by writeLoop
held []Cell
heldW int
heldH int
holding bool
writeSince atomic.Int64 // unix nanoseconds the write out began; zero when none is
wake chan struct{}
quit chan struct{}
writerDone chan struct{}
} }
// finiDrainTimeout bounds how long Fini waits for the terminal to take what is
// queued, the restoring sequences last: a terminal that stopped reading would
// otherwise hold the process at exit
const finiDrainTimeout = 2 * time.Second
// New creates a new Terminal instance. Without a mode it uses // New creates a new Terminal instance. Without a mode it uses
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode // DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
// overrides both, as a --color=always or never would // overrides both, as a --color=always or never would
@@ -129,15 +168,27 @@ func New(colorMode ...ColorMode) Terminal {
c = ColorModeNone c = ColorModeNone
} }
t := &termImpl{ return NewWithBackend(b, c)
}
// NewWithBackend creates a Terminal over b in color mode c, for a pipe, an
// in-process emulator or a test standing in for the tty. b must not be nil;
// the Terminal calls its Init on Init and its Fini on Fini, and writes to it
// on its own goroutine as New's does
func NewWithBackend(b Backend, c ColorMode) Terminal {
return newTerm(b, c)
}
func newTerm(b Backend, c ColorMode) *termImpl {
return &termImpl{
backend: b, backend: b,
output: newOutputBuffer(c),
syntheticCh: make(chan Event, 16), syntheticCh: make(chan Event, 16),
resizeCh: make(chan ResizeEvent, 1), resizeCh: make(chan ResizeEvent, 1),
wake: make(chan struct{}, 1),
quit: make(chan struct{}),
writerDone: make(chan struct{}),
} }
// Initialize output buffer with backend
t.output = newOutputBuffer(b, c)
return t
} }
// Init enters raw mode and sets up terminal // Init enters raw mode and sets up terminal
@@ -159,6 +210,8 @@ func (t *termImpl) Init() error {
// Create input reader wrapping backend // Create input reader wrapping backend
t.input = newInputReader(t.backend) t.input = newInputReader(t.backend)
t.input.sources = backendKeySources(t.backend)
t.input.write = t.keyWrite
// Set resize handler on backend // Set resize handler on backend
t.backend.SetResizeHandler(func(w, h int) { t.backend.SetResizeHandler(func(w, h int) {
@@ -178,13 +231,16 @@ func (t *termImpl) Init() error {
} }
}) })
go t.writeLoop()
// Enter alternate screen, hide cursor // Enter alternate screen, hide cursor
t.writeRaw(csiAltScreenEnter) out := &t.output.buf
t.writeRaw(csiCursorHide) out.Write(csiAltScreenEnter)
out.Write(csiCursorHide)
// DISABLE AUTO-WRAP // DISABLE AUTO-WRAP
// Prevents terminal scroll/wrap on bottom-right corner write // Prevents terminal scroll/wrap on bottom-right corner write
t.writeRaw(csiAutoWrapOff) out.Write(csiAutoWrapOff)
// Invisible cursor // Invisible cursor
t.cursorVisible.Store(false) t.cursorVisible.Store(false)
@@ -196,6 +252,7 @@ func (t *termImpl) Init() error {
// Clear screen // Clear screen
t.output.clear(color.RGB{}, AttrBgDefault) t.output.clear(color.RGB{}, AttrBgDefault)
t.sendLocked()
// Start input reader // Start input reader
t.input.start() t.input.start()
@@ -207,47 +264,66 @@ func (t *termImpl) Init() error {
// Fini restores terminal state // Fini restores terminal state
func (t *termImpl) Fini() { func (t *termImpl) Fini() {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized { if !t.initialized || t.finalized {
t.mu.Unlock()
return return
} }
t.finalized = true
t.holding = false // a frame not yet drawn is not drawn over the restored screen
w := &t.output.buf
// Disable mouse before other cleanup // Disable mouse before other cleanup
if t.mouseMode != MouseModeNone { if t.mouseMode != MouseModeNone {
w := t.output.writer
w.Write(csiMouseMotionOff) w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff) w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff) w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff) w.Write(csiMouseSGROff)
w.Flush() t.sendLocked()
} }
if t.paste { if t.paste {
t.writeRaw(csiPasteOff) 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 { if t.input != nil {
t.input.stop() t.input.stop()
} }
t.mu.Lock()
// Show cursor // Show cursor
t.writeRaw(csiCursorShow) w.Write(csiCursorShow)
// Exit alternate screen // Exit alternate screen
t.writeRaw(csiAltScreenExit) w.Write(csiAltScreenExit)
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled // Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
t.writeRaw(csiAutoWrapOn) w.Write(csiAutoWrapOn)
// Reset attributes // Reset attributes
t.writeRaw(csiSGR0) w.Write(csiSGR0)
t.sendLocked()
t.mu.Unlock()
// The restoring sequences leave before raw mode does, unless the terminal has
// stopped reading: then the termios is restored without them
close(t.quit)
select {
case <-t.writerDone:
case <-time.After(finiDrainTimeout):
}
// Backend cleanup // Backend cleanup
t.backend.Fini() t.backend.Fini()
t.finalized = true
} }
// Size returns current terminal dimensions // Size returns current terminal dimensions
@@ -265,8 +341,8 @@ func (t *termImpl) ColorMode() ColorMode {
return t.output.colorMode return t.output.colorMode
} }
// Flush writes cell buffer to terminal // Flush renders cells and queues them for the terminal, or holds them while a write
// Holds lock for entire operation to prevent race with Clear/MoveCursor // is out. Holds lock for entire operation to prevent race with Clear/MoveCursor
func (t *termImpl) Flush(cells []Cell, width, height int) { func (t *termImpl) Flush(cells []Cell, width, height int) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@@ -275,6 +351,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
return return
} }
if t.writeSince.Load() != 0 {
t.held = append(t.held[:0], cells[:min(len(cells), width*height)]...)
t.heldW, t.heldH, t.holding = width, height, true
return
}
// A frame is a whole screen, so one rendered now supersedes any held
t.holding = false
t.renderLocked(cells, width, height)
}
// renderLocked diffs one frame into the queue. Caller MUST hold mu.
func (t *termImpl) renderLocked(cells []Cell, width, height int) {
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption // Validation against backend size; if mismatch, drop frame to prevent resize race corruption
currW, currH := t.backend.Size() currW, currH := t.backend.Size()
if currW != width || currH != height { if currW != width || currH != height {
@@ -282,6 +370,24 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
} }
t.output.flush(cells, width, height) t.output.flush(cells, width, height)
t.sendLocked()
}
// releaseLocked renders a held frame ahead of a control sequence, so the terminal
// receives the two in the order they were asked for. Caller MUST hold mu.
func (t *termImpl) releaseLocked() {
if t.holding {
t.holding = false
t.renderLocked(t.held, t.heldW, t.heldH)
}
}
// Blocked reports how long the write in flight has waited on the terminal
func (t *termImpl) Blocked() time.Duration {
if since := t.writeSince.Load(); since != 0 {
return time.Since(time.Unix(0, since))
}
return 0
} }
// Clear fills screen with background color // Clear fills screen with background color
@@ -293,7 +399,9 @@ func (t *termImpl) Clear(bg color.RGB) {
return return
} }
t.releaseLocked()
t.output.clear(bg, 0) t.output.clear(bg, 0)
t.sendLocked()
} }
// SetCursorVisible shows/hides cursor // SetCursorVisible shows/hides cursor
@@ -309,13 +417,14 @@ func (t *termImpl) SetCursorVisible(visible bool) {
return return
} }
w := t.output.writer t.releaseLocked()
w := &t.output.buf
if visible { if visible {
w.Write(csiCursorShow) w.Write(csiCursorShow)
} else { } else {
w.Write(csiCursorHide) w.Write(csiCursorHide)
} }
w.Flush() t.sendLocked()
} }
// MoveCursor positions cursor (0-indexed) // MoveCursor positions cursor (0-indexed)
@@ -327,6 +436,7 @@ func (t *termImpl) MoveCursor(x, y int) {
return return
} }
t.releaseLocked()
if t.output != nil { if t.output != nil {
t.output.invalidateCursor() t.output.invalidateCursor()
} }
@@ -345,10 +455,9 @@ func (t *termImpl) MoveCursor(x, y int) {
y = h - 1 y = h - 1
} }
// Write through buffered writer to maintain stream order // Through the queue, to keep the stream's order
wBuf := t.output.writer writeCursorPos(&t.output.buf, x, y)
writeCursorPos(wBuf, x, y) t.sendLocked()
wBuf.Flush()
} }
// Sync forces full redraw // Sync forces full redraw
@@ -362,8 +471,10 @@ func (t *termImpl) Sync() {
// Clear terminal before full redraw // Clear terminal before full redraw
// Diff-based rendering assumes physical terminal matches front buffer state // Diff-based rendering assumes physical terminal matches front buffer state
t.releaseLocked()
t.output.clear(color.RGB{}, AttrBgDefault) t.output.clear(color.RGB{}, AttrBgDefault)
t.output.forceFullRedraw() t.output.forceFullRedraw()
t.sendLocked()
} }
// PollEvent blocks until next input event // PollEvent blocks until next input event
@@ -412,9 +523,11 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
oldMode := t.mouseMode oldMode := t.mouseMode
t.mouseMode = mode t.mouseMode = mode
w := t.output.writer t.releaseLocked()
w := &t.output.buf
// Disable modes no longer needed (reverse order of enable) // Disable modes no longer needed (reverse order of enable): VTE 0.54+,
// WezTerm and Windows Terminal keep each one set and report by the highest
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 { if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
w.Write(csiMouseMotionOff) w.Write(csiMouseMotionOff)
} }
@@ -435,18 +548,34 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
w.Write(csiMouseSGROn) w.Write(csiMouseSGROn)
} }
// Enable new modes (click is base, drag extends, motion extends further) // xterm, xterm.js, kitty, tmux and Konsole keep one mode, which setting
if mode&MouseModeClick != 0 && oldMode&MouseModeClick == 0 { // another replaces and resetting any turns off, and alacritty's and urxvt's
// set clears the others: on any change the highest mode wanted is written
// last, even when already on
var top MouseMode
switch {
case mode == oldMode:
case mode&MouseModeMotion != 0:
top = MouseModeMotion
case mode&MouseModeDrag != 0:
top = MouseModeDrag
case mode&MouseModeClick != 0:
top = MouseModeClick
}
// Enable new modes, and the highest again (click is base, drag extends,
// motion extends further)
if mode&MouseModeClick != 0 && (oldMode&MouseModeClick == 0 || top == MouseModeClick) {
w.Write(csiMouseClickOn) w.Write(csiMouseClickOn)
} }
if mode&MouseModeDrag != 0 && oldMode&MouseModeDrag == 0 { if mode&MouseModeDrag != 0 && (oldMode&MouseModeDrag == 0 || top == MouseModeDrag) {
w.Write(csiMouseDragOn) w.Write(csiMouseDragOn)
} }
if mode&MouseModeMotion != 0 && oldMode&MouseModeMotion == 0 { if mode&MouseModeMotion != 0 && (oldMode&MouseModeMotion == 0 || top == MouseModeMotion) {
w.Write(csiMouseMotionOn) w.Write(csiMouseMotionOn)
} }
w.Flush() t.sendLocked()
return nil return nil
} }
@@ -460,18 +589,53 @@ func (t *termImpl) SetPasteMode(on bool) error {
} }
t.paste = on t.paste = on
t.input.pasteMode.Store(on) t.input.pasteMode.Store(on)
w := t.output.writer t.releaseLocked()
w := &t.output.buf
if on { if on {
w.Write(csiPasteOn) w.Write(csiPasteOn)
} else { } else {
w.Write(csiPasteOff) w.Write(csiPasteOff)
} }
return w.Flush() t.sendLocked()
return nil
} }
// writeRaw writes raw bytes to output // keyProtoPushed is whether a Terminal has the key protocol's flags pushed, for
func (t *termImpl) writeRaw(data []byte) { // EmergencyReset to pop
t.backend.Write(data) 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 // EmergencyReset attempts to restore terminal to sane state
@@ -491,6 +655,10 @@ func EmergencyReset(w io.Writer) {
w.Write(csiMouseClickOff) w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff) w.Write(csiMouseSGROff)
w.Write(csiPasteOff) w.Write(csiPasteOff)
if keyProtoPushed.Load() {
w.Write(csiKeyPop)
w.Write(csiFocusOff)
}
// Write sequences to provided writer // Write sequences to provided writer
w.Write(csiCursorShow) w.Write(csiCursorShow)
+288 -16
View File
@@ -3,6 +3,7 @@ package terminal
import ( import (
"bytes" "bytes"
"cmp" "cmp"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -18,6 +19,7 @@ type fakeBackend struct {
out bytes.Buffer out bytes.Buffer
chunks []string chunks []string
pause time.Duration pause time.Duration
gate chan struct{} // when set, a write waits until it is closed
} }
func (f *fakeBackend) Init() error { return nil } func (f *fakeBackend) Init() error { return nil }
@@ -26,6 +28,9 @@ func (f *fakeBackend) Size() (int, int) { return 1, 1 }
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {} func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
func (f *fakeBackend) Write(p []byte) error { func (f *fakeBackend) Write(p []byte) error {
if f.gate != nil {
<-f.gate
}
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.out.Write(p) f.out.Write(p)
@@ -80,23 +85,38 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"}, {ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"}, {ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
} { } {
f := &fakeBackend{} o := newOutputBuffer(c.mode)
o := newOutputBuffer(f, c.mode)
o.flush(c.cells, len(c.cells), 1) o.flush(c.cells, len(c.cells), 1)
if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want { if got, want := o.buf.String(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want) t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
} }
} }
} }
// A cell is drawn as one glyph: a control character, C0 or C1, that a // A cell is drawn as one glyph: a control character, C0 or C1, a format one
// pasted value carried in, or no rune at all, is drawn as a space, never // (a bidi control, a zero-width space) or a line separator that a pasted
// sent to the terminal // value or a log line carried in, or no rune at all, is drawn as a space,
// never sent to the terminal
func TestCellsSendNoControlCharacters(t *testing.T) { func TestCellsSendNoControlCharacters(t *testing.T) {
f := &fakeBackend{} o := newOutputBuffer(ColorMode256)
o := newOutputBuffer(f, ColorMode256) o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1) if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want { t.Fatalf("%q, want %q", got, want)
}
}
// A cell is one column: a rune a terminal draws in none (a combining mark, a
// joining jamo), in two (wide, fullwidth, emoji) or in its neighbour's cluster
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
// other as itself
func TestEachCellIsOneColumn(t *testing.T) {
o := newOutputBuffer(ColorMode256)
var row []Cell
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
row = append(row, Cell{Rune: r})
}
o.flush(row, len(row), 1)
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
t.Fatalf("%q, want %q", got, want) t.Fatalf("%q, want %q", got, want)
} }
} }
@@ -133,6 +153,92 @@ func TestDoubleEscape(t *testing.T) {
func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} } func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
// parsed is what a reader sends for input arriving in chunks without pauses,
// and how many bytes it still holds back for the rest of a sequence
func parsed(chunks ...string) (events []Event, held int) {
r := &inputReader{eventCh: make(chan Event, 4096)}
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)
}
// Every sequence the key tables hold reads as its key, and the key after it
// as itself
func TestKnownSequencesKeepTheirKeys(t *testing.T) {
for _, s := range csiSequences {
want := []Event{{Type: EventKey, Key: s.key, Modifiers: s.mod}, key('x')}
if got, _ := parsed("\x1b[" + s.seq + "x"); !slices.Equal(got, want) {
t.Errorf("ESC [ %s: got %+v, want %+v", s.seq, got, want)
}
}
}
// No input stops the reader: a reply of any length, Alt with Backspace or a
// character past ASCII, a malformed mouse report, or ESC [ before a byte no
// sequence holds (typed Alt+[) leave the keys after them read. An unknown or
// over-long sequence is dropped up to its final byte, by ECMA-48.
func TestNoInputStopsTheReader(t *testing.T) {
altBracket := Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt}
enter := Event{Type: EventKey, Key: KeyEnter}
for _, c := range []struct {
name string
chunks []string
want []Event
}{
{"long reply", []string{"\x1b[?65;1;9;15;18;21;22cx"}, []Event{key('x')}},
{"split reply", []string{"\x1b[?65;1;9;", "15;18;21;22", "cx"}, []Event{key('x')}},
{"longest reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen-3) + "zx"}, []Event{key('x')}},
{"over-long reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + ";2", "3zx"}, []Event{key('x')}},
{"over-long cut", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + "\rx"}, []Event{enter, key('x')}},
{"unknown final", []string{"\x1b[2^x"}, []Event{key('x')}},
{"alt+backspace", []string{"\x1b\x7fx"}, []Event{{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}, key('x')}},
{"alt+ö", []string{"\x1b\xc3", "\xb6x"}, []Event{{Type: EventKey, Key: KeyRune, Rune: 'ö', Modifiers: ModAlt}, key('x')}},
{"stray byte", []string{"\x1b\x80x"}, []Event{{Type: EventKey, Key: KeyEscape}, key('x')}},
{"alt+[ enter", []string{"\x1b[\rx"}, []Event{altBracket, enter, key('x')}},
{"alt+[ 1 é", []string{"\x1b[1éx"}, []Event{altBracket, key('1'), key('é'), key('x')}},
{"esc alt+[ enter", []string{"\x1b\x1b[\rx"}, []Event{{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}, key('['), enter, key('x')}},
{"bad mouse", []string{"\x1b[<0;1;1;1Mx"}, []Event{key('x')}},
{"mouse", []string{"\x1b[<0;5;3M\x1b[<0;5;3m"}, []Event{
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionPress},
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionRelease}}},
} {
if got, held := parsed(c.chunks...); !slices.Equal(got, c.want) || held != 0 {
t.Errorf("%s: got %+v holding %d, want %+v", c.name, got, held, c.want)
}
}
}
// Whatever bytes arrive, however reads split them, the reader sends the same
// events, holds back no more than ESC and the longest sequence, and holds
// nothing once four DEL, which end any sequence or character, and a paste
// end follow
func FuzzInputNeverStalls(f *testing.F) {
for _, s := range []string{"\x1b[?65;1;9;15;18;21;22cx", "\x1b\x7fx", "\x1b\xc3\xb6", "\x1b[\rx", "\x1b[<0;1;1;1Mx", "\x1b\x1b[A", "a\x1b[200~\x1b[A\x1b[201~b"} {
f.Add(s, uint(len(s)/2))
}
f.Fuzz(func(t *testing.T, in string, cut uint) {
cut %= uint(len(in)) + 1
whole, held := parsed(in)
if split, _ := parsed(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 := parsed(in, "\x7f\x7f\x7f\x7f\x1b[201~"); held != 0 {
t.Fatalf("%q: stalled holding %d bytes", in, held)
}
})
}
// input is what a reader is given, in chunks, and the events it must send // input is what a reader is given, in chunks, and the events it must send
type input struct { type input struct {
off bool off bool
@@ -165,8 +271,7 @@ func expectEvents(t *testing.T, cases map[string]input) {
// Paste mode set on the terminal is what its reader parses by // Paste mode set on the terminal is what its reader parses by
func TestPasteModeReachesTheReader(t *testing.T) { func TestPasteModeReachesTheReader(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term := newTerm(f, ColorMode256)
term.output = newOutputBuffer(f, ColorMode256)
if err := term.Init(); err != nil { if err := term.Init(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -187,12 +292,12 @@ func TestPasteModeReachesTheReader(t *testing.T) {
} }
} }
// Paste mode ends with the session, and Init clears to the terminal's own // The terminal's own bidi reordering is off while the session runs, paste
// background rather than painting it black // mode ends with it, and Init clears to the terminal's own background rather
// than painting it black
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term := newTerm(f, ColorModeTrueColor)
term.output = newOutputBuffer(f, ColorModeTrueColor)
if err := term.Init(); err != nil { if err := term.Init(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -205,4 +310,171 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") { !strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
t.Fatalf("%q", got) t.Fatalf("%q", got)
} }
if i := strings.Index(got, "\x1b[8l"); i < 0 || !strings.Contains(got[i:], "\x1b[8h") {
t.Fatalf("bidi reordering not off for the session: %q", got)
}
}
// A mouse mode change writes the modes dropped as reset and the modes added
// as set, and sets the highest mode kept again, last, when another was dropped
// or added under it; SGR is on while any mode is. Whether the terminal keeps
// one mode (xterm, xterm.js, kitty), lets a set clear the others (alacritty)
// or keeps each and reports by the highest (VTE, WezTerm, Windows Terminal),
// it then reports by the highest mode asked for.
func TestMouseModeChangesKeepReporting(t *testing.T) {
c, d, m := MouseModeClick, MouseModeDrag, MouseModeMotion
for _, tc := range []struct {
from, to MouseMode
want string // +n sets mode n, -n resets it
}{
{0, 0, ""}, {0, c, "+1006 +1000"}, {0, d, "+1006 +1002"}, {0, c | d, "+1006 +1000 +1002"},
{0, m, "+1006 +1003"}, {0, c | m, "+1006 +1000 +1003"}, {0, d | m, "+1006 +1002 +1003"}, {0, c | d | m, "+1006 +1000 +1002 +1003"},
{c, 0, "-1000 -1006"}, {c, c, ""}, {c, d, "-1000 +1002"}, {c, c | d, "+1002"},
{c, m, "-1000 +1003"}, {c, c | m, "+1003"}, {c, d | m, "-1000 +1002 +1003"}, {c, c | d | m, "+1002 +1003"},
{d, 0, "-1002 -1006"}, {d, c, "-1002 +1000"}, {d, d, ""}, {d, c | d, "+1000 +1002"},
{d, m, "-1002 +1003"}, {d, c | m, "-1002 +1000 +1003"}, {d, d | m, "+1003"}, {d, c | d | m, "+1000 +1003"},
{c | d, 0, "-1002 -1000 -1006"}, {c | d, c, "-1002 +1000"}, {c | d, d, "-1000 +1002"}, {c | d, c | d, ""},
{c | d, m, "-1002 -1000 +1003"}, {c | d, c | m, "-1002 +1003"}, {c | d, d | m, "-1000 +1003"}, {c | d, c | d | m, "+1003"},
{m, 0, "-1003 -1006"}, {m, c, "-1003 +1000"}, {m, d, "-1003 +1002"}, {m, c | d, "-1003 +1000 +1002"},
{m, m, ""}, {m, c | m, "+1000 +1003"}, {m, d | m, "+1002 +1003"}, {m, c | d | m, "+1000 +1002 +1003"},
{c | m, 0, "-1003 -1000 -1006"}, {c | m, c, "-1003 +1000"}, {c | m, d, "-1003 -1000 +1002"}, {c | m, c | d, "-1003 +1002"},
{c | m, m, "-1000 +1003"}, {c | m, c | m, ""}, {c | m, d | m, "-1000 +1002 +1003"}, {c | m, c | d | m, "+1002 +1003"},
{d | m, 0, "-1003 -1002 -1006"}, {d | m, c, "-1003 -1002 +1000"}, {d | m, d, "-1003 +1002"}, {d | m, c | d, "-1003 +1000 +1002"},
{d | m, m, "-1002 +1003"}, {d | m, c | m, "-1002 +1000 +1003"}, {d | m, d | m, ""}, {d | m, c | d | m, "+1000 +1003"},
{c | d | m, 0, "-1003 -1002 -1000 -1006"}, {c | d | m, c, "-1003 -1002 +1000"}, {c | d | m, d, "-1003 -1000 +1002"}, {c | d | m, c | d, "-1003 +1002"},
{c | d | m, m, "-1002 -1000 +1003"}, {c | d | m, c | m, "-1002 +1003"}, {c | d | m, d | m, "-1000 +1003"}, {c | d | m, c | d | m, ""},
} {
term := newTerm(&fakeBackend{}, ColorMode256)
term.initialized = true // no writer runs, so what is sent stays queued
term.SetMouseMode(tc.from)
before := string(term.queued)
term.queued = nil
term.SetMouseMode(tc.to)
got := string(term.queued)
var want strings.Builder
for _, f := range strings.Fields(tc.want) {
want.WriteString("\x1b[?" + f[1:] + map[byte]string{'+': "h", '-': "l"}[f[0]])
}
if got != want.String() {
t.Errorf("%03b to %03b: %q, want %q", tc.from, tc.to, got, want.String())
}
one, alacritty, each, sgr := reportedMouseMode(before + got)
if top := highestMouseMode(tc.to); one != top || alacritty != top || each != top || sgr != (tc.to != 0) {
t.Errorf("%03b to %03b reports by %03b, %03b, %03b, SGR %t; want %03b", tc.from, tc.to, one, alacritty, each, sgr, top)
}
}
}
// reportedMouseMode replays mode sets and resets as each kind of terminal
// keeps them and returns the mode each then reports by: xterm, xterm.js and
// kitty keep the last mode set, which a reset of any turns off; alacritty's set
// clears the others and its reset only its own; VTE, WezTerm and Windows
// Terminal keep each, reporting by the highest
func reportedMouseMode(out string) (one, alacritty, each MouseMode, sgr bool) {
modes := map[string]MouseMode{"1000": MouseModeClick, "1002": MouseModeDrag, "1003": MouseModeMotion}
for _, s := range strings.Split(out, "\x1b[?")[1:] {
code, set := s[:len(s)-1], strings.HasSuffix(s, "h")
switch m := modes[code]; {
case code == "1006":
sgr = set
case set:
one, alacritty, each = m, m, each|m
default:
one, alacritty, each = 0, alacritty&^m, each&^m
}
}
return one, alacritty, highestMouseMode(each), sgr
}
func highestMouseMode(m MouseMode) MouseMode {
for m&(m-1) != 0 {
m &= m - 1
}
return m
}
// A terminal that stops reading holds the write in flight, not whoever draws:
// Flush returns while it is out, Blocked says how long it has waited, the newest
// frame held meanwhile is the one drawn once the terminal reads again, and a
// control sequence asked for after a held frame reaches the terminal after it
func TestAStoppedTerminalHoldsNoCaller(t *testing.T) {
f := &fakeBackend{gate: make(chan struct{})}
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
waitFor(t, "the first write to go out", func() bool { return term.Blocked() > 0 })
frame := func(r rune) []Cell { return []Cell{{Rune: r}} }
returned := make(chan struct{})
go func() {
term.Flush(frame('a'), 1, 1)
term.Flush(frame('b'), 1, 1)
close(returned)
}()
select {
case <-returned:
case <-time.After(2 * time.Second):
t.Fatal("Flush waited on a terminal that stopped reading")
}
time.Sleep(10 * time.Millisecond)
if b := term.Blocked(); b < 10*time.Millisecond {
t.Fatalf("blocked %v while the write is held", b)
}
term.SetCursorVisible(true)
close(f.gate)
waitFor(t, "output to drain", func() bool { return term.Blocked() == 0 && strings.Contains(f.written(), "\x1b[?25h") })
got := f.written()
if strings.Contains(got, "a") || !strings.Contains(got, "b") {
t.Fatalf("drew %q; want the newest held frame only", got)
}
if strings.Index(got, "b") > strings.Index(got, "\x1b[?25h") {
t.Fatalf("the cursor was shown before the frame held ahead of it: %q", got)
}
term.Fini()
}
// Fini restores the termios even when the terminal never reads its last
// sequences: it stops waiting after finiDrainTimeout
func TestFiniLeavesAStoppedTerminal(t *testing.T) {
f := &fakeBackend{gate: make(chan struct{})}
defer close(f.gate)
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { term.Fini(); close(done) }()
select {
case <-done:
case <-time.After(finiDrainTimeout + 2*time.Second):
t.Fatal("Fini waited on a terminal that stopped reading")
}
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
for deadline := time.Now().Add(2 * time.Second); !ok(); {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(time.Millisecond)
}
}
// A Terminal built over a caller's backend draws a flushed frame to it
func TestNewWithBackendFlushesToTheBackend(t *testing.T) {
f := &fakeBackend{}
term := NewWithBackend(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
defer term.Fini()
if term.ColorMode() != ColorMode256 {
t.Fatalf("color mode %v", term.ColorMode())
}
term.Flush([]Cell{{Rune: 'x'}}, 1, 1)
waitFor(t, "the frame to reach the backend", func() bool { return strings.Contains(f.written(), "x") })
} }
+33 -13
View File
@@ -94,7 +94,8 @@ r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
String utilities operate on rune counts: `RuneLen`, `Truncate` / String utilities operate on rune counts: `RuneLen`, `Truncate` /
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` / `TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
`PadCenter`, `WrapText`. `PadCenter`, `WrapText` (at line breaks and spaces; a path or key longer than
the line breaks after `.`, `,`, `:`, `/`, `[` or `]`).
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs `Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
accept it. accept it.
@@ -105,7 +106,7 @@ Widgets are stateless render functions (mostly `Region` methods). Application
state lives in plain structs passed by pointer. Available renderers: state lives in plain structs passed by pointer. Available renderers:
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double, boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` / rounded, heavy, dashed and ASCII line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`, `KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`, `ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` / `Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
@@ -122,9 +123,13 @@ role's `Attr` may carry color bits, a palette index or the terminal's own
color, so an application builds one theme per color tier and every control color, so an application builds one theme per color tier and every control
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection always changes a glyph (brackets, a pointer, alone, and `ThemeFor(term.ColorMode())` picks among them. A selection
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437` always changes a glyph (a radio mark, a pointer, the focus mark), so it reads
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets. without color. A reverse-video `Selected` with no color of its own
(`Theme16`, `MonoTheme`) keeps the terminal's own foreground in every cell
drawn on it, so the cursor row is one bar. `GlyphsUnicode`, `GlyphsCP437`
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets;
`Glyphs.Line` is the line type of frames, rules and wires.
```go ```go
th := tui.DefaultTheme th := tui.DefaultTheme
@@ -136,7 +141,7 @@ value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholde
y += rows y += rows
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th) value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json value.Radio(0, 0, []string{"raw", "txt", "json"}, format, th) // ○ raw ● txt ○ json
y += rows y += rows
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th) form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
@@ -144,16 +149,29 @@ form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
- `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab. - `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab.
- `Field` draws the focus mark, the label, the required mark, and the error, - `Field` draws the focus mark, the label, the required mark, and the error,
or the help while focused, and returns the region for the value. or the help while focused, wrapped, and returns the region for the value.
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty. The label leaves the value at least half the row; label width 0 stacks the
label above the value, for narrow screens.
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty,
scrolled to the cursor with a muted `…` where text is out of view.
`TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`, `TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks `AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
as spaces and control characters dropped. as spaces and control characters dropped.
- `Choice` draws a segmented choice; `StepChoice` moves it on Left and Right. - `Radio` draws radio buttons in a row, the chosen one marked; too wide, it
shows the chosen one and its neighbours. `StepChoice` moves it on Left and
Right.
- `Toggle` draws an on/off switch. - `Toggle` draws an on/off switch.
- `Group` draws a foldable group's header, and its summary while folded. - `Group` draws a foldable group's header, and its summary while folded.
- `OptionListState` and `OptionList` filter options by what is typed, - `OptionListState` and `OptionList` filter options by what is typed,
matching name or hint regardless of case. matching name or hint regardless of case. Hints sit beside the names, or,
narrow, the cursor's hint wraps below them; `Rows(w)` is the height to
size a dialog by. With `Menu` there is no filter and `Pick` finds an
option by its `Key`, shown before its name.
- `Frame` fills a region, borders it with its title, and returns the inside;
sized with `Center` to what it holds, a dialog ends on its last line.
- `Rule` draws a line with a title and a hint, dropping the hint first.
- `Wires` lay lines on a grid by the arms that meet in each cell (`H`, `V`),
and `DrawWires` draws every junction, tee and crossing joined.
### Scrollable list with scrollbar ### Scrollable list with scrollbar
@@ -229,7 +247,9 @@ Pure logic, no rendering — usable independently:
- `ScrollState` — item-index scrolling with selection - `ScrollState` — item-index scrolling with selection
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`) (`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
- `ViewportScroll` — row-based content scrolling with viewport clipping - `ViewportScroll` — row-based content scrolling with viewport clipping
(`ClipToViewport` maps content rows to visible rows) (`ClipToViewport` maps content rows to visible rows, `EnsureRange` keeps a
range in view); `Region.Window` draws content taller than the region off
screen and shows the rows in view
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse - `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
keyed state, hierarchical → flat visible-node list keyed state, hierarchical → flat visible-node list
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling - `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
@@ -239,8 +259,8 @@ Pure logic, no rendering — usable independently:
## Notes ## Notes
- Width calculations count runes, not terminal columns; East Asian wide - Width calculations count runes, one column each, as `Flush` draws a wide or
characters and combining marks are not width-aware. joining rune as U+FFFD.
- Zero-value `color.RGB` in style fields generally means "inherit" - Zero-value `color.RGB` in style fields generally means "inherit"
(widget default or row background) — check specific widget docs. A zero (widget default or row background) — check specific widget docs. A zero
background with no Bg color bits is transparent: the cell keeps the background with no Bg color bits is transparent: the cell keeps the
+127 -50
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"strings"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
"github.com/lixenwraith/terminal" "github.com/lixenwraith/terminal"
) )
@@ -14,26 +16,39 @@ const (
LineRounded // ╭─╮│╰╯ LineRounded // ╭─╮│╰╯
LineHeavy // ┏━┓┃┗┛ LineHeavy // ┏━┓┃┗┛
LineNone // spaces (invisible border with padding) LineNone // spaces (invisible border with padding)
LineASCII // +-+|++, for a terminal outside UTF-8
LineDashed // ╭╌╮╎╰╯
) )
// boxChars contains box drawing character sets indexed by LineType // lineChars are each LineType's runes by the arms that meet in a cell: up 1,
var boxChars = [...][6]rune{ // right 2, down 4, left 8, so corners, tees and crossings all join
LineSingle: {'┌', '─', '┐', '│', '└', '┘'}, var lineChars = [...]string{
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'}, LineSingle: " │─└││┌├─┘─┴┐┤┬┼",
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'}, LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬",
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'}, LineRounded: " │─╰││╭├─╯─┴╮┤┬┼",
LineNone: {' ', ' ', ' ', ' ', ' ', ' '}, LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋",
LineNone: " ",
LineASCII: " |-+||++-+-+++++",
LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼",
} }
const ( const (
boxTL = 0 // top-left boxTL = 6 // top-left
boxH = 1 // horizontal boxH = 10 // horizontal
boxTR = 2 // top-right boxTR = 12 // top-right
boxV = 3 // vertical boxV = 5 // vertical
boxBL = 4 // bottom-left boxBL = 3 // bottom-left
boxBR = 5 // bottom-right boxBR = 9 // bottom-right
) )
// runes returns the line's runes by arms; an unknown type draws single
func (l LineType) runes() []rune {
if l >= LineType(len(lineChars)) {
l = LineSingle
}
return []rune(lineChars[l])
}
// --- Box Rendering --- // --- Box Rendering ---
// Box draws border around region edge // Box draws border around region edge
@@ -45,37 +60,35 @@ func (r Region) Box(line LineType, fg color.RGB) {
// with off rows scrolled past its top edge. Rows outside the box are skipped, // with off rows scrolled past its top edge. Rows outside the box are skipped,
// so a partially visible box keeps its sides and grows no false edges. // so a partially visible box keeps its sides and grows no false edges.
func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) { func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) {
r.boxStyled(line, Style{Fg: fg}, totalH, off)
}
// BoxStyle draws a border around the region's edge in a theme's style
func (r Region) BoxStyle(line LineType, s Style) {
r.boxStyled(line, s, r.H, 0)
}
func (r Region) boxStyled(line LineType, s Style, totalH, off int) {
if r.W < 2 || r.H < 1 || totalH < 2 { if r.W < 2 || r.H < 1 || totalH < 2 {
return return
} }
if line >= LineType(len(boxChars)) { chars := line.runes()
line = LineSingle edge := func(y int, left, right rune) {
r.TextStyled(0, y, string(left), s)
r.TextStyled(r.W-1, y, string(right), s)
} }
chars := boxChars[line]
bg := color.RGB{} // Transparent (use existing bg)
for y := range r.H { for y := range r.H {
c := off + y switch c := off + y; {
if c < 0 || c >= totalH { case c < 0 || c >= totalH:
continue case c == 0 || c == totalH-1:
} left, right := chars[boxTL], chars[boxTR]
switch c { if c > 0 {
case 0: left, right = chars[boxBL], chars[boxBR]
r.Cell(0, y, chars[boxTL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxTR], fg, bg, terminal.AttrNone)
for x := 1; x < r.W-1; x++ {
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
}
case totalH - 1:
r.Cell(0, y, chars[boxBL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxBR], fg, bg, terminal.AttrNone)
for x := 1; x < r.W-1; x++ {
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
} }
r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s)
edge(y, left, right)
default: default:
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone) edge(y, chars[boxV], chars[boxV])
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
} }
} }
} }
@@ -99,10 +112,7 @@ func (r Region) HLine(y int, line LineType, fg color.RGB) {
if y < 0 || y >= r.H { if y < 0 || y >= r.H {
return return
} }
if line >= LineType(len(boxChars)) { ch := line.runes()[boxH]
line = LineSingle
}
ch := boxChars[line][boxH]
for x := 0; x < r.W; x++ { for x := 0; x < r.W; x++ {
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
} }
@@ -113,10 +123,7 @@ func (r Region) VLine(x int, line LineType, fg color.RGB) {
if x < 0 || x >= r.W { if x < 0 || x >= r.W {
return return
} }
if line >= LineType(len(boxChars)) { ch := line.runes()[boxV]
line = LineSingle
}
ch := boxChars[line][boxV]
for y := 0; y < r.H; y++ { for y := 0; y < r.H; y++ {
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
} }
@@ -127,11 +134,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
if y < 0 || y >= r.H { if y < 0 || y >= r.H {
return return
} }
if line >= LineType(len(boxChars)) { hChar := line.runes()[boxH]
line = LineSingle
}
hChar := boxChars[line][boxH]
// Fill with horizontal line // Fill with horizontal line
for x := 0; x < r.W; x++ { for x := 0; x < r.W; x++ {
@@ -171,3 +174,77 @@ func (r Region) Card(title string, line LineType, fg color.RGB) Region {
return r.Inset(1) return r.Inset(1)
} }
// --- Themed frames, rules and wires ---
// Frame fills the region with the theme's text style and draws its border
// in Glyphs.Line, the title on the top edge, and returns the inside with a
// column of padding each side. Callers size it to its content, with Center.
func (r Region) Frame(title string, th Theme) Region {
r.FillStyle(th.Text)
r.BoxStyle(th.Glyphs.Line, th.Border)
if title != "" && r.W > 6 {
r.TextStyled(2, 0, " "+Truncate(title, r.W-6)+" ", th.Accent)
}
return r.Sub(2, 1, r.W-4, r.H-2)
}
// Rule draws a line across row y, the title at its third column and the
// hint muted at its end. The title keeps its room: the hint is dropped where
// both do not fit.
func (r Region) Rule(y int, title, hint string, th Theme) {
r.TextStyled(0, y, strings.Repeat(string(th.Glyphs.Line.runes()[boxH]), max(0, r.W)), th.Border)
if title != "" && r.W > 6 {
title = " " + Truncate(title, r.W-6) + " "
r.TextStyled(2, y, title, th.Text)
}
if hint != "" && RuneLen(title)+RuneLen(hint)+6 <= r.W {
r.TextStyled(r.W-RuneLen(hint)-3, y, " "+hint+" ", th.Muted)
}
}
// Wires are lines on a grid, each cell holding the arms that meet there: up
// 1, right 2, down 4, left 8. Wherever lines branch or cross, the cell draws
// the junction, whatever order they were laid in.
type Wires struct {
W int
Arms []uint8
}
// NewWires creates an empty grid w by h
func NewWires(w, h int) Wires {
return Wires{W: w, Arms: make([]uint8, max(0, w*h))}
}
// H lays a wire along row y between columns x0 and x1
func (g Wires) H(y, x0, x1 int) {
for x := min(x0, x1); x < max(x0, x1); x++ {
g.arm(x, y, 2)
g.arm(x+1, y, 8)
}
}
// V lays a wire down column x between rows y0 and y1
func (g Wires) V(x, y0, y1 int) {
for y := min(y0, y1); y < max(y0, y1); y++ {
g.arm(x, y, 4)
g.arm(x, y+1, 1)
}
}
// arm adds an arm to a cell; one off the grid is dropped
func (g Wires) arm(x, y int, a uint8) {
if i := y*g.W + x; x >= 0 && x < g.W && y >= 0 && i < len(g.Arms) {
g.Arms[i] |= a
}
}
// DrawWires draws every cell of the grid that has arms, in the line type
func (r Region) DrawWires(g Wires, line LineType, s Style) {
chars := line.runes()
for i, arms := range g.Arms {
if arms != 0 {
r.TextStyled(i%g.W, i/g.W, string(chars[arms&15]), s)
}
}
}
+41 -10
View File
@@ -2,20 +2,51 @@ package tui
import "github.com/lixenwraith/terminal" import "github.com/lixenwraith/terminal"
// Choice draws options in a row at (x, y), the chosen one bracketed in the // Radio draws options as radio buttons in a row at (x, y): the chosen one
// accent style and the rest muted ("raw [txt] json"), and returns the // with the On glyph in the accent style, the rest with Off, muted ("○ raw
// columns it drew // ● txt ○ json"); chosen -1 marks none. Where the row does not fit, it shows
func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int { // the options around the chosen one, a muted '…' where others are hidden.
// It returns the columns it drew.
func (r Region) Radio(x, y int, options []string, chosen int, th Theme) int {
width := func(lo, hi int) int { // the window [lo, hi), its '…' marks included
w := -2
for _, o := range options[lo:hi] {
w += RuneLen(o) + 4
}
return w + 2*min(lo, 1) + 3*min(len(options)-hi, 1)
}
lo, hi := 0, len(options)
if width(lo, hi) > r.W-x && hi > 0 {
lo = max(0, min(chosen, hi-1))
hi = lo + 1
for grown := true; grown; {
grown = false
if hi < len(options) && width(lo, hi+1) <= r.W-x {
hi, grown = hi+1, true
}
if lo > 0 && width(lo-1, hi) <= r.W-x {
lo, grown = lo-1, true
}
}
}
start := x start := x
for i, o := range options { if lo > 0 {
open, close, style := " ", " ", th.Muted r.TextStyled(x, y, "…", th.Muted)
x += 2
}
for i := lo; i < hi; i++ {
mark, style := th.Glyphs.Off, th.Muted
if i == chosen { if i == chosen {
open, close, style = "[", "]", th.Accent mark, style = th.Glyphs.On, th.Accent
} }
r.TextStyled(x, y, open+o+close, style) r.TextStyled(x, y, Truncate(string(mark)+" "+options[i], max(0, r.W-x)), style)
x += RuneLen(o) + 2 x += RuneLen(options[i]) + 4
} }
return x - start if hi < len(options) {
r.TextStyled(x, y, "…", th.Muted)
x += 3
}
return max(0, min(x-2, r.W)-start)
} }
// StepChoice moves a choice among n options on Left or Right (h, l), // StepChoice moves a choice among n options on Left or Right (h, l),
+1 -5
View File
@@ -203,11 +203,7 @@ func (r Region) docSection(y, x, w int, label string, opts DocOpts) {
r.TextStyled(x, y, label, opts.HeaderStyle) r.TextStyled(x, y, label, opts.HeaderStyle)
return return
} }
line := opts.Rule ch := opts.Rule.runes()[boxH]
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxH]
for i := range w { for i := range w {
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr) r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
} }
+29 -8
View File
@@ -36,36 +36,49 @@ type Field struct {
// Field draws a form row at y: the focus mark, the label in a column labelW // Field draws a form row at y: the focus mark, the label in a column labelW
// wide with the required mark, and below, the error, or the help while // wide with the required mark, and below, the error, or the help while
// focused. It returns the region for the value beside the label, where the // focused, wrapped. The label column leaves the value half the row; labelW 0
// caller draws a TextInput, Choice or Toggle, and the rows it used. // stacks the label on its own row and the value under it, indented. It
// returns the region for the value, where the caller draws a TextInput,
// Radio or Toggle, and the rows it used.
func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) { func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
label := th.Muted label := th.Muted
if focused { if focused {
label = th.Text label = th.Text
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent) r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
} }
x, rows := 4, 2 // stacked: the label's own row, the value under it
if labelW > 0 {
labelW = min(labelW, max(1, (r.W-3)/2))
x, rows = 2+labelW+1, 1
} else {
labelW = r.W - 2
}
name := f.Label name := f.Label
if f.Required { if f.Required {
name = Truncate(name, labelW-2) name = Truncate(name, labelW-2)
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent) r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
} }
r.TextStyled(2, y, Truncate(name, labelW), label) r.TextStyled(2, y, Truncate(name, labelW), label)
value := r.Sub(x, y+rows-1, r.W-x, 1)
x := 2 + labelW + 1
note, style := f.Error, th.Error note, style := f.Error, th.Error
if note == "" && focused { if note == "" && focused {
note, style = f.Help, th.Muted note, style = f.Help, th.Muted
} }
if note == "" { if note == "" {
return r.Sub(x, y, r.W-x, 1), 1 return value, rows
} }
r.TextStyled(x, y+1, Truncate(note, r.W-x), style) lines := WrapText(note, r.W-x)
return r.Sub(x, y, r.W-x, 1), 2 for i, l := range lines {
r.TextStyled(x, y+rows+i, l, style)
}
return value, rows + len(lines)
} }
// TextInput draws state's text on r's first row, or, empty, the placeholder // TextInput draws state's text on r's first row, or, empty, the placeholder
// muted: a default shows what an empty value means. Focused, it draws on the // muted: a default shows what an empty value means. Focused, it draws on the
// Input background with the cursor, scrolling to keep the cursor in view. // Input background with the cursor, scrolling to keep the cursor in view; a
// muted '…' marks text out of view.
func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) { func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
if r.W < 1 || r.H < 1 { if r.W < 1 || r.H < 1 {
return return
@@ -75,7 +88,7 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
bg = th.Input bg = th.Input
} }
state.AdjustScroll(r.W) state.AdjustScroll(r.W)
text, style := []rune(placeholder), th.Muted.On(bg) text, style := []rune(Truncate(placeholder, r.W)), th.Muted.On(bg)
if len(state.Text) > 0 { if len(state.Text) > 0 {
text, style = state.Text[state.Scroll:], th.Text.On(bg) text, style = state.Text[state.Scroll:], th.Text.On(bg)
} }
@@ -86,6 +99,14 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
} }
r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr) r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr)
} }
// Text scrolled out of view on either side is marked, never under the cursor
hidden := th.Muted.On(bg)
if state.Scroll > 0 && (state.Cursor != state.Scroll || !focused) {
r.Cell(0, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr)
}
if end := r.W - 1; len(text) > r.W && (state.Cursor-state.Scroll != end || !focused) {
r.Cell(end, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr)
}
if focused { if focused {
x, ch := state.Cursor-state.Scroll, ' ' x, ch := state.Cursor-state.Scroll, ' '
if x < len(text) { if x < len(text) {
+80 -20
View File
@@ -10,17 +10,24 @@ import (
type Option struct { type Option struct {
Name string // what is chosen Name string // what is chosen
Hint string // a line about it Hint string // a line about it
Key rune // in a menu, the key that picks it at once
} }
// OptionListState filters options by the text typed into it, matching name // OptionListState filters options by the text typed into it, matching name
// or hint regardless of case, and keeps a cursor on the matches // or hint regardless of case, and keeps a cursor on the matches. A menu has
// no filter: each option's Key picks it.
type OptionListState struct { type OptionListState struct {
Options []Option Options []Option
Filter TextFieldState Filter TextFieldState
Cursor int // index into Matches Cursor int // index into Matches
Scroll int // first match shown Scroll int // first match shown
Menu bool // no filter; Pick finds an option by its Key
} }
// minHintW is the narrowest hint column beside the names; narrower, the
// cursor's hint wraps below the list instead
const minHintW = 16
// NewOptionListState creates a list showing every option // NewOptionListState creates a list showing every option
func NewOptionListState(options []Option) *OptionListState { func NewOptionListState(options []Option) *OptionListState {
return &OptionListState{Options: options} return &OptionListState{Options: options}
@@ -59,7 +66,7 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
s.Cursor-- s.Cursor--
case key == terminal.KeyDown: case key == terminal.KeyDown:
s.Cursor++ s.Cursor++
case !s.Filter.HandleKey(key, r, mod): case s.Menu || !s.Filter.HandleKey(key, r, mod):
return false return false
case s.Filter.Value() != filter: case s.Filter.Value() != filter:
s.Cursor = 0 s.Cursor = 0
@@ -68,49 +75,102 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
return true return true
} }
// Pick returns the index in Options of the option whose Key is r
func (s *OptionListState) Pick(r rune) (int, bool) {
for i, o := range s.Options {
if o.Key == r && r != 0 {
return i, true
}
}
return 0, false
}
// Paste adds pasted text to the filter // Paste adds pasted text to the filter
func (s *OptionListState) Paste(text string) bool { func (s *OptionListState) Paste(text string) bool {
if !s.Filter.Paste(text) { if s.Menu || !s.Filter.Paste(text) {
return false return false
} }
s.Cursor = 0 s.Cursor = 0
return true return true
} }
// OptionList draws the filter on the first row and the matches below it, // layout is how the list fits w columns: the names' width, the column the
// the cursor row marked with the pointer on the Selected background, each // names start at, and the rows below the list that hold the cursor's hint
// hint muted in a column after the names // where hints do not fit beside the names
func (s *OptionListState) layout(w int) (nameW, nameX, hintRows int) {
nameX = 2
if s.Menu {
nameX = 4
}
for _, o := range s.Options {
nameW = max(nameW, RuneLen(o.Name))
}
nameW = min(nameW, (w-nameX)/2)
if w-nameX-nameW-2 >= minHintW {
return nameW, nameX, 0
}
nameW = w - nameX
for _, o := range s.Options {
if o.Hint != "" {
hintRows = max(hintRows, min(3, len(WrapText(o.Hint, max(1, w-2)))))
}
}
return nameW, nameX, hintRows
}
// Rows returns the rows the list takes at width w to show every option: the
// filter, a row each, and the cursor's hint where it does not fit beside them
func (s *OptionListState) Rows(w int) int {
_, _, hintRows := s.layout(w)
return 1 - boolToInt(s.Menu) + len(s.Options) + hintRows
}
// OptionList draws the filter on the first row, a menu none, and the
// matches below, the cursor row marked with the pointer on the Selected
// background and a menu's keys before the names in the accent style. Hints
// sit muted beside the names or, where that column would be narrower than
// 16, the cursor's hint wraps onto the list's last rows.
func (r Region) OptionList(s *OptionListState, th Theme) { func (r Region) OptionList(s *OptionListState, th Theme) {
if r.W < 4 || r.H < 2 { if r.W < 4 || r.H < 2 {
return return
} }
top := 1
if s.Menu {
top = 0
} else {
r.TextStyled(0, 0, "/", th.Muted) r.TextStyled(0, 0, "/", th.Muted)
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th) r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
}
m := s.Matches() m := s.Matches()
rows := r.H - 1
if len(m) == 0 { if len(m) == 0 {
r.TextStyled(2, 1, "no match", th.Muted) r.TextStyled(2, top, "no match", th.Muted)
return return
} }
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows)) nameW, nameX, hintRows := s.layout(r.W)
s.Scroll = max(s.Scroll, s.Cursor-rows+1) rows := max(1, r.H-top-hintRows)
s.Scroll = AdjustScroll(s.Cursor, ClampScroll(s.Scroll, rows, len(m)), rows, len(m))
nameW := 0
for _, i := range m {
nameW = max(nameW, RuneLen(s.Options[i].Name))
}
nameW = min(nameW, r.W/2)
for y := 0; y < rows && s.Scroll+y < len(m); y++ { for y := 0; y < rows && s.Scroll+y < len(m); y++ {
o := s.Options[m[s.Scroll+y]] o := s.Options[m[s.Scroll+y]]
row := r.Sub(0, 1+y, r.W, 1) row := r.Sub(0, top+y, r.W, 1)
var bg Style var bg Style
if s.Scroll+y == s.Cursor { if s.Scroll+y == s.Cursor {
bg = th.Selected bg = th.Selected
row.FillStyle(th.Text.On(bg)) row.FillStyle(th.Text.On(bg))
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg)) row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg))
} }
row.TextStyled(2, 0, Truncate(o.Name, nameW), th.Text.On(bg)) if s.Menu && o.Key != 0 {
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg)) row.TextStyled(2, 0, string(o.Key), th.Accent.On(bg))
}
row.TextStyled(nameX, 0, Truncate(o.Name, nameW), th.Text.On(bg))
if hintRows == 0 {
row.TextStyled(nameX+nameW+2, 0, Truncate(o.Hint, r.W-nameX-nameW-2), th.Muted.On(bg))
}
}
hint, n := WrapText(s.Options[m[s.Cursor]].Hint, max(1, r.W-2)), min(hintRows, r.H-top-rows)
if len(hint) > n && n > 0 { // the last row shown ends in '…'
hint = append(hint[:n-1], Truncate(hint[n-1]+" "+hint[n], r.W-2))
}
for y, l := range hint[:min(n, len(hint))] { // on the last rows, so the list ends on text
r.TextStyled(2, r.H-min(n, len(hint))+y, l, th.Muted)
} }
} }
+7 -1
View File
@@ -25,11 +25,17 @@ func (s Style) IsZero() bool {
} }
// On returns s drawn on bg: s's foreground, bg's background (s's own where // On returns s drawn on bg: s's foreground, bg's background (s's own where
// bg's is transparent), and the attributes of both // bg's is transparent), and the attributes of both. On a reverse bg with no
// color of its own, s keeps only its background, with the terminal's own
// foreground and bg's styles: a reversed cell's background is its foreground
// (a console's keeps its intensity), so mixed styles would break the bar.
func (s Style) On(bg Style) Style { func (s Style) On(bg Style) Style {
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle} out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 { if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
} }
if bg.Attr&terminal.AttrReverse != 0 && bg.Bg == (color.RGB{}) && bg.Attr&terminal.AttrBg256 == 0 {
out.Fg, out.Attr = color.RGB{}, terminal.AttrFgDefault|out.Attr&terminal.AttrBgColor|bg.Attr&terminal.AttrStyle
}
return out return out
} }
+25 -26
View File
@@ -1,19 +1,16 @@
package tui package tui
import ( import (
"strings"
"unicode/utf8" "unicode/utf8"
) )
// --- Length calculation --- // RuneLen returns s's width in cells, a rune each: Flush draws every rune in
// one column, a wide or joining one as U+FFFD
// RuneLen returns rune count, used as display width under the package-wide assumption of 1 cell per rune
// Wide (CJK), emoji, and combining characters are not handled
func RuneLen(s string) int { func RuneLen(s string) int {
return utf8.RuneCountInString(s) return utf8.RuneCountInString(s)
} }
// --- Truncation ---
// Truncate truncates string with … suffix if exceeds maxLen // Truncate truncates string with … suffix if exceeds maxLen
func Truncate(s string, maxLen int) string { func Truncate(s string, maxLen int) string {
if maxLen <= 0 { if maxLen <= 0 {
@@ -62,16 +59,12 @@ func TruncateMiddle(s string, maxLen int) string {
return Truncate(s, maxLen) return Truncate(s, maxLen)
} }
// Split remaining space between start and end
// Favor start slightly: (maxLen-1)/2 for start, rest for end
startLen := (maxLen - 1) / 2 startLen := (maxLen - 1) / 2
endLen := maxLen - 1 - startLen endLen := maxLen - 1 - startLen
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:]) return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
} }
// --- Padding ---
// PadRight pads string with spaces to width // PadRight pads string with spaces to width
func PadRight(s string, width int) string { func PadRight(s string, width int) string {
runes := []rune(s) runes := []rune(s)
@@ -116,15 +109,23 @@ func PadCenter(s string, width int) string {
return string(result) return string(result)
} }
// --- Text wrapping --- // WrapText wraps text to fit width: at each line break, at spaces, or, in a
// word longer than a line, after its last '.', ',', ':', '/', '[' or ']'
// WrapText wraps text at word boundaries to fit width // that fits (a path or a key such as a[0].b); a word without one is cut.
// Returns slice of lines, each no longer than width // Returns slice of lines, each no longer than width.
func WrapText(s string, width int) []string { func WrapText(s string, width int) []string {
if width <= 0 { if width <= 0 {
return nil return nil
} }
var lines []string
for line := range strings.SplitSeq(s, "\n") {
lines = append(lines, wrapLine(line, width)...)
}
return lines
}
// wrapLine wraps one line, s holding no line break
func wrapLine(s string, width int) []string {
runes := []rune(s) runes := []rune(s)
if len(runes) == 0 { if len(runes) == 0 {
return []string{""} return []string{""}
@@ -132,41 +133,42 @@ func WrapText(s string, width int) []string {
var lines []string var lines []string
lineStart := 0 lineStart := 0
lastSpace := -1 lastSpace, lastMark := -1, -1
for i := 0; i <= len(runes); i++ { for i := 0; i <= len(runes); i++ {
// Check if we need to wrap
if i-lineStart >= width || i == len(runes) { if i-lineStart >= width || i == len(runes) {
if i == len(runes) { if i == len(runes) {
// End of string
if lineStart < len(runes) { if lineStart < len(runes) {
lines = append(lines, string(runes[lineStart:])) lines = append(lines, string(runes[lineStart:]))
} }
break break
} }
// Need to wrap
wrapAt := i wrapAt := i
if lastSpace > lineStart { switch {
// Wrap at last space case lastSpace > lineStart:
wrapAt = lastSpace wrapAt = lastSpace
case lastMark > lineStart:
wrapAt = lastMark
} }
lines = append(lines, string(runes[lineStart:wrapAt])) lines = append(lines, string(runes[lineStart:wrapAt]))
// Skip space at wrap point
if wrapAt < len(runes) && runes[wrapAt] == ' ' { if wrapAt < len(runes) && runes[wrapAt] == ' ' {
lineStart = wrapAt + 1 lineStart = wrapAt + 1
} else { } else {
lineStart = wrapAt lineStart = wrapAt
} }
lastSpace = -1 lastSpace, lastMark = -1, -1
} }
// Track spaces for word wrapping // Track spaces for word wrapping, and the marks a long word breaks after
if i < len(runes) && runes[i] == ' ' { if i < len(runes) && runes[i] == ' ' {
lastSpace = i lastSpace = i
} }
if i < len(runes) && strings.ContainsRune(".,:/[]", runes[i]) {
lastMark = i + 1
}
} }
if len(lines) == 0 { if len(lines) == 0 {
@@ -176,8 +178,6 @@ func WrapText(s string, width int) []string {
return lines return lines
} }
// --- Repetition ---
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat // RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
func RepeatRune(r rune, n int) string { func RepeatRune(r rune, n int) string {
if n <= 0 { if n <= 0 {
@@ -189,4 +189,3 @@ func RepeatRune(r rune, n int) string {
} }
return string(runes) return string(runes)
} }
+3 -1
View File
@@ -258,12 +258,14 @@ func (t *TextFieldState) MoveToEnd() {
// --- Scroll management --- // --- Scroll management ---
// AdjustScroll updates scroll to keep cursor visible within viewport width // AdjustScroll updates scroll to keep the cursor visible within viewport
// width, and the viewport filled up to the end of the text
func (t *TextFieldState) AdjustScroll(viewportW int) { func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 { if viewportW <= 0 {
return return
} }
t.clampCursor() t.clampCursor()
t.Scroll = min(t.Scroll, max(0, len(t.Text)+1-viewportW))
if t.Cursor < t.Scroll { if t.Cursor < t.Scroll {
t.Scroll = t.Cursor t.Scroll = t.Cursor
} }
+2 -1
View File
@@ -31,12 +31,13 @@ type Glyphs struct {
Unfolded rune // an open group Unfolded rune // an open group
On, Off rune // a toggle On, Off rune // a toggle
Required rune // after a required field's label Required rune // after a required field's label
Line LineType // frames, rules and wires
} }
var ( var (
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'} GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'} GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'}
GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*'} GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*', Line: LineASCII}
) )
// DefaultTheme is a dark theme for true color and 256-color terminals // DefaultTheme is a dark theme for true color and 256-color terminals
+23
View File
@@ -1,5 +1,7 @@
package tui package tui
import "github.com/lixenwraith/terminal"
// ViewportScroll manages row-based scroll for content regions // ViewportScroll manages row-based scroll for content regions
// Distinct from ScrollState which is item-index based // Distinct from ScrollState which is item-index based
type ViewportScroll struct { type ViewportScroll struct {
@@ -117,3 +119,24 @@ func (v *ViewportScroll) EnsureRange(y, h int) {
} }
v.clamp() v.clamp()
} }
// Window shows content h rows tall through the region, from v's offset:
// draw paints a buffer as wide as the region and h tall, filled with bg, and
// the rows in view are copied. Window sets v's heights; a caller keeping a
// row in view sets them first and calls EnsureRange.
func (r Region) Window(h int, v *ViewportScroll, bg Style, draw func(Region)) {
h = max(0, h)
v.SetDimensions(h, r.H)
if r.W <= 0 || r.H <= 0 {
return
}
cells := make([]terminal.Cell, r.W*h)
full := NewRegion(cells, r.W, 0, 0, r.W, h)
full.FillStyle(bg)
draw(full)
shown := min(r.H, h-v.Offset)
for y := range shown {
copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(v.Offset+y)*r.W:][:r.W])
}
r.Sub(0, shown, r.W, r.H-shown).FillStyle(bg)
}
+221 -2
View File
@@ -1,6 +1,9 @@
package tui package tui
import ( import (
"fmt"
"runtime"
"slices"
"strings" "strings"
"testing" "testing"
@@ -34,12 +37,32 @@ func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor { if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
t.Errorf("on %+v: %+v", panel, c) t.Errorf("on %+v: %+v", panel, c)
} }
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr|terminal.AttrReverse { if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr&terminal.AttrBgColor|terminal.AttrFgDefault|terminal.AttrReverse {
t.Errorf("%+v on reverse: %+v", panel, on) t.Errorf("%+v on reverse: %+v", panel, on)
} }
} }
} }
// A reverse cursor row is one bar: its pointer, key, name, hint and blanks
// take one style, whatever each draws in off the row, where they keep it
func TestReverseCursorRowIsOneStyle(t *testing.T) {
s := NewOptionListState([]Option{{Name: "file", Hint: "rotating files", Key: 'f'}, {Name: "tcp", Key: 't'}})
s.Menu = true
for name, th := range map[string]Theme{"16 colors": Theme16, "mono": MonoTheme} {
r := canvas(40, 2, th)
r.OptionList(s, th)
for x, c := range r.Cells[:r.W] {
if first := r.Cells[0]; c.Fg != first.Fg || c.Bg != first.Bg || c.Attrs != first.Attrs {
t.Errorf("%s %q: column %d %+v, column 0 %+v", name, row(r, 0), x, c, first)
break
}
}
if c, a := r.Cells[r.W+2], th.Accent; c.Rune != 't' || c.Fg != a.Fg || c.Attrs&^terminal.AttrBgColor != a.Attr {
t.Errorf("%s %q: the key off the cursor row %+v, want %+v", name, row(r, 1), c, a)
}
}
}
// Every widget marks its selection with a glyph, so it reads with no color // Every widget marks its selection with a glyph, so it reads with no color
func TestSelectionChangesAGlyph(t *testing.T) { func TestSelectionChangesAGlyph(t *testing.T) {
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} { for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
@@ -50,7 +73,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
func selectionChangesAGlyph(t *testing.T, th Theme) { func selectionChangesAGlyph(t *testing.T, th Theme) {
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}}) options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
for name, draw := range map[string]func(r Region, on bool){ for name, draw := range map[string]func(r Region, on bool){
"choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) }, "radio": func(r Region, on bool) { r.Radio(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) }, "toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) }, "field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) }, "group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
@@ -248,3 +271,199 @@ func TestGroupSummarisesWhileFolded(t *testing.T) {
t.Fatalf("%q %q", row(folded, 0), row(open, 0)) t.Fatalf("%q %q", row(folded, 0), row(open, 0))
} }
} }
// A radio row too wide for its region shows the chosen option and those
// around it, a '…' on each side where others are hidden, and reports the
// columns drawn
func TestRadioKeepsTheChosenInView(t *testing.T) {
options := []string{"alpha", "beta", "gamma", "delta"}
for _, c := range []struct {
chosen, w int
want string
}{
{3, 40, "○ alpha ○ beta ○ gamma ● delta"},
{3, 30, "… ○ beta ○ gamma ● delta"},
{3, 16, "… ● delta"},
{3, 7, "… ● de…"},
{0, 17, "● alpha …"},
{1, 19, "○ alpha ● beta …"},
{1, 25, "… ● beta ○ gamma …"},
} {
r := canvas(c.w, 1, MonoTheme)
if n := r.Radio(0, 0, options, c.chosen, MonoTheme); row(r, 0) != c.want || n != min(c.w, RuneLen(c.want)) {
t.Errorf("%d chosen in %d columns: %q, %d drawn; want %q", c.chosen, c.w, row(r, 0), n, c.want)
}
}
}
// A field's view pulls back when its text shrinks, so no column is left
// blank while text is hidden, and a muted '…' marks text out of view, never
// under the cursor
func TestTextInputPullsBackAndMarksHiddenText(t *testing.T) {
f := NewTextFieldState(strings.Repeat("abcdefghij", 2))
draw := func(focused bool) string {
r := canvas(10, 1, MonoTheme)
r.TextInput(f, "", focused, MonoTheme)
return row(r, 0)
}
if got := draw(true); got != "…cdefghij" {
t.Fatalf("the cursor after 20 in 10 columns: %q", got)
}
for range 8 {
f.HandleKey(terminal.KeyBackspace, 0, 0)
}
if got := draw(true); got != "…efghijab" {
t.Fatalf("12 left: %q, scroll %d", got, f.Scroll)
}
f.HandleKey(terminal.KeyHome, 0, 0)
if got := draw(true); got != "abcdefghi…" {
t.Fatalf("the cursor at the start: %q", got)
}
}
// A field's label leaves the value at least half the row; label width 0
// stacks the label on its own row, whole, and the value under it
func TestFieldLeavesTheValueHalfTheRow(t *testing.T) {
for _, w := range []int{12, 30, 80} {
r := canvas(w, 3, MonoTheme)
if v, rows := r.Field(0, 40, Field{Label: "password_file"}, false, MonoTheme); v.W < (w-3)/2 || rows != 1 {
t.Errorf("%d columns: value %d wide, %d rows", w, v.W, rows)
}
r = canvas(w, 3, MonoTheme)
v, rows := r.Field(0, 0, Field{Label: "password_file"}, false, MonoTheme)
if label := Truncate("password_file", w-2); row(r, 0) != " "+label || v.Y != 1 || v.W != w-4 || rows != 2 {
t.Errorf("%d columns stacked: %q, value at row %d, %d wide, %d rows", w, row(r, 0), v.Y, v.W, rows)
}
}
}
// Text wraps at each line break, keeping an empty line, and a word longer
// than the line breaks after a '.', ',', ':', '/', '[' or ']'
func TestWrapTextBreaksAtLinesAndMarks(t *testing.T) {
for text, want := range map[string][]string{
"ab\ncd ef\n\ngh": {"ab", "cd ef", "", "gh"},
"key: /etc/logwisp/very/long/path.pem": {"key:", "/etc/logwisp/very/", "long/path.pem"},
} {
if got := WrapText(text, map[bool]int{true: 5, false: 20}[strings.HasPrefix(text, "ab")]); !slices.Equal(got, want) {
t.Errorf("%q: %q, want %q", text, got, want)
}
}
}
// Wrapping is linear in the lines: 4000 lines allocate under 1 MiB
func TestWrapTextIsLinearInLines(t *testing.T) {
text := strings.Repeat("x\n", 4000)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
lines := WrapText(text, 80)
runtime.ReadMemStats(&after)
if len(lines) != 4001 || after.TotalAlloc-before.TotalAlloc > 1<<20 {
t.Fatalf("%d lines, %d bytes allocated", len(lines), after.TotalAlloc-before.TotalAlloc)
}
}
// Where the hint column would be narrow, the list shows names alone and the
// cursor's hint wraps below them, in rows Rows counts; wide, hints sit beside
func TestNarrowOptionListShowsTheCursorsHint(t *testing.T) {
s := NewOptionListState([]Option{{Name: "pipe", Hint: "standard input to standard output"}, {Name: "tail", Hint: "follow files"}})
s.Cursor = 1
for w, want := range map[int][]string{
60: {"/ type to filter", " pipe standard input to standard output", "▸ tail follow files"},
20: {"/ type to filter", " pipe", "▸ tail", "", " follow files"},
} {
r := canvas(w, s.Rows(w), MonoTheme)
r.OptionList(s, MonoTheme)
var got []string
for y := range r.H {
got = append(got, row(r, y))
}
if !slices.Equal(got, want) {
t.Errorf("%d columns: %q, want %q", w, got, want)
}
}
}
// A menu has no filter: a key picks its option, and typing filters nothing
func TestMenuPicksByKey(t *testing.T) {
s := NewOptionListState([]Option{{Name: "run", Key: 'r'}, {Name: "command", Key: 'c'}})
s.Menu = true
if i, ok := s.Pick('c'); !ok || i != 1 {
t.Fatalf("c: %d, %v", i, ok)
}
if _, ok := s.Pick('x'); ok || s.HandleKey(terminal.KeyRune, 'x', 0) || s.Paste("r") || s.Filter.Value() != "" {
t.Fatalf("x picked or filtered: %q", s.Filter.Value())
}
r := canvas(20, s.Rows(20), MonoTheme)
r.OptionList(s, MonoTheme)
if r.H != 2 || row(r, 0) != "▸ r run" || row(r, 1) != " c command" {
t.Fatalf("%d rows: %q %q", r.H, row(r, 0), row(r, 1))
}
}
// A frame's inside is the region within its border and padding, so a frame
// sized to what it holds ends on it; the border takes the glyphs' line type
func TestFrameHoldsItsInside(t *testing.T) {
ascii := MonoTheme
ascii.Glyphs = GlyphsASCII
for _, c := range []struct {
th Theme
want [3]string
}{{MonoTheme, [3]string{"┌─ T ───┐", "│ abcde │", "└───────┘"}}, {ascii, [3]string{"+- T ---+", "| abcde |", "+-------+"}}} {
r := canvas(9, 3, c.th)
in := r.Frame("T", c.th)
in.TextStyled(0, 0, "abcde", c.th.Text)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != c.want || in.W != 5 || in.H != 1 {
t.Errorf("%q, inside %dx%d", got, in.W, in.H)
}
}
}
// A rule keeps its title; the hint at its end goes first when both do not fit
func TestRuleKeepsTheTitleBeforeTheHint(t *testing.T) {
for w, want := range map[int]string{40: "── file · source ────────────── ⏎ edit ─", 24: "── file · source ───────"} {
r := canvas(w, 1, MonoTheme)
if r.Rule(0, "file · source", "⏎ edit", MonoTheme); row(r, 0) != want {
t.Errorf("%d columns: %q", w, row(r, 0))
}
}
}
// Wires join where they meet: each cell draws the arms laid through it,
// whatever order they were laid in
func TestWiresJoinWhereTheyMeet(t *testing.T) {
g := NewWires(5, 3)
g.V(4, 1, 2)
g.H(1, 0, 4)
g.V(2, 0, 2)
g.H(9, 0, 9) // off the grid
for line, want := range map[LineType][3]string{LineSingle: {" │", "──┼─┐", " │ │"}, LineASCII: {" |", "--+-+", " | |"}} {
r := canvas(5, 3, MonoTheme)
r.DrawWires(g, line, MonoTheme.Border)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != want {
t.Errorf("%d: %q, want %q", line, got, want)
}
}
}
// A window shows the content's rows from the scroll offset, and fills what
// the content does not reach; a window with no columns draws nothing
func TestWindowShowsTheRowsInView(t *testing.T) {
r := canvas(6, 3, MonoTheme)
var v ViewportScroll
v.SetDimensions(10, r.H)
v.EnsureRange(7, 1)
content := func(c Region) {
for y := range c.H {
c.TextStyled(0, y, fmt.Sprintf("row %d", y), MonoTheme.Text)
}
}
r.Window(10, &v, MonoTheme.Text, content)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 5", "row 6", "row 7"} {
t.Fatalf("rows 5-7: %q", got)
}
r.Window(2, &v, MonoTheme.Text, content)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 0", "row 1", ""} {
t.Fatalf("two rows of content: %q", got)
}
r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge
}
+56
View File
@@ -0,0 +1,56 @@
package terminal
import "time"
// sendLocked queues what the operation in progress rendered and wakes the writer.
// Caller MUST hold mu.
func (t *termImpl) sendLocked() {
buf := &t.output.buf
if buf.Len() == 0 {
return
}
t.queued = append(t.queued, buf.Bytes()...)
buf.Reset()
select {
case t.wake <- struct{}{}:
default:
}
}
// writeLoop is the only writer of terminal output after Init. It returns once Fini
// has queued the restoring sequences and they are written; a terminal that never
// takes them leaves it in its write, and Fini stops waiting.
func (t *termImpl) writeLoop() {
defer close(t.writerDone)
for {
select {
case <-t.wake:
t.drain()
case <-t.quit:
t.drain()
return
}
}
}
// drain writes what is queued until nothing is, and draws a frame held while a
// write was out once that write returns. The write runs without mu, so whoever
// draws meanwhile finds writeSince set and holds its frame instead of waiting.
func (t *termImpl) drain() {
t.mu.Lock()
for {
if len(t.queued) == 0 {
t.releaseLocked()
}
if len(t.queued) == 0 {
break
}
t.writing, t.queued = t.queued, t.writing[:0]
t.writeSince.Store(time.Now().UnixNano())
t.mu.Unlock()
_ = t.backend.Write(t.writing) // a failed write is lost, as one through a buffered writer was
t.writeSince.Store(0)
t.mu.Lock()
}
t.mu.Unlock()
}