v0.2.0 16 colors, default colors, /dev/tty, bracketed paste, themed form controls
This commit is contained in:
32 files changed
+1442
-419
No files matched your search
@@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
Direct ANSI terminal control for Go with zero-allocation rendering. Built for
|
Direct ANSI terminal control for Go with zero-allocation rendering. Built for
|
||||||
sustained 60fps full-screen redraws in cell-based applications (games, dashboards,
|
sustained 60fps full-screen redraws in cell-based applications (games, dashboards,
|
||||||
TUIs). Depends only on the standard library and `golang.org/x/sys` (Unix builds).
|
TUIs). Depends on `github.com/lixenwraith/color` (RGB, palettes, blending),
|
||||||
|
`golang.org/x/sys` and `golang.org/x/term`.
|
||||||
|
|
||||||
The package bypasses terminfo/termcap entirely and emits ANSI sequences directly.
|
The package bypasses terminfo/termcap entirely and emits ANSI sequences directly.
|
||||||
Target environments: xterm-compatible terminals on Linux and BSDs, and browsers
|
Target environments: xterm-compatible terminals on Linux and BSDs, Windows
|
||||||
via xterm.js (WASM builds).
|
consoles with VT processing, and browsers via xterm.js (WASM builds).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- True color (24-bit) and 256-color palette output with automatic capability detection
|
- True color (24-bit), 256-color, ANSI 16-color and colorless output with
|
||||||
|
automatic capability detection, and the terminal's own default colors
|
||||||
- Double-buffered output with cell-level diffing — only changed cells emit sequences
|
- Double-buffered output with cell-level diffing — only changed cells emit sequences
|
||||||
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize
|
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize, bracketed paste
|
||||||
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT
|
- Draws on the controlling terminal when stdin or stdout is redirected (Unix)
|
||||||
- Color blending library: alpha, additive, screen, overlay, soft light
|
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT, from `color`
|
||||||
- Named color palettes for true color and xterm-256
|
|
||||||
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
|
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
|
||||||
- Unix and WASM backends behind a common interface
|
- Unix, Windows and WASM backends behind a common interface
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -26,11 +27,12 @@ via xterm.js (WASM builds).
|
|||||||
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
|
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
|
||||||
├── inputReader escape sequence parser, event channel
|
├── inputReader escape sequence parser, event channel
|
||||||
└── Backend (interface)
|
└── Backend (interface)
|
||||||
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH
|
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty
|
||||||
|
├── windowsBackend //go:build windows — console modes, VT processing
|
||||||
└── wasmBackend //go:build wasm — syscall/js, xterm.js bridge
|
└── wasmBackend //go:build wasm — syscall/js, xterm.js bridge
|
||||||
|
|
||||||
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing,
|
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing.
|
||||||
service lifecycle. Platform specifics are isolated in the `Backend` implementations.
|
Platform specifics are isolated in the `Backend` implementations.
|
||||||
|
|
||||||
### Rendering pipeline
|
### Rendering pipeline
|
||||||
|
|
||||||
@@ -56,7 +58,10 @@ without scroll side effects.
|
|||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/lixenwraith/terminal"
|
import (
|
||||||
|
"github.com/lixenwraith/color"
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
term := terminal.New() // color mode auto-detected
|
term := terminal.New() // color mode auto-detected
|
||||||
@@ -71,13 +76,13 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
// Build frame
|
// Build frame
|
||||||
for i := range cells {
|
for i := range cells {
|
||||||
cells[i] = terminal.Cell{Rune: ' ', Bg: terminal.Gunmetal}
|
cells[i] = terminal.Cell{Rune: ' ', Bg: color.Gunmetal}
|
||||||
}
|
}
|
||||||
msg := "hello"
|
msg := "hello"
|
||||||
for i, ch := range msg {
|
for i, ch := range msg {
|
||||||
// len(msg) to utf8.RuneCountIdString(msg) for non-ASCII
|
// len(msg) to utf8.RuneCountIdString(msg) for non-ASCII
|
||||||
cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{
|
cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{
|
||||||
Rune: ch, Fg: terminal.Amber, Bg: terminal.Gunmetal,
|
Rune: ch, Fg: color.Amber, Bg: color.Gunmetal,
|
||||||
Attrs: terminal.AttrBold,
|
Attrs: terminal.AttrBold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,8 +108,8 @@ func main() {
|
|||||||
```go
|
```go
|
||||||
type Cell struct {
|
type Cell struct {
|
||||||
Rune rune
|
Rune rune
|
||||||
Fg RGB
|
Fg color.RGB
|
||||||
Bg RGB
|
Bg color.RGB
|
||||||
Attrs Attr
|
Attrs Attr
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -112,18 +117,39 @@ type Cell struct {
|
|||||||
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
|
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
|
||||||
`AttrBlink`, `AttrReverse`.
|
`AttrBlink`, `AttrReverse`.
|
||||||
|
|
||||||
Two flag bits change color interpretation: with `AttrFg256` / `AttrBg256` set,
|
`Attr` is 16 bits wide; the bits above the styles say how to read a cell's
|
||||||
`Fg.R` / `Bg.R` holds an xterm-256 palette index directly and `G`/`B` are
|
colors:
|
||||||
ignored. This allows exact palette output on true color terminals and skips
|
- With `AttrFg256` / `AttrBg256` set, `Fg.R` / `Bg.R` holds an xterm-256
|
||||||
RGB → palette conversion.
|
palette index directly and `G`/`B` are ignored. This allows exact palette
|
||||||
|
output on true color terminals and skips RGB → palette conversion. Indices
|
||||||
|
0-15 are the ANSI 16, which every terminal shows in its own theme's shades.
|
||||||
|
- With `AttrFgDefault` / `AttrBgDefault` set, the color is the terminal's own
|
||||||
|
(SGR 39/49), whatever `Fg` / `Bg` hold: an application keeps a light or
|
||||||
|
dark terminal's background without knowing which it is.
|
||||||
|
|
||||||
## Color system
|
## Color system
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
|
|
||||||
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
|
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
|
||||||
`38;5;N` after mapping. `DetectColorMode()` inspects the environment
|
`38;5;N` after mapping; `ColorMode16` emits `30-37`/`90-97` and
|
||||||
(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`.
|
`40-47`/`100-107`, an RGB or palette color mapped to the nearest ANSI index;
|
||||||
|
`ColorModeNone` emits attributes only, so a highlight drawn as a background
|
||||||
|
color alone (a list's `CursorBg`) does not show; the `tui` form controls
|
||||||
|
mark every selection with a glyph.
|
||||||
|
|
||||||
|
`DetectColorMode()` reports what the terminal can show from the environment
|
||||||
|
(`COLORTERM`, `TERM`): 16 colors on a text console (`TERM` of `linux`,
|
||||||
|
`cons25*`, `vt` and a digit, `ansi` or `*-16color`), whatever `COLORTERM`
|
||||||
|
says. In 16 colors every color change restates the style from a reset,
|
||||||
|
since a console's bright colors (90-97) also set bold. `New()`
|
||||||
|
also honours `NO_COLOR`, choosing `ColorModeNone`; a mode passed to `New`
|
||||||
|
overrides both, as a `--color=always` or `never` would. `Init` refuses
|
||||||
|
`TERM=dumb`, which cannot address the cursor.
|
||||||
|
|
||||||
|
`Init` and `Sync` clear the screen to the terminal's own background.
|
||||||
|
`EmergencyReset(os.Stdout)` writes to the controlling terminal when stdout is
|
||||||
|
redirected, so the reset never lands in the program's output.
|
||||||
|
|
||||||
### RGB → 256 mapping
|
### RGB → 256 mapping
|
||||||
|
|
||||||
@@ -135,12 +161,12 @@ throughout; degradation is automatic.
|
|||||||
|
|
||||||
Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
|
Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
|
||||||
`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`,
|
`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`,
|
||||||
`P256SteelBlue`, ...) in `rgb_256.go` and named true color values (`Amber`,
|
`P256SteelBlue`, ...) and named true color values (`Amber`, `Gunmetal`,
|
||||||
`Gunmetal`, `Obsidian`, ...) in `rgb_truecolor.go`.
|
`Obsidian`, ...), all in `github.com/lixenwraith/color`.
|
||||||
|
|
||||||
### Blending
|
### Blending
|
||||||
|
|
||||||
`blend.go` provides compositing primitives operating on `RGB`. All take
|
`github.com/lixenwraith/color` provides compositing primitives operating on `RGB`. All take
|
||||||
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.
|
||||||
|
|
||||||
@@ -161,12 +187,12 @@ short-circuits without float math. All float→channel conversions round half-up
|
|||||||
so gradients from `Blend`, `Scale`, `Lerp`, and `SoftLight` are bit-consistent.
|
so gradients from `Blend`, `Scale`, `Lerp`, and `SoftLight` are bit-consistent.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
bg := terminal.Gunmetal
|
bg := color.Gunmetal
|
||||||
glow := terminal.RGB{R: 255, G: 160, B: 40}
|
glow := color.RGB{R: 255, G: 160, B: 40}
|
||||||
|
|
||||||
cell.Bg = terminal.Screen(bg, terminal.Scale(glow, pulse), 1.0) // pulsing glow
|
cell.Bg = color.Screen(bg, color.Scale(glow, pulse), 1.0) // pulsing glow
|
||||||
cell.Bg = terminal.Blend(cell.Bg, terminal.Black, 0.6) // dim overlay backdrop
|
cell.Bg = color.Blend(cell.Bg, color.Black, 0.6) // dim overlay backdrop
|
||||||
cell.Fg = terminal.SoftLight(cell.Fg, tint, 0.4) // subtle recolor
|
cell.Fg = color.SoftLight(cell.Fg, tint, 0.4) // subtle recolor
|
||||||
bar := cold.Lerp(hot, load) // value-mapped gradient
|
bar := cold.Lerp(hot, load) // value-mapped gradient
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -184,30 +210,22 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
|
|||||||
`MouseAction` (press/release/move/drag), modifiers. Enable via
|
`MouseAction` (press/release/move/drag), modifiers. Enable via
|
||||||
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
|
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
|
||||||
- `EventResize` — new `Width`/`Height`
|
- `EventResize` — new `Width`/`Height`
|
||||||
|
- `EventPaste` — `Text` holds a whole paste, as sent, control characters
|
||||||
|
included. Enable via `SetPasteMode(true)` (bracketed paste); off, a paste
|
||||||
|
arrives as keys, its newlines pressing Enter.
|
||||||
- `EventError`, `EventClosed`
|
- `EventError`, `EventClosed`
|
||||||
|
|
||||||
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
|
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
|
||||||
|
ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows).
|
||||||
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.
|
||||||
|
|
||||||
|
On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
|
||||||
|
`app > out.toml` and `cmd | app` keep both as data while the application
|
||||||
|
draws and reads keys on the terminal.
|
||||||
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
|
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
|
||||||
|
|
||||||
## Service wrapper
|
|
||||||
|
|
||||||
`TerminalService` packages lifecycle (init, input goroutine, panic-safe
|
|
||||||
teardown) behind `Init/Start/Stop` for service-registry architectures:
|
|
||||||
|
|
||||||
```go
|
|
||||||
svc := terminal.NewService()
|
|
||||||
svc.Init()
|
|
||||||
svc.Start()
|
|
||||||
defer svc.Stop()
|
|
||||||
|
|
||||||
term := svc.Terminal()
|
|
||||||
for ev := range svc.Events() { /* ... */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Input-goroutine panics trigger `EmergencyReset` (restores cooked mode, main
|
|
||||||
screen, cursor) before printing the stack trace, keeping the shell usable.
|
|
||||||
|
|
||||||
## WASM
|
## WASM
|
||||||
|
|
||||||
WASM builds bridge to xterm.js via JS globals:
|
WASM builds bridge to xterm.js via JS globals:
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
// CSI sequences
|
// CSI sequences
|
||||||
csi = []byte("\x1b[")
|
csi = []byte("\x1b[")
|
||||||
csiEnd = []byte("m")
|
|
||||||
csiReset = []byte("\x1b[0m")
|
|
||||||
csiClear = []byte("\x1b[2J\x1b[H")
|
csiClear = []byte("\x1b[2J\x1b[H")
|
||||||
csiHome = []byte("\x1b[H")
|
|
||||||
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency)
|
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency)
|
||||||
csiSGR0 = []byte("\x1b[0m")
|
csiSGR0 = []byte("\x1b[0m")
|
||||||
|
|
||||||
@@ -28,21 +25,9 @@ var (
|
|||||||
csiAutoWrapOn = []byte("\x1b[?7h")
|
csiAutoWrapOn = []byte("\x1b[?7h")
|
||||||
csiAutoWrapOff = []byte("\x1b[?7l")
|
csiAutoWrapOff = []byte("\x1b[?7l")
|
||||||
|
|
||||||
// Color prefixes
|
// Color parameters, after 38 or 48
|
||||||
csiFg256 = []byte("\x1b[38;5;") // followed by N;m
|
sgrPalette = []byte(";5;") // followed by N
|
||||||
csiBg256 = []byte("\x1b[48;5;") // followed by N;m
|
sgrRGB = []byte(";2;") // followed by R;G;B
|
||||||
csiFgRGB = []byte("\x1b[38;2;") // followed by R;G;B;m
|
|
||||||
csiBgRGB = []byte("\x1b[48;2;") // followed by R;G;B;m
|
|
||||||
csiDefaultFg = []byte("\x1b[39m")
|
|
||||||
csiDefaultBg = []byte("\x1b[49m")
|
|
||||||
|
|
||||||
// Attribute sequences
|
|
||||||
csiAttrBold = []byte("\x1b[1m")
|
|
||||||
csiAttrDim = []byte("\x1b[2m")
|
|
||||||
csiAttrItalic = []byte("\x1b[3m")
|
|
||||||
csiAttrUnderline = []byte("\x1b[4m")
|
|
||||||
csiAttrBlink = []byte("\x1b[5m")
|
|
||||||
csiAttrReverse = []byte("\x1b[7m")
|
|
||||||
|
|
||||||
// Mouse mode sequences (SGR 1006 for extended coordinates)
|
// Mouse mode sequences (SGR 1006 for extended coordinates)
|
||||||
csiMouseClickOn = []byte("\x1b[?1000h") // Enable click reporting
|
csiMouseClickOn = []byte("\x1b[?1000h") // Enable click reporting
|
||||||
@@ -53,6 +38,12 @@ var (
|
|||||||
csiMouseMotionOff = []byte("\x1b[?1003l")
|
csiMouseMotionOff = []byte("\x1b[?1003l")
|
||||||
csiMouseSGROn = []byte("\x1b[?1006h") // Enable SGR extended mode
|
csiMouseSGROn = []byte("\x1b[?1006h") // Enable SGR extended mode
|
||||||
csiMouseSGROff = []byte("\x1b[?1006l")
|
csiMouseSGROff = []byte("\x1b[?1006l")
|
||||||
|
|
||||||
|
// Bracketed paste: the terminal wraps pasted text in pasteStart, pasteEnd
|
||||||
|
csiPasteOn = []byte("\x1b[?2004h")
|
||||||
|
csiPasteOff = []byte("\x1b[?2004l")
|
||||||
|
pasteStart = []byte("\x1b[200~")
|
||||||
|
pasteEnd = []byte("\x1b[201~")
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeInt writes an integer without allocation
|
// writeInt writes an integer without allocation
|
||||||
|
|||||||
+27
-1
@@ -18,6 +18,7 @@ type unixBackend struct {
|
|||||||
out *os.File
|
out *os.File
|
||||||
inFd int
|
inFd int
|
||||||
outFd int
|
outFd int
|
||||||
|
tty *os.File // the controlling terminal, opened for a redirected end
|
||||||
oldTerm *term.State
|
oldTerm *term.State
|
||||||
|
|
||||||
resizeStopCh chan struct{}
|
resizeStopCh chan struct{}
|
||||||
@@ -35,19 +36,43 @@ func newBackend() Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init draws on the controlling terminal in place of a redirected stdin or
|
||||||
|
// stdout, so `app > file` and `cmd | app` keep both as data
|
||||||
func (b *unixBackend) Init() error {
|
func (b *unixBackend) Init() error {
|
||||||
|
if os.Getenv("TERM") == "dumb" {
|
||||||
|
return errors.New("TERM=dumb cannot address the cursor")
|
||||||
|
}
|
||||||
|
if !term.IsTerminal(b.inFd) || !term.IsTerminal(b.outFd) {
|
||||||
|
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no terminal: %w", err)
|
||||||
|
}
|
||||||
|
b.tty = tty
|
||||||
if !term.IsTerminal(b.inFd) {
|
if !term.IsTerminal(b.inFd) {
|
||||||
return fmt.Errorf("stdin is not a terminal")
|
b.in, b.inFd = tty, int(tty.Fd())
|
||||||
|
}
|
||||||
|
if !term.IsTerminal(b.outFd) {
|
||||||
|
b.out, b.outFd = tty, int(tty.Fd())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
old, err := term.MakeRaw(b.inFd)
|
old, err := term.MakeRaw(b.inFd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
b.closeTTY()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
b.oldTerm = old
|
b.oldTerm = old
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// closeTTY closes the controlling terminal Init opened, if any
|
||||||
|
func (b *unixBackend) closeTTY() {
|
||||||
|
if b.tty != nil {
|
||||||
|
b.tty.Close()
|
||||||
|
b.tty = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *unixBackend) Fini() {
|
func (b *unixBackend) Fini() {
|
||||||
if b.resizeStopCh != nil {
|
if b.resizeStopCh != nil {
|
||||||
close(b.resizeStopCh)
|
close(b.resizeStopCh)
|
||||||
@@ -57,6 +82,7 @@ func (b *unixBackend) Fini() {
|
|||||||
if b.oldTerm != nil {
|
if b.oldTerm != nil {
|
||||||
term.Restore(b.inFd, b.oldTerm)
|
term.Restore(b.inFd, b.oldTerm)
|
||||||
}
|
}
|
||||||
|
b.closeTTY()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size delegates to exported WindowSize; getTerminalSize deleted
|
// Size delegates to exported WindowSize; getTerminalSize deleted
|
||||||
|
|||||||
+2
-3
@@ -104,9 +104,9 @@ func (b *wasmBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case data := <-b.inputCh:
|
case data := <-b.inputCh:
|
||||||
// If we received exactly ESC, wait briefly for more data
|
// If we received ESC, or ESC ESC, wait briefly for more data
|
||||||
// (in case it's start of escape sequence split across callbacks)
|
// (in case it's start of escape sequence split across callbacks)
|
||||||
if len(data) == 1 && data[0] == 0x1b {
|
if string(data) == "\x1b" || string(data) == "\x1b\x1b" {
|
||||||
// Use JS setTimeout via a promise-based wait
|
// Use JS setTimeout via a promise-based wait
|
||||||
moreCh := make(chan []byte, 1)
|
moreCh := make(chan []byte, 1)
|
||||||
|
|
||||||
@@ -156,4 +156,3 @@ func (b *wasmBackend) SetResizeHandler(handler func(width, height int)) {
|
|||||||
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
|
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
|
||||||
js.Global().Set("goTerminalResize", resizeCb)
|
js.Global().Set("goTerminalResize", resizeCb)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,9 +14,18 @@ import (
|
|||||||
|
|
||||||
// --- palettes ---------------------------------------------------------------
|
// --- palettes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// colors are one palette's roles
|
||||||
|
type colors struct {
|
||||||
|
Bg, Fg, FocusBg, CursorBg color.RGB
|
||||||
|
Selected, Unselected, Partial, Error color.RGB
|
||||||
|
Warning, Border, HeaderBg, HeaderFg color.RGB
|
||||||
|
StatusFg, HintFg, InputBg, DirFg, FileFg color.RGB
|
||||||
|
SymbolFg color.RGB
|
||||||
|
}
|
||||||
|
|
||||||
type palette struct {
|
type palette struct {
|
||||||
name string
|
name string
|
||||||
th tui.Theme
|
th colors
|
||||||
accent, accent2 color.RGB
|
accent, accent2 color.RGB
|
||||||
hexNull, hexPrint, hexSpace, hexCtrl color.RGB
|
hexNull, hexPrint, hexSpace, hexCtrl color.RGB
|
||||||
hexHigh, matchBg, matchCurBg, markDim color.RGB
|
hexHigh, matchBg, matchCurBg, markDim color.RGB
|
||||||
@@ -25,7 +34,7 @@ type palette struct {
|
|||||||
var palettes = []palette{
|
var palettes = []palette{
|
||||||
{ // Tokyo Night
|
{ // Tokyo Night
|
||||||
name: "tokyonight",
|
name: "tokyonight",
|
||||||
th: tui.Theme{
|
th: colors{
|
||||||
Bg: color.RGB{26, 27, 38}, Fg: color.RGB{192, 202, 245},
|
Bg: color.RGB{26, 27, 38}, Fg: color.RGB{192, 202, 245},
|
||||||
FocusBg: color.RGB{35, 36, 48}, CursorBg: color.RGB{45, 50, 80},
|
FocusBg: color.RGB{35, 36, 48}, CursorBg: color.RGB{45, 50, 80},
|
||||||
Selected: color.RGB{158, 206, 106}, Unselected: color.RGB{86, 95, 137},
|
Selected: color.RGB{158, 206, 106}, Unselected: color.RGB{86, 95, 137},
|
||||||
@@ -45,7 +54,7 @@ var palettes = []palette{
|
|||||||
},
|
},
|
||||||
{ // One Dark
|
{ // One Dark
|
||||||
name: "onedark",
|
name: "onedark",
|
||||||
th: tui.Theme{
|
th: colors{
|
||||||
Bg: color.RGB{40, 44, 52}, Fg: color.RGB{171, 178, 191},
|
Bg: color.RGB{40, 44, 52}, Fg: color.RGB{171, 178, 191},
|
||||||
FocusBg: color.RGB{47, 52, 63}, CursorBg: color.RGB{62, 68, 81},
|
FocusBg: color.RGB{47, 52, 63}, CursorBg: color.RGB{62, 68, 81},
|
||||||
Selected: color.RGB{152, 195, 121}, Unselected: color.RGB{92, 99, 112},
|
Selected: color.RGB{152, 195, 121}, Unselected: color.RGB{92, 99, 112},
|
||||||
|
|||||||
@@ -8,8 +8,27 @@ type ColorMode uint8
|
|||||||
const (
|
const (
|
||||||
ColorMode256 ColorMode = iota // xterm-256 palette
|
ColorMode256 ColorMode = iota // xterm-256 palette
|
||||||
ColorModeTrueColor // 24-bit RGB
|
ColorModeTrueColor // 24-bit RGB
|
||||||
|
ColorMode16 // ANSI 16: SGR 30-37/90-97, 40-47/100-107
|
||||||
|
ColorModeNone // no color; attributes only
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// paletteTo16 maps an xterm-256 palette index to the nearest ANSI 16 index:
|
||||||
|
// the first 16 are themselves, the cube and the grey ramp go by their shade
|
||||||
|
func paletteTo16(i uint8) uint8 {
|
||||||
|
switch {
|
||||||
|
case i < 16:
|
||||||
|
return i
|
||||||
|
case i >= 232:
|
||||||
|
v := 8 + 10*(i-232)
|
||||||
|
return color.RGBTo16(color.RGB{R: v, G: v, B: v})
|
||||||
|
}
|
||||||
|
r, g, b := color.CubeRGB256(i)
|
||||||
|
return color.RGBTo16(color.RGB{R: cubeLevel[r], G: cubeLevel[g], B: cubeLevel[b]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cubeLevel is the channel value of each xterm color cube step
|
||||||
|
var cubeLevel = [6]uint8{0, 95, 135, 175, 215, 255}
|
||||||
|
|
||||||
// WarmPalette256 delegates to the color package's lazily evaluated 256-color LUT builder.
|
// WarmPalette256 delegates to the color package's lazily evaluated 256-color LUT builder.
|
||||||
// Exists to preserve terminal API backwards-compatibility.
|
// Exists to preserve terminal API backwards-compatibility.
|
||||||
func WarmPalette256() {
|
func WarmPalette256() {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal/internal/env"
|
||||||
|
|
||||||
|
// DetectColorMode determines terminal color capability from environment:
|
||||||
|
// 16 colors on text consoles, true color where announced, else 256. It
|
||||||
|
// reports what the terminal can show; New also honours NO_COLOR.
|
||||||
|
func DetectColorMode() ColorMode {
|
||||||
|
switch env.Colors() {
|
||||||
|
case 16:
|
||||||
|
return ColorMode16
|
||||||
|
case 1 << 24:
|
||||||
|
return ColorModeTrueColor
|
||||||
|
}
|
||||||
|
return ColorMode256
|
||||||
|
}
|
||||||
+6
-24
@@ -4,35 +4,17 @@ package terminal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetectColorMode determines terminal color capability from environment
|
// controllingTTY opens the controlling terminal for writing, nil without one
|
||||||
func DetectColorMode() ColorMode {
|
func controllingTTY() *os.File {
|
||||||
colorterm := os.Getenv("COLORTERM")
|
f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
|
||||||
if colorterm == "truecolor" || colorterm == "24bit" {
|
if err != nil {
|
||||||
return ColorModeTrueColor
|
return nil
|
||||||
}
|
}
|
||||||
|
return f
|
||||||
if os.Getenv("KITTY_WINDOW_ID") != "" ||
|
|
||||||
os.Getenv("KONSOLE_VERSION") != "" ||
|
|
||||||
os.Getenv("ITERM_SESSION_ID") != "" ||
|
|
||||||
os.Getenv("ALACRITTY_WINDOW_ID") != "" ||
|
|
||||||
os.Getenv("ALACRITTY_LOG") != "" ||
|
|
||||||
os.Getenv("WEZTERM_PANE") != "" {
|
|
||||||
return ColorModeTrueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
term := os.Getenv("TERM")
|
|
||||||
if strings.Contains(term, "truecolor") ||
|
|
||||||
strings.Contains(term, "24bit") ||
|
|
||||||
strings.Contains(term, "direct") {
|
|
||||||
return ColorModeTrueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
return ColorMode256
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetTerminalMode attempts to restore terminal to cooked mode
|
// resetTerminalMode attempts to restore terminal to cooked mode
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A text console gets 16 colors whatever COLORTERM says, NO_COLOR turns
|
||||||
|
// color off unless the caller names a mode, and TERM=dumb is refused
|
||||||
|
func TestDetectionFollowsTheTerminal(t *testing.T) {
|
||||||
|
t.Setenv("TERM", "linux")
|
||||||
|
t.Setenv("COLORTERM", "truecolor")
|
||||||
|
t.Setenv("NO_COLOR", "")
|
||||||
|
if got := New().ColorMode(); got != ColorMode16 {
|
||||||
|
t.Errorf("text console: mode %d", got)
|
||||||
|
}
|
||||||
|
t.Setenv("NO_COLOR", "1")
|
||||||
|
if got, named := New().ColorMode(), New(ColorMode256).ColorMode(); got != ColorModeNone || named != ColorMode256 {
|
||||||
|
t.Errorf("NO_COLOR: mode %d, named %d", got, named)
|
||||||
|
}
|
||||||
|
t.Setenv("TERM", "dumb")
|
||||||
|
if err := newBackend().Init(); err == nil || !strings.Contains(err.Error(), "dumb") {
|
||||||
|
t.Errorf("TERM=dumb: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
// DetectColorMode determines terminal color capability from environment
|
// DetectColorMode determines terminal color capability from environment
|
||||||
func DetectColorMode() ColorMode {
|
func DetectColorMode() ColorMode {
|
||||||
// Browsers/xterm.js generally support true color
|
// Browsers/xterm.js generally support true color
|
||||||
@@ -10,3 +12,6 @@ func DetectColorMode() ColorMode {
|
|||||||
|
|
||||||
// resetTerminalMode is no-op for WASM; termios does not exist
|
// resetTerminalMode is no-op for WASM; termios does not exist
|
||||||
func resetTerminalMode() {}
|
func resetTerminalMode() {}
|
||||||
|
|
||||||
|
// controllingTTY is nil: the browser terminal is reached through the bridge
|
||||||
|
func controllingTTY() *os.File { return nil }
|
||||||
+3
-9
@@ -8,15 +8,9 @@ import (
|
|||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DetectColorMode() ColorMode {
|
// controllingTTY is nil: Init refuses a redirected stdout, so the library
|
||||||
if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" {
|
// never drew on the console behind one
|
||||||
return ColorModeTrueColor
|
func controllingTTY() *os.File { return nil }
|
||||||
}
|
|
||||||
if ct := os.Getenv("COLORTERM"); ct == "truecolor" || ct == "24bit" {
|
|
||||||
return ColorModeTrueColor
|
|
||||||
}
|
|
||||||
return ColorMode256
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetTerminalMode() {
|
func resetTerminalMode() {
|
||||||
saneIn := uint32(windows.ENABLE_PROCESSED_INPUT |
|
saneIn := uint32(windows.ENABLE_PROCESSED_INPUT |
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
//
|
//
|
||||||
// # Features
|
// # Features
|
||||||
//
|
//
|
||||||
// - True color (24-bit) and 256-color palette support
|
// - True color (24-bit), 256-color, ANSI 16-color and colorless output, and
|
||||||
|
// the terminal's own default colors
|
||||||
// - Double-buffered output with cell-level diffing
|
// - Double-buffered output with cell-level diffing
|
||||||
// - Raw stdin input parsing with escape sequence handling
|
// - Raw input parsing with escape sequence handling and bracketed paste
|
||||||
|
// - Drawing on /dev/tty when stdin or stdout is redirected (Unix)
|
||||||
// - Resize detection (SIGWINCH on Unix, callback on WASM)
|
// - Resize detection (SIGWINCH on Unix, callback on WASM)
|
||||||
// - Clean terminal restoration on exit/panic
|
// - Clean terminal restoration on exit/panic
|
||||||
//
|
//
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
// └── wasmBackend (//go:build wasm) - syscall/js, callbacks
|
// └── wasmBackend (//go:build wasm) - syscall/js, callbacks
|
||||||
//
|
//
|
||||||
// Shared code (no build tags): Terminal interface, cell diffing, ANSI generation,
|
// Shared code (no build tags): Terminal interface, cell diffing, ANSI generation,
|
||||||
// escape sequence parsing, service lifecycle.
|
// escape sequence parsing.
|
||||||
//
|
//
|
||||||
// # WASM Integration
|
// # WASM Integration
|
||||||
//
|
//
|
||||||
@@ -45,4 +47,3 @@
|
|||||||
// Target environments: Linux, macOS, BSDs with xterm-compatible terminals, and
|
// Target environments: Linux, macOS, BSDs with xterm-compatible terminals, and
|
||||||
// modern browsers with xterm.js.
|
// modern browsers with xterm.js.
|
||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
+10
-7
@@ -19,10 +19,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
|
"github.com/lixenwraith/terminal/internal/env"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,12 +35,15 @@ const (
|
|||||||
colorMode16
|
colorMode16
|
||||||
)
|
)
|
||||||
|
|
||||||
// textConsole reports a TERM limited to 16 colors (Linux VT, FreeBSD syscons,
|
// detectColorMode maps what the environment says to a color mode
|
||||||
// VT100-class). It wins over COLORTERM, which profiles export everywhere.
|
func detectColorMode() colorMode {
|
||||||
func textConsole() bool {
|
switch env.Colors() {
|
||||||
term := os.Getenv("TERM")
|
case 16:
|
||||||
return term == "linux" || term == "ansi" || strings.HasPrefix(term, "cons25") ||
|
return colorMode16
|
||||||
strings.HasPrefix(term, "vt") || strings.HasSuffix(term, "-16color")
|
case 1 << 24:
|
||||||
|
return colorModeTrueColor
|
||||||
|
}
|
||||||
|
return colorMode256
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printer manages styled output and the live block. Safe for concurrent use.
|
// Printer manages styled output and the live block. Safe for concurrent use.
|
||||||
|
|||||||
@@ -4,39 +4,10 @@ package inline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func detectColorMode() colorMode {
|
|
||||||
if textConsole() {
|
|
||||||
return colorMode16
|
|
||||||
}
|
|
||||||
colorterm := os.Getenv("COLORTERM")
|
|
||||||
if colorterm == "truecolor" || colorterm == "24bit" {
|
|
||||||
return colorModeTrueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("KITTY_WINDOW_ID") != "" ||
|
|
||||||
os.Getenv("KONSOLE_VERSION") != "" ||
|
|
||||||
os.Getenv("ITERM_SESSION_ID") != "" ||
|
|
||||||
os.Getenv("ALACRITTY_WINDOW_ID") != "" ||
|
|
||||||
os.Getenv("ALACRITTY_LOG") != "" ||
|
|
||||||
os.Getenv("WEZTERM_PANE") != "" {
|
|
||||||
return colorModeTrueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
term := os.Getenv("TERM")
|
|
||||||
if strings.Contains(term, "truecolor") ||
|
|
||||||
strings.Contains(term, "24bit") ||
|
|
||||||
strings.Contains(term, "direct") {
|
|
||||||
return colorModeTrueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
return colorMode256
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowSize(f *os.File) (w, h int, ok bool) {
|
func windowSize(f *os.File) (w, h int, ok bool) {
|
||||||
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
|
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
|
||||||
if err != nil || ws.Col == 0 {
|
if err != nil || ws.Col == 0 {
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ package inline
|
|||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
func detectColorMode() colorMode {
|
|
||||||
if textConsole() {
|
|
||||||
return colorMode16
|
|
||||||
}
|
|
||||||
return colorMode256
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowSize(f *os.File) (w, h int, ok bool) {
|
func windowSize(f *os.File) (w, h int, ok bool) {
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
@@ -8,19 +8,6 @@ import (
|
|||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func detectColorMode() colorMode {
|
|
||||||
if textConsole() {
|
|
||||||
return colorMode16
|
|
||||||
}
|
|
||||||
if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" {
|
|
||||||
return colorModeTrueColor
|
|
||||||
}
|
|
||||||
if ct := os.Getenv("COLORTERM"); ct == "truecolor" || ct == "24bit" {
|
|
||||||
return colorModeTrueColor
|
|
||||||
}
|
|
||||||
return colorMode256
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowSize(f *os.File) (w, h int, ok bool) {
|
func windowSize(f *os.File) (w, h int, ok bool) {
|
||||||
var info windows.ConsoleScreenBufferInfo
|
var info windows.ConsoleScreenBufferInfo
|
||||||
if err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info); err != nil {
|
if err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info); err != nil {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ type EventType uint8
|
|||||||
const (
|
const (
|
||||||
EventKey EventType = iota
|
EventKey EventType = iota
|
||||||
EventResize
|
EventResize
|
||||||
EventPaste // Future: bracketed paste
|
EventPaste // Bracketed paste, Text holds it
|
||||||
EventMouse // SGR mouse reporting
|
EventMouse // SGR mouse reporting
|
||||||
EventError // Read error
|
EventError // Read error
|
||||||
EventClosed // Input closed
|
EventClosed // Input closed
|
||||||
@@ -29,6 +31,7 @@ type Event struct {
|
|||||||
Width int // For EventResize
|
Width int // For EventResize
|
||||||
Height int // For EventResize
|
Height int // For EventResize
|
||||||
Err error // For EventError
|
Err error // For EventError
|
||||||
|
Text string // For EventPaste: the pasted bytes as sent, controls included
|
||||||
|
|
||||||
// Mouse event fields
|
// Mouse event fields
|
||||||
MouseX int
|
MouseX int
|
||||||
@@ -47,6 +50,13 @@ type inputReader struct {
|
|||||||
// Persistent buffer for stream assembly, not fixed size zero-alloc to avoid corrupting partial UTF-8 at boundary
|
// Persistent buffer for stream assembly, not fixed size zero-alloc to avoid corrupting partial UTF-8 at boundary
|
||||||
buf []byte
|
buf []byte
|
||||||
|
|
||||||
|
// Bracketed paste: whether SetPasteMode turned it on, and a paste in
|
||||||
|
// progress, what has arrived of it and when its last bytes did
|
||||||
|
pasteMode atomic.Bool
|
||||||
|
pasting bool
|
||||||
|
paste []byte
|
||||||
|
pasteSeen time.Time
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -105,10 +115,10 @@ func (r *inputReader) readLoop() {
|
|||||||
|
|
||||||
// Panic recovery for raw input reader
|
// Panic recovery for raw input reader
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if p := recover(); p != nil {
|
||||||
EmergencyReset(os.Stdout)
|
EmergencyReset(writerAdapter{r.backend})
|
||||||
// Use \r\n for clean output
|
// Use \r\n for clean output
|
||||||
fmt.Fprintf(os.Stderr, "\r\n\x1b[31mINPUT READER CRASHED: %v\x1b[0m\r\n", r)
|
fmt.Fprintf(os.Stderr, "\r\n\x1b[31mINPUT READER CRASHED: %v\x1b[0m\r\n", p)
|
||||||
fmt.Fprintf(os.Stderr, "Stack Trace:\r\n%s\r\n", debug.Stack())
|
fmt.Fprintf(os.Stderr, "Stack Trace:\r\n%s\r\n", debug.Stack())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -122,11 +132,19 @@ func (r *inputReader) readLoop() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A paste whose end marker never comes ends when input pauses, so
|
||||||
|
// keys are never swallowed for good; a read may block (wasm)
|
||||||
|
if r.pasting && time.Since(r.pasteSeen) > pasteIdle {
|
||||||
|
r.paste = append(r.paste, r.buf...)
|
||||||
|
r.buf = r.buf[:0]
|
||||||
|
r.endPaste()
|
||||||
|
}
|
||||||
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
// Timeout (Unix poll) or empty read
|
// Timeout (Unix poll) or empty read: a pending ESC, or two, was
|
||||||
// Emit pending standalone ESC if present
|
// pressed alone; inside a paste it is text
|
||||||
if len(r.buf) == 1 && r.buf[0] == 0x1b {
|
if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting {
|
||||||
r.sendEvent(Event{Type: EventKey, Key: KeyEscape})
|
r.sendEvent(ev)
|
||||||
r.buf = r.buf[:0]
|
r.buf = r.buf[:0]
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
@@ -168,6 +186,26 @@ func (r *inputReader) parseInput(data []byte) int {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.pasting {
|
||||||
|
end := bytes.Index(data[i:], pasteEnd)
|
||||||
|
if end < 0 {
|
||||||
|
// Keep back what could start pasteEnd until more arrives
|
||||||
|
keep := min(n-i, len(pasteEnd)-1)
|
||||||
|
r.paste = append(r.paste, data[i:n-keep]...)
|
||||||
|
r.pasteSeen = time.Now()
|
||||||
|
return n - keep
|
||||||
|
}
|
||||||
|
r.paste = append(r.paste, data[i:i+end]...)
|
||||||
|
r.endPaste()
|
||||||
|
i += end + len(pasteEnd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.pasteMode.Load() && bytes.HasPrefix(data[i:], pasteStart) {
|
||||||
|
r.pasting, r.pasteSeen = true, time.Now()
|
||||||
|
i += len(pasteStart)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
b := data[i]
|
b := data[i]
|
||||||
|
|
||||||
// Fast path: printable ASCII
|
// Fast path: printable ASCII
|
||||||
@@ -237,6 +275,21 @@ func (r *inputReader) parseInput(data []byte) int {
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loneEscape is what an ESC or two left when input paused was
|
||||||
|
var loneEscape = map[string]Event{
|
||||||
|
"\x1b": {Type: EventKey, Key: KeyEscape},
|
||||||
|
"\x1b\x1b": {Type: EventKey, Key: KeyEscape, Modifiers: ModAlt},
|
||||||
|
}
|
||||||
|
|
||||||
|
// pasteIdle is how long a paste may pause before it is taken as ended
|
||||||
|
var pasteIdle = time.Second
|
||||||
|
|
||||||
|
// endPaste sends the paste gathered so far as one event
|
||||||
|
func (r *inputReader) endPaste() {
|
||||||
|
r.sendEvent(Event{Type: EventPaste, Text: string(r.paste)})
|
||||||
|
r.pasting, r.paste = false, r.paste[:0]
|
||||||
|
}
|
||||||
|
|
||||||
// utf8SeqLen returns expected UTF-8 sequence length from start byte, 0 if invalid
|
// utf8SeqLen returns expected UTF-8 sequence length from start byte, 0 if invalid
|
||||||
func utf8SeqLen(b byte) int {
|
func utf8SeqLen(b byte) int {
|
||||||
if b < 0x80 {
|
if b < 0x80 {
|
||||||
@@ -260,8 +313,22 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) {
|
|||||||
return 0, Event{} // Incomplete, wait for more
|
return 0, Event{} // Incomplete, wait for more
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC ESC -> Alt+Escape
|
// ESC ESC: Escape before a paste, Alt and the sequence that follows (rxvt's
|
||||||
if data[1] == 0x1b {
|
// Alt+arrows), else Alt+Escape; alone it waits for the timeout
|
||||||
|
if rest := data[1:]; rest[0] == 0x1b {
|
||||||
|
switch {
|
||||||
|
case len(rest) == 1:
|
||||||
|
return 0, Event{}
|
||||||
|
case bytes.HasPrefix(rest, pasteStart):
|
||||||
|
return 1, Event{Type: EventKey, Key: KeyEscape}
|
||||||
|
case rest[1] == '[' || rest[1] == 'O':
|
||||||
|
n, ev := r.parseEscape(rest)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, Event{}
|
||||||
|
}
|
||||||
|
ev.Modifiers |= ModAlt
|
||||||
|
return n + 1, ev
|
||||||
|
}
|
||||||
return 2, Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
|
return 2, Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
// Package env reads what the environment says about a terminal's colors,
|
||||||
|
// for terminal and inline alike
|
||||||
|
package env
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Colors reports how many colors the environment says the terminal shows:
|
||||||
|
// 16 on a text console, which wins over COLORTERM since profiles export it
|
||||||
|
// everywhere; 1<<24 when the terminal announces true color; else 256
|
||||||
|
func Colors() int {
|
||||||
|
switch {
|
||||||
|
case textConsole(os.Getenv("TERM")):
|
||||||
|
return 16
|
||||||
|
case trueColor():
|
||||||
|
return 1 << 24
|
||||||
|
}
|
||||||
|
return 256
|
||||||
|
}
|
||||||
|
|
||||||
|
// textConsole reports a TERM limited to 16 colors: the Linux VT, FreeBSD
|
||||||
|
// syscons, VT100-class terminals (vt and a digit, not vte) and *-16color
|
||||||
|
func textConsole(term string) bool {
|
||||||
|
return term == "linux" || term == "ansi" || strings.HasPrefix(term, "cons25") ||
|
||||||
|
len(term) > 2 && term[:2] == "vt" && term[2] >= '0' && term[2] <= '9' ||
|
||||||
|
strings.HasSuffix(term, "-16color")
|
||||||
|
}
|
||||||
|
|
||||||
|
// trueColorVars are set by emulators that draw 24-bit color
|
||||||
|
var trueColorVars = []string{"KITTY_WINDOW_ID", "KONSOLE_VERSION", "ITERM_SESSION_ID",
|
||||||
|
"ALACRITTY_WINDOW_ID", "ALACRITTY_LOG", "WEZTERM_PANE", "WT_SESSION", "WT_PROFILE_ID"}
|
||||||
|
|
||||||
|
func trueColor() bool {
|
||||||
|
if ct := os.Getenv("COLORTERM"); ct == "truecolor" || ct == "24bit" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, v := range trueColorVars {
|
||||||
|
if os.Getenv(v) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term := os.Getenv("TERM")
|
||||||
|
return strings.Contains(term, "truecolor") || strings.Contains(term, "24bit") || strings.Contains(term, "direct")
|
||||||
|
}
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package env
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A text console, VT100-class included, gets 16 colors whatever COLORTERM
|
||||||
|
// says; vte, a modern emulator, is not one
|
||||||
|
func TestTextConsoleWinsOverColorterm(t *testing.T) {
|
||||||
|
for _, v := range trueColorVars {
|
||||||
|
t.Setenv(v, "")
|
||||||
|
}
|
||||||
|
t.Setenv("COLORTERM", "truecolor")
|
||||||
|
for term, want := range map[string]int{
|
||||||
|
"linux": 16, "cons25": 16, "vt220": 16, "ansi": 16, "xterm-16color": 16,
|
||||||
|
"vte-256color": 1 << 24, "vt": 1 << 24, "xterm-256color": 1 << 24,
|
||||||
|
} {
|
||||||
|
t.Setenv("TERM", term)
|
||||||
|
if got := Colors(); got != want {
|
||||||
|
t.Errorf("TERM=%s: %d colors, want %d", term, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv("COLORTERM", "")
|
||||||
|
t.Setenv("TERM", "vte-256color")
|
||||||
|
if got := Colors(); got != 256 {
|
||||||
|
t.Errorf("vte-256color: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package terminal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
)
|
)
|
||||||
@@ -174,8 +176,9 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
|
|
||||||
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
||||||
|
|
||||||
|
// A cell holds one glyph, never a control the terminal would act on
|
||||||
r := c.Rune
|
r := c.Rune
|
||||||
if r == 0 {
|
if unicode.IsControl(r) || !utf8.ValidRune(r) {
|
||||||
r = ' '
|
r = ' '
|
||||||
}
|
}
|
||||||
if r < 0x80 {
|
if r < 0x80 {
|
||||||
@@ -254,86 +257,43 @@ 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 *bufio.Writer, fg, bg color.RGB, attr Attr) {
|
||||||
|
if o.colorMode == ColorModeNone {
|
||||||
|
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
||||||
|
}
|
||||||
// Check what changed
|
// Check what changed
|
||||||
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFg256) != (o.lastAttr&AttrFg256)
|
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
|
||||||
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBg256) != (o.lastAttr&AttrBg256)
|
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
|
||||||
styleAttr := attr & AttrStyle
|
styleAttr := attr & AttrStyle
|
||||||
lastStyleAttr := o.lastAttr & AttrStyle
|
attrChanged := !o.lastValid || styleAttr != o.lastAttr&AttrStyle
|
||||||
attrChanged := !o.lastValid || styleAttr != lastStyleAttr
|
// A text console's bright colors (90-97) set bold, which only a reset
|
||||||
|
// clears: 16 colors restate the whole style from one
|
||||||
|
if o.colorMode == ColorMode16 && (fgChanged || bgChanged) {
|
||||||
|
attrChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
if !fgChanged && !bgChanged && !attrChanged {
|
if !fgChanged && !bgChanged && !attrChanged {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If attributes changed, must reset first
|
|
||||||
if attrChanged {
|
|
||||||
w.Write(csi) // \x1b[
|
|
||||||
first := true
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
w.WriteByte('0')
|
|
||||||
first = false
|
|
||||||
|
|
||||||
// Style attributes
|
|
||||||
if styleAttr&AttrBold != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('1')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if styleAttr&AttrDim != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('2')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if styleAttr&AttrItalic != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('3')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if styleAttr&AttrUnderline != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('4')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if styleAttr&AttrBlink != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('5')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if styleAttr&AttrReverse != 0 {
|
|
||||||
if !first {
|
|
||||||
w.WriteByte(';')
|
|
||||||
}
|
|
||||||
w.WriteByte('7')
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
|
|
||||||
o.writeFgInline(w, fg, attr)
|
|
||||||
o.writeBgInline(w, bg, attr)
|
|
||||||
w.WriteByte('m')
|
|
||||||
} else {
|
|
||||||
// Only colors changed, emit minimal sequence
|
|
||||||
if fgChanged && bgChanged {
|
|
||||||
w.Write(csi)
|
w.Write(csi)
|
||||||
o.writeFgInline(w, fg, attr)
|
// If attributes changed, must reset first, then restate both colors
|
||||||
o.writeBgInline(w, bg, attr)
|
if attrChanged {
|
||||||
|
w.WriteByte('0')
|
||||||
|
for _, a := range attrCodes {
|
||||||
|
if styleAttr&a.bit != 0 {
|
||||||
|
w.WriteByte(';')
|
||||||
|
w.WriteByte(a.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fgChanged, bgChanged = true, true
|
||||||
|
}
|
||||||
|
if fgChanged {
|
||||||
|
o.writeColor(w, fg, attr&AttrFg256 != 0, attr&AttrFgDefault != 0, 30, attrChanged)
|
||||||
|
}
|
||||||
|
if bgChanged {
|
||||||
|
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, attrChanged || fgChanged)
|
||||||
|
}
|
||||||
w.WriteByte('m')
|
w.WriteByte('m')
|
||||||
} else if fgChanged {
|
|
||||||
o.writeFgFull(w, fg, attr)
|
|
||||||
} else if bgChanged {
|
|
||||||
o.writeBgFull(w, bg, attr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
o.lastFg = fg
|
o.lastFg = fg
|
||||||
o.lastBg = bg
|
o.lastBg = bg
|
||||||
@@ -341,89 +301,50 @@ func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, at
|
|||||||
o.lastValid = true
|
o.lastValid = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
|
// attrCodes pairs each style bit with its SGR parameter, in emission order
|
||||||
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg color.RGB, attr Attr) {
|
var attrCodes = [...]struct {
|
||||||
w.WriteByte(';')
|
bit Attr
|
||||||
if attr&AttrFg256 != 0 {
|
code byte
|
||||||
// 256-color: 38;5;N
|
}{{AttrBold, '1'}, {AttrDim, '2'}, {AttrItalic, '3'}, {AttrUnderline, '4'}, {AttrBlink, '5'}, {AttrReverse, '7'}}
|
||||||
w.Write([]byte("38;5;"))
|
|
||||||
writeInt(w, int(fg.R))
|
|
||||||
} else if o.colorMode == ColorModeTrueColor {
|
|
||||||
// True color: 38;2;R;G;B
|
|
||||||
w.Write([]byte("38;2;"))
|
|
||||||
writeInt(w, int(fg.R))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(fg.G))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(fg.B))
|
|
||||||
} else {
|
|
||||||
// Fallback 256: 38;5;N
|
|
||||||
w.Write([]byte("38;5;"))
|
|
||||||
writeInt(w, int(RGBTo256(fg)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
|
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
||||||
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg color.RGB, attr Attr) {
|
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
||||||
w.WriteByte(';')
|
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
|
||||||
if attr&AttrBg256 != 0 {
|
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) {
|
||||||
// 256-color: 48;5;N
|
if o.colorMode == ColorModeNone {
|
||||||
w.Write([]byte("48;5;"))
|
return
|
||||||
writeInt(w, int(bg.R))
|
|
||||||
} else if o.colorMode == ColorModeTrueColor {
|
|
||||||
// True color: 48;2;R;G;B
|
|
||||||
w.Write([]byte("48;2;"))
|
|
||||||
writeInt(w, int(bg.R))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(bg.G))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(bg.B))
|
|
||||||
} else {
|
|
||||||
// Fallback 256: 48;5;N
|
|
||||||
w.Write([]byte("48;5;"))
|
|
||||||
writeInt(w, int(RGBTo256(bg)))
|
|
||||||
}
|
}
|
||||||
|
if sep {
|
||||||
|
w.WriteByte(';')
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
// writeFgFull writes complete fg color sequence
|
case deflt:
|
||||||
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg color.RGB, attr Attr) {
|
writeInt(w, base+9)
|
||||||
if attr&AttrFg256 != 0 {
|
case o.colorMode == ColorMode16:
|
||||||
w.Write(csiFg256)
|
i := color.RGBTo16(c)
|
||||||
writeInt(w, int(fg.R))
|
if palette {
|
||||||
w.WriteByte('m')
|
i = paletteTo16(c.R)
|
||||||
} else if o.colorMode == ColorModeTrueColor {
|
|
||||||
w.Write(csiFgRGB)
|
|
||||||
writeInt(w, int(fg.R))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(fg.G))
|
|
||||||
w.WriteByte(';')
|
|
||||||
writeInt(w, int(fg.B))
|
|
||||||
w.WriteByte('m')
|
|
||||||
} else {
|
|
||||||
w.Write(csiFg256)
|
|
||||||
writeInt(w, int(RGBTo256(fg)))
|
|
||||||
w.WriteByte('m')
|
|
||||||
}
|
}
|
||||||
|
if i >= 8 {
|
||||||
|
base += 60 - 8 // bright: 90-97, 100-107
|
||||||
}
|
}
|
||||||
|
writeInt(w, base+int(i))
|
||||||
// writeBgFull writes complete bg color sequence
|
case palette:
|
||||||
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg color.RGB, attr Attr) {
|
writeInt(w, base+8)
|
||||||
if attr&AttrBg256 != 0 {
|
w.Write(sgrPalette)
|
||||||
w.Write(csiBg256)
|
writeInt(w, int(c.R))
|
||||||
writeInt(w, int(bg.R))
|
case o.colorMode == ColorModeTrueColor:
|
||||||
w.WriteByte('m')
|
writeInt(w, base+8)
|
||||||
} else if o.colorMode == ColorModeTrueColor {
|
w.Write(sgrRGB)
|
||||||
w.Write(csiBgRGB)
|
writeInt(w, int(c.R))
|
||||||
writeInt(w, int(bg.R))
|
|
||||||
w.WriteByte(';')
|
w.WriteByte(';')
|
||||||
writeInt(w, int(bg.G))
|
writeInt(w, int(c.G))
|
||||||
w.WriteByte(';')
|
w.WriteByte(';')
|
||||||
writeInt(w, int(bg.B))
|
writeInt(w, int(c.B))
|
||||||
w.WriteByte('m')
|
default:
|
||||||
} else {
|
writeInt(w, base+8)
|
||||||
w.Write(csiBg256)
|
w.Write(sgrPalette)
|
||||||
writeInt(w, int(RGBTo256(bg)))
|
writeInt(w, int(RGBTo256(c)))
|
||||||
w.WriteByte('m')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +357,14 @@ func (o *outputBuffer) forceFullRedraw() {
|
|||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear writes a clear screen with specified background
|
// clear writes a clear screen with specified background, attr's
|
||||||
func (o *outputBuffer) clear(bg color.RGB) {
|
// AttrBgDefault or AttrBg256 saying how to read it
|
||||||
|
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
||||||
w := o.writer
|
w := o.writer
|
||||||
w.Write(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
o.writeBgFull(w, bg, 0)
|
w.Write(csi)
|
||||||
|
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
|
||||||
|
w.WriteByte('m')
|
||||||
w.Write(csiClear)
|
w.Write(csiClear)
|
||||||
|
|
||||||
o.lastValid = false
|
o.lastValid = false
|
||||||
@@ -448,7 +372,7 @@ func (o *outputBuffer) clear(bg color.RGB) {
|
|||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
for i := range o.front {
|
for i := range o.front {
|
||||||
o.front[i] = Cell{Rune: ' ', Bg: bg}
|
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-14
@@ -7,10 +7,11 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attr represents text attributes (bitmask)
|
// Attr represents text attributes and how to read a cell's colors (bitmask)
|
||||||
type Attr uint8
|
type Attr uint16
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AttrNone Attr = 0
|
AttrNone Attr = 0
|
||||||
@@ -22,11 +23,19 @@ const (
|
|||||||
AttrReverse Attr = 1 << 5
|
AttrReverse Attr = 1 << 5
|
||||||
AttrFg256 Attr = 1 << 6 // Fg.R is 256-color palette index
|
AttrFg256 Attr = 1 << 6 // Fg.R is 256-color palette index
|
||||||
AttrBg256 Attr = 1 << 7 // Bg.R is 256-color palette index
|
AttrBg256 Attr = 1 << 7 // Bg.R is 256-color palette index
|
||||||
|
AttrFgDefault Attr = 1 << 8 // Fg is the terminal's own (SGR 39); Fg is ignored
|
||||||
|
AttrBgDefault Attr = 1 << 9 // Bg is the terminal's own (SGR 49); Bg is ignored
|
||||||
)
|
)
|
||||||
|
|
||||||
// AttrStyle masks only the style bits (excludes color mode flags)
|
// AttrStyle masks only the style bits (excludes color mode flags)
|
||||||
const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBlink | AttrReverse
|
const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBlink | AttrReverse
|
||||||
|
|
||||||
|
// AttrFgColor and AttrBgColor mask the bits that say how to read Fg and Bg
|
||||||
|
const (
|
||||||
|
AttrFgColor Attr = AttrFg256 | AttrFgDefault
|
||||||
|
AttrBgColor Attr = AttrBg256 | AttrBgDefault
|
||||||
|
)
|
||||||
|
|
||||||
// Cell represents a single terminal cell
|
// Cell represents a single terminal cell
|
||||||
type Cell struct {
|
type Cell struct {
|
||||||
Rune rune
|
Rune rune
|
||||||
@@ -77,6 +86,10 @@ type Terminal interface {
|
|||||||
// SetMouseMode enables/disables mouse event reporting
|
// SetMouseMode enables/disables mouse event reporting
|
||||||
// Modes can be combined: MouseModeClick | MouseModeDrag
|
// Modes can be combined: MouseModeClick | MouseModeDrag
|
||||||
SetMouseMode(mode MouseMode) error
|
SetMouseMode(mode MouseMode) error
|
||||||
|
|
||||||
|
// SetPasteMode turns bracketed paste on or off: on, a paste arrives as
|
||||||
|
// one EventPaste rather than as keys, so its newlines don't press Enter
|
||||||
|
SetPasteMode(on bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResizeEvent represents a terminal resize
|
// ResizeEvent represents a terminal resize
|
||||||
@@ -100,18 +113,20 @@ type termImpl struct {
|
|||||||
initialized bool
|
initialized bool
|
||||||
finalized bool
|
finalized bool
|
||||||
mouseMode MouseMode
|
mouseMode MouseMode
|
||||||
|
paste bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Terminal instance
|
// New creates a new Terminal instance. Without a mode it uses
|
||||||
|
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
||||||
|
// overrides both, as a --color=always or never would
|
||||||
func New(colorMode ...ColorMode) Terminal {
|
func New(colorMode ...ColorMode) Terminal {
|
||||||
b := newBackend()
|
b := newBackend()
|
||||||
|
|
||||||
var c ColorMode
|
c := DetectColorMode()
|
||||||
if len(colorMode) == 0 {
|
if len(colorMode) > 0 {
|
||||||
// Use backend detection or fallback env detection for unix
|
|
||||||
c = DetectColorMode()
|
|
||||||
} else {
|
|
||||||
c = colorMode[0]
|
c = colorMode[0]
|
||||||
|
} else if os.Getenv("NO_COLOR") != "" {
|
||||||
|
c = ColorModeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &termImpl{
|
t := &termImpl{
|
||||||
@@ -180,7 +195,7 @@ func (t *termImpl) Init() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear screen
|
// Clear screen
|
||||||
t.output.clear(color.Black)
|
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||||
|
|
||||||
// Start input reader
|
// Start input reader
|
||||||
t.input.start()
|
t.input.start()
|
||||||
@@ -208,6 +223,10 @@ func (t *termImpl) Fini() {
|
|||||||
w.Flush()
|
w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.paste {
|
||||||
|
t.writeRaw(csiPasteOff)
|
||||||
|
}
|
||||||
|
|
||||||
// Stop handlers
|
// Stop handlers
|
||||||
if t.input != nil {
|
if t.input != nil {
|
||||||
t.input.stop()
|
t.input.stop()
|
||||||
@@ -274,7 +293,7 @@ func (t *termImpl) Clear(bg color.RGB) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.output.clear(bg)
|
t.output.clear(bg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCursorVisible shows/hides cursor
|
// SetCursorVisible shows/hides cursor
|
||||||
@@ -343,7 +362,7 @@ 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.output.clear(color.Black)
|
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||||
t.output.forceFullRedraw()
|
t.output.forceFullRedraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,21 +450,47 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPasteMode turns bracketed paste on or off
|
||||||
|
func (t *termImpl) SetPasteMode(on bool) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized || t.paste == on {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.paste = on
|
||||||
|
t.input.pasteMode.Store(on)
|
||||||
|
w := t.output.writer
|
||||||
|
if on {
|
||||||
|
w.Write(csiPasteOn)
|
||||||
|
} else {
|
||||||
|
w.Write(csiPasteOff)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
// writeRaw writes raw bytes to output
|
// writeRaw writes raw bytes to output
|
||||||
func (t *termImpl) writeRaw(data []byte) {
|
func (t *termImpl) writeRaw(data []byte) {
|
||||||
t.backend.Write(data)
|
t.backend.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmergencyReset attempts to restore terminal to sane state
|
// EmergencyReset attempts to restore terminal to sane state
|
||||||
// Call this from panic recovery if Fini() cannot be called normally
|
// Call this from panic recovery if Fini() cannot be called normally. Given a
|
||||||
// EmergencyReset attempts to restore terminal to sane state
|
// redirected stdout, it writes to the controlling terminal instead.
|
||||||
// Call this from panic recovery if Fini() cannot be called normally
|
|
||||||
func EmergencyReset(w io.Writer) {
|
func EmergencyReset(w io.Writer) {
|
||||||
|
if f, ok := w.(*os.File); ok && !term.IsTerminal(int(f.Fd())) {
|
||||||
|
if tty := controllingTTY(); tty != nil {
|
||||||
|
defer tty.Close()
|
||||||
|
w = tty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Disable mouse tracking
|
// Disable mouse tracking
|
||||||
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.Write(csiPasteOff)
|
||||||
|
|
||||||
// Write sequences to provided writer
|
// Write sequences to provided writer
|
||||||
w.Write(csiCursorShow)
|
w.Write(csiCursorShow)
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBackend records output and replays input chunks; an empty chunk, or
|
||||||
|
// none left, is a poll timing out, and pause blocks a read as wasm's does
|
||||||
|
type fakeBackend struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
out bytes.Buffer
|
||||||
|
chunks []string
|
||||||
|
pause time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackend) Init() error { return nil }
|
||||||
|
func (f *fakeBackend) Fini() {}
|
||||||
|
func (f *fakeBackend) Size() (int, int) { return 1, 1 }
|
||||||
|
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
||||||
|
|
||||||
|
func (f *fakeBackend) Write(p []byte) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.out.Write(p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackend) Read(stop <-chan struct{}) ([]byte, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if len(f.chunks) > 0 {
|
||||||
|
c := f.chunks[0]
|
||||||
|
f.chunks = f.chunks[1:]
|
||||||
|
f.mu.Unlock()
|
||||||
|
if c == "" {
|
||||||
|
time.Sleep(5 * time.Millisecond) // a poll timing out
|
||||||
|
}
|
||||||
|
time.Sleep(f.pause)
|
||||||
|
return []byte(c), nil
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
case <-time.After(5 * time.Millisecond):
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackend) written() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each color mode writes each kind of color as such a terminal reads it:
|
||||||
|
// 16 colors as SGR 30-37/90-97 and 40-47/100-107, restated from a reset
|
||||||
|
// since a console's bright colors set bold, a palette index by its shade,
|
||||||
|
// the terminal's own as 39/49 in every mode, no color at all in
|
||||||
|
// ColorModeNone, and elsewhere only what changed from one cell to the next
|
||||||
|
func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
|
||||||
|
red, yellow := color.RGB{R: 205}, color.RGB{R: 255, G: 255}
|
||||||
|
for _, c := range []struct {
|
||||||
|
mode ColorMode
|
||||||
|
cells []Cell
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ColorMode16, []Cell{{Rune: 'x', Fg: red, Bg: yellow}}, "\x1b[0;31;103mx"},
|
||||||
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Bg: color.RGB{R: 196}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;91;101mx"},
|
||||||
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 244}, Attrs: AttrFg256 | AttrBgDefault | AttrBold}}, "\x1b[0;1;90;49mx"},
|
||||||
|
{ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Attrs: AttrFg256}}, "\x1b[0;91;40mx\x1b[0;31;40my"},
|
||||||
|
{ColorMode256, []Cell{{Rune: 'x', Attrs: AttrFgDefault | AttrBgDefault}}, "\x1b[0;39;49mx"},
|
||||||
|
{ColorMode256, []Cell{{Rune: 'x', Fg: color.RGB{R: 1}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Bg: color.RGB{R: 2}, Attrs: AttrFg256 | AttrBg256},
|
||||||
|
{Rune: 'z', Fg: color.RGB{R: 3}, Bg: color.RGB{R: 4}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;38;5;1;48;5;16mx\x1b[48;5;2my\x1b[38;5;3;48;5;4mz"},
|
||||||
|
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
||||||
|
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
||||||
|
} {
|
||||||
|
f := &fakeBackend{}
|
||||||
|
o := newOutputBuffer(f, c.mode)
|
||||||
|
o.flush(c.cells, len(c.cells), 1)
|
||||||
|
if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
||||||
|
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cell is drawn as one glyph: a control character, C0 or C1, that a
|
||||||
|
// pasted value carried in, or no rune at all, is drawn as a space, never
|
||||||
|
// sent to the terminal
|
||||||
|
func TestCellsSendNoControlCharacters(t *testing.T) {
|
||||||
|
f := &fakeBackend{}
|
||||||
|
o := newOutputBuffer(f, ColorMode256)
|
||||||
|
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1)
|
||||||
|
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
||||||
|
t.Fatalf("%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paste arrives as one event, whatever reads split it into: an escape
|
||||||
|
// inside it is text even when a poll times out on it, and a paste the
|
||||||
|
// terminal never ends ends when input pauses, whether reads time out or
|
||||||
|
// block. With paste mode off, a paste marker is no paste.
|
||||||
|
func TestPasteArrivesWhole(t *testing.T) {
|
||||||
|
defer func(d time.Duration) { pasteIdle = d }(pasteIdle)
|
||||||
|
paste := func(s string) Event { return Event{Type: EventPaste, Text: s} }
|
||||||
|
expectEvents(t, map[string]input{
|
||||||
|
"split": {chunks: []string{"a\x1b[20", "0~", "\x1b", "", "[A one\ntwo\x1b[2", "01~b"}, want: []Event{key('a'), paste("\x1b[A one\ntwo"), key('b')}},
|
||||||
|
"unended": {idle: 20 * time.Millisecond, chunks: []string{"\x1b[200~ab\x1b[2", "", "", "", "", "", "", "", "", "", "q"}, want: []Event{paste("ab\x1b[2"), key('q')}},
|
||||||
|
"blocking": {idle: 20 * time.Millisecond, pause: 40 * time.Millisecond, chunks: []string{"\x1b[200~ab", "q"}, want: []Event{paste("ab"), key('q')}},
|
||||||
|
"mode off": {off: true, chunks: []string{"\x1b[200~q"}, want: []Event{key('q')}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC ESC is Escape before a paste, in one read or split after the second
|
||||||
|
// ESC; before another sequence it is Alt and that key (rxvt's Alt+arrows);
|
||||||
|
// alone, once input pauses, Alt+Escape
|
||||||
|
func TestDoubleEscape(t *testing.T) {
|
||||||
|
esc := Event{Type: EventKey, Key: KeyEscape}
|
||||||
|
altEsc := Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
|
||||||
|
pasted := Event{Type: EventPaste, Text: "hi"}
|
||||||
|
expectEvents(t, map[string]input{
|
||||||
|
"one read": {chunks: []string{"\x1b\x1b[200~hi\x1b[201~"}, want: []Event{esc, pasted}},
|
||||||
|
"split": {chunks: []string{"\x1b\x1b", "[200~hi\x1b[201~"}, want: []Event{esc, pasted}},
|
||||||
|
"alt+up": {chunks: []string{"\x1b\x1b[A"}, want: []Event{{Type: EventKey, Key: KeyUp, Modifiers: ModAlt}}},
|
||||||
|
"alone": {chunks: []string{"\x1b\x1b"}, want: []Event{altEsc}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} }
|
||||||
|
|
||||||
|
// input is what a reader is given, in chunks, and the events it must send
|
||||||
|
type input struct {
|
||||||
|
off bool
|
||||||
|
idle, pause time.Duration
|
||||||
|
chunks []string
|
||||||
|
want []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectEvents(t *testing.T, cases map[string]input) {
|
||||||
|
t.Helper()
|
||||||
|
for name, c := range cases {
|
||||||
|
pasteIdle = cmp.Or(c.idle, time.Second)
|
||||||
|
r := newInputReader(&fakeBackend{chunks: c.chunks, pause: c.pause})
|
||||||
|
r.pasteMode.Store(!c.off)
|
||||||
|
r.start()
|
||||||
|
for _, w := range c.want {
|
||||||
|
select {
|
||||||
|
case ev := <-r.events():
|
||||||
|
if ev != w {
|
||||||
|
t.Errorf("%s: got %+v, want %+v", name, ev, w)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Errorf("%s: no event, want %+v", name, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste mode set on the terminal is what its reader parses by
|
||||||
|
func TestPasteModeReachesTheReader(t *testing.T) {
|
||||||
|
f := &fakeBackend{}
|
||||||
|
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||||
|
term.output = newOutputBuffer(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer term.Fini()
|
||||||
|
term.SetPasteMode(true)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.chunks = append(f.chunks, "\x1b[200~x\x1b[201~")
|
||||||
|
f.mu.Unlock()
|
||||||
|
got := make(chan Event, 1)
|
||||||
|
go func() { got <- term.PollEvent() }()
|
||||||
|
select {
|
||||||
|
case ev := <-got:
|
||||||
|
if ev.Type != EventPaste || ev.Text != "x" {
|
||||||
|
t.Fatalf("%+v", ev)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("no event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste mode ends with the session, and Init clears to the terminal's own
|
||||||
|
// background rather than painting it black
|
||||||
|
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||||
|
f := &fakeBackend{}
|
||||||
|
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||||
|
term.output = newOutputBuffer(f, ColorModeTrueColor)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := term.SetPasteMode(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
term.Fini()
|
||||||
|
got := f.written()
|
||||||
|
if !strings.Contains(got, "\x1b[0m\x1b[49m\x1b[2J") || !strings.Contains(got, "\x1b[?2004h") ||
|
||||||
|
!strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-11
@@ -39,15 +39,15 @@ list.Selection = 0
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
root := tui.NewRegion(cells, w, 0, 0, w, h)
|
root := tui.NewRegion(cells, w, 0, 0, w, h)
|
||||||
root.Fill(terminal.Gunmetal)
|
root.Fill(color.Gunmetal)
|
||||||
|
|
||||||
root.Box(tui.LineRounded, terminal.SteelBlue)
|
root.Box(tui.LineRounded, color.SteelBlue)
|
||||||
content := root.Inset(1)
|
content := root.Inset(1)
|
||||||
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
|
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
|
||||||
CursorBg: terminal.DarkSlate,
|
CursorBg: color.DarkSlate,
|
||||||
})
|
})
|
||||||
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
|
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
|
||||||
terminal.IronGray)
|
color.IronGray)
|
||||||
|
|
||||||
term.Flush(cells, w, h)
|
term.Flush(cells, w, h)
|
||||||
|
|
||||||
@@ -114,18 +114,59 @@ rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
|
|||||||
Representative patterns below; remaining widgets follow the same
|
Representative patterns below; remaining widgets follow the same
|
||||||
opts-struct + state-struct shape — read the source for full options.
|
opts-struct + state-struct shape — read the source for full options.
|
||||||
|
|
||||||
|
### Theme and form controls
|
||||||
|
|
||||||
|
A `Theme` is one `Style` per role (`Text`, `Muted`, `Accent`, `Selected`,
|
||||||
|
`Input`, `Cursor`, `Error`, `Border`) plus the `Glyphs` widgets draw. A
|
||||||
|
role's `Attr` may carry color bits, a palette index or the terminal's own
|
||||||
|
color, so an application builds one theme per color tier and every control
|
||||||
|
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
|
||||||
|
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
|
||||||
|
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection always changes a glyph (brackets, a pointer,
|
||||||
|
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437`
|
||||||
|
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets.
|
||||||
|
|
||||||
|
```go
|
||||||
|
th := tui.DefaultTheme
|
||||||
|
root.FillStyle(th.Text) // the panel the controls draw on
|
||||||
|
|
||||||
|
value, rows := form.Field(y, 10, tui.Field{Label: "port", Required: true,
|
||||||
|
Help: "TCP port to listen on", Error: portErr}, focus.Index == 0, th)
|
||||||
|
value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholder
|
||||||
|
y += rows
|
||||||
|
|
||||||
|
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
|
||||||
|
value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json
|
||||||
|
y += rows
|
||||||
|
|
||||||
|
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab.
|
||||||
|
- `Field` draws the focus mark, the label, the required mark, and the error,
|
||||||
|
or the help while focused, and returns the region for the value.
|
||||||
|
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty.
|
||||||
|
`TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
|
||||||
|
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
|
||||||
|
as spaces and control characters dropped.
|
||||||
|
- `Choice` draws a segmented choice; `StepChoice` moves it on Left and Right.
|
||||||
|
- `Toggle` draws an on/off switch.
|
||||||
|
- `Group` draws a foldable group's header, and its summary while folded.
|
||||||
|
- `OptionListState` and `OptionList` filter options by what is typed,
|
||||||
|
matching name or hint regardless of case.
|
||||||
|
|
||||||
### Scrollable list with scrollbar
|
### Scrollable list with scrollbar
|
||||||
|
|
||||||
```go
|
```go
|
||||||
items := make([]tui.ListItem, 0, len(files))
|
items := make([]tui.ListItem, 0, len(files))
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
items = append(items, tui.ListItem{
|
items = append(items, tui.ListItem{
|
||||||
Icon: '▸', IconFg: terminal.Amber,
|
Icon: '▸', IconFg: color.Amber,
|
||||||
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
|
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: terminal.DarkSlate})
|
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSlate})
|
||||||
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, terminal.IronGray)
|
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modal dialog
|
### Modal dialog
|
||||||
@@ -135,11 +176,11 @@ dlg := tui.Center(root, 50, 12)
|
|||||||
content := dlg.Modal(tui.ModalOpts{
|
content := dlg.Modal(tui.ModalOpts{
|
||||||
Title: "Settings",
|
Title: "Settings",
|
||||||
Border: tui.LineDouble,
|
Border: tui.LineDouble,
|
||||||
BorderFg: terminal.SteelBlue,
|
BorderFg: color.SteelBlue,
|
||||||
TitleFg: terminal.White,
|
TitleFg: terminal.White,
|
||||||
Bg: terminal.DarkSlate,
|
Bg: color.DarkSlate,
|
||||||
})
|
})
|
||||||
content.TextBlock(0, 0, body, fg, terminal.DarkSlate, terminal.AttrNone)
|
content.TextBlock(0, 0, body, fg, color.DarkSlate, terminal.AttrNone)
|
||||||
```
|
```
|
||||||
|
|
||||||
`Modal` fills, borders, titles, and returns the content region. `Overlay`
|
`Modal` fills, borders, titles, and returns the content region. `Overlay`
|
||||||
@@ -201,6 +242,8 @@ Pure logic, no rendering — usable independently:
|
|||||||
- Width calculations count runes, not terminal columns; East Asian wide
|
- Width calculations count runes, not terminal columns; East Asian wide
|
||||||
characters and combining marks are not width-aware.
|
characters and combining marks are not width-aware.
|
||||||
- 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.
|
(widget default or row background) — check specific widget docs. A zero
|
||||||
|
background with no Bg color bits is transparent: the cell keeps the
|
||||||
|
background beneath it, bits and all.
|
||||||
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
|
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
|
||||||
application-side geometry from the regions used.
|
application-side geometry from the regions used.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// Choice draws options in a row at (x, y), the chosen one bracketed in the
|
||||||
|
// accent style and the rest muted ("raw [txt] json"), and returns the
|
||||||
|
// columns it drew
|
||||||
|
func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int {
|
||||||
|
start := x
|
||||||
|
for i, o := range options {
|
||||||
|
open, close, style := " ", " ", th.Muted
|
||||||
|
if i == chosen {
|
||||||
|
open, close, style = "[", "]", th.Accent
|
||||||
|
}
|
||||||
|
r.TextStyled(x, y, open+o+close, style)
|
||||||
|
x += RuneLen(o) + 2
|
||||||
|
}
|
||||||
|
return x - start
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepChoice moves a choice among n options on Left or Right (h, l),
|
||||||
|
// stopping at either end, and reports whether it moved
|
||||||
|
func StepChoice(key terminal.Key, r rune, chosen, n int) (int, bool) {
|
||||||
|
switch {
|
||||||
|
case (key == terminal.KeyLeft || key == terminal.KeyRune && r == 'h') && chosen > 0:
|
||||||
|
return chosen - 1, true
|
||||||
|
case (key == terminal.KeyRight || key == terminal.KeyRune && r == 'l') && chosen < n-1:
|
||||||
|
return chosen + 1, true
|
||||||
|
}
|
||||||
|
return chosen, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle draws a switch at (x, y): the On glyph and "on" in the accent
|
||||||
|
// style, or the Off glyph and "off" muted, and returns the columns it drew
|
||||||
|
func (r Region) Toggle(x, y int, on bool, th Theme) int {
|
||||||
|
label, style := string(th.Glyphs.Off)+" off", th.Muted
|
||||||
|
if on {
|
||||||
|
label, style = string(th.Glyphs.On)+" on", th.Accent
|
||||||
|
}
|
||||||
|
r.TextStyled(x, y, label, style)
|
||||||
|
return RuneLen(label)
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Focus is a focus ring over a form's controls, numbered 0 to Len-1
|
||||||
|
type Focus struct {
|
||||||
|
Index, Len int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleKey moves focus on Tab and Shift+Tab, wrapping, and reports whether
|
||||||
|
// it moved
|
||||||
|
func (f *Focus) HandleKey(key terminal.Key, mod terminal.Modifier) bool {
|
||||||
|
if f.Len < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case key == terminal.KeyTab && mod&terminal.ModShift == 0:
|
||||||
|
f.Index = (f.Index + 1) % f.Len
|
||||||
|
case key == terminal.KeyTab || key == terminal.KeyBacktab:
|
||||||
|
f.Index = (f.Index - 1 + f.Len) % f.Len
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field labels one form row
|
||||||
|
type Field struct {
|
||||||
|
Label string
|
||||||
|
Required bool
|
||||||
|
Help string // shown under the field while it has focus
|
||||||
|
Error string // shown under the field, in place of Help
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field draws a form row at y: the focus mark, the label in a column labelW
|
||||||
|
// wide with the required mark, and below, the error, or the help while
|
||||||
|
// focused. It returns the region for the value beside the label, where the
|
||||||
|
// caller draws a TextInput, Choice or Toggle, and the rows it used.
|
||||||
|
func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
|
||||||
|
label := th.Muted
|
||||||
|
if focused {
|
||||||
|
label = th.Text
|
||||||
|
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
|
||||||
|
}
|
||||||
|
name := f.Label
|
||||||
|
if f.Required {
|
||||||
|
name = Truncate(name, labelW-2)
|
||||||
|
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
|
||||||
|
}
|
||||||
|
r.TextStyled(2, y, Truncate(name, labelW), label)
|
||||||
|
|
||||||
|
x := 2 + labelW + 1
|
||||||
|
note, style := f.Error, th.Error
|
||||||
|
if note == "" && focused {
|
||||||
|
note, style = f.Help, th.Muted
|
||||||
|
}
|
||||||
|
if note == "" {
|
||||||
|
return r.Sub(x, y, r.W-x, 1), 1
|
||||||
|
}
|
||||||
|
r.TextStyled(x, y+1, Truncate(note, r.W-x), style)
|
||||||
|
return r.Sub(x, y, r.W-x, 1), 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextInput draws state's text on r's first row, or, empty, the placeholder
|
||||||
|
// muted: a default shows what an empty value means. Focused, it draws on the
|
||||||
|
// Input background with the cursor, scrolling to keep the cursor in view.
|
||||||
|
func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
|
||||||
|
if r.W < 1 || r.H < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var bg Style
|
||||||
|
if focused {
|
||||||
|
bg = th.Input
|
||||||
|
}
|
||||||
|
state.AdjustScroll(r.W)
|
||||||
|
text, style := []rune(placeholder), th.Muted.On(bg)
|
||||||
|
if len(state.Text) > 0 {
|
||||||
|
text, style = state.Text[state.Scroll:], th.Text.On(bg)
|
||||||
|
}
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
ch := ' '
|
||||||
|
if x < len(text) {
|
||||||
|
ch = text[x]
|
||||||
|
}
|
||||||
|
r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr)
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
x, ch := state.Cursor-state.Scroll, ' '
|
||||||
|
if x < len(text) {
|
||||||
|
ch = text[x]
|
||||||
|
}
|
||||||
|
r.Cell(x, 0, ch, th.Cursor.Fg, th.Cursor.Bg, th.Cursor.Attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group draws a foldable group's header at y: the focus mark, the Folded or
|
||||||
|
// Unfolded glyph and the title, and while folded, a muted summary of what
|
||||||
|
// it holds
|
||||||
|
func (r Region) Group(y int, title, summary string, open, focused bool, th Theme) {
|
||||||
|
mark, style := th.Glyphs.Folded, th.Muted
|
||||||
|
if open {
|
||||||
|
mark = th.Glyphs.Unfolded
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
style = th.Text
|
||||||
|
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
|
||||||
|
}
|
||||||
|
r.TextStyled(2, y, string(mark)+" "+title, style)
|
||||||
|
if !open && summary != "" {
|
||||||
|
x := 2 + 2 + RuneLen(title) + 2
|
||||||
|
r.TextStyled(x, y, Truncate(summary, r.W-x), th.Muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option is one entry of an option list
|
||||||
|
type Option struct {
|
||||||
|
Name string // what is chosen
|
||||||
|
Hint string // a line about it
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionListState filters options by the text typed into it, matching name
|
||||||
|
// or hint regardless of case, and keeps a cursor on the matches
|
||||||
|
type OptionListState struct {
|
||||||
|
Options []Option
|
||||||
|
Filter TextFieldState
|
||||||
|
Cursor int // index into Matches
|
||||||
|
Scroll int // first match shown
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOptionListState creates a list showing every option
|
||||||
|
func NewOptionListState(options []Option) *OptionListState {
|
||||||
|
return &OptionListState{Options: options}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches returns the indices of the options the filter matches, in order,
|
||||||
|
// keeping the cursor on one of them
|
||||||
|
func (s *OptionListState) Matches() []int {
|
||||||
|
filter := strings.ToLower(s.Filter.Value())
|
||||||
|
var m []int
|
||||||
|
for i, o := range s.Options {
|
||||||
|
if strings.Contains(strings.ToLower(o.Name), filter) || strings.Contains(strings.ToLower(o.Hint), filter) {
|
||||||
|
m = append(m, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Cursor = max(0, min(s.Cursor, len(m)-1))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chosen returns the index in Options of the option under the cursor, and
|
||||||
|
// false when nothing matches
|
||||||
|
func (s *OptionListState) Chosen() (int, bool) {
|
||||||
|
m := s.Matches()
|
||||||
|
if len(m) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return m[s.Cursor], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleKey moves the cursor on Up and Down, or edits the filter, and
|
||||||
|
// reports whether anything changed
|
||||||
|
func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
|
||||||
|
filter := s.Filter.Value()
|
||||||
|
switch {
|
||||||
|
case key == terminal.KeyUp:
|
||||||
|
s.Cursor--
|
||||||
|
case key == terminal.KeyDown:
|
||||||
|
s.Cursor++
|
||||||
|
case !s.Filter.HandleKey(key, r, mod):
|
||||||
|
return false
|
||||||
|
case s.Filter.Value() != filter:
|
||||||
|
s.Cursor = 0
|
||||||
|
}
|
||||||
|
s.Matches() // keeps the cursor on a match
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste adds pasted text to the filter
|
||||||
|
func (s *OptionListState) Paste(text string) bool {
|
||||||
|
if !s.Filter.Paste(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.Cursor = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionList draws the filter on the first row and the matches below it,
|
||||||
|
// the cursor row marked with the pointer on the Selected background, each
|
||||||
|
// hint muted in a column after the names
|
||||||
|
func (r Region) OptionList(s *OptionListState, th Theme) {
|
||||||
|
if r.W < 4 || r.H < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.TextStyled(0, 0, "/", th.Muted)
|
||||||
|
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
|
||||||
|
|
||||||
|
m := s.Matches()
|
||||||
|
rows := r.H - 1
|
||||||
|
if len(m) == 0 {
|
||||||
|
r.TextStyled(2, 1, "no match", th.Muted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows))
|
||||||
|
s.Scroll = max(s.Scroll, s.Cursor-rows+1)
|
||||||
|
|
||||||
|
nameW := 0
|
||||||
|
for _, i := range m {
|
||||||
|
nameW = max(nameW, RuneLen(s.Options[i].Name))
|
||||||
|
}
|
||||||
|
nameW = min(nameW, r.W/2)
|
||||||
|
for y := 0; y < rows && s.Scroll+y < len(m); y++ {
|
||||||
|
o := s.Options[m[s.Scroll+y]]
|
||||||
|
row := r.Sub(0, 1+y, r.W, 1)
|
||||||
|
var bg Style
|
||||||
|
if s.Scroll+y == s.Cursor {
|
||||||
|
bg = th.Selected
|
||||||
|
row.FillStyle(th.Text.On(bg))
|
||||||
|
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg))
|
||||||
|
}
|
||||||
|
row.TextStyled(2, 0, Truncate(o.Name, nameW), th.Text.On(bg))
|
||||||
|
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg))
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-7
@@ -66,10 +66,10 @@ func (r Region) Inset(n int) Region {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cell sets a single cell with bounds checking.
|
// Cell sets a single cell with bounds checking.
|
||||||
// A zero-value bg (color.RGB{}) is transparent: the existing cell's
|
// A zero-value bg (color.RGB{}) with no Bg color bits in attr is
|
||||||
// background is preserved. Establish a base background first (Fill, or
|
// transparent: the existing cell's background is preserved, bits and all.
|
||||||
// pre-initialized cell buffer). For a literal black background use
|
// Establish a base background first (Fill, or pre-initialized cell buffer).
|
||||||
// CellOpaque or Fill(color.RGB{}).
|
// For a literal black background use CellOpaque or Fill(color.RGB{}).
|
||||||
func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
|
func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
|
||||||
if x < 0 || x >= r.W || y < 0 || y >= r.H {
|
if x < 0 || x >= r.W || y < 0 || y >= r.H {
|
||||||
return
|
return
|
||||||
@@ -85,8 +85,9 @@ func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
|
|||||||
idx := absY*r.TotalW + absX
|
idx := absY*r.TotalW + absX
|
||||||
// Single bounds check for the backing slice
|
// Single bounds check for the backing slice
|
||||||
if uint(idx) < uint(len(r.Cells)) {
|
if uint(idx) < uint(len(r.Cells)) {
|
||||||
if bg == (color.RGB{}) {
|
if bg == (color.RGB{}) && attr&terminal.AttrBgColor == 0 {
|
||||||
bg = r.Cells[idx].Bg // transparent: inherit current background
|
// transparent: inherit current background
|
||||||
|
bg, attr = r.Cells[idx].Bg, attr|r.Cells[idx].Attrs&terminal.AttrBgColor
|
||||||
}
|
}
|
||||||
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
|
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
|
||||||
}
|
}
|
||||||
@@ -115,9 +116,15 @@ func (r Region) CellOpaque(x, y int, ch rune, fg, bg color.RGB, attr terminal.At
|
|||||||
// Opaque by definition: Fill establishes the base layer, so bg is written
|
// Opaque by definition: Fill establishes the base layer, so bg is written
|
||||||
// verbatim (including zero/black) rather than treated as transparent.
|
// verbatim (including zero/black) rather than treated as transparent.
|
||||||
func (r Region) Fill(bg color.RGB) {
|
func (r Region) Fill(bg color.RGB) {
|
||||||
|
r.FillStyle(Style{Bg: bg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillStyle fills the region with spaces in s, opaque as Fill is; a theme's
|
||||||
|
// Text fills the panel its widgets draw on
|
||||||
|
func (r Region) FillStyle(s Style) {
|
||||||
for y := 0; y < r.H; y++ {
|
for y := 0; y < r.H; y++ {
|
||||||
for x := 0; x < r.W; x++ {
|
for x := 0; x < r.W; x++ {
|
||||||
r.CellOpaque(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
|
r.CellOpaque(x, y, ' ', s.Fg, s.Bg, s.Attr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -5,7 +5,9 @@ import (
|
|||||||
"github.com/lixenwraith/terminal"
|
"github.com/lixenwraith/terminal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style bundles foreground, background, and attributes for text rendering
|
// Style bundles foreground, background, and attributes for text rendering.
|
||||||
|
// Attr's color bits say how to read Fg and Bg: terminal.AttrFg256 a palette
|
||||||
|
// index in R, terminal.AttrFgDefault the terminal's own color.
|
||||||
type Style struct {
|
type Style struct {
|
||||||
Fg color.RGB
|
Fg color.RGB
|
||||||
Bg color.RGB
|
Bg color.RGB
|
||||||
@@ -22,3 +24,12 @@ func (s Style) IsZero() bool {
|
|||||||
return s.Fg == (color.RGB{}) && s.Bg == (color.RGB{}) && s.Attr == terminal.AttrNone
|
return s.Fg == (color.RGB{}) && s.Bg == (color.RGB{}) && s.Attr == terminal.AttrNone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
func (s Style) On(bg Style) Style {
|
||||||
|
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
|
||||||
|
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
|
||||||
|
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+47
-1
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/lixenwraith/terminal"
|
"github.com/lixenwraith/terminal"
|
||||||
@@ -16,8 +17,18 @@ type TextFieldState struct {
|
|||||||
Text []rune
|
Text []rune
|
||||||
Cursor int // Positions before which cursor sits (0 = before first char)
|
Cursor int // Positions before which cursor sits (0 = before first char)
|
||||||
Scroll int // First visible rune index
|
Scroll int // First visible rune index
|
||||||
|
|
||||||
|
// Accept, when set, filters the runes typed or pasted: AcceptInteger
|
||||||
|
// and AcceptNumber make a typed field
|
||||||
|
Accept func(rune) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AcceptInteger takes the runes of a decimal integer
|
||||||
|
func AcceptInteger(r rune) bool { return r >= '0' && r <= '9' || r == '-' }
|
||||||
|
|
||||||
|
// AcceptNumber takes the runes of a decimal number, exponent included
|
||||||
|
func AcceptNumber(r rune) bool { return AcceptInteger(r) || strings.ContainsRune(".eE+", r) }
|
||||||
|
|
||||||
// NewTextFieldState creates initialized text field state
|
// NewTextFieldState creates initialized text field state
|
||||||
func NewTextFieldState(initial string) *TextFieldState {
|
func NewTextFieldState(initial string) *TextFieldState {
|
||||||
runes := []rune(initial)
|
runes := []rune(initial)
|
||||||
@@ -51,23 +62,48 @@ func (t *TextFieldState) Clear() {
|
|||||||
|
|
||||||
// --- Character insertion ---
|
// --- Character insertion ---
|
||||||
|
|
||||||
|
// clampCursor keeps Cursor within Text, which a caller may have replaced
|
||||||
|
func (t *TextFieldState) clampCursor() {
|
||||||
|
t.Cursor = max(0, min(t.Cursor, len(t.Text)))
|
||||||
|
}
|
||||||
|
|
||||||
// Insert adds rune at cursor position
|
// Insert adds rune at cursor position
|
||||||
func (t *TextFieldState) Insert(r rune) {
|
func (t *TextFieldState) Insert(r rune) {
|
||||||
|
t.clampCursor()
|
||||||
t.Text = append(t.Text[:t.Cursor], append([]rune{r}, t.Text[t.Cursor:]...)...)
|
t.Text = append(t.Text[:t.Cursor], append([]rune{r}, t.Text[t.Cursor:]...)...)
|
||||||
t.Cursor++
|
t.Cursor++
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertString adds string at cursor position
|
// InsertString adds string at cursor position
|
||||||
func (t *TextFieldState) InsertString(s string) {
|
func (t *TextFieldState) InsertString(s string) {
|
||||||
|
t.clampCursor()
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
|
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
|
||||||
t.Cursor += len(runes)
|
t.Cursor += len(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paste inserts pasted text at the cursor: a line break or tab becomes a
|
||||||
|
// space, and other control characters and runes Accept refuses are dropped.
|
||||||
|
// It reports whether anything was inserted.
|
||||||
|
func (t *TextFieldState) Paste(s string) bool {
|
||||||
|
var runes []rune
|
||||||
|
for _, r := range strings.ReplaceAll(s, "\r\n", "\n") {
|
||||||
|
if r == '\n' || r == '\r' || r == '\t' {
|
||||||
|
r = ' '
|
||||||
|
}
|
||||||
|
if !unicode.IsControl(r) && (t.Accept == nil || t.Accept(r)) {
|
||||||
|
runes = append(runes, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.InsertString(string(runes))
|
||||||
|
return len(runes) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// --- Character deletion ---
|
// --- Character deletion ---
|
||||||
|
|
||||||
// DeleteBackward removes rune before cursor
|
// DeleteBackward removes rune before cursor
|
||||||
func (t *TextFieldState) DeleteBackward() bool {
|
func (t *TextFieldState) DeleteBackward() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor > 0 {
|
if t.Cursor > 0 {
|
||||||
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
|
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
|
||||||
t.Cursor--
|
t.Cursor--
|
||||||
@@ -78,6 +114,7 @@ func (t *TextFieldState) DeleteBackward() bool {
|
|||||||
|
|
||||||
// DeleteForward removes rune at cursor
|
// DeleteForward removes rune at cursor
|
||||||
func (t *TextFieldState) DeleteForward() bool {
|
func (t *TextFieldState) DeleteForward() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor < len(t.Text) {
|
if t.Cursor < len(t.Text) {
|
||||||
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
|
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
|
||||||
return true
|
return true
|
||||||
@@ -89,6 +126,7 @@ func (t *TextFieldState) DeleteForward() bool {
|
|||||||
|
|
||||||
// DeleteWordBackward removes word before cursor
|
// DeleteWordBackward removes word before cursor
|
||||||
func (t *TextFieldState) DeleteWordBackward() bool {
|
func (t *TextFieldState) DeleteWordBackward() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor == 0 {
|
if t.Cursor == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -112,6 +150,7 @@ func (t *TextFieldState) DeleteWordBackward() bool {
|
|||||||
|
|
||||||
// DeleteWordForward removes word after cursor
|
// DeleteWordForward removes word after cursor
|
||||||
func (t *TextFieldState) DeleteWordForward() bool {
|
func (t *TextFieldState) DeleteWordForward() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor >= len(t.Text) {
|
if t.Cursor >= len(t.Text) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -133,6 +172,7 @@ func (t *TextFieldState) DeleteWordForward() bool {
|
|||||||
|
|
||||||
// DeleteToEnd removes from cursor to end
|
// DeleteToEnd removes from cursor to end
|
||||||
func (t *TextFieldState) DeleteToEnd() bool {
|
func (t *TextFieldState) DeleteToEnd() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor < len(t.Text) {
|
if t.Cursor < len(t.Text) {
|
||||||
t.Text = t.Text[:t.Cursor]
|
t.Text = t.Text[:t.Cursor]
|
||||||
return true
|
return true
|
||||||
@@ -142,6 +182,7 @@ func (t *TextFieldState) DeleteToEnd() bool {
|
|||||||
|
|
||||||
// DeleteToStart removes from start to cursor
|
// DeleteToStart removes from start to cursor
|
||||||
func (t *TextFieldState) DeleteToStart() bool {
|
func (t *TextFieldState) DeleteToStart() bool {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor > 0 {
|
if t.Cursor > 0 {
|
||||||
t.Text = t.Text[t.Cursor:]
|
t.Text = t.Text[t.Cursor:]
|
||||||
t.Cursor = 0
|
t.Cursor = 0
|
||||||
@@ -155,6 +196,7 @@ func (t *TextFieldState) DeleteToStart() bool {
|
|||||||
|
|
||||||
// MoveLeft moves cursor left
|
// MoveLeft moves cursor left
|
||||||
func (t *TextFieldState) MoveLeft() {
|
func (t *TextFieldState) MoveLeft() {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor > 0 {
|
if t.Cursor > 0 {
|
||||||
t.Cursor--
|
t.Cursor--
|
||||||
}
|
}
|
||||||
@@ -162,6 +204,7 @@ func (t *TextFieldState) MoveLeft() {
|
|||||||
|
|
||||||
// MoveRight moves cursor right
|
// MoveRight moves cursor right
|
||||||
func (t *TextFieldState) MoveRight() {
|
func (t *TextFieldState) MoveRight() {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor < len(t.Text) {
|
if t.Cursor < len(t.Text) {
|
||||||
t.Cursor++
|
t.Cursor++
|
||||||
}
|
}
|
||||||
@@ -171,6 +214,7 @@ func (t *TextFieldState) MoveRight() {
|
|||||||
|
|
||||||
// MoveWordLeft moves cursor to previous word boundary
|
// MoveWordLeft moves cursor to previous word boundary
|
||||||
func (t *TextFieldState) MoveWordLeft() {
|
func (t *TextFieldState) MoveWordLeft() {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor == 0 {
|
if t.Cursor == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,6 +230,7 @@ func (t *TextFieldState) MoveWordLeft() {
|
|||||||
|
|
||||||
// MoveWordRight moves cursor to next word boundary
|
// MoveWordRight moves cursor to next word boundary
|
||||||
func (t *TextFieldState) MoveWordRight() {
|
func (t *TextFieldState) MoveWordRight() {
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor >= len(t.Text) {
|
if t.Cursor >= len(t.Text) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,6 +263,7 @@ func (t *TextFieldState) AdjustScroll(viewportW int) {
|
|||||||
if viewportW <= 0 {
|
if viewportW <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
t.clampCursor()
|
||||||
if t.Cursor < t.Scroll {
|
if t.Cursor < t.Scroll {
|
||||||
t.Scroll = t.Cursor
|
t.Scroll = t.Cursor
|
||||||
}
|
}
|
||||||
@@ -271,7 +317,7 @@ func (t *TextFieldState) HandleKey(key terminal.Key, r rune, mod terminal.Modifi
|
|||||||
case terminal.KeyCtrlW:
|
case terminal.KeyCtrlW:
|
||||||
return t.DeleteWordBackward()
|
return t.DeleteWordBackward()
|
||||||
case terminal.KeyRune:
|
case terminal.KeyRune:
|
||||||
if r >= 32 { // Printable
|
if !unicode.IsControl(r) && (t.Accept == nil || t.Accept(r)) {
|
||||||
t.Insert(r)
|
t.Insert(r)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-54
@@ -2,65 +2,100 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme defines semantic colors for TUI components
|
// Theme styles the widgets that take one. Each role is a Style whose Attr may
|
||||||
|
// carry color bits (a palette index, the terminal's own color), so one theme
|
||||||
|
// per color tier, mono included, draws every widget alike. A role with a zero
|
||||||
|
// Bg and no Bg bits draws on whatever is beneath it.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
Bg color.RGB
|
Text Style // values and body text; its Bg is the panel's
|
||||||
Fg color.RGB
|
Muted Style // labels, hints, placeholders and defaults, help lines
|
||||||
FocusBg color.RGB
|
Accent Style // marks: focus, the chosen option, a required field
|
||||||
CursorBg color.RGB
|
Selected Style // the cursor row of a list
|
||||||
|
Input Style // the background of the field being edited
|
||||||
Selected color.RGB
|
Cursor Style // the text cursor
|
||||||
Unselected color.RGB
|
Error Style // errors
|
||||||
Partial color.RGB
|
Border Style // boxes and rules
|
||||||
Error color.RGB
|
Glyphs Glyphs
|
||||||
Warning color.RGB
|
|
||||||
|
|
||||||
Border color.RGB
|
|
||||||
HeaderBg color.RGB
|
|
||||||
HeaderFg color.RGB
|
|
||||||
StatusFg color.RGB
|
|
||||||
HintFg color.RGB
|
|
||||||
InputBg color.RGB
|
|
||||||
|
|
||||||
DirFg color.RGB
|
|
||||||
FileFg color.RGB
|
|
||||||
SymbolFg color.RGB
|
|
||||||
|
|
||||||
SyntaxComment color.RGB
|
|
||||||
SyntaxString color.RGB
|
|
||||||
SyntaxKeyword color.RGB
|
|
||||||
SyntaxType color.RGB
|
|
||||||
SyntaxNumber color.RGB
|
|
||||||
SyntaxSymbol color.RGB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultTheme provides reasonable defaults
|
// Glyphs are the marks widgets draw; a selection always changes a glyph, so
|
||||||
|
// it reads without color. A text console's font takes GlyphsCP437, a
|
||||||
|
// terminal outside UTF-8 GlyphsASCII.
|
||||||
|
type Glyphs struct {
|
||||||
|
Focus rune // beside the focused control
|
||||||
|
Pointer rune // the cursor row of a list
|
||||||
|
Folded rune // a closed group
|
||||||
|
Unfolded rune // an open group
|
||||||
|
On, Off rune // a toggle
|
||||||
|
Required rune // after a required field's label
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
|
||||||
|
GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'}
|
||||||
|
GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*'}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTheme is a dark theme for true color and 256-color terminals
|
||||||
var DefaultTheme = Theme{
|
var DefaultTheme = Theme{
|
||||||
Bg: color.RGB{R: 20, G: 20, B: 30},
|
Text: Style{Fg: color.RGB{R: 200, G: 200, B: 200}, Bg: color.RGB{R: 20, G: 20, B: 30}},
|
||||||
Fg: color.RGB{R: 200, G: 200, B: 200},
|
Muted: Style{Fg: color.RGB{R: 120, G: 125, B: 140}},
|
||||||
FocusBg: color.RGB{R: 30, G: 35, B: 45},
|
Accent: Style{Fg: color.RGB{R: 80, G: 160, B: 220}, Attr: terminal.AttrBold},
|
||||||
CursorBg: color.RGB{R: 50, G: 50, B: 70},
|
Selected: Style{Bg: color.RGB{R: 40, G: 45, B: 65}},
|
||||||
Selected: color.RGB{R: 80, G: 200, B: 80},
|
Input: Style{Bg: color.RGB{R: 30, G: 30, B: 50}},
|
||||||
Unselected: color.RGB{R: 100, G: 100, B: 100},
|
Cursor: Style{Fg: color.RGB{R: 20, G: 20, B: 30}, Bg: color.RGB{R: 200, G: 200, B: 200}},
|
||||||
Partial: color.RGB{R: 80, G: 160, B: 220},
|
Error: Style{Fg: color.RGB{R: 255, G: 80, B: 80}},
|
||||||
Error: color.RGB{R: 255, G: 80, B: 80},
|
Border: Style{Fg: color.RGB{R: 60, G: 80, B: 100}},
|
||||||
Warning: color.RGB{R: 255, G: 80, B: 80},
|
Glyphs: GlyphsUnicode,
|
||||||
Border: color.RGB{R: 60, G: 80, B: 100},
|
|
||||||
HeaderBg: color.RGB{R: 40, G: 60, B: 90},
|
|
||||||
HeaderFg: color.RGB{R: 255, G: 255, B: 255},
|
|
||||||
StatusFg: color.RGB{R: 140, G: 140, B: 140},
|
|
||||||
HintFg: color.RGB{R: 100, G: 180, B: 200},
|
|
||||||
InputBg: color.RGB{R: 30, G: 30, B: 50},
|
|
||||||
DirFg: color.RGB{R: 130, G: 170, B: 220},
|
|
||||||
FileFg: color.RGB{R: 200, G: 200, B: 200},
|
|
||||||
SymbolFg: color.RGB{R: 180, G: 220, B: 220},
|
|
||||||
SyntaxComment: color.RGB{R: 100, G: 110, B: 120},
|
|
||||||
SyntaxString: color.RGB{R: 180, G: 220, B: 140},
|
|
||||||
SyntaxKeyword: color.RGB{R: 180, G: 140, B: 220},
|
|
||||||
SyntaxType: color.RGB{R: 80, G: 200, B: 200},
|
|
||||||
SyntaxNumber: color.RGB{R: 220, G: 180, B: 120},
|
|
||||||
SyntaxSymbol: color.RGB{R: 220, G: 180, B: 80},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonoTheme draws with attributes alone in the terminal's own colors: the
|
||||||
|
// theme for ColorModeNone, and one that reads on any terminal
|
||||||
|
var MonoTheme = Theme{
|
||||||
|
Text: Style{Attr: own | terminal.AttrBgDefault},
|
||||||
|
Muted: Style{Attr: own | terminal.AttrDim},
|
||||||
|
Accent: Style{Attr: own | terminal.AttrBold},
|
||||||
|
Selected: Style{Attr: own | terminal.AttrReverse},
|
||||||
|
Input: Style{Attr: own | terminal.AttrUnderline},
|
||||||
|
Cursor: Style{Attr: own | terminal.AttrReverse},
|
||||||
|
Error: Style{Attr: own | terminal.AttrBold | terminal.AttrUnderline},
|
||||||
|
Border: Style{Attr: own},
|
||||||
|
Glyphs: GlyphsUnicode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme16 uses the ANSI 16 colors alone, in the terminal's own shades, on
|
||||||
|
// its own background: the theme for ColorMode16, a text console's
|
||||||
|
var Theme16 = Theme{
|
||||||
|
Text: Style{Attr: own | terminal.AttrBgDefault},
|
||||||
|
Muted: ansi(color.ANSIBrightBlack, 0),
|
||||||
|
Accent: ansi(color.ANSICyan, terminal.AttrBold),
|
||||||
|
Selected: Style{Attr: own | terminal.AttrReverse},
|
||||||
|
Input: Style{Attr: own | terminal.AttrUnderline},
|
||||||
|
Cursor: Style{Attr: own | terminal.AttrReverse},
|
||||||
|
Error: ansi(color.ANSIRed, terminal.AttrBold),
|
||||||
|
Border: Style{Attr: own},
|
||||||
|
Glyphs: GlyphsUnicode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeFor returns the theme for a color mode: DefaultTheme with 256 colors
|
||||||
|
// or more, Theme16 with 16, MonoTheme with none
|
||||||
|
func ThemeFor(mode terminal.ColorMode) Theme {
|
||||||
|
switch mode {
|
||||||
|
case terminal.ColorMode16:
|
||||||
|
return Theme16
|
||||||
|
case terminal.ColorModeNone:
|
||||||
|
return MonoTheme
|
||||||
|
}
|
||||||
|
return DefaultTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// own is the terminal's own foreground
|
||||||
|
const own = terminal.AttrFgDefault
|
||||||
|
|
||||||
|
// ansi is a foreground of the ANSI 16, in the terminal's shade of it
|
||||||
|
func ansi(i uint8, attr terminal.Attr) Style {
|
||||||
|
return Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256 | attr}
|
||||||
|
}
|
||||||
+1
-2
@@ -98,7 +98,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
|
|||||||
|
|
||||||
lineFg := opts.LineFg
|
lineFg := opts.LineFg
|
||||||
if lineFg == (color.RGB{}) {
|
if lineFg == (color.RGB{}) {
|
||||||
lineFg = DefaultTheme.Border
|
lineFg = DefaultTheme.Border.Fg
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered := 0
|
rendered := 0
|
||||||
@@ -338,4 +338,3 @@ func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
|
|||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/color"
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canvas returns a w x h region filled with th's panel
|
||||||
|
func canvas(w, h int, th Theme) Region {
|
||||||
|
r := NewRegion(make([]terminal.Cell, w*h), w, 0, 0, w, h)
|
||||||
|
r.FillStyle(th.Text)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// row returns the runes of row y, trailing spaces trimmed
|
||||||
|
func row(r Region, y int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
b.WriteRune(r.Cells[y*r.TotalW+x].Rune)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transparent background keeps the kind of the one beneath it: text drawn,
|
||||||
|
// or a style put, on the terminal's own background or a palette color stays
|
||||||
|
// on it
|
||||||
|
func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
|
||||||
|
for _, panel := range []Style{MonoTheme.Text, {Bg: color.RGB{R: 4}, Attr: terminal.AttrBg256}} {
|
||||||
|
r := canvas(4, 1, Theme{Text: panel})
|
||||||
|
r.Text(0, 0, "ab", color.RGB{R: 1}, color.RGB{}, terminal.AttrBold)
|
||||||
|
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
|
||||||
|
t.Errorf("on %+v: %+v", panel, c)
|
||||||
|
}
|
||||||
|
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr|terminal.AttrReverse {
|
||||||
|
t.Errorf("%+v on reverse: %+v", panel, on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every widget marks its selection with a glyph, so it reads with no color
|
||||||
|
func TestSelectionChangesAGlyph(t *testing.T) {
|
||||||
|
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
|
||||||
|
selectionChangesAGlyph(t, th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectionChangesAGlyph(t *testing.T, th Theme) {
|
||||||
|
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
|
||||||
|
for name, draw := range map[string]func(r Region, on bool){
|
||||||
|
"choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
|
||||||
|
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
|
||||||
|
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
|
||||||
|
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
|
||||||
|
"option list": func(r Region, on bool) {
|
||||||
|
options.Cursor = map[bool]int{true: 1}[on]
|
||||||
|
r.OptionList(options, th)
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
off, on := canvas(20, 3, th), canvas(20, 3, th)
|
||||||
|
draw(off, false)
|
||||||
|
draw(on, true)
|
||||||
|
if row(off, 0)+row(off, 1)+row(off, 2) == row(on, 0)+row(on, 1)+row(on, 2) {
|
||||||
|
t.Errorf("%s: %q either way", name, row(on, 0)+"|"+row(on, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 16-color theme names only ANSI colors and the terminal's own, which a
|
||||||
|
// text console shows as it shows everything else; ThemeFor picks it
|
||||||
|
func TestTheme16NamesOnlyANSIColors(t *testing.T) {
|
||||||
|
ansiOrOwn := func(c color.RGB, attr, palette, own terminal.Attr, transparent bool) bool {
|
||||||
|
return attr&own != 0 || attr&palette != 0 && c.R < 16 || transparent && c == (color.RGB{}) && attr&(palette|own) == 0
|
||||||
|
}
|
||||||
|
for _, s := range []Style{Theme16.Text, Theme16.Muted, Theme16.Accent, Theme16.Selected, Theme16.Input, Theme16.Cursor, Theme16.Error, Theme16.Border} {
|
||||||
|
if !ansiOrOwn(s.Fg, s.Attr, terminal.AttrFg256, terminal.AttrFgDefault, false) || !ansiOrOwn(s.Bg, s.Attr, terminal.AttrBg256, terminal.AttrBgDefault, true) {
|
||||||
|
t.Errorf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ThemeFor(terminal.ColorMode16).Muted != Theme16.Muted || ThemeFor(terminal.ColorModeNone).Muted != MonoTheme.Muted ||
|
||||||
|
ThemeFor(terminal.ColorModeTrueColor).Muted != DefaultTheme.Muted {
|
||||||
|
t.Error("ThemeFor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The option list matches name or hint regardless of case, a changed filter
|
||||||
|
// puts the cursor on the first match, and the cursor stays on the matches
|
||||||
|
func TestOptionListFilters(t *testing.T) {
|
||||||
|
s := NewOptionListState([]Option{{Name: "file", Hint: "tail files"}, {Name: "Console", Hint: "stdin or stdout"},
|
||||||
|
{Name: "tcp", Hint: "listen on TCP"}, {Name: "http", Hint: "Serve HTTP"}})
|
||||||
|
for range 5 {
|
||||||
|
s.HandleKey(terminal.KeyDown, 0, 0)
|
||||||
|
}
|
||||||
|
if i, _ := s.Chosen(); i != 3 {
|
||||||
|
t.Fatalf("past the end: %d", i)
|
||||||
|
}
|
||||||
|
s.Paste("t")
|
||||||
|
if m := s.Matches(); len(m) != 4 || s.Cursor != 0 {
|
||||||
|
t.Fatalf("t: %v cursor %d", m, s.Cursor)
|
||||||
|
}
|
||||||
|
s.HandleKey(terminal.KeyDown, 0, 0)
|
||||||
|
if s.HandleKey(terminal.KeyLeft, 0, 0); s.Cursor != 1 {
|
||||||
|
t.Fatalf("filter cursor moved: list cursor %d", s.Cursor)
|
||||||
|
}
|
||||||
|
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "t" || s.Cursor != 1 {
|
||||||
|
t.Fatalf("nothing before the filter cursor: %q cursor %d", s.Filter.Value(), s.Cursor)
|
||||||
|
}
|
||||||
|
s.HandleKey(terminal.KeyRight, 0, 0)
|
||||||
|
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "" || s.Cursor != 0 {
|
||||||
|
t.Fatalf("erased: %q cursor %d", s.Filter.Value(), s.Cursor)
|
||||||
|
}
|
||||||
|
for filter, want := range map[string]int{"TCP": 2, "cons": 1, "serve h": 3} {
|
||||||
|
s.Filter.SetValue(filter)
|
||||||
|
if m := s.Matches(); len(m) != 1 || m[0] != want {
|
||||||
|
t.Errorf("%s: %v", filter, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Filter.SetValue("udp")
|
||||||
|
if _, ok := s.Chosen(); ok {
|
||||||
|
t.Fatal("udp matched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field holds no control character, which the terminal or a file it is
|
||||||
|
// saved to would act on: pasted line breaks and tabs become spaces, other
|
||||||
|
// controls, pasted or typed, are dropped
|
||||||
|
func TestFieldHoldsNoControls(t *testing.T) {
|
||||||
|
var f TextFieldState
|
||||||
|
f.Paste("a\x1b[31mb\r\nc\td\u009b")
|
||||||
|
f.HandleKey(terminal.KeyRune, 0x9b, 0)
|
||||||
|
f.HandleKey(terminal.KeyRune, 0x85, 0)
|
||||||
|
if got := f.Value(); got != "a[31mb c d" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field whose text the caller replaced draws and takes input without
|
||||||
|
// panicking: the cursor stays within the text
|
||||||
|
func TestTextInputSurvivesReplacedText(t *testing.T) {
|
||||||
|
f := NewTextFieldState("abcdefghij")
|
||||||
|
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
|
||||||
|
f.Text = []rune("ab")
|
||||||
|
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
|
||||||
|
f.Cursor = 9
|
||||||
|
f.Paste("x")
|
||||||
|
type press struct {
|
||||||
|
key terminal.Key
|
||||||
|
mod terminal.Modifier
|
||||||
|
}
|
||||||
|
ctrl := terminal.ModCtrl
|
||||||
|
for cursor, keys := range map[int][]press{
|
||||||
|
9: {{terminal.KeyBackspace, 0}, {terminal.KeyCtrlW, 0}, {terminal.KeyCtrlU, 0}, {terminal.KeyLeft, ctrl}, {terminal.KeyRight, 0}},
|
||||||
|
-1: {{terminal.KeyDelete, 0}, {terminal.KeyDelete, ctrl}, {terminal.KeyRight, ctrl}, {terminal.KeyLeft, 0}, {terminal.KeyCtrlK, 0}},
|
||||||
|
} {
|
||||||
|
for _, k := range keys {
|
||||||
|
f.Text, f.Cursor = []rune("a b"), cursor
|
||||||
|
f.HandleKey(k.key, 0, k.mod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Text, f.Cursor = []rune("a b"), -1
|
||||||
|
f.HandleKey(terminal.KeyCtrlK, 0, 0)
|
||||||
|
if got := f.Value(); got != "" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typed field takes only its runes, typed or pasted
|
||||||
|
func TestTypedFieldTakesItsRunes(t *testing.T) {
|
||||||
|
f := TextFieldState{Accept: AcceptInteger}
|
||||||
|
for _, r := range "4a2" {
|
||||||
|
f.HandleKey(terminal.KeyRune, r, 0)
|
||||||
|
}
|
||||||
|
f.Paste("1 0x8")
|
||||||
|
if got := f.Value(); got != "42108" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab and Shift+Tab move focus around the ring, wrapping either way
|
||||||
|
func TestFocusRingWraps(t *testing.T) {
|
||||||
|
f := Focus{Len: 3}
|
||||||
|
f.HandleKey(terminal.KeyBacktab, terminal.ModShift)
|
||||||
|
back := f.Index
|
||||||
|
f.HandleKey(terminal.KeyTab, terminal.ModShift)
|
||||||
|
shifted := f.Index
|
||||||
|
for range 2 {
|
||||||
|
f.HandleKey(terminal.KeyTab, 0)
|
||||||
|
}
|
||||||
|
if back != 2 || shifted != 1 || f.Index != 0 || f.HandleKey(terminal.KeyEnter, 0) {
|
||||||
|
t.Fatalf("back %d, shifted %d, then %d", back, shifted, f.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field shows its error whether or not it has focus, its help only with
|
||||||
|
// focus, and its value beside the label column
|
||||||
|
func TestFieldShowsErrorOrHelp(t *testing.T) {
|
||||||
|
f := Field{Label: "port", Required: true, Help: "listen port"}
|
||||||
|
r := canvas(30, 2, DefaultTheme)
|
||||||
|
value, rows := r.Field(0, 6, f, false, DefaultTheme)
|
||||||
|
if rows != 1 || value.X != 9 || row(r, 0) != " port *" {
|
||||||
|
t.Fatalf("unfocused: %d rows, value at %d, %q", rows, value.X, row(r, 0))
|
||||||
|
}
|
||||||
|
r = canvas(30, 2, DefaultTheme)
|
||||||
|
if _, rows = r.Field(0, 6, f, true, DefaultTheme); rows != 2 || row(r, 1) != " listen port" {
|
||||||
|
t.Fatalf("focused: %d rows, %q", rows, row(r, 1))
|
||||||
|
}
|
||||||
|
f.Error = "port: want 1-65535"
|
||||||
|
r = canvas(30, 2, DefaultTheme)
|
||||||
|
if _, rows = r.Field(0, 6, f, false, DefaultTheme); rows != 2 || !strings.HasSuffix(row(r, 1), "want 1-65535") {
|
||||||
|
t.Fatalf("error: %d rows, %q", rows, row(r, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty input shows its default, muted, with the cursor over it; typed
|
||||||
|
// text replaces it
|
||||||
|
func TestTextInputShowsTheDefaultUntilTyped(t *testing.T) {
|
||||||
|
var f TextFieldState
|
||||||
|
r := canvas(10, 1, DefaultTheme)
|
||||||
|
r.TextInput(&f, "8080", true, DefaultTheme)
|
||||||
|
if c := r.Cells[1]; row(r, 0) != "8080" || c.Fg != DefaultTheme.Muted.Fg || r.Cells[0].Bg != DefaultTheme.Cursor.Bg {
|
||||||
|
t.Fatalf("empty: %q %+v", row(r, 0), c)
|
||||||
|
}
|
||||||
|
f.HandleKey(terminal.KeyRune, '9', 0)
|
||||||
|
r.TextInput(&f, "8080", true, DefaultTheme)
|
||||||
|
if c := r.Cells[0]; row(r, 0) != "9" || c.Fg != DefaultTheme.Text.Fg || c.Bg != DefaultTheme.Input.Bg {
|
||||||
|
t.Fatalf("typed: %q %+v", row(r, 0), c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A choice moves with Left and Right, or h and l, and stops at either end
|
||||||
|
func TestChoiceStopsAtTheEnds(t *testing.T) {
|
||||||
|
i, _ := StepChoice(terminal.KeyRune, 'l', 0, 2)
|
||||||
|
j, moved := StepChoice(terminal.KeyRight, 0, i, 2)
|
||||||
|
k, _ := StepChoice(terminal.KeyLeft, 0, j, 2)
|
||||||
|
if i != 1 || j != 1 || moved || k != 0 {
|
||||||
|
t.Fatalf("%d %d %v %d", i, j, moved, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folded group says what it holds; an open one shows it instead
|
||||||
|
func TestGroupSummarisesWhileFolded(t *testing.T) {
|
||||||
|
folded, open := canvas(30, 1, MonoTheme), canvas(30, 1, MonoTheme)
|
||||||
|
folded.Group(0, "tls", "on, pinned", false, false, MonoTheme)
|
||||||
|
open.Group(0, "tls", "on, pinned", true, false, MonoTheme)
|
||||||
|
if row(folded, 0) != " ▸ tls on, pinned" || row(open, 0) != " ▾ tls" {
|
||||||
|
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user