v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
# 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 only on the standard library and `golang.org/x/sys` (Unix builds).
|
||||
|
||||
The package bypasses terminfo/termcap entirely and emits ANSI sequences directly.
|
||||
Target environments: xterm-compatible terminals on Linux and BSDs, and browsers
|
||||
via xterm.js (WASM builds).
|
||||
|
||||
## Features
|
||||
|
||||
- True color (24-bit) and 256-color palette output with automatic capability detection
|
||||
- Double-buffered output with cell-level diffing — only changed cells emit sequences
|
||||
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize
|
||||
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT
|
||||
- Color blending library: alpha, additive, screen, overlay, soft light
|
||||
- Named color palettes for true color and xterm-256
|
||||
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
|
||||
- Unix 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
|
||||
└── wasmBackend //go:build wasm — syscall/js, xterm.js bridge
|
||||
|
||||
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing,
|
||||
service lifecycle. 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/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: terminal.Gunmetal}
|
||||
}
|
||||
msg := "hello"
|
||||
for i, ch := range msg {
|
||||
cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{
|
||||
Rune: ch, Fg: terminal.Amber, Bg: terminal.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 RGB
|
||||
Bg RGB
|
||||
Attrs Attr
|
||||
}
|
||||
```
|
||||
|
||||
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
|
||||
`AttrBlink`, `AttrReverse`.
|
||||
|
||||
Two flag bits change color interpretation: 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.
|
||||
|
||||
## Color system
|
||||
|
||||
### Modes
|
||||
|
||||
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
|
||||
`38;5;N` after mapping. `DetectColorMode()` inspects the environment
|
||||
(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`.
|
||||
|
||||
### 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`, ...) in `rgb_256.go` and named true color values (`Amber`,
|
||||
`Gunmetal`, `Obsidian`, ...) in `rgb_truecolor.go`.
|
||||
|
||||
### Blending
|
||||
|
||||
`blend.go` 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.
|
||||
|
||||
```go
|
||||
bg := terminal.Gunmetal
|
||||
glow := terminal.RGB{R: 255, G: 160, B: 40}
|
||||
|
||||
cell.Bg = terminal.Screen(bg, terminal.Scale(glow, pulse), 1.0) // pulsing glow
|
||||
cell.Bg = terminal.Blend(cell.Bg, terminal.Black, 0.6) // dim overlay backdrop
|
||||
cell.Fg = terminal.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`
|
||||
- `EventError`, `EventClosed`
|
||||
|
||||
A standalone ESC press is disambiguated from escape sequences by a 50ms
|
||||
timeout. Partial UTF-8 and escape sequences at read boundaries are reassembled
|
||||
in a persistent buffer. `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 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.
|
||||
Reference in New Issue
Block a user