v0.2.0 16 colors, default colors, /dev/tty, bracketed paste, themed form controls

This commit is contained in:
lixen committed 2026-10-09 14:15:46 -04:00
1 parent 070131c7fd
commit 2b5d127e84
32 files changed
+1443 -420

No files matched your search

+67 -49
View File
@@ -2,22 +2,23 @@
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).
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, and browsers
via xterm.js (WASM builds).
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) 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
- 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
- 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 and WASM backends behind a common interface
- Unix, Windows and WASM backends behind a common interface
## Architecture
@@ -26,11 +27,12 @@ via xterm.js (WASM builds).
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
├── inputReader escape sequence parser, event channel
└── 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
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing,
service lifecycle. Platform specifics are isolated in the `Backend` implementations.
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing.
Platform specifics are isolated in the `Backend` implementations.
### Rendering pipeline
@@ -56,7 +58,10 @@ without scroll side effects.
```go
package main
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
func main() {
term := terminal.New() // color mode auto-detected
@@ -71,13 +76,13 @@ func main() {
for {
// Build frame
for i := range cells {
cells[i] = terminal.Cell{Rune: ' ', Bg: terminal.Gunmetal}
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: terminal.Amber, Bg: terminal.Gunmetal,
Rune: ch, Fg: color.Amber, Bg: color.Gunmetal,
Attrs: terminal.AttrBold,
}
}
@@ -103,8 +108,8 @@ func main() {
```go
type Cell struct {
Rune rune
Fg RGB
Bg RGB
Fg color.RGB
Bg color.RGB
Attrs Attr
}
```
@@ -112,18 +117,39 @@ type Cell struct {
`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.
`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. `DetectColorMode()` inspects the environment
(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`.
`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
@@ -135,12 +161,12 @@ 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`.
`P256SteelBlue`, ...) and named true color values (`Amber`, `Gunmetal`,
`Obsidian`, ...), all in `github.com/lixenwraith/color`.
### 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
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.
```go
bg := terminal.Gunmetal
glow := terminal.RGB{R: 255, G: 160, B: 40}
bg := color.Gunmetal
glow := color.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
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
```
@@ -184,30 +210,22 @@ division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
`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`).
## 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:
+9 -18
View File
@@ -8,10 +8,7 @@ import (
var (
// CSI sequences
csi = []byte("\x1b[")
csiEnd = []byte("m")
csiReset = []byte("\x1b[0m")
csiClear = []byte("\x1b[2J\x1b[H")
csiHome = []byte("\x1b[H")
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency)
csiSGR0 = []byte("\x1b[0m")
@@ -28,21 +25,9 @@ var (
csiAutoWrapOn = []byte("\x1b[?7h")
csiAutoWrapOff = []byte("\x1b[?7l")
// Color prefixes
csiFg256 = []byte("\x1b[38;5;") // followed by N;m
csiBg256 = []byte("\x1b[48;5;") // followed by N;m
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")
// Color parameters, after 38 or 48
sgrPalette = []byte(";5;") // followed by N
sgrRGB = []byte(";2;") // followed by R;G;B
// Mouse mode sequences (SGR 1006 for extended coordinates)
csiMouseClickOn = []byte("\x1b[?1000h") // Enable click reporting
@@ -53,6 +38,12 @@ var (
csiMouseMotionOff = []byte("\x1b[?1003l")
csiMouseSGROn = []byte("\x1b[?1006h") // Enable SGR extended mode
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
+27 -1
View File
@@ -18,6 +18,7 @@ type unixBackend struct {
out *os.File
inFd int
outFd int
tty *os.File // the controlling terminal, opened for a redirected end
oldTerm *term.State
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 {
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) {
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)
if err != nil {
b.closeTTY()
return err
}
b.oldTerm = old
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() {
if b.resizeStopCh != nil {
close(b.resizeStopCh)
@@ -57,6 +82,7 @@ func (b *unixBackend) Fini() {
if b.oldTerm != nil {
term.Restore(b.inFd, b.oldTerm)
}
b.closeTTY()
}
// Size delegates to exported WindowSize; getTerminalSize deleted
+2 -3
View File
@@ -104,9 +104,9 @@ func (b *wasmBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
select {
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)
if len(data) == 1 && data[0] == 0x1b {
if string(data) == "\x1b" || string(data) == "\x1b\x1b" {
// Use JS setTimeout via a promise-based wait
moreCh := make(chan []byte, 1)
@@ -156,4 +156,3 @@ func (b *wasmBackend) SetResizeHandler(handler func(width, height int)) {
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
js.Global().Set("goTerminalResize", resizeCb)
}
+12 -3
View File
@@ -14,9 +14,18 @@ import (
// --- 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 {
name string
th tui.Theme
th colors
accent, accent2 color.RGB
hexNull, hexPrint, hexSpace, hexCtrl color.RGB
hexHigh, matchBg, matchCurBg, markDim color.RGB
@@ -25,7 +34,7 @@ type palette struct {
var palettes = []palette{
{ // Tokyo Night
name: "tokyonight",
th: tui.Theme{
th: colors{
Bg: color.RGB{26, 27, 38}, Fg: color.RGB{192, 202, 245},
FocusBg: color.RGB{35, 36, 48}, CursorBg: color.RGB{45, 50, 80},
Selected: color.RGB{158, 206, 106}, Unselected: color.RGB{86, 95, 137},
@@ -45,7 +54,7 @@ var palettes = []palette{
},
{ // One Dark
name: "onedark",
th: tui.Theme{
th: colors{
Bg: color.RGB{40, 44, 52}, Fg: color.RGB{171, 178, 191},
FocusBg: color.RGB{47, 52, 63}, CursorBg: color.RGB{62, 68, 81},
Selected: color.RGB{152, 195, 121}, Unselected: color.RGB{92, 99, 112},
+19
View File
@@ -8,8 +8,27 @@ type ColorMode uint8
const (
ColorMode256 ColorMode = iota // xterm-256 palette
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.
// Exists to preserve terminal API backwards-compatibility.
func WarmPalette256() {
+18
View File
@@ -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
View File
@@ -4,35 +4,17 @@ package terminal
import (
"os"
"strings"
"golang.org/x/sys/unix"
)
// DetectColorMode determines terminal color capability from environment
func DetectColorMode() ColorMode {
colorterm := os.Getenv("COLORTERM")
if colorterm == "truecolor" || colorterm == "24bit" {
return ColorModeTrueColor
// controllingTTY opens the controlling terminal for writing, nil without one
func controllingTTY() *os.File {
f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return nil
}
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
return f
}
// resetTerminalMode attempts to restore terminal to cooked mode
+27
View File
@@ -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)
}
}
+5
View File
@@ -2,6 +2,8 @@
package terminal
import "os"
// DetectColorMode determines terminal color capability from environment
func DetectColorMode() ColorMode {
// Browsers/xterm.js generally support true color
@@ -10,3 +12,6 @@ func DetectColorMode() ColorMode {
// resetTerminalMode is no-op for WASM; termios does not exist
func resetTerminalMode() {}
// controllingTTY is nil: the browser terminal is reached through the bridge
func controllingTTY() *os.File { return nil }
+3 -9
View File
@@ -8,15 +8,9 @@ import (
"golang.org/x/sys/windows"
)
func DetectColorMode() ColorMode {
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
}
// controllingTTY is nil: Init refuses a redirected stdout, so the library
// never drew on the console behind one
func controllingTTY() *os.File { return nil }
func resetTerminalMode() {
saneIn := uint32(windows.ENABLE_PROCESSED_INPUT |
+5 -4
View File
@@ -2,9 +2,11 @@
//
// # 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
// - 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)
// - Clean terminal restoration on exit/panic
//
@@ -24,7 +26,7 @@
// └── wasmBackend (//go:build wasm) - syscall/js, callbacks
//
// Shared code (no build tags): Terminal interface, cell diffing, ANSI generation,
// escape sequence parsing, service lifecycle.
// escape sequence parsing.
//
// # WASM Integration
//
@@ -45,4 +47,3 @@
// Target environments: Linux, macOS, BSDs with xterm-compatible terminals, and
// modern browsers with xterm.js.
package terminal
+10 -7
View File
@@ -19,10 +19,10 @@ import (
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal/internal/env"
"golang.org/x/term"
)
@@ -35,12 +35,15 @@ const (
colorMode16
)
// textConsole reports a TERM limited to 16 colors (Linux VT, FreeBSD syscons,
// VT100-class). It wins over COLORTERM, which profiles export everywhere.
func textConsole() bool {
term := os.Getenv("TERM")
return term == "linux" || term == "ansi" || strings.HasPrefix(term, "cons25") ||
strings.HasPrefix(term, "vt") || strings.HasSuffix(term, "-16color")
// detectColorMode maps what the environment says to a color mode
func detectColorMode() colorMode {
switch env.Colors() {
case 16:
return colorMode16
case 1 << 24:
return colorModeTrueColor
}
return colorMode256
}
// Printer manages styled output and the live block. Safe for concurrent use.
-29
View File
@@ -4,39 +4,10 @@ package inline
import (
"os"
"strings"
"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) {
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
if err != nil || ws.Col == 0 {
-7
View File
@@ -4,13 +4,6 @@ package inline
import "os"
func detectColorMode() colorMode {
if textConsole() {
return colorMode16
}
return colorMode256
}
func windowSize(f *os.File) (w, h int, ok bool) {
return 0, 0, false
}
-13
View File
@@ -8,19 +8,6 @@ import (
"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) {
var info windows.ConsoleScreenBufferInfo
if err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info); err != nil {
+77 -10
View File
@@ -1,10 +1,12 @@
package terminal
import (
"bytes"
"fmt"
"os"
"runtime/debug"
"sync"
"sync/atomic"
"time"
)
@@ -14,7 +16,7 @@ type EventType uint8
const (
EventKey EventType = iota
EventResize
EventPaste // Future: bracketed paste
EventPaste // Bracketed paste, Text holds it
EventMouse // SGR mouse reporting
EventError // Read error
EventClosed // Input closed
@@ -29,6 +31,7 @@ type Event struct {
Width int // For EventResize
Height int // For EventResize
Err error // For EventError
Text string // For EventPaste: the pasted bytes as sent, controls included
// Mouse event fields
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
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
running bool
}
@@ -105,10 +115,10 @@ func (r *inputReader) readLoop() {
// Panic recovery for raw input reader
defer func() {
if r := recover(); r != nil {
EmergencyReset(os.Stdout)
if p := recover(); p != nil {
EmergencyReset(writerAdapter{r.backend})
// 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())
os.Exit(1)
}
@@ -122,11 +132,19 @@ func (r *inputReader) readLoop() {
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 {
// Timeout (Unix poll) or empty read
// Emit pending standalone ESC if present
if len(r.buf) == 1 && r.buf[0] == 0x1b {
r.sendEvent(Event{Type: EventKey, Key: KeyEscape})
// Timeout (Unix poll) or empty read: a pending ESC, or two, was
// pressed alone; inside a paste it is text
if ev, ok := loneEscape[string(r.buf)]; ok && !r.pasting {
r.sendEvent(ev)
r.buf = r.buf[:0]
}
select {
@@ -168,6 +186,26 @@ func (r *inputReader) parseInput(data []byte) int {
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]
// Fast path: printable ASCII
@@ -237,6 +275,21 @@ func (r *inputReader) parseInput(data []byte) int {
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
func utf8SeqLen(b byte) int {
if b < 0x80 {
@@ -260,8 +313,22 @@ func (r *inputReader) parseEscape(data []byte) (int, Event) {
return 0, Event{} // Incomplete, wait for more
}
// ESC ESC -> Alt+Escape
if data[1] == 0x1b {
// ESC ESC: Escape before a paste, Alt and the sequence that follows (rxvt's
// 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}
}
+46
View File
@@ -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")
}
+26
View File
@@ -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)
}
}
+77 -153
View File
@@ -2,6 +2,8 @@ package terminal
import (
"bufio"
"unicode"
"unicode/utf8"
"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)
// A cell holds one glyph, never a control the terminal would act on
r := c.Rune
if r == 0 {
if unicode.IsControl(r) || !utf8.ValidRune(r) {
r = ' '
}
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
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
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFg256) != (o.lastAttr&AttrFg256)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBg256) != (o.lastAttr&AttrBg256)
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
styleAttr := attr & AttrStyle
lastStyleAttr := o.lastAttr & AttrStyle
attrChanged := !o.lastValid || styleAttr != lastStyleAttr
attrChanged := !o.lastValid || styleAttr != o.lastAttr&AttrStyle
// 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 {
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)
o.writeFgInline(w, fg, attr)
o.writeBgInline(w, bg, attr)
// If attributes changed, must reset first, then restate both colors
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')
} else if fgChanged {
o.writeFgFull(w, fg, attr)
} else if bgChanged {
o.writeBgFull(w, bg, attr)
}
}
o.lastFg = fg
o.lastBg = bg
@@ -341,89 +301,50 @@ func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, at
o.lastValid = true
}
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg color.RGB, attr Attr) {
w.WriteByte(';')
if attr&AttrFg256 != 0 {
// 256-color: 38;5;N
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)))
}
}
// attrCodes pairs each style bit with its SGR parameter, in emission order
var attrCodes = [...]struct {
bit Attr
code byte
}{{AttrBold, '1'}, {AttrDim, '2'}, {AttrItalic, '3'}, {AttrUnderline, '4'}, {AttrBlink, '5'}, {AttrReverse, '7'}}
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg color.RGB, attr Attr) {
w.WriteByte(';')
if attr&AttrBg256 != 0 {
// 256-color: 48;5;N
w.Write([]byte("48;5;"))
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)))
// writeColor writes one color's SGR parameters, after a ';' when sep, for
// base 30 (foreground) or 40 (background): the terminal's own, a palette
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) {
if o.colorMode == ColorModeNone {
return
}
}
// writeFgFull writes complete fg color sequence
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg color.RGB, attr Attr) {
if attr&AttrFg256 != 0 {
w.Write(csiFg256)
writeInt(w, int(fg.R))
w.WriteByte('m')
} else if o.colorMode == ColorModeTrueColor {
w.Write(csiFgRGB)
writeInt(w, int(fg.R))
if sep {
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')
}
}
// writeBgFull writes complete bg color sequence
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg color.RGB, attr Attr) {
if attr&AttrBg256 != 0 {
w.Write(csiBg256)
writeInt(w, int(bg.R))
w.WriteByte('m')
} else if o.colorMode == ColorModeTrueColor {
w.Write(csiBgRGB)
writeInt(w, int(bg.R))
switch {
case deflt:
writeInt(w, base+9)
case o.colorMode == ColorMode16:
i := color.RGBTo16(c)
if palette {
i = paletteTo16(c.R)
}
if i >= 8 {
base += 60 - 8 // bright: 90-97, 100-107
}
writeInt(w, base+int(i))
case palette:
writeInt(w, base+8)
w.Write(sgrPalette)
writeInt(w, int(c.R))
case o.colorMode == ColorModeTrueColor:
writeInt(w, base+8)
w.Write(sgrRGB)
writeInt(w, int(c.R))
w.WriteByte(';')
writeInt(w, int(bg.G))
writeInt(w, int(c.G))
w.WriteByte(';')
writeInt(w, int(bg.B))
w.WriteByte('m')
} else {
w.Write(csiBg256)
writeInt(w, int(RGBTo256(bg)))
w.WriteByte('m')
writeInt(w, int(c.B))
default:
writeInt(w, base+8)
w.Write(sgrPalette)
writeInt(w, int(RGBTo256(c)))
}
}
@@ -436,11 +357,14 @@ func (o *outputBuffer) forceFullRedraw() {
o.cursorValid = false
}
// clear writes a clear screen with specified background
func (o *outputBuffer) clear(bg color.RGB) {
// clear writes a clear screen with specified background, attr's
// AttrBgDefault or AttrBg256 saying how to read it
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
w := o.writer
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)
o.lastValid = false
@@ -448,7 +372,7 @@ func (o *outputBuffer) clear(bg color.RGB) {
w.Flush()
for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg}
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
}
}
+59 -14
View File
@@ -7,10 +7,11 @@ import (
"sync/atomic"
"github.com/lixenwraith/color"
"golang.org/x/term"
)
// Attr represents text attributes (bitmask)
type Attr uint8
// Attr represents text attributes and how to read a cell's colors (bitmask)
type Attr uint16
const (
AttrNone Attr = 0
@@ -22,11 +23,19 @@ const (
AttrReverse Attr = 1 << 5
AttrFg256 Attr = 1 << 6 // Fg.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)
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
type Cell struct {
Rune rune
@@ -77,6 +86,10 @@ type Terminal interface {
// SetMouseMode enables/disables mouse event reporting
// Modes can be combined: MouseModeClick | MouseModeDrag
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
@@ -100,18 +113,20 @@ type termImpl struct {
initialized bool
finalized bool
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 {
b := newBackend()
var c ColorMode
if len(colorMode) == 0 {
// Use backend detection or fallback env detection for unix
c = DetectColorMode()
} else {
c := DetectColorMode()
if len(colorMode) > 0 {
c = colorMode[0]
} else if os.Getenv("NO_COLOR") != "" {
c = ColorModeNone
}
t := &termImpl{
@@ -180,7 +195,7 @@ func (t *termImpl) Init() error {
}
// Clear screen
t.output.clear(color.Black)
t.output.clear(color.RGB{}, AttrBgDefault)
// Start input reader
t.input.start()
@@ -208,6 +223,10 @@ func (t *termImpl) Fini() {
w.Flush()
}
if t.paste {
t.writeRaw(csiPasteOff)
}
// Stop handlers
if t.input != nil {
t.input.stop()
@@ -274,7 +293,7 @@ func (t *termImpl) Clear(bg color.RGB) {
return
}
t.output.clear(bg)
t.output.clear(bg, 0)
}
// SetCursorVisible shows/hides cursor
@@ -343,7 +362,7 @@ func (t *termImpl) Sync() {
// Clear terminal before full redraw
// Diff-based rendering assumes physical terminal matches front buffer state
t.output.clear(color.Black)
t.output.clear(color.RGB{}, AttrBgDefault)
t.output.forceFullRedraw()
}
@@ -431,21 +450,47 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
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
func (t *termImpl) writeRaw(data []byte) {
t.backend.Write(data)
}
// EmergencyReset attempts to restore terminal to sane state
// Call this from panic recovery if Fini() cannot be called normally
// 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
// redirected stdout, it writes to the controlling terminal instead.
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
w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
w.Write(csiPasteOff)
// Write sequences to provided writer
w.Write(csiCursorShow)
+208
View File
@@ -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
View File
@@ -39,15 +39,15 @@ list.Selection = 0
for {
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.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,
terminal.IronGray)
color.IronGray)
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
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
```go
items := make([]tui.ListItem, 0, len(files))
for _, f := range files {
items = append(items, tui.ListItem{
Icon: '▸', IconFg: terminal.Amber,
Icon: '▸', IconFg: color.Amber,
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
})
}
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: terminal.DarkSlate})
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, terminal.IronGray)
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSlate})
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
```
### Modal dialog
@@ -135,11 +176,11 @@ dlg := tui.Center(root, 50, 12)
content := dlg.Modal(tui.ModalOpts{
Title: "Settings",
Border: tui.LineDouble,
BorderFg: terminal.SteelBlue,
BorderFg: color.SteelBlue,
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`
@@ -201,6 +242,8 @@ Pure logic, no rendering — usable independently:
- Width calculations count runes, not terminal columns; East Asian wide
characters and combining marks are not width-aware.
- 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
application-side geometry from the regions used.
+42
View File
@@ -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
View File
@@ -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)
}
}
+116
View File
@@ -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
View File
@@ -66,10 +66,10 @@ func (r Region) Inset(n int) Region {
}
// Cell sets a single cell with bounds checking.
// A zero-value bg (color.RGB{}) is transparent: the existing cell's
// background is preserved. Establish a base background first (Fill, or
// pre-initialized cell buffer). For a literal black background use
// CellOpaque or Fill(color.RGB{}).
// A zero-value bg (color.RGB{}) with no Bg color bits in attr is
// transparent: the existing cell's background is preserved, bits and all.
// Establish a base background first (Fill, or pre-initialized cell buffer).
// 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) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
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
// Single bounds check for the backing slice
if uint(idx) < uint(len(r.Cells)) {
if bg == (color.RGB{}) {
bg = r.Cells[idx].Bg // transparent: inherit current background
if bg == (color.RGB{}) && attr&terminal.AttrBgColor == 0 {
// 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}
}
@@ -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
// verbatim (including zero/black) rather than treated as transparent.
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 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
View File
@@ -5,7 +5,9 @@ import (
"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 {
Fg 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
}
// 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
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"unicode"
"github.com/lixenwraith/terminal"
@@ -16,8 +17,18 @@ type TextFieldState struct {
Text []rune
Cursor int // Positions before which cursor sits (0 = before first char)
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
func NewTextFieldState(initial string) *TextFieldState {
runes := []rune(initial)
@@ -51,23 +62,48 @@ func (t *TextFieldState) Clear() {
// --- 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
func (t *TextFieldState) Insert(r rune) {
t.clampCursor()
t.Text = append(t.Text[:t.Cursor], append([]rune{r}, t.Text[t.Cursor:]...)...)
t.Cursor++
}
// InsertString adds string at cursor position
func (t *TextFieldState) InsertString(s string) {
t.clampCursor()
runes := []rune(s)
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
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 ---
// DeleteBackward removes rune before cursor
func (t *TextFieldState) DeleteBackward() bool {
t.clampCursor()
if t.Cursor > 0 {
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
t.Cursor--
@@ -78,6 +114,7 @@ func (t *TextFieldState) DeleteBackward() bool {
// DeleteForward removes rune at cursor
func (t *TextFieldState) DeleteForward() bool {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
return true
@@ -89,6 +126,7 @@ func (t *TextFieldState) DeleteForward() bool {
// DeleteWordBackward removes word before cursor
func (t *TextFieldState) DeleteWordBackward() bool {
t.clampCursor()
if t.Cursor == 0 {
return false
}
@@ -112,6 +150,7 @@ func (t *TextFieldState) DeleteWordBackward() bool {
// DeleteWordForward removes word after cursor
func (t *TextFieldState) DeleteWordForward() bool {
t.clampCursor()
if t.Cursor >= len(t.Text) {
return false
}
@@ -133,6 +172,7 @@ func (t *TextFieldState) DeleteWordForward() bool {
// DeleteToEnd removes from cursor to end
func (t *TextFieldState) DeleteToEnd() bool {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Text = t.Text[:t.Cursor]
return true
@@ -142,6 +182,7 @@ func (t *TextFieldState) DeleteToEnd() bool {
// DeleteToStart removes from start to cursor
func (t *TextFieldState) DeleteToStart() bool {
t.clampCursor()
if t.Cursor > 0 {
t.Text = t.Text[t.Cursor:]
t.Cursor = 0
@@ -155,6 +196,7 @@ func (t *TextFieldState) DeleteToStart() bool {
// MoveLeft moves cursor left
func (t *TextFieldState) MoveLeft() {
t.clampCursor()
if t.Cursor > 0 {
t.Cursor--
}
@@ -162,6 +204,7 @@ func (t *TextFieldState) MoveLeft() {
// MoveRight moves cursor right
func (t *TextFieldState) MoveRight() {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Cursor++
}
@@ -171,6 +214,7 @@ func (t *TextFieldState) MoveRight() {
// MoveWordLeft moves cursor to previous word boundary
func (t *TextFieldState) MoveWordLeft() {
t.clampCursor()
if t.Cursor == 0 {
return
}
@@ -186,6 +230,7 @@ func (t *TextFieldState) MoveWordLeft() {
// MoveWordRight moves cursor to next word boundary
func (t *TextFieldState) MoveWordRight() {
t.clampCursor()
if t.Cursor >= len(t.Text) {
return
}
@@ -218,6 +263,7 @@ func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 {
return
}
t.clampCursor()
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
@@ -271,7 +317,7 @@ func (t *TextFieldState) HandleKey(key terminal.Key, r rune, mod terminal.Modifi
case terminal.KeyCtrlW:
return t.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 { // Printable
if !unicode.IsControl(r) && (t.Accept == nil || t.Accept(r)) {
t.Insert(r)
return true
}
+89 -54
View File
@@ -2,65 +2,100 @@ package tui
import (
"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 {
Bg color.RGB
Fg color.RGB
FocusBg color.RGB
CursorBg color.RGB
Selected color.RGB
Unselected color.RGB
Partial color.RGB
Error color.RGB
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
Text Style // values and body text; its Bg is the panel's
Muted Style // labels, hints, placeholders and defaults, help lines
Accent Style // marks: focus, the chosen option, a required field
Selected Style // the cursor row of a list
Input Style // the background of the field being edited
Cursor Style // the text cursor
Error Style // errors
Border Style // boxes and rules
Glyphs Glyphs
}
// 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{
Bg: color.RGB{R: 20, G: 20, B: 30},
Fg: color.RGB{R: 200, G: 200, B: 200},
FocusBg: color.RGB{R: 30, G: 35, B: 45},
CursorBg: color.RGB{R: 50, G: 50, B: 70},
Selected: color.RGB{R: 80, G: 200, B: 80},
Unselected: color.RGB{R: 100, G: 100, B: 100},
Partial: color.RGB{R: 80, G: 160, B: 220},
Error: color.RGB{R: 255, G: 80, B: 80},
Warning: color.RGB{R: 255, G: 80, B: 80},
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},
Text: Style{Fg: color.RGB{R: 200, G: 200, B: 200}, Bg: color.RGB{R: 20, G: 20, B: 30}},
Muted: Style{Fg: color.RGB{R: 120, G: 125, B: 140}},
Accent: Style{Fg: color.RGB{R: 80, G: 160, B: 220}, Attr: terminal.AttrBold},
Selected: Style{Bg: color.RGB{R: 40, G: 45, B: 65}},
Input: Style{Bg: color.RGB{R: 30, G: 30, B: 50}},
Cursor: Style{Fg: color.RGB{R: 20, G: 20, B: 30}, Bg: color.RGB{R: 200, G: 200, B: 200}},
Error: Style{Fg: color.RGB{R: 255, G: 80, B: 80}},
Border: Style{Fg: color.RGB{R: 60, G: 80, B: 100}},
Glyphs: GlyphsUnicode,
}
// 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
View File
@@ -98,7 +98,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
lineFg := opts.LineFg
if lineFg == (color.RGB{}) {
lineFg = DefaultTheme.Border
lineFg = DefaultTheme.Border.Fg
}
rendered := 0
@@ -338,4 +338,3 @@ func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
}
return -1
}
+250
View File
@@ -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))
}
}