Compare commits
9
Commits
v0.2.0
...
97f66b1f1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f66b1f1c | ||
|
|
a8805d9be1
|
||
|
|
bc1021a454
|
||
|
|
01a81100bb
|
||
|
|
d89e331752
|
||
|
|
b62a7d08d1
|
||
|
|
0919d2a6b1
|
||
|
|
c657af606d
|
||
|
|
7d1c3b24a0
|
No files matched your search
@@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
|
||||
|
||||
Terminal (interface)
|
||||
└── 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
|
||||
└── Backend (interface)
|
||||
├── 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.
|
||||
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
|
||||
|
||||
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
|
||||
is dropped to prevent resize-race corruption. The next frame (built at the new
|
||||
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
|
||||
redraw — required after any external process writes to the terminal.
|
||||
|
||||
Auto-wrap is disabled during the session, making the bottom-right cell writable
|
||||
without scroll side effects.
|
||||
Output is written on a goroutine of its own, so no call waits on the terminal.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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 |
|
||||
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips |
|
||||
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure |
|
||||
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow |
|
||||
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight |
|
||||
| `Scale(c, factor)` | channel multiply | dim/brighten |
|
||||
| `Grayscale(c)` | Rec. 601 luma | desaturation |
|
||||
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
|
||||
- `Blend(dst, src, alpha)`: linear interpolation, standard transparency
|
||||
- `Add(dst, src, alpha)`: saturating add, bright accumulation, clips
|
||||
- `Screen(dst, src, alpha)`: `1-(1-d)(1-s)`, lightens, never clips
|
||||
- `Overlay(dst, src, alpha)`: multiply/screen split at 0.5, contrast, keeps dst structure
|
||||
- `SoftLight(dst, src, intensity)`: Perez soft light, gentle tint/glow
|
||||
- `Max(dst, src, alpha)`: per-channel max, non-additive highlight
|
||||
- `Scale(c, factor)`: channel multiply, dim/brighten
|
||||
- `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
|
||||
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`, ...),
|
||||
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
|
||||
(`ModShift`, `ModAlt`, `ModCtrl`)
|
||||
(`ModShift`, `ModAlt`, `ModCtrl`), `Action` (`KeyPress`, and `KeyRepeat` and
|
||||
`KeyRelease` while releases are reported; see below)
|
||||
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
|
||||
`MouseAction` (press/release/move/drag), modifiers. Enable via
|
||||
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
|
||||
@@ -213,12 +233,13 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
|
||||
- `EventPaste` — `Text` holds a whole paste, as sent, control characters
|
||||
included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste
|
||||
arrives as keys, its newlines pressing Enter.
|
||||
- `EventKeyReport` — the outcome of a `SetKeyReport` ask: `Report` and `Source`
|
||||
- `EventFocus` — `Focused`, while releases come through the terminal's protocol
|
||||
- `EventError`, `EventClosed`
|
||||
|
||||
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
|
||||
|
||||
### Key releases
|
||||
|
||||
A terminal reports presses: a key held down arrives as more presses at the
|
||||
keyboard's repeat rate, and nothing says when it comes up. A game that needs
|
||||
holds asks for releases:
|
||||
|
||||
```go
|
||||
term.SetKeyReport(terminal.KeyReportReleases, terminal.KeySourceConsole|terminal.KeySourceProtocol)
|
||||
```
|
||||
|
||||
The ask returns at once. An `EventKeyReport` follows with what the terminal
|
||||
reports now (`Report`) and where releases come from (`Source`), so a game
|
||||
learns the outcome of every ask and can fall back, to taps for one, where
|
||||
there are none. `SetKeyReport(terminal.KeyReportPresses, 0)` gives the source
|
||||
back. Until a game asks for releases, nothing is sent to the terminal or
|
||||
switched, and every key event is a press, as before.
|
||||
|
||||
While releases are reported:
|
||||
|
||||
- Each key event carries its `Action`: `KeyPress`, `KeyRepeat` while held, and
|
||||
`KeyRelease`. A release carries what its press carried, paired by physical
|
||||
key, so letting go of Shift first still releases `'A'`, not `'a'`.
|
||||
- A key event the terminal names no physical key for (text from an input
|
||||
method, a key its protocol leaves out) can't be held: its release follows
|
||||
at once.
|
||||
- Every hold ends. Focus leaving, the console switching away, a change of
|
||||
source and `Fini` release the keys held with `Lost` set, since their own
|
||||
releases won't arrive. A press of a key already held means its release was
|
||||
lost (a terminal's own mode can swallow one): the old hold ends, `Lost`,
|
||||
before the new press.
|
||||
|
||||
Releases come from the first of these sources a game allows that the
|
||||
terminal has:
|
||||
|
||||
- `KeySourceProtocol`: the terminal's key event protocol, CSI u with event
|
||||
types. The package asks the terminal whether it has the protocol (`CSI ? u`,
|
||||
fenced by a DA1 request every terminal answers), pushes its flags and
|
||||
confirms what they came to before reporting releases; it goes by the
|
||||
terminal's answers, never by `TERM` or the terminal's name. Alacritty 0.13
|
||||
and later is the reference (Enter, Tab and Backspace release from 0.15).
|
||||
Other terminals that answer the same way (foot, Ghostty, WezTerm with its
|
||||
opt-in setting, recent Konsole) should work but are untested. Works over ssh.
|
||||
tmux, screen and mosh don't pass the protocol through, and VTE terminals and
|
||||
xterm don't have it; there the answer is presses after one round trip. The
|
||||
flags are popped when the game goes back to presses, at `Fini`, and in
|
||||
`EmergencyReset`.
|
||||
|
||||
With the protocol on, every key arrives as an escape code, and each press
|
||||
reads as the key a legacy terminal sends for it reads, so a game can keep
|
||||
the protocol on for its whole run: Shift goes into the character, Ctrl with
|
||||
a character becomes its control key, and Alt stays a modifier. Ctrl with a
|
||||
character that has no control key (Ctrl+1) keeps `ModCtrl`, which a legacy
|
||||
terminal can't send. Escape no longer waits out the ESC timeout. Lone
|
||||
modifier keys, media keys and F13 and up report nothing. Alacritty reports no
|
||||
keys while its own Vi mode, search or hints run; a key held then ends on its
|
||||
next press, or as focus leaves.
|
||||
|
||||
`KeyHolds` is the pairing on its own, for a host that reads its platform's key
|
||||
events and builds `Event`s itself (`KeySourceHost`): it gives each release its
|
||||
press's key and turns the contradictions above into `Lost` releases.
|
||||
|
||||
A Terminal over a caller's backend (`NewWithBackend`) reports presses.
|
||||
|
||||
## WASM
|
||||
|
||||
WASM builds bridge to xterm.js via JS globals:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
|
||||
@@ -9,7 +9,7 @@ var (
|
||||
// CSI sequences
|
||||
csi = []byte("\x1b[")
|
||||
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")
|
||||
|
||||
// Cursor control
|
||||
@@ -17,9 +17,10 @@ var (
|
||||
csiCursorShow = []byte("\x1b[?25h")
|
||||
csiCursorPos = []byte("\x1b[") // followed by row;colH
|
||||
|
||||
// Screen modes
|
||||
csiAltScreenEnter = []byte("\x1b[?1049h")
|
||||
csiAltScreenExit = []byte("\x1b[?1049l")
|
||||
// Screen modes; the alternate screen also turns off the terminal's own bidi
|
||||
// reordering (ECMA-48 BDSM, mode 8), so right-to-left text keeps its cells
|
||||
csiAltScreenEnter = []byte("\x1b[?1049h\x1b[8l")
|
||||
csiAltScreenExit = []byte("\x1b[?1049l\x1b[8h")
|
||||
// DECAWM: Auto-Wrap Mode
|
||||
// ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner
|
||||
csiAutoWrapOn = []byte("\x1b[?7h")
|
||||
@@ -48,7 +49,7 @@ var (
|
||||
|
||||
// writeInt writes an integer without allocation
|
||||
// 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 {
|
||||
n = 0
|
||||
}
|
||||
@@ -67,7 +68,6 @@ func writeInt(w *bufio.Writer, n int) {
|
||||
w.WriteByte(byte(n%10) + '0')
|
||||
return
|
||||
}
|
||||
// Fallback for >999 (rare)
|
||||
var buf [5]byte
|
||||
i := 4
|
||||
for n > 0 {
|
||||
@@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
writeInt(w, y+1)
|
||||
w.WriteByte(';')
|
||||
@@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) {
|
||||
}
|
||||
|
||||
// writeCursorForward writes cursor forward N positions
|
||||
func writeCursorForward(w *bufio.Writer, n int) {
|
||||
func writeCursorForward(w *bytes.Buffer, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@ func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// keySources: the key protocol, which the terminal is asked about
|
||||
func (b *unixBackend) keySources() KeySource { return KeySourceProtocol }
|
||||
|
||||
func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
|
||||
b.resizeStopCh = make(chan struct{})
|
||||
b.resizeDoneCh = make(chan struct{})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
// the terminal's own default colors
|
||||
// - Double-buffered output with cell-level diffing
|
||||
// - Raw input parsing with escape sequence handling and bracketed paste
|
||||
// - Key releases on request, through the terminal's key event protocol
|
||||
// (Alacritty's), and presses on terminals without it
|
||||
// - Drawing on /dev/tty when stdin or stdout is redirected (Unix)
|
||||
// - Resize detection (SIGWINCH on Unix, callback on WASM)
|
||||
// - Clean terminal restoration on exit/panic
|
||||
@@ -39,8 +41,11 @@
|
||||
//
|
||||
// # Performance
|
||||
//
|
||||
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells
|
||||
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls.
|
||||
// Output uses cell-level diffing. Only changed cells generate ANSI sequences.
|
||||
// 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.
|
||||
//
|
||||
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module github.com/lixenwraith/terminal
|
||||
|
||||
go 1.27.1
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
github.com/lixenwraith/color v0.1.3
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.0
|
||||
)
|
||||
@@ -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=
|
||||
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.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/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=
|
||||
@@ -16,10 +16,12 @@ type EventType uint8
|
||||
const (
|
||||
EventKey EventType = iota
|
||||
EventResize
|
||||
EventPaste // Bracketed paste, Text holds it
|
||||
EventMouse // SGR mouse reporting
|
||||
EventError // Read error
|
||||
EventClosed // Input closed
|
||||
EventPaste // Bracketed paste, Text holds it
|
||||
EventMouse // SGR mouse reporting
|
||||
EventError // Read error
|
||||
EventClosed // Input closed
|
||||
EventFocus // The terminal gained or lost focus, while it reports releases through its protocol
|
||||
EventKeyReport // What a SetKeyReport ask came to: Report through Source
|
||||
)
|
||||
|
||||
// Event represents a terminal input event
|
||||
@@ -28,16 +30,22 @@ type Event struct {
|
||||
Key Key
|
||||
Rune rune
|
||||
Modifiers Modifier
|
||||
Width int // For EventResize
|
||||
Height int // For EventResize
|
||||
Err error // For EventError
|
||||
Text string // For EventPaste: the pasted bytes as sent, controls included
|
||||
Action KeyAction // For EventKey: KeyPress unless the terminal reports releases
|
||||
Lost bool // For a KeyRelease the package made for a hold whose own release can't arrive
|
||||
Width int // For EventResize
|
||||
Height int // For EventResize
|
||||
Err error // For EventError
|
||||
Text string // For EventPaste: the pasted bytes as sent, controls included
|
||||
Focused bool // For EventFocus: whether the terminal now has focus
|
||||
Report KeyReport // For EventKeyReport: what the terminal now reports of each key
|
||||
Source KeySource // For EventKeyReport: the source releases come from, zero for presses
|
||||
|
||||
// Mouse event fields
|
||||
MouseX int
|
||||
MouseY int
|
||||
MouseBtn MouseButton
|
||||
MouseAction MouseAction
|
||||
Touch bool // A touch, from a host that reads one; terminals report none
|
||||
}
|
||||
|
||||
// inputReader handles raw stdin parsing
|
||||
@@ -57,6 +65,37 @@ type inputReader struct {
|
||||
paste []byte
|
||||
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
|
||||
running bool
|
||||
}
|
||||
@@ -66,6 +105,7 @@ func newInputReader(backend Backend) *inputReader {
|
||||
return &inputReader{
|
||||
backend: backend,
|
||||
eventCh: make(chan Event, 256),
|
||||
asks: make(chan keyAsk, 1),
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
buf: make([]byte, 0, 256),
|
||||
@@ -125,9 +165,12 @@ func (r *inputReader) readLoop() {
|
||||
}()
|
||||
|
||||
for {
|
||||
r.serviceKeys()
|
||||
|
||||
// Blocking read from backend
|
||||
data, err := r.backend.Read(r.stopCh)
|
||||
if err != nil {
|
||||
r.holds.ReleaseAll(r.sendEvent)
|
||||
r.sendEvent(Event{Type: EventError, Err: err})
|
||||
return
|
||||
}
|
||||
@@ -144,11 +187,12 @@ func (r *inputReader) readLoop() {
|
||||
// Timeout (Unix poll) or empty read: a pending ESC, or two, was
|
||||
// pressed alone; inside a paste it is text
|
||||
if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting {
|
||||
r.sendEvent(ev)
|
||||
r.sendKey(0, ev)
|
||||
r.buf = r.buf[:0]
|
||||
}
|
||||
select {
|
||||
case <-r.stopCh:
|
||||
r.holds.ReleaseAll(r.sendEvent)
|
||||
r.sendEvent(Event{Type: EventClosed})
|
||||
return
|
||||
default:
|
||||
@@ -186,6 +230,19 @@ func (r *inputReader) parseInput(data []byte) int {
|
||||
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 {
|
||||
end := bytes.Index(data[i:], pasteEnd)
|
||||
if end < 0 {
|
||||
@@ -210,7 +267,7 @@ func (r *inputReader) parseInput(data []byte) int {
|
||||
|
||||
// Fast path: printable ASCII
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
|
||||
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
@@ -229,8 +286,10 @@ func (r *inputReader) parseInput(data []byte) int {
|
||||
}
|
||||
|
||||
// Only emit if not a swallowed unknown sequence
|
||||
if ev.Key != KeyNone || ev.Type != EventKey {
|
||||
if ev.Type != EventKey {
|
||||
r.sendEvent(ev)
|
||||
} else if ev.Key != KeyNone {
|
||||
r.sendKey(0, ev)
|
||||
}
|
||||
i += consumed
|
||||
continue
|
||||
@@ -238,14 +297,14 @@ func (r *inputReader) parseInput(data []byte) int {
|
||||
|
||||
// Control characters
|
||||
if b < 0x20 {
|
||||
r.sendEvent(r.parseControl(b))
|
||||
r.sendKey(0, r.parseControl(b))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// DEL
|
||||
if b == 0x7f {
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyBackspace})
|
||||
r.sendKey(0, Event{Type: EventKey, Key: KeyBackspace})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
@@ -265,7 +324,7 @@ func (r *inputReader) parseInput(data []byte) int {
|
||||
}
|
||||
|
||||
rn, size := decodeRune(data[i:])
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rn})
|
||||
r.sendKey(0, Event{Type: EventKey, Key: KeyRune, Rune: rn})
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
@@ -321,7 +380,7 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) {
|
||||
return 0, Event{}
|
||||
case bytes.HasPrefix(rest, pasteStart):
|
||||
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)
|
||||
if n == 0 {
|
||||
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 0, Event{}
|
||||
// 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{}
|
||||
}
|
||||
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
|
||||
func (r *inputReader) parseCSI(data []byte) (int, Event) {
|
||||
if len(data) < 3 {
|
||||
n := csiLen(data)
|
||||
switch {
|
||||
case n == csiPartial:
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
// SGR mouse: ESC [ < Btn ; X ; Y M/m
|
||||
if data[2] == '<' {
|
||||
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
|
||||
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)
|
||||
}
|
||||
if b < 0x20 || b > 0x7e {
|
||||
return 0, Event{}
|
||||
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)))
|
||||
}
|
||||
end++
|
||||
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}
|
||||
}
|
||||
|
||||
// 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}
|
||||
if key, mod, ok := lookupCSI(data[2:n]); ok {
|
||||
return n, Event{Type: EventKey, Key: key, Modifiers: mod}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -408,7 +510,13 @@ func (r *inputReader) parseSS3(data []byte) (int, Event) {
|
||||
return 0, Event{}
|
||||
}
|
||||
if key, mod, ok := lookupSS3(data[2:3]); ok {
|
||||
return 3, Event{Type: EventKey, Key: key, Modifiers: mod}
|
||||
ev := Event{Type: EventKey, Key: key, Modifiers: mod}
|
||||
if r.decoding && key != KeyRune {
|
||||
// A terminal reporting by the protocol names this key too
|
||||
r.sendKey(protoNamed+uint32(key), ev)
|
||||
return 3, Event{Type: EventKey, Key: KeyNone}
|
||||
}
|
||||
return 3, ev
|
||||
}
|
||||
// Unknown SS3 - consume to prevent garbage
|
||||
return 3, Event{Type: EventKey, Key: KeyNone}
|
||||
@@ -483,31 +591,12 @@ func (r *inputReader) parseControl(b byte) Event {
|
||||
return Event{Type: EventKey, Key: KeyNone}
|
||||
}
|
||||
|
||||
// parseSGRMouse parses mouse SGR sequences
|
||||
func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
||||
// Format: ESC [ < Btn ; X ; Y M/m
|
||||
// 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]
|
||||
// parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from
|
||||
// the parameters and final byte; a malformed one is dropped as KeyNone
|
||||
func parseSGRMouse(params []byte, final byte) Event {
|
||||
btn, x, y, ok := parseSGRParams(params)
|
||||
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
|
||||
@@ -541,7 +630,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
||||
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
||||
}
|
||||
|
||||
if data[end] == 'M' {
|
||||
if final == 'M' {
|
||||
if isMotion {
|
||||
if ev.MouseBtn != MouseBtnNone {
|
||||
ev.MouseAction = MouseActionDrag
|
||||
@@ -567,7 +656,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
||||
ev.Modifiers |= ModCtrl
|
||||
}
|
||||
|
||||
return end + 1, ev
|
||||
return ev
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// sendKey sends a key event at the level the terminal reports. code names the
|
||||
// physical key the event came from, zero for one the terminal names none for:
|
||||
// at releases such a key can't be held, so its release follows at once.
|
||||
func (r *inputReader) sendKey(code uint32, ev Event) {
|
||||
switch {
|
||||
case r.report == KeyReportPresses:
|
||||
if ev.Action != KeyRelease {
|
||||
ev.Action = KeyPress
|
||||
r.sendEvent(ev)
|
||||
}
|
||||
case code == 0:
|
||||
ev.Action = KeyPress
|
||||
r.sendEvent(ev)
|
||||
ev.Action = KeyRelease
|
||||
r.sendEvent(ev)
|
||||
default:
|
||||
r.holds.Report(code, ev, r.sendEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// sendEvent sends an event to the channel, non-blocking
|
||||
func (r *inputReader) sendEvent(ev Event) {
|
||||
select {
|
||||
@@ -663,3 +772,30 @@ func decodeRune(data []byte) (rune, int) {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -90,6 +90,31 @@ const (
|
||||
ModCtrl Modifier = 1 << 2
|
||||
)
|
||||
|
||||
// KeyAction is what happened to a key. Only a terminal reporting releases (see
|
||||
// SetKeyReport) tells them apart; elsewhere every key event is a press, and a
|
||||
// key held down arrives as more presses at the keyboard's repeat rate.
|
||||
type KeyAction uint8
|
||||
|
||||
const (
|
||||
KeyPress KeyAction = iota // the key went down
|
||||
KeyRepeat // the keyboard repeated a key held down
|
||||
KeyRelease // the key came up
|
||||
)
|
||||
|
||||
// String returns the action's name
|
||||
func (a KeyAction) String() string {
|
||||
switch a {
|
||||
case KeyPress:
|
||||
return "Press"
|
||||
case KeyRepeat:
|
||||
return "Repeat"
|
||||
case KeyRelease:
|
||||
return "Release"
|
||||
default:
|
||||
return "None"
|
||||
}
|
||||
}
|
||||
|
||||
// escapeSequence maps escape sequences to keys
|
||||
// Key: sequence after ESC [ (e.g., "A" for up arrow)
|
||||
type escapeSequence struct {
|
||||
|
||||
+407
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
|
||||
"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 {
|
||||
front []Cell
|
||||
width int
|
||||
height int
|
||||
colorMode ColorMode
|
||||
writer *bufio.Writer
|
||||
buf bytes.Buffer
|
||||
|
||||
cursorX int
|
||||
cursorY int
|
||||
@@ -27,7 +30,7 @@ type outputBuffer struct {
|
||||
lastValid bool
|
||||
}
|
||||
|
||||
// writerAdapter adapts Backend to io.Writer for bufio
|
||||
// writerAdapter adapts Backend to io.Writer
|
||||
type writerAdapter struct {
|
||||
b Backend
|
||||
}
|
||||
@@ -40,17 +43,10 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// newOutputBuffer creates a new output buffer
|
||||
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
||||
// Use 128KB buffer for minimal calls to backend
|
||||
adapter := writerAdapter{b: backend}
|
||||
return &outputBuffer{
|
||||
writer: bufio.NewWriterSize(adapter, 131072),
|
||||
colorMode: colorMode,
|
||||
}
|
||||
func newOutputBuffer(colorMode ColorMode) *outputBuffer {
|
||||
return &outputBuffer{colorMode: colorMode}
|
||||
}
|
||||
|
||||
// resize updates buffer dimensions
|
||||
func (o *outputBuffer) resize(width, height int) {
|
||||
size := width * height
|
||||
if cap(o.front) < size {
|
||||
@@ -68,16 +64,31 @@ func (o *outputBuffer) resize(width, height int) {
|
||||
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 {
|
||||
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
|
||||
return a.Rune == b.Rune &&
|
||||
a.Bg == b.Bg &&
|
||||
a.Fg == b.Fg &&
|
||||
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) {
|
||||
if width != o.width || height != o.height {
|
||||
o.resize(width, height)
|
||||
@@ -88,18 +99,21 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
return
|
||||
}
|
||||
|
||||
w := o.writer
|
||||
w := &o.buf
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
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
|
||||
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
||||
rowEnd--
|
||||
}
|
||||
if rowEnd == 0 {
|
||||
continue // Entire row unchanged
|
||||
continue
|
||||
}
|
||||
|
||||
x := 0
|
||||
@@ -111,13 +125,11 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Found dirty cell - check for small gaps ahead to potentially merge segments
|
||||
segStart := x
|
||||
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 {
|
||||
// Find gap size
|
||||
gapStart := segEnd
|
||||
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
|
||||
gapStart++
|
||||
@@ -125,7 +137,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
gapSize := gapStart - segEnd
|
||||
|
||||
if gapSize == 0 {
|
||||
// No gap, extend to next unchanged
|
||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||
segEnd++
|
||||
}
|
||||
@@ -133,16 +144,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
}
|
||||
|
||||
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
|
||||
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment
|
||||
refCell := cells[rowStart+segEnd-1]
|
||||
|
||||
for k := 0; k < gapSize; 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 {
|
||||
gapCompatible = false
|
||||
break
|
||||
@@ -150,41 +160,30 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
}
|
||||
|
||||
if !gapCompatible {
|
||||
break // Gap has different style, cheaper to jump
|
||||
break
|
||||
}
|
||||
|
||||
// Check if there's more dirty content after gap
|
||||
if gapStart >= rowEnd {
|
||||
break // Gap extends to row end
|
||||
break
|
||||
}
|
||||
|
||||
// Small gap with content after - include gap in segment
|
||||
segEnd = gapStart
|
||||
// Continue to find more dirty cells
|
||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||
segEnd++
|
||||
}
|
||||
}
|
||||
|
||||
// Positions cursor to segment start
|
||||
o.moveCursorTo(w, segStart, y)
|
||||
|
||||
// Write segment [segStart, segEnd)
|
||||
for sx := segStart; sx < segEnd; sx++ {
|
||||
cidx := rowStart + sx
|
||||
c := cells[cidx]
|
||||
|
||||
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
||||
|
||||
// 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 {
|
||||
if r := c.Rune; r >= ' ' && r < 0x7f {
|
||||
w.WriteByte(byte(r))
|
||||
} else {
|
||||
w.WriteRune(r)
|
||||
w.WriteRune(oneColumn(r))
|
||||
}
|
||||
|
||||
o.front[cidx] = c
|
||||
@@ -197,10 +196,92 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
|
||||
w.Write(csiSGR0)
|
||||
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 {
|
||||
if n == 1 {
|
||||
return 3 // \x1b[C
|
||||
@@ -208,13 +289,12 @@ func cursorForwardCost(n int) int {
|
||||
return 3 + digitCount(n) // \x1b[nC
|
||||
}
|
||||
|
||||
// cursorAbsoluteCost returns byte cost of absolute cursor position
|
||||
func cursorAbsoluteCost(x, y int) int {
|
||||
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 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 {
|
||||
if n < 10 {
|
||||
return 1
|
||||
@@ -229,7 +309,7 @@ func digitCount(n int) int {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
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 {
|
||||
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
||||
}
|
||||
// Check what changed
|
||||
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
|
||||
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
|
||||
styleAttr := attr & AttrStyle
|
||||
@@ -310,7 +389,7 @@ var attrCodes = [...]struct {
|
||||
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
||||
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
||||
// 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 {
|
||||
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() {
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: 0}
|
||||
@@ -357,10 +435,10 @@ func (o *outputBuffer) forceFullRedraw() {
|
||||
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
|
||||
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
||||
w := o.writer
|
||||
w := &o.buf
|
||||
w.Write(csiSGR0)
|
||||
w.Write(csi)
|
||||
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.cursorValid = false
|
||||
w.Flush()
|
||||
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateCursor marks cursor position as unknown
|
||||
func (o *outputBuffer) invalidateCursor() {
|
||||
o.cursorValid = false
|
||||
}
|
||||
+210
-42
@@ -1,10 +1,12 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/color"
|
||||
"golang.org/x/term"
|
||||
@@ -61,10 +63,16 @@ type Terminal interface {
|
||||
// ColorMode returns detected color capability
|
||||
ColorMode() ColorMode
|
||||
|
||||
// Flush writes cell buffer to terminal
|
||||
// Cells are row-major: cells[y*width + x]
|
||||
// Flush hands a frame to the terminal; 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)
|
||||
|
||||
// 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(bg color.RGB)
|
||||
|
||||
@@ -90,6 +98,16 @@ type Terminal interface {
|
||||
// SetPasteMode turns bracketed paste on or off: on, a paste arrives as
|
||||
// one EventPaste rather than as keys, so its newlines don't press Enter
|
||||
SetPasteMode(on bool) error
|
||||
|
||||
// SetKeyReport asks for keys to be reported at level report. Releases come
|
||||
// through the first source in allow the terminal has, the console before
|
||||
// the protocol; KeyReportPresses gives up the source in use. It returns at
|
||||
// once, and an EventKeyReport follows with what the terminal reports now,
|
||||
// so a game learns the outcome of every ask; asks the reader has not yet
|
||||
// acted on are answered together, by the latest. A change of source ends
|
||||
// the holds it leaves with Lost releases. Until an ask for releases, nothing
|
||||
// is sent to the terminal or switched.
|
||||
SetKeyReport(report KeyReport, allow KeySource) error
|
||||
}
|
||||
|
||||
// ResizeEvent represents a terminal resize
|
||||
@@ -114,8 +132,29 @@ type termImpl struct {
|
||||
finalized bool
|
||||
mouseMode MouseMode
|
||||
paste bool
|
||||
keyProto bool // the key protocol's flags are pushed
|
||||
|
||||
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
|
||||
// stops reading holds that goroutine rather than whoever draws. queued is what
|
||||
// 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
|
||||
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
||||
// overrides both, as a --color=always or never would
|
||||
@@ -129,15 +168,27 @@ func New(colorMode ...ColorMode) Terminal {
|
||||
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,
|
||||
output: newOutputBuffer(c),
|
||||
syntheticCh: make(chan Event, 16),
|
||||
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
|
||||
@@ -159,6 +210,8 @@ func (t *termImpl) Init() error {
|
||||
|
||||
// Create input reader wrapping backend
|
||||
t.input = newInputReader(t.backend)
|
||||
t.input.sources = backendKeySources(t.backend)
|
||||
t.input.write = t.keyWrite
|
||||
|
||||
// Set resize handler on backend
|
||||
t.backend.SetResizeHandler(func(w, h int) {
|
||||
@@ -178,13 +231,16 @@ func (t *termImpl) Init() error {
|
||||
}
|
||||
})
|
||||
|
||||
go t.writeLoop()
|
||||
|
||||
// Enter alternate screen, hide cursor
|
||||
t.writeRaw(csiAltScreenEnter)
|
||||
t.writeRaw(csiCursorHide)
|
||||
out := &t.output.buf
|
||||
out.Write(csiAltScreenEnter)
|
||||
out.Write(csiCursorHide)
|
||||
|
||||
// DISABLE AUTO-WRAP
|
||||
// Prevents terminal scroll/wrap on bottom-right corner write
|
||||
t.writeRaw(csiAutoWrapOff)
|
||||
out.Write(csiAutoWrapOff)
|
||||
|
||||
// Invisible cursor
|
||||
t.cursorVisible.Store(false)
|
||||
@@ -196,6 +252,7 @@ func (t *termImpl) Init() error {
|
||||
|
||||
// Clear screen
|
||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||
t.sendLocked()
|
||||
|
||||
// Start input reader
|
||||
t.input.start()
|
||||
@@ -207,47 +264,66 @@ func (t *termImpl) Init() error {
|
||||
// Fini restores terminal state
|
||||
func (t *termImpl) Fini() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if !t.initialized || t.finalized {
|
||||
t.mu.Unlock()
|
||||
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
|
||||
if t.mouseMode != MouseModeNone {
|
||||
w := t.output.writer
|
||||
w.Write(csiMouseMotionOff)
|
||||
w.Write(csiMouseDragOff)
|
||||
w.Write(csiMouseClickOff)
|
||||
w.Write(csiMouseSGROff)
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
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 {
|
||||
t.input.stop()
|
||||
}
|
||||
t.mu.Lock()
|
||||
|
||||
// Show cursor
|
||||
t.writeRaw(csiCursorShow)
|
||||
w.Write(csiCursorShow)
|
||||
|
||||
// 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
|
||||
t.writeRaw(csiAutoWrapOn)
|
||||
w.Write(csiAutoWrapOn)
|
||||
|
||||
// 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
|
||||
t.backend.Fini()
|
||||
|
||||
t.finalized = true
|
||||
}
|
||||
|
||||
// Size returns current terminal dimensions
|
||||
@@ -265,8 +341,8 @@ func (t *termImpl) ColorMode() ColorMode {
|
||||
return t.output.colorMode
|
||||
}
|
||||
|
||||
// Flush writes cell buffer to terminal
|
||||
// Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||
// Flush renders cells and queues them for the terminal, or holds them while a write
|
||||
// is out. Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||
func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
@@ -275,6 +351,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||
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
|
||||
currW, currH := t.backend.Size()
|
||||
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.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
|
||||
@@ -293,7 +399,9 @@ func (t *termImpl) Clear(bg color.RGB) {
|
||||
return
|
||||
}
|
||||
|
||||
t.releaseLocked()
|
||||
t.output.clear(bg, 0)
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// SetCursorVisible shows/hides cursor
|
||||
@@ -309,13 +417,14 @@ func (t *termImpl) SetCursorVisible(visible bool) {
|
||||
return
|
||||
}
|
||||
|
||||
w := t.output.writer
|
||||
t.releaseLocked()
|
||||
w := &t.output.buf
|
||||
if visible {
|
||||
w.Write(csiCursorShow)
|
||||
} else {
|
||||
w.Write(csiCursorHide)
|
||||
}
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// MoveCursor positions cursor (0-indexed)
|
||||
@@ -327,6 +436,7 @@ func (t *termImpl) MoveCursor(x, y int) {
|
||||
return
|
||||
}
|
||||
|
||||
t.releaseLocked()
|
||||
if t.output != nil {
|
||||
t.output.invalidateCursor()
|
||||
}
|
||||
@@ -345,10 +455,9 @@ func (t *termImpl) MoveCursor(x, y int) {
|
||||
y = h - 1
|
||||
}
|
||||
|
||||
// Write through buffered writer to maintain stream order
|
||||
wBuf := t.output.writer
|
||||
writeCursorPos(wBuf, x, y)
|
||||
wBuf.Flush()
|
||||
// Through the queue, to keep the stream's order
|
||||
writeCursorPos(&t.output.buf, x, y)
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// Sync forces full redraw
|
||||
@@ -362,8 +471,10 @@ func (t *termImpl) Sync() {
|
||||
|
||||
// Clear terminal before full redraw
|
||||
// Diff-based rendering assumes physical terminal matches front buffer state
|
||||
t.releaseLocked()
|
||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||
t.output.forceFullRedraw()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// PollEvent blocks until next input event
|
||||
@@ -412,9 +523,11 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
||||
oldMode := t.mouseMode
|
||||
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 {
|
||||
w.Write(csiMouseMotionOff)
|
||||
}
|
||||
@@ -435,18 +548,34 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
||||
w.Write(csiMouseSGROn)
|
||||
}
|
||||
|
||||
// Enable new modes (click is base, drag extends, motion extends further)
|
||||
if mode&MouseModeClick != 0 && oldMode&MouseModeClick == 0 {
|
||||
// xterm, xterm.js, kitty, tmux and Konsole keep one mode, which setting
|
||||
// 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)
|
||||
}
|
||||
if mode&MouseModeDrag != 0 && oldMode&MouseModeDrag == 0 {
|
||||
if mode&MouseModeDrag != 0 && (oldMode&MouseModeDrag == 0 || top == MouseModeDrag) {
|
||||
w.Write(csiMouseDragOn)
|
||||
}
|
||||
if mode&MouseModeMotion != 0 && oldMode&MouseModeMotion == 0 {
|
||||
if mode&MouseModeMotion != 0 && (oldMode&MouseModeMotion == 0 || top == MouseModeMotion) {
|
||||
w.Write(csiMouseMotionOn)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -460,18 +589,53 @@ func (t *termImpl) SetPasteMode(on bool) error {
|
||||
}
|
||||
t.paste = on
|
||||
t.input.pasteMode.Store(on)
|
||||
w := t.output.writer
|
||||
t.releaseLocked()
|
||||
w := &t.output.buf
|
||||
if on {
|
||||
w.Write(csiPasteOn)
|
||||
} else {
|
||||
w.Write(csiPasteOff)
|
||||
}
|
||||
return w.Flush()
|
||||
t.sendLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeRaw writes raw bytes to output
|
||||
func (t *termImpl) writeRaw(data []byte) {
|
||||
t.backend.Write(data)
|
||||
// keyProtoPushed is whether a Terminal has the key protocol's flags pushed, for
|
||||
// EmergencyReset to pop
|
||||
var keyProtoPushed atomic.Bool
|
||||
|
||||
// keyWrite queues seq, the input reader's request, for the terminal unless Fini
|
||||
// has begun; pushed is whether the protocol's flags are pushed once it is
|
||||
// written, which Fini and EmergencyReset undo
|
||||
func (t *termImpl) keyWrite(seq []byte, pushed bool) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.initialized || t.finalized {
|
||||
return false
|
||||
}
|
||||
t.releaseLocked()
|
||||
t.output.buf.Write(seq)
|
||||
t.sendLocked()
|
||||
// A terminal that takes a request for its own (an old st restores the
|
||||
// cursor on CSI ? u) has moved it as far as the next frame knows
|
||||
t.output.invalidateCursor()
|
||||
t.keyProto = pushed
|
||||
keyProtoPushed.Store(pushed)
|
||||
return true
|
||||
}
|
||||
|
||||
// SetKeyReport hands the ask to the input reader, which answers it
|
||||
func (t *termImpl) SetKeyReport(report KeyReport, allow KeySource) error {
|
||||
if report > KeyReportReleases {
|
||||
return fmt.Errorf("terminal: unknown key report %d", report)
|
||||
}
|
||||
t.mu.Lock()
|
||||
ready := t.initialized && !t.finalized
|
||||
t.mu.Unlock()
|
||||
if ready {
|
||||
t.input.askKeys(report, allow)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmergencyReset attempts to restore terminal to sane state
|
||||
@@ -491,6 +655,10 @@ func EmergencyReset(w io.Writer) {
|
||||
w.Write(csiMouseClickOff)
|
||||
w.Write(csiMouseSGROff)
|
||||
w.Write(csiPasteOff)
|
||||
if keyProtoPushed.Load() {
|
||||
w.Write(csiKeyPop)
|
||||
w.Write(csiFocusOff)
|
||||
}
|
||||
|
||||
// Write sequences to provided writer
|
||||
w.Write(csiCursorShow)
|
||||
|
||||
+288
-16
@@ -3,6 +3,7 @@ package terminal
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -18,6 +19,7 @@ type fakeBackend struct {
|
||||
out bytes.Buffer
|
||||
chunks []string
|
||||
pause time.Duration
|
||||
gate chan struct{} // when set, a write waits until it is closed
|
||||
}
|
||||
|
||||
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) Write(p []byte) error {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
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"},
|
||||
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
||||
} {
|
||||
f := &fakeBackend{}
|
||||
o := newOutputBuffer(f, c.mode)
|
||||
o := newOutputBuffer(c.mode)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A cell is drawn as one glyph: a control character, C0 or C1, that a
|
||||
// pasted value carried in, or no rune at all, is drawn as a space, never
|
||||
// sent to the terminal
|
||||
// A cell is drawn as one glyph: a control character, C0 or C1, a format one
|
||||
// (a bidi control, a zero-width space) or a line separator that a pasted
|
||||
// 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) {
|
||||
f := &fakeBackend{}
|
||||
o := newOutputBuffer(f, ColorMode256)
|
||||
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1)
|
||||
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
||||
o := newOutputBuffer(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)
|
||||
if got, want := o.buf.String(), "\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)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +153,92 @@ func TestDoubleEscape(t *testing.T) {
|
||||
|
||||
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
|
||||
type input struct {
|
||||
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
|
||||
func TestPasteModeReachesTheReader(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||
term.output = newOutputBuffer(f, ColorMode256)
|
||||
term := newTerm(f, ColorMode256)
|
||||
if err := term.Init(); err != nil {
|
||||
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
|
||||
// background rather than painting it black
|
||||
// The terminal's own bidi reordering is off while the session runs, paste
|
||||
// mode ends with it, and Init clears to the terminal's own background rather
|
||||
// than painting it black
|
||||
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||
term.output = newOutputBuffer(f, ColorModeTrueColor)
|
||||
term := newTerm(f, ColorModeTrueColor)
|
||||
if err := term.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -205,4 +310,171 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
|
||||
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
@@ -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` /
|
||||
`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
|
||||
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:
|
||||
|
||||
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`,
|
||||
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
|
||||
`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
|
||||
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
|
||||
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,
|
||||
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437`
|
||||
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets.
|
||||
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection
|
||||
always changes a glyph (a radio mark, a pointer, the focus mark), so it reads
|
||||
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
|
||||
th := tui.DefaultTheme
|
||||
@@ -136,7 +141,7 @@ value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholde
|
||||
y += rows
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
- `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.
|
||||
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty.
|
||||
or the help while focused, wrapped, and returns the region for the value.
|
||||
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`,
|
||||
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
|
||||
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.
|
||||
- `Group` draws a foldable group's header, and its summary while folded.
|
||||
- `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
|
||||
|
||||
@@ -229,7 +247,9 @@ Pure logic, no rendering — usable independently:
|
||||
- `ScrollState` — item-index scrolling with selection
|
||||
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
|
||||
- `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
|
||||
keyed state, hierarchical → flat visible-node list
|
||||
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
|
||||
@@ -239,8 +259,8 @@ Pure logic, no rendering — usable independently:
|
||||
|
||||
## Notes
|
||||
|
||||
- Width calculations count runes, not terminal columns; East Asian wide
|
||||
characters and combining marks are not width-aware.
|
||||
- Width calculations count runes, one column each, as `Flush` draws a wide or
|
||||
joining rune as U+FFFD.
|
||||
- Zero-value `color.RGB` in style fields generally means "inherit"
|
||||
(widget default or row background) — check specific widget docs. A zero
|
||||
background with no Bg color bits is transparent: the cell keeps the
|
||||
|
||||
+127
-50
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lixenwraith/color"
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
@@ -14,26 +16,39 @@ const (
|
||||
LineRounded // ╭─╮│╰╯
|
||||
LineHeavy // ┏━┓┃┗┛
|
||||
LineNone // spaces (invisible border with padding)
|
||||
LineASCII // +-+|++, for a terminal outside UTF-8
|
||||
LineDashed // ╭╌╮╎╰╯
|
||||
)
|
||||
|
||||
// boxChars contains box drawing character sets indexed by LineType
|
||||
var boxChars = [...][6]rune{
|
||||
LineSingle: {'┌', '─', '┐', '│', '└', '┘'},
|
||||
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'},
|
||||
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'},
|
||||
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'},
|
||||
LineNone: {' ', ' ', ' ', ' ', ' ', ' '},
|
||||
// lineChars are each LineType's runes by the arms that meet in a cell: up 1,
|
||||
// right 2, down 4, left 8, so corners, tees and crossings all join
|
||||
var lineChars = [...]string{
|
||||
LineSingle: " │─└││┌├─┘─┴┐┤┬┼",
|
||||
LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬",
|
||||
LineRounded: " │─╰││╭├─╯─┴╮┤┬┼",
|
||||
LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋",
|
||||
LineNone: " ",
|
||||
LineASCII: " |-+||++-+-+++++",
|
||||
LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼",
|
||||
}
|
||||
|
||||
const (
|
||||
boxTL = 0 // top-left
|
||||
boxH = 1 // horizontal
|
||||
boxTR = 2 // top-right
|
||||
boxV = 3 // vertical
|
||||
boxBL = 4 // bottom-left
|
||||
boxBR = 5 // bottom-right
|
||||
boxTL = 6 // top-left
|
||||
boxH = 10 // horizontal
|
||||
boxTR = 12 // top-right
|
||||
boxV = 5 // vertical
|
||||
boxBL = 3 // bottom-left
|
||||
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 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,
|
||||
// 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) {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if line >= LineType(len(boxChars)) {
|
||||
line = LineSingle
|
||||
chars := line.runes()
|
||||
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 {
|
||||
c := off + y
|
||||
if c < 0 || c >= totalH {
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case 0:
|
||||
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)
|
||||
switch c := off + y; {
|
||||
case c < 0 || c >= totalH:
|
||||
case c == 0 || c == totalH-1:
|
||||
left, right := chars[boxTL], chars[boxTR]
|
||||
if c > 0 {
|
||||
left, right = chars[boxBL], chars[boxBR]
|
||||
}
|
||||
r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s)
|
||||
edge(y, left, right)
|
||||
default:
|
||||
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||
edge(y, chars[boxV], chars[boxV])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,10 +112,7 @@ func (r Region) HLine(y int, line LineType, fg color.RGB) {
|
||||
if y < 0 || y >= r.H {
|
||||
return
|
||||
}
|
||||
if line >= LineType(len(boxChars)) {
|
||||
line = LineSingle
|
||||
}
|
||||
ch := boxChars[line][boxH]
|
||||
ch := line.runes()[boxH]
|
||||
for x := 0; x < r.W; x++ {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if line >= LineType(len(boxChars)) {
|
||||
line = LineSingle
|
||||
}
|
||||
ch := boxChars[line][boxV]
|
||||
ch := line.runes()[boxV]
|
||||
for y := 0; y < r.H; y++ {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if line >= LineType(len(boxChars)) {
|
||||
line = LineSingle
|
||||
}
|
||||
|
||||
hChar := boxChars[line][boxH]
|
||||
hChar := line.runes()[boxH]
|
||||
|
||||
// Fill with horizontal line
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-12
@@ -2,20 +2,51 @@ package tui
|
||||
|
||||
import "github.com/lixenwraith/terminal"
|
||||
|
||||
// Choice draws options in a row at (x, y), the chosen one bracketed in the
|
||||
// accent style and the rest muted ("raw [txt] json"), and returns the
|
||||
// columns it drew
|
||||
func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int {
|
||||
start := x
|
||||
for i, o := range options {
|
||||
open, close, style := " ", " ", th.Muted
|
||||
if i == chosen {
|
||||
open, close, style = "[", "]", th.Accent
|
||||
// Radio draws options as radio buttons in a row at (x, y): the chosen one
|
||||
// with the On glyph in the accent style, the rest with Off, muted ("○ raw
|
||||
// ● txt ○ json"); chosen -1 marks none. Where the row does not fit, it shows
|
||||
// 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
|
||||
}
|
||||
r.TextStyled(x, y, open+o+close, style)
|
||||
x += RuneLen(o) + 2
|
||||
return w + 2*min(lo, 1) + 3*min(len(options)-hi, 1)
|
||||
}
|
||||
return x - start
|
||||
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
|
||||
if lo > 0 {
|
||||
r.TextStyled(x, y, "…", th.Muted)
|
||||
x += 2
|
||||
}
|
||||
for i := lo; i < hi; i++ {
|
||||
mark, style := th.Glyphs.Off, th.Muted
|
||||
if i == chosen {
|
||||
mark, style = th.Glyphs.On, th.Accent
|
||||
}
|
||||
r.TextStyled(x, y, Truncate(string(mark)+" "+options[i], max(0, r.W-x)), style)
|
||||
x += RuneLen(options[i]) + 4
|
||||
}
|
||||
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),
|
||||
|
||||
+1
-5
@@ -203,11 +203,7 @@ func (r Region) docSection(y, x, w int, label string, opts DocOpts) {
|
||||
r.TextStyled(x, y, label, opts.HeaderStyle)
|
||||
return
|
||||
}
|
||||
line := opts.Rule
|
||||
if line >= LineType(len(boxChars)) {
|
||||
line = LineSingle
|
||||
}
|
||||
ch := boxChars[line][boxH]
|
||||
ch := opts.Rule.runes()[boxH]
|
||||
for i := range w {
|
||||
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
|
||||
}
|
||||
|
||||
+29
-8
@@ -36,36 +36,49 @@ type Field struct {
|
||||
|
||||
// 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
|
||||
// focused. It returns the region for the value beside the label, where the
|
||||
// caller draws a TextInput, Choice or Toggle, and the rows it used.
|
||||
// focused, wrapped. The label column leaves the value half the row; labelW 0
|
||||
// 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) {
|
||||
label := th.Muted
|
||||
if focused {
|
||||
label = th.Text
|
||||
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
|
||||
if f.Required {
|
||||
name = Truncate(name, labelW-2)
|
||||
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
|
||||
}
|
||||
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
|
||||
if note == "" && focused {
|
||||
note, style = f.Help, th.Muted
|
||||
}
|
||||
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)
|
||||
return r.Sub(x, y, r.W-x, 1), 2
|
||||
lines := WrapText(note, r.W-x)
|
||||
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
|
||||
// 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) {
|
||||
if r.W < 1 || r.H < 1 {
|
||||
return
|
||||
@@ -75,7 +88,7 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
|
||||
bg = th.Input
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
x, ch := state.Cursor-state.Scroll, ' '
|
||||
if x < len(text) {
|
||||
|
||||
+84
-24
@@ -10,17 +10,24 @@ import (
|
||||
type Option struct {
|
||||
Name string // what is chosen
|
||||
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
|
||||
// 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 {
|
||||
Options []Option
|
||||
Filter TextFieldState
|
||||
Cursor int // index into Matches
|
||||
Scroll int // first match shown
|
||||
Cursor int // index into Matches
|
||||
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
|
||||
func NewOptionListState(options []Option) *OptionListState {
|
||||
return &OptionListState{Options: options}
|
||||
@@ -59,7 +66,7 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
|
||||
s.Cursor--
|
||||
case key == terminal.KeyDown:
|
||||
s.Cursor++
|
||||
case !s.Filter.HandleKey(key, r, mod):
|
||||
case s.Menu || !s.Filter.HandleKey(key, r, mod):
|
||||
return false
|
||||
case s.Filter.Value() != filter:
|
||||
s.Cursor = 0
|
||||
@@ -68,49 +75,102 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
|
||||
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
|
||||
func (s *OptionListState) Paste(text string) bool {
|
||||
if !s.Filter.Paste(text) {
|
||||
if s.Menu || !s.Filter.Paste(text) {
|
||||
return false
|
||||
}
|
||||
s.Cursor = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// OptionList draws the filter on the first row and the matches below it,
|
||||
// the cursor row marked with the pointer on the Selected background, each
|
||||
// hint muted in a column after the names
|
||||
// layout is how the list fits w columns: the names' width, the column the
|
||||
// names start at, and the rows below the list that hold the cursor's hint
|
||||
// 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) {
|
||||
if r.W < 4 || r.H < 2 {
|
||||
return
|
||||
}
|
||||
r.TextStyled(0, 0, "/", th.Muted)
|
||||
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
|
||||
|
||||
top := 1
|
||||
if s.Menu {
|
||||
top = 0
|
||||
} else {
|
||||
r.TextStyled(0, 0, "/", th.Muted)
|
||||
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
|
||||
}
|
||||
m := s.Matches()
|
||||
rows := r.H - 1
|
||||
if len(m) == 0 {
|
||||
r.TextStyled(2, 1, "no match", th.Muted)
|
||||
r.TextStyled(2, top, "no match", th.Muted)
|
||||
return
|
||||
}
|
||||
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows))
|
||||
s.Scroll = max(s.Scroll, s.Cursor-rows+1)
|
||||
|
||||
nameW := 0
|
||||
for _, i := range m {
|
||||
nameW = max(nameW, RuneLen(s.Options[i].Name))
|
||||
}
|
||||
nameW = min(nameW, r.W/2)
|
||||
nameW, nameX, hintRows := s.layout(r.W)
|
||||
rows := max(1, r.H-top-hintRows)
|
||||
s.Scroll = AdjustScroll(s.Cursor, ClampScroll(s.Scroll, rows, len(m)), rows, len(m))
|
||||
for y := 0; y < rows && s.Scroll+y < len(m); 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
|
||||
if s.Scroll+y == s.Cursor {
|
||||
bg = th.Selected
|
||||
row.FillStyle(th.Text.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))
|
||||
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg))
|
||||
if s.Menu && o.Key != 0 {
|
||||
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
@@ -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
|
||||
// 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
+25
-26
@@ -1,19 +1,16 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// --- Length calculation ---
|
||||
|
||||
// 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
|
||||
// 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
|
||||
func RuneLen(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
// --- Truncation ---
|
||||
|
||||
// Truncate truncates string with … suffix if exceeds maxLen
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
@@ -62,16 +59,12 @@ func TruncateMiddle(s string, maxLen int) string {
|
||||
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
|
||||
endLen := maxLen - 1 - startLen
|
||||
|
||||
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
|
||||
}
|
||||
|
||||
// --- Padding ---
|
||||
|
||||
// PadRight pads string with spaces to width
|
||||
func PadRight(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
@@ -116,15 +109,23 @@ func PadCenter(s string, width int) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// --- Text wrapping ---
|
||||
|
||||
// WrapText wraps text at word boundaries to fit width
|
||||
// Returns slice of lines, each no longer than width
|
||||
// WrapText wraps text to fit width: at each line break, at spaces, or, in a
|
||||
// word longer than a line, after its last '.', ',', ':', '/', '[' or ']'
|
||||
// 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.
|
||||
func WrapText(s string, width int) []string {
|
||||
if width <= 0 {
|
||||
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)
|
||||
if len(runes) == 0 {
|
||||
return []string{""}
|
||||
@@ -132,41 +133,42 @@ func WrapText(s string, width int) []string {
|
||||
|
||||
var lines []string
|
||||
lineStart := 0
|
||||
lastSpace := -1
|
||||
lastSpace, lastMark := -1, -1
|
||||
|
||||
for i := 0; i <= len(runes); i++ {
|
||||
// Check if we need to wrap
|
||||
if i-lineStart >= width || i == len(runes) {
|
||||
if i == len(runes) {
|
||||
// End of string
|
||||
if lineStart < len(runes) {
|
||||
lines = append(lines, string(runes[lineStart:]))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Need to wrap
|
||||
wrapAt := i
|
||||
if lastSpace > lineStart {
|
||||
// Wrap at last space
|
||||
switch {
|
||||
case lastSpace > lineStart:
|
||||
wrapAt = lastSpace
|
||||
case lastMark > lineStart:
|
||||
wrapAt = lastMark
|
||||
}
|
||||
|
||||
lines = append(lines, string(runes[lineStart:wrapAt]))
|
||||
|
||||
// Skip space at wrap point
|
||||
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
|
||||
lineStart = wrapAt + 1
|
||||
} else {
|
||||
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] == ' ' {
|
||||
lastSpace = i
|
||||
}
|
||||
if i < len(runes) && strings.ContainsRune(".,:/[]", runes[i]) {
|
||||
lastMark = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
@@ -176,8 +178,6 @@ func WrapText(s string, width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// --- Repetition ---
|
||||
|
||||
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
|
||||
func RepeatRune(r rune, n int) string {
|
||||
if n <= 0 {
|
||||
@@ -189,4 +189,3 @@ func RepeatRune(r rune, n int) string {
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
@@ -258,12 +258,14 @@ func (t *TextFieldState) MoveToEnd() {
|
||||
|
||||
// --- 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) {
|
||||
if viewportW <= 0 {
|
||||
return
|
||||
}
|
||||
t.clampCursor()
|
||||
t.Scroll = min(t.Scroll, max(0, len(t.Text)+1-viewportW))
|
||||
if t.Cursor < t.Scroll {
|
||||
t.Scroll = t.Cursor
|
||||
}
|
||||
|
||||
+8
-7
@@ -25,18 +25,19 @@ type Theme struct {
|
||||
// it reads without color. A text console's font takes GlyphsCP437, a
|
||||
// terminal outside UTF-8 GlyphsASCII.
|
||||
type Glyphs struct {
|
||||
Focus rune // beside the focused control
|
||||
Pointer rune // the cursor row of a list
|
||||
Folded rune // a closed group
|
||||
Unfolded rune // an open group
|
||||
On, Off rune // a toggle
|
||||
Required rune // after a required field's label
|
||||
Focus rune // beside the focused control
|
||||
Pointer rune // the cursor row of a list
|
||||
Folded rune // a closed group
|
||||
Unfolded rune // an open group
|
||||
On, Off rune // a toggle
|
||||
Required rune // after a required field's label
|
||||
Line LineType // frames, rules and wires
|
||||
}
|
||||
|
||||
var (
|
||||
GlyphsUnicode = 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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package tui
|
||||
|
||||
import "github.com/lixenwraith/terminal"
|
||||
|
||||
// ViewportScroll manages row-based scroll for content regions
|
||||
// Distinct from ScrollState which is item-index based
|
||||
type ViewportScroll struct {
|
||||
@@ -117,3 +119,24 @@ func (v *ViewportScroll) EnsureRange(y, h int) {
|
||||
}
|
||||
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
@@ -1,6 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
func TestSelectionChangesAGlyph(t *testing.T) {
|
||||
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
|
||||
@@ -50,7 +73,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
||||
func selectionChangesAGlyph(t *testing.T, th Theme) {
|
||||
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
|
||||
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) },
|
||||
"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) },
|
||||
@@ -248,3 +271,199 @@ func TestGroupSummarisesWhileFolded(t *testing.T) {
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user