Files
terminal/README.md
T

242 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# terminal
Direct ANSI terminal control for Go with zero-allocation rendering. Built for
sustained 60fps full-screen redraws in cell-based applications (games, dashboards,
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.
Target environments: xterm-compatible terminals on Linux and BSDs, Windows
consoles with VT processing, and browsers via xterm.js (WASM builds).
## Features
- 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
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize, bracketed paste
- Draws on the controlling terminal when stdin or stdout is redirected (Unix)
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT, from `color`
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
- Unix, Windows and WASM backends behind a common interface
## Architecture
Terminal (interface)
└── termImpl
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
├── inputReader escape sequence parser, event channel
└── Backend (interface)
├── 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
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing.
Platform specifics are isolated in the `Backend` implementations.
### Rendering pipeline
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
passes it to `Flush`. The output buffer diffs against the previously flushed frame:
- Rows are scanned with early termination (trailing unchanged cells skipped).
- Cursor moves are emitted only when the write position is non-contiguous.
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
are suppressed.
- If the backend size changed between buffer preparation and `Flush`, the frame
is dropped to prevent resize-race corruption. The next frame (built at the new
size) renders normally.
`Sync()` clears the screen and invalidates the front buffer, forcing a full
redraw — required after any external process writes to the terminal.
Auto-wrap is disabled during the session, making the bottom-right cell writable
without scroll side effects.
## Quick start
```go
package main
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
func main() {
term := terminal.New() // color mode auto-detected
if err := term.Init(); err != nil {
panic(err)
}
defer term.Fini()
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
for {
// Build frame
for i := range cells {
cells[i] = terminal.Cell{Rune: ' ', Bg: color.Gunmetal}
}
msg := "hello"
for i, ch := range msg {
// len(msg) to utf8.RuneCountIdString(msg) for non-ASCII
cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{
Rune: ch, Fg: color.Amber, Bg: color.Gunmetal,
Attrs: terminal.AttrBold,
}
}
term.Flush(cells, w, h)
// Handle input
ev := term.PollEvent()
switch ev.Type {
case terminal.EventKey:
if ev.Key == terminal.KeyEscape || ev.Rune == 'q' {
return
}
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
}
}
}
```
## Cells and attributes
```go
type Cell struct {
Rune rune
Fg color.RGB
Bg color.RGB
Attrs Attr
}
```
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
`AttrBlink`, `AttrReverse`.
`Attr` is 16 bits wide; the bits above the styles say how to read a cell's
colors:
- With `AttrFg256` / `AttrBg256` set, `Fg.R` / `Bg.R` holds an xterm-256
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
### Modes
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
`38;5;N` after mapping; `ColorMode16` emits `30-37`/`90-97` and
`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
`RGBTo256` maps any `RGB` to the nearest xterm-256 index using perceptually
weighted Redmean distance. The full mapping is pre-computed at init into a
6-bit-quantized LUT (256KB, L2-resident), making per-cell conversion a single
array load. Applications targeting 256-color terminals can render in RGB
throughout; degradation is automatic.
Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`,
`P256SteelBlue`, ...) and named true color values (`Amber`, `Gunmetal`,
`Obsidian`, ...), all in `github.com/lixenwraith/color`.
### Blending
`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
for per-cell use at frame rate.
| Function | Operation | Character |
|---|---|---|
| `Blend(dst, src, alpha)` | linear interpolation | standard transparency |
| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips |
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips |
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure |
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow |
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight |
| `Scale(c, factor)` | channel multiply | dim/brighten |
| `Grayscale(c)` | Rec. 601 luma | desaturation |
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1
short-circuits without float math. All float→channel conversions round half-up,
so gradients from `Blend`, `Scale`, `Lerp`, and `SoftLight` are bit-consistent.
```go
bg := color.Gunmetal
glow := color.RGB{R: 255, G: 160, B: 40}
cell.Bg = color.Screen(bg, color.Scale(glow, pulse), 1.0) // pulsing glow
cell.Bg = color.Blend(cell.Bg, color.Black, 0.6) // dim overlay backdrop
cell.Fg = color.SoftLight(cell.Fg, tint, 0.4) // subtle recolor
bar := cold.Lerp(hot, load) // value-mapped gradient
```
Integer paths (`Add`, `Screen`, `Overlay`) use a `(x + (x>>8) + 1) >> 8`
division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
## Input
`PollEvent()` blocks on a unified channel. `Event.Type` values:
- `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...),
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
(`ModShift`, `ModAlt`, `ModCtrl`)
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
`MouseAction` (press/release/move/drag), modifiers. Enable via
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
- `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`
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
ESC ESC is Alt+Escape alone, Escape before a paste, and Alt with the key that follows otherwise (rxvt's Alt+arrows).
Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer.
A cell is drawn as one glyph: a control character in `Rune`, such as a pasted escape, is drawn as a space.
With paste mode off, a paste marker is not a paste. A paste whose end marker never arrives ends once input pauses for a second, so what follows a paste that stalls that long arrives as keys.
On Unix, a redirected stdin or stdout is replaced by `/dev/tty`, so
`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`).
## WASM
WASM builds bridge to xterm.js via JS globals:
goTerminalWrite(Uint8Array) // Go → JS terminal output
goTerminalInput(Uint8Array) // JS → Go keyboard input
goTerminalResize(cols, rows) // JS → Go resize
xterm.cols, xterm.rows // initial size query
## Sub-packages
- [`tui`](tui/README.md) — immediate-mode widget toolkit (regions, layout,
widgets, scroll/editor state) built on the cell buffer model.