Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,
|
||||||
@@ -218,7 +237,6 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,11 @@
|
|||||||
//
|
//
|
||||||
// # Performance
|
// # Performance
|
||||||
//
|
//
|
||||||
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells
|
// Output uses cell-level diffing. Only changed cells generate ANSI sequences.
|
||||||
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls.
|
// Style attributes are coalesced to minimize SGR calls. One goroutine writes
|
||||||
|
// what is rendered, so a terminal that stops reading never holds the caller:
|
||||||
|
// Flush keeps the newest frame until the write in flight returns, and Blocked
|
||||||
|
// reports how long it has waited.
|
||||||
// Input parsing is zero-allocation for common cases.
|
// Input parsing is zero-allocation for common cases.
|
||||||
//
|
//
|
||||||
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
module github.com/lixenwraith/terminal
|
module github.com/lixenwraith/terminal
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lixenwraith/color v0.1.3
|
github.com/lixenwraith/color v0.1.3
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.47.0
|
||||||
)
|
)
|
||||||
@@ -4,5 +4,9 @@ github.com/lixenwraith/color v0.1.3 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzF
|
|||||||
github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU=
|
github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
@@ -57,6 +57,9 @@ 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
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -186,6 +189,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 {
|
||||||
@@ -321,7 +337,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 +367,79 @@ 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0, Event{}
|
// Alt+Backspace
|
||||||
|
if data[1] == 0x7f {
|
||||||
|
return 2, Event{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+multibyte character; a byte no character starts with leaves Escape
|
||||||
|
size := utf8SeqLen(data[1])
|
||||||
|
if size == 0 {
|
||||||
|
return 1, Event{Type: EventKey, Key: KeyEscape}
|
||||||
|
}
|
||||||
|
if len(data) < 1+size {
|
||||||
|
return 0, Event{}
|
||||||
|
}
|
||||||
|
rn, n := decodeRune(data[1:])
|
||||||
|
return 1 + n, Event{Type: EventKey, Key: KeyRune, Rune: rn, Modifiers: ModAlt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// csiMaxLen bounds a CSI sequence a terminal sends, with ESC [ and its final
|
||||||
|
// byte; a longer one is dropped up to its final byte
|
||||||
|
const csiMaxLen = 256
|
||||||
|
|
||||||
|
// csiLen results other than a length: more may be arriving, a byte no
|
||||||
|
// sequence holds came (ESC [ was typed, as Alt+[), or no final byte came
|
||||||
|
// within csiMaxLen
|
||||||
|
const (
|
||||||
|
csiPartial = 0
|
||||||
|
csiTyped = -1
|
||||||
|
csiOverlong = -2
|
||||||
|
)
|
||||||
|
|
||||||
|
// csiLen returns the length of the CSI sequence data starts with, or why it
|
||||||
|
// has none yet. Parameter and intermediate bytes run to a final byte as in
|
||||||
|
// ECMA-48, but for the Linux console's F1-F5, ESC [ [ A to E.
|
||||||
|
func csiLen(data []byte) int {
|
||||||
|
i := 2
|
||||||
|
if len(data) > 2 && data[2] == '[' {
|
||||||
|
i = 3
|
||||||
|
}
|
||||||
|
for ; i < len(data); i++ {
|
||||||
|
if i == csiMaxLen {
|
||||||
|
return csiOverlong
|
||||||
|
}
|
||||||
|
switch b := data[i]; {
|
||||||
|
case b >= 0x40 && b <= 0x7e:
|
||||||
|
return i + 1
|
||||||
|
case b < 0x20 || b > 0x3f:
|
||||||
|
return csiTyped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return csiPartial
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCSI parses CSI sequence without allocation
|
// 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])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -483,31 +523,12 @@ func (r *inputReader) parseControl(b byte) Event {
|
|||||||
return Event{Type: EventKey, Key: KeyNone}
|
return Event{Type: EventKey, Key: KeyNone}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSGRMouse parses mouse SGR sequences
|
// parseSGRMouse parses mouse SGR sequences, ESC [ < Btn ; X ; Y M/m, from
|
||||||
func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
// the parameters and final byte; a malformed one is dropped as KeyNone
|
||||||
// Format: ESC [ < Btn ; X ; Y M/m
|
func parseSGRMouse(params []byte, final byte) Event {
|
||||||
// Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes
|
|
||||||
if len(data) < 10 {
|
|
||||||
return 0, Event{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find terminator M or m
|
|
||||||
end := 3
|
|
||||||
for end < len(data) && end < 32 {
|
|
||||||
if data[end] == 'M' || data[end] == 'm' {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
if end >= len(data) || (data[end] != 'M' && data[end] != 'm') {
|
|
||||||
return 0, Event{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse: Btn;X;Y
|
|
||||||
params := data[3:end]
|
|
||||||
btn, x, y, ok := parseSGRParams(params)
|
btn, x, y, ok := parseSGRParams(params)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, Event{}
|
return Event{Type: EventKey, Key: KeyNone}
|
||||||
}
|
}
|
||||||
|
|
||||||
ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
|
ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
|
||||||
@@ -541,7 +562,7 @@ func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
|||||||
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
||||||
}
|
}
|
||||||
|
|
||||||
if data[end] == 'M' {
|
if final == 'M' {
|
||||||
if isMotion {
|
if isMotion {
|
||||||
if ev.MouseBtn != MouseBtnNone {
|
if ev.MouseBtn != MouseBtnNone {
|
||||||
ev.MouseAction = MouseActionDrag
|
ev.MouseAction = MouseActionDrag
|
||||||
@@ -567,7 +588,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
|
||||||
@@ -663,3 +684,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
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bytes"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
// outputBuffer manages double-buffered terminal output with diffing
|
// outputBuffer renders double-buffered terminal output with diffing. It only
|
||||||
|
// encodes: what an operation renders collects in buf, and termImpl hands it to
|
||||||
|
// the goroutine that writes it.
|
||||||
type outputBuffer struct {
|
type outputBuffer struct {
|
||||||
front []Cell
|
front []Cell
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
colorMode ColorMode
|
colorMode ColorMode
|
||||||
writer *bufio.Writer
|
buf bytes.Buffer
|
||||||
|
|
||||||
cursorX int
|
cursorX int
|
||||||
cursorY int
|
cursorY int
|
||||||
@@ -27,7 +30,7 @@ type outputBuffer struct {
|
|||||||
lastValid bool
|
lastValid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerAdapter adapts Backend to io.Writer for bufio
|
// writerAdapter adapts Backend to io.Writer
|
||||||
type writerAdapter struct {
|
type writerAdapter struct {
|
||||||
b Backend
|
b Backend
|
||||||
}
|
}
|
||||||
@@ -40,17 +43,10 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOutputBuffer creates a new output buffer
|
func newOutputBuffer(colorMode ColorMode) *outputBuffer {
|
||||||
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
return &outputBuffer{colorMode: colorMode}
|
||||||
// Use 128KB buffer for minimal calls to backend
|
|
||||||
adapter := writerAdapter{b: backend}
|
|
||||||
return &outputBuffer{
|
|
||||||
writer: bufio.NewWriterSize(adapter, 131072),
|
|
||||||
colorMode: colorMode,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize updates buffer dimensions
|
|
||||||
func (o *outputBuffer) resize(width, height int) {
|
func (o *outputBuffer) resize(width, height int) {
|
||||||
size := width * height
|
size := width * height
|
||||||
if cap(o.front) < size {
|
if cap(o.front) < size {
|
||||||
@@ -68,16 +64,31 @@ func (o *outputBuffer) resize(width, height int) {
|
|||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// cellEqual compares two cells for equality (standalone for inlining)
|
// cellEqual compares the fields most likely to change first
|
||||||
func cellEqual(a, b Cell) bool {
|
func cellEqual(a, b Cell) bool {
|
||||||
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
|
|
||||||
return a.Rune == b.Rune &&
|
return a.Rune == b.Rune &&
|
||||||
a.Bg == b.Bg &&
|
a.Bg == b.Bg &&
|
||||||
a.Fg == b.Fg &&
|
a.Fg == b.Fg &&
|
||||||
a.Attrs == b.Attrs
|
a.Attrs == b.Attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// flush writes the back buffer to terminal, diffing against front buffer
|
// Cell has no padding, so a row compares as the bytes it occupies; the assertion
|
||||||
|
// fails to compile should a field change leave padding behind
|
||||||
|
var _ [0]struct{} = [unsafe.Sizeof(Cell{}) - unsafe.Sizeof(rune(0)) - 2*unsafe.Sizeof(color.RGB{}) - unsafe.Sizeof(Attr(0))]struct{}{}
|
||||||
|
|
||||||
|
// rowEqual reports whether two rows of cells hold the same cells, as one memcmp
|
||||||
|
func rowEqual(a, b []Cell) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(a) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
n := len(a) * int(unsafe.Sizeof(Cell{}))
|
||||||
|
return bytes.Equal(unsafe.Slice((*byte)(unsafe.Pointer(&a[0])), n), unsafe.Slice((*byte)(unsafe.Pointer(&b[0])), n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush renders the back buffer into buf, diffing against front buffer
|
||||||
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||||
if width != o.width || height != o.height {
|
if width != o.width || height != o.height {
|
||||||
o.resize(width, height)
|
o.resize(width, height)
|
||||||
@@ -88,18 +99,21 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := o.writer
|
w := &o.buf
|
||||||
|
|
||||||
for y := 0; y < height; y++ {
|
for y := 0; y < height; y++ {
|
||||||
rowStart := y * width
|
rowStart := y * width
|
||||||
|
// Most rows of a frame are unchanged; one compare settles each of those
|
||||||
|
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Early termination: find last dirty cell in row (scan backward)
|
|
||||||
rowEnd := width
|
rowEnd := width
|
||||||
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
||||||
rowEnd--
|
rowEnd--
|
||||||
}
|
}
|
||||||
if rowEnd == 0 {
|
if rowEnd == 0 {
|
||||||
continue // Entire row unchanged
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
x := 0
|
x := 0
|
||||||
@@ -111,13 +125,11 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Found dirty cell - check for small gaps ahead to potentially merge segments
|
|
||||||
segStart := x
|
segStart := x
|
||||||
segEnd := x + 1
|
segEnd := x + 1
|
||||||
|
|
||||||
// Extend segment through small gaps (≤3 unchanged cells)
|
// A gap of up to 3 unchanged cells is rewritten, about the bytes of a cursor move
|
||||||
for segEnd < rowEnd {
|
for segEnd < rowEnd {
|
||||||
// Find gap size
|
|
||||||
gapStart := segEnd
|
gapStart := segEnd
|
||||||
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
|
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
|
||||||
gapStart++
|
gapStart++
|
||||||
@@ -125,7 +137,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
gapSize := gapStart - segEnd
|
gapSize := gapStart - segEnd
|
||||||
|
|
||||||
if gapSize == 0 {
|
if gapSize == 0 {
|
||||||
// No gap, extend to next unchanged
|
|
||||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||||
segEnd++
|
segEnd++
|
||||||
}
|
}
|
||||||
@@ -133,16 +144,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if gapSize > 3 {
|
if gapSize > 3 {
|
||||||
break // Gap too large, end segment here
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move
|
// Only a gap in the segment's style: an SGR inside it costs more than the move
|
||||||
gapCompatible := true
|
gapCompatible := true
|
||||||
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment
|
refCell := cells[rowStart+segEnd-1]
|
||||||
|
|
||||||
for k := 0; k < gapSize; k++ {
|
for k := 0; k < gapSize; k++ {
|
||||||
gCell := cells[rowStart+segEnd+k]
|
gCell := cells[rowStart+segEnd+k]
|
||||||
// Strict equality on style/color to ensure no SGR emission
|
|
||||||
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
|
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
|
||||||
gapCompatible = false
|
gapCompatible = false
|
||||||
break
|
break
|
||||||
@@ -150,41 +160,30 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !gapCompatible {
|
if !gapCompatible {
|
||||||
break // Gap has different style, cheaper to jump
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's more dirty content after gap
|
|
||||||
if gapStart >= rowEnd {
|
if gapStart >= rowEnd {
|
||||||
break // Gap extends to row end
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small gap with content after - include gap in segment
|
|
||||||
segEnd = gapStart
|
segEnd = gapStart
|
||||||
// Continue to find more dirty cells
|
|
||||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||||
segEnd++
|
segEnd++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positions cursor to segment start
|
|
||||||
o.moveCursorTo(w, segStart, y)
|
o.moveCursorTo(w, segStart, y)
|
||||||
|
|
||||||
// Write segment [segStart, segEnd)
|
|
||||||
for sx := segStart; sx < segEnd; sx++ {
|
for sx := segStart; sx < segEnd; sx++ {
|
||||||
cidx := rowStart + sx
|
cidx := rowStart + sx
|
||||||
c := cells[cidx]
|
c := cells[cidx]
|
||||||
|
|
||||||
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
||||||
|
if r := c.Rune; r >= ' ' && r < 0x7f {
|
||||||
// A cell holds one glyph, never a control the terminal would act on
|
|
||||||
r := c.Rune
|
|
||||||
if unicode.IsControl(r) || !utf8.ValidRune(r) {
|
|
||||||
r = ' '
|
|
||||||
}
|
|
||||||
if r < 0x80 {
|
|
||||||
w.WriteByte(byte(r))
|
w.WriteByte(byte(r))
|
||||||
} else {
|
} else {
|
||||||
w.WriteRune(r)
|
w.WriteRune(oneColumn(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
o.front[cidx] = c
|
o.front[cidx] = c
|
||||||
@@ -197,10 +196,92 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
|
|
||||||
w.Write(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
o.lastValid = false
|
o.lastValid = false
|
||||||
w.Flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cursorForwardCost returns byte cost of cursor forward sequence
|
// oneColumn is r as a cell draws it: one glyph in one column, where the
|
||||||
|
// terminal draws ambiguous-width runes (box drawing, U+FFFD) narrow
|
||||||
|
func oneColumn(r rune) rune {
|
||||||
|
if uint32(r) < 0x10000 && plain[r>>6]&(1<<(r&63)) != 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if !utf8.ValidRune(r) {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
for _, k := range drawnAs {
|
||||||
|
if unicode.In(r, k.runes...) {
|
||||||
|
return k.as
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawnAs is the glyph a cell draws for each kind of rune that would not take
|
||||||
|
// one column: a space for one a terminal acts on, hides or reorders text by
|
||||||
|
// (a control, a format character, a line separator), U+FFFD for one it draws
|
||||||
|
// in none or two or joins to a neighbour (a mark, a wide or emoji rune)
|
||||||
|
var drawnAs = [...]struct {
|
||||||
|
as rune
|
||||||
|
runes []*unicode.RangeTable
|
||||||
|
}{
|
||||||
|
{' ', []*unicode.RangeTable{unicode.Cc, unicode.Cf, unicode.Zl, unicode.Zp}},
|
||||||
|
{utf8.RuneError, []*unicode.RangeTable{wideOrJoining, unicode.Mn, unicode.Me, unicode.Mc}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain has a bit set for each BMP rune a cell draws as itself, so most text
|
||||||
|
// skips drawnAs's searches
|
||||||
|
var plain = func() (p [0x10000 >> 6]uint64) {
|
||||||
|
for i := range p {
|
||||||
|
if i < 0xD800>>6 || i >= 0xE000>>6 {
|
||||||
|
p[i] = ^uint64(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range drawnAs {
|
||||||
|
for _, t := range k.runes {
|
||||||
|
for _, r := range t.R16 {
|
||||||
|
for c := int(r.Lo); c <= int(r.Hi); c += int(r.Stride) {
|
||||||
|
p[c>>6] &^= 1 << (c & 63)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}()
|
||||||
|
|
||||||
|
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
|
||||||
|
// fullwidth and emoji presentation as of Unicode 17, and the letters a
|
||||||
|
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
|
||||||
|
// Extend ones); a range spans the unassigned code points between its runes
|
||||||
|
var wideOrJoining = &unicode.RangeTable{
|
||||||
|
R16: []unicode.Range16{
|
||||||
|
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
|
||||||
|
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
|
||||||
|
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
|
||||||
|
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
|
||||||
|
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
|
||||||
|
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
|
||||||
|
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
|
||||||
|
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
|
||||||
|
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
|
||||||
|
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
|
||||||
|
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
|
||||||
|
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
|
||||||
|
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
|
||||||
|
},
|
||||||
|
R32: []unicode.Range32{
|
||||||
|
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
|
||||||
|
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
|
||||||
|
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
|
||||||
|
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
|
||||||
|
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
|
||||||
|
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
|
||||||
|
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
|
||||||
|
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
|
||||||
|
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
|
||||||
|
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
|
||||||
|
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func cursorForwardCost(n int) int {
|
func cursorForwardCost(n int) int {
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
return 3 // \x1b[C
|
return 3 // \x1b[C
|
||||||
@@ -208,13 +289,12 @@ func cursorForwardCost(n int) int {
|
|||||||
return 3 + digitCount(n) // \x1b[nC
|
return 3 + digitCount(n) // \x1b[nC
|
||||||
}
|
}
|
||||||
|
|
||||||
// cursorAbsoluteCost returns byte cost of absolute cursor position
|
|
||||||
func cursorAbsoluteCost(x, y int) int {
|
func cursorAbsoluteCost(x, y int) int {
|
||||||
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
|
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
|
||||||
return 4 + digitCount(y+1) + digitCount(x+1)
|
return 4 + digitCount(y+1) + digitCount(x+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// digitCount returns number of decimal digits in n
|
// digitCount caps at 4: no terminal is 10000 cells across
|
||||||
func digitCount(n int) int {
|
func digitCount(n int) int {
|
||||||
if n < 10 {
|
if n < 10 {
|
||||||
return 1
|
return 1
|
||||||
@@ -229,7 +309,7 @@ func digitCount(n int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// moveCursorTo positions cursor using most efficient method
|
// moveCursorTo positions cursor using most efficient method
|
||||||
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
func (o *outputBuffer) moveCursorTo(w *bytes.Buffer, x, y int) {
|
||||||
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,11 +336,10 @@ func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
||||||
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, attr Attr) {
|
func (o *outputBuffer) writeStyleCoalesced(w *bytes.Buffer, fg, bg color.RGB, attr Attr) {
|
||||||
if o.colorMode == ColorModeNone {
|
if o.colorMode == ColorModeNone {
|
||||||
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
||||||
}
|
}
|
||||||
// Check what changed
|
|
||||||
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
|
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
|
||||||
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
|
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
|
||||||
styleAttr := attr & AttrStyle
|
styleAttr := attr & AttrStyle
|
||||||
@@ -310,7 +389,7 @@ var attrCodes = [...]struct {
|
|||||||
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
||||||
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
||||||
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
|
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
|
||||||
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) {
|
func (o *outputBuffer) writeColor(w *bytes.Buffer, c color.RGB, palette, deflt bool, base int, sep bool) {
|
||||||
if o.colorMode == ColorModeNone {
|
if o.colorMode == ColorModeNone {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -348,7 +427,6 @@ func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forceFullRedraw clears front buffer to force complete redraw
|
|
||||||
func (o *outputBuffer) forceFullRedraw() {
|
func (o *outputBuffer) forceFullRedraw() {
|
||||||
for i := range o.front {
|
for i := range o.front {
|
||||||
o.front[i] = Cell{Rune: 0}
|
o.front[i] = Cell{Rune: 0}
|
||||||
@@ -357,10 +435,10 @@ func (o *outputBuffer) forceFullRedraw() {
|
|||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear writes a clear screen with specified background, attr's
|
// clear renders a clear screen with specified background, attr's
|
||||||
// AttrBgDefault or AttrBg256 saying how to read it
|
// AttrBgDefault or AttrBg256 saying how to read it
|
||||||
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
||||||
w := o.writer
|
w := &o.buf
|
||||||
w.Write(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
w.Write(csi)
|
w.Write(csi)
|
||||||
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
|
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
|
||||||
@@ -369,14 +447,12 @@ func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
|||||||
|
|
||||||
o.lastValid = false
|
o.lastValid = false
|
||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
w.Flush()
|
|
||||||
|
|
||||||
for i := range o.front {
|
for i := range o.front {
|
||||||
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// invalidateCursor marks cursor position as unknown
|
|
||||||
func (o *outputBuffer) invalidateCursor() {
|
func (o *outputBuffer) invalidateCursor() {
|
||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
}
|
}
|
||||||
+145
-43
@@ -5,6 +5,7 @@ import (
|
|||||||
"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 +62,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)
|
||||||
|
|
||||||
@@ -114,8 +121,28 @@ type termImpl struct {
|
|||||||
finalized bool
|
finalized bool
|
||||||
mouseMode MouseMode
|
mouseMode MouseMode
|
||||||
paste bool
|
paste bool
|
||||||
|
|
||||||
|
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
|
||||||
|
// stops reading holds that goroutine rather than whoever draws. queued is what
|
||||||
|
// was rendered and not yet taken; a frame Flush is handed while a write is out
|
||||||
|
// waits in held, newest first, until it returns. Both are guarded by mu.
|
||||||
|
queued []byte
|
||||||
|
writing []byte // owned by writeLoop
|
||||||
|
held []Cell
|
||||||
|
heldW int
|
||||||
|
heldH int
|
||||||
|
holding bool
|
||||||
|
writeSince atomic.Int64 // unix nanoseconds the write out began; zero when none is
|
||||||
|
wake chan struct{}
|
||||||
|
quit chan struct{}
|
||||||
|
writerDone chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finiDrainTimeout bounds how long Fini waits for the terminal to take what is
|
||||||
|
// queued, the restoring sequences last: a terminal that stopped reading would
|
||||||
|
// otherwise hold the process at exit
|
||||||
|
const finiDrainTimeout = 2 * time.Second
|
||||||
|
|
||||||
// New creates a new Terminal instance. Without a mode it uses
|
// New creates a new Terminal instance. Without a mode it uses
|
||||||
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
||||||
// overrides both, as a --color=always or never would
|
// overrides both, as a --color=always or never would
|
||||||
@@ -129,15 +156,27 @@ func New(colorMode ...ColorMode) Terminal {
|
|||||||
c = ColorModeNone
|
c = ColorModeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &termImpl{
|
return NewWithBackend(b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithBackend creates a Terminal over b in color mode c, for a pipe, an
|
||||||
|
// in-process emulator or a test standing in for the tty. b must not be nil;
|
||||||
|
// the Terminal calls its Init on Init and its Fini on Fini, and writes to it
|
||||||
|
// on its own goroutine as New's does
|
||||||
|
func NewWithBackend(b Backend, c ColorMode) Terminal {
|
||||||
|
return newTerm(b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTerm(b Backend, c ColorMode) *termImpl {
|
||||||
|
return &termImpl{
|
||||||
backend: b,
|
backend: b,
|
||||||
|
output: newOutputBuffer(c),
|
||||||
syntheticCh: make(chan Event, 16),
|
syntheticCh: make(chan Event, 16),
|
||||||
resizeCh: make(chan ResizeEvent, 1),
|
resizeCh: make(chan ResizeEvent, 1),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
writerDone: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize output buffer with backend
|
|
||||||
t.output = newOutputBuffer(b, c)
|
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init enters raw mode and sets up terminal
|
// Init enters raw mode and sets up terminal
|
||||||
@@ -178,13 +217,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 +238,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,24 +250,25 @@ 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
|
// Stop handlers
|
||||||
@@ -233,21 +277,29 @@ func (t *termImpl) Fini() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 +317,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 +327,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 +346,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 +375,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 +393,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 +412,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 +431,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 +447,10 @@ func (t *termImpl) Sync() {
|
|||||||
|
|
||||||
// Clear terminal before full redraw
|
// Clear terminal before full redraw
|
||||||
// Diff-based rendering assumes physical terminal matches front buffer state
|
// Diff-based rendering assumes physical terminal matches front buffer state
|
||||||
|
t.releaseLocked()
|
||||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||||
t.output.forceFullRedraw()
|
t.output.forceFullRedraw()
|
||||||
|
t.sendLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollEvent blocks until next input event
|
// PollEvent blocks until next input event
|
||||||
@@ -412,9 +499,11 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
oldMode := t.mouseMode
|
oldMode := t.mouseMode
|
||||||
t.mouseMode = mode
|
t.mouseMode = mode
|
||||||
|
|
||||||
w := t.output.writer
|
t.releaseLocked()
|
||||||
|
w := &t.output.buf
|
||||||
|
|
||||||
// Disable modes no longer needed (reverse order of enable)
|
// Disable modes no longer needed (reverse order of enable): VTE 0.54+,
|
||||||
|
// WezTerm and Windows Terminal keep each one set and report by the highest
|
||||||
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
||||||
w.Write(csiMouseMotionOff)
|
w.Write(csiMouseMotionOff)
|
||||||
}
|
}
|
||||||
@@ -435,18 +524,34 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
w.Write(csiMouseSGROn)
|
w.Write(csiMouseSGROn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable new modes (click is base, drag extends, motion extends further)
|
// xterm, xterm.js, kitty, tmux and Konsole keep one mode, which setting
|
||||||
if mode&MouseModeClick != 0 && oldMode&MouseModeClick == 0 {
|
// another replaces and resetting any turns off, and alacritty's and urxvt's
|
||||||
|
// set clears the others: on any change the highest mode wanted is written
|
||||||
|
// last, even when already on
|
||||||
|
var top MouseMode
|
||||||
|
switch {
|
||||||
|
case mode == oldMode:
|
||||||
|
case mode&MouseModeMotion != 0:
|
||||||
|
top = MouseModeMotion
|
||||||
|
case mode&MouseModeDrag != 0:
|
||||||
|
top = MouseModeDrag
|
||||||
|
case mode&MouseModeClick != 0:
|
||||||
|
top = MouseModeClick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable new modes, and the highest again (click is base, drag extends,
|
||||||
|
// motion extends further)
|
||||||
|
if mode&MouseModeClick != 0 && (oldMode&MouseModeClick == 0 || top == MouseModeClick) {
|
||||||
w.Write(csiMouseClickOn)
|
w.Write(csiMouseClickOn)
|
||||||
}
|
}
|
||||||
if mode&MouseModeDrag != 0 && oldMode&MouseModeDrag == 0 {
|
if mode&MouseModeDrag != 0 && (oldMode&MouseModeDrag == 0 || top == MouseModeDrag) {
|
||||||
w.Write(csiMouseDragOn)
|
w.Write(csiMouseDragOn)
|
||||||
}
|
}
|
||||||
if mode&MouseModeMotion != 0 && oldMode&MouseModeMotion == 0 {
|
if mode&MouseModeMotion != 0 && (oldMode&MouseModeMotion == 0 || top == MouseModeMotion) {
|
||||||
w.Write(csiMouseMotionOn)
|
w.Write(csiMouseMotionOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
t.sendLocked()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,18 +565,15 @@ 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
|
|
||||||
func (t *termImpl) writeRaw(data []byte) {
|
|
||||||
t.backend.Write(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmergencyReset attempts to restore terminal to sane state
|
// EmergencyReset attempts to restore terminal to sane state
|
||||||
|
|||||||
+288
-16
@@ -3,6 +3,7 @@ package terminal
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,6 +19,7 @@ type fakeBackend struct {
|
|||||||
out bytes.Buffer
|
out bytes.Buffer
|
||||||
chunks []string
|
chunks []string
|
||||||
pause time.Duration
|
pause time.Duration
|
||||||
|
gate chan struct{} // when set, a write waits until it is closed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBackend) Init() error { return nil }
|
func (f *fakeBackend) Init() error { return nil }
|
||||||
@@ -26,6 +28,9 @@ func (f *fakeBackend) Size() (int, int) { return 1, 1 }
|
|||||||
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
||||||
|
|
||||||
func (f *fakeBackend) Write(p []byte) error {
|
func (f *fakeBackend) Write(p []byte) error {
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.out.Write(p)
|
f.out.Write(p)
|
||||||
@@ -80,23 +85,38 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
|
|||||||
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
||||||
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
||||||
} {
|
} {
|
||||||
f := &fakeBackend{}
|
o := newOutputBuffer(c.mode)
|
||||||
o := newOutputBuffer(f, c.mode)
|
|
||||||
o.flush(c.cells, len(c.cells), 1)
|
o.flush(c.cells, len(c.cells), 1)
|
||||||
if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
if got, want := o.buf.String(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
||||||
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cell is drawn as one glyph: a control character, C0 or C1, that a
|
// A cell is drawn as one glyph: a control character, C0 or C1, a format one
|
||||||
// pasted value carried in, or no rune at all, is drawn as a space, never
|
// (a bidi control, a zero-width space) or a line separator that a pasted
|
||||||
// sent to the terminal
|
// value or a log line carried in, or no rune at all, is drawn as a space,
|
||||||
|
// never sent to the terminal
|
||||||
func TestCellsSendNoControlCharacters(t *testing.T) {
|
func TestCellsSendNoControlCharacters(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
o := newOutputBuffer(ColorMode256)
|
||||||
o := newOutputBuffer(f, ColorMode256)
|
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
|
||||||
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1)
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
||||||
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
t.Fatalf("%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cell is one column: a rune a terminal draws in none (a combining mark, a
|
||||||
|
// joining jamo), in two (wide, fullwidth, emoji) or in its neighbour's cluster
|
||||||
|
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
|
||||||
|
// other as itself
|
||||||
|
func TestEachCellIsOneColumn(t *testing.T) {
|
||||||
|
o := newOutputBuffer(ColorMode256)
|
||||||
|
var row []Cell
|
||||||
|
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
|
||||||
|
row = append(row, Cell{Rune: r})
|
||||||
|
}
|
||||||
|
o.flush(row, len(row), 1)
|
||||||
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
|
||||||
t.Fatalf("%q, want %q", got, want)
|
t.Fatalf("%q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +153,92 @@ func TestDoubleEscape(t *testing.T) {
|
|||||||
|
|
||||||
func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
|
func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
|
||||||
|
|
||||||
|
// parsed is what a reader sends for input arriving in chunks without pauses,
|
||||||
|
// and how many bytes it still holds back for the rest of a sequence
|
||||||
|
func parsed(chunks ...string) (events []Event, held int) {
|
||||||
|
r := &inputReader{eventCh: make(chan Event, 4096)}
|
||||||
|
r.pasteMode.Store(true)
|
||||||
|
var buf []byte
|
||||||
|
for _, c := range chunks {
|
||||||
|
buf = append(buf, c...)
|
||||||
|
buf = buf[r.parseInput(buf):]
|
||||||
|
}
|
||||||
|
close(r.eventCh)
|
||||||
|
for ev := range r.eventCh {
|
||||||
|
events = append(events, ev)
|
||||||
|
}
|
||||||
|
return events, len(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every sequence the key tables hold reads as its key, and the key after it
|
||||||
|
// as itself
|
||||||
|
func TestKnownSequencesKeepTheirKeys(t *testing.T) {
|
||||||
|
for _, s := range csiSequences {
|
||||||
|
want := []Event{{Type: EventKey, Key: s.key, Modifiers: s.mod}, key('x')}
|
||||||
|
if got, _ := parsed("\x1b[" + s.seq + "x"); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("ESC [ %s: got %+v, want %+v", s.seq, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No input stops the reader: a reply of any length, Alt with Backspace or a
|
||||||
|
// character past ASCII, a malformed mouse report, or ESC [ before a byte no
|
||||||
|
// sequence holds (typed Alt+[) leave the keys after them read. An unknown or
|
||||||
|
// over-long sequence is dropped up to its final byte, by ECMA-48.
|
||||||
|
func TestNoInputStopsTheReader(t *testing.T) {
|
||||||
|
altBracket := Event{Type: EventKey, Key: KeyRune, Rune: '[', Modifiers: ModAlt}
|
||||||
|
enter := Event{Type: EventKey, Key: KeyEnter}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
chunks []string
|
||||||
|
want []Event
|
||||||
|
}{
|
||||||
|
{"long reply", []string{"\x1b[?65;1;9;15;18;21;22cx"}, []Event{key('x')}},
|
||||||
|
{"split reply", []string{"\x1b[?65;1;9;", "15;18;21;22", "cx"}, []Event{key('x')}},
|
||||||
|
{"longest reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen-3) + "zx"}, []Event{key('x')}},
|
||||||
|
{"over-long reply", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + ";2", "3zx"}, []Event{key('x')}},
|
||||||
|
{"over-long cut", []string{"\x1b[" + strings.Repeat("1", csiMaxLen) + "\rx"}, []Event{enter, key('x')}},
|
||||||
|
{"unknown final", []string{"\x1b[2^x"}, []Event{key('x')}},
|
||||||
|
{"alt+backspace", []string{"\x1b\x7fx"}, []Event{{Type: EventKey, Key: KeyBackspace, Modifiers: ModAlt}, key('x')}},
|
||||||
|
{"alt+ö", []string{"\x1b\xc3", "\xb6x"}, []Event{{Type: EventKey, Key: KeyRune, Rune: 'ö', Modifiers: ModAlt}, key('x')}},
|
||||||
|
{"stray byte", []string{"\x1b\x80x"}, []Event{{Type: EventKey, Key: KeyEscape}, key('x')}},
|
||||||
|
{"alt+[ enter", []string{"\x1b[\rx"}, []Event{altBracket, enter, key('x')}},
|
||||||
|
{"alt+[ 1 é", []string{"\x1b[1éx"}, []Event{altBracket, key('1'), key('é'), key('x')}},
|
||||||
|
{"esc alt+[ enter", []string{"\x1b\x1b[\rx"}, []Event{{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}, key('['), enter, key('x')}},
|
||||||
|
{"bad mouse", []string{"\x1b[<0;1;1;1Mx"}, []Event{key('x')}},
|
||||||
|
{"mouse", []string{"\x1b[<0;5;3M\x1b[<0;5;3m"}, []Event{
|
||||||
|
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionPress},
|
||||||
|
{Type: EventMouse, MouseX: 4, MouseY: 2, MouseBtn: MouseBtnLeft, MouseAction: MouseActionRelease}}},
|
||||||
|
} {
|
||||||
|
if got, held := parsed(c.chunks...); !slices.Equal(got, c.want) || held != 0 {
|
||||||
|
t.Errorf("%s: got %+v holding %d, want %+v", c.name, got, held, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever bytes arrive, however reads split them, the reader sends the same
|
||||||
|
// events, holds back no more than ESC and the longest sequence, and holds
|
||||||
|
// nothing once four DEL, which end any sequence or character, and a paste
|
||||||
|
// end follow
|
||||||
|
func FuzzInputNeverStalls(f *testing.F) {
|
||||||
|
for _, s := range []string{"\x1b[?65;1;9;15;18;21;22cx", "\x1b\x7fx", "\x1b\xc3\xb6", "\x1b[\rx", "\x1b[<0;1;1;1Mx", "\x1b\x1b[A", "a\x1b[200~\x1b[A\x1b[201~b"} {
|
||||||
|
f.Add(s, uint(len(s)/2))
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, in string, cut uint) {
|
||||||
|
cut %= uint(len(in)) + 1
|
||||||
|
whole, held := parsed(in)
|
||||||
|
if split, _ := parsed(in[:cut], in[cut:]); !slices.Equal(whole, split) {
|
||||||
|
t.Fatalf("%q: whole %+v, split at %d %+v", in, whole, cut, split)
|
||||||
|
}
|
||||||
|
if held > csiMaxLen+1 {
|
||||||
|
t.Fatalf("%q: holds %d bytes", in, held)
|
||||||
|
}
|
||||||
|
if _, held := parsed(in, "\x7f\x7f\x7f\x7f\x1b[201~"); held != 0 {
|
||||||
|
t.Fatalf("%q: stalled holding %d bytes", in, held)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// input is what a reader is given, in chunks, and the events it must send
|
// input is what a reader is given, in chunks, and the events it must send
|
||||||
type input struct {
|
type input struct {
|
||||||
off bool
|
off bool
|
||||||
@@ -165,8 +271,7 @@ func expectEvents(t *testing.T, cases map[string]input) {
|
|||||||
// Paste mode set on the terminal is what its reader parses by
|
// Paste mode set on the terminal is what its reader parses by
|
||||||
func TestPasteModeReachesTheReader(t *testing.T) {
|
func TestPasteModeReachesTheReader(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
f := &fakeBackend{}
|
||||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
term := newTerm(f, ColorMode256)
|
||||||
term.output = newOutputBuffer(f, ColorMode256)
|
|
||||||
if err := term.Init(); err != nil {
|
if err := term.Init(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -187,12 +292,12 @@ func TestPasteModeReachesTheReader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paste mode ends with the session, and Init clears to the terminal's own
|
// The terminal's own bidi reordering is off while the session runs, paste
|
||||||
// background rather than painting it black
|
// mode ends with it, and Init clears to the terminal's own background rather
|
||||||
|
// than painting it black
|
||||||
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
f := &fakeBackend{}
|
||||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
term := newTerm(f, ColorModeTrueColor)
|
||||||
term.output = newOutputBuffer(f, ColorModeTrueColor)
|
|
||||||
if err := term.Init(); err != nil {
|
if err := term.Init(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -205,4 +310,171 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
|||||||
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
|
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
|
||||||
t.Fatalf("%q", got)
|
t.Fatalf("%q", got)
|
||||||
}
|
}
|
||||||
|
if i := strings.Index(got, "\x1b[8l"); i < 0 || !strings.Contains(got[i:], "\x1b[8h") {
|
||||||
|
t.Fatalf("bidi reordering not off for the session: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mouse mode change writes the modes dropped as reset and the modes added
|
||||||
|
// as set, and sets the highest mode kept again, last, when another was dropped
|
||||||
|
// or added under it; SGR is on while any mode is. Whether the terminal keeps
|
||||||
|
// one mode (xterm, xterm.js, kitty), lets a set clear the others (alacritty)
|
||||||
|
// or keeps each and reports by the highest (VTE, WezTerm, Windows Terminal),
|
||||||
|
// it then reports by the highest mode asked for.
|
||||||
|
func TestMouseModeChangesKeepReporting(t *testing.T) {
|
||||||
|
c, d, m := MouseModeClick, MouseModeDrag, MouseModeMotion
|
||||||
|
for _, tc := range []struct {
|
||||||
|
from, to MouseMode
|
||||||
|
want string // +n sets mode n, -n resets it
|
||||||
|
}{
|
||||||
|
{0, 0, ""}, {0, c, "+1006 +1000"}, {0, d, "+1006 +1002"}, {0, c | d, "+1006 +1000 +1002"},
|
||||||
|
{0, m, "+1006 +1003"}, {0, c | m, "+1006 +1000 +1003"}, {0, d | m, "+1006 +1002 +1003"}, {0, c | d | m, "+1006 +1000 +1002 +1003"},
|
||||||
|
{c, 0, "-1000 -1006"}, {c, c, ""}, {c, d, "-1000 +1002"}, {c, c | d, "+1002"},
|
||||||
|
{c, m, "-1000 +1003"}, {c, c | m, "+1003"}, {c, d | m, "-1000 +1002 +1003"}, {c, c | d | m, "+1002 +1003"},
|
||||||
|
{d, 0, "-1002 -1006"}, {d, c, "-1002 +1000"}, {d, d, ""}, {d, c | d, "+1000 +1002"},
|
||||||
|
{d, m, "-1002 +1003"}, {d, c | m, "-1002 +1000 +1003"}, {d, d | m, "+1003"}, {d, c | d | m, "+1000 +1003"},
|
||||||
|
{c | d, 0, "-1002 -1000 -1006"}, {c | d, c, "-1002 +1000"}, {c | d, d, "-1000 +1002"}, {c | d, c | d, ""},
|
||||||
|
{c | d, m, "-1002 -1000 +1003"}, {c | d, c | m, "-1002 +1003"}, {c | d, d | m, "-1000 +1003"}, {c | d, c | d | m, "+1003"},
|
||||||
|
{m, 0, "-1003 -1006"}, {m, c, "-1003 +1000"}, {m, d, "-1003 +1002"}, {m, c | d, "-1003 +1000 +1002"},
|
||||||
|
{m, m, ""}, {m, c | m, "+1000 +1003"}, {m, d | m, "+1002 +1003"}, {m, c | d | m, "+1000 +1002 +1003"},
|
||||||
|
{c | m, 0, "-1003 -1000 -1006"}, {c | m, c, "-1003 +1000"}, {c | m, d, "-1003 -1000 +1002"}, {c | m, c | d, "-1003 +1002"},
|
||||||
|
{c | m, m, "-1000 +1003"}, {c | m, c | m, ""}, {c | m, d | m, "-1000 +1002 +1003"}, {c | m, c | d | m, "+1002 +1003"},
|
||||||
|
{d | m, 0, "-1003 -1002 -1006"}, {d | m, c, "-1003 -1002 +1000"}, {d | m, d, "-1003 +1002"}, {d | m, c | d, "-1003 +1000 +1002"},
|
||||||
|
{d | m, m, "-1002 +1003"}, {d | m, c | m, "-1002 +1000 +1003"}, {d | m, d | m, ""}, {d | m, c | d | m, "+1000 +1003"},
|
||||||
|
{c | d | m, 0, "-1003 -1002 -1000 -1006"}, {c | d | m, c, "-1003 -1002 +1000"}, {c | d | m, d, "-1003 -1000 +1002"}, {c | d | m, c | d, "-1003 +1002"},
|
||||||
|
{c | d | m, m, "-1002 -1000 +1003"}, {c | d | m, c | m, "-1002 +1003"}, {c | d | m, d | m, "-1000 +1003"}, {c | d | m, c | d | m, ""},
|
||||||
|
} {
|
||||||
|
term := newTerm(&fakeBackend{}, ColorMode256)
|
||||||
|
term.initialized = true // no writer runs, so what is sent stays queued
|
||||||
|
term.SetMouseMode(tc.from)
|
||||||
|
before := string(term.queued)
|
||||||
|
term.queued = nil
|
||||||
|
term.SetMouseMode(tc.to)
|
||||||
|
got := string(term.queued)
|
||||||
|
|
||||||
|
var want strings.Builder
|
||||||
|
for _, f := range strings.Fields(tc.want) {
|
||||||
|
want.WriteString("\x1b[?" + f[1:] + map[byte]string{'+': "h", '-': "l"}[f[0]])
|
||||||
|
}
|
||||||
|
if got != want.String() {
|
||||||
|
t.Errorf("%03b to %03b: %q, want %q", tc.from, tc.to, got, want.String())
|
||||||
|
}
|
||||||
|
one, alacritty, each, sgr := reportedMouseMode(before + got)
|
||||||
|
if top := highestMouseMode(tc.to); one != top || alacritty != top || each != top || sgr != (tc.to != 0) {
|
||||||
|
t.Errorf("%03b to %03b reports by %03b, %03b, %03b, SGR %t; want %03b", tc.from, tc.to, one, alacritty, each, sgr, top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedMouseMode replays mode sets and resets as each kind of terminal
|
||||||
|
// keeps them and returns the mode each then reports by: xterm, xterm.js and
|
||||||
|
// kitty keep the last mode set, which a reset of any turns off; alacritty's set
|
||||||
|
// clears the others and its reset only its own; VTE, WezTerm and Windows
|
||||||
|
// Terminal keep each, reporting by the highest
|
||||||
|
func reportedMouseMode(out string) (one, alacritty, each MouseMode, sgr bool) {
|
||||||
|
modes := map[string]MouseMode{"1000": MouseModeClick, "1002": MouseModeDrag, "1003": MouseModeMotion}
|
||||||
|
for _, s := range strings.Split(out, "\x1b[?")[1:] {
|
||||||
|
code, set := s[:len(s)-1], strings.HasSuffix(s, "h")
|
||||||
|
switch m := modes[code]; {
|
||||||
|
case code == "1006":
|
||||||
|
sgr = set
|
||||||
|
case set:
|
||||||
|
one, alacritty, each = m, m, each|m
|
||||||
|
default:
|
||||||
|
one, alacritty, each = 0, alacritty&^m, each&^m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return one, alacritty, highestMouseMode(each), sgr
|
||||||
|
}
|
||||||
|
|
||||||
|
func highestMouseMode(m MouseMode) MouseMode {
|
||||||
|
for m&(m-1) != 0 {
|
||||||
|
m &= m - 1
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// A terminal that stops reading holds the write in flight, not whoever draws:
|
||||||
|
// Flush returns while it is out, Blocked says how long it has waited, the newest
|
||||||
|
// frame held meanwhile is the one drawn once the terminal reads again, and a
|
||||||
|
// control sequence asked for after a held frame reaches the terminal after it
|
||||||
|
func TestAStoppedTerminalHoldsNoCaller(t *testing.T) {
|
||||||
|
f := &fakeBackend{gate: make(chan struct{})}
|
||||||
|
term := newTerm(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitFor(t, "the first write to go out", func() bool { return term.Blocked() > 0 })
|
||||||
|
|
||||||
|
frame := func(r rune) []Cell { return []Cell{{Rune: r}} }
|
||||||
|
returned := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
term.Flush(frame('a'), 1, 1)
|
||||||
|
term.Flush(frame('b'), 1, 1)
|
||||||
|
close(returned)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-returned:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Flush waited on a terminal that stopped reading")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if b := term.Blocked(); b < 10*time.Millisecond {
|
||||||
|
t.Fatalf("blocked %v while the write is held", b)
|
||||||
|
}
|
||||||
|
term.SetCursorVisible(true)
|
||||||
|
|
||||||
|
close(f.gate)
|
||||||
|
waitFor(t, "output to drain", func() bool { return term.Blocked() == 0 && strings.Contains(f.written(), "\x1b[?25h") })
|
||||||
|
got := f.written()
|
||||||
|
if strings.Contains(got, "a") || !strings.Contains(got, "b") {
|
||||||
|
t.Fatalf("drew %q; want the newest held frame only", got)
|
||||||
|
}
|
||||||
|
if strings.Index(got, "b") > strings.Index(got, "\x1b[?25h") {
|
||||||
|
t.Fatalf("the cursor was shown before the frame held ahead of it: %q", got)
|
||||||
|
}
|
||||||
|
term.Fini()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fini restores the termios even when the terminal never reads its last
|
||||||
|
// sequences: it stops waiting after finiDrainTimeout
|
||||||
|
func TestFiniLeavesAStoppedTerminal(t *testing.T) {
|
||||||
|
f := &fakeBackend{gate: make(chan struct{})}
|
||||||
|
defer close(f.gate)
|
||||||
|
term := newTerm(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { term.Fini(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(finiDrainTimeout + 2*time.Second):
|
||||||
|
t.Fatal("Fini waited on a terminal that stopped reading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, what string, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); !ok(); {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for %s", what)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Terminal built over a caller's backend draws a flushed frame to it
|
||||||
|
func TestNewWithBackendFlushesToTheBackend(t *testing.T) {
|
||||||
|
f := &fakeBackend{}
|
||||||
|
term := NewWithBackend(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
if term.ColorMode() != ColorMode256 {
|
||||||
|
t.Fatalf("color mode %v", term.ColorMode())
|
||||||
|
}
|
||||||
|
term.Flush([]Cell{{Rune: 'x'}}, 1, 1)
|
||||||
|
waitFor(t, "the frame to reach the backend", func() bool { return strings.Contains(f.written(), "x") })
|
||||||
}
|
}
|
||||||
+33
-13
@@ -94,7 +94,8 @@ r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
|
|||||||
|
|
||||||
String utilities operate on rune counts: `RuneLen`, `Truncate` /
|
String utilities operate on rune counts: `RuneLen`, `Truncate` /
|
||||||
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
|
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
|
||||||
`PadCenter`, `WrapText`.
|
`PadCenter`, `WrapText` (at line breaks and spaces; a path or key longer than
|
||||||
|
the line breaks after `.`, `,`, `:`, `/`, `[` or `]`).
|
||||||
|
|
||||||
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
|
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
|
||||||
accept it.
|
accept it.
|
||||||
@@ -105,7 +106,7 @@ Widgets are stateless render functions (mostly `Region` methods). Application
|
|||||||
state lives in plain structs passed by pointer. Available renderers:
|
state lives in plain structs passed by pointer. Available renderers:
|
||||||
|
|
||||||
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
|
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
|
||||||
rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
|
rounded, heavy, dashed and ASCII line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
|
||||||
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
|
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
|
||||||
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
|
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
|
||||||
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
|
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
|
||||||
@@ -122,9 +123,13 @@ role's `Attr` may carry color bits, a palette index or the terminal's own
|
|||||||
color, so an application builds one theme per color tier and every control
|
color, so an application builds one theme per color tier and every control
|
||||||
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
|
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
|
||||||
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
|
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
|
||||||
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection always changes a glyph (brackets, a pointer,
|
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection
|
||||||
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437`
|
always changes a glyph (a radio mark, a pointer, the focus mark), so it reads
|
||||||
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets.
|
without color. A reverse-video `Selected` with no color of its own
|
||||||
|
(`Theme16`, `MonoTheme`) keeps the terminal's own foreground in every cell
|
||||||
|
drawn on it, so the cursor row is one bar. `GlyphsUnicode`, `GlyphsCP437`
|
||||||
|
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets;
|
||||||
|
`Glyphs.Line` is the line type of frames, rules and wires.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
th := tui.DefaultTheme
|
th := tui.DefaultTheme
|
||||||
@@ -136,7 +141,7 @@ value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholde
|
|||||||
y += rows
|
y += rows
|
||||||
|
|
||||||
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
|
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
|
||||||
value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json
|
value.Radio(0, 0, []string{"raw", "txt", "json"}, format, th) // ○ raw ● txt ○ json
|
||||||
y += rows
|
y += rows
|
||||||
|
|
||||||
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
|
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
|
||||||
@@ -144,16 +149,29 @@ form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
|
|||||||
|
|
||||||
- `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab.
|
- `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab.
|
||||||
- `Field` draws the focus mark, the label, the required mark, and the error,
|
- `Field` draws the focus mark, the label, the required mark, and the error,
|
||||||
or the help while focused, and returns the region for the value.
|
or the help while focused, wrapped, and returns the region for the value.
|
||||||
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty.
|
The label leaves the value at least half the row; label width 0 stacks the
|
||||||
|
label above the value, for narrow screens.
|
||||||
|
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty,
|
||||||
|
scrolled to the cursor with a muted `…` where text is out of view.
|
||||||
`TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
|
`TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
|
||||||
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
|
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
|
||||||
as spaces and control characters dropped.
|
as spaces and control characters dropped.
|
||||||
- `Choice` draws a segmented choice; `StepChoice` moves it on Left and Right.
|
- `Radio` draws radio buttons in a row, the chosen one marked; too wide, it
|
||||||
|
shows the chosen one and its neighbours. `StepChoice` moves it on Left and
|
||||||
|
Right.
|
||||||
- `Toggle` draws an on/off switch.
|
- `Toggle` draws an on/off switch.
|
||||||
- `Group` draws a foldable group's header, and its summary while folded.
|
- `Group` draws a foldable group's header, and its summary while folded.
|
||||||
- `OptionListState` and `OptionList` filter options by what is typed,
|
- `OptionListState` and `OptionList` filter options by what is typed,
|
||||||
matching name or hint regardless of case.
|
matching name or hint regardless of case. Hints sit beside the names, or,
|
||||||
|
narrow, the cursor's hint wraps below them; `Rows(w)` is the height to
|
||||||
|
size a dialog by. With `Menu` there is no filter and `Pick` finds an
|
||||||
|
option by its `Key`, shown before its name.
|
||||||
|
- `Frame` fills a region, borders it with its title, and returns the inside;
|
||||||
|
sized with `Center` to what it holds, a dialog ends on its last line.
|
||||||
|
- `Rule` draws a line with a title and a hint, dropping the hint first.
|
||||||
|
- `Wires` lay lines on a grid by the arms that meet in each cell (`H`, `V`),
|
||||||
|
and `DrawWires` draws every junction, tee and crossing joined.
|
||||||
|
|
||||||
### Scrollable list with scrollbar
|
### Scrollable list with scrollbar
|
||||||
|
|
||||||
@@ -229,7 +247,9 @@ Pure logic, no rendering — usable independently:
|
|||||||
- `ScrollState` — item-index scrolling with selection
|
- `ScrollState` — item-index scrolling with selection
|
||||||
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
|
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
|
||||||
- `ViewportScroll` — row-based content scrolling with viewport clipping
|
- `ViewportScroll` — row-based content scrolling with viewport clipping
|
||||||
(`ClipToViewport` maps content rows to visible rows)
|
(`ClipToViewport` maps content rows to visible rows, `EnsureRange` keeps a
|
||||||
|
range in view); `Region.Window` draws content taller than the region off
|
||||||
|
screen and shows the rows in view
|
||||||
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
|
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
|
||||||
keyed state, hierarchical → flat visible-node list
|
keyed state, hierarchical → flat visible-node list
|
||||||
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
|
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
|
||||||
@@ -239,8 +259,8 @@ Pure logic, no rendering — usable independently:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Width calculations count runes, not terminal columns; East Asian wide
|
- Width calculations count runes, one column each, as `Flush` draws a wide or
|
||||||
characters and combining marks are not width-aware.
|
joining rune as U+FFFD.
|
||||||
- Zero-value `color.RGB` in style fields generally means "inherit"
|
- Zero-value `color.RGB` in style fields generally means "inherit"
|
||||||
(widget default or row background) — check specific widget docs. A zero
|
(widget default or row background) — check specific widget docs. A zero
|
||||||
background with no Bg color bits is transparent: the cell keeps the
|
background with no Bg color bits is transparent: the cell keeps the
|
||||||
|
|||||||
+127
-50
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
"github.com/lixenwraith/terminal"
|
"github.com/lixenwraith/terminal"
|
||||||
)
|
)
|
||||||
@@ -14,26 +16,39 @@ const (
|
|||||||
LineRounded // ╭─╮│╰╯
|
LineRounded // ╭─╮│╰╯
|
||||||
LineHeavy // ┏━┓┃┗┛
|
LineHeavy // ┏━┓┃┗┛
|
||||||
LineNone // spaces (invisible border with padding)
|
LineNone // spaces (invisible border with padding)
|
||||||
|
LineASCII // +-+|++, for a terminal outside UTF-8
|
||||||
|
LineDashed // ╭╌╮╎╰╯
|
||||||
)
|
)
|
||||||
|
|
||||||
// boxChars contains box drawing character sets indexed by LineType
|
// lineChars are each LineType's runes by the arms that meet in a cell: up 1,
|
||||||
var boxChars = [...][6]rune{
|
// right 2, down 4, left 8, so corners, tees and crossings all join
|
||||||
LineSingle: {'┌', '─', '┐', '│', '└', '┘'},
|
var lineChars = [...]string{
|
||||||
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'},
|
LineSingle: " │─└││┌├─┘─┴┐┤┬┼",
|
||||||
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'},
|
LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬",
|
||||||
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'},
|
LineRounded: " │─╰││╭├─╯─┴╮┤┬┼",
|
||||||
LineNone: {' ', ' ', ' ', ' ', ' ', ' '},
|
LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋",
|
||||||
|
LineNone: " ",
|
||||||
|
LineASCII: " |-+||++-+-+++++",
|
||||||
|
LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼",
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
boxTL = 0 // top-left
|
boxTL = 6 // top-left
|
||||||
boxH = 1 // horizontal
|
boxH = 10 // horizontal
|
||||||
boxTR = 2 // top-right
|
boxTR = 12 // top-right
|
||||||
boxV = 3 // vertical
|
boxV = 5 // vertical
|
||||||
boxBL = 4 // bottom-left
|
boxBL = 3 // bottom-left
|
||||||
boxBR = 5 // bottom-right
|
boxBR = 9 // bottom-right
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runes returns the line's runes by arms; an unknown type draws single
|
||||||
|
func (l LineType) runes() []rune {
|
||||||
|
if l >= LineType(len(lineChars)) {
|
||||||
|
l = LineSingle
|
||||||
|
}
|
||||||
|
return []rune(lineChars[l])
|
||||||
|
}
|
||||||
|
|
||||||
// --- Box Rendering ---
|
// --- Box Rendering ---
|
||||||
|
|
||||||
// Box draws border around region edge
|
// Box draws border around region edge
|
||||||
@@ -45,37 +60,35 @@ func (r Region) Box(line LineType, fg color.RGB) {
|
|||||||
// with off rows scrolled past its top edge. Rows outside the box are skipped,
|
// with off rows scrolled past its top edge. Rows outside the box are skipped,
|
||||||
// so a partially visible box keeps its sides and grows no false edges.
|
// so a partially visible box keeps its sides and grows no false edges.
|
||||||
func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) {
|
func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) {
|
||||||
|
r.boxStyled(line, Style{Fg: fg}, totalH, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxStyle draws a border around the region's edge in a theme's style
|
||||||
|
func (r Region) BoxStyle(line LineType, s Style) {
|
||||||
|
r.boxStyled(line, s, r.H, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) boxStyled(line LineType, s Style, totalH, off int) {
|
||||||
if r.W < 2 || r.H < 1 || totalH < 2 {
|
if r.W < 2 || r.H < 1 || totalH < 2 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if line >= LineType(len(boxChars)) {
|
chars := line.runes()
|
||||||
line = LineSingle
|
edge := func(y int, left, right rune) {
|
||||||
|
r.TextStyled(0, y, string(left), s)
|
||||||
|
r.TextStyled(r.W-1, y, string(right), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
chars := boxChars[line]
|
|
||||||
bg := color.RGB{} // Transparent (use existing bg)
|
|
||||||
|
|
||||||
for y := range r.H {
|
for y := range r.H {
|
||||||
c := off + y
|
switch c := off + y; {
|
||||||
if c < 0 || c >= totalH {
|
case c < 0 || c >= totalH:
|
||||||
continue
|
case c == 0 || c == totalH-1:
|
||||||
}
|
left, right := chars[boxTL], chars[boxTR]
|
||||||
switch c {
|
if c > 0 {
|
||||||
case 0:
|
left, right = chars[boxBL], chars[boxBR]
|
||||||
r.Cell(0, y, chars[boxTL], fg, bg, terminal.AttrNone)
|
|
||||||
r.Cell(r.W-1, y, chars[boxTR], fg, bg, terminal.AttrNone)
|
|
||||||
for x := 1; x < r.W-1; x++ {
|
|
||||||
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
|
|
||||||
}
|
|
||||||
case totalH - 1:
|
|
||||||
r.Cell(0, y, chars[boxBL], fg, bg, terminal.AttrNone)
|
|
||||||
r.Cell(r.W-1, y, chars[boxBR], fg, bg, terminal.AttrNone)
|
|
||||||
for x := 1; x < r.W-1; x++ {
|
|
||||||
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
|
|
||||||
}
|
}
|
||||||
|
r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s)
|
||||||
|
edge(y, left, right)
|
||||||
default:
|
default:
|
||||||
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
|
edge(y, chars[boxV], chars[boxV])
|
||||||
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,10 +112,7 @@ func (r Region) HLine(y int, line LineType, fg color.RGB) {
|
|||||||
if y < 0 || y >= r.H {
|
if y < 0 || y >= r.H {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if line >= LineType(len(boxChars)) {
|
ch := line.runes()[boxH]
|
||||||
line = LineSingle
|
|
||||||
}
|
|
||||||
ch := boxChars[line][boxH]
|
|
||||||
for x := 0; x < r.W; x++ {
|
for x := 0; x < r.W; x++ {
|
||||||
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
||||||
}
|
}
|
||||||
@@ -113,10 +123,7 @@ func (r Region) VLine(x int, line LineType, fg color.RGB) {
|
|||||||
if x < 0 || x >= r.W {
|
if x < 0 || x >= r.W {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if line >= LineType(len(boxChars)) {
|
ch := line.runes()[boxV]
|
||||||
line = LineSingle
|
|
||||||
}
|
|
||||||
ch := boxChars[line][boxV]
|
|
||||||
for y := 0; y < r.H; y++ {
|
for y := 0; y < r.H; y++ {
|
||||||
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
|
||||||
}
|
}
|
||||||
@@ -127,11 +134,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
|
|||||||
if y < 0 || y >= r.H {
|
if y < 0 || y >= r.H {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if line >= LineType(len(boxChars)) {
|
hChar := line.runes()[boxH]
|
||||||
line = LineSingle
|
|
||||||
}
|
|
||||||
|
|
||||||
hChar := boxChars[line][boxH]
|
|
||||||
|
|
||||||
// Fill with horizontal line
|
// Fill with horizontal line
|
||||||
for x := 0; x < r.W; x++ {
|
for x := 0; x < r.W; x++ {
|
||||||
@@ -171,3 +174,77 @@ func (r Region) Card(title string, line LineType, fg color.RGB) Region {
|
|||||||
|
|
||||||
return r.Inset(1)
|
return r.Inset(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Themed frames, rules and wires ---
|
||||||
|
|
||||||
|
// Frame fills the region with the theme's text style and draws its border
|
||||||
|
// in Glyphs.Line, the title on the top edge, and returns the inside with a
|
||||||
|
// column of padding each side. Callers size it to its content, with Center.
|
||||||
|
func (r Region) Frame(title string, th Theme) Region {
|
||||||
|
r.FillStyle(th.Text)
|
||||||
|
r.BoxStyle(th.Glyphs.Line, th.Border)
|
||||||
|
if title != "" && r.W > 6 {
|
||||||
|
r.TextStyled(2, 0, " "+Truncate(title, r.W-6)+" ", th.Accent)
|
||||||
|
}
|
||||||
|
return r.Sub(2, 1, r.W-4, r.H-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule draws a line across row y, the title at its third column and the
|
||||||
|
// hint muted at its end. The title keeps its room: the hint is dropped where
|
||||||
|
// both do not fit.
|
||||||
|
func (r Region) Rule(y int, title, hint string, th Theme) {
|
||||||
|
r.TextStyled(0, y, strings.Repeat(string(th.Glyphs.Line.runes()[boxH]), max(0, r.W)), th.Border)
|
||||||
|
if title != "" && r.W > 6 {
|
||||||
|
title = " " + Truncate(title, r.W-6) + " "
|
||||||
|
r.TextStyled(2, y, title, th.Text)
|
||||||
|
}
|
||||||
|
if hint != "" && RuneLen(title)+RuneLen(hint)+6 <= r.W {
|
||||||
|
r.TextStyled(r.W-RuneLen(hint)-3, y, " "+hint+" ", th.Muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires are lines on a grid, each cell holding the arms that meet there: up
|
||||||
|
// 1, right 2, down 4, left 8. Wherever lines branch or cross, the cell draws
|
||||||
|
// the junction, whatever order they were laid in.
|
||||||
|
type Wires struct {
|
||||||
|
W int
|
||||||
|
Arms []uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWires creates an empty grid w by h
|
||||||
|
func NewWires(w, h int) Wires {
|
||||||
|
return Wires{W: w, Arms: make([]uint8, max(0, w*h))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H lays a wire along row y between columns x0 and x1
|
||||||
|
func (g Wires) H(y, x0, x1 int) {
|
||||||
|
for x := min(x0, x1); x < max(x0, x1); x++ {
|
||||||
|
g.arm(x, y, 2)
|
||||||
|
g.arm(x+1, y, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V lays a wire down column x between rows y0 and y1
|
||||||
|
func (g Wires) V(x, y0, y1 int) {
|
||||||
|
for y := min(y0, y1); y < max(y0, y1); y++ {
|
||||||
|
g.arm(x, y, 4)
|
||||||
|
g.arm(x, y+1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm adds an arm to a cell; one off the grid is dropped
|
||||||
|
func (g Wires) arm(x, y int, a uint8) {
|
||||||
|
if i := y*g.W + x; x >= 0 && x < g.W && y >= 0 && i < len(g.Arms) {
|
||||||
|
g.Arms[i] |= a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawWires draws every cell of the grid that has arms, in the line type
|
||||||
|
func (r Region) DrawWires(g Wires, line LineType, s Style) {
|
||||||
|
chars := line.runes()
|
||||||
|
for i, arms := range g.Arms {
|
||||||
|
if arms != 0 {
|
||||||
|
r.TextStyled(i%g.W, i/g.W, string(chars[arms&15]), s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-12
@@ -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.
|
||||||
start := x
|
// It returns the columns it drew.
|
||||||
for i, o := range options {
|
func (r Region) Radio(x, y int, options []string, chosen int, th Theme) int {
|
||||||
open, close, style := " ", " ", th.Muted
|
width := func(lo, hi int) int { // the window [lo, hi), its '…' marks included
|
||||||
if i == chosen {
|
w := -2
|
||||||
open, close, style = "[", "]", th.Accent
|
for _, o := range options[lo:hi] {
|
||||||
|
w += RuneLen(o) + 4
|
||||||
}
|
}
|
||||||
r.TextStyled(x, y, open+o+close, style)
|
return w + 2*min(lo, 1) + 3*min(len(options)-hi, 1)
|
||||||
x += RuneLen(o) + 2
|
|
||||||
}
|
}
|
||||||
return x - start
|
lo, hi := 0, len(options)
|
||||||
|
if width(lo, hi) > r.W-x && hi > 0 {
|
||||||
|
lo = max(0, min(chosen, hi-1))
|
||||||
|
hi = lo + 1
|
||||||
|
for grown := true; grown; {
|
||||||
|
grown = false
|
||||||
|
if hi < len(options) && width(lo, hi+1) <= r.W-x {
|
||||||
|
hi, grown = hi+1, true
|
||||||
|
}
|
||||||
|
if lo > 0 && width(lo-1, hi) <= r.W-x {
|
||||||
|
lo, grown = lo-1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := x
|
||||||
|
if lo > 0 {
|
||||||
|
r.TextStyled(x, y, "…", th.Muted)
|
||||||
|
x += 2
|
||||||
|
}
|
||||||
|
for i := lo; i < hi; i++ {
|
||||||
|
mark, style := th.Glyphs.Off, th.Muted
|
||||||
|
if i == chosen {
|
||||||
|
mark, style = th.Glyphs.On, th.Accent
|
||||||
|
}
|
||||||
|
r.TextStyled(x, y, Truncate(string(mark)+" "+options[i], max(0, r.W-x)), style)
|
||||||
|
x += RuneLen(options[i]) + 4
|
||||||
|
}
|
||||||
|
if hi < len(options) {
|
||||||
|
r.TextStyled(x, y, "…", th.Muted)
|
||||||
|
x += 3
|
||||||
|
}
|
||||||
|
return max(0, min(x-2, r.W)-start)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StepChoice moves a choice among n options on Left or Right (h, l),
|
// StepChoice moves a choice among n options on Left or Right (h, l),
|
||||||
|
|||||||
+1
-5
@@ -203,11 +203,7 @@ func (r Region) docSection(y, x, w int, label string, opts DocOpts) {
|
|||||||
r.TextStyled(x, y, label, opts.HeaderStyle)
|
r.TextStyled(x, y, label, opts.HeaderStyle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
line := opts.Rule
|
ch := opts.Rule.runes()[boxH]
|
||||||
if line >= LineType(len(boxChars)) {
|
|
||||||
line = LineSingle
|
|
||||||
}
|
|
||||||
ch := boxChars[line][boxH]
|
|
||||||
for i := range w {
|
for i := range w {
|
||||||
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
|
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-8
@@ -36,36 +36,49 @@ type Field struct {
|
|||||||
|
|
||||||
// Field draws a form row at y: the focus mark, the label in a column labelW
|
// Field draws a form row at y: the focus mark, the label in a column labelW
|
||||||
// wide with the required mark, and below, the error, or the help while
|
// wide with the required mark, and below, the error, or the help while
|
||||||
// focused. It returns the region for the value beside the label, where the
|
// focused, wrapped. The label column leaves the value half the row; labelW 0
|
||||||
// caller draws a TextInput, Choice or Toggle, and the rows it used.
|
// stacks the label on its own row and the value under it, indented. It
|
||||||
|
// returns the region for the value, where the caller draws a TextInput,
|
||||||
|
// Radio or Toggle, and the rows it used.
|
||||||
func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
|
func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
|
||||||
label := th.Muted
|
label := th.Muted
|
||||||
if focused {
|
if focused {
|
||||||
label = th.Text
|
label = th.Text
|
||||||
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
|
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
|
||||||
}
|
}
|
||||||
|
x, rows := 4, 2 // stacked: the label's own row, the value under it
|
||||||
|
if labelW > 0 {
|
||||||
|
labelW = min(labelW, max(1, (r.W-3)/2))
|
||||||
|
x, rows = 2+labelW+1, 1
|
||||||
|
} else {
|
||||||
|
labelW = r.W - 2
|
||||||
|
}
|
||||||
name := f.Label
|
name := f.Label
|
||||||
if f.Required {
|
if f.Required {
|
||||||
name = Truncate(name, labelW-2)
|
name = Truncate(name, labelW-2)
|
||||||
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
|
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
|
||||||
}
|
}
|
||||||
r.TextStyled(2, y, Truncate(name, labelW), label)
|
r.TextStyled(2, y, Truncate(name, labelW), label)
|
||||||
|
value := r.Sub(x, y+rows-1, r.W-x, 1)
|
||||||
|
|
||||||
x := 2 + labelW + 1
|
|
||||||
note, style := f.Error, th.Error
|
note, style := f.Error, th.Error
|
||||||
if note == "" && focused {
|
if note == "" && focused {
|
||||||
note, style = f.Help, th.Muted
|
note, style = f.Help, th.Muted
|
||||||
}
|
}
|
||||||
if note == "" {
|
if note == "" {
|
||||||
return r.Sub(x, y, r.W-x, 1), 1
|
return value, rows
|
||||||
}
|
}
|
||||||
r.TextStyled(x, y+1, Truncate(note, r.W-x), style)
|
lines := WrapText(note, r.W-x)
|
||||||
return r.Sub(x, y, r.W-x, 1), 2
|
for i, l := range lines {
|
||||||
|
r.TextStyled(x, y+rows+i, l, style)
|
||||||
|
}
|
||||||
|
return value, rows + len(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextInput draws state's text on r's first row, or, empty, the placeholder
|
// TextInput draws state's text on r's first row, or, empty, the placeholder
|
||||||
// muted: a default shows what an empty value means. Focused, it draws on the
|
// muted: a default shows what an empty value means. Focused, it draws on the
|
||||||
// Input background with the cursor, scrolling to keep the cursor in view.
|
// Input background with the cursor, scrolling to keep the cursor in view; a
|
||||||
|
// muted '…' marks text out of view.
|
||||||
func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
|
func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
|
||||||
if r.W < 1 || r.H < 1 {
|
if r.W < 1 || r.H < 1 {
|
||||||
return
|
return
|
||||||
@@ -75,7 +88,7 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
|
|||||||
bg = th.Input
|
bg = th.Input
|
||||||
}
|
}
|
||||||
state.AdjustScroll(r.W)
|
state.AdjustScroll(r.W)
|
||||||
text, style := []rune(placeholder), th.Muted.On(bg)
|
text, style := []rune(Truncate(placeholder, r.W)), th.Muted.On(bg)
|
||||||
if len(state.Text) > 0 {
|
if len(state.Text) > 0 {
|
||||||
text, style = state.Text[state.Scroll:], th.Text.On(bg)
|
text, style = state.Text[state.Scroll:], th.Text.On(bg)
|
||||||
}
|
}
|
||||||
@@ -86,6 +99,14 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
|
|||||||
}
|
}
|
||||||
r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr)
|
r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr)
|
||||||
}
|
}
|
||||||
|
// Text scrolled out of view on either side is marked, never under the cursor
|
||||||
|
hidden := th.Muted.On(bg)
|
||||||
|
if state.Scroll > 0 && (state.Cursor != state.Scroll || !focused) {
|
||||||
|
r.Cell(0, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr)
|
||||||
|
}
|
||||||
|
if end := r.W - 1; len(text) > r.W && (state.Cursor-state.Scroll != end || !focused) {
|
||||||
|
r.Cell(end, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr)
|
||||||
|
}
|
||||||
if focused {
|
if focused {
|
||||||
x, ch := state.Cursor-state.Scroll, ' '
|
x, ch := state.Cursor-state.Scroll, ' '
|
||||||
if x < len(text) {
|
if x < len(text) {
|
||||||
|
|||||||
+84
-24
@@ -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
|
||||||
}
|
}
|
||||||
r.TextStyled(0, 0, "/", th.Muted)
|
top := 1
|
||||||
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
|
if s.Menu {
|
||||||
|
top = 0
|
||||||
|
} else {
|
||||||
|
r.TextStyled(0, 0, "/", th.Muted)
|
||||||
|
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
|
||||||
|
}
|
||||||
m := s.Matches()
|
m := s.Matches()
|
||||||
rows := r.H - 1
|
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
r.TextStyled(2, 1, "no match", th.Muted)
|
r.TextStyled(2, top, "no match", th.Muted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows))
|
nameW, nameX, hintRows := s.layout(r.W)
|
||||||
s.Scroll = max(s.Scroll, s.Cursor-rows+1)
|
rows := max(1, r.H-top-hintRows)
|
||||||
|
s.Scroll = AdjustScroll(s.Cursor, ClampScroll(s.Scroll, rows, len(m)), rows, len(m))
|
||||||
nameW := 0
|
|
||||||
for _, i := range m {
|
|
||||||
nameW = max(nameW, RuneLen(s.Options[i].Name))
|
|
||||||
}
|
|
||||||
nameW = min(nameW, r.W/2)
|
|
||||||
for y := 0; y < rows && s.Scroll+y < len(m); y++ {
|
for y := 0; y < rows && s.Scroll+y < len(m); y++ {
|
||||||
o := s.Options[m[s.Scroll+y]]
|
o := s.Options[m[s.Scroll+y]]
|
||||||
row := r.Sub(0, 1+y, r.W, 1)
|
row := r.Sub(0, top+y, r.W, 1)
|
||||||
var bg Style
|
var bg Style
|
||||||
if s.Scroll+y == s.Cursor {
|
if s.Scroll+y == s.Cursor {
|
||||||
bg = th.Selected
|
bg = th.Selected
|
||||||
row.FillStyle(th.Text.On(bg))
|
row.FillStyle(th.Text.On(bg))
|
||||||
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg))
|
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg))
|
||||||
}
|
}
|
||||||
row.TextStyled(2, 0, Truncate(o.Name, nameW), th.Text.On(bg))
|
if s.Menu && o.Key != 0 {
|
||||||
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg))
|
row.TextStyled(2, 0, string(o.Key), th.Accent.On(bg))
|
||||||
|
}
|
||||||
|
row.TextStyled(nameX, 0, Truncate(o.Name, nameW), th.Text.On(bg))
|
||||||
|
if hintRows == 0 {
|
||||||
|
row.TextStyled(nameX+nameW+2, 0, Truncate(o.Hint, r.W-nameX-nameW-2), th.Muted.On(bg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hint, n := WrapText(s.Options[m[s.Cursor]].Hint, max(1, r.W-2)), min(hintRows, r.H-top-rows)
|
||||||
|
if len(hint) > n && n > 0 { // the last row shown ends in '…'
|
||||||
|
hint = append(hint[:n-1], Truncate(hint[n-1]+" "+hint[n], r.W-2))
|
||||||
|
}
|
||||||
|
for y, l := range hint[:min(n, len(hint))] { // on the last rows, so the list ends on text
|
||||||
|
r.TextStyled(2, r.H-min(n, len(hint))+y, l, th.Muted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-1
@@ -25,11 +25,17 @@ func (s Style) IsZero() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On returns s drawn on bg: s's foreground, bg's background (s's own where
|
// On returns s drawn on bg: s's foreground, bg's background (s's own where
|
||||||
// bg's is transparent), and the attributes of both
|
// bg's is transparent), and the attributes of both. On a reverse bg with no
|
||||||
|
// color of its own, s keeps only its background, with the terminal's own
|
||||||
|
// foreground and bg's styles: a reversed cell's background is its foreground
|
||||||
|
// (a console's keeps its intensity), so mixed styles would break the bar.
|
||||||
func (s Style) On(bg Style) Style {
|
func (s Style) On(bg Style) Style {
|
||||||
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
|
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
|
||||||
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
|
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
|
||||||
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
|
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
|
||||||
}
|
}
|
||||||
|
if bg.Attr&terminal.AttrReverse != 0 && bg.Bg == (color.RGB{}) && bg.Attr&terminal.AttrBg256 == 0 {
|
||||||
|
out.Fg, out.Attr = color.RGB{}, terminal.AttrFgDefault|out.Attr&terminal.AttrBgColor|bg.Attr&terminal.AttrStyle
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
+25
-26
@@ -1,19 +1,16 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Length calculation ---
|
// RuneLen returns s's width in cells, a rune each: Flush draws every rune in
|
||||||
|
// one column, a wide or joining one as U+FFFD
|
||||||
// RuneLen returns rune count, used as display width under the package-wide assumption of 1 cell per rune
|
|
||||||
// Wide (CJK), emoji, and combining characters are not handled
|
|
||||||
func RuneLen(s string) int {
|
func RuneLen(s string) int {
|
||||||
return utf8.RuneCountInString(s)
|
return utf8.RuneCountInString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Truncation ---
|
|
||||||
|
|
||||||
// Truncate truncates string with … suffix if exceeds maxLen
|
// Truncate truncates string with … suffix if exceeds maxLen
|
||||||
func Truncate(s string, maxLen int) string {
|
func Truncate(s string, maxLen int) string {
|
||||||
if maxLen <= 0 {
|
if maxLen <= 0 {
|
||||||
@@ -62,16 +59,12 @@ func TruncateMiddle(s string, maxLen int) string {
|
|||||||
return Truncate(s, maxLen)
|
return Truncate(s, maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split remaining space between start and end
|
|
||||||
// Favor start slightly: (maxLen-1)/2 for start, rest for end
|
|
||||||
startLen := (maxLen - 1) / 2
|
startLen := (maxLen - 1) / 2
|
||||||
endLen := maxLen - 1 - startLen
|
endLen := maxLen - 1 - startLen
|
||||||
|
|
||||||
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
|
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Padding ---
|
|
||||||
|
|
||||||
// PadRight pads string with spaces to width
|
// PadRight pads string with spaces to width
|
||||||
func PadRight(s string, width int) string {
|
func PadRight(s string, width int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
@@ -116,15 +109,23 @@ func PadCenter(s string, width int) string {
|
|||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Text wrapping ---
|
// WrapText wraps text to fit width: at each line break, at spaces, or, in a
|
||||||
|
// word longer than a line, after its last '.', ',', ':', '/', '[' or ']'
|
||||||
// WrapText wraps text at word boundaries to fit width
|
// that fits (a path or a key such as a[0].b); a word without one is cut.
|
||||||
// Returns slice of lines, each no longer than width
|
// Returns slice of lines, each no longer than width.
|
||||||
func WrapText(s string, width int) []string {
|
func WrapText(s string, width int) []string {
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var lines []string
|
||||||
|
for line := range strings.SplitSeq(s, "\n") {
|
||||||
|
lines = append(lines, wrapLine(line, width)...)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapLine wraps one line, s holding no line break
|
||||||
|
func wrapLine(s string, width int) []string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if len(runes) == 0 {
|
if len(runes) == 0 {
|
||||||
return []string{""}
|
return []string{""}
|
||||||
@@ -132,41 +133,42 @@ func WrapText(s string, width int) []string {
|
|||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
lineStart := 0
|
lineStart := 0
|
||||||
lastSpace := -1
|
lastSpace, lastMark := -1, -1
|
||||||
|
|
||||||
for i := 0; i <= len(runes); i++ {
|
for i := 0; i <= len(runes); i++ {
|
||||||
// Check if we need to wrap
|
|
||||||
if i-lineStart >= width || i == len(runes) {
|
if i-lineStart >= width || i == len(runes) {
|
||||||
if i == len(runes) {
|
if i == len(runes) {
|
||||||
// End of string
|
|
||||||
if lineStart < len(runes) {
|
if lineStart < len(runes) {
|
||||||
lines = append(lines, string(runes[lineStart:]))
|
lines = append(lines, string(runes[lineStart:]))
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to wrap
|
|
||||||
wrapAt := i
|
wrapAt := i
|
||||||
if lastSpace > lineStart {
|
switch {
|
||||||
// Wrap at last space
|
case lastSpace > lineStart:
|
||||||
wrapAt = lastSpace
|
wrapAt = lastSpace
|
||||||
|
case lastMark > lineStart:
|
||||||
|
wrapAt = lastMark
|
||||||
}
|
}
|
||||||
|
|
||||||
lines = append(lines, string(runes[lineStart:wrapAt]))
|
lines = append(lines, string(runes[lineStart:wrapAt]))
|
||||||
|
|
||||||
// Skip space at wrap point
|
|
||||||
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
|
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
|
||||||
lineStart = wrapAt + 1
|
lineStart = wrapAt + 1
|
||||||
} else {
|
} else {
|
||||||
lineStart = wrapAt
|
lineStart = wrapAt
|
||||||
}
|
}
|
||||||
lastSpace = -1
|
lastSpace, lastMark = -1, -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track spaces for word wrapping
|
// Track spaces for word wrapping, and the marks a long word breaks after
|
||||||
if i < len(runes) && runes[i] == ' ' {
|
if i < len(runes) && runes[i] == ' ' {
|
||||||
lastSpace = i
|
lastSpace = i
|
||||||
}
|
}
|
||||||
|
if i < len(runes) && strings.ContainsRune(".,:/[]", runes[i]) {
|
||||||
|
lastMark = i + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
@@ -176,8 +178,6 @@ func WrapText(s string, width int) []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Repetition ---
|
|
||||||
|
|
||||||
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
|
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
|
||||||
func RepeatRune(r rune, n int) string {
|
func RepeatRune(r rune, n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
@@ -189,4 +189,3 @@ func RepeatRune(r rune, n int) string {
|
|||||||
}
|
}
|
||||||
return string(runes)
|
return string(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +258,14 @@ func (t *TextFieldState) MoveToEnd() {
|
|||||||
|
|
||||||
// --- Scroll management ---
|
// --- Scroll management ---
|
||||||
|
|
||||||
// AdjustScroll updates scroll to keep cursor visible within viewport width
|
// AdjustScroll updates scroll to keep the cursor visible within viewport
|
||||||
|
// width, and the viewport filled up to the end of the text
|
||||||
func (t *TextFieldState) AdjustScroll(viewportW int) {
|
func (t *TextFieldState) AdjustScroll(viewportW int) {
|
||||||
if viewportW <= 0 {
|
if viewportW <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.clampCursor()
|
t.clampCursor()
|
||||||
|
t.Scroll = min(t.Scroll, max(0, len(t.Text)+1-viewportW))
|
||||||
if t.Cursor < t.Scroll {
|
if t.Cursor < t.Scroll {
|
||||||
t.Scroll = t.Cursor
|
t.Scroll = t.Cursor
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-7
@@ -25,18 +25,19 @@ type Theme struct {
|
|||||||
// it reads without color. A text console's font takes GlyphsCP437, a
|
// it reads without color. A text console's font takes GlyphsCP437, a
|
||||||
// terminal outside UTF-8 GlyphsASCII.
|
// terminal outside UTF-8 GlyphsASCII.
|
||||||
type Glyphs struct {
|
type Glyphs struct {
|
||||||
Focus rune // beside the focused control
|
Focus rune // beside the focused control
|
||||||
Pointer rune // the cursor row of a list
|
Pointer rune // the cursor row of a list
|
||||||
Folded rune // a closed group
|
Folded rune // a closed group
|
||||||
Unfolded rune // an open group
|
Unfolded rune // an open group
|
||||||
On, Off rune // a toggle
|
On, Off rune // a toggle
|
||||||
Required rune // after a required field's label
|
Required rune // after a required field's label
|
||||||
|
Line LineType // frames, rules and wires
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
|
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
|
||||||
GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'}
|
GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'}
|
||||||
GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*'}
|
GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*', Line: LineASCII}
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultTheme is a dark theme for true color and 256-color terminals
|
// DefaultTheme is a dark theme for true color and 256-color terminals
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
// ViewportScroll manages row-based scroll for content regions
|
// ViewportScroll manages row-based scroll for content regions
|
||||||
// Distinct from ScrollState which is item-index based
|
// Distinct from ScrollState which is item-index based
|
||||||
type ViewportScroll struct {
|
type ViewportScroll struct {
|
||||||
@@ -117,3 +119,24 @@ func (v *ViewportScroll) EnsureRange(y, h int) {
|
|||||||
}
|
}
|
||||||
v.clamp()
|
v.clamp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window shows content h rows tall through the region, from v's offset:
|
||||||
|
// draw paints a buffer as wide as the region and h tall, filled with bg, and
|
||||||
|
// the rows in view are copied. Window sets v's heights; a caller keeping a
|
||||||
|
// row in view sets them first and calls EnsureRange.
|
||||||
|
func (r Region) Window(h int, v *ViewportScroll, bg Style, draw func(Region)) {
|
||||||
|
h = max(0, h)
|
||||||
|
v.SetDimensions(h, r.H)
|
||||||
|
if r.W <= 0 || r.H <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cells := make([]terminal.Cell, r.W*h)
|
||||||
|
full := NewRegion(cells, r.W, 0, 0, r.W, h)
|
||||||
|
full.FillStyle(bg)
|
||||||
|
draw(full)
|
||||||
|
shown := min(r.H, h-v.Offset)
|
||||||
|
for y := range shown {
|
||||||
|
copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(v.Offset+y)*r.W:][:r.W])
|
||||||
|
}
|
||||||
|
r.Sub(0, shown, r.W, r.H-shown).FillStyle(bg)
|
||||||
|
}
|
||||||
+221
-2
@@ -1,6 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,12 +37,32 @@ func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
|
|||||||
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
|
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
|
||||||
t.Errorf("on %+v: %+v", panel, c)
|
t.Errorf("on %+v: %+v", panel, c)
|
||||||
}
|
}
|
||||||
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr|terminal.AttrReverse {
|
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr&terminal.AttrBgColor|terminal.AttrFgDefault|terminal.AttrReverse {
|
||||||
t.Errorf("%+v on reverse: %+v", panel, on)
|
t.Errorf("%+v on reverse: %+v", panel, on)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reverse cursor row is one bar: its pointer, key, name, hint and blanks
|
||||||
|
// take one style, whatever each draws in off the row, where they keep it
|
||||||
|
func TestReverseCursorRowIsOneStyle(t *testing.T) {
|
||||||
|
s := NewOptionListState([]Option{{Name: "file", Hint: "rotating files", Key: 'f'}, {Name: "tcp", Key: 't'}})
|
||||||
|
s.Menu = true
|
||||||
|
for name, th := range map[string]Theme{"16 colors": Theme16, "mono": MonoTheme} {
|
||||||
|
r := canvas(40, 2, th)
|
||||||
|
r.OptionList(s, th)
|
||||||
|
for x, c := range r.Cells[:r.W] {
|
||||||
|
if first := r.Cells[0]; c.Fg != first.Fg || c.Bg != first.Bg || c.Attrs != first.Attrs {
|
||||||
|
t.Errorf("%s %q: column %d %+v, column 0 %+v", name, row(r, 0), x, c, first)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, a := r.Cells[r.W+2], th.Accent; c.Rune != 't' || c.Fg != a.Fg || c.Attrs&^terminal.AttrBgColor != a.Attr {
|
||||||
|
t.Errorf("%s %q: the key off the cursor row %+v, want %+v", name, row(r, 1), c, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Every widget marks its selection with a glyph, so it reads with no color
|
// Every widget marks its selection with a glyph, so it reads with no color
|
||||||
func TestSelectionChangesAGlyph(t *testing.T) {
|
func TestSelectionChangesAGlyph(t *testing.T) {
|
||||||
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
|
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
|
||||||
@@ -50,7 +73,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
|||||||
func selectionChangesAGlyph(t *testing.T, th Theme) {
|
func selectionChangesAGlyph(t *testing.T, th Theme) {
|
||||||
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
|
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
|
||||||
for name, draw := range map[string]func(r Region, on bool){
|
for name, draw := range map[string]func(r Region, on bool){
|
||||||
"choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
|
"radio": func(r Region, on bool) { r.Radio(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
|
||||||
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
|
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
|
||||||
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
|
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
|
||||||
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
|
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
|
||||||
@@ -248,3 +271,199 @@ func TestGroupSummarisesWhileFolded(t *testing.T) {
|
|||||||
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
|
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A radio row too wide for its region shows the chosen option and those
|
||||||
|
// around it, a '…' on each side where others are hidden, and reports the
|
||||||
|
// columns drawn
|
||||||
|
func TestRadioKeepsTheChosenInView(t *testing.T) {
|
||||||
|
options := []string{"alpha", "beta", "gamma", "delta"}
|
||||||
|
for _, c := range []struct {
|
||||||
|
chosen, w int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{3, 40, "○ alpha ○ beta ○ gamma ● delta"},
|
||||||
|
{3, 30, "… ○ beta ○ gamma ● delta"},
|
||||||
|
{3, 16, "… ● delta"},
|
||||||
|
{3, 7, "… ● de…"},
|
||||||
|
{0, 17, "● alpha …"},
|
||||||
|
{1, 19, "○ alpha ● beta …"},
|
||||||
|
{1, 25, "… ● beta ○ gamma …"},
|
||||||
|
} {
|
||||||
|
r := canvas(c.w, 1, MonoTheme)
|
||||||
|
if n := r.Radio(0, 0, options, c.chosen, MonoTheme); row(r, 0) != c.want || n != min(c.w, RuneLen(c.want)) {
|
||||||
|
t.Errorf("%d chosen in %d columns: %q, %d drawn; want %q", c.chosen, c.w, row(r, 0), n, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field's view pulls back when its text shrinks, so no column is left
|
||||||
|
// blank while text is hidden, and a muted '…' marks text out of view, never
|
||||||
|
// under the cursor
|
||||||
|
func TestTextInputPullsBackAndMarksHiddenText(t *testing.T) {
|
||||||
|
f := NewTextFieldState(strings.Repeat("abcdefghij", 2))
|
||||||
|
draw := func(focused bool) string {
|
||||||
|
r := canvas(10, 1, MonoTheme)
|
||||||
|
r.TextInput(f, "", focused, MonoTheme)
|
||||||
|
return row(r, 0)
|
||||||
|
}
|
||||||
|
if got := draw(true); got != "…cdefghij" {
|
||||||
|
t.Fatalf("the cursor after 20 in 10 columns: %q", got)
|
||||||
|
}
|
||||||
|
for range 8 {
|
||||||
|
f.HandleKey(terminal.KeyBackspace, 0, 0)
|
||||||
|
}
|
||||||
|
if got := draw(true); got != "…efghijab" {
|
||||||
|
t.Fatalf("12 left: %q, scroll %d", got, f.Scroll)
|
||||||
|
}
|
||||||
|
f.HandleKey(terminal.KeyHome, 0, 0)
|
||||||
|
if got := draw(true); got != "abcdefghi…" {
|
||||||
|
t.Fatalf("the cursor at the start: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field's label leaves the value at least half the row; label width 0
|
||||||
|
// stacks the label on its own row, whole, and the value under it
|
||||||
|
func TestFieldLeavesTheValueHalfTheRow(t *testing.T) {
|
||||||
|
for _, w := range []int{12, 30, 80} {
|
||||||
|
r := canvas(w, 3, MonoTheme)
|
||||||
|
if v, rows := r.Field(0, 40, Field{Label: "password_file"}, false, MonoTheme); v.W < (w-3)/2 || rows != 1 {
|
||||||
|
t.Errorf("%d columns: value %d wide, %d rows", w, v.W, rows)
|
||||||
|
}
|
||||||
|
r = canvas(w, 3, MonoTheme)
|
||||||
|
v, rows := r.Field(0, 0, Field{Label: "password_file"}, false, MonoTheme)
|
||||||
|
if label := Truncate("password_file", w-2); row(r, 0) != " "+label || v.Y != 1 || v.W != w-4 || rows != 2 {
|
||||||
|
t.Errorf("%d columns stacked: %q, value at row %d, %d wide, %d rows", w, row(r, 0), v.Y, v.W, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text wraps at each line break, keeping an empty line, and a word longer
|
||||||
|
// than the line breaks after a '.', ',', ':', '/', '[' or ']'
|
||||||
|
func TestWrapTextBreaksAtLinesAndMarks(t *testing.T) {
|
||||||
|
for text, want := range map[string][]string{
|
||||||
|
"ab\ncd ef\n\ngh": {"ab", "cd ef", "", "gh"},
|
||||||
|
"key: /etc/logwisp/very/long/path.pem": {"key:", "/etc/logwisp/very/", "long/path.pem"},
|
||||||
|
} {
|
||||||
|
if got := WrapText(text, map[bool]int{true: 5, false: 20}[strings.HasPrefix(text, "ab")]); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%q: %q, want %q", text, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapping is linear in the lines: 4000 lines allocate under 1 MiB
|
||||||
|
func TestWrapTextIsLinearInLines(t *testing.T) {
|
||||||
|
text := strings.Repeat("x\n", 4000)
|
||||||
|
var before, after runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
lines := WrapText(text, 80)
|
||||||
|
runtime.ReadMemStats(&after)
|
||||||
|
if len(lines) != 4001 || after.TotalAlloc-before.TotalAlloc > 1<<20 {
|
||||||
|
t.Fatalf("%d lines, %d bytes allocated", len(lines), after.TotalAlloc-before.TotalAlloc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the hint column would be narrow, the list shows names alone and the
|
||||||
|
// cursor's hint wraps below them, in rows Rows counts; wide, hints sit beside
|
||||||
|
func TestNarrowOptionListShowsTheCursorsHint(t *testing.T) {
|
||||||
|
s := NewOptionListState([]Option{{Name: "pipe", Hint: "standard input to standard output"}, {Name: "tail", Hint: "follow files"}})
|
||||||
|
s.Cursor = 1
|
||||||
|
for w, want := range map[int][]string{
|
||||||
|
60: {"/ type to filter", " pipe standard input to standard output", "▸ tail follow files"},
|
||||||
|
20: {"/ type to filter", " pipe", "▸ tail", "", " follow files"},
|
||||||
|
} {
|
||||||
|
r := canvas(w, s.Rows(w), MonoTheme)
|
||||||
|
r.OptionList(s, MonoTheme)
|
||||||
|
var got []string
|
||||||
|
for y := range r.H {
|
||||||
|
got = append(got, row(r, y))
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("%d columns: %q, want %q", w, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A menu has no filter: a key picks its option, and typing filters nothing
|
||||||
|
func TestMenuPicksByKey(t *testing.T) {
|
||||||
|
s := NewOptionListState([]Option{{Name: "run", Key: 'r'}, {Name: "command", Key: 'c'}})
|
||||||
|
s.Menu = true
|
||||||
|
if i, ok := s.Pick('c'); !ok || i != 1 {
|
||||||
|
t.Fatalf("c: %d, %v", i, ok)
|
||||||
|
}
|
||||||
|
if _, ok := s.Pick('x'); ok || s.HandleKey(terminal.KeyRune, 'x', 0) || s.Paste("r") || s.Filter.Value() != "" {
|
||||||
|
t.Fatalf("x picked or filtered: %q", s.Filter.Value())
|
||||||
|
}
|
||||||
|
r := canvas(20, s.Rows(20), MonoTheme)
|
||||||
|
r.OptionList(s, MonoTheme)
|
||||||
|
if r.H != 2 || row(r, 0) != "▸ r run" || row(r, 1) != " c command" {
|
||||||
|
t.Fatalf("%d rows: %q %q", r.H, row(r, 0), row(r, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frame's inside is the region within its border and padding, so a frame
|
||||||
|
// sized to what it holds ends on it; the border takes the glyphs' line type
|
||||||
|
func TestFrameHoldsItsInside(t *testing.T) {
|
||||||
|
ascii := MonoTheme
|
||||||
|
ascii.Glyphs = GlyphsASCII
|
||||||
|
for _, c := range []struct {
|
||||||
|
th Theme
|
||||||
|
want [3]string
|
||||||
|
}{{MonoTheme, [3]string{"┌─ T ───┐", "│ abcde │", "└───────┘"}}, {ascii, [3]string{"+- T ---+", "| abcde |", "+-------+"}}} {
|
||||||
|
r := canvas(9, 3, c.th)
|
||||||
|
in := r.Frame("T", c.th)
|
||||||
|
in.TextStyled(0, 0, "abcde", c.th.Text)
|
||||||
|
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != c.want || in.W != 5 || in.H != 1 {
|
||||||
|
t.Errorf("%q, inside %dx%d", got, in.W, in.H)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule keeps its title; the hint at its end goes first when both do not fit
|
||||||
|
func TestRuleKeepsTheTitleBeforeTheHint(t *testing.T) {
|
||||||
|
for w, want := range map[int]string{40: "── file · source ────────────── ⏎ edit ─", 24: "── file · source ───────"} {
|
||||||
|
r := canvas(w, 1, MonoTheme)
|
||||||
|
if r.Rule(0, "file · source", "⏎ edit", MonoTheme); row(r, 0) != want {
|
||||||
|
t.Errorf("%d columns: %q", w, row(r, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires join where they meet: each cell draws the arms laid through it,
|
||||||
|
// whatever order they were laid in
|
||||||
|
func TestWiresJoinWhereTheyMeet(t *testing.T) {
|
||||||
|
g := NewWires(5, 3)
|
||||||
|
g.V(4, 1, 2)
|
||||||
|
g.H(1, 0, 4)
|
||||||
|
g.V(2, 0, 2)
|
||||||
|
g.H(9, 0, 9) // off the grid
|
||||||
|
for line, want := range map[LineType][3]string{LineSingle: {" │", "──┼─┐", " │ │"}, LineASCII: {" |", "--+-+", " | |"}} {
|
||||||
|
r := canvas(5, 3, MonoTheme)
|
||||||
|
r.DrawWires(g, line, MonoTheme.Border)
|
||||||
|
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != want {
|
||||||
|
t.Errorf("%d: %q, want %q", line, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window shows the content's rows from the scroll offset, and fills what
|
||||||
|
// the content does not reach; a window with no columns draws nothing
|
||||||
|
func TestWindowShowsTheRowsInView(t *testing.T) {
|
||||||
|
r := canvas(6, 3, MonoTheme)
|
||||||
|
var v ViewportScroll
|
||||||
|
v.SetDimensions(10, r.H)
|
||||||
|
v.EnsureRange(7, 1)
|
||||||
|
content := func(c Region) {
|
||||||
|
for y := range c.H {
|
||||||
|
c.TextStyled(0, y, fmt.Sprintf("row %d", y), MonoTheme.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Window(10, &v, MonoTheme.Text, content)
|
||||||
|
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 5", "row 6", "row 7"} {
|
||||||
|
t.Fatalf("rows 5-7: %q", got)
|
||||||
|
}
|
||||||
|
r.Window(2, &v, MonoTheme.Text, content)
|
||||||
|
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 0", "row 1", ""} {
|
||||||
|
t.Fatalf("two rows of content: %q", got)
|
||||||
|
}
|
||||||
|
r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// sendLocked queues what the operation in progress rendered and wakes the writer.
|
||||||
|
// Caller MUST hold mu.
|
||||||
|
func (t *termImpl) sendLocked() {
|
||||||
|
buf := &t.output.buf
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.queued = append(t.queued, buf.Bytes()...)
|
||||||
|
buf.Reset()
|
||||||
|
select {
|
||||||
|
case t.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLoop is the only writer of terminal output after Init. It returns once Fini
|
||||||
|
// has queued the restoring sequences and they are written; a terminal that never
|
||||||
|
// takes them leaves it in its write, and Fini stops waiting.
|
||||||
|
func (t *termImpl) writeLoop() {
|
||||||
|
defer close(t.writerDone)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.wake:
|
||||||
|
t.drain()
|
||||||
|
case <-t.quit:
|
||||||
|
t.drain()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain writes what is queued until nothing is, and draws a frame held while a
|
||||||
|
// write was out once that write returns. The write runs without mu, so whoever
|
||||||
|
// draws meanwhile finds writeSince set and holds its frame instead of waiting.
|
||||||
|
func (t *termImpl) drain() {
|
||||||
|
t.mu.Lock()
|
||||||
|
for {
|
||||||
|
if len(t.queued) == 0 {
|
||||||
|
t.releaseLocked()
|
||||||
|
}
|
||||||
|
if len(t.queued) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.writing, t.queued = t.queued, t.writing[:0]
|
||||||
|
t.writeSince.Store(time.Now().UnixNano())
|
||||||
|
t.mu.Unlock()
|
||||||
|
_ = t.backend.Write(t.writing) // a failed write is lost, as one through a buffered writer was
|
||||||
|
t.writeSince.Store(0)
|
||||||
|
t.mu.Lock()
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user