Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc22a9bf00 | ||
|
|
d4cc9f2407 | ||
|
|
e9aeed0be0 | ||
|
|
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,20 +224,24 @@ 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
|
||||||
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
|
`KeyRelease` while releases are reported; see below)
|
||||||
`MouseAction` (press/release/move/drag), modifiers. Enable via
|
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (the three buttons, the
|
||||||
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
|
wheel's four directions, back and forward), `MouseAction`
|
||||||
|
(press/release/move/drag; a wheel step is a press with no release),
|
||||||
|
modifiers. Enable via `SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR
|
||||||
|
protocol only.
|
||||||
- `EventResize` — new `Width`/`Height`
|
- `EventResize` — new `Width`/`Height`
|
||||||
- `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 +249,116 @@ 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:
|
||||||
|
|
||||||
|
- `KeySourceConsole`: the kernel console's own keyboard, on the console the
|
||||||
|
game runs on: a Linux VT (`/dev/ttyN`) or FreeBSD's vt(4) (`/dev/ttyvN`).
|
||||||
|
While releases are reported the package switches the keyboard to raw
|
||||||
|
keycodes, which the kernel allows without root (Linux's `K_MEDIUMRAW` on
|
||||||
|
the game's controlling tty, FreeBSD's `K_CODE` on a console the game has
|
||||||
|
open), and switches it back when the game goes back to presses, at `Fini`
|
||||||
|
and in `EmergencyReset`. Each key types what the console types for it
|
||||||
|
cooked, by the console's own keymap read at the switch. Switching to
|
||||||
|
another console ends the holds. The console's mouse reports pause while the
|
||||||
|
keyboard is raw, and a mouse paste then reads as keys. Never over ssh,
|
||||||
|
where the keyboard is the far terminal's.
|
||||||
|
|
||||||
|
On Linux that is `loadkeys` layouts, dead keys and compose, the locks and
|
||||||
|
their lights, Alt+F*n* and Alt+arrows switching consoles, and cursor keys
|
||||||
|
with modifiers from Linux 7.1. Left out while raw: codes typed with Alt on
|
||||||
|
the keypad, braille, Ctrl+Alt+Del, and Scroll Lock, which only lets stopped
|
||||||
|
output go on.
|
||||||
|
|
||||||
|
On FreeBSD it is `kbdmap` layouts and their accents, the locks and their
|
||||||
|
lights, Alt+F*n* and Print Screen switching consoles, codes typed with left
|
||||||
|
Alt on the keypad, and Alt as ESC before a character (right Alt too with
|
||||||
|
`kern.vt.enable_altgr=0`). Left out while raw: the machine's keys
|
||||||
|
(Ctrl+Alt+Del, the debugger, suspend), Shift+Insert's paste, and Scroll
|
||||||
|
Lock. With Scroll Lock on, the console's history mode takes every key, so
|
||||||
|
the keyboard stays as it is and the ask reports presses; an ask after
|
||||||
|
Scroll Lock goes off tries again. syscons (`kern.vty=sc`) has no console
|
||||||
|
source.
|
||||||
|
|
||||||
|
Before the first switch the package runs the game's binary again as
|
||||||
|
`console-guard`, which switches the keyboard back, restores the tty and
|
||||||
|
shows the cursor if the game dies where nothing of its own runs: `kill -9`,
|
||||||
|
the OOM killer, a Go fatal error. A game leading its console's session
|
||||||
|
(on Linux, run with `exec` from the login shell or by an autologin) takes
|
||||||
|
the console with it as it dies; the guard then opens it again to restore
|
||||||
|
it. The package's `init` turns the guard's run aside before `main`; the
|
||||||
|
`init`s of packages initialized before it run in the guard too, so keep
|
||||||
|
side effects out of them. On FreeBSD the guard goes by the game's name, so
|
||||||
|
`pkill -9` by that name ends it too: kill the game by its PID. Should the
|
||||||
|
guard be killed as well, switch the keyboard back from elsewhere: on Linux
|
||||||
|
`sudo kbd_mode -u -C /dev/ttyN`; FreeBSD has no command for it, so build
|
||||||
|
one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '#include <sys/ioctl.h>\n#include <sys/kbio.h>\nint main(void) { return ioctl(0, KDSKBMODE, K_XLATE); }\n' |
|
||||||
|
cc -x c -o /tmp/kbdxlate - && /tmp/kbdxlate < /dev/ttyvN
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type unixBackend struct {
|
|||||||
outFd int
|
outFd int
|
||||||
tty *os.File // the controlling terminal, opened for a redirected end
|
tty *os.File // the controlling terminal, opened for a redirected end
|
||||||
oldTerm *term.State
|
oldTerm *term.State
|
||||||
|
console keyConsole // the console input comes from, nil off one
|
||||||
|
|
||||||
resizeStopCh chan struct{}
|
resizeStopCh chan struct{}
|
||||||
resizeDoneCh chan struct{}
|
resizeDoneCh chan struct{}
|
||||||
@@ -56,12 +57,14 @@ func (b *unixBackend) Init() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cooked, _ := unix.IoctlGetTermios(b.inFd, tcgets)
|
||||||
old, err := term.MakeRaw(b.inFd)
|
old, err := term.MakeRaw(b.inFd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.closeTTY()
|
b.closeTTY()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
b.oldTerm = old
|
b.oldTerm = old
|
||||||
|
b.console = openConsole(b.in, cooked)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,10 @@ func (b *unixBackend) Fini() {
|
|||||||
<-b.resizeDoneCh
|
<-b.resizeDoneCh
|
||||||
b.resizeStopCh = nil
|
b.resizeStopCh = nil
|
||||||
}
|
}
|
||||||
|
if b.console != nil {
|
||||||
|
b.console.close()
|
||||||
|
b.console = nil
|
||||||
|
}
|
||||||
if b.oldTerm != nil {
|
if b.oldTerm != nil {
|
||||||
term.Restore(b.inFd, b.oldTerm)
|
term.Restore(b.inFd, b.oldTerm)
|
||||||
}
|
}
|
||||||
@@ -160,6 +167,18 @@ func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keySources: the console's keyboard on a console, and the key protocol,
|
||||||
|
// which the terminal is asked about
|
||||||
|
func (b *unixBackend) keySources() KeySource {
|
||||||
|
if b.console != nil {
|
||||||
|
return KeySourceConsole | KeySourceProtocol
|
||||||
|
}
|
||||||
|
return KeySourceProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyConsole is the console input comes from, nil off one
|
||||||
|
func (b *unixBackend) keyConsole() keyConsole { return b.console }
|
||||||
|
|
||||||
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,148 @@
|
|||||||
|
// keys — shows each key event the terminal reports, for trying key releases
|
||||||
|
// where a game runs: a Linux or FreeBSD console, Alacritty, ssh, tmux.
|
||||||
|
//
|
||||||
|
// Keys: F2 asks for releases or gives them back · Ctrl+C quits
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
term := terminal.New()
|
||||||
|
|
||||||
|
// Raw-mode safety net: restore the terminal before re-panicking.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
terminal.EmergencyReset(os.Stdout)
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "terminal init:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
|
||||||
|
v := &viewer{term: term}
|
||||||
|
v.ask(true)
|
||||||
|
for {
|
||||||
|
v.draw()
|
||||||
|
ev := term.PollEvent()
|
||||||
|
switch ev.Type {
|
||||||
|
case terminal.EventClosed, terminal.EventError:
|
||||||
|
return
|
||||||
|
case terminal.EventKeyReport:
|
||||||
|
v.report = ev.Report.String()
|
||||||
|
if ev.Source != 0 {
|
||||||
|
v.report += " from " + ev.Source.String()
|
||||||
|
}
|
||||||
|
v.log(" report: " + v.report)
|
||||||
|
case terminal.EventFocus:
|
||||||
|
v.log(fmt.Sprintf(" focus: %v", ev.Focused))
|
||||||
|
case terminal.EventKey:
|
||||||
|
v.log(describe(ev))
|
||||||
|
if ev.Action == terminal.KeyRelease {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ev.Key {
|
||||||
|
case terminal.KeyCtrlC:
|
||||||
|
return
|
||||||
|
case terminal.KeyF2:
|
||||||
|
if ev.Action == terminal.KeyPress {
|
||||||
|
v.ask(!v.releases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewer is the screen: what the terminal reports and the events so far
|
||||||
|
type viewer struct {
|
||||||
|
term terminal.Terminal
|
||||||
|
releases bool
|
||||||
|
report string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask asks for releases, from the console or the terminal's protocol, or
|
||||||
|
// gives them back
|
||||||
|
func (v *viewer) ask(releases bool) {
|
||||||
|
v.releases, v.report = releases, "asking"
|
||||||
|
if releases {
|
||||||
|
v.term.SetKeyReport(terminal.KeyReportReleases, terminal.KeySourceConsole|terminal.KeySourceProtocol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.term.SetKeyReport(terminal.KeyReportPresses, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// log adds a line, keeping the last screenful
|
||||||
|
func (v *viewer) log(line string) {
|
||||||
|
v.lines = append(v.lines, line)
|
||||||
|
if len(v.lines) > 500 {
|
||||||
|
v.lines = v.lines[len(v.lines)-500:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw shows the report above the events, the latest at the bottom
|
||||||
|
func (v *viewer) draw() {
|
||||||
|
w, h := v.term.Size()
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cells := make([]terminal.Cell, w*h)
|
||||||
|
text := func(y int, s string, attr terminal.Attr) {
|
||||||
|
x := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if x >= w {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cells[y*w+x] = terminal.Cell{Rune: r, Attrs: attr | terminal.AttrFgDefault | terminal.AttrBgDefault}
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
for ; x < w; x++ {
|
||||||
|
cells[y*w+x] = terminal.Cell{Rune: ' ', Attrs: attr | terminal.AttrFgDefault | terminal.AttrBgDefault}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text(0, "keys: "+v.report, terminal.AttrBold|terminal.AttrReverse)
|
||||||
|
if h > 1 {
|
||||||
|
text(1, "F2 asks for releases or gives them back · Ctrl+C quits", terminal.AttrDim)
|
||||||
|
}
|
||||||
|
shown := v.lines[max(0, len(v.lines)-(h-2)):]
|
||||||
|
for y := 2; y < h; y++ {
|
||||||
|
line := ""
|
||||||
|
if i := y - 2; i < len(shown) {
|
||||||
|
line = shown[i]
|
||||||
|
}
|
||||||
|
text(y, line, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
v.term.Flush(cells, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe is a key event in one line: its action, key, modifiers and
|
||||||
|
// whether its release was made up for a hold whose own can't arrive
|
||||||
|
func describe(ev terminal.Event) string {
|
||||||
|
key := terminal.KeyName(ev.Key)
|
||||||
|
if ev.Key == terminal.KeyRune {
|
||||||
|
key = fmt.Sprintf("%q", ev.Rune)
|
||||||
|
}
|
||||||
|
var mods []string
|
||||||
|
for _, m := range []struct {
|
||||||
|
mod terminal.Modifier
|
||||||
|
name string
|
||||||
|
}{{terminal.ModCtrl, "ctrl"}, {terminal.ModAlt, "alt"}, {terminal.ModShift, "shift"}} {
|
||||||
|
if ev.Modifiers&m.mod != 0 {
|
||||||
|
mods = append(mods, m.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("%-8v %s", ev.Action, strings.Join(append(mods, key), "+"))
|
||||||
|
if ev.Lost {
|
||||||
|
line += " (lost)"
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The FreeBSD console's ioctls (sys/kbio.h, sys/consio.h), which
|
||||||
|
// golang.org/x/sys/unix does not define
|
||||||
|
const (
|
||||||
|
kdGetKeyboardMode = 0x40044B06 // KDGKBMODE
|
||||||
|
kdSetKeyboardMode = 0x20044B07 // KDSKBMODE
|
||||||
|
kdGetLockState = 0x40044B13 // KDGKBSTATE
|
||||||
|
kdSetLockState = 0x20044B14 // KDSKBSTATE
|
||||||
|
gioKeymap = 0x20006B06 // GIO_KEYMAP
|
||||||
|
gioDeadKeymap = 0x20006B08 // GIO_DEADKEYMAP
|
||||||
|
vtActivate = 0x20047605 // VT_ACTIVATE
|
||||||
|
vtGetActive = 0x40047607 // VT_GETACTIVE
|
||||||
|
vtGetIndex = 0x40047608 // VT_GETINDEX
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyboard modes
|
||||||
|
const (
|
||||||
|
kXlate = 1 // K_XLATE
|
||||||
|
kCode = 2 // K_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
// keymapT is GIO_KEYMAP's keymap_t
|
||||||
|
type keymapT struct {
|
||||||
|
nKeys uint16
|
||||||
|
keys [fbsdNumKeys]fbsdKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// accentmapT is GIO_DEADKEYMAP's accentmap_t
|
||||||
|
type accentmapT struct {
|
||||||
|
nAccs uint16
|
||||||
|
accents [fbsdDeadKeys]fbsdAccent
|
||||||
|
}
|
||||||
|
|
||||||
|
// freebsdConsole is the vt(4) console the game runs on, its keyboard switched
|
||||||
|
// to keycodes (K_CODE) while releases come from it
|
||||||
|
type freebsdConsole struct {
|
||||||
|
tty *os.File
|
||||||
|
fd int
|
||||||
|
vt int // the console's number, from 1
|
||||||
|
termios *unix.Termios // the termios the backend found, which the guard restores
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
isRaw bool
|
||||||
|
km fbsdKeymap
|
||||||
|
kb fbsdKeys
|
||||||
|
away bool // another console had the keyboard when last asked
|
||||||
|
guard *keyGuard
|
||||||
|
}
|
||||||
|
|
||||||
|
// openConsole is the console tty is, nil unless it is one: a vt(4) console,
|
||||||
|
// not a pty, a serial line or syscons
|
||||||
|
func openConsole(tty *os.File, cooked *unix.Termios) keyConsole {
|
||||||
|
if vty, err := unix.Sysctl("kern.vty"); cooked == nil || err != nil || vty != "vt" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fd := int(tty.Fd())
|
||||||
|
vt, err := unix.IoctlGetInt(fd, vtGetIndex)
|
||||||
|
if err != nil || vt < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := unix.IoctlGetInt(fd, kdGetKeyboardMode); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := &freebsdConsole{tty: tty, fd: fd, vt: vt, termios: cooked}
|
||||||
|
c.kb.km = &c.km
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare reads the keymap and starts the guard; a keyboard some other
|
||||||
|
// program has raw is left alone
|
||||||
|
func (c *freebsdConsole) prepare() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isRaw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
mode, err := unix.IoctlGetInt(c.fd, kdGetKeyboardMode)
|
||||||
|
if err != nil || mode != kXlate || !c.readKeymap() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.guard == nil {
|
||||||
|
if c.guard, err = startGuard(c.tty); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKeymap reads the keyboard's keymap and accents, and whether vt takes
|
||||||
|
// right Alt for AltGr
|
||||||
|
func (c *freebsdConsole) readKeymap() bool {
|
||||||
|
var km keymapT
|
||||||
|
if ioctlPtr(c.fd, gioKeymap, unsafe.Pointer(&km)) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.km.keys = km.keys
|
||||||
|
var am accentmapT
|
||||||
|
c.km.nAccents = 0
|
||||||
|
if ioctlPtr(c.fd, gioDeadKeymap, unsafe.Pointer(&am)) == nil {
|
||||||
|
c.km.accents, c.km.nAccents = am.accents, min(int(am.nAccs), fbsdDeadKeys)
|
||||||
|
}
|
||||||
|
altGr, err := unix.SysctlUint32("kern.vt.enable_altgr")
|
||||||
|
c.km.altGr = err != nil || altGr != 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw arms the guard and switches the keyboard to keycodes, which vt allows
|
||||||
|
// whoever has the console open. Scroll Lock on is the console's history mode,
|
||||||
|
// which takes every key but its own: the keyboard stays as it is then.
|
||||||
|
func (c *freebsdConsole) raw() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isRaw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.guard == nil || !c.guard.arm(kXlate, c.termios) {
|
||||||
|
c.dropGuard()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
locks, err := unix.IoctlGetInt(c.fd, kdGetLockState)
|
||||||
|
if err != nil || locks&fbsdSLKED != 0 || unix.IoctlSetInt(c.fd, kdSetKeyboardMode, kCode) != nil {
|
||||||
|
c.guard.disarm()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// What was typed cooked would read as keycodes
|
||||||
|
flushInput(c.fd)
|
||||||
|
c.kb.seed(uint32(locks))
|
||||||
|
c.away, c.isRaw = false, true
|
||||||
|
rawConsoleKeys.Store(&consoleRestore{fd: c.fd, mode: kXlate, guard: c.guard})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooked switches the keyboard back
|
||||||
|
func (c *freebsdConsole) cooked() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cookedLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookedLocked switches the keyboard back, dropping keycodes not yet read; a
|
||||||
|
// console that refuses keeps the guard armed. Caller MUST hold mu.
|
||||||
|
func (c *freebsdConsole) cookedLocked() {
|
||||||
|
if !c.isRaw {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.isRaw = false
|
||||||
|
rawConsoleKeys.Store(nil)
|
||||||
|
if unix.IoctlSetInt(c.fd, kdSetKeyboardMode, kXlate) == nil {
|
||||||
|
flushInput(c.fd)
|
||||||
|
c.guard.disarm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys translates raw keycodes: one byte each, bit 7 set on release
|
||||||
|
func (c *freebsdConsole) keys(data []byte, key func(code uint32, action KeyAction, typed []byte)) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.isRaw {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, b := range data {
|
||||||
|
code, down := int(b&0x7f), b&0x80 == 0
|
||||||
|
if code == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
action := KeyRelease
|
||||||
|
if down {
|
||||||
|
action = KeyPress
|
||||||
|
if c.kb.isDown(code) {
|
||||||
|
action = KeyRepeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typed, act := c.kb.key(code, down)
|
||||||
|
c.act(act)
|
||||||
|
key(uint32(code), action, typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// act does what a key asks of the console: the locks it toggled kept with the
|
||||||
|
// keyboard and its lights, and a switch to another console. Caller MUST hold
|
||||||
|
// mu.
|
||||||
|
func (c *freebsdConsole) act(a consoleAct) {
|
||||||
|
if a.leds {
|
||||||
|
unix.IoctlSetInt(c.fd, kdSetLockState, int(c.kb.state&fbsdLockMask))
|
||||||
|
}
|
||||||
|
if a.vt != 0 {
|
||||||
|
c.switchTo(a.vt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchTo switches to console vt, or the one before or after the one on
|
||||||
|
// screen, as vt does for a key cooked; vt itself refuses a console no one has
|
||||||
|
// open
|
||||||
|
func (c *freebsdConsole) switchTo(vt int) {
|
||||||
|
cur, err := unix.IoctlGetInt(c.fd, vtGetActive)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch vt {
|
||||||
|
case vtNext:
|
||||||
|
vt = cur%fbsdWindows + 1
|
||||||
|
case vtPrev:
|
||||||
|
vt = (cur+fbsdWindows-2)%fbsdWindows + 1
|
||||||
|
}
|
||||||
|
if vt != cur {
|
||||||
|
unix.IoctlSetInt(c.fd, vtActivate, vt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// front is whether this console is on screen; back on it, translation starts
|
||||||
|
// again from the console's locks, its modifiers having moved elsewhere
|
||||||
|
func (c *freebsdConsole) front() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
active, err := unix.IoctlGetInt(c.fd, vtGetActive)
|
||||||
|
if !c.isRaw || err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
front := active == c.vt
|
||||||
|
if front && c.away {
|
||||||
|
locks, _ := unix.IoctlGetInt(c.fd, kdGetLockState)
|
||||||
|
c.kb.seed(uint32(locks))
|
||||||
|
}
|
||||||
|
c.away = !front
|
||||||
|
return front
|
||||||
|
}
|
||||||
|
|
||||||
|
// close switches the keyboard back and ends the guard
|
||||||
|
func (c *freebsdConsole) close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cookedLocked()
|
||||||
|
c.dropGuard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropGuard ends the guard, which the next prepare starts again. Caller MUST
|
||||||
|
// hold mu.
|
||||||
|
func (c *freebsdConsole) dropGuard() {
|
||||||
|
if c.guard != nil {
|
||||||
|
c.guard.close()
|
||||||
|
c.guard = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushInput drops the console's pending input as its keyboard switches:
|
||||||
|
// keycodes would read as characters, and characters as keycodes
|
||||||
|
func flushInput(fd int) {
|
||||||
|
unix.IoctlSetPointerInt(fd, unix.TIOCFLUSH, unix.TCIFLUSH) // FREAD, which TCIFLUSH equals here
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardExecutable is this program's binary, which runs again as the guard
|
||||||
|
func guardExecutable() (string, error) { return os.Executable() }
|
||||||
|
|
||||||
|
// nameGuard leaves the guard's process name as it is: FreeBSD names a process
|
||||||
|
// after its binary, and the guard's arguments name it
|
||||||
|
func nameGuard() {}
|
||||||
|
|
||||||
|
// consolePath is the console's device, for the guard to open it again
|
||||||
|
func consolePath(tty *os.File) string {
|
||||||
|
vt, err := unix.IoctlGetInt(int(tty.Fd()), vtGetIndex)
|
||||||
|
if err != nil || vt < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/dev/ttyv" + strconv.FormatInt(int64(vt-1), 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardRestore switches the console keyboard back for the guard, then has
|
||||||
|
// rest restore the console's tty. A session whose leader ends revokes its
|
||||||
|
// console, the game's leaving with it, which then takes no ioctl (ENOTTY):
|
||||||
|
// the guard opens it again, which vt needs no more of to switch the keyboard.
|
||||||
|
func guardRestore(tty *os.File, path string, mode int, rest func(fd int)) {
|
||||||
|
fd := int(tty.Fd())
|
||||||
|
switch err := unix.IoctlSetInt(fd, kdSetKeyboardMode, mode); err {
|
||||||
|
case unix.ENOTTY, unix.ENXIO, unix.EBADF:
|
||||||
|
guardReopen(path, mode, func(fd int) {
|
||||||
|
flushInput(fd)
|
||||||
|
rest(fd)
|
||||||
|
})
|
||||||
|
case nil:
|
||||||
|
flushInput(fd)
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
rest(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Linux console's ioctls (linux/kd.h, linux/vt.h, linux/tiocl.h), which
|
||||||
|
// golang.org/x/sys/unix does not define
|
||||||
|
const (
|
||||||
|
kdGetKeyboardMode = 0x4B44 // KDGKBMODE
|
||||||
|
kdSetKeyboardMode = 0x4B45 // KDSKBMODE
|
||||||
|
kdGetKeymapEntry = 0x4B46 // KDGKBENT
|
||||||
|
kdGetFunctionKey = 0x4B48 // KDGKBSENT
|
||||||
|
kdGetAccents = 0x4BFA // KDGKBDIACRUC
|
||||||
|
kdGetMetaMode = 0x4B62 // KDGKBMETA
|
||||||
|
kdGetLockFlags = 0x4B64 // KDGKBLED
|
||||||
|
kdSetLockFlags = 0x4B65 // KDSKBLED
|
||||||
|
vtGetState = 0x5603 // VT_GETSTATE
|
||||||
|
vtActivate = 0x5606 // VT_ACTIVATE
|
||||||
|
tioclGetShiftState = 6 // TIOCLINUX subcode: the modifiers down
|
||||||
|
ttyMajor = 4 // the consoles' device major, minors 1 to 63
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyboard modes, and the meta mode that types Meta keys as ESC prefixes
|
||||||
|
const (
|
||||||
|
kXlate = 0x01 // K_XLATE
|
||||||
|
kMediumRaw = 0x02 // K_MEDIUMRAW
|
||||||
|
kUnicode = 0x03 // K_UNICODE
|
||||||
|
kEscPrefix = 0x04 // K_ESCPREFIX
|
||||||
|
)
|
||||||
|
|
||||||
|
type kbEntry struct {
|
||||||
|
table, index uint8
|
||||||
|
value uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbSEntry struct {
|
||||||
|
fn uint8
|
||||||
|
str [512]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbDiacrsUC struct {
|
||||||
|
count uint32
|
||||||
|
entries [256]struct{ diacr, base, result uint32 }
|
||||||
|
}
|
||||||
|
|
||||||
|
type vtStat struct {
|
||||||
|
active, signal, state uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxConsole is the Linux console the game runs on, its keyboard switched
|
||||||
|
// to K_MEDIUMRAW while releases come from it
|
||||||
|
type linuxConsole struct {
|
||||||
|
tty *os.File
|
||||||
|
fd int
|
||||||
|
vt int // the console's number, from 1
|
||||||
|
termios *unix.Termios // the termios the backend found, which the guard restores
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
mode int // the keyboard mode prepare found, which cooked restores
|
||||||
|
isRaw bool
|
||||||
|
km linuxKeymap
|
||||||
|
kb linuxKeys
|
||||||
|
defLock uint8 // the default lock flags, beside the flags in KDGKBLED's answer
|
||||||
|
dec mediumRaw
|
||||||
|
away bool // another console had the keyboard when last asked
|
||||||
|
guard *keyGuard
|
||||||
|
}
|
||||||
|
|
||||||
|
// openConsole is the console tty is, nil unless it is one: a Linux virtual
|
||||||
|
// console, not a pty, a serial line or /dev/console
|
||||||
|
func openConsole(tty *os.File, cooked *unix.Termios) keyConsole {
|
||||||
|
fd := int(tty.Fd())
|
||||||
|
var st unix.Stat_t
|
||||||
|
if cooked == nil || unix.Fstat(fd, &st) != nil || unix.Major(st.Rdev) != ttyMajor {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
vt := int(unix.Minor(st.Rdev))
|
||||||
|
if vt < 1 || vt > 63 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := unix.IoctlGetInt(fd, kdGetKeyboardMode); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := &linuxConsole{tty: tty, fd: fd, vt: vt, termios: cooked}
|
||||||
|
c.kb.km = &c.km
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare reads the keymap, starts the guard and lets output a Scroll Lock
|
||||||
|
// stopped go on; a keyboard some other program has raw is left alone
|
||||||
|
func (c *linuxConsole) prepare() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isRaw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
mode, err := unix.IoctlGetInt(c.fd, kdGetKeyboardMode)
|
||||||
|
if err != nil || mode != kXlate && mode != kUnicode || !c.readKeymap() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.guard == nil {
|
||||||
|
if c.guard, err = startGuard(c.tty); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mode = mode
|
||||||
|
unix.IoctlSetInt(c.fd, unix.TCXONC, unix.TCOON)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKeymap reads the kernel's keymap: each map's entries, the function key
|
||||||
|
// strings, the accent table and the meta mode. Entries are read cooked, while
|
||||||
|
// KDGKBENT still answers Unicode ones.
|
||||||
|
func (c *linuxConsole) readKeymap() bool {
|
||||||
|
km := &c.km
|
||||||
|
for table := range km.maps {
|
||||||
|
e := kbEntry{table: uint8(table)}
|
||||||
|
if ioctlPtr(c.fd, kdGetKeymapEntry, unsafe.Pointer(&e)) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if e.value == kNoSuchMap {
|
||||||
|
km.maps[table] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := km.maps[table]
|
||||||
|
if m == nil {
|
||||||
|
m = new([nrKeys]uint16)
|
||||||
|
km.maps[table] = m
|
||||||
|
}
|
||||||
|
m[0] = e.value ^ 0xf000
|
||||||
|
for i := 1; i < nrKeys; i++ {
|
||||||
|
e.index = uint8(i)
|
||||||
|
if ioctlPtr(c.fd, kdGetKeymapEntry, unsafe.Pointer(&e)) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m[i] = e.value ^ 0xf000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var s kbSEntry
|
||||||
|
for fn := range km.funcs {
|
||||||
|
s.fn, km.funcs[fn] = uint8(fn), ""
|
||||||
|
if ioctlPtr(c.fd, kdGetFunctionKey, unsafe.Pointer(&s)) == nil {
|
||||||
|
if n := strings.IndexByte(string(s.str[:]), 0); n >= 0 {
|
||||||
|
km.funcs[fn] = string(s.str[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var acc kbDiacrsUC
|
||||||
|
km.accents = km.accents[:0]
|
||||||
|
if ioctlPtr(c.fd, kdGetAccents, unsafe.Pointer(&acc)) == nil {
|
||||||
|
for _, a := range acc.entries[:min(int(acc.count), len(acc.entries))] {
|
||||||
|
km.accents = append(km.accents, linuxAccent{rune(a.diacr), rune(a.base), rune(a.result)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta, err := unix.IoctlGetInt(c.fd, kdGetMetaMode)
|
||||||
|
km.escMeta = err != nil || meta == kEscPrefix
|
||||||
|
km.csiMods = linuxAtLeast(7, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxAtLeast is whether the running kernel is major.minor or later
|
||||||
|
func linuxAtLeast(major, minor int) bool {
|
||||||
|
var u unix.Utsname
|
||||||
|
if unix.Uname(&u) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
release := unix.ByteSliceToString(u.Release[:])
|
||||||
|
parts := strings.SplitN(release, ".", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
maj, err1 := strconv.Atoi(parts[0])
|
||||||
|
min, err2 := strconv.Atoi(strings.TrimRightFunc(parts[1], func(r rune) bool { return r < '0' || r > '9' }))
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return maj > major || maj == major && min >= minor
|
||||||
|
}
|
||||||
|
|
||||||
|
// state reads the modifiers down and the lock flags translation starts from
|
||||||
|
func (c *linuxConsole) state() (shift, lock uint8) {
|
||||||
|
arg := [1]byte{tioclGetShiftState}
|
||||||
|
if ioctlPtr(c.fd, unix.TIOCLINUX, unsafe.Pointer(&arg[0])) == nil {
|
||||||
|
shift = arg[0]
|
||||||
|
}
|
||||||
|
if flags, err := unix.IoctlGetInt(c.fd, kdGetLockFlags); err == nil {
|
||||||
|
lock, c.defLock = uint8(flags&0x07), uint8(flags>>4&0x07)
|
||||||
|
}
|
||||||
|
return shift, lock
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw arms the guard and switches the keyboard to K_MEDIUMRAW, which the
|
||||||
|
// kernel allows on the caller's controlling tty
|
||||||
|
func (c *linuxConsole) raw() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isRaw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.guard == nil || !c.guard.arm(c.mode, c.termios) {
|
||||||
|
c.dropGuard()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
shift, lock := c.state()
|
||||||
|
if unix.IoctlSetInt(c.fd, kdSetKeyboardMode, kMediumRaw) != nil {
|
||||||
|
c.guard.disarm()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.kb.seed(shift, lock)
|
||||||
|
c.dec, c.away, c.isRaw = mediumRaw{}, false, true
|
||||||
|
rawConsoleKeys.Store(&consoleRestore{fd: c.fd, mode: c.mode, guard: c.guard})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooked switches the keyboard back
|
||||||
|
func (c *linuxConsole) cooked() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cookedLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookedLocked switches the keyboard back; a console that refuses keeps the
|
||||||
|
// guard armed. Caller MUST hold mu.
|
||||||
|
func (c *linuxConsole) cookedLocked() {
|
||||||
|
if !c.isRaw {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.isRaw = false
|
||||||
|
rawConsoleKeys.Store(nil)
|
||||||
|
if unix.IoctlSetInt(c.fd, kdSetKeyboardMode, c.mode) == nil {
|
||||||
|
c.guard.disarm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys decodes raw keycodes and translates each key
|
||||||
|
func (c *linuxConsole) keys(data []byte, key func(code uint32, action KeyAction, typed []byte)) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.isRaw {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.dec.decode(data, func(code int, down bool) {
|
||||||
|
if code >= nrKeys {
|
||||||
|
return // no keymap maps it
|
||||||
|
}
|
||||||
|
action := KeyRelease
|
||||||
|
if down {
|
||||||
|
action = KeyPress
|
||||||
|
if c.kb.isDown(code) {
|
||||||
|
action = KeyRepeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typed, act := c.kb.key(code, down, action == KeyRepeat)
|
||||||
|
c.act(act)
|
||||||
|
key(uint32(code), action, typed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// act does what a key asks of the console. Caller MUST hold mu.
|
||||||
|
func (c *linuxConsole) act(a consoleAct) {
|
||||||
|
if a.leds {
|
||||||
|
unix.IoctlSetInt(c.fd, kdSetLockFlags, int(c.kb.leds)|int(c.defLock)<<4)
|
||||||
|
}
|
||||||
|
if a.start {
|
||||||
|
unix.IoctlSetInt(c.fd, unix.TCXONC, unix.TCOON)
|
||||||
|
}
|
||||||
|
if a.vt != 0 {
|
||||||
|
c.switchTo(a.vt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchTo switches to console vt, or the one before or after this one in
|
||||||
|
// use, as the kernel does for a key cooked: only to a console in use, which
|
||||||
|
// VT_GETSTATE tells of the first 15
|
||||||
|
func (c *linuxConsole) switchTo(vt int) {
|
||||||
|
var st vtStat
|
||||||
|
if ioctlPtr(c.fd, vtGetState, unsafe.Pointer(&st)) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur, inUse := int(st.active), func(n int) bool { return n > 15 || st.state&(1<<n) != 0 }
|
||||||
|
if vt < 0 && cur >= 1 && cur <= 15 {
|
||||||
|
step := 1
|
||||||
|
if vt == vtPrev {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
vt = 0
|
||||||
|
for n := (cur+step+14)%15 + 1; n != cur; n = (n+step+14)%15 + 1 {
|
||||||
|
if inUse(n) {
|
||||||
|
vt = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vt > 0 && vt != cur && inUse(vt) {
|
||||||
|
unix.IoctlSetInt(c.fd, vtActivate, vt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// front is whether this console is on screen; back on it, translation starts
|
||||||
|
// again from the console's state, its modifiers having moved elsewhere
|
||||||
|
func (c *linuxConsole) front() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
var st vtStat
|
||||||
|
if !c.isRaw || ioctlPtr(c.fd, vtGetState, unsafe.Pointer(&st)) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
front := int(st.active) == c.vt
|
||||||
|
if front && c.away {
|
||||||
|
c.kb.seed(c.state())
|
||||||
|
c.dec = mediumRaw{}
|
||||||
|
}
|
||||||
|
c.away = !front
|
||||||
|
return front
|
||||||
|
}
|
||||||
|
|
||||||
|
// close switches the keyboard back and ends the guard
|
||||||
|
func (c *linuxConsole) close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cookedLocked()
|
||||||
|
c.dropGuard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropGuard ends the guard, which the next prepare starts again. Caller MUST
|
||||||
|
// hold mu.
|
||||||
|
func (c *linuxConsole) dropGuard() {
|
||||||
|
if c.guard != nil {
|
||||||
|
c.guard.close()
|
||||||
|
c.guard = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardExecutable is this program's binary, which runs again as the guard:
|
||||||
|
// the running one, even replaced or deleted since
|
||||||
|
func guardExecutable() (string, error) { return "/proc/self/exe", nil }
|
||||||
|
|
||||||
|
// nameGuard names the guard's process as its arguments do; init runs on the
|
||||||
|
// main thread, whose name is the process's
|
||||||
|
func nameGuard() {
|
||||||
|
name := append([]byte(guardName), 0)
|
||||||
|
unix.Prctl(unix.PR_SET_NAME, uintptr(unsafe.Pointer(&name[0])), 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consolePath is the console's device, for the guard to open it again
|
||||||
|
func consolePath(tty *os.File) string {
|
||||||
|
var st unix.Stat_t
|
||||||
|
if unix.Fstat(int(tty.Fd()), &st) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/dev/tty" + strconv.Itoa(int(unix.Minor(st.Rdev)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardRestore switches the console keyboard back for the guard, then has
|
||||||
|
// rest restore the console's tty. A game that led the console's session took
|
||||||
|
// the console with it, the kernel hanging it up as the game died: the guard,
|
||||||
|
// leading a session of its own then, opens the console again once it is free,
|
||||||
|
// which makes it the guard's controlling tty and lets it switch the keyboard.
|
||||||
|
func guardRestore(tty *os.File, path string, mode int, rest func(fd int)) {
|
||||||
|
fd := int(tty.Fd())
|
||||||
|
if err := unix.IoctlSetInt(fd, kdSetKeyboardMode, mode); err != unix.EIO && err != unix.EPERM {
|
||||||
|
rest(fd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sid, _ := unix.Getsid(0); sid != os.Getpid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardReopen(path, mode, func(fd int) {
|
||||||
|
rest(fd)
|
||||||
|
// Given up, the console isn't hung up again as the guard ends
|
||||||
|
unix.IoctlSetInt(fd, unix.TIOCNOTTY, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushInput drops the console's pending input as its keyboard switches, which
|
||||||
|
// KDSKBMODE does itself here
|
||||||
|
func flushInput(int) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !linux && !freebsd
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
// restoreConsoleKeys has no console keyboard to restore here
|
||||||
|
func restoreConsoleKeys() {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build unix && !linux && !freebsd
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openConsole finds no console whose keyboard goes raw here
|
||||||
|
func openConsole(*os.File, *unix.Termios) keyConsole { return nil }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the consoles whose keyboard goes raw share: the Linux console and
|
||||||
|
// FreeBSD's vt(4)
|
||||||
|
|
||||||
|
// ioctlPtr is an ioctl whose argument is a pointer
|
||||||
|
func ioctlPtr(fd int, req uint, arg unsafe.Pointer) error {
|
||||||
|
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)); e != 0 {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleRestore is what restores a console keyboard left raw
|
||||||
|
type consoleRestore struct {
|
||||||
|
fd, mode int
|
||||||
|
guard *keyGuard
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConsoleKeys is the console a Terminal has raw, for EmergencyReset
|
||||||
|
var rawConsoleKeys atomic.Pointer[consoleRestore]
|
||||||
|
|
||||||
|
// restoreConsoleKeys switches back a console keyboard a Terminal has raw. It
|
||||||
|
// takes no lock, for a reset from a panic that may hold one.
|
||||||
|
func restoreConsoleKeys() {
|
||||||
|
if r := rawConsoleKeys.Swap(nil); r != nil && unix.IoctlSetInt(r.fd, kdSetKeyboardMode, r.mode) == nil {
|
||||||
|
flushInput(r.fd)
|
||||||
|
r.guard.disarm()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 @@
|
|||||||
// 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, from the Linux or FreeBSD console's keyboard
|
||||||
|
// or the terminal's key event protocol (Alacritty's), and presses where
|
||||||
|
// there are neither
|
||||||
// - 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 +42,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.
|
||||||
|
|||||||
+498
@@ -0,0 +1,498 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import "unicode/utf8"
|
||||||
|
|
||||||
|
// The FreeBSD console's keymap and its translation of keycodes, after the
|
||||||
|
// kernel's own (sys/dev/kbd/kbd.c, sys/dev/vt/vt_core.c), so a vt(4)
|
||||||
|
// keyboard switched to keycodes (K_CODE) types what the console types with it
|
||||||
|
// cooked: the keyboard's driver makes an action of each keycode by the keymap
|
||||||
|
// and its modifiers and locks, and vt types the action.
|
||||||
|
|
||||||
|
// Actions the keymap's special entries hold (sys/kbio.h)
|
||||||
|
const (
|
||||||
|
fbsdNOP = 0x00 // nothing
|
||||||
|
fbsdLSH = 0x02 // left Shift
|
||||||
|
fbsdRSH = 0x03 // right Shift
|
||||||
|
fbsdCLK = 0x04 // Caps Lock
|
||||||
|
fbsdNLK = 0x05 // Num Lock
|
||||||
|
fbsdSLK = 0x06 // Scroll Lock
|
||||||
|
fbsdLALT = 0x07 // left Alt
|
||||||
|
fbsdBTAB = 0x08 // back tab
|
||||||
|
fbsdLCTR = 0x09 // left Ctrl
|
||||||
|
fbsdNEXT = 0x0a // the next console
|
||||||
|
fbsdFSCR = 0x0b // console 1, the others after it
|
||||||
|
fbsdFFN = 0x1b // function key 1, the others after it
|
||||||
|
fbsdLFN = 0x7a // the last function key
|
||||||
|
fbsdRCTR = 0x80 // right Ctrl
|
||||||
|
fbsdRALT = 0x81 // right Alt
|
||||||
|
fbsdALK = 0x82 // AltGr Lock
|
||||||
|
fbsdASH = 0x83 // AltGr
|
||||||
|
fbsdMETA = 0x84 // Meta
|
||||||
|
fbsdRBT = 0x85 // reboot
|
||||||
|
fbsdDBG = 0x86 // the debugger
|
||||||
|
fbsdSUSP = 0x87 // suspend
|
||||||
|
fbsdSPSC = 0x88 // the screen saver
|
||||||
|
fbsdFACC = 0x89 // the first dead key
|
||||||
|
fbsdLACC = 0x97 // the last dead key
|
||||||
|
fbsdSTBY = 0x98 // standby
|
||||||
|
fbsdPREV = 0x99 // the previous console
|
||||||
|
fbsdPNC = 0x9a // panic
|
||||||
|
fbsdLSHA = 0x9b // left Shift, AltGr Lock pressed alone
|
||||||
|
fbsdRSHA = 0x9c // right Shift, AltGr Lock pressed alone
|
||||||
|
fbsdLCTRA = 0x9d // left Ctrl, AltGr Lock pressed alone
|
||||||
|
fbsdRCTRA = 0x9e // right Ctrl, AltGr Lock pressed alone
|
||||||
|
fbsdLALTA = 0x9f // left Alt, AltGr Lock pressed alone
|
||||||
|
fbsdRALTA = 0xa0 // right Alt, AltGr Lock pressed alone
|
||||||
|
fbsdHALT = 0xa1 // halt
|
||||||
|
fbsdPDWN = 0xa2 // power down
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the keyboard's driver flags an action with (sys/kbio.h)
|
||||||
|
const (
|
||||||
|
fbsdNoKey = 0x01000000 // NOKEY: nothing to type
|
||||||
|
fbsdFKey = 0x02000000 // FKEY: a function key
|
||||||
|
fbsdMKey = 0x04000000 // MKEY: typed with Meta
|
||||||
|
fbsdBKey = 0x08000000 // BKEY: back tab
|
||||||
|
fbsdErrKey = 0x20000000 // ERRKEY: a character a dead key can't accent
|
||||||
|
fbsdRelKey = 0x40000000 // RELKEY: a special key let go
|
||||||
|
fbsdSpclKey = 0x80000000 // SPCLKEY: a special action
|
||||||
|
fbsdFlags = 0xff000000 // KEYFLAGS
|
||||||
|
)
|
||||||
|
|
||||||
|
// The keyboard's state: the locks, as KDGKBSTATE reports them, and the
|
||||||
|
// modifiers (sys/dev/kbd/kbdreg.h)
|
||||||
|
const (
|
||||||
|
fbsdCLKED = 1 << 0 // Caps Lock on
|
||||||
|
fbsdNLKED = 1 << 1 // Num Lock on
|
||||||
|
fbsdSLKED = 1 << 2 // Scroll Lock on
|
||||||
|
fbsdALKED = 1 << 3 // AltGr Lock on
|
||||||
|
fbsdLockMask = fbsdCLKED | fbsdNLKED | fbsdSLKED | fbsdALKED
|
||||||
|
fbsdShifts1 = 1 << 16
|
||||||
|
fbsdShifts2 = 1 << 17
|
||||||
|
fbsdShifts = fbsdShifts1 | fbsdShifts2
|
||||||
|
fbsdCtls1 = 1 << 18
|
||||||
|
fbsdCtls2 = 1 << 19
|
||||||
|
fbsdCtls = fbsdCtls1 | fbsdCtls2
|
||||||
|
fbsdAlts1 = 1 << 20
|
||||||
|
fbsdAlts2 = 1 << 21
|
||||||
|
fbsdAlts = fbsdAlts1 | fbsdAlts2
|
||||||
|
fbsdAgrs1 = 1 << 22
|
||||||
|
fbsdAgrs2 = 1 << 23
|
||||||
|
fbsdAgrs = fbsdAgrs1 | fbsdAgrs2
|
||||||
|
fbsdMetas1 = 1 << 24
|
||||||
|
fbsdMetas2 = 1 << 25
|
||||||
|
fbsdMetas = fbsdMetas1 | fbsdMetas2
|
||||||
|
fbsdNLKDown = 1 << 26
|
||||||
|
fbsdSLKDown = 1 << 27
|
||||||
|
fbsdCLKDown = 1 << 28
|
||||||
|
fbsdALKDown = 1 << 29
|
||||||
|
fbsdShiftAOn = 1 << 30 // a Shift, Ctrl or Alt that locks AltGr pressed alone so far
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fbsdNumKeys = 256 // NUM_KEYS: entries in a keymap
|
||||||
|
fbsdAltGrKeys = 128 // ALTGR_OFFSET: where the keys' AltGr entries start
|
||||||
|
fbsdDeadKeys = 15 // NUM_DEADKEYS
|
||||||
|
fbsdAccents = 52 // NUM_ACCENTCHARS: the characters a dead key accents
|
||||||
|
fbsdLockC = 1 // FLAG_LOCK_C: Caps Lock shifts the key
|
||||||
|
fbsdLockN = 2 // FLAG_LOCK_N: Num Lock shifts the key
|
||||||
|
fbsdKeycodes = 128 // K_CODE's keycodes, 7 bits
|
||||||
|
fbsdWindows = 12 // VT_MAXWINDOWS: the consoles vt(4) has
|
||||||
|
)
|
||||||
|
|
||||||
|
// fbsdKeymap is a FreeBSD console keymap as GIO_KEYMAP and GIO_DEADKEYMAP
|
||||||
|
// read it, and whether vt(4) takes right Alt for AltGr
|
||||||
|
// (kern.vt.enable_altgr) rather than the Alt that types ESC before a
|
||||||
|
// character
|
||||||
|
type fbsdKeymap struct {
|
||||||
|
keys [fbsdNumKeys]fbsdKey
|
||||||
|
accents [fbsdDeadKeys]fbsdAccent
|
||||||
|
nAccents int
|
||||||
|
altGr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdKey is a key's entry (keyent_t), laid out as the kernel's: its action
|
||||||
|
// in each state of Shift, Ctrl and Alt, the states whose action is special,
|
||||||
|
// and the locks that shift it
|
||||||
|
type fbsdKey struct {
|
||||||
|
actions [8]uint32
|
||||||
|
spcl uint8
|
||||||
|
flgs uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdAccent is a dead key's accent (acc_t), laid out as the kernel's: its
|
||||||
|
// character, and the characters it makes of others, in pairs up to a zero
|
||||||
|
type fbsdAccent struct {
|
||||||
|
char uint32
|
||||||
|
pairs [fbsdAccents][2]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdKeys translates keycodes as a vt(4) console does, keeping the
|
||||||
|
// keyboard's modifiers, locks and dead key, the special action each key down
|
||||||
|
// began, the code left Alt and the keypad compose, and Alt for vt's ESC. The
|
||||||
|
// machine's keys (reboot, halt, power down, suspend, standby, the debugger,
|
||||||
|
// panic), the screen saver and paste type nothing; Scroll Lock does nothing,
|
||||||
|
// leaving the console's history mode alone. The cursor keys type as they do
|
||||||
|
// outside application mode, which the package never sets.
|
||||||
|
type fbsdKeys struct {
|
||||||
|
km *fbsdKeymap
|
||||||
|
down [fbsdKeycodes / 64]uint64 // keys down
|
||||||
|
state uint32 // modifiers and locks
|
||||||
|
accent int // the dead key pending, from 1; zero for none
|
||||||
|
lastact [fbsdKeycodes]uint32 // the special action each key down began
|
||||||
|
composing bool // left Alt down, the keypad's digits composing a code
|
||||||
|
composed uint32 // the code composed so far
|
||||||
|
alt bool // Alt down, for vt's ESC before a character
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed starts translation from the console's locks; modifiers already down
|
||||||
|
// are not known, the keyboard's driver forgetting them as it switches
|
||||||
|
func (k *fbsdKeys) seed(locks uint32) {
|
||||||
|
*k = fbsdKeys{km: k.km, state: locks & fbsdLockMask}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDown is whether a key is down
|
||||||
|
func (k *fbsdKeys) isDown(code int) bool {
|
||||||
|
return code >= 0 && code < fbsdKeycodes && k.down[code/64]&(1<<(code%64)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// key translates one keycode event: down for a press, or a repeat, which the
|
||||||
|
// keyboard sends as another press. It returns what the console types for it
|
||||||
|
// and what it asks of the console.
|
||||||
|
func (k *fbsdKeys) key(code int, down bool) (typed []byte, act consoleAct) {
|
||||||
|
if code <= 0 || code >= fbsdKeycodes {
|
||||||
|
return nil, act
|
||||||
|
}
|
||||||
|
if down {
|
||||||
|
k.down[code/64] |= 1 << (code % 64)
|
||||||
|
} else {
|
||||||
|
k.down[code/64] &^= 1 << (code % 64)
|
||||||
|
}
|
||||||
|
if k.compose(code, down) {
|
||||||
|
return nil, act
|
||||||
|
}
|
||||||
|
locks := k.state & fbsdLockMask
|
||||||
|
c := k.action(code, !down)
|
||||||
|
act.leds = k.state&fbsdLockMask != locks
|
||||||
|
typed, act.vt = k.vt(c)
|
||||||
|
if !k.composing && k.composed > 0 {
|
||||||
|
// Left Alt up types the code composed
|
||||||
|
ch, _ := k.vt(k.composed)
|
||||||
|
typed, k.composed = append(typed, ch...), 0
|
||||||
|
}
|
||||||
|
return typed, act
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keycodes of left Alt and the keypad's digits, which the keyboard's
|
||||||
|
// driver composes a code with
|
||||||
|
const fbsdKeyLeftAlt = 0x38
|
||||||
|
|
||||||
|
var fbsdPadDigits = map[int]uint32{
|
||||||
|
0x47: 7, 0x48: 8, 0x49: 9, 0x4b: 4, 0x4c: 5, 0x4d: 6, 0x4f: 1, 0x50: 2, 0x51: 3, 0x52: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// compose composes a code of the keypad's digits typed with left Alt down, as
|
||||||
|
// the keyboard's driver does before it makes an action of a keycode
|
||||||
|
// (kbdmux_read_char): true when it takes the key, which then types nothing.
|
||||||
|
// Another key ends a code begun, and is lost to it; a code past a byte types
|
||||||
|
// nothing.
|
||||||
|
func (k *fbsdKeys) compose(code int, down bool) bool {
|
||||||
|
if code == fbsdKeyLeftAlt {
|
||||||
|
if down && !k.composing {
|
||||||
|
k.composing, k.composed = true, 0
|
||||||
|
} else if !down && k.composing {
|
||||||
|
k.composing = false
|
||||||
|
if k.composed > 0xff {
|
||||||
|
k.composed = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !k.composing {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if digit, ok := fbsdPadDigits[code]; ok {
|
||||||
|
if down {
|
||||||
|
k.composed = min(k.composed*10+digit, 0x100)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if k.composed > 0 {
|
||||||
|
k.composing, k.composed = false, 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockKey toggles a lock as its key goes down, not as it repeats
|
||||||
|
func lockKey(state *uint32, down, locked uint32) {
|
||||||
|
if *state&down == 0 {
|
||||||
|
*state |= down
|
||||||
|
*state ^= locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// action makes an action of a keycode by the keymap, as the keyboard's
|
||||||
|
// driver does (genkbd_keyaction): a character, a special action, or a flag
|
||||||
|
// for none
|
||||||
|
func (k *fbsdKeys) action(code int, up bool) uint32 {
|
||||||
|
state := k.state
|
||||||
|
i := code
|
||||||
|
if f := state & (fbsdAgrs | fbsdALKED); f == fbsdAgrs1 || f == fbsdAgrs2 || f == fbsdALKED {
|
||||||
|
i += fbsdAltGrKeys
|
||||||
|
}
|
||||||
|
key := &k.km.keys[i]
|
||||||
|
col := 0
|
||||||
|
if state&fbsdShifts != 0 {
|
||||||
|
col |= 1
|
||||||
|
}
|
||||||
|
if state&fbsdCtls != 0 {
|
||||||
|
col |= 2
|
||||||
|
}
|
||||||
|
if state&fbsdAlts != 0 {
|
||||||
|
col |= 4
|
||||||
|
}
|
||||||
|
if key.flgs&fbsdLockC != 0 && state&fbsdCLKED != 0 || key.flgs&fbsdLockN != 0 && state&fbsdNLKED != 0 {
|
||||||
|
col ^= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if up {
|
||||||
|
action := k.lastact[code]
|
||||||
|
k.lastact[code] = fbsdNOP
|
||||||
|
if alone, ok := fbsdLockingMods[action]; ok {
|
||||||
|
// Pressed alone, the key toggles AltGr Lock
|
||||||
|
if state&fbsdShiftAOn != 0 {
|
||||||
|
lockKey(&state, fbsdALKDown, fbsdALKED)
|
||||||
|
state &^= fbsdALKDown
|
||||||
|
}
|
||||||
|
action = alone
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case fbsdNOP:
|
||||||
|
// Regular keys go up unreported
|
||||||
|
k.state &^= fbsdShiftAOn
|
||||||
|
return fbsdNoKey
|
||||||
|
case fbsdLSH:
|
||||||
|
state &^= fbsdShifts1
|
||||||
|
case fbsdRSH:
|
||||||
|
state &^= fbsdShifts2
|
||||||
|
case fbsdLCTR:
|
||||||
|
state &^= fbsdCtls1
|
||||||
|
case fbsdRCTR:
|
||||||
|
state &^= fbsdCtls2
|
||||||
|
case fbsdLALT:
|
||||||
|
state &^= fbsdAlts1
|
||||||
|
case fbsdRALT:
|
||||||
|
state &^= fbsdAlts2
|
||||||
|
case fbsdASH:
|
||||||
|
state &^= fbsdAgrs1
|
||||||
|
case fbsdMETA:
|
||||||
|
state &^= fbsdMetas1
|
||||||
|
case fbsdNLK:
|
||||||
|
state &^= fbsdNLKDown
|
||||||
|
case fbsdCLK:
|
||||||
|
state &^= fbsdCLKDown
|
||||||
|
case fbsdSLK:
|
||||||
|
state &^= fbsdSLKDown
|
||||||
|
case fbsdALK:
|
||||||
|
state &^= fbsdALKDown
|
||||||
|
}
|
||||||
|
k.state = state &^ fbsdShiftAOn
|
||||||
|
return fbsdSpclKey | fbsdRelKey | action
|
||||||
|
}
|
||||||
|
|
||||||
|
action := key.actions[col]
|
||||||
|
state &^= fbsdShiftAOn
|
||||||
|
if key.spcl&(0x80>>col) == 0 {
|
||||||
|
// A character, the dead key pending accenting it
|
||||||
|
k.lastact[code] = fbsdNOP
|
||||||
|
k.state = state
|
||||||
|
if k.accent > 0 {
|
||||||
|
if action = k.accented(action); action == fbsdErrKey {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state&fbsdMetas != 0 {
|
||||||
|
action |= fbsdMKey
|
||||||
|
}
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
// A special key keeps the action it went down with while it repeats
|
||||||
|
if k.lastact[code] == fbsdNOP {
|
||||||
|
k.lastact[code] = action
|
||||||
|
}
|
||||||
|
if k.lastact[code] != action {
|
||||||
|
action = fbsdNOP
|
||||||
|
}
|
||||||
|
if alone, ok := fbsdLockingMods[action]; ok {
|
||||||
|
state |= fbsdShiftAOn
|
||||||
|
action = alone
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case fbsdNLK:
|
||||||
|
lockKey(&state, fbsdNLKDown, fbsdNLKED)
|
||||||
|
case fbsdCLK:
|
||||||
|
lockKey(&state, fbsdCLKDown, fbsdCLKED)
|
||||||
|
case fbsdSLK:
|
||||||
|
// The console's history mode follows Scroll Lock; raw, it stays
|
||||||
|
case fbsdALK:
|
||||||
|
lockKey(&state, fbsdALKDown, fbsdALKED)
|
||||||
|
case fbsdSPSC, fbsdRBT, fbsdSUSP, fbsdSTBY, fbsdDBG, fbsdNEXT, fbsdPREV, fbsdPNC, fbsdHALT, fbsdPDWN:
|
||||||
|
k.accent = 0
|
||||||
|
case fbsdBTAB:
|
||||||
|
k.accent = 0
|
||||||
|
action |= fbsdBKey
|
||||||
|
case fbsdLSH:
|
||||||
|
state |= fbsdShifts1
|
||||||
|
case fbsdRSH:
|
||||||
|
state |= fbsdShifts2
|
||||||
|
case fbsdLCTR:
|
||||||
|
state |= fbsdCtls1
|
||||||
|
case fbsdRCTR:
|
||||||
|
state |= fbsdCtls2
|
||||||
|
case fbsdLALT:
|
||||||
|
state |= fbsdAlts1
|
||||||
|
case fbsdRALT:
|
||||||
|
state |= fbsdAlts2
|
||||||
|
case fbsdASH:
|
||||||
|
state |= fbsdAgrs1
|
||||||
|
case fbsdMETA:
|
||||||
|
state |= fbsdMetas1
|
||||||
|
case fbsdNOP:
|
||||||
|
k.state = state
|
||||||
|
return fbsdNoKey
|
||||||
|
default:
|
||||||
|
k.state = state
|
||||||
|
if action >= fbsdFACC && action <= fbsdLACC {
|
||||||
|
action = k.deadKey(action)
|
||||||
|
if action == fbsdNoKey || action == fbsdErrKey {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
if state&fbsdMetas != 0 {
|
||||||
|
return action | fbsdMKey
|
||||||
|
}
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
if k.accent > 0 {
|
||||||
|
k.accent = 0
|
||||||
|
return fbsdErrKey
|
||||||
|
}
|
||||||
|
if action >= fbsdFFN && action <= fbsdLFN {
|
||||||
|
action |= fbsdFKey
|
||||||
|
}
|
||||||
|
return fbsdSpclKey | action
|
||||||
|
}
|
||||||
|
k.state = state
|
||||||
|
return fbsdSpclKey | action
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdLockingMods are the modifiers that lock AltGr pressed alone, and the
|
||||||
|
// modifier each is otherwise
|
||||||
|
var fbsdLockingMods = map[uint32]uint32{
|
||||||
|
fbsdLSHA: fbsdLSH, fbsdRSHA: fbsdRSH, fbsdLCTRA: fbsdLCTR,
|
||||||
|
fbsdRCTRA: fbsdRCTR, fbsdLALTA: fbsdLALT, fbsdRALTA: fbsdRALT,
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadKey is a dead key: the next character takes its accent, and the dead
|
||||||
|
// key again types the accent itself
|
||||||
|
func (k *fbsdKeys) deadKey(action uint32) uint32 {
|
||||||
|
i := int(action-fbsdFACC) + 1
|
||||||
|
if i > k.km.nAccents || k.km.accents[i-1].char == 0 {
|
||||||
|
k.accent = 0
|
||||||
|
return fbsdErrKey
|
||||||
|
}
|
||||||
|
if i == k.accent {
|
||||||
|
k.accent = 0
|
||||||
|
return k.km.accents[i-1].char
|
||||||
|
}
|
||||||
|
k.accent = i
|
||||||
|
return fbsdNoKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// accented is what the dead key pending makes of ch: its accent for a space,
|
||||||
|
// the accented character, or an error for a character it can't accent
|
||||||
|
func (k *fbsdKeys) accented(ch uint32) uint32 {
|
||||||
|
acc := &k.km.accents[k.accent-1]
|
||||||
|
k.accent = 0
|
||||||
|
if ch == ' ' {
|
||||||
|
return acc.char
|
||||||
|
}
|
||||||
|
for _, p := range acc.pairs {
|
||||||
|
if p[0] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if p[0] == ch {
|
||||||
|
return p[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fbsdErrKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// vt types an action as vt(4) does (vt_processkey): a character as UTF-8,
|
||||||
|
// after an ESC while Alt is down; F1 to F12, the cursor and editing keys and
|
||||||
|
// back tab as xterm's sequences; and a console to switch to
|
||||||
|
func (k *fbsdKeys) vt(c uint32) (typed []byte, vt int) {
|
||||||
|
if c&fbsdRelKey != 0 {
|
||||||
|
switch c &^ fbsdRelKey {
|
||||||
|
case fbsdSpclKey | fbsdRALT:
|
||||||
|
if k.km.altGr {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
case fbsdSpclKey | fbsdLALT:
|
||||||
|
k.alt = false
|
||||||
|
}
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case fbsdSpclKey | fbsdRALT:
|
||||||
|
if !k.km.altGr {
|
||||||
|
k.alt = true
|
||||||
|
}
|
||||||
|
case fbsdSpclKey | fbsdLALT:
|
||||||
|
k.alt = true
|
||||||
|
}
|
||||||
|
if c&fbsdSpclKey != 0 {
|
||||||
|
c &^= fbsdSpclKey
|
||||||
|
switch {
|
||||||
|
case c >= fbsdFSCR && c < fbsdFSCR+fbsdWindows:
|
||||||
|
return nil, int(c-fbsdFSCR) + 1
|
||||||
|
case c == fbsdNEXT:
|
||||||
|
return nil, vtNext
|
||||||
|
case c == fbsdPREV:
|
||||||
|
return nil, vtPrev
|
||||||
|
case c == fbsdBKey|fbsdBTAB:
|
||||||
|
return []byte("\x1b[Z"), 0
|
||||||
|
case c&fbsdFKey != 0:
|
||||||
|
return fbsdFunctionKey(c &^ fbsdFKey), 0
|
||||||
|
}
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
if c&fbsdFlags != 0 {
|
||||||
|
return nil, 0 // nothing, a character it can't accent, or with Meta, which vt drops
|
||||||
|
}
|
||||||
|
if k.alt {
|
||||||
|
typed = append(typed, 0x1b)
|
||||||
|
}
|
||||||
|
return utf8.AppendRune(typed, rune(c)), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdFunctionKeys are what vt(4) types for the function keys, by number:
|
||||||
|
// xterm's sequences for F1 to F12, and for the cursor and editing keys the
|
||||||
|
// keymap binds as function keys 49 to 61; nothing for the others
|
||||||
|
var fbsdFunctionKeys = map[uint32]string{
|
||||||
|
1: "\x1bOP", 2: "\x1bOQ", 3: "\x1bOR", 4: "\x1bOS",
|
||||||
|
5: "\x1b[15~", 6: "\x1b[17~", 7: "\x1b[18~", 8: "\x1b[19~",
|
||||||
|
9: "\x1b[20~", 10: "\x1b[21~", 11: "\x1b[23~", 12: "\x1b[24~",
|
||||||
|
49: "\x1b[H", 50: "\x1b[A", 51: "\x1b[5~", 53: "\x1b[D", 55: "\x1b[C",
|
||||||
|
57: "\x1b[F", 58: "\x1b[B", 59: "\x1b[6~", 60: "\x1b[2~", 61: "\x1b[3~",
|
||||||
|
}
|
||||||
|
|
||||||
|
// fbsdFunctionKey is what vt(4) types for a function key action
|
||||||
|
func fbsdFunctionKey(action uint32) []byte {
|
||||||
|
if s, ok := fbsdFunctionKeys[action-fbsdFFN+1]; ok {
|
||||||
|
return []byte(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testFreeBSDKeymap is the FreeBSD kernel's built-in keymap (US) and accent
|
||||||
|
// map (sys/dev/kbd/kbdtables.h), as GIO_KEYMAP and GIO_DEADKEYMAP read them,
|
||||||
|
// with vt's default AltGr
|
||||||
|
func testFreeBSDKeymap(t *testing.T) *fbsdKeymap {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open("testdata/freebsd-keymap.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
km := &fbsdKeymap{nAccents: fbsdDeadKeys, altGr: true}
|
||||||
|
hex := func(s string) uint32 {
|
||||||
|
x, err := strconv.ParseUint(s, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return uint32(x)
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
i, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch fields[0] {
|
||||||
|
case "key":
|
||||||
|
key := &km.keys[i]
|
||||||
|
for j, v := range fields[2:10] {
|
||||||
|
key.actions[j] = hex(v)
|
||||||
|
}
|
||||||
|
key.spcl, key.flgs = uint8(hex(fields[10])), uint8(hex(fields[11]))
|
||||||
|
case "acc":
|
||||||
|
acc := &km.accents[i]
|
||||||
|
acc.char = hex(fields[2])
|
||||||
|
for j, pair := range fields[3:] {
|
||||||
|
base, result, _ := strings.Cut(pair, ":")
|
||||||
|
acc.pairs[j] = [2]uint32{hex(base), hex(result)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return km
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FreeBSD console's keycodes up to 88 are the Linux console's; its others
|
||||||
|
// are its own
|
||||||
|
const (
|
||||||
|
fbsdKeyKPEnter = 89
|
||||||
|
fbsdKeyRightCtrl = 90
|
||||||
|
fbsdKeyPrint = 92
|
||||||
|
fbsdKeyRightAlt = 93
|
||||||
|
fbsdKeyHome = 94
|
||||||
|
fbsdKeyUp = 95
|
||||||
|
fbsdKeyPageUp = 96
|
||||||
|
fbsdKeyLeft = 97
|
||||||
|
fbsdKeyEnd = 99
|
||||||
|
fbsdKeyInsert = 102
|
||||||
|
fbsdKeyDelete = 103
|
||||||
|
fbsdKeyPause = 104
|
||||||
|
fbsdKeyF11 = 87
|
||||||
|
fbsdKeyF12 = 88
|
||||||
|
keyKP9 = 73
|
||||||
|
keyKP6 = 77
|
||||||
|
keyKP2 = 80
|
||||||
|
keyKP3 = 81
|
||||||
|
)
|
||||||
|
|
||||||
|
// fbsdTypedBy is what vt types for strokes, and what they ask of it
|
||||||
|
func fbsdTypedBy(k *fbsdKeys, strokes []stroke) (string, []consoleAct) {
|
||||||
|
var out []byte
|
||||||
|
var acts []consoleAct
|
||||||
|
for _, s := range strokes {
|
||||||
|
typed, act := k.key(s.code, s.down)
|
||||||
|
out = append(out, typed...)
|
||||||
|
if act != (consoleAct{}) {
|
||||||
|
acts = append(acts, act)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), acts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys type what vt types for them cooked, by the keymap and the modifiers
|
||||||
|
// and locks they move: Shift into the character, Caps Lock on letters only,
|
||||||
|
// Ctrl as control characters, Alt as an ESC prefix, F1 to F12 and the cursor
|
||||||
|
// and editing keys as xterm's sequences, and the keypad by Num Lock
|
||||||
|
func TestFreeBSDConsoleTypesAsCooked(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want string
|
||||||
|
altGr string // with right Alt as Alt, when it differs
|
||||||
|
}{
|
||||||
|
{"letter", press(keyA), "a", ""},
|
||||||
|
{"shifted letter", hold(keyLeftShift, press(keyA)), "A", ""},
|
||||||
|
{"shifted digit", hold(keyLeftShift, press(key1)), "!", ""},
|
||||||
|
{"caps lock", slices.Concat(press(keyCapsLock), press(keyA, key1), press(keyCapsLock), press(keyA)), "A1a", ""},
|
||||||
|
{"caps lock with shift", slices.Concat(press(keyCapsLock), hold(keyLeftShift, press(keyA))), "a", ""},
|
||||||
|
{"ctrl letter", hold(keyLeftCtrl, press(keyA)), "\x01", ""},
|
||||||
|
{"ctrl shift letter", hold(keyLeftCtrl, hold(keyLeftShift, press(keyA))), "\x01", ""},
|
||||||
|
{"right ctrl letter", hold(fbsdKeyRightCtrl, press(keyA)), "\x01", ""},
|
||||||
|
{"ctrl space", hold(keyLeftCtrl, press(keySpace)), "\x00", ""},
|
||||||
|
{"ctrl backspace", hold(keyLeftCtrl, press(keyBackspace)), "\x7f", ""},
|
||||||
|
{"alt letter", hold(keyLeftAlt, press(keyA)), "\x1ba", ""},
|
||||||
|
{"alt enter", hold(keyLeftAlt, press(keyEnter)), "\x1b\r", ""},
|
||||||
|
{"ctrl alt letter", hold(keyLeftCtrl, hold(keyLeftAlt, press(keyA))), "\x1b\x01", ""},
|
||||||
|
{"right alt letter", hold(fbsdKeyRightAlt, press(keyA)), "a", "\x1ba"},
|
||||||
|
{"alt released", slices.Concat(hold(keyLeftAlt, press(keyA)), press(keyA)), "\x1baa", ""},
|
||||||
|
{"enter tab escape backspace", press(keyEnter, keyTab, keyEsc, keyBackspace), "\r\t\x1b\x08", ""},
|
||||||
|
{"back tab", hold(keyLeftShift, press(keyTab)), "\x1b[Z", ""},
|
||||||
|
{"alt back tab", hold(keyLeftAlt, hold(keyLeftShift, press(keyTab))), "\x1b[Z", ""},
|
||||||
|
{"function keys", press(keyF1, keyF6, fbsdKeyF12), "\x1bOP\x1b[17~\x1b[24~", ""},
|
||||||
|
{"shifted function key, no sequence", hold(keyLeftShift, press(keyF1)), "", ""},
|
||||||
|
{"editing keys", press(fbsdKeyHome, fbsdKeyEnd, fbsdKeyInsert, fbsdKeyDelete, fbsdKeyPageUp), "\x1b[H\x1b[F\x1b[2~\x1b[3~\x1b[5~", ""},
|
||||||
|
{"cursor", press(fbsdKeyUp, fbsdKeyLeft), "\x1b[A\x1b[D", ""},
|
||||||
|
{"shifted cursor", hold(keyLeftShift, press(fbsdKeyUp)), "\x1b[A", ""},
|
||||||
|
{"keypad, num lock off", press(keyKP8, keyKP5, keyKP0, keyKPDot, fbsdKeyKPEnter), "\x1b[A\x1b[2~\x7f\r", ""},
|
||||||
|
{"keypad, num lock on", slices.Concat(press(keyNumLock), press(keyKP8, keyKP0, keyKPDot)), "80.", ""},
|
||||||
|
{"repeat", []stroke{{keyQ, true}, {keyQ, true}, {keyQ, true}, {keyQ, false}}, "qqq", ""},
|
||||||
|
{"shift released first", []stroke{{keyLeftShift, true}, {keyA, true}, {keyLeftShift, false}, {keyA, true}, {keyA, false}}, "Aa", ""},
|
||||||
|
{"two shifts", []stroke{{keyLeftShift, true}, {keyRtShift, true}, {keyLeftShift, false}, {keyA, true}, {keyA, false}, {keyRtShift, false}}, "A", ""},
|
||||||
|
{"alt keypad code", hold(keyLeftAlt, press(keyKP6, keyKP5)), "A", ""},
|
||||||
|
{"alt keypad code, num lock on", slices.Concat(press(keyNumLock), hold(keyLeftAlt, press(keyKP2, keyKP3, keyKP3))), "é", ""},
|
||||||
|
{"alt keypad code past a byte", hold(keyLeftAlt, press(keyKP9, keyKP9, keyKP9)), "", ""},
|
||||||
|
{"another key ends a code", slices.Concat([]stroke{{keyLeftAlt, true}}, press(keyKP6, keyA, keyA), []stroke{{keyLeftAlt, false}}), "\x1ba", ""},
|
||||||
|
{"right alt keypad", hold(fbsdKeyRightAlt, press(keyKP6)), "6", "\x1b6"},
|
||||||
|
{"inert keys", slices.Concat(
|
||||||
|
hold(keyLeftCtrl, hold(keyLeftAlt, press(fbsdKeyDelete))), // reboot
|
||||||
|
hold(keyLeftCtrl, hold(keyLeftAlt, press(keyEsc))), // the debugger
|
||||||
|
hold(keyLeftShift, press(fbsdKeyInsert)), // paste
|
||||||
|
hold(keyLeftAlt, press(fbsdKeyPause)), // suspend
|
||||||
|
press(keyScrollLk, fbsdKeyPause)), "", ""},
|
||||||
|
}
|
||||||
|
for _, altGr := range []bool{true, false} {
|
||||||
|
km := testFreeBSDKeymap(t)
|
||||||
|
km.altGr = altGr
|
||||||
|
for _, c := range cases {
|
||||||
|
want := c.want
|
||||||
|
if !altGr && c.altGr != "" {
|
||||||
|
want = c.altGr
|
||||||
|
}
|
||||||
|
k := &fbsdKeys{km: km}
|
||||||
|
got, _ := fbsdTypedBy(k, c.strokes)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s (altGr %v): typed %q, want %q", c.name, altGr, got, want)
|
||||||
|
}
|
||||||
|
if k.state&^fbsdLockMask != 0 || k.alt {
|
||||||
|
t.Errorf("%s (altGr %v): modifiers %#x (alt %v) left down", c.name, altGr, k.state&^fbsdLockMask, k.alt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys that switch consoles and the locks ask the console for what vt would
|
||||||
|
// do cooked: a console by number or the one before or after, and the locks
|
||||||
|
// kept with the keyboard. Scroll Lock asks for nothing, the console's history
|
||||||
|
// mode staying off.
|
||||||
|
func TestFreeBSDConsoleKeysAskTheConsole(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want []consoleAct
|
||||||
|
locks uint32
|
||||||
|
}{
|
||||||
|
{"alt f2", hold(keyLeftAlt, press(keyF2)), []consoleAct{{vt: 2}}, 0},
|
||||||
|
{"ctrl alt f1", hold(keyLeftCtrl, hold(keyLeftAlt, press(keyF1))), []consoleAct{{vt: 1}}, 0},
|
||||||
|
{"alt shift f1", hold(keyLeftAlt, hold(keyLeftShift, press(keyF1))), []consoleAct{{vt: 11}}, 0},
|
||||||
|
{"alt f12", hold(keyLeftAlt, press(fbsdKeyF12)), []consoleAct{{vt: 12}}, 0},
|
||||||
|
{"print screen, shifted", slices.Concat(press(fbsdKeyPrint), hold(keyLeftShift, press(fbsdKeyPrint))), []consoleAct{{vt: vtNext}, {vt: vtPrev}}, 0},
|
||||||
|
{"caps lock twice", press(keyCapsLock, keyCapsLock), []consoleAct{{leds: true}, {leds: true}}, 0},
|
||||||
|
{"caps lock held", []stroke{{keyCapsLock, true}, {keyCapsLock, true}, {keyCapsLock, false}}, []consoleAct{{leds: true}}, fbsdCLKED},
|
||||||
|
{"num lock", press(keyNumLock), []consoleAct{{leds: true}}, fbsdNLKED},
|
||||||
|
{"scroll lock", press(keyScrollLk), nil, 0},
|
||||||
|
{"a special key repeats as it went down", []stroke{
|
||||||
|
{keyLeftAlt, true}, {keyF2, true}, {keyLeftAlt, false}, {keyF2, true}, {keyF2, false},
|
||||||
|
}, []consoleAct{{vt: 2}}, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
k := &fbsdKeys{km: testFreeBSDKeymap(t)}
|
||||||
|
if _, got := fbsdTypedBy(k, c.strokes); !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("%s: asked %+v, want %+v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
if k.state != c.locks {
|
||||||
|
t.Errorf("%s: state %#x, want %#x", c.name, k.state, c.locks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation starts from the console's locks, with no modifier down
|
||||||
|
func TestFreeBSDConsoleSeedsFromTheConsole(t *testing.T) {
|
||||||
|
k := &fbsdKeys{km: testFreeBSDKeymap(t)}
|
||||||
|
fbsdTypedBy(k, []stroke{{keyLeftShift, true}, {keyLeftAlt, true}})
|
||||||
|
k.seed(fbsdNLKED | fbsdCLKED | fbsdShifts1)
|
||||||
|
if got, _ := fbsdTypedBy(k, press(keyKP8, keyA)); got != "8A" {
|
||||||
|
t.Errorf("typed %q, want %q", got, "8A")
|
||||||
|
}
|
||||||
|
if k.isDown(keyLeftShift) || k.state != fbsdNLKED|fbsdCLKED {
|
||||||
|
t.Errorf("seeded state %#x, left shift down %v", k.state, k.isDown(keyLeftShift))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AltGr, from a key some keymaps bind, and AltGr Lock, from a modifier that
|
||||||
|
// toggles it pressed alone, type the keys' AltGr entries
|
||||||
|
func TestFreeBSDConsoleAltGr(t *testing.T) {
|
||||||
|
km := testFreeBSDKeymap(t)
|
||||||
|
all := func(action uint32) fbsdKey {
|
||||||
|
return fbsdKey{actions: [8]uint32{action, action, action, action, action, action, action, action}, spcl: 0xff}
|
||||||
|
}
|
||||||
|
// AltGr looks every key up in the keymap's AltGr half, modifiers too
|
||||||
|
km.keys[fbsdKeyRightAlt] = all(fbsdASH)
|
||||||
|
km.keys[keyLeftShift] = all(fbsdLSHA)
|
||||||
|
km.keys[fbsdAltGrKeys+keyLeftShift] = all(fbsdLSHA)
|
||||||
|
km.keys[fbsdAltGrKeys+keyA] = fbsdKey{actions: [8]uint32{'α', 'Α', 1, 1, 'α', 'Α', 1, 1}, flgs: fbsdLockC}
|
||||||
|
k := &fbsdKeys{km: km}
|
||||||
|
strokes := slices.Concat(
|
||||||
|
hold(fbsdKeyRightAlt, press(keyA)),
|
||||||
|
press(keyA),
|
||||||
|
press(keyLeftShift), // AltGr Lock on
|
||||||
|
press(keyA),
|
||||||
|
hold(keyLeftShift, press(keyA)), // Shift with a key leaves the lock
|
||||||
|
press(keyLeftShift), // AltGr Lock off
|
||||||
|
press(keyA))
|
||||||
|
got, acts := fbsdTypedBy(k, strokes)
|
||||||
|
if got != "αaαΑa" {
|
||||||
|
t.Errorf("typed %q, want %q", got, "αaαΑa")
|
||||||
|
}
|
||||||
|
if want := []consoleAct{{leds: true}, {leds: true}}; !slices.Equal(acts, want) {
|
||||||
|
t.Errorf("asked %+v, want %+v", acts, want)
|
||||||
|
}
|
||||||
|
if k.state != 0 {
|
||||||
|
t.Errorf("state %#x left", k.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead keys type what the accent map makes of the character after them, as
|
||||||
|
// vt does cooked: the accented character, the accent for a space or the dead
|
||||||
|
// key again, and nothing for a character the accent map has no pair for or a
|
||||||
|
// special key, which ends the dead key. Modifiers keep it pending.
|
||||||
|
func TestFreeBSDConsoleDeadKeys(t *testing.T) {
|
||||||
|
km := testFreeBSDKeymap(t)
|
||||||
|
km.keys[keyGrave].actions[0], km.keys[keyGrave].actions[1] = fbsdFACC, fbsdFACC+3 // grave, tilde
|
||||||
|
km.keys[keyGrave].actions[2] = fbsdFACC + 4 // macron, with no accent
|
||||||
|
km.keys[keyGrave].spcl |= 0x80 | 0x40 | 0x20
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"dead key", press(keyGrave, keyA), "à"},
|
||||||
|
{"shifted dead key", slices.Concat(hold(keyLeftShift, press(keyGrave)), press(keyN)), "ñ"},
|
||||||
|
{"shifted character", slices.Concat(press(keyGrave), hold(keyLeftShift, press(keyA))), "À"},
|
||||||
|
{"dead key and space", press(keyGrave, keySpace), "`"},
|
||||||
|
{"dead key twice", press(keyGrave, keyGrave, keyA), "`a"},
|
||||||
|
{"no pair", press(keyGrave, keyB, keyA), "a"},
|
||||||
|
{"over a cursor key", press(keyGrave, fbsdKeyUp, keyA), "a"},
|
||||||
|
{"no accent", slices.Concat(hold(keyLeftCtrl, press(keyGrave)), press(keyA)), "a"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
k := &fbsdKeys{km: km}
|
||||||
|
if got, _ := fbsdTypedBy(k, c.strokes); got != c.want {
|
||||||
|
t.Errorf("%s: typed %q, want %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,48 @@ 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
|
||||||
|
|
||||||
|
// The console the backend runs on, nil for none, and how the reader
|
||||||
|
// switches its keyboard raw, and back
|
||||||
|
console keyConsole
|
||||||
|
keysRaw func() (ok, again bool)
|
||||||
|
keysCooked func()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// The console: whether its keyboard refused to go raw, for good (-1) or
|
||||||
|
// for this ask (-2), and the parser reading what it types for a key
|
||||||
|
consoleHas int8
|
||||||
|
typer *inputReader
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -66,6 +116,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,13 +176,28 @@ 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.end()
|
||||||
r.sendEvent(Event{Type: EventError, Err: err})
|
r.sendEvent(Event{Type: EventError, Err: err})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.source == KeySourceConsole {
|
||||||
|
r.consoleRead(data)
|
||||||
|
select {
|
||||||
|
case <-r.stopCh:
|
||||||
|
r.end()
|
||||||
|
r.sendEvent(Event{Type: EventClosed})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A paste whose end marker never comes ends when input pauses, so
|
// A paste whose end marker never comes ends when input pauses, so
|
||||||
// keys are never swallowed for good; a read may block (wasm)
|
// keys are never swallowed for good; a read may block (wasm)
|
||||||
if r.pasting && time.Since(r.pasteSeen) > pasteIdle {
|
if r.pasting && time.Since(r.pasteSeen) > pasteIdle {
|
||||||
@@ -144,11 +210,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.end()
|
||||||
r.sendEvent(Event{Type: EventClosed})
|
r.sendEvent(Event{Type: EventClosed})
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
@@ -174,6 +241,14 @@ func (r *inputReader) readLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// end releases what is held as input ends, and the console's keyboard
|
||||||
|
func (r *inputReader) end() {
|
||||||
|
r.holds.ReleaseAll(r.sendEvent)
|
||||||
|
if r.source == KeySourceConsole {
|
||||||
|
r.console.cooked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseInput parses raw bytes into events and returns bytes consumed (stop on incomplete sequence)
|
// parseInput parses raw bytes into events and returns bytes consumed (stop on incomplete sequence)
|
||||||
func (r *inputReader) parseInput(data []byte) int {
|
func (r *inputReader) parseInput(data []byte) int {
|
||||||
i := 0
|
i := 0
|
||||||
@@ -186,6 +261,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 +298,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 +317,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 +328,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 +355,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 +411,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 +441,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 +541,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,77 +622,45 @@ 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, or a button past 9, is
|
||||||
// Format: ESC [ < Btn ; X ; Y M/m
|
// dropped as KeyNone
|
||||||
// Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes
|
func parseSGRMouse(params []byte, final byte) Event {
|
||||||
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
|
||||||
|
|
||||||
// Decode button and action
|
// Bits 0-1 number a button within its bank: the main three and none, the
|
||||||
// Bits 0-1: button (0=left, 1=middle, 2=right, 3=release)
|
// wheel's four directions (bit 6), or buttons 8 to 11 (bit 7); bit 5 is motion
|
||||||
// Bit 5 (32): motion
|
id := btn & 0x03
|
||||||
// Bit 6 (64): scroll
|
switch btn & (64 | 128) {
|
||||||
buttonID := btn & 0x03
|
|
||||||
isMotion := btn&32 != 0
|
|
||||||
isScroll := btn&64 != 0
|
|
||||||
|
|
||||||
if isScroll {
|
|
||||||
// Scroll: buttonID 0=up, 1=down
|
|
||||||
if buttonID == 0 {
|
|
||||||
ev.MouseBtn = MouseBtnWheelUp
|
|
||||||
} else {
|
|
||||||
ev.MouseBtn = MouseBtnWheelDown
|
|
||||||
}
|
|
||||||
ev.MouseAction = MouseActionPress // Scroll is instantaneous
|
|
||||||
} else {
|
|
||||||
// Regular button
|
|
||||||
switch buttonID {
|
|
||||||
case 0:
|
case 0:
|
||||||
ev.MouseBtn = MouseBtnLeft
|
ev.MouseBtn = [...]MouseButton{MouseBtnLeft, MouseBtnMiddle, MouseBtnRight, MouseBtnNone}[id]
|
||||||
case 1:
|
case 64:
|
||||||
ev.MouseBtn = MouseBtnMiddle
|
ev.MouseBtn = [...]MouseButton{MouseBtnWheelUp, MouseBtnWheelDown, MouseBtnWheelLeft, MouseBtnWheelRight}[id]
|
||||||
case 2:
|
case 128:
|
||||||
ev.MouseBtn = MouseBtnRight
|
if id > 1 {
|
||||||
case 3:
|
return Event{Type: EventKey, Key: KeyNone}
|
||||||
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
}
|
||||||
|
ev.MouseBtn = MouseBtnBack + MouseButton(id)
|
||||||
|
default:
|
||||||
|
return Event{Type: EventKey, Key: KeyNone}
|
||||||
}
|
}
|
||||||
|
|
||||||
if data[end] == 'M' {
|
switch {
|
||||||
if isMotion {
|
case btn&64 != 0:
|
||||||
if ev.MouseBtn != MouseBtnNone {
|
ev.MouseAction = MouseActionPress // A wheel step is its press
|
||||||
ev.MouseAction = MouseActionDrag
|
case final == 'm':
|
||||||
} else {
|
|
||||||
ev.MouseAction = MouseActionMove
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev.MouseAction = MouseActionPress
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev.MouseAction = MouseActionRelease
|
ev.MouseAction = MouseActionRelease
|
||||||
}
|
case btn&32 == 0:
|
||||||
|
ev.MouseAction = MouseActionPress
|
||||||
|
case ev.MouseBtn != MouseBtnNone:
|
||||||
|
ev.MouseAction = MouseActionDrag
|
||||||
|
default:
|
||||||
|
ev.MouseAction = MouseActionMove
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract modifiers from button byte
|
// Extract modifiers from button byte
|
||||||
@@ -567,7 +674,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 +712,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 +790,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 {
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
// The console source: a kernel console's keyboard switched to raw keycodes.
|
||||||
|
// Each key types what the console types for it cooked, read as a legacy
|
||||||
|
// terminal's bytes are, and is held by its keycode.
|
||||||
|
|
||||||
|
// keyConsole is a kernel console whose keyboard the reader can switch to raw
|
||||||
|
// keycodes: the Linux console, FreeBSD's vt(4). The reader calls it from its
|
||||||
|
// goroutine; close comes from Fini, on another.
|
||||||
|
type keyConsole interface {
|
||||||
|
// prepare reads what translating keys needs and readies the switch: the
|
||||||
|
// keymap, the guard, output a Scroll Lock stopped. False when the console
|
||||||
|
// can't go raw.
|
||||||
|
prepare() bool
|
||||||
|
|
||||||
|
// raw switches the keyboard to raw keycodes, false when the console refuses
|
||||||
|
raw() bool
|
||||||
|
|
||||||
|
// cooked switches the keyboard back to the mode raw found
|
||||||
|
cooked()
|
||||||
|
|
||||||
|
// keys decodes raw keycodes, keeping a partial one for the next call, and
|
||||||
|
// reports each key event with what the console types for it
|
||||||
|
keys(data []byte, key func(code uint32, action KeyAction, typed []byte))
|
||||||
|
|
||||||
|
// front is whether the console is the one on screen, the one the
|
||||||
|
// keyboard types into
|
||||||
|
front() bool
|
||||||
|
|
||||||
|
// close switches the keyboard back if it is raw and ends the guard
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleAct is what a key asks of the console beside typing
|
||||||
|
type consoleAct struct {
|
||||||
|
vt int // a console to switch to, from 1, or vtPrev or vtNext; zero for none
|
||||||
|
leds bool // the lock flags changed
|
||||||
|
start bool // restart output stopped with Scroll Lock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console switches a key asks for beside switching to a console by number
|
||||||
|
const (
|
||||||
|
vtPrev = -1 // the console before this one
|
||||||
|
vtNext = -2 // the console after this one
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleBackend is a backend on a kernel console
|
||||||
|
type consoleBackend interface {
|
||||||
|
keyConsole() keyConsole
|
||||||
|
}
|
||||||
|
|
||||||
|
// backendConsole is the console b runs on, nil for none
|
||||||
|
func backendConsole(b Backend) keyConsole {
|
||||||
|
if c, ok := b.(consoleBackend); ok {
|
||||||
|
return c.keyConsole()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConsole switches the console's keyboard raw for releases; a console that
|
||||||
|
// refuses is not asked again, or not until the next ask when it may not
|
||||||
|
// refuse then
|
||||||
|
func (r *inputReader) rawConsole() {
|
||||||
|
// An ESC waiting for its timeout was Escape, pressed before the switch
|
||||||
|
if ev, ok := loneEscape[string(r.buf)]; ok {
|
||||||
|
r.sendKey(0, ev)
|
||||||
|
}
|
||||||
|
r.buf = r.buf[:0]
|
||||||
|
if r.keysRaw == nil {
|
||||||
|
r.consoleHas = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok, again := r.keysRaw(); !ok {
|
||||||
|
r.consoleHas = -1
|
||||||
|
if again {
|
||||||
|
r.consoleHas = -2
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.holds = KeyHolds{}
|
||||||
|
r.source, r.report = KeySourceConsole, KeyReportReleases
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleRead reads raw keycodes, and ends every hold while another console
|
||||||
|
// has the keyboard: their releases go there
|
||||||
|
func (r *inputReader) consoleRead(data []byte) {
|
||||||
|
if len(data) > 0 {
|
||||||
|
r.console.keys(data, r.consoleKey)
|
||||||
|
}
|
||||||
|
if !r.console.front() {
|
||||||
|
r.holds.ReleaseAll(r.sendEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleKey sends a key event as what the console types for it reads: the
|
||||||
|
// last key held by the keycode, any before it as taps, as a dead key's
|
||||||
|
// character the key types before its own
|
||||||
|
func (r *inputReader) consoleKey(code uint32, action KeyAction, typed []byte) {
|
||||||
|
if action == KeyRelease {
|
||||||
|
r.sendKey(code, Event{Type: EventKey, Action: KeyRelease})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var last Event
|
||||||
|
have := false
|
||||||
|
r.typedKeys(typed, func(ev Event) {
|
||||||
|
if have {
|
||||||
|
r.sendKey(0, last)
|
||||||
|
}
|
||||||
|
last, have = ev, true
|
||||||
|
})
|
||||||
|
if have {
|
||||||
|
last.Action = action
|
||||||
|
r.sendKey(code, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// typedKeys reads what a console types for one key as a legacy terminal's
|
||||||
|
// input reads, the key whole: an ESC it ends with is Escape
|
||||||
|
func (r *inputReader) typedKeys(typed []byte, key func(Event)) {
|
||||||
|
if len(typed) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := r.typer
|
||||||
|
if p == nil {
|
||||||
|
// A parser of its own: no paste, no protocol, no stop
|
||||||
|
p = &inputReader{eventCh: make(chan Event, 512)}
|
||||||
|
r.typer = p
|
||||||
|
}
|
||||||
|
p.dropCSI = false
|
||||||
|
n := p.parseInput(typed)
|
||||||
|
if ev, ok := loneEscape[string(typed[n:])]; ok {
|
||||||
|
p.sendKey(0, ev)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-p.eventCh:
|
||||||
|
if ev.Type == EventKey && ev.Key != KeyNone {
|
||||||
|
key(ev)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeConsole is a backend on a Linux console: its keyboard goes raw when
|
||||||
|
// asked, unless it refuses, and raw, its input is K_MEDIUMRAW keycodes typed
|
||||||
|
// by the kernel's built-in keymap
|
||||||
|
type fakeConsole struct {
|
||||||
|
fakeBackend
|
||||||
|
refuse bool
|
||||||
|
notNow int // raw refusals left, as a console that can't go raw just now
|
||||||
|
isRaw bool
|
||||||
|
away bool
|
||||||
|
prepares int
|
||||||
|
rawAt int // how much output the terminal had written when the keyboard went raw
|
||||||
|
cookedAt int // and when it went back
|
||||||
|
closed bool
|
||||||
|
kb linuxKeys
|
||||||
|
dec mediumRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeConsole(t *testing.T) *fakeConsole {
|
||||||
|
return &fakeConsole{kb: linuxKeys{km: testKeymap(t)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) keySources() KeySource { return KeySourceConsole | KeySourceProtocol }
|
||||||
|
func (f *fakeConsole) keyConsole() keyConsole { return f }
|
||||||
|
func (f *fakeConsole) Fini() { f.close() } // as unixBackend's
|
||||||
|
|
||||||
|
func (f *fakeConsole) prepare() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.prepares++
|
||||||
|
return !f.refuse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) raw() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.notNow > 0 {
|
||||||
|
f.notNow--
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.isRaw, f.rawAt = true, f.out.Len()
|
||||||
|
f.kb.seed(0, 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) cooked() {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.isRaw {
|
||||||
|
f.isRaw, f.cookedAt = false, f.out.Len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) keys(data []byte, key func(code uint32, action KeyAction, typed []byte)) {
|
||||||
|
f.dec.decode(data, func(code int, down bool) {
|
||||||
|
action := KeyRelease
|
||||||
|
if down {
|
||||||
|
action = KeyPress
|
||||||
|
if f.kb.isDown(code) {
|
||||||
|
action = KeyRepeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typed, _ := f.kb.key(code, down, action == KeyRepeat)
|
||||||
|
key(uint32(code), action, typed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) front() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return !f.away
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsole) close() {
|
||||||
|
f.cooked()
|
||||||
|
f.mu.Lock()
|
||||||
|
f.closed = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyed hands the reader keycodes, as the raw console sends them
|
||||||
|
func (f *fakeConsole) keyed(codes ...byte) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.chunks = append(f.chunks, string(codes))
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// state reads the fake's fields under its lock
|
||||||
|
func (f *fakeConsole) state() (isRaw bool, prepares int, out string, rawAt, cookedAt int, closed bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.isRaw, f.prepares, f.out.String(), f.rawAt, f.cookedAt, f.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// up is a keycode's release byte
|
||||||
|
func up(code byte) byte { return code | 0x80 }
|
||||||
|
|
||||||
|
// keyEvent is a key event as the console source sends it
|
||||||
|
func keyEvent(action KeyAction, r rune, lost bool) Event {
|
||||||
|
return Event{Type: EventKey, Key: KeyRune, Rune: r, Action: action, Lost: lost}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console's keyboard goes raw when releases are asked of it, before the
|
||||||
|
// protocol. Raw, each key types as it does cooked and is held by its keycode:
|
||||||
|
// Shift let go first still releases 'A', a key held repeats, and another
|
||||||
|
// console taking the keyboard ends the holds Lost. Mouse reports are off while
|
||||||
|
// the keyboard is raw, the console having taken that first, and come back on
|
||||||
|
// with it. Going back to presses switches the keyboard back, and so does the
|
||||||
|
// input ending at Fini, before the backend closes the console.
|
||||||
|
func TestConsoleSourceComesAndGoes(t *testing.T) {
|
||||||
|
f := newFakeConsole(t)
|
||||||
|
term := newTerm(f, ColorModeNone)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
term.SetMouseMode(MouseModeClick)
|
||||||
|
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole|KeySourceProtocol)
|
||||||
|
if ev := poll(t, term); ev.Type != EventKeyReport || ev.Report != KeyReportReleases || ev.Source != KeySourceConsole {
|
||||||
|
t.Fatalf("answered %+v, want releases from the console", ev)
|
||||||
|
}
|
||||||
|
isRaw, _, out, rawAt, _, _ := f.state()
|
||||||
|
if !isRaw {
|
||||||
|
t.Fatal("keyboard not raw")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out[:rawAt], string(csiMouseClickOff)) {
|
||||||
|
t.Errorf("mouse reports still on as the keyboard went raw: %q", out[:rawAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
f.keyed(keyLeftShift, keyA, up(keyLeftShift), up(keyA))
|
||||||
|
f.keyed(keyQ, keyQ, up(keyQ))
|
||||||
|
f.keyed(keyEnter, up(keyEnter))
|
||||||
|
want := []Event{
|
||||||
|
keyEvent(KeyPress, 'A', false), keyEvent(KeyRelease, 'A', false),
|
||||||
|
keyEvent(KeyPress, 'q', false), keyEvent(KeyRepeat, 'q', false), keyEvent(KeyRelease, 'q', false),
|
||||||
|
{Type: EventKey, Key: KeyEnter, Action: KeyPress}, {Type: EventKey, Key: KeyEnter, Action: KeyRelease},
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if ev := poll(t, term); ev != w {
|
||||||
|
t.Fatalf("event %d: %+v, want %+v", i, ev, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.keyed(keyA)
|
||||||
|
if ev := poll(t, term); ev != keyEvent(KeyPress, 'a', false) {
|
||||||
|
t.Fatalf("got %+v, want a press of 'a'", ev)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.away = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
if ev := poll(t, term); ev != keyEvent(KeyRelease, 'a', true) {
|
||||||
|
t.Fatalf("got %+v, want 'a' released Lost", ev)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.away = false
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
term.SetKeyReport(KeyReportPresses, 0)
|
||||||
|
if ev := poll(t, term); ev.Type != EventKeyReport || ev.Report != KeyReportPresses || ev.Source != 0 {
|
||||||
|
t.Fatalf("answered %+v, want presses", ev)
|
||||||
|
}
|
||||||
|
waitFor(t, "mouse reports back on", func() bool {
|
||||||
|
isRaw, _, out, _, cookedAt, _ := f.state()
|
||||||
|
return !isRaw && strings.Contains(out[cookedAt:], string(csiMouseClickOn))
|
||||||
|
})
|
||||||
|
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole)
|
||||||
|
poll(t, term)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.closed = false
|
||||||
|
f.mu.Unlock()
|
||||||
|
term.input.stop()
|
||||||
|
if isRaw, _, _, _, _, closed := f.state(); isRaw || closed {
|
||||||
|
t.Errorf("input ended with the keyboard raw %v, the console closed %v", isRaw, closed)
|
||||||
|
}
|
||||||
|
term.Fini()
|
||||||
|
if _, _, _, _, _, closed := f.state(); !closed {
|
||||||
|
t.Error("Fini left the console open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A console that can't go raw is not asked again: the protocol is next, and
|
||||||
|
// the console here doesn't answer its questions
|
||||||
|
func TestConsoleRefusingIsAskedOnce(t *testing.T) {
|
||||||
|
f := newFakeConsole(t)
|
||||||
|
f.refuse = true
|
||||||
|
old := protoTimeout
|
||||||
|
protoTimeout = 50 * time.Millisecond
|
||||||
|
defer func() { protoTimeout = old }()
|
||||||
|
term := newTerm(f, ColorModeNone)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
for range 2 {
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole|KeySourceProtocol)
|
||||||
|
if ev := poll(t, term); ev.Type != EventKeyReport || ev.Report != KeyReportPresses {
|
||||||
|
t.Fatalf("answered %+v, want presses", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, prepares, _, _, _, _ := f.state(); prepares != 1 {
|
||||||
|
t.Errorf("console asked %d times, want once", prepares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A console that can't go raw just now is asked again at the next ask, and
|
||||||
|
// mouse reports come back on after the refusal
|
||||||
|
func TestConsoleRefusingNowIsAskedAgain(t *testing.T) {
|
||||||
|
f := newFakeConsole(t)
|
||||||
|
f.notNow = 1
|
||||||
|
old := protoTimeout
|
||||||
|
protoTimeout = 50 * time.Millisecond
|
||||||
|
defer func() { protoTimeout = old }()
|
||||||
|
term := newTerm(f, ColorModeNone)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
term.SetMouseMode(MouseModeClick)
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole|KeySourceProtocol)
|
||||||
|
if ev := poll(t, term); ev.Type != EventKeyReport || ev.Report != KeyReportPresses {
|
||||||
|
t.Fatalf("answered %+v, want presses", ev)
|
||||||
|
}
|
||||||
|
waitFor(t, "mouse reports back on", func() bool {
|
||||||
|
_, _, out, _, _, _ := f.state()
|
||||||
|
return strings.Count(out, string(csiMouseClickOn)) == 2
|
||||||
|
})
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole|KeySourceProtocol)
|
||||||
|
if ev := poll(t, term); ev.Type != EventKeyReport || ev.Source != KeySourceConsole {
|
||||||
|
t.Fatalf("answered %+v, want releases from the console", ev)
|
||||||
|
}
|
||||||
|
if _, prepares, _, _, _, _ := f.state(); prepares != 2 {
|
||||||
|
t.Errorf("console prepared %d times, want twice", prepares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse reporting asked for while the keyboard is raw starts once it is
|
||||||
|
// cooked again
|
||||||
|
func TestConsoleHoldsMouseReportsWhileRaw(t *testing.T) {
|
||||||
|
f := newFakeConsole(t)
|
||||||
|
term := newTerm(f, ColorModeNone)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole)
|
||||||
|
poll(t, term)
|
||||||
|
term.SetMouseMode(MouseModeClick | MouseModeDrag)
|
||||||
|
if _, _, out, rawAt, _, _ := f.state(); strings.Contains(out[rawAt:], string(csiMouseSGROn)) {
|
||||||
|
t.Fatalf("mouse reports on while raw: %q", out[rawAt:])
|
||||||
|
}
|
||||||
|
term.SetKeyReport(KeyReportPresses, 0)
|
||||||
|
poll(t, term)
|
||||||
|
waitFor(t, "mouse reports on", func() bool {
|
||||||
|
_, _, out, _, cookedAt, _ := f.state()
|
||||||
|
return strings.Contains(out[cookedAt:], string(csiMouseDragOn))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key whose string reads as several keys taps all but the last, which it
|
||||||
|
// holds by its keycode
|
||||||
|
func TestConsoleKeyTypingSeveralKeys(t *testing.T) {
|
||||||
|
f := newFakeConsole(t)
|
||||||
|
f.kb.km.funcs[0] = "ab" // F1
|
||||||
|
term := newTerm(f, ColorModeNone)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
term.SetKeyReport(KeyReportReleases, KeySourceConsole)
|
||||||
|
poll(t, term)
|
||||||
|
f.keyed(keyF1, up(keyF1))
|
||||||
|
want := []Event{
|
||||||
|
keyEvent(KeyPress, 'a', false), keyEvent(KeyRelease, 'a', false), keyEvent(KeyPress, 'b', false),
|
||||||
|
keyEvent(KeyRelease, 'b', false),
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if ev := poll(t, term); ev != w {
|
||||||
|
t.Fatalf("event %d: %+v, want %+v", i, ev, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The guard restores a console keyboard the game leaves raw by dying where no
|
||||||
|
// handler of its own runs: SIGKILL, the OOM killer, a Go fatal error. Before
|
||||||
|
// the first raw switch the package runs its program's binary again with
|
||||||
|
// guardEnv set, which the package's init catches before main. The guard keeps
|
||||||
|
// the console and the read end of a pipe whose write end only the game holds:
|
||||||
|
// the pipe's end is the game's, and if the keyboard is raw then, the guard
|
||||||
|
// switches it back, restores the termios and shows the cursor.
|
||||||
|
|
||||||
|
// guardEnv marks a run of the binary as a guard, which ps lists as guardName
|
||||||
|
const (
|
||||||
|
guardEnv = "LIXENWRAITH_TERMINAL_GUARD"
|
||||||
|
guardName = "console-guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The guard's messages: armed with the keyboard mode and termios to restore,
|
||||||
|
// or disarmed
|
||||||
|
const (
|
||||||
|
guardArm = 'A'
|
||||||
|
guardDisarm = 'D'
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if os.Getenv(guardEnv) == "1" {
|
||||||
|
nameGuard()
|
||||||
|
os.Exit(runGuard(os.NewFile(3, "guard"), os.NewFile(4, "console")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyGuard is the game's end of its guard
|
||||||
|
type keyGuard struct {
|
||||||
|
w *os.File // the pipe's write end, closed on exec in any other child
|
||||||
|
}
|
||||||
|
|
||||||
|
// startGuard runs the guard over tty
|
||||||
|
func startGuard(tty *os.File) (*keyGuard, error) {
|
||||||
|
exe, err := guardExecutable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
// Named apart from the game, which on Linux spares it a kill by the
|
||||||
|
// game's name
|
||||||
|
cmd := exec.Command(exe)
|
||||||
|
cmd.Args = []string{guardName}
|
||||||
|
cmd.Env = append(os.Environ(), guardEnv+"=1")
|
||||||
|
cmd.ExtraFiles = []*os.File{r, tty}
|
||||||
|
// A group of its own, so a kill of the game's job leaves it to restore.
|
||||||
|
// A game leading its session takes the console with it as it dies, the
|
||||||
|
// kernel hanging the console up: its guard leads a session of its own, to
|
||||||
|
// take the console up again.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if sid, err := unix.Getsid(0); err == nil && sid == os.Getpid() {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go cmd.Wait()
|
||||||
|
return &keyGuard{w: w}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// termiosBytes is t as the guard reads it, the same binary on both ends
|
||||||
|
func termiosBytes(t *unix.Termios) []byte {
|
||||||
|
return unsafe.Slice((*byte)(unsafe.Pointer(t)), unsafe.Sizeof(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm tells the guard the keyboard is about to go raw, and what restores it;
|
||||||
|
// false when the guard is gone. One write, under the pipe's atomic size.
|
||||||
|
func (g *keyGuard) arm(mode int, cooked *unix.Termios) bool {
|
||||||
|
_, err := g.w.Write(append([]byte{guardArm, byte(mode)}, termiosBytes(cooked)...))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// disarm tells the guard the keyboard is cooked again
|
||||||
|
func (g *keyGuard) disarm() { g.w.Write([]byte{guardDisarm}) }
|
||||||
|
|
||||||
|
// close ends the guard, which exits disarmed
|
||||||
|
func (g *keyGuard) close() { g.w.Close() }
|
||||||
|
|
||||||
|
// guardReset is what the guard writes after restoring the keyboard: mouse
|
||||||
|
// reports off, the cursor shown, attributes reset, but no RIS, which would
|
||||||
|
// clear the message the game died with
|
||||||
|
var guardReset = string(csiMouseMotionOff) + string(csiMouseDragOff) + string(csiMouseClickOff) +
|
||||||
|
string(csiMouseSGROff) + string(csiPasteOff) + string(csiCursorShow) + string(csiSGR0) + string(csiAutoWrapOn)
|
||||||
|
|
||||||
|
// runGuard waits on cmds until the game is gone, and restores the console
|
||||||
|
// tty if the keyboard was raw then
|
||||||
|
func runGuard(cmds, tty *os.File) int {
|
||||||
|
signal.Ignore(syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM,
|
||||||
|
syscall.SIGTSTP, syscall.SIGTTIN, syscall.SIGTTOU, syscall.SIGPIPE)
|
||||||
|
path := consolePath(tty)
|
||||||
|
armed, mode, cooked := guardWait(cmds)
|
||||||
|
if armed {
|
||||||
|
guardRestore(tty, path, mode, func(fd int) {
|
||||||
|
unix.IoctlSetTermios(fd, tcsets, &cooked)
|
||||||
|
unix.Write(fd, []byte(guardReset))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardReopenFor is how long the guard tries to open again a console its
|
||||||
|
// game took along
|
||||||
|
const guardReopenFor = 2 * time.Second
|
||||||
|
|
||||||
|
// guardReopen opens the console at path again, until its keyboard switches
|
||||||
|
// back to mode or guardReopenFor passes, and has rest restore the rest
|
||||||
|
// through it
|
||||||
|
func guardReopen(path string, mode int, rest func(fd int)) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for end := time.Now().Add(guardReopenFor); time.Now().Before(end); time.Sleep(10 * time.Millisecond) {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok := unix.IoctlSetInt(int(f.Fd()), kdSetKeyboardMode, mode) == nil
|
||||||
|
if ok {
|
||||||
|
rest(int(f.Fd()))
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardWait reads the game's messages to their end: whether the guard was
|
||||||
|
// armed then, and with what
|
||||||
|
func guardWait(cmds io.Reader) (armed bool, mode int, cooked unix.Termios) {
|
||||||
|
var msg [1]byte
|
||||||
|
arg := make([]byte, 1+len(termiosBytes(&cooked)))
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(cmds, msg[:]); err != nil {
|
||||||
|
return armed, mode, cooked
|
||||||
|
}
|
||||||
|
switch msg[0] {
|
||||||
|
case guardArm:
|
||||||
|
if _, err := io.ReadFull(cmds, arg); err != nil {
|
||||||
|
return armed, mode, cooked
|
||||||
|
}
|
||||||
|
armed, mode = true, int(arg[0])
|
||||||
|
copy(termiosBytes(&cooked), arg[1:])
|
||||||
|
case guardDisarm:
|
||||||
|
armed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The guard ends armed with the last arm's mode and termios when the arms
|
||||||
|
// outnumber the disarms, and disarmed otherwise
|
||||||
|
func TestGuardFollowsArmsToTheEnd(t *testing.T) {
|
||||||
|
arm := func(mode byte, iflag uint32) []byte {
|
||||||
|
tio := unix.Termios{Iflag: iflag}
|
||||||
|
return append([]byte{guardArm, mode}, termiosBytes(&tio)...)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msgs [][]byte
|
||||||
|
armed bool
|
||||||
|
mode int
|
||||||
|
iflag uint32
|
||||||
|
}{
|
||||||
|
{"never armed", nil, false, 0, 0},
|
||||||
|
{"armed", [][]byte{arm(3, 7)}, true, 3, 7},
|
||||||
|
{"disarmed", [][]byte{arm(3, 7), {guardDisarm}}, false, 0, 0},
|
||||||
|
{"armed again", [][]byte{arm(3, 7), {guardDisarm}, arm(1, 9)}, true, 1, 9},
|
||||||
|
{"cut off arming", [][]byte{{guardArm, 3, 1, 2}}, false, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
armed, mode, tio := guardWait(bytes.NewReader(bytes.Join(c.msgs, nil)))
|
||||||
|
if armed != c.armed || armed && (mode != c.mode || tio.Iflag != c.iflag) {
|
||||||
|
t.Errorf("%s: armed %v mode %d iflag %d, want %v %d %d", c.name, armed, mode, tio.Iflag, c.armed, c.mode, c.iflag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPty opens a pty pair, the far end standing in for the console
|
||||||
|
func openPty(t *testing.T) (master, tty *os.File) {
|
||||||
|
t.Helper()
|
||||||
|
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no pty:", err)
|
||||||
|
}
|
||||||
|
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tty, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { tty.Close(); master.Close() })
|
||||||
|
return master, tty
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardGame is a game for TestGuardRestoresWhenTheGameIsKilled: it guards the
|
||||||
|
// pty it is handed, arms the guard with the pty's termios as cooked, makes
|
||||||
|
// the pty raw, says so and waits to be killed
|
||||||
|
func TestGuardGame(t *testing.T) {
|
||||||
|
path := os.Getenv("TERMINAL_TEST_GUARD_GAME")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("run by TestGuardRestoresWhenTheGameIsKilled")
|
||||||
|
}
|
||||||
|
tty, err := os.OpenFile(path, os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cooked, err := unix.IoctlGetTermios(int(tty.Fd()), tcgets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, err := startGuard(tty)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !g.arm(kUnicode, cooked) {
|
||||||
|
t.Fatal("arming failed")
|
||||||
|
}
|
||||||
|
raw := *cooked
|
||||||
|
raw.Lflag &^= unix.ICANON | unix.ECHO | unix.ISIG
|
||||||
|
if err := unix.IoctlSetTermios(int(tty.Fd()), tcsets, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if os.Getenv("TERMINAL_TEST_GUARD_DISARM") != "" {
|
||||||
|
g.disarm()
|
||||||
|
}
|
||||||
|
fmt.Println("raw")
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A game killed with its console raw leaves the guard to restore it: the
|
||||||
|
// termios the game found, and the cursor shown, with no reset that would
|
||||||
|
// clear the game's last words; a game that disarmed first leaves it as it was
|
||||||
|
func TestGuardRestoresWhenTheGameIsKilled(t *testing.T) {
|
||||||
|
for _, disarm := range []bool{false, true} {
|
||||||
|
master, tty := openPty(t)
|
||||||
|
cooked, err := unix.IoctlGetTermios(int(tty.Fd()), tcgets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
game := exec.Command(os.Args[0], "-test.run=^TestGuardGame$", "-test.v")
|
||||||
|
game.Env = append(os.Environ(), "TERMINAL_TEST_GUARD_GAME="+tty.Name())
|
||||||
|
if disarm {
|
||||||
|
game.Env = append(game.Env, "TERMINAL_TEST_GUARD_DISARM=1")
|
||||||
|
}
|
||||||
|
stdout, err := game.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := game.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := bufio.NewScanner(stdout)
|
||||||
|
for lines.Scan() && lines.Text() != "raw" {
|
||||||
|
}
|
||||||
|
game.Process.Kill()
|
||||||
|
game.Wait()
|
||||||
|
|
||||||
|
restored := func() bool {
|
||||||
|
now, err := unix.IoctlGetTermios(int(tty.Fd()), tcgets)
|
||||||
|
return err == nil && now.Lflag == cooked.Lflag
|
||||||
|
}
|
||||||
|
if !disarm {
|
||||||
|
waitFor(t, "the termios restored", restored)
|
||||||
|
out := readPending(t, master)
|
||||||
|
if !strings.Contains(out, string(csiCursorShow)) || strings.Contains(out, string(csiRIS)) {
|
||||||
|
t.Errorf("guard wrote %q, want the cursor shown and no reset", out)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if restored() {
|
||||||
|
t.Error("a disarmed guard restored the termios")
|
||||||
|
}
|
||||||
|
if out := readPending(t, master); out != "" {
|
||||||
|
t.Errorf("a disarmed guard wrote %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPending is what the pty's master has to read now
|
||||||
|
func readPending(t *testing.T, master *os.File) string {
|
||||||
|
t.Helper()
|
||||||
|
fds := []unix.PollFd{{Fd: int32(master.Fd()), Events: unix.POLLIN}}
|
||||||
|
var out []byte
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
if n, err := unix.Poll(fds, 100); err != nil || n == 0 {
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
n, err := master.Read(buf)
|
||||||
|
if err != nil && err != io.EOF || n == 0 {
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
out = append(out, buf[:n]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
+417
@@ -0,0 +1,417 @@
|
|||||||
|
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
|
||||||
|
if r.consoleHas == -2 {
|
||||||
|
r.consoleHas = 0 // refused the last ask only
|
||||||
|
}
|
||||||
|
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&KeySourceConsole != 0 && r.consoleHas >= 0:
|
||||||
|
return KeySourceConsole
|
||||||
|
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 target == KeySourceConsole:
|
||||||
|
r.rawConsole()
|
||||||
|
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
|
||||||
|
switch source {
|
||||||
|
case KeySourceProtocol:
|
||||||
|
r.request(exchangePop, false, csiKeyPop, csiFocusOff, csiDA1)
|
||||||
|
case KeySourceConsole:
|
||||||
|
r.keysCooked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Linux console's keymap and its translation of keycodes, after the
|
||||||
|
// kernel's own (drivers/tty/vt/keyboard.c), so a keyboard switched to raw
|
||||||
|
// keycodes types what the console types with it cooked. Keysyms are kept as
|
||||||
|
// the kernel keeps them: 0xf000 | type<<8 | value for a typed one, anything
|
||||||
|
// less a Unicode code point.
|
||||||
|
|
||||||
|
// Keysym types (linux/keyboard.h)
|
||||||
|
const (
|
||||||
|
ktLatin = iota // a Latin-1 character
|
||||||
|
ktFn // a function key's string
|
||||||
|
ktSpec // a console function: Enter, the locks, console switching
|
||||||
|
ktPad // a keypad key
|
||||||
|
ktDead // a dead key
|
||||||
|
ktCons // a switch to a console
|
||||||
|
ktCur // a cursor key
|
||||||
|
ktShift // a modifier
|
||||||
|
ktMeta // Alt with a character
|
||||||
|
ktASCII // a digit of a code typed with Alt held
|
||||||
|
ktLock // a modifier lock
|
||||||
|
ktLetter // a letter, which Caps Lock shifts
|
||||||
|
ktSLock // a sticky modifier
|
||||||
|
ktDead2 // a dead key by its character
|
||||||
|
ktBrl // a braille dot
|
||||||
|
ktCSI // a CSI ~ key that says its modifiers (kernel 7.1 and later)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modifier groups, the bits of the shift state (linux/keyboard.h)
|
||||||
|
const (
|
||||||
|
kgShift = iota
|
||||||
|
kgAltGr
|
||||||
|
kgCtrl
|
||||||
|
kgAlt
|
||||||
|
kgShiftL
|
||||||
|
kgShiftR
|
||||||
|
kgCtrlL
|
||||||
|
kgCtrlR
|
||||||
|
kgCapsShift
|
||||||
|
nrShift
|
||||||
|
)
|
||||||
|
|
||||||
|
// The lock flags, as KDGKBLED reports them
|
||||||
|
const (
|
||||||
|
ledScroll = 1 << iota
|
||||||
|
ledNum
|
||||||
|
ledCaps
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
nrKeys = 256 // keycodes a keymap maps
|
||||||
|
kNoSuchMap = 0x027f // KDGKBENT's answer for the first key of a map the keymap lacks
|
||||||
|
symHole = 0xf200 // K_HOLE as the kernel keeps it: a key that does nothing
|
||||||
|
)
|
||||||
|
|
||||||
|
// linuxKeymap is a Linux console keymap as KDGKBENT, KDGKBSENT and
|
||||||
|
// KDGKBDIACRUC read it
|
||||||
|
type linuxKeymap struct {
|
||||||
|
maps [256]*[nrKeys]uint16 // by shift state, nil where the keymap has none
|
||||||
|
funcs [256]string // the function keys' strings
|
||||||
|
accents []linuxAccent // what a dead key makes of the character after it
|
||||||
|
escMeta bool // Meta keys type ESC and the character (K_ESCPREFIX), else it with bit 7 set
|
||||||
|
csiMods bool // cursor and CSI keys say their modifiers, as from kernel 7.1
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxAccent is an accented character: a dead key's character and the
|
||||||
|
// character typed after it
|
||||||
|
type linuxAccent struct {
|
||||||
|
diacr, base, result rune
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxKeys translates keycodes as the Linux console does in Unicode mode,
|
||||||
|
// keeping its modifiers, locks, lock flags and dead keys. Codes typed with
|
||||||
|
// Alt and the keypad and braille type nothing; the keypad and cursor keys
|
||||||
|
// type as they do outside application mode, which the package never sets.
|
||||||
|
type linuxKeys struct {
|
||||||
|
km *linuxKeymap
|
||||||
|
down [nrKeys / 64]uint64 // keys down
|
||||||
|
shiftDown [nrShift]uint8 // keys down of each modifier
|
||||||
|
shift uint16 // modifiers down
|
||||||
|
lock uint16 // modifiers locked
|
||||||
|
slock uint16 // modifiers stuck for the next key
|
||||||
|
leds uint8 // lock flags
|
||||||
|
diacr rune // a dead key's character, for the next character
|
||||||
|
deadNext bool // compose: the next character is a dead key's
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed starts translation from the console's state: the modifiers it has
|
||||||
|
// down and its lock flags; keys already down are not known
|
||||||
|
func (k *linuxKeys) seed(shift, leds uint8) {
|
||||||
|
*k = linuxKeys{km: k.km, leds: leds & (ledScroll | ledNum | ledCaps)}
|
||||||
|
for g := range nrShift {
|
||||||
|
if shift&(1<<g) != 0 {
|
||||||
|
k.shiftDown[g], k.shift = 1, k.shift|1<<g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDown is whether a key is down
|
||||||
|
func (k *linuxKeys) isDown(code int) bool {
|
||||||
|
return code < nrKeys && k.down[code/64]&(1<<(code%64)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// key translates one keycode event: down for a press, rep for a repeat. It
|
||||||
|
// returns what the console types for it and what it asks of the console.
|
||||||
|
func (k *linuxKeys) key(code int, down, rep bool) (typed []byte, act consoleAct) {
|
||||||
|
if code >= nrKeys {
|
||||||
|
return nil, act // past every keymap
|
||||||
|
}
|
||||||
|
if down {
|
||||||
|
k.down[code/64] |= 1 << (code % 64)
|
||||||
|
} else {
|
||||||
|
k.down[code/64] &^= 1 << (code % 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ((k.shift | k.slock) ^ k.lock) & 0xff
|
||||||
|
km := k.km.maps[state]
|
||||||
|
// A key that says its modifiers falls back to the plain map when the
|
||||||
|
// modifiers have no map: the kernel compares the entry with the bare
|
||||||
|
// K_HOLE, which no kept entry is, so a hole in a map stays a hole
|
||||||
|
if km == nil && state != 0 && k.km.csiMods && k.km.maps[0] != nil {
|
||||||
|
if t := symType(k.km.maps[0][code]); t == ktCur || t == ktCSI {
|
||||||
|
km = k.km.maps[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if km == nil {
|
||||||
|
k.computeShift()
|
||||||
|
k.slock = 0
|
||||||
|
return nil, act
|
||||||
|
}
|
||||||
|
|
||||||
|
sym := km[code]
|
||||||
|
if sym < 0xf000 {
|
||||||
|
if down {
|
||||||
|
typed = k.unicode(typed, rune(sym))
|
||||||
|
}
|
||||||
|
return typed, act
|
||||||
|
}
|
||||||
|
t, val := symType(sym), byte(sym)
|
||||||
|
if t == ktLetter {
|
||||||
|
t = ktLatin
|
||||||
|
// The kernel takes the shifted entry's value, whatever its type
|
||||||
|
if k.leds&ledCaps != 0 {
|
||||||
|
if m := k.km.maps[state^1<<kgShift]; m != nil {
|
||||||
|
val = byte(m[code])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t {
|
||||||
|
case ktLatin:
|
||||||
|
if down {
|
||||||
|
typed = k.unicode(typed, rune(val))
|
||||||
|
}
|
||||||
|
case ktFn:
|
||||||
|
if down {
|
||||||
|
typed = append(typed, k.km.funcs[val]...)
|
||||||
|
}
|
||||||
|
case ktSpec:
|
||||||
|
if down {
|
||||||
|
typed, act = k.spec(val, rep)
|
||||||
|
}
|
||||||
|
case ktPad:
|
||||||
|
if down {
|
||||||
|
typed = k.pad(val)
|
||||||
|
}
|
||||||
|
case ktDead:
|
||||||
|
if down && int(val) < len(deadChars) {
|
||||||
|
typed = k.dead(typed, rune(deadChars[val]))
|
||||||
|
}
|
||||||
|
case ktDead2:
|
||||||
|
if down {
|
||||||
|
typed = k.dead(typed, rune(val))
|
||||||
|
}
|
||||||
|
case ktCons:
|
||||||
|
if down {
|
||||||
|
act.vt = int(val) + 1
|
||||||
|
}
|
||||||
|
case ktCur:
|
||||||
|
if down {
|
||||||
|
typed = k.cur(val)
|
||||||
|
}
|
||||||
|
case ktShift:
|
||||||
|
k.shiftKey(val, down, rep)
|
||||||
|
case ktMeta:
|
||||||
|
if down && k.km.escMeta {
|
||||||
|
typed = append(typed, 0x1b, val)
|
||||||
|
} else if down {
|
||||||
|
typed = append(typed, val|0x80)
|
||||||
|
}
|
||||||
|
case ktLock:
|
||||||
|
if down && !rep && val < 16 {
|
||||||
|
k.lock ^= 1 << val
|
||||||
|
}
|
||||||
|
case ktSLock:
|
||||||
|
k.shiftKey(val, down, rep)
|
||||||
|
if down && !rep && val < 16 {
|
||||||
|
k.slock ^= 1 << val
|
||||||
|
if k.km.maps[(k.lock^k.slock)&0xff] == nil {
|
||||||
|
k.slock = 1 << val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ktCSI:
|
||||||
|
if down && k.km.csiMods {
|
||||||
|
typed = k.csi(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t != ktSLock {
|
||||||
|
k.slock = 0
|
||||||
|
}
|
||||||
|
return typed, act
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadChars are the characters of the dead keys KT_DEAD names by number, as
|
||||||
|
// the accent table knows them
|
||||||
|
const deadChars = "`'^~\",_U.*=cki#o!?+-)(:n;$@"
|
||||||
|
|
||||||
|
// unicode types ch, or what a dead key before it makes of it; after compose,
|
||||||
|
// ch is the dead key
|
||||||
|
func (k *linuxKeys) unicode(typed []byte, ch rune) []byte {
|
||||||
|
if k.diacr != 0 {
|
||||||
|
ch, typed = k.combine(typed, ch)
|
||||||
|
}
|
||||||
|
if k.deadNext {
|
||||||
|
k.deadNext, k.diacr = false, ch
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
return utf8.AppendRune(typed, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dead is a dead key: the next character takes its accent; one already
|
||||||
|
// pending combines with it first
|
||||||
|
func (k *linuxKeys) dead(typed []byte, ch rune) []byte {
|
||||||
|
if k.diacr != 0 {
|
||||||
|
ch, typed = k.combine(typed, ch)
|
||||||
|
}
|
||||||
|
k.diacr = ch
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
|
||||||
|
// combine is what the pending dead key makes of ch: their accented character,
|
||||||
|
// the dead key's own for space or itself, else ch with the dead key's
|
||||||
|
// character typed before it
|
||||||
|
func (k *linuxKeys) combine(typed []byte, ch rune) (rune, []byte) {
|
||||||
|
d := k.diacr
|
||||||
|
k.diacr = 0
|
||||||
|
for _, a := range k.km.accents {
|
||||||
|
if a.diacr == d && a.base == ch {
|
||||||
|
return a.result, typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ch == ' ' || ch == d {
|
||||||
|
return d, typed
|
||||||
|
}
|
||||||
|
return ch, utf8.AppendRune(typed, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// symType is a typed keysym's type, -1 for a Unicode one
|
||||||
|
func symType(sym uint16) int {
|
||||||
|
if sym < 0xf000 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(sym>>8) - 0xf0
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftKey moves a modifier, which a second key of the same group holds down
|
||||||
|
// until both are up; Caps Shift is Shift that ends Caps Lock
|
||||||
|
func (k *linuxKeys) shiftKey(val byte, down, rep bool) {
|
||||||
|
if rep || val >= nrShift {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val == kgCapsShift {
|
||||||
|
val = kgShift
|
||||||
|
if down {
|
||||||
|
k.leds &^= ledCaps // the kernel ends Caps Lock itself, raw or not
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case down && k.shiftDown[val] < 255:
|
||||||
|
k.shiftDown[val]++
|
||||||
|
case !down && k.shiftDown[val] > 0:
|
||||||
|
k.shiftDown[val]--
|
||||||
|
}
|
||||||
|
if k.shiftDown[val] > 0 {
|
||||||
|
k.shift |= 1 << val
|
||||||
|
} else {
|
||||||
|
k.shift &^= 1 << val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeShift counts the modifiers again from the keys down, by the plain
|
||||||
|
// map, as the kernel does when a key finds no map
|
||||||
|
func (k *linuxKeys) computeShift() {
|
||||||
|
k.shift, k.shiftDown = 0, [nrShift]uint8{}
|
||||||
|
plain := k.km.maps[0]
|
||||||
|
if plain == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for code := range nrKeys {
|
||||||
|
if !k.isDown(code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, val := symType(plain[code]), byte(plain[code])
|
||||||
|
if t != ktShift && t != ktSLock {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if val == kgCapsShift {
|
||||||
|
val = kgShift
|
||||||
|
}
|
||||||
|
if val < nrShift {
|
||||||
|
k.shiftDown[val]++
|
||||||
|
k.shift |= 1 << val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spec runs a console function key (KT_SPEC) as the console does cooked.
|
||||||
|
// The register and memory dumps, scrollback, Ctrl+Alt+Del, spawning a console
|
||||||
|
// and the last console do nothing; SAK the kernel runs itself, raw or not.
|
||||||
|
// Scroll Lock only ever restarts output, so a game can't be frozen.
|
||||||
|
func (k *linuxKeys) spec(val byte, rep bool) ([]byte, consoleAct) {
|
||||||
|
var act consoleAct
|
||||||
|
switch val {
|
||||||
|
case 0: // K_HOLE
|
||||||
|
k.computeShift()
|
||||||
|
case 1: // K_ENTER, after a dead key pending
|
||||||
|
var typed []byte
|
||||||
|
if k.diacr != 0 {
|
||||||
|
typed, k.diacr = utf8.AppendRune(typed, k.diacr), 0
|
||||||
|
}
|
||||||
|
return append(typed, '\r'), act
|
||||||
|
case 5: // K_BREAK, which a raw tty reads as NUL
|
||||||
|
return []byte{0}, act
|
||||||
|
case 7: // K_CAPS
|
||||||
|
if !rep {
|
||||||
|
k.leds ^= ledCaps
|
||||||
|
act.leds = true
|
||||||
|
}
|
||||||
|
case 8, 19: // K_NUM, K_BARENUMLOCK
|
||||||
|
if !rep {
|
||||||
|
k.leds ^= ledNum
|
||||||
|
act.leds = true
|
||||||
|
}
|
||||||
|
case 9: // K_HOLD
|
||||||
|
act.start = !rep
|
||||||
|
case 13: // K_CAPSON
|
||||||
|
if !rep && k.leds&ledCaps == 0 {
|
||||||
|
k.leds |= ledCaps
|
||||||
|
act.leds = true
|
||||||
|
}
|
||||||
|
case 14: // K_COMPOSE
|
||||||
|
k.deadNext = true
|
||||||
|
case 16: // K_DECRCONSOLE
|
||||||
|
act.vt = vtPrev
|
||||||
|
case 17: // K_INCRCONSOLE
|
||||||
|
act.vt = vtNext
|
||||||
|
}
|
||||||
|
return nil, act
|
||||||
|
}
|
||||||
|
|
||||||
|
// padChars are what the keypad keys type with Num Lock on (K_P0 to K_PPARENR)
|
||||||
|
const padChars = "0123456789+-*/\r,.?()"
|
||||||
|
|
||||||
|
// The function strings the keypad types with Num Lock off
|
||||||
|
const (
|
||||||
|
fnFind = 20
|
||||||
|
fnInsert = 21
|
||||||
|
fnRemove = 22
|
||||||
|
fnSelect = 23
|
||||||
|
fnPgUp = 24
|
||||||
|
fnPgDn = 25
|
||||||
|
)
|
||||||
|
|
||||||
|
// pad is a keypad key: its character with Num Lock on, else the navigation
|
||||||
|
// key it doubles as
|
||||||
|
func (k *linuxKeys) pad(val byte) []byte {
|
||||||
|
if k.leds&ledNum == 0 {
|
||||||
|
switch val {
|
||||||
|
case 15, 16: // K_PCOMMA, K_PDOT
|
||||||
|
return []byte(k.km.funcs[fnRemove])
|
||||||
|
case 0:
|
||||||
|
return []byte(k.km.funcs[fnInsert])
|
||||||
|
case 1:
|
||||||
|
return []byte(k.km.funcs[fnSelect])
|
||||||
|
case 2:
|
||||||
|
return k.cur(0)
|
||||||
|
case 3:
|
||||||
|
return []byte(k.km.funcs[fnPgDn])
|
||||||
|
case 4:
|
||||||
|
return k.cur(1)
|
||||||
|
case 5:
|
||||||
|
return []byte("\x1b[G")
|
||||||
|
case 6:
|
||||||
|
return k.cur(2)
|
||||||
|
case 7:
|
||||||
|
return []byte(k.km.funcs[fnFind])
|
||||||
|
case 8:
|
||||||
|
return k.cur(3)
|
||||||
|
case 9:
|
||||||
|
return []byte(k.km.funcs[fnPgUp])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if int(val) < len(padChars) {
|
||||||
|
return []byte{padChars[val]}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cur is a cursor key (K_DOWN, K_LEFT, K_RIGHT, K_UP): CSI letter, or with
|
||||||
|
// modifiers down on a kernel that says them, CSI 1 ; modifiers letter
|
||||||
|
func (k *linuxKeys) cur(val byte) []byte {
|
||||||
|
if val > 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch := "BDCA"[val]
|
||||||
|
if mod := k.csiMod(); mod > 1 && k.km.csiMods {
|
||||||
|
return []byte{0x1b, '[', '1', ';', '0' + byte(mod), ch}
|
||||||
|
}
|
||||||
|
return []byte{0x1b, '[', ch}
|
||||||
|
}
|
||||||
|
|
||||||
|
// csi is a KT_CSI key: CSI value ~, or CSI value ; modifiers ~
|
||||||
|
func (k *linuxKeys) csi(val byte) []byte {
|
||||||
|
if val > 99 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
typed := strconv.AppendInt([]byte{0x1b, '['}, int64(val), 10)
|
||||||
|
if mod := k.csiMod(); mod > 1 {
|
||||||
|
typed = append(typed, ';', '0'+byte(mod))
|
||||||
|
}
|
||||||
|
return append(typed, '~')
|
||||||
|
}
|
||||||
|
|
||||||
|
// csiMod is xterm's modifier parameter for the modifiers down: Shift, Alt and
|
||||||
|
// Ctrl, the kernel leaving out their left and right groups and AltGr, which
|
||||||
|
// keymaps use to pick a layout
|
||||||
|
func (k *linuxKeys) csiMod() int {
|
||||||
|
mod := 1
|
||||||
|
if k.shift&(1<<kgShift) != 0 {
|
||||||
|
mod++
|
||||||
|
}
|
||||||
|
if k.shift&(1<<kgAlt) != 0 {
|
||||||
|
mod += 2
|
||||||
|
}
|
||||||
|
if k.shift&(1<<kgCtrl) != 0 {
|
||||||
|
mod += 4
|
||||||
|
}
|
||||||
|
return mod
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediumRaw decodes the Linux console's K_MEDIUMRAW input: a keycode under
|
||||||
|
// 128 is one byte, bit 7 set on release; a higher one is a byte of 0, 0x80 on
|
||||||
|
// release, then its high and low 7 bits, each with bit 7 set
|
||||||
|
type mediumRaw struct {
|
||||||
|
up bool
|
||||||
|
high int
|
||||||
|
n int // bytes of a long keycode seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode reports each keycode event in data, keeping a long keycode begun at
|
||||||
|
// its end for the next call
|
||||||
|
func (m *mediumRaw) decode(data []byte, key func(code int, down bool)) {
|
||||||
|
for _, b := range data {
|
||||||
|
if m.n > 0 && b&0x80 != 0 {
|
||||||
|
if m.n++; m.n == 2 {
|
||||||
|
m.high = int(b & 0x7f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.n = 0
|
||||||
|
key(m.high<<7|int(b&0x7f), !m.up)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.n = 0 // a long keycode broken off is dropped
|
||||||
|
if b&0x7f == 0 {
|
||||||
|
m.up, m.n = b != 0, 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key(int(b&0x7f), b&0x80 == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testKeymap is the kernel's built-in keymap (US), as KDGKBENT and KDGKBSENT
|
||||||
|
// read it from a Linux 6.18 console
|
||||||
|
func testKeymap(t *testing.T) *linuxKeymap {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open("testdata/linux-keymap.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
km := &linuxKeymap{escMeta: true}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(nil, 1<<16)
|
||||||
|
for sc.Scan() {
|
||||||
|
kind, rest, _ := strings.Cut(sc.Text(), " ")
|
||||||
|
n, rest, _ := strings.Cut(rest, " ")
|
||||||
|
i, err := strconv.Atoi(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "map":
|
||||||
|
m := new([nrKeys]uint16)
|
||||||
|
for j, v := range strings.Fields(rest) {
|
||||||
|
x, err := strconv.ParseUint(v, 16, 16)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m[j] = uint16(x) ^ 0xf000
|
||||||
|
}
|
||||||
|
km.maps[i] = m
|
||||||
|
case "fn":
|
||||||
|
if km.funcs[i], err = strconv.Unquote(rest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return km
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux input keycodes
|
||||||
|
const (
|
||||||
|
keyEsc = 1
|
||||||
|
key1 = 2
|
||||||
|
key2 = 3
|
||||||
|
keyBackspace = 14
|
||||||
|
keyTab = 15
|
||||||
|
keyQ = 16
|
||||||
|
keyO = 24
|
||||||
|
keyEnter = 28
|
||||||
|
keyLeftCtrl = 29
|
||||||
|
keyA = 30
|
||||||
|
keyS = 31
|
||||||
|
keyGrave = 41
|
||||||
|
keyLeftShift = 42
|
||||||
|
keyB = 48
|
||||||
|
keyN = 49
|
||||||
|
keyRtShift = 54
|
||||||
|
keyLeftAlt = 56
|
||||||
|
keySpace = 57
|
||||||
|
keyCapsLock = 58
|
||||||
|
keyF1 = 59
|
||||||
|
keyF2 = 60
|
||||||
|
keyF6 = 64
|
||||||
|
keyNumLock = 69
|
||||||
|
keyScrollLk = 70
|
||||||
|
keyKP8 = 72
|
||||||
|
keyKP5 = 76
|
||||||
|
keyKP0 = 82
|
||||||
|
keyKPDot = 83
|
||||||
|
keyKPEnter = 96
|
||||||
|
keyRightAlt = 100
|
||||||
|
keyHome = 102
|
||||||
|
keyUp = 103
|
||||||
|
keyPageUp = 104
|
||||||
|
keyLeft = 105
|
||||||
|
keyRight = 106
|
||||||
|
keyDelete = 111
|
||||||
|
keyPause = 119
|
||||||
|
keyCompose = 127
|
||||||
|
)
|
||||||
|
|
||||||
|
// stroke is a key going down (repeating when already down) or up
|
||||||
|
type stroke struct {
|
||||||
|
code int
|
||||||
|
down bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func press(codes ...int) []stroke {
|
||||||
|
var s []stroke
|
||||||
|
for _, c := range codes {
|
||||||
|
s = append(s, stroke{c, true}, stroke{c, false})
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func hold(code int, inner ...[]stroke) []stroke {
|
||||||
|
s := []stroke{{code, true}}
|
||||||
|
for _, in := range inner {
|
||||||
|
s = append(s, in...)
|
||||||
|
}
|
||||||
|
return append(s, stroke{code, false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// typedBy is what the console types for strokes, and what they ask of it
|
||||||
|
func typedBy(k *linuxKeys, strokes []stroke) (string, []consoleAct) {
|
||||||
|
var out []byte
|
||||||
|
var acts []consoleAct
|
||||||
|
for _, s := range strokes {
|
||||||
|
typed, act := k.key(s.code, s.down, s.down && k.isDown(s.code))
|
||||||
|
out = append(out, typed...)
|
||||||
|
if act != (consoleAct{}) {
|
||||||
|
acts = append(acts, act)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), acts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys type what the Linux console types for them cooked, by its keymap and
|
||||||
|
// the modifiers and locks they move: Shift into the character, Caps Lock on
|
||||||
|
// letters only, Ctrl as control characters, Alt as an ESC prefix, the
|
||||||
|
// function and editing keys as their strings, the keypad by Num Lock, and
|
||||||
|
// cursor keys with their modifiers only on a kernel that says them
|
||||||
|
func TestLinuxConsoleTypesAsCooked(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want string // before kernel 7.1
|
||||||
|
csiMods string // from 7.1, when it differs
|
||||||
|
}{
|
||||||
|
{"letter", press(keyA), "a", ""},
|
||||||
|
{"shifted letter", hold(keyLeftShift, press(keyA)), "A", ""},
|
||||||
|
{"shifted digit", hold(keyLeftShift, press(key1)), "!", ""},
|
||||||
|
{"caps lock", slices.Concat(press(keyCapsLock), press(keyA, key1), press(keyCapsLock), press(keyA)), "A1a", ""},
|
||||||
|
{"caps lock with shift", slices.Concat(press(keyCapsLock), hold(keyLeftShift, press(keyA))), "a", ""},
|
||||||
|
{"ctrl letter", hold(keyLeftCtrl, press(keyA)), "\x01", ""},
|
||||||
|
{"ctrl shift letter", hold(keyLeftCtrl, hold(keyLeftShift, press(keyA))), "\x01", ""},
|
||||||
|
{"ctrl space", hold(keyLeftCtrl, press(keySpace)), "\x00", ""},
|
||||||
|
{"ctrl backspace", hold(keyLeftCtrl, press(keyBackspace)), "\x08", ""},
|
||||||
|
{"alt letter", hold(keyLeftAlt, press(keyA)), "\x1ba", ""},
|
||||||
|
{"alt enter", hold(keyLeftAlt, press(keyEnter)), "\x1b\r", ""},
|
||||||
|
{"ctrl alt letter", hold(keyLeftCtrl, hold(keyLeftAlt, press(keyA))), "\x1b\x01", ""},
|
||||||
|
{"altgr", hold(keyRightAlt, press(key2)), "@", ""},
|
||||||
|
{"enter tab escape backspace", press(keyEnter, keyTab, keyEsc, keyBackspace), "\r\t\x1b\x7f", ""},
|
||||||
|
{"function keys", press(keyF1, keyF6), "\x1b[[A\x1b[17~", ""},
|
||||||
|
{"shifted function key", hold(keyLeftShift, press(keyF1)), "\x1b[23~", ""},
|
||||||
|
{"editing keys", press(keyHome, keyDelete, keyPageUp, keyPause), "\x1b[1~\x1b[3~\x1b[5~\x1b[P", ""},
|
||||||
|
{"cursor", press(keyUp, keyLeft), "\x1b[A\x1b[D", ""},
|
||||||
|
{"shifted cursor", hold(keyLeftShift, press(keyUp)), "\x1b[A", "\x1b[1;2A"},
|
||||||
|
{"ctrl cursor", hold(keyLeftCtrl, press(keyRight)), "\x1b[C", "\x1b[1;5C"},
|
||||||
|
{"alt shift cursor, no map", hold(keyLeftAlt, hold(keyLeftShift, press(keyUp))), "", "\x1b[1;4A"},
|
||||||
|
{"keypad, num lock off", press(keyKP8, keyKP5, keyKP0, keyKPDot, keyKPEnter), "\x1b[A\x1b[G\x1b[2~\x1b[3~\r", ""},
|
||||||
|
{"keypad, num lock on", slices.Concat(press(keyNumLock), press(keyKP8, keyKP0, keyKPDot)), "80.", ""},
|
||||||
|
{"repeat", []stroke{{keyQ, true}, {keyQ, true}, {keyQ, true}, {keyQ, false}}, "qqq", ""},
|
||||||
|
{"shift released first", []stroke{{keyLeftShift, true}, {keyA, true}, {keyLeftShift, false}, {keyA, true}, {keyA, false}}, "Aa", ""},
|
||||||
|
{"two shifts", []stroke{{keyLeftShift, true}, {keyRtShift, true}, {keyLeftShift, false}, {keyA, true}, {keyA, false}, {keyRtShift, false}}, "A", ""},
|
||||||
|
{"inert keys", slices.Concat(hold(keyLeftShift, press(keyPageUp)), hold(keyLeftCtrl, hold(keyLeftAlt, press(keyDelete)))), "", ""},
|
||||||
|
}
|
||||||
|
for _, csiMods := range []bool{false, true} {
|
||||||
|
km := testKeymap(t)
|
||||||
|
km.csiMods = csiMods
|
||||||
|
for _, c := range cases {
|
||||||
|
want := c.want
|
||||||
|
if csiMods && c.csiMods != "" {
|
||||||
|
want = c.csiMods
|
||||||
|
}
|
||||||
|
k := &linuxKeys{km: km}
|
||||||
|
got, _ := typedBy(k, c.strokes)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s (csiMods %v): typed %q, want %q", c.name, csiMods, got, want)
|
||||||
|
}
|
||||||
|
if k.shift != 0 {
|
||||||
|
t.Errorf("%s (csiMods %v): modifiers %#x left down", c.name, csiMods, k.shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys that switch consoles, the locks and Scroll Lock ask the console for
|
||||||
|
// what the kernel would do cooked: the locks' flags, a console by number or
|
||||||
|
// the one before or after, and output going on
|
||||||
|
func TestLinuxConsoleKeysAskTheConsole(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want []consoleAct
|
||||||
|
leds uint8
|
||||||
|
}{
|
||||||
|
{"alt f2", hold(keyLeftAlt, press(keyF2)), []consoleAct{{vt: 2}}, 0},
|
||||||
|
{"ctrl alt f1", hold(keyLeftCtrl, hold(keyLeftAlt, press(keyF1))), []consoleAct{{vt: 1}}, 0},
|
||||||
|
{"altgr f1", hold(keyRightAlt, press(keyF1)), []consoleAct{{vt: 13}}, 0},
|
||||||
|
{"alt left right", hold(keyLeftAlt, press(keyLeft, keyRight)), []consoleAct{{vt: vtPrev}, {vt: vtNext}}, 0},
|
||||||
|
{"caps lock twice", press(keyCapsLock, keyCapsLock), []consoleAct{{leds: true}, {leds: true}}, 0},
|
||||||
|
{"caps lock held", []stroke{{keyCapsLock, true}, {keyCapsLock, true}, {keyCapsLock, false}}, []consoleAct{{leds: true}}, ledCaps},
|
||||||
|
{"num lock", press(keyNumLock), []consoleAct{{leds: true}}, ledNum},
|
||||||
|
{"scroll lock", press(keyScrollLk), []consoleAct{{start: true}}, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
k := &linuxKeys{km: testKeymap(t)}
|
||||||
|
if _, got := typedBy(k, c.strokes); !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("%s: asked %+v, want %+v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
if k.leds != c.leds {
|
||||||
|
t.Errorf("%s: lock flags %#x, want %#x", c.name, k.leds, c.leds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation starts from the console's state: modifiers already down and the
|
||||||
|
// locks already on
|
||||||
|
func TestLinuxConsoleSeedsFromTheConsole(t *testing.T) {
|
||||||
|
k := &linuxKeys{km: testKeymap(t)}
|
||||||
|
k.seed(1<<kgShift, ledNum)
|
||||||
|
got, _ := typedBy(k, slices.Concat(press(keyA, keyKP8), []stroke{{keyLeftShift, false}}, press(keyA)))
|
||||||
|
if got != "A8a" {
|
||||||
|
t.Errorf("typed %q, want %q", got, "A8a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky and locked modifiers, which some keymaps bind, hold for the next key
|
||||||
|
// and until locked off; a key no map has types nothing
|
||||||
|
func TestLinuxConsoleLocks(t *testing.T) {
|
||||||
|
km := testKeymap(t)
|
||||||
|
const keyShiftLock, keyStickyShift, keyNoMap = 85, 86, 89
|
||||||
|
km.maps[0][keyShiftLock] = 0xf000 | ktLock<<8 | kgShift
|
||||||
|
km.maps[1][keyShiftLock] = 0xf000 | ktLock<<8 | kgShift
|
||||||
|
km.maps[0][keyStickyShift] = 0xf000 | ktSLock<<8 | kgShift
|
||||||
|
km.maps[1][keyStickyShift] = 0xf000 | ktSLock<<8 | kgShift
|
||||||
|
k := &linuxKeys{km: km}
|
||||||
|
strokes := slices.Concat(press(keyShiftLock), press(keyA, keyA), press(keyShiftLock), press(keyStickyShift), press(keyA, keyA))
|
||||||
|
if got, _ := typedBy(k, strokes); got != "AAAa" {
|
||||||
|
t.Errorf("typed %q, want %q", got, "AAAa")
|
||||||
|
}
|
||||||
|
if got, _ := typedBy(k, hold(keyLeftAlt, hold(keyLeftShift, press(keyNoMap)))); got != "" {
|
||||||
|
t.Errorf("a key with no map typed %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead keys and compose type what the console's accent table makes of the
|
||||||
|
// character after them, as cooked: their accented character, the dead key's
|
||||||
|
// own for a space or itself, or the dead key's character and the next when
|
||||||
|
// the table has no pair for them. A key typing no character leaves a dead key
|
||||||
|
// pending, and Enter types it.
|
||||||
|
func TestLinuxConsoleDeadKeys(t *testing.T) {
|
||||||
|
km := testKeymap(t)
|
||||||
|
km.maps[0][keyGrave] = 0xf000 | ktDead<<8 // dead grave, by number
|
||||||
|
km.maps[1][keyGrave] = 0xf000 | ktDead2<<8 | '~' // dead tilde, by its character
|
||||||
|
km.maps[0][keyCompose] = 0xf000 | ktSpec<<8 | 14 // K_COMPOSE
|
||||||
|
km.accents = []linuxAccent{{'`', 'a', 'à'}, {'~', 'n', 'ñ'}, {'o', 'a', 'å'}, {'s', 's', 'ß'}}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
strokes []stroke
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"dead key", press(keyGrave, keyA), "à"},
|
||||||
|
{"dead key by its character", slices.Concat(hold(keyLeftShift, press(keyGrave)), press(keyN)), "ñ"},
|
||||||
|
{"dead key and space", press(keyGrave, keySpace), "`"},
|
||||||
|
{"dead key twice", press(keyGrave, keyGrave, keyA), "à"},
|
||||||
|
{"no pair", press(keyGrave, keyB), "`b"},
|
||||||
|
{"dead key and enter", press(keyGrave, keyEnter), "`\r"},
|
||||||
|
{"over a cursor key", press(keyGrave, keyUp, keyA), "\x1b[Aà"},
|
||||||
|
{"compose", press(keyCompose, keyO, keyA), "å"},
|
||||||
|
{"compose a pair", press(keyCompose, keyS, keyS), "ß"},
|
||||||
|
{"compose and a dead key", press(keyCompose, keyGrave, keyA, keyB), "àb"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
k := &linuxKeys{km: km}
|
||||||
|
if got, _ := typedBy(k, c.strokes); got != c.want {
|
||||||
|
t.Errorf("%s: typed %q, want %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// K_MEDIUMRAW input decodes to keycodes, a long one across reads, and a long
|
||||||
|
// one broken off is dropped without the key after it
|
||||||
|
func TestMediumRawDecodes(t *testing.T) {
|
||||||
|
type ev struct {
|
||||||
|
code int
|
||||||
|
down bool
|
||||||
|
}
|
||||||
|
var got []ev
|
||||||
|
var m mediumRaw
|
||||||
|
key := func(code int, down bool) { got = append(got, ev{code, down}) }
|
||||||
|
m.decode([]byte{30, 30 | 0x80, 0x00, 0x81}, key)
|
||||||
|
m.decode([]byte{0x80 | 55, 0x80, 0x82, 0x80 | 3}, key)
|
||||||
|
m.decode([]byte{0x00, 0x81, 28, 0x80 | 28}, key)
|
||||||
|
want := []ev{{30, true}, {30, false}, {1<<7 | 55, true}, {2<<7 | 3, false}, {28, true}, {28, false}}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("decoded %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ const (
|
|||||||
MouseBtnRight
|
MouseBtnRight
|
||||||
MouseBtnWheelUp
|
MouseBtnWheelUp
|
||||||
MouseBtnWheelDown
|
MouseBtnWheelDown
|
||||||
MouseBtnBack // Button 4 (if supported)
|
MouseBtnBack // Button 8, a mouse's back side button
|
||||||
MouseBtnForward // Button 5 (if supported)
|
MouseBtnForward // Button 9, its forward side button
|
||||||
|
MouseBtnWheelLeft // A wheel tilted left, or a touchpad swiped so
|
||||||
|
MouseBtnWheelRight // A wheel tilted right, or a touchpad swiped so
|
||||||
)
|
)
|
||||||
|
|
||||||
// MouseAction represents the type of mouse event
|
// MouseAction represents the type of mouse event
|
||||||
@@ -52,6 +54,10 @@ func (b MouseButton) String() string {
|
|||||||
return "Back"
|
return "Back"
|
||||||
case MouseBtnForward:
|
case MouseBtnForward:
|
||||||
return "Forward"
|
return "Forward"
|
||||||
|
case MouseBtnWheelLeft:
|
||||||
|
return "WheelLeft"
|
||||||
|
case MouseBtnWheelRight:
|
||||||
|
return "WheelRight"
|
||||||
default:
|
default:
|
||||||
return "None"
|
return "None"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := o.writer
|
w := &o.buf
|
||||||
|
start := w.Len()
|
||||||
|
|
||||||
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 +126,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 +138,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 +145,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 +161,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
|
||||||
@@ -195,12 +195,97 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A frame that changed no cell writes nothing, the reset included
|
||||||
|
if w.Len() > start {
|
||||||
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 +293,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 +313,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 +340,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 +393,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 +431,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 +439,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 +451,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
|
||||||
}
|
}
|
||||||
+302
-43
@@ -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,31 @@ type termImpl struct {
|
|||||||
finalized bool
|
finalized bool
|
||||||
mouseMode MouseMode
|
mouseMode MouseMode
|
||||||
paste bool
|
paste bool
|
||||||
|
keyProto bool // the key protocol's flags are pushed
|
||||||
|
consoleRaw bool // the console's keyboard is raw: mouse reports wait
|
||||||
|
|
||||||
|
// 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
|
||||||
|
drained chan struct{} // a drain left nothing queued
|
||||||
|
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 +170,28 @@ func New(colorMode ...ColorMode) Terminal {
|
|||||||
c = ColorModeNone
|
c = ColorModeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &termImpl{
|
return NewWithBackend(b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithBackend creates a Terminal over b in color mode c, for a pipe, an
|
||||||
|
// in-process emulator or a test standing in for the tty. b must not be nil;
|
||||||
|
// the Terminal calls its Init on Init and its Fini on Fini, and writes to it
|
||||||
|
// on its own goroutine as New's does
|
||||||
|
func NewWithBackend(b Backend, c ColorMode) Terminal {
|
||||||
|
return newTerm(b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTerm(b Backend, c ColorMode) *termImpl {
|
||||||
|
return &termImpl{
|
||||||
backend: b,
|
backend: b,
|
||||||
|
output: newOutputBuffer(c),
|
||||||
syntheticCh: make(chan Event, 16),
|
syntheticCh: make(chan Event, 16),
|
||||||
resizeCh: make(chan ResizeEvent, 1),
|
resizeCh: make(chan ResizeEvent, 1),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
drained: make(chan struct{}, 1),
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
writerDone: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize output buffer with backend
|
|
||||||
t.output = newOutputBuffer(b, c)
|
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init enters raw mode and sets up terminal
|
// Init enters raw mode and sets up terminal
|
||||||
@@ -159,6 +213,11 @@ 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
|
||||||
|
if t.input.console = backendConsole(t.backend); t.input.console != nil {
|
||||||
|
t.input.keysRaw, t.input.keysCooked = t.keysRaw, t.keysCooked
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +237,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 +258,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 +270,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 +347,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 +357,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 +376,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 +405,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 +423,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 +442,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 +461,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 +477,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
|
||||||
@@ -408,13 +525,25 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
if !t.initialized || t.finalized {
|
if !t.initialized || t.finalized {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if t.consoleRaw {
|
||||||
|
t.mouseMode = mode // reported once the console's keyboard is cooked again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.setMouseModeLocked(mode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMouseModeLocked writes what moves the terminal's mouse reporting to mode.
|
||||||
|
// Caller MUST hold mu.
|
||||||
|
func (t *termImpl) setMouseModeLocked(mode MouseMode) {
|
||||||
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,19 +564,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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPasteMode turns bracketed paste on or off
|
// SetPasteMode turns bracketed paste on or off
|
||||||
@@ -460,24 +604,135 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleDrainTimeout bounds how long switching a console's keyboard raw waits
|
||||||
|
// for the console to take what is queued, its mouse reports off among it
|
||||||
|
const consoleDrainTimeout = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// keysRaw switches the console's keyboard raw for the input reader: false
|
||||||
|
// when it stays cooked, and whether it may not for a later ask. The console
|
||||||
|
// types its mouse reports into the input the keycodes come through, so they
|
||||||
|
// are off while it is raw, the console having taken that before the switch,
|
||||||
|
// which drops reports already sent.
|
||||||
|
func (t *termImpl) keysRaw() (ok, again bool) {
|
||||||
|
c := t.input.console
|
||||||
|
if !c.prepare() {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
mode := t.mouseMode
|
||||||
|
if mode != MouseModeNone {
|
||||||
|
t.setMouseModeLocked(MouseModeNone)
|
||||||
|
t.mouseMode = mode
|
||||||
|
}
|
||||||
|
t.consoleRaw = true
|
||||||
|
if t.idleLocked(consoleDrainTimeout) && !t.finalized && c.raw() {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
t.mouseBackLocked()
|
||||||
|
return false, !t.finalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// keysCooked switches the console's keyboard back for the input reader, and
|
||||||
|
// mouse reports on again
|
||||||
|
func (t *termImpl) keysCooked() {
|
||||||
|
t.input.console.cooked()
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.initialized {
|
||||||
|
t.mouseBackLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouseBackLocked ends the hold on mouse reports the console's keyboard put
|
||||||
|
// while raw, reporting as asked. Caller MUST hold mu.
|
||||||
|
func (t *termImpl) mouseBackLocked() {
|
||||||
|
t.consoleRaw = false
|
||||||
|
if mode := t.mouseMode; mode != MouseModeNone && !t.finalized {
|
||||||
|
t.mouseMode = MouseModeNone
|
||||||
|
t.setMouseModeLocked(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idleLocked waits, mu released meanwhile, until the terminal has taken all
|
||||||
|
// that is queued, or for timeout; it returns with mu held, and whether the
|
||||||
|
// queue emptied. Caller MUST hold mu.
|
||||||
|
func (t *termImpl) idleLocked(timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for len(t.queued) > 0 || t.writeSince.Load() != 0 {
|
||||||
|
left := time.Until(deadline)
|
||||||
|
if left <= 0 || t.finalized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-t.drained:
|
||||||
|
case <-time.After(left):
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
}
|
||||||
|
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
|
||||||
// Call this from panic recovery if Fini() cannot be called normally. Given a
|
// Call this from panic recovery if Fini() cannot be called normally. Given a
|
||||||
// redirected stdout, it writes to the controlling terminal instead.
|
// redirected stdout, it writes to the controlling terminal instead.
|
||||||
func EmergencyReset(w io.Writer) {
|
func EmergencyReset(w io.Writer) {
|
||||||
|
// A console keyboard left raw types nothing a player can use
|
||||||
|
restoreConsoleKeys()
|
||||||
|
|
||||||
if f, ok := w.(*os.File); ok && !term.IsTerminal(int(f.Fd())) {
|
if f, ok := w.(*os.File); ok && !term.IsTerminal(int(f.Fd())) {
|
||||||
if tty := controllingTTY(); tty != nil {
|
if tty := controllingTTY(); tty != nil {
|
||||||
defer tty.Close()
|
defer tty.Close()
|
||||||
@@ -491,6 +746,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)
|
||||||
|
|||||||
+329
-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,27 +85,54 @@ 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frame that changes no cell writes nothing, so drawing an idle screen
|
||||||
|
// again costs the terminal no bytes
|
||||||
|
func TestUnchangedFrameWritesNothing(t *testing.T) {
|
||||||
|
o := newOutputBuffer(ColorMode256)
|
||||||
|
frame := []Cell{{Rune: 'a'}, {Rune: 'é'}}
|
||||||
|
o.flush(frame, len(frame), 1)
|
||||||
|
o.buf.Reset()
|
||||||
|
if o.flush(frame, len(frame), 1); o.buf.Len() != 0 {
|
||||||
|
t.Fatalf("unchanged frame wrote %q", o.buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A paste arrives as one event, whatever reads split it into: an escape
|
// A paste arrives as one event, whatever reads split it into: an escape
|
||||||
// inside it is text even when a poll times out on it, and a paste the
|
// inside it is text even when a poll times out on it, and a paste the
|
||||||
// terminal never ends ends when input pauses, whether reads time out or
|
// terminal never ends ends when input pauses, whether reads time out or
|
||||||
@@ -133,6 +165,121 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mouse report names its button in each bank: the main three, the wheel's four
|
||||||
|
// directions, and back and forward among buttons 8 to 11, whose others, which no
|
||||||
|
// application could tell apart from the main three before, are dropped
|
||||||
|
func TestMouseReportsNameTheirButtons(t *testing.T) {
|
||||||
|
at := func(btn MouseButton, action MouseAction, mods Modifier) Event {
|
||||||
|
return Event{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: btn, MouseAction: action, Modifiers: mods}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
report string
|
||||||
|
want []Event
|
||||||
|
}{
|
||||||
|
{"\x1b[<20;5;3M", []Event{at(MouseBtnLeft, MouseActionPress, ModShift|ModCtrl)}},
|
||||||
|
{"\x1b[<34;5;3M", []Event{at(MouseBtnRight, MouseActionDrag, 0)}},
|
||||||
|
{"\x1b[<35;5;3M", []Event{at(MouseBtnNone, MouseActionMove, 0)}},
|
||||||
|
{"\x1b[<64;5;3M", []Event{at(MouseBtnWheelUp, MouseActionPress, 0)}},
|
||||||
|
{"\x1b[<65;5;3M", []Event{at(MouseBtnWheelDown, MouseActionPress, 0)}},
|
||||||
|
{"\x1b[<66;5;3M", []Event{at(MouseBtnWheelLeft, MouseActionPress, 0)}},
|
||||||
|
{"\x1b[<75;5;3M", []Event{at(MouseBtnWheelRight, MouseActionPress, ModAlt)}},
|
||||||
|
{"\x1b[<128;5;3M\x1b[<128;5;3m", []Event{at(MouseBtnBack, MouseActionPress, 0), at(MouseBtnBack, MouseActionRelease, 0)}},
|
||||||
|
{"\x1b[<129;5;3M", []Event{at(MouseBtnForward, MouseActionPress, 0)}},
|
||||||
|
{"\x1b[<130;5;3Mx", []Event{key('x')}},
|
||||||
|
{"\x1b[<192;5;3Mx", []Event{key('x')}},
|
||||||
|
} {
|
||||||
|
if got, _ := parsed(c.report); !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("%q: got %+v, want %+v", c.report, got, 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 +312,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 +333,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 +351,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") })
|
||||||
}
|
}
|
||||||
Vendored
+116
@@ -0,0 +1,116 @@
|
|||||||
|
key 0 0 0 0 0 0 0 0 0 ff 0
|
||||||
|
key 1 1b 1b 1b 1b 1b 1b 86 1b 2 0
|
||||||
|
key 2 31 21 0 0 31 21 0 0 33 0
|
||||||
|
key 3 32 40 0 0 32 40 0 0 0 0
|
||||||
|
key 4 33 23 0 0 33 23 0 0 33 0
|
||||||
|
key 5 34 24 0 0 34 24 0 0 33 0
|
||||||
|
key 6 35 25 0 0 35 25 0 0 33 0
|
||||||
|
key 7 36 5e 1e 1e 36 5e 1e 1e 0 0
|
||||||
|
key 8 37 26 0 0 37 26 0 0 33 0
|
||||||
|
key 9 38 2a 0 0 38 2a 0 0 33 0
|
||||||
|
key 10 39 28 0 0 39 28 0 0 33 0
|
||||||
|
key 11 30 29 0 0 30 29 0 0 33 0
|
||||||
|
key 12 2d 5f 1f 1f 2d 5f 1f 1f 0 0
|
||||||
|
key 13 3d 2b 0 0 3d 2b 0 0 33 0
|
||||||
|
key 14 8 8 7f 7f 8 8 7f 7f 0 0
|
||||||
|
key 15 9 8 0 0 9 8 0 0 77 0
|
||||||
|
key 16 71 51 11 11 71 51 11 11 0 1
|
||||||
|
key 17 77 57 17 17 77 57 17 17 0 1
|
||||||
|
key 18 65 45 5 5 65 45 5 5 0 1
|
||||||
|
key 19 72 52 12 12 72 52 12 12 0 1
|
||||||
|
key 20 74 54 14 14 74 54 14 14 0 1
|
||||||
|
key 21 79 59 19 19 79 59 19 19 0 1
|
||||||
|
key 22 75 55 15 15 75 55 15 15 0 1
|
||||||
|
key 23 69 49 9 9 69 49 9 9 0 1
|
||||||
|
key 24 6f 4f f f 6f 4f f f 0 1
|
||||||
|
key 25 70 50 10 10 70 50 10 10 0 1
|
||||||
|
key 26 5b 7b 1b 1b 5b 7b 1b 1b 0 0
|
||||||
|
key 27 5d 7d 1d 1d 5d 7d 1d 1d 0 0
|
||||||
|
key 28 d d a a d d a a 0 0
|
||||||
|
key 29 9 9 9 9 9 9 9 9 ff 0
|
||||||
|
key 30 61 41 1 1 61 41 1 1 0 1
|
||||||
|
key 31 73 53 13 13 73 53 13 13 0 1
|
||||||
|
key 32 64 44 4 4 64 44 4 4 0 1
|
||||||
|
key 33 66 46 6 6 66 46 6 6 0 1
|
||||||
|
key 34 67 47 7 7 67 47 7 7 0 1
|
||||||
|
key 35 68 48 8 8 68 48 8 8 0 1
|
||||||
|
key 36 6a 4a a a 6a 4a a a 0 1
|
||||||
|
key 37 6b 4b b b 6b 4b b b 0 1
|
||||||
|
key 38 6c 4c c c 6c 4c c c 0 1
|
||||||
|
key 39 3b 3a 0 0 3b 3a 0 0 33 0
|
||||||
|
key 40 27 22 0 0 27 22 0 0 33 0
|
||||||
|
key 41 60 7e 0 0 60 7e 0 0 33 0
|
||||||
|
key 42 2 2 2 2 2 2 2 2 ff 0
|
||||||
|
key 43 5c 7c 1c 1c 5c 7c 1c 1c 0 0
|
||||||
|
key 44 7a 5a 1a 1a 7a 5a 1a 1a 0 1
|
||||||
|
key 45 78 58 18 18 78 58 18 18 0 1
|
||||||
|
key 46 63 43 3 3 63 43 3 3 0 1
|
||||||
|
key 47 76 56 16 16 76 56 16 16 0 1
|
||||||
|
key 48 62 42 2 2 62 42 2 2 0 1
|
||||||
|
key 49 6e 4e e e 6e 4e e e 0 1
|
||||||
|
key 50 6d 4d d d 6d 4d d d 0 1
|
||||||
|
key 51 2c 3c 0 0 2c 3c 0 0 33 0
|
||||||
|
key 52 2e 3e 0 0 2e 3e 0 0 33 0
|
||||||
|
key 53 2f 3f 0 0 2f 3f 0 0 33 0
|
||||||
|
key 54 3 3 3 3 3 3 3 3 ff 0
|
||||||
|
key 55 2a 2a 2a 2a 2a 2a 2a 2a 0 0
|
||||||
|
key 56 7 7 7 7 7 7 7 7 ff 0
|
||||||
|
key 57 20 20 0 20 20 20 87 20 2 0
|
||||||
|
key 58 4 4 4 4 4 4 4 4 ff 0
|
||||||
|
key 59 1b 27 33 3f b 15 b 15 ff 0
|
||||||
|
key 60 1c 28 34 40 c 16 c 16 ff 0
|
||||||
|
key 61 1d 29 35 41 d 17 d 17 ff 0
|
||||||
|
key 62 1e 2a 36 42 e 18 e 18 ff 0
|
||||||
|
key 63 1f 2b 37 43 f 19 f 19 ff 0
|
||||||
|
key 64 20 2c 38 44 10 1a 10 1a ff 0
|
||||||
|
key 65 21 2d 39 45 11 11 11 11 ff 0
|
||||||
|
key 66 22 2e 3a 46 12 12 12 12 ff 0
|
||||||
|
key 67 23 2f 3b 47 13 13 13 13 ff 0
|
||||||
|
key 68 24 30 3c 48 14 14 14 14 ff 0
|
||||||
|
key 69 5 5 5 5 5 5 5 5 ff 0
|
||||||
|
key 70 6 6 6 6 6 6 6 6 ff 0
|
||||||
|
key 71 4b 37 37 37 37 37 37 37 80 2
|
||||||
|
key 72 4c 38 38 38 38 38 38 38 80 2
|
||||||
|
key 73 4d 39 39 39 39 39 39 39 80 2
|
||||||
|
key 74 4e 2d 2d 2d 2d 2d 2d 2d 80 2
|
||||||
|
key 75 4f 34 34 34 34 34 34 34 80 2
|
||||||
|
key 76 50 35 35 35 35 35 35 35 80 2
|
||||||
|
key 77 51 36 36 36 36 36 36 36 80 2
|
||||||
|
key 78 52 2b 2b 2b 2b 2b 2b 2b 80 2
|
||||||
|
key 79 53 31 31 31 31 31 31 31 80 2
|
||||||
|
key 80 54 32 32 32 32 32 32 32 80 2
|
||||||
|
key 81 55 33 33 33 33 33 33 33 80 2
|
||||||
|
key 82 56 30 30 30 30 30 30 30 80 2
|
||||||
|
key 83 7f 2e 2e 2e 2e 2e 85 85 3 2
|
||||||
|
key 84 0 0 0 0 0 0 0 0 ff 0
|
||||||
|
key 85 0 0 0 0 0 0 0 0 ff 0
|
||||||
|
key 86 0 0 0 0 0 0 0 0 ff 0
|
||||||
|
key 87 25 31 3d 49 15 15 15 15 ff 0
|
||||||
|
key 88 26 32 3e 4a 16 16 16 16 ff 0
|
||||||
|
key 89 d d a a d d a a 0 0
|
||||||
|
key 90 80 80 80 80 80 80 80 80 ff 0
|
||||||
|
key 91 2f 2f 2f 2f 2f 2f 2f 2f 0 2
|
||||||
|
key 92 a 99 86 86 0 0 0 0 ff 0
|
||||||
|
key 93 81 81 81 81 81 81 81 81 ff 0
|
||||||
|
key 94 4b 4b 4b 4b 4b 4b 4b 4b ff 0
|
||||||
|
key 95 4c 4c 4c 4c 4c 4c 4c 4c ff 0
|
||||||
|
key 96 4d 4d 4d 4d 4d 4d 4d 4d ff 0
|
||||||
|
key 97 4f 4f 4f 4f 4f 4f 4f 4f ff 0
|
||||||
|
key 98 51 51 51 51 51 51 51 51 ff 0
|
||||||
|
key 99 53 53 53 53 53 53 53 53 ff 0
|
||||||
|
key 100 54 54 54 54 54 54 54 54 ff 0
|
||||||
|
key 101 55 55 55 55 55 55 55 55 ff 0
|
||||||
|
key 102 56 a3 56 56 56 56 56 56 ff 0
|
||||||
|
key 103 57 57 57 57 57 57 85 57 ff 0
|
||||||
|
key 104 6 88 6 88 87 0 87 0 ff 0
|
||||||
|
key 105 58 58 58 58 58 58 58 58 ff 0
|
||||||
|
key 106 59 59 59 59 59 59 59 59 ff 0
|
||||||
|
key 107 5a 5a 5a 5a 5a 5a 5a 5a ff 0
|
||||||
|
key 108 0 0 0 0 0 0 0 0 ff 0
|
||||||
|
acc 0 60 61:e0 41:c0 65:e8 45:c8 69:ec 49:cc 6f:f2 4f:d2 75:f9 55:d9
|
||||||
|
acc 1 b4 61:e1 41:c1 65:e9 45:c9 69:ed 49:cd 6f:f3 4f:d3 75:fa 55:da 79:fd 59:dd
|
||||||
|
acc 2 5e 61:e2 41:c2 65:ea 45:ca 69:ee 49:ce 6f:f4 4f:d4 75:fb 55:db
|
||||||
|
acc 3 7e 61:e3 41:c3 6e:f1 4e:d1 6f:f5 4f:d5
|
||||||
|
acc 7 a8 61:e4 41:c4 65:eb 45:cb 69:ef 49:cf 6f:f6 4f:d6 75:fc 55:dc 79:ff
|
||||||
|
acc 9 b0 61:e5 41:c5
|
||||||
|
acc 10 b8 63:e7 43:c7
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
map 0 0200 001b 0031 0032 0033 0034 0035 0036 0037 0038 0039 0030 002d 003d 007f 0009 0b71 0b77 0b65 0b72 0b74 0b79 0b75 0b69 0b6f 0b70 005b 005d 0201 0702 0b61 0b73 0b64 0b66 0b67 0b68 0b6a 0b6b 0b6c 003b 0027 0060 0700 005c 0b7a 0b78 0b63 0b76 0b62 0b6e 0b6d 002c 002e 002f 0700 030c 0703 0020 0207 0100 0101 0102 0103 0104 0105 0106 0107 0108 0109 0208 0209 0307 0308 0309 030b 0304 0305 0306 030a 0301 0302 0303 0300 0310 0206 0200 003c 010a 010b 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 001c 0701 0205 0114 0603 0118 0601 0602 0117 0600 0119 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 1 0200 001b 0021 0040 0023 0024 0025 005e 0026 002a 0028 0029 005f 002b 007f 0009 0b51 0b57 0b45 0b52 0b54 0b59 0b55 0b49 0b4f 0b50 007b 007d 0201 0702 0b41 0b53 0b44 0b46 0b47 0b48 0b4a 0b4b 0b4c 003a 0022 007e 0700 007c 0b5a 0b58 0b43 0b56 0b42 0b4e 0b4d 003c 003e 003f 0700 030c 0703 0020 0207 010a 010b 010c 010d 010e 010f 0110 0111 0112 0113 0213 0203 0307 0308 0309 030b 0304 0305 0306 030a 0301 0302 0303 0300 0310 0206 0200 003e 010a 010b 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 0200 0701 0205 0114 0603 020b 0601 0602 0117 0600 020a 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 2 0200 0200 0200 0040 0200 0024 0200 0200 007b 005b 005d 007d 005c 0200 0200 0200 0b71 0b77 0918 0b72 0b74 0b79 0b75 0b69 0b6f 0b70 0200 007e 0201 0702 0914 0b73 0917 0919 0b67 0b68 0b6a 0b6b 0b6c 0200 0200 0200 0700 0200 0b7a 0b78 0916 0b76 0915 0b6e 0b6d 0200 0200 0200 0700 030c 0703 0200 0207 050c 050d 050e 050f 0510 0511 0512 0513 0514 0515 0208 0202 0911 0912 0913 030b 090e 090f 0910 030a 090b 090c 090d 090a 0310 0206 0200 007c 0516 0517 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 0200 0701 0205 0114 0603 0118 0601 0602 0117 0600 0119 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 4 0200 0200 0200 0000 001b 001c 001d 001e 001f 007f 0200 0200 001f 0200 0008 0200 0011 0017 0005 0012 0014 0019 0015 0009 000f 0010 001b 001d 0201 0702 0001 0013 0004 0006 0007 0008 000a 000b 000c 0200 0007 0000 0700 001c 001a 0018 0003 0016 0002 000e 000d 0200 020e 007f 0700 030c 0703 0000 0207 0100 0101 0102 0103 0104 0105 0106 0107 0108 0109 0208 0204 0307 0308 0309 030b 0304 0305 0306 030a 0301 0302 0303 0300 0310 0206 0200 0200 010a 010b 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 001c 0701 0205 0114 0603 0118 0601 0602 0117 0600 0119 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 5 0200 0200 0200 0000 0200 0200 0200 0200 0200 0200 0200 0200 001f 0200 0200 0200 0011 0017 0005 0012 0014 0019 0015 0009 000f 0010 0200 0200 0201 0702 0001 0013 0004 0006 0007 0008 000a 000b 000c 0200 0200 0200 0700 0200 001a 0018 0003 0016 0002 000e 000d 0200 0200 0200 0700 030c 0703 0200 0207 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0208 0200 0307 0308 0309 030b 0304 0305 0306 030a 0301 0302 0303 0300 0310 0206 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 0200 0701 0205 0114 0603 0118 0601 0602 0117 0600 0119 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 8 0200 081b 0831 0832 0833 0834 0835 0836 0837 0838 0839 0830 082d 083d 087f 0809 0871 0877 0865 0872 0874 0879 0875 0869 086f 0870 085b 085d 080d 0702 0861 0873 0864 0866 0867 0868 086a 086b 086c 083b 0827 0860 0700 085c 087a 0878 0863 0876 0862 086e 086d 082c 082e 082f 0700 030c 0703 0820 0207 0500 0501 0502 0503 0504 0505 0506 0507 0508 0509 0208 0209 0907 0908 0909 030b 0904 0905 0906 030a 0901 0902 0903 0900 0310 0206 0200 083c 050a 050b 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 001c 0701 0205 0114 0603 0118 0210 0211 0117 0600 0119 0115 0116 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
map 12 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0811 0817 0805 0812 0814 0819 0815 0809 080f 0810 0200 0200 0201 0702 0801 0813 0804 0806 0807 0808 080a 080b 080c 0200 0200 0200 0700 0200 081a 0818 0803 0816 0802 080e 080d 0200 0200 0200 0700 030c 0703 0200 0207 0500 0501 0502 0503 0504 0505 0506 0507 0508 0509 0208 0200 0307 0308 0309 030b 0304 0305 0306 030a 0301 0302 0303 0300 020c 0206 0200 0200 050a 050b 0200 0200 0200 0200 0200 0200 0200 030e 0702 030d 0200 0701 0205 0114 0603 0118 0601 0602 0117 0600 0119 0115 020c 011a 010c 010d 011b 011c 0110 0311 011d 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200 0200
|
||||||
|
fn 0 "\x1b[[A"
|
||||||
|
fn 1 "\x1b[[B"
|
||||||
|
fn 2 "\x1b[[C"
|
||||||
|
fn 3 "\x1b[[D"
|
||||||
|
fn 4 "\x1b[[E"
|
||||||
|
fn 5 "\x1b[17~"
|
||||||
|
fn 6 "\x1b[18~"
|
||||||
|
fn 7 "\x1b[19~"
|
||||||
|
fn 8 "\x1b[20~"
|
||||||
|
fn 9 "\x1b[21~"
|
||||||
|
fn 10 "\x1b[23~"
|
||||||
|
fn 11 "\x1b[24~"
|
||||||
|
fn 12 "\x1b[25~"
|
||||||
|
fn 13 "\x1b[26~"
|
||||||
|
fn 14 "\x1b[28~"
|
||||||
|
fn 15 "\x1b[29~"
|
||||||
|
fn 16 "\x1b[31~"
|
||||||
|
fn 17 "\x1b[32~"
|
||||||
|
fn 18 "\x1b[33~"
|
||||||
|
fn 19 "\x1b[34~"
|
||||||
|
fn 20 "\x1b[1~"
|
||||||
|
fn 21 "\x1b[2~"
|
||||||
|
fn 22 "\x1b[3~"
|
||||||
|
fn 23 "\x1b[4~"
|
||||||
|
fn 24 "\x1b[5~"
|
||||||
|
fn 25 "\x1b[6~"
|
||||||
|
fn 26 "\x1b[M"
|
||||||
|
fn 29 "\x1b[P"
|
||||||
+58
-15
@@ -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
|
||||||
|
|
||||||
@@ -169,6 +187,28 @@ r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSl
|
|||||||
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
|
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ScrollThumb` is the thumb a bar draws. `ScrollDrag` drives one by the
|
||||||
|
pointer, rows counted from the track's top: a press on the thumb takes hold
|
||||||
|
of it, a press on the track moves the thumb's middle there and takes hold, and
|
||||||
|
a drag keeps the held row under the pointer, each returning the offset to show.
|
||||||
|
|
||||||
|
```go
|
||||||
|
var drag tui.ScrollDrag // kept across events
|
||||||
|
bar := r.Sub(r.W-1, 0, 1, r.H) // the column ScrollBar draws, in screen cells at bar.X, bar.Y
|
||||||
|
|
||||||
|
// per left button event:
|
||||||
|
switch ev.MouseAction {
|
||||||
|
case terminal.MouseActionPress:
|
||||||
|
if ev.MouseX == bar.X {
|
||||||
|
state.ScrollTo(drag.Press(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total))
|
||||||
|
}
|
||||||
|
case terminal.MouseActionDrag:
|
||||||
|
state.ScrollTo(drag.Drag(ev.MouseY-bar.Y, bar.H, state.Offset, state.Visible, state.Total))
|
||||||
|
case terminal.MouseActionRelease:
|
||||||
|
drag.Release()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Modal dialog
|
### Modal dialog
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -229,7 +269,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,11 +281,12 @@ 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
|
||||||
background beneath it, bits and all.
|
background beneath it, bits and all.
|
||||||
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
|
- Mouse hit testing: `TabBar` returns `[]TabBounds`, and `ScrollDrag` maps a
|
||||||
application-side geometry from the regions used.
|
scrollbar's rows to offsets; other widgets require application-side
|
||||||
|
geometry from the regions used.
|
||||||
+128
-54
@@ -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++ {
|
||||||
@@ -146,10 +149,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
|
|||||||
text = Truncate(text, r.W-2)
|
text = Truncate(text, r.W-2)
|
||||||
textLen = RuneLen(text)
|
textLen = RuneLen(text)
|
||||||
}
|
}
|
||||||
startX := (r.W - textLen) / 2
|
r.Text((r.W-textLen)/2, y, text, fg, color.RGB{}, terminal.AttrBold)
|
||||||
for i, ch := range text {
|
|
||||||
r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +171,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),
|
||||||
|
|||||||
+3
-15
@@ -225,11 +225,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
|
|||||||
yesBg = style.ButtonFocusBg
|
yesBg = style.ButtonFocusBg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, ch := range yesLabel {
|
content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone)
|
||||||
if buttonX+i < content.W {
|
|
||||||
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No button
|
// No button
|
||||||
noX := buttonX + yesW + buttonGap
|
noX := buttonX + yesW + buttonGap
|
||||||
@@ -239,11 +235,7 @@ func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
|
|||||||
noFg = style.ButtonFocusFg
|
noFg = style.ButtonFocusFg
|
||||||
noBg = style.ButtonFocusBg
|
noBg = style.ButtonFocusBg
|
||||||
}
|
}
|
||||||
for i, ch := range noLabel {
|
content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone)
|
||||||
if noX+i < content.W {
|
|
||||||
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.Sub(0, 0, content.W, buttonY-1)
|
return content.Sub(0, 0, content.W, buttonY-1)
|
||||||
}
|
}
|
||||||
@@ -317,11 +309,7 @@ func (r Region) AlertDialog(opts AlertOpts) Region {
|
|||||||
buttonW := RuneLen(buttonLabel)
|
buttonW := RuneLen(buttonLabel)
|
||||||
buttonX := (content.W - buttonW) / 2
|
buttonX := (content.W - buttonW) / 2
|
||||||
|
|
||||||
for i, ch := range buttonLabel {
|
content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
|
||||||
if buttonX+i < content.W {
|
|
||||||
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.Sub(0, 0, content.W, buttonY-1)
|
return content.Sub(0, 0, content.W, buttonY-1)
|
||||||
}
|
}
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+1
-6
@@ -164,12 +164,7 @@ func (r Region) Form(state *FormState, opts FormOpts) int {
|
|||||||
|
|
||||||
// Label
|
// Label
|
||||||
label := field.Label + ":"
|
label := field.Label + ":"
|
||||||
for j, ch := range label {
|
r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone)
|
||||||
if j >= labelW || j >= r.W {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field area
|
// Field area
|
||||||
fieldX := labelW
|
fieldX := labelW
|
||||||
|
|||||||
+2
-10
@@ -36,10 +36,7 @@ func (r Region) Modal(opts ModalOpts) Region {
|
|||||||
title = Truncate(title, r.W-4)
|
title = Truncate(title, r.W-4)
|
||||||
titleLen = RuneLen(title)
|
titleLen = RuneLen(title)
|
||||||
}
|
}
|
||||||
x := (r.W - titleLen) / 2
|
r.Text((r.W-titleLen)/2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||||
for i, ch := range title {
|
|
||||||
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint right-aligned on top edge
|
// Hint right-aligned on top edge
|
||||||
@@ -54,12 +51,7 @@ func (r Region) Modal(opts ModalOpts) Region {
|
|||||||
if x < r.W/2 {
|
if x < r.W/2 {
|
||||||
x = r.W / 2
|
x = r.W / 2
|
||||||
}
|
}
|
||||||
for i, ch := range hint {
|
r.Sub(0, 0, r.W-1, 1).Text(x, 0, hint, opts.HintFg, opts.Bg, terminal.AttrNone)
|
||||||
if x+i >= r.W-1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return content region
|
// Return content region
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-7
@@ -33,13 +33,7 @@ func (r Region) Pane(opts PaneOpts) Region {
|
|||||||
if RuneLen(title) > r.W-4 {
|
if RuneLen(title) > r.W-4 {
|
||||||
title = Truncate(title, r.W-4)
|
title = Truncate(title, r.W-4)
|
||||||
}
|
}
|
||||||
x := 2
|
r.Text(2, 0, title, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||||
for i, ch := range title {
|
|
||||||
if x+i >= r.W-1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content region is everything inside the border. The title sits ON the border row.
|
// Content region is everything inside the border. The title sits ON the border row.
|
||||||
|
|||||||
@@ -194,10 +194,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
|
|||||||
title = Truncate(title, overlayW-4)
|
title = Truncate(title, overlayW-4)
|
||||||
titleLen = RuneLen(title)
|
titleLen = RuneLen(title)
|
||||||
}
|
}
|
||||||
titleX := (overlayW - titleLen) / 2
|
overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
||||||
for i, ch := range title {
|
|
||||||
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
|
|||||||
+64
-10
@@ -35,8 +35,8 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackH := r.H
|
trackH := r.H
|
||||||
if total <= visible || trackH < 3 {
|
thumbY, thumbH := ScrollThumb(trackH, offset, visible, total)
|
||||||
// No scrolling needed or track too small
|
if thumbH == 0 {
|
||||||
if opts.HideIdle {
|
if opts.HideIdle {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -46,14 +46,6 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbH := min(max((visible*trackH)/total, 1), trackH)
|
|
||||||
maxScroll := total - visible
|
|
||||||
thumbY := 0
|
|
||||||
if maxScroll > 0 {
|
|
||||||
thumbY = (offset * (trackH - thumbH)) / maxScroll
|
|
||||||
}
|
|
||||||
thumbY = min(max(thumbY, 0), trackH-thumbH)
|
|
||||||
|
|
||||||
for y := range trackH {
|
for y := range trackH {
|
||||||
if y >= thumbY && y < thumbY+thumbH {
|
if y >= thumbY && y < thumbY+thumbH {
|
||||||
r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone)
|
r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone)
|
||||||
@@ -63,6 +55,68 @@ func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScrollThumb is the thumb a scrollbar trackH rows tall draws for offset: its
|
||||||
|
// first row and its height, 0 when content fits or the track is under 3 rows
|
||||||
|
func ScrollThumb(trackH, offset, visible, total int) (y, h int) {
|
||||||
|
if total <= visible || trackH < 3 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
h = min(max((visible*trackH)/total, 1), trackH)
|
||||||
|
y = (offset * (trackH - h)) / (total - visible)
|
||||||
|
return min(max(y, 0), trackH-h), h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollDrag holds a scrollbar's thumb under the pointer, rows counted from the
|
||||||
|
// track's top: a press on the thumb takes hold where it lands, a press on the
|
||||||
|
// track moves the thumb's middle there first, and each drag keeps the row held
|
||||||
|
// under the pointer. Each call returns the offset to show.
|
||||||
|
type ScrollDrag struct {
|
||||||
|
Active bool // The thumb is held
|
||||||
|
Grab int // The thumb's row the pointer holds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press takes hold of the thumb at track row y; off the track it holds nothing
|
||||||
|
func (d *ScrollDrag) Press(y, trackH, offset, visible, total int) int {
|
||||||
|
top, h := ScrollThumb(trackH, offset, visible, total)
|
||||||
|
d.Active = h > 0 && y >= 0 && y < trackH
|
||||||
|
if !d.Active {
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
if y < top || y >= top+h {
|
||||||
|
offset = scrollOffsetAt(y-h/2, trackH, h, visible, total)
|
||||||
|
top, _ = ScrollThumb(trackH, offset, visible, total)
|
||||||
|
}
|
||||||
|
d.Grab = min(max(y-top, 0), h-1)
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag moves the held row to track row y, clamped at the track's ends
|
||||||
|
func (d *ScrollDrag) Drag(y, trackH, offset, visible, total int) int {
|
||||||
|
_, h := ScrollThumb(trackH, offset, visible, total)
|
||||||
|
if !d.Active || h == 0 {
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
return scrollOffsetAt(y-d.Grab, trackH, h, visible, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release lets go of the thumb
|
||||||
|
func (d *ScrollDrag) Release() { *d = ScrollDrag{} }
|
||||||
|
|
||||||
|
// scrollOffsetAt is the offset whose thumb, h rows tall, starts nearest row top
|
||||||
|
func scrollOffsetAt(top, trackH, h, visible, total int) int {
|
||||||
|
span, maxScroll := trackH-h, total-visible
|
||||||
|
top = min(max(top, 0), span)
|
||||||
|
o := (top*maxScroll + span - 1) / span // The first offset whose thumb starts at or past top
|
||||||
|
if o > 0 {
|
||||||
|
at, _ := ScrollThumb(trackH, o, visible, total)
|
||||||
|
before, _ := ScrollThumb(trackH, o-1, visible, total)
|
||||||
|
if at-top > top-before {
|
||||||
|
return o - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
|
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
|
||||||
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) {
|
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) {
|
||||||
if y < 0 || y >= r.H {
|
if y < 0 || y >= r.H {
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
+1
-7
@@ -64,13 +64,7 @@ func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []Ta
|
|||||||
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
|
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStart := x + opts.Padding
|
r.TextStyled(x+opts.Padding, y, title, style)
|
||||||
for j, ch := range title {
|
|
||||||
if titleStart+j >= r.W {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
|
|
||||||
}
|
|
||||||
|
|
||||||
for j := 0; j < opts.Padding; j++ {
|
for j := 0; j < opts.Padding; j++ {
|
||||||
pos := x + opts.Padding + RuneLen(title) + j
|
pos := x + opts.Padding + RuneLen(title) + j
|
||||||
|
|||||||
+1
-5
@@ -171,11 +171,7 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali
|
|||||||
startX = x
|
startX = x
|
||||||
}
|
}
|
||||||
|
|
||||||
for j, ch := range text {
|
r.TextStyled(startX, y, text, style)
|
||||||
if startX+j < r.W {
|
|
||||||
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
x += w
|
x += w
|
||||||
|
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-6
@@ -108,12 +108,7 @@ func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
|
|||||||
if RuneLen(placeholder) > viewportW {
|
if RuneLen(placeholder) > viewportW {
|
||||||
placeholder = Truncate(placeholder, viewportW)
|
placeholder = Truncate(placeholder, viewportW)
|
||||||
}
|
}
|
||||||
for i, ch := range placeholder {
|
r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
|
||||||
if x+i >= contentX+contentW {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Scroll indicators
|
// Scroll indicators
|
||||||
if state.Scroll > 0 && x > contentX {
|
if state.Scroll > 0 && x > contentX {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+309
-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,287 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// A press on a scrollbar's track moves the thumb's middle to it and a press on
|
||||||
|
// the thumb leaves it, either taking hold; a drag keeps the held row under the
|
||||||
|
// pointer, clamped at the ends, or as near as an offset can put it when there are
|
||||||
|
// fewer offsets than rows. A bar with no thumb, or a row off it, holds nothing.
|
||||||
|
func TestScrollDragHoldsTheThumbUnderThePointer(t *testing.T) {
|
||||||
|
near := func(tops []int, want, got int) bool {
|
||||||
|
for _, top := range tops {
|
||||||
|
if max(top-want, want-top) < max(got-want, want-got) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for trackH := 3; trackH <= 10; trackH++ {
|
||||||
|
for total := 1; total <= 30; total++ {
|
||||||
|
for visible := 1; visible <= total+1; visible++ {
|
||||||
|
var tops []int
|
||||||
|
for o := 0; o <= total-visible; o++ {
|
||||||
|
top, _ := ScrollThumb(trackH, o, visible, total)
|
||||||
|
tops = append(tops, top)
|
||||||
|
}
|
||||||
|
for y := -1; y <= trackH; y++ {
|
||||||
|
var d ScrollDrag
|
||||||
|
from := (total - visible) / 3
|
||||||
|
fromTop, h := ScrollThumb(trackH, from, visible, total)
|
||||||
|
off := d.Press(y, trackH, from, visible, total)
|
||||||
|
top, _ := ScrollThumb(trackH, off, visible, total)
|
||||||
|
onThumb := func(top int) bool { return y >= top && y < top+h }
|
||||||
|
switch {
|
||||||
|
case h == 0 || y < 0 || y >= trackH:
|
||||||
|
if d.Active || off != from {
|
||||||
|
t.Fatalf("%d/%d/%d: a press at %d off the bar holds %v at %d", trackH, visible, total, y, d.Active, off)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case onThumb(fromTop):
|
||||||
|
if off != from {
|
||||||
|
t.Fatalf("%d/%d/%d: a press on the thumb moved it %d to %d", trackH, visible, total, from, off)
|
||||||
|
}
|
||||||
|
case !near(tops, min(max(y-h/2, 0), trackH-h), top) || !onThumb(top) && slices.ContainsFunc(tops, onThumb):
|
||||||
|
t.Fatalf("%d/%d/%d: a press at %d starts the thumb at %d", trackH, visible, total, y, top)
|
||||||
|
}
|
||||||
|
for to := -2; to <= trackH+1; to++ {
|
||||||
|
got, _ := ScrollThumb(trackH, d.Drag(to, trackH, off, visible, total), visible, total)
|
||||||
|
if want := min(max(to-d.Grab, 0), trackH-h); !near(tops, want, got) {
|
||||||
|
t.Fatalf("%d/%d/%d: held at %d and dragged to %d, the thumb starts at %d, want near %d",
|
||||||
|
trackH, visible, total, d.Grab, to, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A label is drawn a column a rune, so one holding runes of more than a byte
|
||||||
|
// keeps them together: each widget draws s as many times as it is given it
|
||||||
|
func TestLabelsKeepTheirRunesTogether(t *testing.T) {
|
||||||
|
const s = "● é · ñ"
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
n int
|
||||||
|
draw func(r Region)
|
||||||
|
}{
|
||||||
|
{"pane", 1, func(r Region) { r.Pane(PaneOpts{Title: s}) }},
|
||||||
|
{"modal", 2, func(r Region) { r.Modal(ModalOpts{Title: s, Hint: s}) }},
|
||||||
|
{"divider", 1, func(r Region) { r.Divider(0, s, LineSingle, color.RGB{}) }},
|
||||||
|
{"tabs", 2, func(r Region) { r.TabBar(0, []string{s, s}, 0, DefaultTabBarOpts()) }},
|
||||||
|
{"table", 2, func(r Region) { r.Table([]string{s}, [][]string{{s}}, TableOpts{}) }},
|
||||||
|
{"form", 1, func(r Region) { r.Form(NewFormState(s), FormOpts{}) }},
|
||||||
|
{"field", 1, func(r Region) { r.TextField(NewTextFieldState(""), TextFieldOpts{Placeholder: s}) }},
|
||||||
|
{"confirm", 4, func(r Region) {
|
||||||
|
r.ConfirmDialog(NewConfirmState(true), ConfirmOpts{Title: s, Message: s, YesLabel: s, NoLabel: s})
|
||||||
|
}},
|
||||||
|
{"alert", 3, func(r Region) { r.AlertDialog(AlertOpts{Title: s, Message: s, Button: s}) }},
|
||||||
|
{"progress", 1, func(r Region) { r.ProgressOverlay(DefaultProgressOpts(s, "", ProgressSpinner)) }},
|
||||||
|
} {
|
||||||
|
r := canvas(48, 14, MonoTheme)
|
||||||
|
c.draw(r)
|
||||||
|
rows := make([]string, r.H)
|
||||||
|
for y := range rows {
|
||||||
|
rows[y] = row(r, y)
|
||||||
|
}
|
||||||
|
if got := strings.Count(strings.Join(rows, "\n"), s); got != c.n {
|
||||||
|
t.Errorf("%s: %d of %d, rows %q", c.name, got, c.n, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case t.drained <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user