From 2b5d127e843c65e8be109fab19e67a8bad05854b Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Fri, 9 Oct 2026 14:15:46 -0400 Subject: [PATCH] v0.2.0 16 colors, default colors, /dev/tty, bracketed paste, themed form controls --- README.md | 118 ++++++++++-------- ansi.go | 29 ++--- backend_unix.go | 30 ++++- backend_wasm.go | 5 +- cmd/example-tui/render.go | 15 ++- color.go | 19 +++ detect_env.go | 18 +++ detect_unix.go | 30 +---- detect_unix_test.go | 27 ++++ detect_wasm.go | 7 +- detect_windows.go | 12 +- doc.go | 9 +- inline/inline.go | 17 +-- inline/sys_unix.go | 29 ----- inline/sys_unsupported.go | 7 -- inline/sys_windows.go | 13 -- input.go | 93 ++++++++++++-- internal/env/env.go | 46 +++++++ internal/env/env_test.go | 26 ++++ output.go | 222 +++++++++++---------------------- terminal.go | 73 ++++++++--- terminal_test.go | 208 +++++++++++++++++++++++++++++++ tui/README.md | 65 ++++++++-- tui/choice.go | 42 +++++++ tui/field.go | 115 ++++++++++++++++++ tui/optionlist.go | 116 ++++++++++++++++++ tui/region.go | 21 ++-- tui/style.go | 13 +- tui/textfield_state.go | 50 +++++++- tui/theme.go | 143 ++++++++++++++-------- tui/tree.go | 3 +- tui/widget_test.go | 250 ++++++++++++++++++++++++++++++++++++++ 32 files changed, 1447 insertions(+), 424 deletions(-) create mode 100644 detect_env.go create mode 100644 detect_unix_test.go create mode 100644 internal/env/env.go create mode 100644 internal/env/env_test.go create mode 100644 terminal_test.go create mode 100644 tui/choice.go create mode 100644 tui/field.go create mode 100644 tui/optionlist.go create mode 100644 tui/widget_test.go diff --git a/README.md b/README.md index eff1ac4..73eab46 100644 --- a/README.md +++ b/README.md @@ -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 - └── wasmBackend //go:build wasm — syscall/js, xterm.js bridge + ├── 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: diff --git a/ansi.go b/ansi.go index 54c4174..022aa6f 100644 --- a/ansi.go +++ b/ansi.go @@ -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 @@ -108,4 +99,4 @@ func writeCursorForward(w *bufio.Writer, n int) { w.Write(csi) writeInt(w, n) w.WriteByte('C') -} \ No newline at end of file +} diff --git a/backend_unix.go b/backend_unix.go index 86a42ee..ca60140 100644 --- a/backend_unix.go +++ b/backend_unix.go @@ -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 !term.IsTerminal(b.inFd) { - return fmt.Errorf("stdin is not a terminal") + 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) { + 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 diff --git a/backend_wasm.go b/backend_wasm.go index e65b8e0..9f9c3db 100644 --- a/backend_wasm.go +++ b/backend_wasm.go @@ -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) } - diff --git a/cmd/example-tui/render.go b/cmd/example-tui/render.go index 3073566..4f776e5 100644 --- a/cmd/example-tui/render.go +++ b/cmd/example-tui/render.go @@ -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}, diff --git a/color.go b/color.go index d33e612..a2416e9 100644 --- a/color.go +++ b/color.go @@ -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() { diff --git a/detect_env.go b/detect_env.go new file mode 100644 index 0000000..df6c00c --- /dev/null +++ b/detect_env.go @@ -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 +} diff --git a/detect_unix.go b/detect_unix.go index 3ea3373..ac78f7a 100644 --- a/detect_unix.go +++ b/detect_unix.go @@ -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 diff --git a/detect_unix_test.go b/detect_unix_test.go new file mode 100644 index 0000000..052f497 --- /dev/null +++ b/detect_unix_test.go @@ -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) + } +} diff --git a/detect_wasm.go b/detect_wasm.go index ffa5552..1060587 100644 --- a/detect_wasm.go +++ b/detect_wasm.go @@ -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 @@ -9,4 +11,7 @@ func DetectColorMode() ColorMode { } // resetTerminalMode is no-op for WASM; termios does not exist -func resetTerminalMode() {} \ No newline at end of file +func resetTerminalMode() {} + +// controllingTTY is nil: the browser terminal is reached through the bridge +func controllingTTY() *os.File { return nil } diff --git a/detect_windows.go b/detect_windows.go index 5a5b648..4fd115e 100644 --- a/detect_windows.go +++ b/detect_windows.go @@ -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 | diff --git a/doc.go b/doc.go index fd3566f..e51caa6 100644 --- a/doc.go +++ b/doc.go @@ -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 - diff --git a/inline/inline.go b/inline/inline.go index ca9e986..1f419f5 100644 --- a/inline/inline.go +++ b/inline/inline.go @@ -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. diff --git a/inline/sys_unix.go b/inline/sys_unix.go index 0e53a77..313a2f8 100644 --- a/inline/sys_unix.go +++ b/inline/sys_unix.go @@ -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 { diff --git a/inline/sys_unsupported.go b/inline/sys_unsupported.go index 081389d..484436e 100644 --- a/inline/sys_unsupported.go +++ b/inline/sys_unsupported.go @@ -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 } diff --git a/inline/sys_windows.go b/inline/sys_windows.go index bbc97f5..c37f85c 100644 --- a/inline/sys_windows.go +++ b/inline/sys_windows.go @@ -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 { diff --git a/input.go b/input.go index 4b45f02..f0df248 100644 --- a/input.go +++ b/input.go @@ -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 @@ -26,9 +28,10 @@ type Event struct { Key Key Rune rune Modifiers Modifier - Width int // For EventResize - Height int // For EventResize - Err error // For EventError + 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} } diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..52915ef --- /dev/null +++ b/internal/env/env.go @@ -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") +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 0000000..fc0698d --- /dev/null +++ b/internal/env/env_test.go @@ -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) + } +} diff --git a/output.go b/output.go index b59daa5..4a38d19 100644 --- a/output.go +++ b/output.go @@ -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 + w.Write(csi) + // If attributes changed, must reset first, then restate both colors if attrChanged { - w.Write(csi) // \x1b[ - first := true - - // Reset w.WriteByte('0') - first = false - - // Style attributes - if styleAttr&AttrBold != 0 { - if !first { + for _, a := range attrCodes { + if styleAttr&a.bit != 0 { w.WriteByte(';') + w.WriteByte(a.code) } - 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) - w.WriteByte('m') - } else if fgChanged { - o.writeFgFull(w, fg, attr) - } else if bgChanged { - o.writeBgFull(w, bg, attr) } + 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') 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} } } diff --git a/terminal.go b/terminal.go index bf9021e..0b6d277 100644 --- a/terminal.go +++ b/terminal.go @@ -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) diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..29d0ff6 --- /dev/null +++ b/terminal_test.go @@ -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) + } +} diff --git a/tui/README.md b/tui/README.md index ac4865a..43b9b32 100644 --- a/tui/README.md +++ b/tui/README.md @@ -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. diff --git a/tui/choice.go b/tui/choice.go new file mode 100644 index 0000000..fec898e --- /dev/null +++ b/tui/choice.go @@ -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) +} diff --git a/tui/field.go b/tui/field.go new file mode 100644 index 0000000..73cf5e1 --- /dev/null +++ b/tui/field.go @@ -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) + } +} diff --git a/tui/optionlist.go b/tui/optionlist.go new file mode 100644 index 0000000..ef8ef89 --- /dev/null +++ b/tui/optionlist.go @@ -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)) + } +} diff --git a/tui/region.go b/tui/region.go index 11fc932..9f57e25 100644 --- a/tui/region.go +++ b/tui/region.go @@ -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) } } } diff --git a/tui/style.go b/tui/style.go index 5cf8d84..268708d 100644 --- a/tui/style.go +++ b/tui/style.go @@ -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 +} diff --git a/tui/textfield_state.go b/tui/textfield_state.go index 903deff..5a7387b 100644 --- a/tui/textfield_state.go +++ b/tui/textfield_state.go @@ -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,10 +317,10 @@ 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 } } return false -} \ No newline at end of file +} diff --git a/tui/theme.go b/tui/theme.go index e735a2d..9130ed5 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -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} +} diff --git a/tui/tree.go b/tui/tree.go index a530d37..4cbf3b9 100644 --- a/tui/tree.go +++ b/tui/tree.go @@ -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 } - diff --git a/tui/widget_test.go b/tui/widget_test.go new file mode 100644 index 0000000..21f4ff5 --- /dev/null +++ b/tui/widget_test.go @@ -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)) + } +}