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