v0.1.0 initial commit

This commit is contained in:
2026-07-12 18:41:05 -04:00
commit aa22225c61
69 changed files with 10971 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.idea
bin/
dev/
logs/
log/
examples/
catalog.txt
combined.txt
+28
View File
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Lixen Wraith
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+223
View File
@@ -0,0 +1,223 @@
# terminal
Direct ANSI terminal control for Go with zero-allocation rendering. Built for
sustained 60fps full-screen redraws in cell-based applications (games, dashboards,
TUIs). Depends only on the standard library and `golang.org/x/sys` (Unix builds).
The package bypasses terminfo/termcap entirely and emits ANSI sequences directly.
Target environments: xterm-compatible terminals on Linux and BSDs, and browsers
via xterm.js (WASM builds).
## Features
- True color (24-bit) and 256-color palette output with automatic capability detection
- Double-buffered output with cell-level diffing — only changed cells emit sequences
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT
- Color blending library: alpha, additive, screen, overlay, soft light
- Named color palettes for true color and xterm-256
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
- Unix and WASM backends behind a common interface
## Architecture
Terminal (interface)
└── termImpl
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
├── inputReader escape sequence parser, event channel
└── Backend (interface)
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH
└── wasmBackend //go:build wasm — syscall/js, xterm.js bridge
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing,
service lifecycle. Platform specifics are isolated in the `Backend` implementations.
### Rendering pipeline
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
passes it to `Flush`. The output buffer diffs against the previously flushed frame:
- Rows are scanned with early termination (trailing unchanged cells skipped).
- Cursor moves are emitted only when the write position is non-contiguous.
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
are suppressed.
- If the backend size changed between buffer preparation and `Flush`, the frame
is dropped to prevent resize-race corruption. The next frame (built at the new
size) renders normally.
`Sync()` clears the screen and invalidates the front buffer, forcing a full
redraw — required after any external process writes to the terminal.
Auto-wrap is disabled during the session, making the bottom-right cell writable
without scroll side effects.
## Quick start
```go
package main
import "github.com/lixenwraith/terminal"
func main() {
term := terminal.New() // color mode auto-detected
if err := term.Init(); err != nil {
panic(err)
}
defer term.Fini()
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
for {
// Build frame
for i := range cells {
cells[i] = terminal.Cell{Rune: ' ', Bg: terminal.Gunmetal}
}
msg := "hello"
for i, ch := range msg {
// 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,
Attrs: terminal.AttrBold,
}
}
term.Flush(cells, w, h)
// Handle input
ev := term.PollEvent()
switch ev.Type {
case terminal.EventKey:
if ev.Key == terminal.KeyEscape || ev.Rune == 'q' {
return
}
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
}
}
}
```
## Cells and attributes
```go
type Cell struct {
Rune rune
Fg RGB
Bg RGB
Attrs Attr
}
```
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
`AttrBlink`, `AttrReverse`.
Two flag bits change color interpretation: with `AttrFg256` / `AttrBg256` set,
`Fg.R` / `Bg.R` holds an xterm-256 palette index directly and `G`/`B` are
ignored. This allows exact palette output on true color terminals and skips
RGB → palette conversion.
## Color system
### Modes
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
`38;5;N` after mapping. `DetectColorMode()` inspects the environment
(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`.
### RGB → 256 mapping
`RGBTo256` maps any `RGB` to the nearest xterm-256 index using perceptually
weighted Redmean distance. The full mapping is pre-computed at init into a
6-bit-quantized LUT (256KB, L2-resident), making per-cell conversion a single
array load. Applications targeting 256-color terminals can render in RGB
throughout; degradation is automatic.
Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`,
`P256SteelBlue`, ...) in `rgb_256.go` and named true color values (`Amber`,
`Gunmetal`, `Obsidian`, ...) in `rgb_truecolor.go`.
### Blending
`blend.go` provides compositing primitives operating on `RGB`. All take
destination first and are branch-free in the hot path or LUT-backed; suitable
for per-cell use at frame rate.
| Function | Operation | Character |
|---|---|---|
| `Blend(dst, src, alpha)` | linear interpolation | standard transparency |
| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips |
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips |
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure |
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow |
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight |
| `Scale(c, factor)` | channel multiply | dim/brighten |
| `Grayscale(c)` | Rec. 601 luma | desaturation |
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1
short-circuits without float math. 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}
cell.Bg = terminal.Screen(bg, terminal.Scale(glow, pulse), 1.0) // pulsing glow
cell.Bg = terminal.Blend(cell.Bg, terminal.Black, 0.6) // dim overlay backdrop
cell.Fg = terminal.SoftLight(cell.Fg, tint, 0.4) // subtle recolor
bar := cold.Lerp(hot, load) // value-mapped gradient
```
Integer paths (`Add`, `Screen`, `Overlay`) use a `(x + (x>>8) + 1) >> 8`
division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
## Input
`PollEvent()` blocks on a unified channel. `Event.Type` values:
- `EventKey``Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...),
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
(`ModShift`, `ModAlt`, `ModCtrl`)
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
`MouseAction` (press/release/move/drag), modifiers. Enable via
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
- `EventResize` — new `Width`/`Height`
- `EventError`, `EventClosed`
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer.
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
## Service wrapper
`TerminalService` packages lifecycle (init, input goroutine, panic-safe
teardown) behind `Init/Start/Stop` for service-registry architectures:
```go
svc := terminal.NewService()
svc.Init()
svc.Start()
defer svc.Stop()
term := svc.Terminal()
for ev := range svc.Events() { /* ... */ }
```
Input-goroutine panics trigger `EmergencyReset` (restores cooked mode, main
screen, cursor) before printing the stack trace, keeping the shell usable.
## WASM
WASM builds bridge to xterm.js via JS globals:
goTerminalWrite(Uint8Array) // Go → JS terminal output
goTerminalInput(Uint8Array) // JS → Go keyboard input
goTerminalResize(cols, rows) // JS → Go resize
xterm.cols, xterm.rows // initial size query
## Sub-packages
- [`tui`](tui/README.md) — immediate-mode widget toolkit (regions, layout,
widgets, scroll/editor state) built on the cell buffer model.
+111
View File
@@ -0,0 +1,111 @@
package terminal
import (
"bufio"
)
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
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")
// Cursor control
csiCursorHide = []byte("\x1b[?25l")
csiCursorShow = []byte("\x1b[?25h")
csiCursorPos = []byte("\x1b[") // followed by row;colH
// Screen modes
csiAltScreenEnter = []byte("\x1b[?1049h")
csiAltScreenExit = []byte("\x1b[?1049l")
// DECAWM: Auto-Wrap Mode
// ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner
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")
// Mouse mode sequences (SGR 1006 for extended coordinates)
csiMouseClickOn = []byte("\x1b[?1000h") // Enable click reporting
csiMouseClickOff = []byte("\x1b[?1000l")
csiMouseDragOn = []byte("\x1b[?1002h") // Enable button-event (drag) tracking
csiMouseDragOff = []byte("\x1b[?1002l")
csiMouseMotionOn = []byte("\x1b[?1003h") // Enable any-event (all motion) tracking
csiMouseMotionOff = []byte("\x1b[?1003l")
csiMouseSGROn = []byte("\x1b[?1006h") // Enable SGR extended mode
csiMouseSGROff = []byte("\x1b[?1006l")
)
// writeInt writes an integer without allocation
// Optimized for terminal values (0-255 common, 0-999 typical max)
func writeInt(w *bufio.Writer, n int) {
if n < 0 {
n = 0
}
if n < 10 {
w.WriteByte(byte(n) + '0')
return
}
if n < 100 {
w.WriteByte(byte(n/10) + '0')
w.WriteByte(byte(n%10) + '0')
return
}
if n < 1000 {
w.WriteByte(byte(n/100) + '0')
w.WriteByte(byte(n/10%10) + '0')
w.WriteByte(byte(n%10) + '0')
return
}
// Fallback for >999 (rare)
var buf [5]byte
i := 4
for n > 0 {
buf[i] = byte(n%10) + '0'
n /= 10
i--
}
w.Write(buf[i+1:])
}
// writeCursorPos writes cursor positioning sequence (0-indexed input)
func writeCursorPos(w *bufio.Writer, x, y int) {
w.Write(csiCursorPos)
writeInt(w, y+1)
w.WriteByte(';')
writeInt(w, x+1)
w.WriteByte('H')
}
// writeCursorForward writes cursor forward N positions
func writeCursorForward(w *bufio.Writer, n int) {
if n <= 0 {
return
}
if n == 1 {
w.Write([]byte("\x1b[C"))
return
}
w.Write(csi)
writeInt(w, n)
w.WriteByte('C')
}
+24
View File
@@ -0,0 +1,24 @@
package terminal
// Backend abstracts platform-specific terminal operations.
// This interface allows the terminal package to support both
// native Unix environments and WASM/Browser environments (via xterm.js).
type Backend interface {
// Lifecycle
Init() error
Fini()
// Capabilities
Size() (width, height int)
// I/O
// Write writes raw bytes to the terminal output.
Write(p []byte) error
// Read blocks until input is available, the stop channel is closed, or an error occurs.
Read(stopCh <-chan struct{}) ([]byte, error)
// Callbacks
// SetResizeHandler registers a callback for terminal resize events.
SetResizeHandler(handler func(width, height int))
}
+157
View File
@@ -0,0 +1,157 @@
//go:build unix
package terminal
import (
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
type unixBackend struct {
in *os.File
out *os.File
inFd int
outFd int
oldTerm *term.State
resizeStopCh chan struct{}
resizeDoneCh chan struct{}
}
const escapeTimeoutMs = 10
func newBackend() Backend {
return &unixBackend{
in: os.Stdin,
out: os.Stdout,
inFd: int(os.Stdin.Fd()),
outFd: int(os.Stdout.Fd()),
}
}
func (b *unixBackend) Init() error {
if !term.IsTerminal(b.inFd) {
return fmt.Errorf("stdin is not a terminal")
}
old, err := term.MakeRaw(b.inFd)
if err != nil {
return err
}
b.oldTerm = old
return nil
}
func (b *unixBackend) Fini() {
if b.resizeStopCh != nil {
close(b.resizeStopCh)
<-b.resizeDoneCh
b.resizeStopCh = nil
}
if b.oldTerm != nil {
term.Restore(b.inFd, b.oldTerm)
}
}
// Size delegates to exported WindowSize; getTerminalSize deleted
func (b *unixBackend) Size() (int, int) {
if w, h, ok := WindowSize(b.out); ok {
return w, h
}
return 80, 24 // Fallback
}
// WindowSize queries terminal dimensions for f without raw mode or
// Terminal lifecycle. ok=false when f is not a terminal.
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 {
return 0, 0, false
}
return int(ws.Col), int(ws.Row), true
}
func (b *unixBackend) Write(p []byte) error {
_, err := b.out.Write(p)
return err
}
// Read implements the polling logic previously in input.go
func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
// Buffer for single read
buf := make([]byte, 256)
for {
select {
case <-stopCh:
return nil, nil
default:
}
// Poll with timeout to allow checking stopCh
fds := []unix.PollFd{
{Fd: int32(b.inFd), Events: unix.POLLIN},
}
// Timeout to differentiate standalone ESC from escape sequences
n, err := unix.Poll(fds, escapeTimeoutMs)
if err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
return nil, err
}
if n == 0 {
// Timeout - return empty to let readLoop handle pending ESC
return nil, nil
}
// Read data
rn, err := unix.Read(b.inFd, buf)
if err != nil {
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
return nil, err
}
if rn == 0 {
// EOF
return nil, nil
}
// Return copy of data
ret := make([]byte, rn)
copy(ret, buf[:rn])
return ret, nil
}
}
func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
b.resizeStopCh = make(chan struct{})
b.resizeDoneCh = make(chan struct{})
go func() {
defer close(b.resizeDoneCh)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
defer signal.Stop(sigCh)
for {
select {
case <-b.resizeStopCh:
return
case <-sigCh:
w, h := b.Size()
handler(w, h)
}
}
}()
}
+159
View File
@@ -0,0 +1,159 @@
//go:build wasm
package terminal
import (
"os"
"syscall/js"
)
type wasmBackend struct {
width, height int
inputCh chan []byte
jsCallbacks []js.Func
returnEmptyNext bool // Signal to return empty on next Read() for standalone ESC
}
const escapeTimeoutMs = 10
func newBackend() Backend {
return &wasmBackend{
width: 80,
height: 24,
inputCh: make(chan []byte, 256),
}
}
func (b *wasmBackend) Init() error {
// Register JS callbacks
inputCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) > 0 {
data := make([]byte, args[0].Length())
js.CopyBytesToGo(data, args[0])
select {
case b.inputCh <- data:
default:
// Buffer full, drop input
}
}
return nil
})
b.jsCallbacks = append(b.jsCallbacks, inputCb)
js.Global().Set("goTerminalInput", inputCb)
resizeCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) >= 2 {
w, h := args[0].Int(), args[1].Int()
b.width, b.height = w, h
// Resize handler is set via SetResizeHandler, but we need to store it
// or have this callback call a method. For simplicity, we'll assign
// the handler to a struct field if we need dynamic updates,
// but here we rely on the struct field set by SetResizeHandler.
// However, SetResizeHandler might be called after Init.
// See below for corrected flow.
}
return nil
})
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
js.Global().Set("goTerminalResize", resizeCb)
// Initial size query
if xterm := js.Global().Get("xterm"); !xterm.IsUndefined() {
b.width = xterm.Get("cols").Int()
b.height = xterm.Get("rows").Int()
}
return nil
}
func (b *wasmBackend) Fini() {
for _, cb := range b.jsCallbacks {
cb.Release()
}
js.Global().Delete("goTerminalInput")
js.Global().Delete("goTerminalResize")
}
func (b *wasmBackend) Size() (int, int) {
return b.width, b.height
}
// WindowSize queries xterm.js dimensions. The file argument is ignored;
// WASM has a single terminal. ok=false when the xterm global is absent.
func WindowSize(_ *os.File) (w, h int, ok bool) {
xterm := js.Global().Get("xterm")
if xterm.IsUndefined() || xterm.IsNull() {
return 0, 0, false
}
return xterm.Get("cols").Int(), xterm.Get("rows").Int(), true
}
func (b *wasmBackend) Write(p []byte) error {
arr := js.Global().Get("Uint8Array").New(len(p))
js.CopyBytesToJS(arr, p)
js.Global().Call("goTerminalWrite", arr)
return nil
}
func (b *wasmBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
// After standalone ESC timeout, return empty to trigger readLoop's ESC emission
if b.returnEmptyNext {
b.returnEmptyNext = false
return nil, nil
}
select {
case data := <-b.inputCh:
// If we received exactly ESC, wait briefly for more data
// (in case it's start of escape sequence split across callbacks)
if len(data) == 1 && data[0] == 0x1b {
// Use JS setTimeout via a promise-based wait
moreCh := make(chan []byte, 1)
// Schedule timeout callback
var timeoutCb js.Func
timeoutCb = js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case moreCh <- nil:
default:
}
timeoutCb.Release()
return nil
})
js.Global().Call("setTimeout", timeoutCb, escapeTimeoutMs)
// Wait for more data or timeout
select {
case more := <-b.inputCh:
// More data arrived, combine
return append(data, more...), nil
case <-moreCh:
// Timeout, standalone ESC confirmed
// Signal next Read() to return empty (triggers readLoop standalone ESC logic)
b.returnEmptyNext = true
return data, nil
case <-stopCh:
return nil, nil
}
}
return data, nil
case <-stopCh:
return nil, nil
}
}
func (b *wasmBackend) SetResizeHandler(handler func(width, height int)) {
// Overwrite the resize callback to include the handler invocation
// This ensures the handler acts on the latest registration
resizeCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) >= 2 {
w, h := args[0].Int(), args[1].Int()
b.width, b.height = w, h
handler(w, h)
}
return nil
})
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
js.Global().Set("goTerminalResize", resizeCb)
}
+176
View File
@@ -0,0 +1,176 @@
//go:build windows
package terminal
import (
"fmt"
"os"
"time"
"golang.org/x/sys/windows"
)
const escapeTimeoutMs = 10
type windowsBackend struct {
stdin windows.Handle
stdout windows.Handle
oldStdinMode uint32
oldStdoutMode uint32
oldInputCP uint32
oldOutputCP uint32
resizeStopCh chan struct{}
resizeDoneCh chan struct{}
}
func newBackend() Backend {
return &windowsBackend{
stdin: windows.Handle(os.Stdin.Fd()),
stdout: windows.Handle(os.Stdout.Fd()),
}
}
func (b *windowsBackend) Init() error {
if os.Getenv("WT_SESSION") == "" && os.Getenv("WT_PROFILE_ID") == "" {
return fmt.Errorf("Windows Terminal required: WT_SESSION unset; conhost lacks alt screen")
}
if err := windows.GetConsoleMode(b.stdin, &b.oldStdinMode); err != nil {
return fmt.Errorf("GetConsoleMode stdin: %w", err)
}
if err := windows.GetConsoleMode(b.stdout, &b.oldStdoutMode); err != nil {
return fmt.Errorf("GetConsoleMode stdout: %w", err)
}
b.oldInputCP, _ = windows.GetConsoleCP()
b.oldOutputCP, _ = windows.GetConsoleOutputCP()
stdinMode := uint32(windows.ENABLE_MOUSE_INPUT |
windows.ENABLE_EXTENDED_FLAGS |
windows.ENABLE_VIRTUAL_TERMINAL_INPUT)
if err := windows.SetConsoleMode(b.stdin, stdinMode); err != nil {
return fmt.Errorf("SetConsoleMode stdin: %w", err)
}
stdoutMode := uint32(windows.ENABLE_PROCESSED_OUTPUT |
windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING |
windows.DISABLE_NEWLINE_AUTO_RETURN)
if err := windows.SetConsoleMode(b.stdout, stdoutMode); err != nil {
windows.SetConsoleMode(b.stdin, b.oldStdinMode)
return fmt.Errorf("SetConsoleMode stdout: %w", err)
}
windows.SetConsoleCP(65001)
windows.SetConsoleOutputCP(65001)
return nil
}
func (b *windowsBackend) Fini() {
if b.resizeStopCh != nil {
close(b.resizeStopCh)
<-b.resizeDoneCh
b.resizeStopCh = nil
}
if b.oldStdinMode != 0 {
windows.SetConsoleMode(b.stdin, b.oldStdinMode)
}
if b.oldStdoutMode != 0 {
windows.SetConsoleMode(b.stdout, b.oldStdoutMode)
}
if b.oldInputCP != 0 {
windows.SetConsoleCP(b.oldInputCP)
}
if b.oldOutputCP != 0 {
windows.SetConsoleOutputCP(b.oldOutputCP)
}
}
func windowSizeHandle(h windows.Handle) (int, int, bool) {
var info windows.ConsoleScreenBufferInfo
if err := windows.GetConsoleScreenBufferInfo(h, &info); err != nil {
return 0, 0, false
}
w := int(info.Window.Right-info.Window.Left) + 1
ht := int(info.Window.Bottom-info.Window.Top) + 1
if w < 1 || ht < 1 {
return 0, 0, false
}
return w, ht, true
}
// WindowSize queries terminal dimensions for f without console mode changes.
// ok=false when f is not a console handle.
func WindowSize(f *os.File) (w, h int, ok bool) {
return windowSizeHandle(windows.Handle(f.Fd()))
}
func (b *windowsBackend) Size() (int, int) {
if w, h, ok := windowSizeHandle(b.stdout); ok {
return w, h
}
return 80, 24
}
func (b *windowsBackend) Write(p []byte) error {
var written uint32
return windows.WriteFile(b.stdout, p, &written, nil)
}
func (b *windowsBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
buf := make([]byte, 256)
for {
select {
case <-stopCh:
return nil, nil
default:
}
ev, err := windows.WaitForSingleObject(b.stdin, uint32(escapeTimeoutMs))
if err != nil {
return nil, fmt.Errorf("WaitForSingleObject: %w", err)
}
if ev == windows.WAIT_TIMEOUT {
// Mirrors unix poll timeout: lets readLoop emit pending standalone ESC
return nil, nil
}
var n uint32
if err := windows.ReadFile(b.stdin, buf, &n, nil); err != nil {
return nil, fmt.Errorf("ReadFile: %w", err)
}
if n == 0 {
// VTP consumed a non-keyboard record (e.g. focus event) producing no bytes
return nil, nil
}
ret := make([]byte, n)
copy(ret, buf[:n])
return ret, nil
}
}
func (b *windowsBackend) SetResizeHandler(handler func(int, int)) {
b.resizeStopCh = make(chan struct{})
b.resizeDoneCh = make(chan struct{})
go func() {
defer close(b.resizeDoneCh)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
w, h := b.Size()
for {
select {
case <-b.resizeStopCh:
return
case <-ticker.C:
nw, nh := b.Size()
if nw != w || nh != h {
w, h = nw, nh
handler(w, h)
}
}
}
}()
}
+187
View File
@@ -0,0 +1,187 @@
package terminal
import "math"
const softLightLUTSize = 256
// Perez SoftLight lookup tables (array access, no pointers)
// Pre-computed at init to avoid sqrt/division in per-cell loops
var (
softLightG [softLightLUTSize]float64
softLightDF [softLightLUTSize]float64
)
func init() {
for i := range softLightLUTSize {
df := float64(i) / 255.0
softLightDF[i] = df
if df <= 0.25 {
softLightG[i] = ((16.0*df-12.0)*df + 4.0) * df
} else {
softLightG[i] = math.Sqrt(df)
}
}
}
// clampU8 converts float to uint8 with saturation
func clampU8(v float64) uint8 {
if v >= 255.0 {
return 255
}
if v <= 0.0 {
return 0
}
// Round-half-up; unifies rounding across Blend/Scale/Lerp/SoftLight
return uint8(v + 0.5)
}
// addU8 is saturating uint8 addition
func addU8(a, b uint8) uint8 {
sum := int(a) + int(b)
if sum > 255 {
return 255
}
return uint8(sum)
}
// fastDiv255 approximates x / 255 using integer math: (x + (x >> 8) + 1) >> 8
// Faster than DIV instruction, exact for x in [0, 255*255]
func fastDiv255(x int) int {
return (x + (x >> 8) + 1) >> 8
}
// softLightChannel applies Perez soft light to one channel via LUTs
func softLightChannel(d, s uint8, intensity float64) uint8 {
df := softLightDF[d]
sf := softLightDF[s]
var result float64
if sf < 0.5 {
result = df - (1.0-2.0*sf)*df*(1.0-df)
} else {
// LUT replaces math.Sqrt
result = df + (2.0*sf-1.0)*(softLightG[d]-df)
}
// Lerp toward result by intensity, single dependency chain
result = df + (result-df)*intensity
return clampU8(result * 255.0)
}
// overlayChannel combines multiply (d < 128) and screen (d >= 128),
// preserving destination highlights and shadows
func overlayChannel(d, s uint8) uint8 {
if d < 128 {
return uint8(fastDiv255(2 * int(d) * int(s)))
}
return uint8(255 - fastDiv255(2*(255-int(d))*(255-int(s))))
}
// Blend performs linear alpha blend of src over dst
// alpha <= 0 returns dst, alpha >= 1 returns src
func Blend(dst, src RGB, alpha float64) RGB {
if alpha >= 1.0 {
return src
}
if alpha <= 0.0 {
return dst
}
inv := 1.0 - alpha
return RGB{
R: clampU8(float64(src.R)*alpha + float64(dst.R)*inv),
G: clampU8(float64(src.G)*alpha + float64(dst.G)*inv),
B: clampU8(float64(src.B)*alpha + float64(dst.B)*inv),
}
}
// SoftLight applies Perez soft light blend, gentler than linear alpha
// intensity in [0,1] mixes between dst and the blended result
func SoftLight(dst, src RGB, intensity float64) RGB {
return RGB{
R: softLightChannel(dst.R, src.R, intensity),
G: softLightChannel(dst.G, src.G, intensity),
B: softLightChannel(dst.B, src.B, intensity),
}
}
// Max returns per-channel maximum, alpha-blended over dst
func Max(dst, src RGB, alpha float64) RGB {
if alpha <= 0.0 {
return dst
}
maxed := RGB{
R: max(dst.R, src.R),
G: max(dst.G, src.G),
B: max(dst.B, src.B),
}
if alpha >= 1.0 {
return maxed
}
return Blend(dst, maxed, alpha)
}
// Add performs saturating additive blend, alpha-blended over dst
func Add(dst, src RGB, alpha float64) RGB {
if alpha <= 0.0 {
return dst
}
added := RGB{
R: addU8(dst.R, src.R),
G: addU8(dst.G, src.G),
B: addU8(dst.B, src.B),
}
if alpha >= 1.0 {
return added
}
return Blend(dst, added, alpha)
}
// Screen applies 1-(1-dst)*(1-src), alpha-blended over dst
// Always lightens; useful for glow accumulation without clipping harshness of Add
func Screen(dst, src RGB, alpha float64) RGB {
if alpha <= 0.0 {
return dst
}
screened := RGB{
R: uint8(255 - fastDiv255((255-int(dst.R))*(255-int(src.R)))),
G: uint8(255 - fastDiv255((255-int(dst.G))*(255-int(src.G)))),
B: uint8(255 - fastDiv255((255-int(dst.B))*(255-int(src.B)))),
}
if alpha >= 1.0 {
return screened
}
return Blend(dst, screened, alpha)
}
// Overlay combines multiply (darks) and screen (lights), alpha-blended over dst
func Overlay(dst, src RGB, alpha float64) RGB {
if alpha <= 0.0 {
return dst
}
overlaid := RGB{
R: overlayChannel(dst.R, src.R),
G: overlayChannel(dst.G, src.G),
B: overlayChannel(dst.B, src.B),
}
if alpha >= 1.0 {
return overlaid
}
return Blend(dst, overlaid, alpha)
}
// Scale multiplies all channels by factor, saturating (factor > 1.0 brightens)
func Scale(c RGB, factor float64) RGB {
return RGB{
R: clampU8(float64(c.R) * factor),
G: clampU8(float64(c.G) * factor),
B: clampU8(float64(c.B) * factor),
}
}
// Grayscale converts to grayscale using Rec. 601 luma coefficients
// Y = R*0.299 + G*0.587 + B*0.114, integer math
func Grayscale(c RGB) RGB {
gray := uint8((int(c.R)*299 + int(c.G)*587 + int(c.B)*114) / 1000)
return RGB{R: gray, G: gray, B: gray}
}
+115
View File
@@ -0,0 +1,115 @@
package terminal
// ColorMode indicates terminal color capability
type ColorMode uint8
const (
ColorMode256 ColorMode = iota // xterm-256 palette
ColorModeTrueColor // 24-bit RGB
)
// RGB represents a 24-bit color
type RGB struct {
R uint8 `toml:"r"`
G uint8 `toml:"g"`
B uint8 `toml:"b"`
}
// Lerp linearly interpolates from c to other by t, clamped to [0,1]
func (c RGB) Lerp(other RGB, t float64) RGB {
if t <= 0 {
return c
}
if t >= 1 {
return other
}
return RGB{
R: clampU8(float64(c.R) + (float64(other.R)-float64(c.R))*t),
G: clampU8(float64(c.G) + (float64(other.G)-float64(c.G))*t),
B: clampU8(float64(c.B) + (float64(other.B)-float64(c.B))*t),
}
}
// RGBBlack is the zero value black color
var RGBBlack = RGB{0, 0, 0}
// 6-bit quantized LUT for Redmean-based 256-color mapping
// 64×64×64 = 262,144 bytes, fits in L2 cache
var lut256 [64 * 64 * 64]uint8
func init() {
// Pre-compute Redmean-based palette mapping for all 6-bit quantized RGB values
for r := 0; r < 64; r++ {
for g := 0; g < 64; g++ {
for b := 0; b < 64; b++ {
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
r8 := (r << 2) | 2
g8 := (g << 2) | 2
b8 := (b << 2) | 2
lut256[r<<12|g<<6|b] = computeRedmean256(r8, g8, b8)
}
}
}
}
// computeRedmean256 finds the nearest 256-palette index using Redmean distance
// Called only at init() to populate LUT
func computeRedmean256(r, g, b int) uint8 {
// Grayscale fast path
if r == g && g == b {
if r < 8 {
return 16
}
if r > 238 {
return 231
}
return uint8(232 + (r-8)/10)
}
bestIdx := uint8(16)
minDist := 1 << 30
// Search 6×6×6 cube (indices 16-231)
for i := 0; i < 216; i++ {
cr := cubeValues[i/36]
cg := cubeValues[(i/6)%6]
cb := cubeValues[i%6]
d := redmeanDistance(r, g, b, cr, cg, cb)
if d < minDist {
minDist = d
bestIdx = uint8(16 + i)
}
}
// Search grayscale ramp (indices 232-255)
for i := 0; i < 24; i++ {
gray := 8 + i*10
d := redmeanDistance(r, g, b, gray, gray, gray)
if d < minDist {
minDist = d
bestIdx = uint8(232 + i)
}
}
return bestIdx
}
// redmeanDistance calculates perceptually-weighted color distance
// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB
func redmeanDistance(r1, g1, b1, r2, g2, b2 int) int {
rmean := (r1 + r2) / 2
dr := r1 - r2
dg := g1 - g2
db := b1 - b2
return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8)
}
// Color cube values for 6×6×6 palette (indices 16-231)
var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
// RGBTo256 converts RGB to nearest 256-color palette index
// O(1) lookup via pre-computed Redmean LUT
func RGBTo256(c RGB) uint8 {
return lut256[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
}
+52
View File
@@ -0,0 +1,52 @@
//go:build unix
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
}
if os.Getenv("KITTY_WINDOW_ID") != "" ||
os.Getenv("KONSOLE_VERSION") != "" ||
os.Getenv("ITERM_SESSION_ID") != "" ||
os.Getenv("ALACRITTY_WINDOW_ID") != "" ||
os.Getenv("ALACRITTY_LOG") != "" ||
os.Getenv("WEZTERM_PANE") != "" {
return ColorModeTrueColor
}
term := os.Getenv("TERM")
if strings.Contains(term, "truecolor") ||
strings.Contains(term, "24bit") ||
strings.Contains(term, "direct") {
return ColorModeTrueColor
}
return ColorMode256
}
// resetTerminalMode attempts to restore terminal to cooked mode
// Best-effort for crash recovery; errors ignored
func resetTerminalMode() {
// Try to restore via /dev/tty (works even if stdin redirected)
if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
fd := int(tty.Fd())
// Get current termios, enable ECHO and ICANON
if termios, err := unix.IoctlGetTermios(fd, unix.TCGETS); err == nil {
termios.Lflag |= unix.ECHO | unix.ICANON | unix.ISIG | unix.IEXTEN
termios.Iflag |= unix.ICRNL
unix.IoctlSetTermios(fd, unix.TCSETS, termios)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build wasm
package terminal
// DetectColorMode determines terminal color capability from environment
func DetectColorMode() ColorMode {
// Browsers/xterm.js generally support true color
return ColorModeTrueColor
}
// resetTerminalMode is no-op for WASM; termios does not exist
func resetTerminalMode() {}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package terminal
import (
"os"
"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
}
func resetTerminalMode() {
saneIn := uint32(windows.ENABLE_PROCESSED_INPUT |
windows.ENABLE_LINE_INPUT |
windows.ENABLE_ECHO_INPUT |
windows.ENABLE_MOUSE_INPUT |
windows.ENABLE_QUICK_EDIT_MODE |
windows.ENABLE_EXTENDED_FLAGS)
saneOut := uint32(windows.ENABLE_PROCESSED_OUTPUT | windows.ENABLE_WRAP_AT_EOL_OUTPUT)
if h, err := openConsoleDev("CONIN$"); err == nil {
windows.SetConsoleMode(h, saneIn)
windows.CloseHandle(h)
}
if h, err := openConsoleDev("CONOUT$"); err == nil {
windows.SetConsoleMode(h, saneOut)
windows.CloseHandle(h)
}
}
func openConsoleDev(name string) (windows.Handle, error) {
return windows.CreateFile(
windows.StringToUTF16Ptr(name),
windows.GENERIC_READ|windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
nil,
windows.OPEN_EXISTING,
0,
0,
)
}
+48
View File
@@ -0,0 +1,48 @@
// Package terminal provides direct ANSI terminal control with zero-alloc rendering.
//
// # Features
//
// - True color (24-bit) and 256-color palette support
// - Double-buffered output with cell-level diffing
// - Raw stdin input parsing with escape sequence handling
// - Resize detection (SIGWINCH on Unix, callback on WASM)
// - Clean terminal restoration on exit/panic
//
// # Platform Support
//
// The package uses build tags to separate platform-specific code:
//
// - unix: Native terminal via termios, unix.Poll, SIGWINCH
// - wasm: Browser terminal via xterm.js JavaScript bridge
//
// # Architecture
//
// The Backend interface abstracts platform-specific operations:
//
// Backend (interface)
// ├── unixBackend (//go:build unix) - termios, raw I/O, signals
// └── wasmBackend (//go:build wasm) - syscall/js, callbacks
//
// Shared code (no build tags): Terminal interface, cell diffing, ANSI generation,
// escape sequence parsing, service lifecycle.
//
// # WASM Integration
//
// WASM builds require JavaScript glue exposing these globals:
//
// goTerminalWrite(Uint8Array) // Go → JS: terminal output
// goTerminalInput(Uint8Array) // JS → Go: keyboard input
// goTerminalResize(cols, rows) // JS → Go: terminal resize
// xterm.cols, xterm.rows // Initial size query
//
// # Performance
//
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls.
// Input parsing is zero-allocation for common cases.
//
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
// Target environments: Linux, macOS, BSDs with xterm-compatible terminals, and
// modern browsers with xterm.js.
package terminal
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"fmt"
"os"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/inline"
)
func main() {
p := inline.New(os.Stdout)
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
dim := inline.Fg(terminal.IronGray)
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
frame := 0
for i, pkg := range pkgs {
const steps = 25
for s := range steps {
pct := (float64(i) + float64(s)/steps) / float64(len(pkgs))
p.Update(
inline.Spinner(frame)+" installing "+p.Paint(pkg, name),
"["+inline.Bar(32, pct, inline.BarBlock)+"] "+
p.Paint(fmt.Sprintf("%d/%d", i+1, len(pkgs)), dim),
)
frame++
time.Sleep(40 * time.Millisecond)
}
p.Log("%s %s", p.Paint("✓", okSt), pkg)
}
p.Done(p.Paint("✓ 5 packages installed", okSt))
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/lixenwraith/terminal
go 1.26.4
require (
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
)
+4
View File
@@ -0,0 +1,4 @@
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+141
View File
@@ -0,0 +1,141 @@
# inline
Styled text and in-place progress in the normal terminal scrollback. No raw
mode, no alternate screen, no input handling, no cursor hiding — the shell
keeps owning the terminal. Intended for CLI tools (package managers, service
tooling, build scripts) that want color and live status without a full-screen
TUI.
Unix only (`//go:build unix`). Depends on the parent `terminal` package for
color types, capability detection, and RGB → 256 mapping.
## Model
Output is split into two zones:
installed openssl ← permanent lines (Log) — scroll normally
installed zlib
⠹ installing curl ← live block (Update) — rewritten in place
[██████░░░░░░░░] 2/5
`Log` prints permanent lines above the live block; `Update` replaces the live
block by cursor-up + clear + rewrite; `Done` erases the block and optionally
prints final lines. Interleaving is handled internally — `Log` during an
active live block erases, prints, and redraws in one flush.
## API
### Printer
| Method | Description |
|---|---|
| `New(w io.Writer) *Printer` | Creates a printer. Terminal detection via size probe; color defaults on for terminals with `NO_COLOR` unset. Safe for concurrent use. |
| `Log(format string, a ...any)` | Prints one permanent line above the live block (`Printf` semantics, newline appended). |
| `Update(lines ...string)` | Replaces the live block, rewriting in place. No-op on non-terminal output. |
| `Done(final ...string)` | Erases the live block and prints final permanent lines. Call before exit. |
| `Paint(s string, st Style) string` | Returns `s` wrapped in SGR codes for the detected color mode, or unchanged when color is off. |
| `SetColor(on bool)` | Overrides color detection (e.g. force styling into a pipe for `less -R`). Affects `Paint` only; `Update` remains terminal-gated. |
| `Size() (w, h int)` | Current terminal dimensions, 80×24 when unknown. |
### Style
Value type, zero value is unstyled, builder-composable:
| Function | Description |
|---|---|
| `Fg(c terminal.RGB) Style` | Starts a style with foreground color. |
| `(s Style) Bg(c terminal.RGB) Style` | Adds background color. |
| `(s Style) Attr(a terminal.Attr) Style` | Adds attribute bits (`AttrBold`, `AttrDim`, ...). |
```go
warn := inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
p.Log("%s low disk space", p.Paint("warning:", warn))
```
True color terminals get `38;2;R;G;B`; 256-color terminals get `38;5;N` via
Redmean mapping — same degradation path as the parent package.
### Progress helpers
Pure string builders, no Printer required:
| Function | Description |
|---|---|
| `Bar(width int, pct float64, chars [3]rune) string` | Progress bar of `width` cells, `pct` clamped to [0,1], half-cell resolution via the partial rune. |
| `BarBlock` | Default character set `[3]rune{'█', '▌', '░'}`. |
| `Spinner(frame int) string` | Braille spinner frame for a monotonic counter. |
Compose with `Paint` for colored bars:
```go
line := "[" + p.Paint(inline.Bar(30, pct, inline.BarBlock), barStyle) + "]"
```
## Non-terminal output
When output is a pipe or file (CI, redirection): `Update` is a no-op, `Paint`
returns input unchanged, `Log` and `Done` print plain sequential text. A tool
using inline degrades to ordinary log output with no code changes.
## Example
Simulated package installation — spinner, overall progress bar, permanent
completion lines:
```go
package main
import (
"fmt"
"os"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/inline"
)
func main() {
p := inline.New(os.Stdout)
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
dim := inline.Fg(terminal.IronGray)
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
frame := 0
for i, pkg := range pkgs {
const steps = 25
for s := range steps {
pct := (float64(i) + float64(s)/steps) / float64(len(pkgs))
p.Update(
inline.Spinner(frame)+" installing "+p.Paint(pkg, name),
"["+inline.Bar(32, pct, inline.BarBlock)+"] "+
p.Paint(fmt.Sprintf("%d/%d", i+1, len(pkgs)), dim),
)
frame++
time.Sleep(40 * time.Millisecond)
}
p.Log("%s %s", p.Paint("✓", okSt), pkg)
}
p.Done(p.Paint("✓ 5 packages installed", okSt))
}
```
Run in a terminal: the two-line status block animates in place while
completion lines accumulate above it. Piped (`go run . | cat`): only the
completion lines and the final summary appear, unstyled.
## Notes
- Width is measured in runes (`unicode/utf8`); East Asian wide characters and
combining marks are not width-aware — same limitation as `tui`.
- Live block lines must occupy one visual row each: no `\n`, tabs, or control
characters. Lines are truncated to terminal width automatically; embedded
SGR from `Paint` is preserved through truncation.
- The live block is clamped to terminal height 1 rows (newest lines kept).
- Pass external strings (package names, paths) as `Log` arguments, never as
the format string.
- Ctrl-C mid-update leaves the live block on screen but the terminal in a
normal state — no raw mode or screen buffer to restore.
+137
View File
@@ -0,0 +1,137 @@
//go:build unix
// Package inline renders styled text and in-place progress in the normal
// terminal scrollback: no raw mode, no alternate screen, no input handling,
// no cursor hiding. Intended for CLI tools that want color and live status
// without owning the screen.
//
// Model: permanent lines scroll via Log; a live block of status lines is
// pinned below them and rewritten in place via Update. Done erases the
// block and optionally prints final permanent lines.
//
// Non-terminal output (pipes, CI): Update is a no-op, styling is stripped
// unless overridden with SetColor(true), Log and Done print plainly.
//
// Width is measured in runes (unicode/utf8); wide and combining characters
// are not width-aware — same documented limitation as tui.
package inline
import (
"bufio"
"fmt"
"io"
"os"
"sync"
"github.com/lixenwraith/terminal"
)
// Printer manages styled output and the live block. Safe for concurrent use.
type Printer struct {
mu sync.Mutex
w *bufio.Writer
tty *os.File // non-nil when output is a terminal
color bool
mode terminal.ColorMode
live []string // desired live block content
drawn int // lines currently on screen (may be clamped below len(live))
}
// New creates a Printer for w. Terminal detection via WindowSize probe;
// styling defaults on for terminals with NO_COLOR unset.
func New(w io.Writer) *Printer {
p := &Printer{w: bufio.NewWriter(w)}
if f, isFile := w.(*os.File); isFile {
if _, _, ok := terminal.WindowSize(f); ok {
p.tty = f
}
}
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
p.mode = terminal.DetectColorMode()
return p
}
// SetColor overrides style detection. Affects Paint only; live-block
// updates remain terminal-gated.
func (p *Printer) SetColor(on bool) {
p.mu.Lock()
p.color = on
p.mu.Unlock()
}
// Size returns terminal dimensions, 80×24 when unknown
func (p *Printer) Size() (w, h int) {
if p.tty != nil {
if w, h, ok := terminal.WindowSize(p.tty); ok {
return w, h
}
}
return 80, 24
}
// Log prints a permanent line above the live block
func (p *Printer) Log(format string, a ...any) {
p.mu.Lock()
defer p.mu.Unlock()
p.eraseLocked()
fmt.Fprintf(p.w, format, a...)
p.w.WriteByte('\n')
p.redrawLocked()
p.w.Flush()
}
// Update replaces the live block, rewriting in place. No-op on non-terminal output.
func (p *Printer) Update(lines ...string) {
p.mu.Lock()
defer p.mu.Unlock()
if p.tty == nil {
return
}
p.eraseLocked()
p.live = append(p.live[:0], lines...)
p.redrawLocked()
p.w.Flush()
}
// Done erases the live block and prints final permanent lines
func (p *Printer) Done(final ...string) {
p.mu.Lock()
defer p.mu.Unlock()
p.eraseLocked()
p.live = p.live[:0]
for _, ln := range final {
p.w.WriteString(ln)
p.w.WriteByte('\n')
}
p.w.Flush()
}
// eraseLocked removes the drawn live block; cursor ends at block origin.
// Cursor sits one line below the block (redraw ends each line with '\n').
func (p *Printer) eraseLocked() {
if p.tty == nil || p.drawn == 0 {
return
}
fmt.Fprintf(p.w, "\x1b[%dA\r\x1b[J", p.drawn)
p.drawn = 0
}
// redrawLocked writes the live block; assumes screen below cursor is clear.
// Lines are truncated to terminal width so cursor-up arithmetic stays valid
// (relies on xterm deferred autowrap for exact-width lines). Block is
// clamped to height-1 rows, keeping newest lines.
func (p *Printer) redrawLocked() {
if p.tty == nil || len(p.live) == 0 {
return
}
w, h := p.Size()
lines := p.live
if h > 1 && len(lines) > h-1 {
lines = lines[len(lines)-(h-1):]
}
for _, ln := range lines {
p.w.WriteString(truncVisible(ln, w))
p.w.WriteByte('\n')
}
p.drawn = len(lines)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build unix
package inline
import "strings"
// BarBlock is the default bar character set [filled, partial, empty]
var BarBlock = [3]rune{'█', '▌', '░'}
// Bar renders an unstyled progress bar of width cells, pct in [0,1]
func Bar(width int, pct float64, chars [3]rune) string {
if width < 1 {
return ""
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(width) * pct)
rem := float64(width)*pct - float64(filled)
var b strings.Builder
b.Grow(width * 3) // Worst-case UTF-8
for i := range width {
switch {
case i < filled:
b.WriteRune(chars[0])
case i == filled && rem >= 0.5 && filled < width:
b.WriteRune(chars[1])
default:
b.WriteRune(chars[2])
}
}
return b.String()
}
// Braille frames; intentionally duplicated from tui (no tui dependency)
var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// Spinner returns the frame for a monotonic counter
func Spinner(frame int) string {
i := frame % len(spinnerFrames)
if i < 0 {
i = -i
}
return spinnerFrames[i]
}
+142
View File
@@ -0,0 +1,142 @@
//go:build unix
package inline
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/lixenwraith/terminal"
)
// Style describes text appearance; zero value is unstyled.
// Composable: inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
type Style struct {
fg, bg terminal.RGB
hasFg, hasBg bool
attr terminal.Attr
}
// Fg starts a style with foreground color
func Fg(c terminal.RGB) Style { return Style{fg: c, hasFg: true} }
// Bg sets background color
func (s Style) Bg(c terminal.RGB) Style { s.bg, s.hasBg = c, true; return s }
// Attr adds attribute bits
func (s Style) Attr(a terminal.Attr) Style { s.attr |= a; return s }
// Paint returns s styled for the detected terminal, unchanged when color
// is disabled. Composes with Log: p.Log("%s %s", p.Paint("ok", st), name)
func (p *Printer) Paint(s string, st Style) string {
if !p.color {
return s
}
var b strings.Builder
p.writeSGR(&b, st)
b.WriteString(s)
b.WriteString("\x1b[0m")
return b.String()
}
func (p *Printer) writeSGR(b *strings.Builder, s Style) {
b.WriteString("\x1b[0")
for _, m := range [...]struct {
bit terminal.Attr
code string
}{
{terminal.AttrBold, ";1"}, {terminal.AttrDim, ";2"},
{terminal.AttrItalic, ";3"}, {terminal.AttrUnderline, ";4"},
{terminal.AttrBlink, ";5"}, {terminal.AttrReverse, ";7"},
} {
if s.attr&m.bit != 0 {
b.WriteString(m.code)
}
}
if s.hasFg {
if p.mode == terminal.ColorModeTrueColor {
fmt.Fprintf(b, ";38;2;%d;%d;%d", s.fg.R, s.fg.G, s.fg.B)
} else {
fmt.Fprintf(b, ";38;5;%d", terminal.RGBTo256(s.fg))
}
}
if s.hasBg {
if p.mode == terminal.ColorModeTrueColor {
fmt.Fprintf(b, ";48;2;%d;%d;%d", s.bg.R, s.bg.G, s.bg.B)
} else {
fmt.Fprintf(b, ";48;5;%d", terminal.RGBTo256(s.bg))
}
}
b.WriteByte('m')
}
// --- Width handling (internal, rune-count semantics) ---
// Handles only 'm'-terminated escapes — this package's own SGR output.
// visibleLen counts runes excluding SGR sequences
func visibleLen(s string) int {
n := 0
for {
i := strings.IndexByte(s, 0x1b)
if i < 0 {
return n + utf8.RuneCountInString(s)
}
n += utf8.RuneCountInString(s[:i])
m := strings.IndexByte(s[i:], 'm')
if m < 0 {
return n // Unterminated escape, remainder not visible
}
s = s[i+m+1:]
}
}
// runePrefix returns up to k leading runes of s and the count taken
func runePrefix(s string, k int) (string, int) {
if k <= 0 {
return "", 0
}
n := 0
for i := range s {
if n == k {
return s[:i], n
}
n++
}
return s, n
}
// truncVisible truncates to max visible runes, preserving embedded SGR
// sequences and appending a reset when cut
func truncVisible(s string, max int) string {
if visibleLen(s) <= max {
return s
}
var b strings.Builder
n := 0
for len(s) > 0 {
i := strings.IndexByte(s, 0x1b)
if i != 0 {
seg := s
if i > 0 {
seg = s[:i]
}
pre, taken := runePrefix(seg, max-n)
b.WriteString(pre)
n += taken
if n >= max {
break
}
s = s[len(seg):]
continue
}
m := strings.IndexByte(s, 'm')
if m < 0 {
break // Unterminated escape, drop remainder
}
b.WriteString(s[:m+1])
s = s[m+1:]
}
b.WriteString("\x1b[0m")
return b.String()
}
+598
View File
@@ -0,0 +1,598 @@
package terminal
import (
"fmt"
"os"
"runtime/debug"
"sync"
"time"
)
// EventType distinguishes input event categories
type EventType uint8
const (
EventKey EventType = iota
EventResize
EventPaste // Future: bracketed paste
EventMouse // SGR mouse reporting
EventError // Read error
EventClosed // Input closed
)
// Event represents a terminal input event
type Event struct {
Type EventType
Key Key
Rune rune
Modifiers Modifier
Width int // For EventResize
Height int // For EventResize
Err error // For EventError
// Mouse event fields
MouseX int
MouseY int
MouseBtn MouseButton
MouseAction MouseAction
}
// inputReader handles raw stdin parsing
type inputReader struct {
backend Backend
eventCh chan Event
stopCh chan struct{}
doneCh chan struct{}
// Persistent buffer for stream assembly, not fixed size zero-alloc to avoid corrupting partial UTF-8 at boundary
buf []byte
mu sync.Mutex
running bool
}
// newInputReader creates a new input reader
func newInputReader(backend Backend) *inputReader {
return &inputReader{
backend: backend,
eventCh: make(chan Event, 256),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
buf: make([]byte, 0, 256),
}
}
// start begins reading input in a goroutine
func (r *inputReader) start() {
r.mu.Lock()
if r.running {
r.mu.Unlock()
return
}
r.running = true
r.mu.Unlock()
go r.readLoop()
}
// stop signals the reader to stop
func (r *inputReader) stop() {
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return
}
r.running = false
r.mu.Unlock()
close(r.stopCh)
// Wait with timeout - don't block forever if read is stuck
select {
case <-r.doneCh:
case <-time.After(100 * time.Millisecond):
// Reader stuck on blocking read, proceed anyway
}
}
// events returns the event channel
func (r *inputReader) events() <-chan Event {
return r.eventCh
}
// readLoop is the main input reading goroutine
func (r *inputReader) readLoop() {
defer close(r.doneCh)
// Panic recovery for raw input reader
defer func() {
if r := recover(); r != nil {
EmergencyReset(os.Stdout)
// 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, "Stack Trace:\r\n%s\r\n", debug.Stack())
os.Exit(1)
}
}()
for {
// Blocking read from backend
data, err := r.backend.Read(r.stopCh)
if err != nil {
r.sendEvent(Event{Type: EventError, Err: err})
return
}
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})
r.buf = r.buf[:0]
}
select {
case <-r.stopCh:
r.sendEvent(Event{Type: EventClosed})
return
default:
continue
}
}
// Append to persistent buffer
r.buf = append(r.buf, data...)
// Parse as much as possible, get consumed count
consumed := r.parseInput(r.buf)
// Compact buffer
if consumed > 0 {
if consumed >= len(r.buf) {
r.buf = r.buf[:0]
} else {
copy(r.buf, r.buf[consumed:])
r.buf = r.buf[:len(r.buf)-consumed]
}
}
}
}
// parseInput parses raw bytes into events and returns bytes consumed (stop on incomplete sequence)
func (r *inputReader) parseInput(data []byte) int {
i := 0
n := len(data)
for i < n {
select {
case <-r.stopCh:
return i
default:
}
b := data[i]
// Fast path: printable ASCII
if b >= 0x20 && b < 0x7f {
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
i++
continue
}
// Escape sequence
if b == 0x1b {
// Need at least 2 bytes to determine sequence type
if i+1 >= n {
return i // Wait for more data
}
consumed, ev := r.parseEscape(data[i:])
if consumed == 0 {
// Incomplete sequence, wait for more data
return i
}
// Only emit if not a swallowed unknown sequence
if ev.Key != KeyNone || ev.Type != EventKey {
r.sendEvent(ev)
}
i += consumed
continue
}
// Control characters
if b < 0x20 {
r.sendEvent(r.parseControl(b))
i++
continue
}
// DEL
if b == 0x7f {
r.sendEvent(Event{Type: EventKey, Key: KeyBackspace})
i++
continue
}
// UTF-8 multibyte
if b >= 0x80 {
// Check if full sequence available
seqLen := utf8SeqLen(b)
if seqLen == 0 {
// Invalid start byte, skip
i++
continue
}
if i+seqLen > n {
// Incomplete UTF-8, wait for more data
return i
}
rn, size := decodeRune(data[i:])
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rn})
i += size
continue
}
i++
}
return i
}
// utf8SeqLen returns expected UTF-8 sequence length from start byte, 0 if invalid
func utf8SeqLen(b byte) int {
if b < 0x80 {
return 1
}
if b&0xe0 == 0xc0 {
return 2
}
if b&0xf0 == 0xe0 {
return 3
}
if b&0xf8 == 0xf0 {
return 4
}
return 0 // Invalid
}
// parseEscape attempts to parse an escape sequence, returns 0 on incomplete
func (r *inputReader) parseEscape(data []byte) (int, Event) {
if len(data) < 2 {
return 0, Event{} // Incomplete, wait for more
}
// ESC ESC -> Alt+Escape
if data[1] == 0x1b {
return 2, Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
}
if data[1] == '[' {
return r.parseCSI(data)
}
if data[1] == 'O' {
return r.parseSS3(data)
}
// Alt+Control character (ESC + 0x00-0x1F)
if data[1] < 0x20 {
ev := r.parseControl(data[1])
ev.Modifiers |= ModAlt
return 2, ev
}
// Alt+printable
if data[1] >= 0x20 && data[1] < 0x7f {
return 2, Event{Type: EventKey, Key: KeyRune, Rune: rune(data[1]), Modifiers: ModAlt}
}
return 0, Event{}
}
// parseCSI parses CSI sequence without allocation
func (r *inputReader) parseCSI(data []byte) (int, Event) {
if len(data) < 3 {
return 0, Event{}
}
// SGR mouse: ESC [ < Btn ; X ; Y M/m
if data[2] == '<' {
return r.parseSGRMouse(data)
}
end := 2
maxScan := len(data)
if maxScan > 16 {
maxScan = 16
}
for end < maxScan {
b := data[end]
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' {
end++
break
}
if b < 0x20 || b > 0x7e {
return 0, Event{}
}
end++
}
// Check if we found a terminator or ran out of data
if end <= 2 || end > maxScan {
return 0, Event{} // Incomplete
}
// Check last byte is valid terminator
lastByte := data[end-1]
if !((lastByte >= 'A' && lastByte <= 'Z') || (lastByte >= 'a' && lastByte <= 'z') || lastByte == '~') {
return 0, Event{} // Incomplete, no terminator found
}
if key, mod, ok := lookupCSI(data[2:end]); ok {
return end, Event{Type: EventKey, Key: key, Modifiers: mod}
}
// Unknown but valid CSI syntax - consume and return KeyNone
return end, Event{Type: EventKey, Key: KeyNone}
}
// parseSS3 parses SS3 sequence without allocation, returns length even for unknown sequences
func (r *inputReader) parseSS3(data []byte) (int, Event) {
if len(data) < 3 {
return 0, Event{}
}
if key, mod, ok := lookupSS3(data[2:3]); ok {
return 3, Event{Type: EventKey, Key: key, Modifiers: mod}
}
// Unknown SS3 - consume to prevent garbage
return 3, Event{Type: EventKey, Key: KeyNone}
}
// parseControl maps control characters to keys
func (r *inputReader) parseControl(b byte) Event {
switch b {
case 0x00: // Ctrl+Space or Ctrl+@
return Event{Type: EventKey, Key: KeyCtrlSpace}
case 0x01:
return Event{Type: EventKey, Key: KeyCtrlA}
case 0x02:
return Event{Type: EventKey, Key: KeyCtrlB}
case 0x03:
return Event{Type: EventKey, Key: KeyCtrlC}
case 0x04:
return Event{Type: EventKey, Key: KeyCtrlD}
case 0x05:
return Event{Type: EventKey, Key: KeyCtrlE}
case 0x06:
return Event{Type: EventKey, Key: KeyCtrlF}
case 0x07:
return Event{Type: EventKey, Key: KeyCtrlG}
case 0x08: // Ctrl+H or Backspace
return Event{Type: EventKey, Key: KeyBackspace}
case 0x09: // Tab
return Event{Type: EventKey, Key: KeyTab}
case 0x0a, 0x0d: // LF, CR (Enter)
return Event{Type: EventKey, Key: KeyEnter}
case 0x0b:
return Event{Type: EventKey, Key: KeyCtrlK}
case 0x0c:
return Event{Type: EventKey, Key: KeyCtrlL}
case 0x0e:
return Event{Type: EventKey, Key: KeyCtrlN}
case 0x0f:
return Event{Type: EventKey, Key: KeyCtrlO}
case 0x10:
return Event{Type: EventKey, Key: KeyCtrlP}
case 0x11:
return Event{Type: EventKey, Key: KeyCtrlQ}
case 0x12:
return Event{Type: EventKey, Key: KeyCtrlR}
case 0x13:
return Event{Type: EventKey, Key: KeyCtrlS}
case 0x14:
return Event{Type: EventKey, Key: KeyCtrlT}
case 0x15:
return Event{Type: EventKey, Key: KeyCtrlU}
case 0x16:
return Event{Type: EventKey, Key: KeyCtrlV}
case 0x17:
return Event{Type: EventKey, Key: KeyCtrlW}
case 0x18:
return Event{Type: EventKey, Key: KeyCtrlX}
case 0x19:
return Event{Type: EventKey, Key: KeyCtrlY}
case 0x1a:
return Event{Type: EventKey, Key: KeyCtrlZ}
case 0x1b: // ESC (shouldn't reach here normally)
return Event{Type: EventKey, Key: KeyEscape}
case 0x1c:
return Event{Type: EventKey, Key: KeyCtrlBackslash}
case 0x1d:
return Event{Type: EventKey, Key: KeyCtrlBracketRight}
case 0x1e:
return Event{Type: EventKey, Key: KeyCtrlCaret}
case 0x1f:
return Event{Type: EventKey, Key: KeyCtrlUnderscore}
}
return Event{Type: EventKey, Key: KeyNone}
}
// parseSGRMouse parses mouse SGR sequences
func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
// Format: ESC [ < Btn ; X ; Y M/m
// Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes
if len(data) < 10 {
return 0, Event{}
}
// Find terminator M or m
end := 3
for end < len(data) && end < 32 {
if data[end] == 'M' || data[end] == 'm' {
break
}
end++
}
if end >= len(data) || (data[end] != 'M' && data[end] != 'm') {
return 0, Event{}
}
// Parse: Btn;X;Y
params := data[3:end]
btn, x, y, ok := parseSGRParams(params)
if !ok {
return 0, Event{}
}
ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
// Decode button and action
// Bits 0-1: button (0=left, 1=middle, 2=right, 3=release)
// Bit 5 (32): motion
// Bit 6 (64): scroll
buttonID := btn & 0x03
isMotion := btn&32 != 0
isScroll := btn&64 != 0
if isScroll {
// Scroll: buttonID 0=up, 1=down
if buttonID == 0 {
ev.MouseBtn = MouseBtnWheelUp
} else {
ev.MouseBtn = MouseBtnWheelDown
}
ev.MouseAction = MouseActionPress // Scroll is instantaneous
} else {
// Regular button
switch buttonID {
case 0:
ev.MouseBtn = MouseBtnLeft
case 1:
ev.MouseBtn = MouseBtnMiddle
case 2:
ev.MouseBtn = MouseBtnRight
case 3:
ev.MouseBtn = MouseBtnNone // Release with no specific button
}
if data[end] == 'M' {
if isMotion {
if ev.MouseBtn != MouseBtnNone {
ev.MouseAction = MouseActionDrag
} else {
ev.MouseAction = MouseActionMove
}
} else {
ev.MouseAction = MouseActionPress
}
} else {
ev.MouseAction = MouseActionRelease
}
}
// Extract modifiers from button byte
if btn&4 != 0 {
ev.Modifiers |= ModShift
}
if btn&8 != 0 {
ev.Modifiers |= ModAlt
}
if btn&16 != 0 {
ev.Modifiers |= ModCtrl
}
return end + 1, ev
}
// parseSGRParams extracts btn, x, y from "Btn;X;Y" format
func parseSGRParams(data []byte) (btn, x, y int, ok bool) {
state := 0 // 0=btn, 1=x, 2=y
val := 0
for _, b := range data {
if b == ';' {
switch state {
case 0:
btn = val
case 1:
x = val
}
state++
val = 0
if state > 2 {
return 0, 0, 0, false
}
} else if b >= '0' && b <= '9' {
val = val*10 + int(b-'0')
if val > 9999 { // Sanity limit
return 0, 0, 0, false
}
} else {
return 0, 0, 0, false
}
}
if state != 2 {
return 0, 0, 0, false
}
y = val
return btn, x, y, true
}
// sendEvent sends an event to the channel, non-blocking
func (r *inputReader) sendEvent(ev Event) {
select {
case r.eventCh <- ev:
default:
// Channel full, drop event (shouldn't happen with 64 buffer)
}
}
// decodeRune decodes the first UTF-8 rune from data
func decodeRune(data []byte) (rune, int) {
if len(data) == 0 {
return 0, 0
}
b := data[0]
if b < 0x80 {
return rune(b), 1
}
var size int
var min rune
var r rune
switch {
case b&0xe0 == 0xc0:
size = 2
min = 0x80
r = rune(b & 0x1f)
case b&0xf0 == 0xe0:
size = 3
min = 0x800
r = rune(b & 0x0f)
case b&0xf8 == 0xf0:
size = 4
min = 0x10000
r = rune(b & 0x07)
default:
return 0xFFFD, 1 // Invalid, return replacement char
}
if len(data) < size {
return 0xFFFD, 1
}
for i := 1; i < size; i++ {
if data[i]&0xc0 != 0x80 {
return 0xFFFD, 1
}
r = r<<6 | rune(data[i]&0x3f)
}
if r < min {
return 0xFFFD, 1 // Overlong encoding
}
return r, size
}
+395
View File
@@ -0,0 +1,395 @@
package terminal
// Key represents a parsed input key
type Key uint16
// Key constants - designed for expansion
const (
KeyNone Key = iota
KeyRune // Printable character (check Event.Rune)
// Control keys
KeyEscape
KeyEnter
KeyTab
KeyBacktab // Shift+Tab
KeyShiftTab // Same as KeyBacktab,for clarity
KeyBackspace
KeyDelete
KeySpace
// Navigation
KeyUp
KeyDown
KeyLeft
KeyRight
KeyHome
KeyEnd
KeyPageUp
KeyPageDown
KeyInsert
// Function keys
KeyF1
KeyF2
KeyF3
KeyF4
KeyF5
KeyF6
KeyF7
KeyF8
KeyF9
KeyF10
KeyF11
KeyF12
// Ctrl+letter (Ctrl+A = 0x01, Ctrl+Z = 0x1A)
KeyCtrlA
KeyCtrlB
KeyCtrlC
KeyCtrlD
KeyCtrlE
KeyCtrlF
KeyCtrlG
KeyCtrlH // Often same as Backspace
KeyCtrlI // Often same as Tab
KeyCtrlJ // Often same as Enter
KeyCtrlK
KeyCtrlL
KeyCtrlM // Often same as Enter
KeyCtrlN
KeyCtrlO
KeyCtrlP
KeyCtrlQ
KeyCtrlR
KeyCtrlS
KeyCtrlT
KeyCtrlU
KeyCtrlV
KeyCtrlW
KeyCtrlX
KeyCtrlY
KeyCtrlZ
// Ctrl+special
KeyCtrlSpace // Ctrl+@ / Ctrl+Space (Ctrl+@ produces NUL byte 0x00)
KeyCtrlBackslash
KeyCtrlBracketLeft
KeyCtrlBracketRight
KeyCtrlCaret
KeyCtrlUnderscore
)
// Modifier flags
type Modifier uint8
const (
ModNone Modifier = 0
ModShift Modifier = 1 << 0
ModAlt Modifier = 1 << 1
ModCtrl Modifier = 1 << 2
)
// escapeSequence maps escape sequences to keys
// Key: sequence after ESC [ (e.g., "A" for up arrow)
type escapeSequence struct {
seq string
key Key
mod Modifier
}
// Known escape sequences (CSI sequences: ESC [ ...)
var csiSequences = []escapeSequence{
// Arrow keys
{"A", KeyUp, ModNone},
{"B", KeyDown, ModNone},
{"C", KeyRight, ModNone},
{"D", KeyLeft, ModNone},
{"Z", KeyBacktab, ModShift}, // Shift+Tab
// Arrow keys with modifiers (xterm style: ESC [ 1 ; mod X)
{"1;2A", KeyUp, ModShift},
{"1;2B", KeyDown, ModShift},
{"1;2C", KeyRight, ModShift},
{"1;2D", KeyLeft, ModShift},
{"1;3A", KeyUp, ModAlt},
{"1;3B", KeyDown, ModAlt},
{"1;3C", KeyRight, ModAlt},
{"1;3D", KeyLeft, ModAlt},
{"1;5A", KeyUp, ModCtrl},
{"1;5B", KeyDown, ModCtrl},
{"1;5C", KeyRight, ModCtrl},
{"1;5D", KeyLeft, ModCtrl},
// Navigation
{"H", KeyHome, ModNone},
{"F", KeyEnd, ModNone},
{"1~", KeyHome, ModNone},
{"4~", KeyEnd, ModNone},
{"5~", KeyPageUp, ModNone},
{"6~", KeyPageDown, ModNone},
{"2~", KeyInsert, ModNone},
{"3~", KeyDelete, ModNone},
{"7~", KeyHome, ModNone},
{"8~", KeyEnd, ModNone},
// Function keys (xterm)
{"11~", KeyF1, ModNone},
{"12~", KeyF2, ModNone},
{"13~", KeyF3, ModNone},
{"14~", KeyF4, ModNone},
{"15~", KeyF5, ModNone},
{"17~", KeyF6, ModNone},
{"18~", KeyF7, ModNone},
{"19~", KeyF8, ModNone},
{"20~", KeyF9, ModNone},
{"21~", KeyF10, ModNone},
{"23~", KeyF11, ModNone},
{"24~", KeyF12, ModNone},
// Function keys (vt style)
{"[A", KeyF1, ModNone},
{"[B", KeyF2, ModNone},
{"[C", KeyF3, ModNone},
{"[D", KeyF4, ModNone},
{"[E", KeyF5, ModNone},
// Shift+Navigation (mod=2)
{"1;2H", KeyHome, ModShift},
{"1;2F", KeyEnd, ModShift},
{"2;2~", KeyInsert, ModShift},
{"3;2~", KeyDelete, ModShift},
{"5;2~", KeyPageUp, ModShift},
{"6;2~", KeyPageDown, ModShift},
// Alt+Arrows (mod=3) - already have 1;3A-D
// Alt+Navigation (mod=3)
{"1;3H", KeyHome, ModAlt},
{"1;3F", KeyEnd, ModAlt},
{"2;3~", KeyInsert, ModAlt},
{"3;3~", KeyDelete, ModAlt},
{"5;3~", KeyPageUp, ModAlt},
{"6;3~", KeyPageDown, ModAlt},
// Shift+Alt (mod=4)
{"1;4A", KeyUp, ModShift | ModAlt},
{"1;4B", KeyDown, ModShift | ModAlt},
{"1;4C", KeyRight, ModShift | ModAlt},
{"1;4D", KeyLeft, ModShift | ModAlt},
{"1;4H", KeyHome, ModShift | ModAlt},
{"1;4F", KeyEnd, ModShift | ModAlt},
{"2;4~", KeyInsert, ModShift | ModAlt},
{"3;4~", KeyDelete, ModShift | ModAlt},
{"5;4~", KeyPageUp, ModShift | ModAlt},
{"6;4~", KeyPageDown, ModShift | ModAlt},
// Ctrl+Arrows (mod=5) - already have 1;5A-D
// Ctrl+Navigation (mod=5)
{"1;5H", KeyHome, ModCtrl},
{"1;5F", KeyEnd, ModCtrl},
{"2;5~", KeyInsert, ModCtrl},
{"3;5~", KeyDelete, ModCtrl},
{"5;5~", KeyPageUp, ModCtrl},
{"6;5~", KeyPageDown, ModCtrl},
// Shift+Ctrl (mod=6)
{"1;6A", KeyUp, ModShift | ModCtrl},
{"1;6B", KeyDown, ModShift | ModCtrl},
{"1;6C", KeyRight, ModShift | ModCtrl},
{"1;6D", KeyLeft, ModShift | ModCtrl},
{"1;6H", KeyHome, ModShift | ModCtrl},
{"1;6F", KeyEnd, ModShift | ModCtrl},
{"2;6~", KeyInsert, ModShift | ModCtrl},
{"3;6~", KeyDelete, ModShift | ModCtrl},
{"5;6~", KeyPageUp, ModShift | ModCtrl},
{"6;6~", KeyPageDown, ModShift | ModCtrl},
// Alt+Ctrl (mod=7)
{"1;7A", KeyUp, ModAlt | ModCtrl},
{"1;7B", KeyDown, ModAlt | ModCtrl},
{"1;7C", KeyRight, ModAlt | ModCtrl},
{"1;7D", KeyLeft, ModAlt | ModCtrl},
{"1;7H", KeyHome, ModAlt | ModCtrl},
{"1;7F", KeyEnd, ModAlt | ModCtrl},
{"2;7~", KeyInsert, ModAlt | ModCtrl},
{"3;7~", KeyDelete, ModAlt | ModCtrl},
{"5;7~", KeyPageUp, ModAlt | ModCtrl},
{"6;7~", KeyPageDown, ModAlt | ModCtrl},
// Shift+Alt+Ctrl (mod=8)
{"1;8A", KeyUp, ModShift | ModAlt | ModCtrl},
{"1;8B", KeyDown, ModShift | ModAlt | ModCtrl},
{"1;8C", KeyRight, ModShift | ModAlt | ModCtrl},
{"1;8D", KeyLeft, ModShift | ModAlt | ModCtrl},
{"1;8H", KeyHome, ModShift | ModAlt | ModCtrl},
{"1;8F", KeyEnd, ModShift | ModAlt | ModCtrl},
{"2;8~", KeyInsert, ModShift | ModAlt | ModCtrl},
{"3;8~", KeyDelete, ModShift | ModAlt | ModCtrl},
{"5;8~", KeyPageUp, ModShift | ModAlt | ModCtrl},
{"6;8~", KeyPageDown, ModShift | ModAlt | ModCtrl},
// F-keys with modifiers (CSI style: ESC [ 1 ; mod P/Q/R/S for F1-F4)
// F1-F4 with Shift (mod=2)
{"1;2P", KeyF1, ModShift},
{"1;2Q", KeyF2, ModShift},
{"1;2R", KeyF3, ModShift},
{"1;2S", KeyF4, ModShift},
// F1-F4 with Alt (mod=3)
{"1;3P", KeyF1, ModAlt},
{"1;3Q", KeyF2, ModAlt},
{"1;3R", KeyF3, ModAlt},
{"1;3S", KeyF4, ModAlt},
// F1-F4 with Ctrl (mod=5)
{"1;5P", KeyF1, ModCtrl},
{"1;5Q", KeyF2, ModCtrl},
{"1;5R", KeyF3, ModCtrl},
{"1;5S", KeyF4, ModCtrl},
// F1-F4 with Shift+Alt (mod=4)
{"1;4P", KeyF1, ModShift | ModAlt},
{"1;4Q", KeyF2, ModShift | ModAlt},
{"1;4R", KeyF3, ModShift | ModAlt},
{"1;4S", KeyF4, ModShift | ModAlt},
// F1-F4 with Shift+Ctrl (mod=6)
{"1;6P", KeyF1, ModShift | ModCtrl},
{"1;6Q", KeyF2, ModShift | ModCtrl},
{"1;6R", KeyF3, ModShift | ModCtrl},
{"1;6S", KeyF4, ModShift | ModCtrl},
// F1-F4 with Alt+Ctrl (mod=7)
{"1;7P", KeyF1, ModAlt | ModCtrl},
{"1;7Q", KeyF2, ModAlt | ModCtrl},
{"1;7R", KeyF3, ModAlt | ModCtrl},
{"1;7S", KeyF4, ModAlt | ModCtrl},
// F1-F4 with Shift+Alt+Ctrl (mod=8)
{"1;8P", KeyF1, ModShift | ModAlt | ModCtrl},
{"1;8Q", KeyF2, ModShift | ModAlt | ModCtrl},
{"1;8R", KeyF3, ModShift | ModAlt | ModCtrl},
{"1;8S", KeyF4, ModShift | ModAlt | ModCtrl},
// F5-F12 with modifiers (ESC [ N ; mod ~)
// F5 (15~)
{"15;2~", KeyF5, ModShift},
{"15;3~", KeyF5, ModAlt},
{"15;4~", KeyF5, ModShift | ModAlt},
{"15;5~", KeyF5, ModCtrl},
{"15;6~", KeyF5, ModShift | ModCtrl},
{"15;7~", KeyF5, ModAlt | ModCtrl},
{"15;8~", KeyF5, ModShift | ModAlt | ModCtrl},
// F6 (17~)
{"17;2~", KeyF6, ModShift},
{"17;3~", KeyF6, ModAlt},
{"17;4~", KeyF6, ModShift | ModAlt},
{"17;5~", KeyF6, ModCtrl},
{"17;6~", KeyF6, ModShift | ModCtrl},
{"17;7~", KeyF6, ModAlt | ModCtrl},
{"17;8~", KeyF6, ModShift | ModAlt | ModCtrl},
// F7 (18~)
{"18;2~", KeyF7, ModShift},
{"18;3~", KeyF7, ModAlt},
{"18;4~", KeyF7, ModShift | ModAlt},
{"18;5~", KeyF7, ModCtrl},
{"18;6~", KeyF7, ModShift | ModCtrl},
{"18;7~", KeyF7, ModAlt | ModCtrl},
{"18;8~", KeyF7, ModShift | ModAlt | ModCtrl},
// F8 (19~)
{"19;2~", KeyF8, ModShift},
{"19;3~", KeyF8, ModAlt},
{"19;4~", KeyF8, ModShift | ModAlt},
{"19;5~", KeyF8, ModCtrl},
{"19;6~", KeyF8, ModShift | ModCtrl},
{"19;7~", KeyF8, ModAlt | ModCtrl},
{"19;8~", KeyF8, ModShift | ModAlt | ModCtrl},
// F9 (20~)
{"20;2~", KeyF9, ModShift},
{"20;3~", KeyF9, ModAlt},
{"20;4~", KeyF9, ModShift | ModAlt},
{"20;5~", KeyF9, ModCtrl},
{"20;6~", KeyF9, ModShift | ModCtrl},
{"20;7~", KeyF9, ModAlt | ModCtrl},
{"20;8~", KeyF9, ModShift | ModAlt | ModCtrl},
// F10 (21~)
{"21;2~", KeyF10, ModShift},
{"21;3~", KeyF10, ModAlt},
{"21;4~", KeyF10, ModShift | ModAlt},
{"21;5~", KeyF10, ModCtrl},
{"21;6~", KeyF10, ModShift | ModCtrl},
{"21;7~", KeyF10, ModAlt | ModCtrl},
{"21;8~", KeyF10, ModShift | ModAlt | ModCtrl},
// F11 (23~)
{"23;2~", KeyF11, ModShift},
{"23;3~", KeyF11, ModAlt},
{"23;4~", KeyF11, ModShift | ModAlt},
{"23;5~", KeyF11, ModCtrl},
{"23;6~", KeyF11, ModShift | ModCtrl},
{"23;7~", KeyF11, ModAlt | ModCtrl},
{"23;8~", KeyF11, ModShift | ModAlt | ModCtrl},
// F12 (24~)
{"24;2~", KeyF12, ModShift},
{"24;3~", KeyF12, ModAlt},
{"24;4~", KeyF12, ModShift | ModAlt},
{"24;5~", KeyF12, ModCtrl},
{"24;6~", KeyF12, ModShift | ModCtrl},
{"24;7~", KeyF12, ModAlt | ModCtrl},
{"24;8~", KeyF12, ModShift | ModAlt | ModCtrl},
}
// SS3 sequences (ESC O ...)
var ss3Sequences = []escapeSequence{
{"A", KeyUp, ModNone},
{"B", KeyDown, ModNone},
{"C", KeyRight, ModNone},
{"D", KeyLeft, ModNone},
{"H", KeyHome, ModNone},
{"F", KeyEnd, ModNone},
{"P", KeyF1, ModNone},
{"Q", KeyF2, ModNone},
{"R", KeyF3, ModNone},
{"S", KeyF4, ModNone},
// Numeric Keypad (Application Mode)
{"M", KeyEnter, ModNone}, // Keypad Enter
{"X", KeyRune, ModNone}, // Keypad = (some terminals)
{"j", KeyRune, ModNone}, // Keypad *
{"k", KeyRune, ModNone}, // Keypad +
{"l", KeyRune, ModNone}, // Keypad ,
{"m", KeyRune, ModNone}, // Keypad -
{"n", KeyRune, ModNone}, // Keypad .
{"o", KeyRune, ModNone}, // Keypad /
{"p", KeyRune, ModNone}, // Keypad 0
{"q", KeyRune, ModNone}, // Keypad 1
{"r", KeyRune, ModNone}, // Keypad 2
{"s", KeyRune, ModNone}, // Keypad 3
{"t", KeyRune, ModNone}, // Keypad 4
{"u", KeyRune, ModNone}, // Keypad 5
{"v", KeyRune, ModNone}, // Keypad 6
{"w", KeyRune, ModNone}, // Keypad 7
{"x", KeyRune, ModNone}, // Keypad 8
{"y", KeyRune, ModNone}, // Keypad 9
}
var csiMap = buildSequenceMap(csiSequences)
var ss3Map = buildSequenceMap(ss3Sequences)
func buildSequenceMap(seqs []escapeSequence) map[string]escapeSequence {
m := make(map[string]escapeSequence, len(seqs))
for _, s := range seqs {
m[s.seq] = s
}
return m
}
// lookupCSI performs zero-alloc map lookup via compiler optimization
// The string([]byte) conversion inline in map access does not allocate
func lookupCSI(seq []byte) (Key, Modifier, bool) {
if s, ok := csiMap[string(seq)]; ok {
return s.key, s.mod, true
}
return KeyNone, ModNone, false
}
// lookupSS3 performs zero-alloc map lookup
func lookupSS3(seq []byte) (Key, Modifier, bool) {
if s, ok := ss3Map[string(seq)]; ok {
return s.key, s.mod, true
}
return KeyNone, ModNone, false
}
+93
View File
@@ -0,0 +1,93 @@
package terminal
// keyToName maps Key constants to canonical config string names
var keyToName = map[Key]string{
KeyEscape: "escape",
KeyEnter: "enter",
KeyTab: "tab",
KeyBacktab: "backtab",
KeyBackspace: "backspace",
KeyDelete: "delete",
KeySpace: "space",
KeyUp: "up",
KeyDown: "down",
KeyLeft: "left",
KeyRight: "right",
KeyHome: "home",
KeyEnd: "end",
KeyPageUp: "page_up",
KeyPageDown: "page_down",
KeyInsert: "insert",
KeyF1: "f1",
KeyF2: "f2",
KeyF3: "f3",
KeyF4: "f4",
KeyF5: "f5",
KeyF6: "f6",
KeyF7: "f7",
KeyF8: "f8",
KeyF9: "f9",
KeyF10: "f10",
KeyF11: "f11",
KeyF12: "f12",
KeyCtrlA: "ctrl_a",
KeyCtrlB: "ctrl_b",
KeyCtrlC: "ctrl_c",
KeyCtrlD: "ctrl_d",
KeyCtrlE: "ctrl_e",
KeyCtrlF: "ctrl_f",
KeyCtrlG: "ctrl_g",
KeyCtrlH: "ctrl_h",
KeyCtrlI: "ctrl_i",
KeyCtrlJ: "ctrl_j",
KeyCtrlK: "ctrl_k",
KeyCtrlL: "ctrl_l",
KeyCtrlM: "ctrl_m",
KeyCtrlN: "ctrl_n",
KeyCtrlO: "ctrl_o",
KeyCtrlP: "ctrl_p",
KeyCtrlQ: "ctrl_q",
KeyCtrlR: "ctrl_r",
KeyCtrlS: "ctrl_s",
KeyCtrlT: "ctrl_t",
KeyCtrlU: "ctrl_u",
KeyCtrlV: "ctrl_v",
KeyCtrlW: "ctrl_w",
KeyCtrlX: "ctrl_x",
KeyCtrlY: "ctrl_y",
KeyCtrlZ: "ctrl_z",
KeyCtrlSpace: "ctrl_space",
KeyCtrlBackslash: "ctrl_backslash",
KeyCtrlBracketLeft: "ctrl_bracket_left",
KeyCtrlBracketRight: "ctrl_bracket_right",
KeyCtrlCaret: "ctrl_caret",
KeyCtrlUnderscore: "ctrl_underscore",
}
// nameToKey is the reverse lookup, built from keyToName
var nameToKey map[string]Key
func init() {
nameToKey = make(map[string]Key, len(keyToName))
for k, v := range keyToName {
nameToKey[v] = k
}
// Aliases
nameToKey["shift_tab"] = KeyBacktab
}
// KeyName returns the canonical string name for a Key constant
// Returns empty string for KeyNone and KeyRune
func KeyName(k Key) string {
return keyToName[k]
}
// KeyByName resolves a canonical name to a Key constant
// Returns KeyNone and false if name is unknown
func KeyByName(name string) (Key, bool) {
k, ok := nameToKey[name]
return k, ok
}
+74
View File
@@ -0,0 +1,74 @@
package terminal
// MouseButton represents mouse button identity
type MouseButton uint8
const (
MouseBtnNone MouseButton = iota
MouseBtnLeft
MouseBtnMiddle
MouseBtnRight
MouseBtnWheelUp
MouseBtnWheelDown
MouseBtnBack // Button 4 (if supported)
MouseBtnForward // Button 5 (if supported)
)
// MouseAction represents the type of mouse event
type MouseAction uint8
const (
MouseActionNone MouseAction = iota
MouseActionPress
MouseActionRelease
MouseActionMove
MouseActionDrag
)
// MouseMode controls which mouse events are reported (bitmask)
type MouseMode uint8
const (
MouseModeNone MouseMode = 0
MouseModeClick MouseMode = 1 << 0 // Press/release events
MouseModeDrag MouseMode = 1 << 1 // Drag events (button held + motion)
MouseModeMotion MouseMode = 1 << 2 // All motion events
)
// String returns human-readable button name
func (b MouseButton) String() string {
switch b {
case MouseBtnLeft:
return "Left"
case MouseBtnMiddle:
return "Middle"
case MouseBtnRight:
return "Right"
case MouseBtnWheelUp:
return "WheelUp"
case MouseBtnWheelDown:
return "WheelDown"
case MouseBtnBack:
return "Back"
case MouseBtnForward:
return "Forward"
default:
return "None"
}
}
// String returns human-readable action name
func (a MouseAction) String() string {
switch a {
case MouseActionPress:
return "Press"
case MouseActionRelease:
return "Release"
case MouseActionMove:
return "Move"
case MouseActionDrag:
return "Drag"
default:
return "None"
}
}
+456
View File
@@ -0,0 +1,456 @@
package terminal
import (
"bufio"
)
// outputBuffer manages double-buffered terminal output with diffing
type outputBuffer struct {
front []Cell
width int
height int
colorMode ColorMode
writer *bufio.Writer
cursorX int
cursorY int
cursorValid bool
// Style state for coalescing
lastFg RGB
lastBg RGB
lastAttr Attr
lastValid bool
}
// writerAdapter adapts Backend to io.Writer for bufio
type writerAdapter struct {
b Backend
}
func (wa writerAdapter) Write(p []byte) (int, error) {
err := wa.b.Write(p)
if err != nil {
return 0, err
}
return len(p), nil
}
// newOutputBuffer creates a new output buffer
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
// Use 128KB buffer for minimal calls to backend
adapter := writerAdapter{b: backend}
return &outputBuffer{
writer: bufio.NewWriterSize(adapter, 131072),
colorMode: colorMode,
}
}
// resize updates buffer dimensions
func (o *outputBuffer) resize(width, height int) {
size := width * height
if cap(o.front) < size {
o.front = make([]Cell, size)
} else {
o.front = o.front[:size]
}
o.width = width
o.height = height
for i := range o.front {
o.front[i] = Cell{Rune: 0}
}
o.lastValid = false
o.cursorValid = false
}
// cellEqual compares two cells for equality (standalone for inlining)
func cellEqual(a, b Cell) bool {
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
return a.Rune == b.Rune &&
a.Bg == b.Bg &&
a.Fg == b.Fg &&
a.Attrs == b.Attrs
}
// flush writes the back buffer to terminal, diffing against front buffer
func (o *outputBuffer) flush(cells []Cell, width, height int) {
if width != o.width || height != o.height {
o.resize(width, height)
}
expectedSize := width * height
if len(cells) < expectedSize {
return
}
w := o.writer
for y := 0; y < height; y++ {
rowStart := y * width
// Early termination: find last dirty cell in row (scan backward)
rowEnd := width
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
rowEnd--
}
if rowEnd == 0 {
continue // Entire row unchanged
}
x := 0
for x < rowEnd {
idx := rowStart + x
if cellEqual(cells[idx], o.front[idx]) {
x++
continue
}
// Found dirty cell - check for small gaps ahead to potentially merge segments
segStart := x
segEnd := x + 1
// Extend segment through small gaps (≤3 unchanged cells)
for segEnd < rowEnd {
// Find gap size
gapStart := segEnd
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
gapStart++
}
gapSize := gapStart - segEnd
if gapSize == 0 {
// No gap, extend to next unchanged
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++
}
continue
}
if gapSize > 3 {
break // Gap too large, end segment here
}
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move
gapCompatible := true
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment
for k := 0; k < gapSize; k++ {
gCell := cells[rowStart+segEnd+k]
// Strict equality on style/color to ensure no SGR emission
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
gapCompatible = false
break
}
}
if !gapCompatible {
break // Gap has different style, cheaper to jump
}
// Check if there's more dirty content after gap
if gapStart >= rowEnd {
break // Gap extends to row end
}
// Small gap with content after - include gap in segment
segEnd = gapStart
// Continue to find more dirty cells
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++
}
}
// Positions cursor to segment start
o.moveCursorTo(w, segStart, y)
// Write segment [segStart, segEnd)
for sx := segStart; sx < segEnd; sx++ {
cidx := rowStart + sx
c := cells[cidx]
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
r := c.Rune
if r == 0 {
r = ' '
}
if r < 0x80 {
w.WriteByte(byte(r))
} else {
w.WriteRune(r)
}
o.front[cidx] = c
o.cursorX++
}
x = segEnd
}
}
w.Write(csiSGR0)
o.lastValid = false
w.Flush()
}
// cursorForwardCost returns byte cost of cursor forward sequence
func cursorForwardCost(n int) int {
if n == 1 {
return 3 // \x1b[C
}
return 3 + digitCount(n) // \x1b[nC
}
// cursorAbsoluteCost returns byte cost of absolute cursor position
func cursorAbsoluteCost(x, y int) int {
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
return 4 + digitCount(y+1) + digitCount(x+1)
}
// digitCount returns number of decimal digits in n
func digitCount(n int) int {
if n < 10 {
return 1
}
if n < 100 {
return 2
}
if n < 1000 {
return 3
}
return 4
}
// moveCursorTo positions cursor using most efficient method
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
if o.cursorValid && o.cursorX == x && o.cursorY == y {
return
}
moved := false
if o.cursorValid && o.cursorY == y && x > o.cursorX {
gap := x - o.cursorX
fwdCost := cursorForwardCost(gap)
absCost := cursorAbsoluteCost(x, y)
if fwdCost < absCost {
writeCursorForward(w, gap)
moved = true
}
}
if !moved {
writeCursorPos(w, x, y)
}
o.cursorX = x
o.cursorY = y
o.cursorValid = true
}
// writeStyleCoalesced emits a single combined SGR sequence when style changes
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg RGB, attr Attr) {
// 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)
styleAttr := attr & AttrStyle
lastStyleAttr := o.lastAttr & AttrStyle
attrChanged := !o.lastValid || styleAttr != lastStyleAttr
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)
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
o.lastAttr = attr
o.lastValid = true
}
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg 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)))
}
}
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg 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)))
}
}
// writeFgFull writes complete fg color sequence
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg 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))
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 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))
w.WriteByte(';')
writeInt(w, int(bg.G))
w.WriteByte(';')
writeInt(w, int(bg.B))
w.WriteByte('m')
} else {
w.Write(csiBg256)
writeInt(w, int(RGBTo256(bg)))
w.WriteByte('m')
}
}
// forceFullRedraw clears front buffer to force complete redraw
func (o *outputBuffer) forceFullRedraw() {
for i := range o.front {
o.front[i] = Cell{Rune: 0}
}
o.lastValid = false
o.cursorValid = false
}
// clear writes a clear screen with specified background
func (o *outputBuffer) clear(bg RGB) {
w := o.writer
w.Write(csiSGR0)
o.writeBgFull(w, bg, 0)
w.Write(csiClear)
o.lastValid = false
o.cursorValid = false
w.Flush()
for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg}
}
}
// invalidateCursor marks cursor position as unknown
func (o *outputBuffer) invalidateCursor() {
o.cursorValid = false
}
+93
View File
@@ -0,0 +1,93 @@
package terminal
// Generic xterm 256-color palette indices without game semantics
// Game systems reference these via aliases in their own parameter files
//
// Color cube: index = 16 + 36*r + 6*g + b where r,g,b ∈ [0,5]
// Grayscale ramp: indices 232-255, level = 8 + 10*(index-232)
//
// Ordered dark-to-light within each hue group
const (
// --- Blue ---
P256DeepNavy uint8 = 17 // (0,0,1)
P256DarkBlue uint8 = 18 // (0,0,2)
P256SteelBlue uint8 = 75 // (1,3,5)
P256LightBlue uint8 = 81 // (1,4,5)
// --- Teal / Cyan ---
P256DeepTeal uint8 = 23 // (0,1,1)
P256Teal uint8 = 44 // (0,4,4)
P256Green uint8 = 46 // (0,5,0)
P256Cyan uint8 = 51 // (0,5,5)
P256LightCyan uint8 = 87 // (1,5,5)
// --- Blue / Purple ---
P256CobaltBlue uint8 = 33 // (0,2,5)
P256DarkPurpleBlue uint8 = 54 // (1,0,2)
P256Indigo uint8 = 63 // (1,1,5)
P256Purple uint8 = 129 // (3,0,5)
P256Violet uint8 = 134 // (3,1,4)
P256MediumPurple uint8 = 135 // (3,1,5)
P256Orchid uint8 = 176 // (4,2,4)
// --- Green / Yellow-Green ---
P256YellowGreen uint8 = 154 // (3,5,0)
// --- Red ---
P256Maroon uint8 = 52 // (1,0,0)
P256DarkCrimson uint8 = 88 // (2,0,0)
P256Crimson uint8 = 160 // (4,0,0)
// --- Red / Orange / Yellow ---
P256Red uint8 = 196 // (5,0,0)
P256Rose uint8 = 198 // (5,0,2)
P256RedOrange uint8 = 202 // (5,1,0)
P256Orange uint8 = 208 // (5,2,0)
P256Amber uint8 = 214 // (5,3,0)
P256Gold uint8 = 220 // (5,4,0)
P256Yellow uint8 = 226 // (5,5,0)
// --- Orange / Brown ---
P256DarkAmber uint8 = 94 // (2,1,0)
// --- Grayscale ---
P256Gray uint8 = 240 // Grayscale step 8, level ~88
)
// Cube256 returns the xterm 256-palette index for an RGB cube coordinate.
// r, g, b must be in [0,5]. Values outside that range are clamped.
func Cube256(r, g, b uint8) uint8 {
if r > 5 {
r = 5
}
if g > 5 {
g = 5
}
if b > 5 {
b = 5
}
return 16 + 36*r + 6*g + b
}
// CubeRGB256 returns the (r, g, b) cube coordinates for a 256-palette color cube index.
// Index must be in [16,231]. Returns (0,0,0) for out-of-range indices.
func CubeRGB256(index uint8) (r, g, b uint8) {
if index < 16 || index > 231 {
return 0, 0, 0
}
n := index - 16
r = n / 36
g = (n % 36) / 6
b = n % 6
return r, g, b
}
// Gray256 returns the xterm 256-palette index for a grayscale step.
// step must be in [0,23] (maps to indices 232-255, levels 8-238).
func Gray256(step uint8) uint8 {
if step > 23 {
step = 23
}
return 232 + step
}
+159
View File
@@ -0,0 +1,159 @@
package terminal
// Generic TrueColor palette — pure RGB definitions without game semantics
// Game systems and renderers reference these via aliases in their own parameter files
//
// Naming: standard color names where RGB closely matches (CSS, X11, Pantone-adjacent),
// descriptive compound names otherwise. Ordered dark-to-light within each hue group.
var (
// --- Achromatic ---
Black = RGB{0, 0, 0}
Charcoal = RGB{5, 5, 5}
Obsidian = RGB{20, 20, 30} // Blue-black
Gunmetal = RGB{26, 27, 38} // Blue-tinted near-black
DarkSlate = RGB{35, 36, 48} // Blue-gray near-black
DimGray = RGB{55, 55, 55}
DarkGray = RGB{60, 60, 60}
IronGray = RGB{80, 80, 80}
SlateGray = RGB{80, 80, 90} // Cool-tinted
Taupe = RGB{100, 95, 85} // Warm gray
Gray = RGB{120, 120, 120}
MidGray = RGB{128, 128, 128}
CoolSilver = RGB{140, 145, 155} // Blue-tinted silver
DimSilver = RGB{155, 155, 155}
Silver = RGB{180, 180, 180}
LightGray = RGB{200, 200, 200}
NearWhite = RGB{250, 250, 250}
White = RGB{255, 255, 255}
// --- Brown / Earth ---
Chocolate = RGB{90, 25, 15}
SaddleBrown = RGB{101, 67, 33}
DarkRust = RGB{140, 35, 25}
Sienna = RGB{140, 60, 0}
DarkPlum = RGB{60, 30, 40} // Warm dark purple-brown
BlueCharcoal = RGB{40, 45, 60} // Cool dark blue-gray
// --- Red ---
BlackRed = RGB{50, 15, 15}
Oxblood = RGB{100, 20, 20}
DarkBurgundy = RGB{100, 25, 20}
DarkCrimson = RGB{139, 0, 0}
Brick = RGB{180, 40, 40}
Cinnabar = RGB{200, 60, 50}
IndianRed = RGB{180, 60, 60}
BurntSienna = RGB{200, 60, 25}
Vermilion = RGB{227, 66, 82}
Red = RGB{255, 0, 0}
BrightRed = RGB{255, 60, 60}
Coral = RGB{255, 80, 80}
Salmon = RGB{255, 100, 100}
LightCoral = RGB{255, 140, 140}
LightRose = RGB{255, 150, 150}
MistyRose = RGB{255, 200, 200}
// --- Orange ---
DarkAmber = RGB{60, 40, 0}
Rust = RGB{180, 60, 20}
Amber = RGB{180, 120, 0}
Bronze = RGB{200, 100, 0}
BurntOrange = RGB{200, 110, 0}
Terracotta = RGB{220, 100, 50}
FlameOrange = RGB{240, 100, 30}
OrangeRed = RGB{255, 69, 0}
RedOrange = RGB{255, 80, 40}
Mango = RGB{255, 120, 50}
TigerOrange = RGB{255, 140, 0}
WarmOrange = RGB{255, 140, 40}
Apricot = RGB{255, 160, 60}
Orange = RGB{255, 165, 0}
// --- Yellow ---
DarkGold = RGB{200, 150, 0}
OliveYellow = RGB{200, 180, 60}
Gold = RGB{255, 215, 0}
LemonYellow = RGB{255, 240, 60}
Yellow = RGB{255, 255, 0}
PaleGold = RGB{255, 200, 100}
Buttercream = RGB{255, 250, 150}
PaleLemon = RGB{255, 255, 100}
Ivory = RGB{255, 255, 220}
Cream = RGB{255, 255, 200}
// --- Green ---
BlackGreen = RGB{0, 40, 0}
DarkFern = RGB{30, 80, 25}
DeepForest = RGB{25, 80, 35}
HunterGreen = RGB{35, 90, 30}
DarkGreen = RGB{15, 130, 15}
ForestGreen = RGB{34, 139, 34}
MediumGreen = RGB{40, 150, 40}
FernGreen = RGB{50, 140, 45}
LeafGreen = RGB{60, 160, 60}
SeaGreen = RGB{60, 180, 80}
SageGreen = RGB{70, 170, 100}
GrassGreen = RGB{70, 180, 55}
EmeraldGreen = RGB{60, 220, 100}
MintGreen = RGB{100, 220, 130}
BrightGreen = RGB{20, 200, 20}
YellowGreen = RGB{100, 220, 80}
LimeGreen = RGB{50, 205, 50}
NeonGreen = RGB{50, 255, 50}
BrightLime = RGB{120, 255, 80}
Lime = RGB{0, 255, 0}
LightGreen = RGB{144, 238, 144}
PaleGreen = RGB{120, 255, 120}
PastelGreen = RGB{100, 220, 100}
PaleMint = RGB{150, 255, 180}
Honeydew = RGB{200, 255, 200}
// --- Cyan / Teal ---
Teal = RGB{0, 139, 139}
DimCyan = RGB{0, 160, 160}
VibrantCyan = RGB{0, 200, 200}
DarkTurquoise = RGB{0, 206, 209}
BrightCyan = RGB{0, 220, 220}
Cyan = RGB{0, 255, 255}
SkyTeal = RGB{80, 200, 220}
PaleCyan = RGB{200, 255, 255}
AliceBlue = RGB{230, 245, 255}
IceCyan = RGB{240, 255, 255}
// --- Blue ---
DeepNavy = RGB{15, 25, 50}
DeepIndigo = RGB{40, 0, 180}
NavyBlue = RGB{30, 60, 120}
CobaltBlue = RGB{50, 80, 200}
SteelBlue = RGB{60, 100, 180}
MediumBlue = RGB{60, 120, 200}
RoyalBlue = RGB{65, 105, 225}
CeruleanBlue = RGB{80, 140, 220}
Cornflower = RGB{80, 130, 255}
DodgerBlue = RGB{40, 180, 255}
LightBlue = RGB{120, 170, 255}
LightSkyBlue = RGB{135, 206, 250}
BabyBlue = RGB{160, 210, 255}
Blue = RGB{0, 0, 255}
// --- Purple / Violet ---
DeepPurple = RGB{60, 20, 80}
DarkViolet = RGB{120, 40, 180}
MutedPurple = RGB{160, 100, 160}
MediumPurple = RGB{170, 100, 210}
ElectricViolet = RGB{180, 130, 255}
LightOrchid = RGB{200, 130, 210}
Orchid = RGB{200, 120, 220}
PaleVioletRed = RGB{219, 112, 147}
SoftLavender = RGB{220, 150, 230}
PaleLavender = RGB{220, 180, 255}
// --- Pink / Rose ---
RoseRed = RGB{255, 60, 120}
HotMagenta = RGB{255, 60, 200}
HotPink = RGB{255, 140, 200}
PalePink = RGB{255, 145, 220}
LightPink = RGB{255, 182, 193}
Pink = RGB{255, 192, 203}
Magenta = RGB{255, 0, 255}
)
+146
View File
@@ -0,0 +1,146 @@
package terminal
import (
"fmt"
"os"
"runtime/debug"
"sync"
)
// TerminalService manages terminal lifecycle and input polling
type TerminalService struct {
term Terminal
colorMode ColorMode
eventCh chan Event
stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
running bool
}
// NewService creates a new terminal service
func NewService() *TerminalService {
return &TerminalService{
eventCh: make(chan Event, 256),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Name implements Service
func (s *TerminalService) Name() string {
return "terminal"
}
// Dependencies implements Service
func (s *TerminalService) Dependencies() []string {
return nil
}
// Init implements Service
// args[0]: ColorMode (optional, defaults to DetectColorMode())
func (s *TerminalService) Init(args ...any) error {
s.colorMode = DetectColorMode()
if len(args) > 0 {
if cm, ok := args[0].(ColorMode); ok {
s.colorMode = cm
}
}
s.term = New(s.colorMode)
if err := s.term.Init(); err != nil {
return fmt.Errorf("terminal init: %w", err)
}
// Enable mouse click reporting
if err := s.term.SetMouseMode(MouseModeClick); err != nil {
// No-op for mouse error, continue
}
return nil
}
// Start implements Service - launches input polling goroutine
func (s *TerminalService) Start() error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return nil
}
s.running = true
s.mu.Unlock()
go s.pollLoop()
return nil
}
// pollLoop reads input events until stop signal
func (s *TerminalService) pollLoop() {
defer close(s.doneCh)
defer func() {
if r := recover(); r != nil {
EmergencyReset(os.Stdout)
os.Stdout.Sync()
os.Stderr.Sync()
fmt.Fprintf(os.Stderr, "\r\n\x1b[31mTERMINAL POLL CRASHED: %v\x1b[0m\r\n", r)
fmt.Fprintf(os.Stderr, "Stack Trace:\r\n%s\r\n", debug.Stack())
os.Stderr.Sync()
os.Exit(1)
}
}()
for {
select {
case <-s.stopCh:
return
default:
}
ev := s.term.PollEvent()
if ev.Type == EventClosed || ev.Type == EventError {
return
}
select {
case s.eventCh <- ev:
case <-s.stopCh:
return
}
}
}
// Stop implements Service - signals stop and restores terminal
func (s *TerminalService) Stop() error {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return nil
}
s.running = false
s.mu.Unlock()
close(s.stopCh)
// Post synthetic close event to unblock PollEvent
if s.term != nil {
s.term.PostEvent(Event{Type: EventClosed})
}
<-s.doneCh
if s.term != nil {
s.term.Fini()
}
return nil
}
// Terminal returns the wrapped terminal instance
func (s *TerminalService) Terminal() Terminal {
return s.term
}
// Events returns the input event channel
func (s *TerminalService) Events() <-chan Event {
return s.eventCh
}
+458
View File
@@ -0,0 +1,458 @@
package terminal
import (
"io"
"os"
"sync"
"sync/atomic"
)
// Attr represents text attributes (bitmask)
type Attr uint8
const (
AttrNone Attr = 0
AttrBold Attr = 1 << 0
AttrDim Attr = 1 << 1
AttrItalic Attr = 1 << 2
AttrUnderline Attr = 1 << 3
AttrBlink Attr = 1 << 4
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
)
// AttrStyle masks only the style bits (excludes color mode flags)
const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBlink | AttrReverse
// Cell represents a single terminal cell
type Cell struct {
Rune rune
Fg RGB
Bg RGB
Attrs Attr
}
// Terminal provides low-level terminal access
type Terminal interface {
// Init enters raw mode, alternate screen buffer, hides cursor
Init() error
// Fini restores terminal state. Safe to call multiple times
Fini()
// Size returns current terminal dimensions
Size() (width, height int)
// ResizeChan returns channel that receives resize events
ResizeChan() <-chan ResizeEvent
// ColorMode returns detected color capability
ColorMode() ColorMode
// Flush writes cell buffer to terminal
// Cells are row-major: cells[y*width + x]
Flush(cells []Cell, width, height int)
// Clear fills screen with specified background color
Clear(bg RGB)
// SetCursorVisible shows/hides cursor
SetCursorVisible(visible bool)
// MoveCursor positions cursor (0-indexed)
MoveCursor(x, y int)
// Sync forces full redraw
Sync()
// PollEvent blocks until next input event
PollEvent() Event
// PostEvent injects a synthetic event
PostEvent(Event)
// SetMouseMode enables/disables mouse event reporting
// Modes can be combined: MouseModeClick | MouseModeDrag
SetMouseMode(mode MouseMode) error
}
// ResizeEvent represents a terminal resize
type ResizeEvent struct {
Width int
Height int
}
// termImpl implements Terminal using the Backend interface
type termImpl struct {
backend Backend
output *outputBuffer
input *inputReader
resizeCh chan ResizeEvent
syntheticCh chan Event
cursorVisible atomic.Bool
mu sync.Mutex
initialized bool
finalized bool
mouseMode MouseMode
}
// New creates a new Terminal instance
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 = colorMode[0]
}
t := &termImpl{
backend: b,
syntheticCh: make(chan Event, 16),
resizeCh: make(chan ResizeEvent, 1),
}
// Initialize output buffer with backend
t.output = newOutputBuffer(b, c)
return t
}
// Init enters raw mode and sets up terminal
func (t *termImpl) Init() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.initialized {
return nil
}
// Initialize backend (raw mode)
if err := t.backend.Init(); err != nil {
return err
}
w, h := t.backend.Size()
t.output.resize(w, h)
// Create input reader wrapping backend
t.input = newInputReader(t.backend)
// Set resize handler on backend
t.backend.SetResizeHandler(func(w, h int) {
// Non-blocking send to avoid backend blocking
select {
case t.resizeCh <- ResizeEvent{Width: w, Height: h}:
default:
// Drain and replace to ensure latest size is pending
select {
case <-t.resizeCh:
default:
}
select {
case t.resizeCh <- ResizeEvent{Width: w, Height: h}:
default:
}
}
})
// Enter alternate screen, hide cursor
t.writeRaw(csiAltScreenEnter)
t.writeRaw(csiCursorHide)
// DISABLE AUTO-WRAP
// Prevents terminal scroll/wrap on bottom-right corner write
t.writeRaw(csiAutoWrapOff)
// Invisible cursor
t.cursorVisible.Store(false)
// Clear screen
t.output.clear(RGBBlack)
// Start input reader
t.input.start()
t.initialized = true
return nil
}
// Fini restores terminal state
func (t *termImpl) Fini() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
// Disable mouse before other cleanup
if t.mouseMode != MouseModeNone {
w := t.output.writer
w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
w.Flush()
}
// Stop handlers
if t.input != nil {
t.input.stop()
}
// Show cursor
t.writeRaw(csiCursorShow)
// Exit alternate screen
t.writeRaw(csiAltScreenExit)
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
t.writeRaw(csiAutoWrapOn)
// Reset attributes
t.writeRaw(csiSGR0)
// Backend cleanup
t.backend.Fini()
t.finalized = true
}
// Size returns current terminal dimensions
func (t *termImpl) Size() (int, int) {
return t.backend.Size()
}
// ResizeChan returns the resize event channel
func (t *termImpl) ResizeChan() <-chan ResizeEvent {
return t.resizeCh
}
// ColorMode returns detected color capability
func (t *termImpl) ColorMode() ColorMode {
return t.output.colorMode
}
// Flush writes cell buffer to terminal
// Holds lock for entire operation to prevent race with Clear/MoveCursor
func (t *termImpl) Flush(cells []Cell, width, height int) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption
currW, currH := t.backend.Size()
if currW != width || currH != height {
return
}
t.output.flush(cells, width, height)
}
// Clear fills screen with background color
func (t *termImpl) Clear(bg RGB) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
t.output.clear(bg)
}
// SetCursorVisible shows/hides cursor
func (t *termImpl) SetCursorVisible(visible bool) {
if t.cursorVisible.Swap(visible) == visible {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
w := t.output.writer
if visible {
w.Write(csiCursorShow)
} else {
w.Write(csiCursorHide)
}
w.Flush()
}
// MoveCursor positions cursor (0-indexed)
func (t *termImpl) MoveCursor(x, y int) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
if t.output != nil {
t.output.invalidateCursor()
}
w, h := t.backend.Size()
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x >= w {
x = w - 1
}
if y >= h {
y = h - 1
}
// Write through buffered writer to maintain stream order
wBuf := t.output.writer
writeCursorPos(wBuf, x, y)
wBuf.Flush()
}
// Sync forces full redraw
func (t *termImpl) Sync() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
// Clear terminal before full redraw
// Diff-based rendering assumes physical terminal matches front buffer state
t.output.clear(RGBBlack)
t.output.forceFullRedraw()
}
// PollEvent blocks until next input event
func (t *termImpl) PollEvent() Event {
// Check synthetic events first
select {
case ev := <-t.syntheticCh:
return ev
default:
}
// Wait for input or resize
select {
case ev := <-t.syntheticCh:
return ev
case ev := <-t.input.events():
return ev
case re := <-t.resizeCh:
// We can return resize event directly
return Event{
Type: EventResize,
Width: re.Width,
Height: re.Height,
}
}
}
// PostEvent injects a synthetic event
func (t *termImpl) PostEvent(ev Event) {
select {
case t.syntheticCh <- ev:
default:
// Channel full, drop
}
}
// SetMouseMode enables or disables mouse mode
func (t *termImpl) SetMouseMode(mode MouseMode) error {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return nil
}
oldMode := t.mouseMode
t.mouseMode = mode
w := t.output.writer
// Disable modes no longer needed (reverse order of enable)
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
w.Write(csiMouseMotionOff)
}
if oldMode&MouseModeDrag != 0 && mode&MouseModeDrag == 0 {
w.Write(csiMouseDragOff)
}
if oldMode&MouseModeClick != 0 && mode&MouseModeClick == 0 {
w.Write(csiMouseClickOff)
}
// Disable SGR if disabling all mouse
if mode == MouseModeNone && oldMode != MouseModeNone {
w.Write(csiMouseSGROff)
}
// Enable SGR first if enabling any mouse mode
if mode != MouseModeNone && oldMode == MouseModeNone {
w.Write(csiMouseSGROn)
}
// Enable new modes (click is base, drag extends, motion extends further)
if mode&MouseModeClick != 0 && oldMode&MouseModeClick == 0 {
w.Write(csiMouseClickOn)
}
if mode&MouseModeDrag != 0 && oldMode&MouseModeDrag == 0 {
w.Write(csiMouseDragOn)
}
if mode&MouseModeMotion != 0 && oldMode&MouseModeMotion == 0 {
w.Write(csiMouseMotionOn)
}
w.Flush()
return nil
}
// 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
func EmergencyReset(w io.Writer) {
// Disable mouse tracking
w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
// Write sequences to provided writer
w.Write(csiCursorShow)
w.Write(csiAltScreenExit)
w.Write(csiSGR0)
w.Write(csiAutoWrapOn)
w.Write(csiRIS)
// Flush if it's a file
if f, ok := w.(*os.File); ok {
f.Sync()
}
// Attempt raw mode reset via stty - escape sequences alone don't restore termios
// This is best-effort; ignore errors in crash context
resetTerminalMode()
}
+206
View File
@@ -0,0 +1,206 @@
# tui
Immediate-mode widget toolkit on top of the `terminal` cell buffer. No retained
widget tree, no framework loop: the application owns a `[]terminal.Cell` buffer
and all state; `tui` provides regions, layout math, render functions, and
plain-struct state helpers. Every frame is a full logical redraw — the
`terminal` diff layer keeps actual output minimal.
## Core concept: Region
A `Region` is a bounds-checked rectangular view over a cell slice. All drawing
goes through regions; coordinates are region-relative. Sub-regions nest and
clip to parent bounds, so widgets cannot draw outside their allotted area.
```go
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
root := tui.NewRegion(cells, w, 0, 0, w, h)
panel := root.Sub(2, 1, 40, 10) // clipped view
inner := panel.Inset(1) // shrink 1 cell on all sides
```
Out-of-bounds writes are silently dropped — no bounds management needed in
widget code.
## Quick start
```go
term := terminal.New()
term.Init()
defer term.Fini()
w, h := term.Size()
cells := make([]terminal.Cell, w*h)
list := tui.NewScrollState(len(items), h-2)
list.Selection = 0
for {
root := tui.NewRegion(cells, w, 0, 0, w, h)
root.Fill(terminal.Gunmetal)
root.Box(tui.LineRounded, terminal.SteelBlue)
content := root.Inset(1)
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
CursorBg: terminal.DarkSlate,
})
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
terminal.IronGray)
term.Flush(cells, w, h)
ev := term.PollEvent()
switch ev.Type {
case terminal.EventKey:
switch ev.Key {
case terminal.KeyUp:
list.SelectPrev()
case terminal.KeyDown:
list.SelectNext()
case terminal.KeyEscape:
return
}
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
list.SetVisible(h - 2)
}
}
```
## Layout
```go
cols := tui.SplitH(root, 0.3, 0.7) // ratio split, normalized
rows := tui.SplitV(cols[1], 0.5, 0.5)
side, main := tui.SplitHFixed(root, 24) // fixed left width
top, rest := tui.SplitVFixed(root, 3) // fixed top height
dlg := tui.Center(root, 50, 12) // centered sub-region
```
The last ratio segment absorbs rounding remainder — no gaps.
## Text and style
```go
r.Text(x, y, "label", fg, bg, terminal.AttrNone)
r.TextCenter(y, "title", fg, bg, terminal.AttrBold)
r.TextRight(y, "hint", fg, bg, terminal.AttrDim)
lines := r.TextBlock(x, y, longText, fg, bg, attr) // word-wrapped, returns line count
r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
```
String utilities operate on rune counts: `RuneLen`, `Truncate` /
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
`PadCenter`, `WrapText`.
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
accept it.
## Widgets
Widgets are stateless render functions (mostly `Region` methods). Application
state lives in plain structs passed by pointer. Available renderers:
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
`ScrollIndicator`, masonry layout.
Representative patterns below; remaining widgets follow the same
opts-struct + state-struct shape — read the source for full options.
### 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,
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)
```
### Modal dialog
```go
dlg := tui.Center(root, 50, 12)
content := dlg.Modal(tui.ModalOpts{
Title: "Settings",
Border: tui.LineDouble,
BorderFg: terminal.SteelBlue,
TitleFg: terminal.White,
Bg: terminal.DarkSlate,
})
content.TextBlock(0, 0, body, fg, terminal.DarkSlate, terminal.AttrNone)
```
`Modal` fills, borders, titles, and returns the content region. `Overlay`
adds fullscreen/floating/shadow variants; `ConfirmDialog` adds yes/no buttons
with focus state.
### Progress overlay
```go
prog := tui.NewProgressState(tui.DefaultProgressOpts("Indexing", "Scanning...",
tui.ProgressDeterminate))
// per frame:
prog.Tick()
prog.SetProgress(done / total)
if prog.Visible {
root.ProgressOverlay(prog.Opts)
}
```
Five progress types (spinner, determinate, indeterminate, pulse, dots), eight
spinner styles, eight bar styles, seven frame styles — combinable via opts.
### Multi-line editor
```go
ed := tui.NewEditorState(initialText)
// input:
if ev.Type == terminal.EventKey {
ed.HandleKey(ev.Key, ev.Rune, ev.Modifiers) // full emacs-style bindings built in
}
// render:
r.Editor(ed, tui.EditorOpts{LineNumbers: true, Border: tui.LineSingle, Focused: true})
text := ed.Value()
```
`TextFieldState` + `TextField` provide the single-line equivalent
(placeholder, prefix, password mask, max length).
## State helpers
Pure logic, no rendering — usable independently:
- `ScrollState` — item-index scrolling with selection
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
- `ViewportScroll` — row-based content scrolling with viewport clipping
(`ClipToViewport` maps content rows to visible rows)
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
keyed state, hierarchical → flat visible-node list
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
- `MasonryState` — multi-column layout calculation over a viewport
- Free functions: `AdjustScroll`, `ClampScroll`, `ClampCursor`, `ScrollPercent`,
`PageDelta`
## Notes
- Width calculations count runes, not terminal columns; East Asian wide
characters and combining marks are not width-aware.
- Zero-value `terminal.RGB` in style fields generally means "inherit"
(widget default or row background) — check specific widget docs.
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
application-side geometry from the regions used.
+159
View File
@@ -0,0 +1,159 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// LineType specifies box drawing character style
type LineType uint8
const (
LineSingle LineType = iota // ┌─┐│└┘
LineDouble // ╔═╗║╚╝
LineRounded // ╭─╮│╰╯
LineHeavy // ┏━┓┃┗┛
LineNone // spaces (invisible border with padding)
)
// boxChars contains box drawing character sets indexed by LineType
var boxChars = [...][6]rune{
LineSingle: {'┌', '─', '┐', '│', '└', '┘'},
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'},
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'},
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'},
LineNone: {' ', ' ', ' ', ' ', ' ', ' '},
}
const (
boxTL = 0 // top-left
boxH = 1 // horizontal
boxTR = 2 // top-right
boxV = 3 // vertical
boxBL = 4 // bottom-left
boxBR = 5 // bottom-right
)
// --- Box Rendering ---
// Box draws border around region edge
func (r Region) Box(line LineType, fg terminal.RGB) {
if r.W < 2 || r.H < 2 {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
chars := boxChars[line]
bg := terminal.RGB{} // Transparent (use existing bg)
// Corners
r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, 0, chars[boxTR], fg, bg, terminal.AttrNone)
r.Cell(0, r.H-1, chars[boxBL], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, r.H-1, chars[boxBR], fg, bg, terminal.AttrNone)
// Horizontal edges
for x := 1; x < r.W-1; x++ {
r.Cell(x, 0, chars[boxH], fg, bg, terminal.AttrNone)
r.Cell(x, r.H-1, chars[boxH], fg, bg, terminal.AttrNone)
}
// Vertical edges
for y := 1; y < r.H-1; y++ {
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
}
}
// BoxFilled draws border and fills interior with background
func (r Region) BoxFilled(line LineType, fg, bg terminal.RGB) {
// Fill interior first
for y := 1; y < r.H-1; y++ {
for x := 1; x < r.W-1; x++ {
r.Cell(x, y, ' ', fg, bg, terminal.AttrNone)
}
}
// Draw border on top
r.Box(line, fg)
}
// --- Line rendering ---
// HLine draws horizontal line across region width at row y
func (r Region) HLine(y int, line LineType, fg terminal.RGB) {
if y < 0 || y >= r.H {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxH]
for x := 0; x < r.W; x++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// VLine draws vertical line across region height at column x
func (r Region) VLine(x int, line LineType, fg terminal.RGB) {
if x < 0 || x >= r.W {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxV]
for y := 0; y < r.H; y++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// Divider draws horizontal line with optional centered label
func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
if y < 0 || y >= r.H {
return
}
if line >= LineType(len(boxChars)) {
line = LineSingle
}
hChar := boxChars[line][boxH]
// Fill with horizontal line
for x := 0; x < r.W; x++ {
r.Cell(x, y, hChar, fg, terminal.RGB{}, terminal.AttrNone)
}
// Center label if provided
if label != "" && r.W > 4 {
text := " " + label + " "
textLen := RuneLen(text)
if textLen > r.W-2 {
text = Truncate(text, r.W-2)
textLen = RuneLen(text)
}
startX := (r.W - textLen) / 2
for i, ch := range text {
r.Cell(startX+i, y, ch, fg, terminal.RGB{}, terminal.AttrBold)
}
}
}
// --- Card rendering ---
// Card draws titled border and returns inner content region
func (r Region) Card(title string, line LineType, fg terminal.RGB) Region {
r.Box(line, fg)
if title != "" && r.W > 4 {
maxTitleLen := r.W - 4
displayTitle := title
if RuneLen(displayTitle) > maxTitleLen {
displayTitle = Truncate(displayTitle, maxTitleLen)
}
titleX := (r.W - RuneLen(displayTitle) - 2) / 2
r.Text(titleX, 0, " "+displayTitle+" ", fg, terminal.RGB{}, terminal.AttrBold)
}
return r.Inset(1)
}
+127
View File
@@ -0,0 +1,127 @@
package tui
import "github.com/lixenwraith/terminal"
// Button defines a single button in a button bar
type Button struct {
Label string
Key string // Keyboard hint (e.g., "Ctrl+S")
Focused bool
}
// ButtonBarOpts configures button bar rendering
type ButtonBarOpts struct {
Align BarAlign
Gap int
Style ButtonStyle
}
// ButtonStyle defines button bar colors
type ButtonStyle struct {
LabelFg terminal.RGB
LabelBg terminal.RGB
KeyFg terminal.RGB
FocusFg terminal.RGB
FocusBg terminal.RGB
Bg terminal.RGB
}
// DefaultButtonStyle returns default button colors with dark background
func DefaultButtonStyle() ButtonStyle {
return DefaultButtonStyleFrom(terminal.RGB{R: 25, G: 25, B: 35})
}
// DefaultButtonStyleFrom returns default button colors using the given background
func DefaultButtonStyleFrom(bg terminal.RGB) ButtonStyle {
return ButtonStyle{
LabelFg: terminal.RGB{R: 200, G: 200, B: 200},
LabelBg: terminal.RGB{R: 50, G: 50, B: 60},
KeyFg: terminal.RGB{R: 130, G: 130, B: 150},
FocusFg: terminal.RGB{R: 255, G: 255, B: 255},
FocusBg: terminal.RGB{R: 60, G: 80, B: 120},
Bg: bg,
}
}
// ButtonBar renders a row of buttons with labels and keyboard hints at row y
func (r Region) ButtonBar(y int, buttons []Button, opts ButtonBarOpts) {
if len(buttons) == 0 || y < 0 || y >= r.H {
return
}
style := opts.Style
if style == (ButtonStyle{}) {
style = DefaultButtonStyle()
}
gap := opts.Gap
if gap < 1 {
gap = 2
}
// Calculate total width
totalW := 0
for i, btn := range buttons {
btnW := RuneLen(btn.Label) + 2
if btn.Key != "" {
btnW += RuneLen(btn.Key) + 1
}
totalW += btnW
if i < len(buttons)-1 {
totalW += gap
}
}
// Starting X
x := 0
switch opts.Align {
case BarAlignRight:
x = r.W - totalW
case BarAlignCenter:
x = (r.W - totalW) / 2
case BarAlignLeft:
x = 0
}
if x < 0 {
x = 0
}
// Clear row
for i := 0; i < r.W; i++ {
r.Cell(i, y, ' ', style.LabelFg, style.Bg, terminal.AttrNone)
}
// Render buttons
for i, btn := range buttons {
fg := style.LabelFg
bg := style.LabelBg
if btn.Focused {
fg = style.FocusFg
bg = style.FocusBg
}
label := " " + btn.Label + " "
for _, ch := range label {
if x >= r.W {
break
}
r.Cell(x, y, ch, fg, bg, terminal.AttrNone)
x++
}
if btn.Key != "" {
keyStr := " " + btn.Key
for _, ch := range keyStr {
if x >= r.W {
break
}
r.Cell(x, y, ch, style.KeyFg, style.Bg, terminal.AttrNone)
x++
}
}
if i < len(buttons)-1 {
x += gap
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// CheckState represents checkbox visual state
type CheckState uint8
const (
CheckNone CheckState = iota // [ ]
CheckPartial // [o]
CheckFull // [x]
CheckPlus // [+]
)
// Checkbox draws a checkbox indicator
func (r Region) Checkbox(x, y int, state CheckState, fg terminal.RGB) {
if x < 0 || x+2 >= r.W || y < 0 || y >= r.H {
return
}
var ch rune
switch state {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+1, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+2, y, ']', fg, terminal.RGB{}, terminal.AttrNone)
}
+67
View File
@@ -0,0 +1,67 @@
package tui
// Coverage represents count/total for display in tree nodes
type Coverage struct {
Count int
Total int
}
// IsAll returns true if count equals total and total is positive
func (c Coverage) IsAll() bool {
return c.Count == c.Total && c.Total > 0
}
// IsPartial returns true if count is between zero and total (exclusive)
func (c Coverage) IsPartial() bool {
return c.Count > 0 && c.Count < c.Total
}
// IsNone returns true if count is zero
func (c Coverage) IsNone() bool {
return c.Count == 0
}
// String returns coverage as "[count/total]", "[ALL]", or empty string if total is zero
func (c Coverage) String() string {
if c.Total == 0 {
return ""
}
if c.IsAll() {
return "[ALL]"
}
return "[" + intStr(c.Count) + "/" + intStr(c.Total) + "]"
}
// FormatCoverageSuffix returns a suffix string for TreeNode.Suffix
func FormatCoverageSuffix(count, total int) string {
if total == 0 {
return ""
}
if count == total {
return " [ALL]"
}
return " [" + intStr(count) + "/" + intStr(total) + "]"
}
// intStr converts int to string without fmt dependency
func intStr(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+324
View File
@@ -0,0 +1,324 @@
package tui
import "github.com/lixenwraith/terminal"
// ConfirmResult represents dialog outcome
type ConfirmResult uint8
const (
ConfirmPending ConfirmResult = iota
ConfirmYes
ConfirmNo
ConfirmCancel
)
// ConfirmState holds confirmation dialog state
type ConfirmState struct {
FocusYes bool // true = Yes focused, false = No focused
Result ConfirmResult
}
// NewConfirmState creates dialog state with default selection
func NewConfirmState(defaultYes bool) *ConfirmState {
return &ConfirmState{
FocusYes: defaultYes,
Result: ConfirmPending,
}
}
// Toggle switches focus between Yes and No
func (c *ConfirmState) Toggle() {
c.FocusYes = !c.FocusYes
}
// Confirm selects currently focused button
func (c *ConfirmState) Confirm() {
if c.FocusYes {
c.Result = ConfirmYes
} else {
c.Result = ConfirmNo
}
}
// SelectYes directly selects Yes
func (c *ConfirmState) SelectYes() {
c.Result = ConfirmYes
}
// SelectNo directly selects No
func (c *ConfirmState) SelectNo() {
c.Result = ConfirmNo
}
// Cancel cancels the dialog
func (c *ConfirmState) Cancel() {
c.Result = ConfirmCancel
}
// HandleKey processes input, returns true if dialog should close
func (c *ConfirmState) HandleKey(key terminal.Key, r rune) bool {
switch key {
case terminal.KeyLeft, terminal.KeyRight, terminal.KeyTab:
c.Toggle()
return false
case terminal.KeyEnter:
c.Confirm()
return true
case terminal.KeyEscape:
c.Cancel()
return true
case terminal.KeyRune:
switch r {
case 'y', 'Y':
c.SelectYes()
return true
case 'n', 'N':
c.SelectNo()
return true
case 'h':
c.FocusYes = true
return false
case 'l':
c.FocusYes = false
return false
}
}
return false
}
// ConfirmOpts configures confirmation dialog
type ConfirmOpts struct {
Title string
Message string
YesLabel string // Default "Yes"
NoLabel string // Default "No"
Destructive bool // Style Yes as warning
Style ConfirmStyle
}
// ConfirmStyle defines dialog colors
type ConfirmStyle struct {
BorderFg terminal.RGB
TitleFg terminal.RGB
MessageFg terminal.RGB
Bg terminal.RGB
ButtonFg terminal.RGB
ButtonBg terminal.RGB
ButtonFocusFg terminal.RGB
ButtonFocusBg terminal.RGB
DestructiveFg terminal.RGB
DestructiveBg terminal.RGB
}
// DefaultConfirmStyle returns default dialog colors
func DefaultConfirmStyle() ConfirmStyle {
return ConfirmStyle{
BorderFg: terminal.RGB{R: 100, G: 100, B: 120},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
MessageFg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
ButtonFg: terminal.RGB{R: 180, G: 180, B: 180},
ButtonBg: terminal.RGB{R: 50, G: 50, B: 60},
ButtonFocusFg: terminal.RGB{R: 255, G: 255, B: 255},
ButtonFocusBg: terminal.RGB{R: 60, G: 80, B: 120},
DestructiveFg: terminal.RGB{R: 255, G: 255, B: 255},
DestructiveBg: terminal.RGB{R: 180, G: 60, B: 60},
}
}
// ConfirmDialog renders confirmation dialog centered in region
// Returns content region for additional content if needed
func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.YesLabel == "" {
opts.YesLabel = "Yes"
}
if opts.NoLabel == "" {
opts.NoLabel = "No"
}
// Calculate dialog size
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 40
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
if dialogW < 20 {
dialogW = 20
}
dialogH := 3 + len(msgLines) + 3 // border + message + spacing + buttons + border
if dialogH > r.H-2 {
dialogH = r.H - 2
}
// Center dialog
dialog := Center(r, dialogW, dialogH)
// Draw modal frame
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Buttons row
buttonY := content.H - 1
if buttonY < y+1 {
buttonY = y + 1
}
yesLabel := " " + opts.YesLabel + " "
noLabel := " " + opts.NoLabel + " "
yesW := RuneLen(yesLabel)
noW := RuneLen(noLabel)
buttonGap := 4
totalButtonW := yesW + buttonGap + noW
buttonX := (content.W - totalButtonW) / 2
if buttonX < 0 {
buttonX = 0
}
// Yes button
yesFg := style.ButtonFg
yesBg := style.ButtonBg
if state.FocusYes {
if opts.Destructive {
yesFg = style.DestructiveFg
yesBg = style.DestructiveBg
} else {
yesFg = style.ButtonFocusFg
yesBg = style.ButtonFocusBg
}
}
for i, ch := range yesLabel {
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
}
}
// No button
noX := buttonX + yesW + buttonGap
noFg := style.ButtonFg
noBg := style.ButtonBg
if !state.FocusYes {
noFg = style.ButtonFocusFg
noBg = style.ButtonFocusBg
}
for i, ch := range noLabel {
if noX+i < content.W {
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1)
}
// AlertOpts configures single-button alert dialog
type AlertOpts struct {
Title string
Message string
Button string // Default "OK"
Style ConfirmStyle
}
// AlertDialog renders single-button alert, returns true when dismissed
func (r Region) AlertDialog(opts AlertOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.Button == "" {
opts.Button = "OK"
}
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 36
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
dialogH := 3 + len(msgLines) + 3
if dialogH > r.H-2 {
dialogH = r.H - 2
}
dialog := Center(r, dialogW, dialogH)
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Button
buttonY := content.H - 1
buttonLabel := " " + opts.Button + " "
buttonW := RuneLen(buttonLabel)
buttonX := (content.W - buttonW) / 2
for i, ch := range buttonLabel {
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1)
}
+197
View File
@@ -0,0 +1,197 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// EditorOpts configures editor rendering
type EditorOpts struct {
LineNumbers bool
LineNumWidth int // 0 = auto-size
WrapLines bool
Border LineType
Focused bool
Style EditorStyle
}
// EditorStyle defines editor colors
type EditorStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
LineNumFg terminal.RGB
LineNumBg terminal.RGB
CurrentLineBg terminal.RGB
BorderFg terminal.RGB
}
// DefaultEditorStyle returns default colors
func DefaultEditorStyle() EditorStyle {
return EditorStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 25, G: 25, B: 35},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
LineNumFg: terminal.RGB{R: 100, G: 100, B: 120},
LineNumBg: terminal.RGB{R: 30, G: 30, B: 40},
CurrentLineBg: terminal.RGB{R: 35, G: 35, B: 50},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// Editor renders multi-line editor and returns content height used
func (r Region) Editor(state *EditorState, opts EditorOpts) int {
if r.W < 3 || r.H < 1 {
return 0
}
style := opts.Style
if style == (EditorStyle{}) {
style = DefaultEditorStyle()
}
// Calculate content area accounting for border
contentX := 0
contentY := 0
contentW := r.W
contentH := r.H
if opts.Border != LineNone {
if r.H < 3 {
return 0
}
r.Box(opts.Border, style.BorderFg)
contentX = 1
contentY = 1
contentW = r.W - 2
contentH = r.H - 2
}
// Line number gutter
gutterW := 0
if opts.LineNumbers {
gutterW = opts.LineNumWidth
if gutterW == 0 {
digits := 1
n := len(state.Lines)
for n >= 10 {
digits++
n /= 10
}
gutterW = digits + 1
}
contentW -= gutterW
}
if contentW < 1 || contentH < 1 {
return 0
}
state.AdjustScroll(contentW, contentH)
// Render each visible line
for y := 0; y < contentH; y++ {
lineIdx := state.ScrollY + y
isCurrentLine := lineIdx == state.CursorLine
bg := style.TextBg
if isCurrentLine && opts.Focused {
bg = style.CurrentLineBg
}
// Line number gutter
if opts.LineNumbers {
for gx := 0; gx < gutterW-1; gx++ {
r.Cell(contentX+gx, contentY+y, ' ', style.LineNumFg, style.LineNumBg, terminal.AttrNone)
}
r.Cell(contentX+gutterW-1, contentY+y, '│', style.LineNumFg, style.LineNumBg, terminal.AttrDim)
if lineIdx < len(state.Lines) {
numStr := formatLineNum(lineIdx+1, gutterW-1)
for i, ch := range numStr {
r.Cell(contentX+i, contentY+y, ch, style.LineNumFg, style.LineNumBg, terminal.AttrNone)
}
}
}
textX := contentX + gutterW
// Fill text area with background
for x := 0; x < contentW; x++ {
r.Cell(textX+x, contentY+y, ' ', style.TextFg, bg, terminal.AttrNone)
}
if lineIdx >= len(state.Lines) {
continue
}
line := []rune(state.Lines[lineIdx])
// Scroll indicator left
if state.ScrollX > 0 && len(line) > 0 {
r.Cell(textX, contentY+y, '◀', style.LineNumFg, bg, terminal.AttrDim)
}
// Render visible text
for x := 0; x < contentW; x++ {
charIdx := state.ScrollX + x
if charIdx >= len(line) {
break
}
ch := line[charIdx]
fg := style.TextFg
cellBg := bg
if opts.Focused && lineIdx == state.CursorLine && charIdx == state.CursorCol {
fg = style.CursorFg
cellBg = style.CursorBg
}
r.Cell(textX+x, contentY+y, ch, fg, cellBg, terminal.AttrNone)
}
// Scroll indicator right
if state.ScrollX+contentW < len(line) {
r.Cell(textX+contentW-1, contentY+y, '▶', style.LineNumFg, bg, terminal.AttrDim)
}
// Cursor at end of line
if opts.Focused && lineIdx == state.CursorLine && state.CursorCol >= len(line) {
cursorX := state.CursorCol - state.ScrollX
if cursorX >= 0 && cursorX < contentW {
r.Cell(textX+cursorX, contentY+y, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
}
// Vertical scroll indicators
if state.ScrollY > 0 {
r.Cell(contentX+gutterW+contentW-1, contentY, '▲', style.LineNumFg, style.TextBg, terminal.AttrDim)
}
if state.ScrollY+contentH < len(state.Lines) {
r.Cell(contentX+gutterW+contentW-1, contentY+contentH-1, '▼', style.LineNumFg, style.TextBg, terminal.AttrDim)
}
if opts.Border != LineNone {
return r.H
}
return contentH
}
// formatLineNum formats line number right-aligned to width
func formatLineNum(num, width int) string {
s := ""
for num > 0 {
s = string(rune('0'+num%10)) + s
num /= 10
}
if s == "" {
s = "0"
}
for len(s) < width {
s = " " + s
}
return s
}
+535
View File
@@ -0,0 +1,535 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// splitLines splits string into lines by newline character
func splitLines(s string) []string {
if s == "" {
return []string{""}
}
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
// EditorState holds multi-line text editor state
type EditorState struct {
Lines []string
CursorLine int
CursorCol int
ScrollX int
ScrollY int
ViewportW int // Updated during render
ViewportH int // Updated during render
}
// NewEditorState creates initialized editor state
func NewEditorState(initial string) *EditorState {
lines := splitLines(initial)
if len(lines) == 0 {
lines = []string{""}
}
return &EditorState{
Lines: lines,
}
}
// --- Value access ---
// Value returns all lines joined with newlines
func (e *EditorState) Value() string {
if len(e.Lines) == 0 {
return ""
}
result := e.Lines[0]
for i := 1; i < len(e.Lines); i++ {
result += "\n" + e.Lines[i]
}
return result
}
// SetValue replaces all content and resets cursor
func (e *EditorState) SetValue(s string) {
e.Lines = splitLines(s)
if len(e.Lines) == 0 {
e.Lines = []string{""}
}
e.CursorLine = 0
e.CursorCol = 0
e.ScrollX = 0
e.ScrollY = 0
}
// Clear empties the editor
func (e *EditorState) Clear() {
e.Lines = []string{""}
e.CursorLine = 0
e.CursorCol = 0
e.ScrollX = 0
e.ScrollY = 0
}
// --- Line queries ---
// LineCount returns number of lines
func (e *EditorState) LineCount() int {
return len(e.Lines)
}
// CurrentLine returns current line text
func (e *EditorState) CurrentLine() string {
if e.CursorLine < 0 || e.CursorLine >= len(e.Lines) {
return ""
}
return e.Lines[e.CursorLine]
}
// --- Cursor clamping ---
// clampCursor ensures cursor is within valid bounds
func (e *EditorState) clampCursor() {
if len(e.Lines) == 0 {
e.Lines = []string{""}
}
if e.CursorLine < 0 {
e.CursorLine = 0
}
if e.CursorLine >= len(e.Lines) {
e.CursorLine = len(e.Lines) - 1
}
lineLen := len([]rune(e.Lines[e.CursorLine]))
if e.CursorCol < 0 {
e.CursorCol = 0
}
if e.CursorCol > lineLen {
e.CursorCol = lineLen
}
}
// --- Character insertion ---
// Insert adds a rune at cursor position
func (e *EditorState) Insert(r rune) {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
line = append(line[:e.CursorCol], append([]rune{r}, line[e.CursorCol:]...)...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol++
}
// InsertString adds string at cursor, handling newlines
func (e *EditorState) InsertString(s string) {
for _, r := range s {
if r == '\n' {
e.InsertNewline()
} else {
e.Insert(r)
}
}
}
// InsertNewline splits current line at cursor
func (e *EditorState) InsertNewline() {
e.clampCursor()
runes := []rune(e.Lines[e.CursorLine])
before := string(runes[:e.CursorCol])
after := string(runes[e.CursorCol:])
e.Lines[e.CursorLine] = before
e.Lines = append(e.Lines[:e.CursorLine+1], append([]string{after}, e.Lines[e.CursorLine+1:]...)...)
e.CursorLine++
e.CursorCol = 0
}
// --- Character deletion ---
// DeleteBackward deletes character before cursor or merges lines
func (e *EditorState) DeleteBackward() bool {
e.clampCursor()
if e.CursorCol > 0 {
line := []rune(e.Lines[e.CursorLine])
line = append(line[:e.CursorCol-1], line[e.CursorCol:]...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol--
return true
}
if e.CursorLine > 0 {
prevLine := e.Lines[e.CursorLine-1]
curLine := e.Lines[e.CursorLine]
newCol := len([]rune(prevLine))
e.Lines[e.CursorLine-1] = prevLine + curLine
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
e.CursorLine--
e.CursorCol = newCol
return true
}
return false
}
// DeleteForward deletes character at cursor or merges with next line
func (e *EditorState) DeleteForward() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol < len(line) {
line = append(line[:e.CursorCol], line[e.CursorCol+1:]...)
e.Lines[e.CursorLine] = string(line)
return true
}
if e.CursorLine < len(e.Lines)-1 {
e.Lines[e.CursorLine] = e.Lines[e.CursorLine] + e.Lines[e.CursorLine+1]
e.Lines = append(e.Lines[:e.CursorLine+1], e.Lines[e.CursorLine+2:]...)
return true
}
return false
}
// --- Word deletion ---
// DeleteWordBackward deletes word before cursor
func (e *EditorState) DeleteWordBackward() bool {
e.clampCursor()
if e.CursorCol == 0 {
return e.DeleteBackward()
}
line := []rune(e.Lines[e.CursorLine])
end := e.CursorCol
// Skip trailing non-word chars
for end > 0 && !isWordChar(line[end-1]) {
end--
}
// Skip word chars
start := end
for start > 0 && isWordChar(line[start-1]) {
start--
}
if start == e.CursorCol {
start = e.CursorCol - 1
}
line = append(line[:start], line[e.CursorCol:]...)
e.Lines[e.CursorLine] = string(line)
e.CursorCol = start
return true
}
// DeleteWordForward deletes word after cursor
func (e *EditorState) DeleteWordForward() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol >= len(line) {
return e.DeleteForward()
}
start := e.CursorCol
end := start
// Skip word chars
for end < len(line) && isWordChar(line[end]) {
end++
}
// Skip trailing non-word chars
for end < len(line) && !isWordChar(line[end]) {
end++
}
if end == start {
end = start + 1
}
line = append(line[:start], line[end:]...)
e.Lines[e.CursorLine] = string(line)
return true
}
// Line deletion
// DeleteToEndOfLine deletes from cursor to end of line
func (e *EditorState) DeleteToEndOfLine() bool {
e.clampCursor()
line := []rune(e.Lines[e.CursorLine])
if e.CursorCol < len(line) {
e.Lines[e.CursorLine] = string(line[:e.CursorCol])
return true
}
// At end of line - merge with next
return e.DeleteForward()
}
// DeleteToStartOfLine deletes from start to cursor
func (e *EditorState) DeleteToStartOfLine() bool {
e.clampCursor()
if e.CursorCol > 0 {
line := []rune(e.Lines[e.CursorLine])
e.Lines[e.CursorLine] = string(line[e.CursorCol:])
e.CursorCol = 0
return true
}
return false
}
// DeleteLine removes current line
func (e *EditorState) DeleteLine() bool {
if len(e.Lines) == 1 {
e.Lines[0] = ""
e.CursorCol = 0
return true
}
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
e.clampCursor()
return true
}
// --- Line navigation ---
// MoveUp moves cursor to previous line
func (e *EditorState) MoveUp() {
if e.CursorLine > 0 {
e.CursorLine--
e.clampCursor()
}
}
// MoveDown moves cursor to next line
func (e *EditorState) MoveDown() {
if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.clampCursor()
}
}
// --- Character navigation ---
// MoveLeft moves cursor left, wrapping to previous line
func (e *EditorState) MoveLeft() {
if e.CursorCol > 0 {
e.CursorCol--
} else if e.CursorLine > 0 {
e.CursorLine--
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
}
// MoveRight moves cursor right, wrapping to next line
func (e *EditorState) MoveRight() {
lineLen := len([]rune(e.Lines[e.CursorLine]))
if e.CursorCol < lineLen {
e.CursorCol++
} else if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.CursorCol = 0
}
}
// --- Word navigation ---
// MoveWordLeft moves cursor to previous word boundary
func (e *EditorState) MoveWordLeft() {
if e.CursorCol == 0 {
if e.CursorLine > 0 {
e.CursorLine--
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
return
}
line := []rune(e.Lines[e.CursorLine])
for e.CursorCol > 0 && !isWordChar(line[e.CursorCol-1]) {
e.CursorCol--
}
for e.CursorCol > 0 && isWordChar(line[e.CursorCol-1]) {
e.CursorCol--
}
}
// MoveWordRight moves cursor to next word boundary
func (e *EditorState) MoveWordRight() {
line := []rune(e.Lines[e.CursorLine])
lineLen := len(line)
if e.CursorCol >= lineLen {
if e.CursorLine < len(e.Lines)-1 {
e.CursorLine++
e.CursorCol = 0
}
return
}
for e.CursorCol < lineLen && isWordChar(line[e.CursorCol]) {
e.CursorCol++
}
for e.CursorCol < lineLen && !isWordChar(line[e.CursorCol]) {
e.CursorCol++
}
}
// --- Positions navigation ---
// MoveToLineStart moves cursor to start of line
func (e *EditorState) MoveToLineStart() {
e.CursorCol = 0
}
// MoveToLineEnd moves cursor to end of line
func (e *EditorState) MoveToLineEnd() {
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
// MoveToStart moves cursor to start of document
func (e *EditorState) MoveToStart() {
e.CursorLine = 0
e.CursorCol = 0
}
// MoveToEnd moves cursor to end of document
func (e *EditorState) MoveToEnd() {
e.CursorLine = len(e.Lines) - 1
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
}
// --- Page navigation ---
// PageUp moves cursor up by half viewport
func (e *EditorState) PageUp() {
delta := e.ViewportH / 2
if delta < 1 {
delta = 1
}
e.CursorLine -= delta
if e.CursorLine < 0 {
e.CursorLine = 0
}
e.clampCursor()
}
// PageDown moves cursor down by half viewport
func (e *EditorState) PageDown() {
delta := e.ViewportH / 2
if delta < 1 {
delta = 1
}
e.CursorLine += delta
if e.CursorLine >= len(e.Lines) {
e.CursorLine = len(e.Lines) - 1
}
e.clampCursor()
}
// --- Scroll management ---
// AdjustScroll updates scroll to keep cursor visible
func (e *EditorState) AdjustScroll(viewportW, viewportH int) {
e.ViewportW = viewportW
e.ViewportH = viewportH
// Vertical
if e.CursorLine < e.ScrollY {
e.ScrollY = e.CursorLine
}
if e.CursorLine >= e.ScrollY+viewportH {
e.ScrollY = e.CursorLine - viewportH + 1
}
if e.ScrollY < 0 {
e.ScrollY = 0
}
// Horizontal
if e.CursorCol < e.ScrollX {
e.ScrollX = e.CursorCol
}
if e.CursorCol >= e.ScrollX+viewportW {
e.ScrollX = e.CursorCol - viewportW + 1
}
if e.ScrollX < 0 {
e.ScrollX = 0
}
}
// --- Input handling ---
// HandleKey processes keyboard input, returns true if state changed
func (e *EditorState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyUp:
e.MoveUp()
return true
case terminal.KeyDown:
e.MoveDown()
return true
case terminal.KeyLeft:
if mod&terminal.ModCtrl != 0 {
e.MoveWordLeft()
} else {
e.MoveLeft()
}
return true
case terminal.KeyRight:
if mod&terminal.ModCtrl != 0 {
e.MoveWordRight()
} else {
e.MoveRight()
}
return true
case terminal.KeyHome:
if mod&terminal.ModCtrl != 0 {
e.MoveToStart()
} else {
e.MoveToLineStart()
}
return true
case terminal.KeyEnd:
if mod&terminal.ModCtrl != 0 {
e.MoveToEnd()
} else {
e.MoveToLineEnd()
}
return true
case terminal.KeyPageUp:
e.PageUp()
return true
case terminal.KeyPageDown:
e.PageDown()
return true
case terminal.KeyEnter:
e.InsertNewline()
return true
case terminal.KeyBackspace:
if mod&terminal.ModCtrl != 0 {
return e.DeleteWordBackward()
}
return e.DeleteBackward()
case terminal.KeyDelete:
if mod&terminal.ModCtrl != 0 {
return e.DeleteWordForward()
}
return e.DeleteForward()
case terminal.KeyCtrlA:
e.MoveToLineStart()
return true
case terminal.KeyCtrlE:
e.MoveToLineEnd()
return true
case terminal.KeyCtrlK:
return e.DeleteToEndOfLine()
case terminal.KeyCtrlU:
return e.DeleteToStartOfLine()
case terminal.KeyCtrlW:
return e.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 {
e.Insert(r)
return true
}
}
return false
}
+228
View File
@@ -0,0 +1,228 @@
package tui
import "github.com/lixenwraith/terminal"
// FormField pairs a label with an editable text field
type FormField struct {
Label string
State *TextFieldState
}
// FormState holds state for a multi-field form with focus tracking
type FormState struct {
Fields []FormField
Focus int
}
// NewFormState creates a form with labeled fields initialized to empty values
func NewFormState(labels ...string) *FormState {
fields := make([]FormField, len(labels))
for i, label := range labels {
fields[i] = FormField{
Label: label,
State: NewTextFieldState(""),
}
}
return &FormState{Fields: fields}
}
// Value returns the text value of the field at idx
func (f *FormState) Value(idx int) string {
if idx >= 0 && idx < len(f.Fields) {
return f.Fields[idx].State.Value()
}
return ""
}
// SetValue replaces the text of the field at idx
func (f *FormState) SetValue(idx int, val string) {
if idx >= 0 && idx < len(f.Fields) {
f.Fields[idx].State.SetValue(val)
}
}
// Clear empties all fields in the form
func (f *FormState) Clear() {
for i := range f.Fields {
f.Fields[i].State.Clear()
}
}
// FocusNext moves focus to the next field, wrapping around
func (f *FormState) FocusNext() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus + 1) % len(f.Fields)
}
}
// FocusPrev moves focus to the previous field, wrapping around
func (f *FormState) FocusPrev() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus - 1 + len(f.Fields)) % len(f.Fields)
}
}
// CurrentField returns the TextFieldState of the focused field, or nil
func (f *FormState) CurrentField() *TextFieldState {
if f.Focus >= 0 && f.Focus < len(f.Fields) {
return f.Fields[f.Focus].State
}
return nil
}
// HandleKey processes keyboard input for form navigation and field editing, returns true if state changed
func (f *FormState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyTab:
if mod&terminal.ModShift != 0 {
f.FocusPrev()
} else {
f.FocusNext()
}
return true
case terminal.KeyUp:
f.FocusPrev()
return true
case terminal.KeyDown:
f.FocusNext()
return true
default:
if field := f.CurrentField(); field != nil {
return field.HandleKey(key, r, mod)
}
}
return false
}
// FormOpts configures form rendering
type FormOpts struct {
LabelWidth int
Spacing int
Style FormStyle
}
// FormStyle defines form colors
type FormStyle struct {
LabelFg terminal.RGB
FieldFg terminal.RGB
FieldBg terminal.RGB
FocusBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
Bg terminal.RGB
}
// DefaultFormStyle returns default form colors
func DefaultFormStyle() FormStyle {
return FormStyle{
LabelFg: terminal.RGB{R: 150, G: 150, B: 180},
FieldFg: terminal.RGB{R: 220, G: 220, B: 220},
FieldBg: terminal.RGB{R: 35, G: 35, B: 45},
FocusBg: terminal.RGB{R: 45, G: 45, B: 60},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 25, G: 25, B: 35},
}
}
// Form renders a multi-field form with labels and editable text fields, returns height used
func (r Region) Form(state *FormState, opts FormOpts) int {
if len(state.Fields) == 0 || r.H < 1 {
return 0
}
style := opts.Style
if style == (FormStyle{}) {
style = DefaultFormStyle()
}
labelW := opts.LabelWidth
if labelW <= 0 {
for _, f := range state.Fields {
if w := RuneLen(f.Label); w > labelW {
labelW = w
}
}
labelW += 2
}
spacing := opts.Spacing
if spacing < 1 {
spacing = 1
}
y := 0
for i, field := range state.Fields {
if y >= r.H {
break
}
isFocused := i == state.Focus
// Label
label := field.Label + ":"
for j, ch := range label {
if j >= labelW || j >= r.W {
break
}
r.Cell(j, y, ch, style.LabelFg, style.Bg, terminal.AttrNone)
}
// Field area
fieldX := labelW
fieldW := r.W - labelW
if fieldW < 1 {
y += spacing
continue
}
fieldBg := style.FieldBg
if isFocused {
fieldBg = style.FocusBg
}
for x := fieldX; x < fieldX+fieldW && x < r.W; x++ {
r.Cell(x, y, ' ', style.FieldFg, fieldBg, terminal.AttrNone)
}
// Render text using existing TextField logic
text := field.State.Text
scroll := field.State.Scroll
cursor := field.State.Cursor
field.State.AdjustScroll(fieldW)
scroll = field.State.Scroll
for x := 0; x < fieldW; x++ {
runeIdx := scroll + x
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
}
fg := style.FieldFg
bg := fieldBg
if isFocused && runeIdx == cursor {
fg = style.CursorFg
bg = style.CursorBg
}
if fieldX+x < r.W {
r.Cell(fieldX+x, y, ch, fg, bg, terminal.AttrNone)
}
}
// Cursor at end of text
if isFocused && cursor == len(text) {
cursorX := fieldX + cursor - scroll
if cursorX >= fieldX && cursorX < fieldX+fieldW && cursorX < r.W {
r.Cell(cursorX, y, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
y += spacing
}
return y
}
+78
View File
@@ -0,0 +1,78 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// InputOpts configures single-line input field
type InputOpts struct {
Label string
LabelFg terminal.RGB
Text string
Cursor int // Cursor position in text (rune index)
CursorBg terminal.RGB
TextFg terminal.RGB
Bg terminal.RGB
}
// Input renders labeled text input field on row y, handling cursor display and horizontal scrolling
func (r Region) Input(y int, opts InputOpts) {
if y < 0 || y >= r.H || r.W < 5 {
return
}
x := 0
// Label
if opts.Label != "" {
for _, ch := range opts.Label {
if x >= r.W {
break
}
r.Cell(x, y, ch, opts.LabelFg, opts.Bg, terminal.AttrNone)
x++
}
}
// Available width for input text
inputW := r.W - x
if inputW < 3 {
return
}
runes := []rune(opts.Text)
cursor := opts.Cursor
if cursor > len(runes) {
cursor = len(runes)
}
if cursor < 0 {
cursor = 0
}
// Horizontal scroll to keep cursor visible
scroll := 0
if cursor >= inputW-1 {
scroll = cursor - inputW + 2
}
// Render visible portion
for i := 0; i < inputW; i++ {
runeIdx := scroll + i
ch := ' '
if runeIdx < len(runes) {
ch = runes[runeIdx]
}
bg := opts.Bg
if runeIdx == cursor {
bg = opts.CursorBg
}
r.Cell(x+i, y, ch, opts.TextFg, bg, terminal.AttrNone)
}
// Cursor at end if past text
if cursor == len(runes) && cursor-scroll < inputW {
r.Cell(x+cursor-scroll, y, ' ', opts.TextFg, opts.CursorBg, terminal.AttrNone)
}
}
+199
View File
@@ -0,0 +1,199 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// KeyValue renders right-aligned key, separator, left-aligned value on row
// Key width auto-sizes based on content, capped at 40% of region width
// Value gets remainder, minimum 30% of region width
func (r Region) KeyValue(y int, key, value string, keyStyle, valStyle Style, sep rune) {
if y < 0 || y >= r.H || r.W < 3 {
return
}
keyLen := RuneLen(key)
// Dynamic allocation: key gets what it needs up to 40%
maxKeyW := (r.W * 2) / 5 // 40%
minValW := (r.W * 3) / 10 // 30%
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1 // -1 for separator
if valW < minValW && r.W > minValW+2 {
// Reclaim from key to meet minimum value width
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
// Truncate key if needed
keyRunes := []rune(key)
if len(keyRunes) > keyW {
if keyW > 1 {
keyRunes = keyRunes[:keyW-1]
keyRunes = append(keyRunes, '…')
} else {
keyRunes = keyRunes[:1]
}
}
// Truncate value if needed
valRunes := []rune(value)
if len(valRunes) > valW {
if valW > 1 {
valRunes = valRunes[:valW-1]
valRunes = append(valRunes, '…')
} else {
valRunes = valRunes[:1]
}
}
// Right-align key within allocated width
keyX := keyW - len(keyRunes)
for i, ch := range keyRunes {
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
}
// Separator
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
// Left-align value
for i, ch := range valRunes {
r.Cell(keyW+1+i, y, ch, valStyle.Fg, valStyle.Bg, valStyle.Attr)
}
}
// KeyValueWrap renders key-value with value wrapping to subsequent lines
// Returns number of lines used
// Layout:
//
// key: value text that is
// long and wraps to
// next line
func (r Region) KeyValueWrap(y int, key, value string, keyStyle, valStyle Style, sep rune) int {
if y < 0 || y >= r.H || r.W < 3 {
return 0
}
keyLen := RuneLen(key)
// Dynamic allocation same as KeyValue
maxKeyW := (r.W * 2) / 5 // 40%
minValW := (r.W * 3) / 10 // 30%
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1 // -1 for separator
if valW < minValW && r.W > minValW+2 {
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
// Truncate key if needed
keyRunes := []rune(key)
if len(keyRunes) > keyW {
if keyW > 1 {
keyRunes = keyRunes[:keyW-1]
keyRunes = append(keyRunes, '…')
} else {
keyRunes = keyRunes[:1]
}
}
// Right-align key within allocated width
keyX := keyW - len(keyRunes)
for i, ch := range keyRunes {
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
}
// Separator
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
// Wrap value text
valueX := keyW + 1
lines := WrapText(value, valW)
if len(lines) == 0 {
return 1
}
rendered := 0
for i, line := range lines {
lineY := y + i
if lineY >= r.H {
break
}
r.Text(valueX, lineY, line, valStyle.Fg, valStyle.Bg, valStyle.Attr)
rendered++
}
if rendered < 1 {
rendered = 1
}
return rendered
}
// MeasureKeyValueWrap calculates lines needed for KeyValueWrap without rendering
// Useful for layout pre-calculation
func (r Region) MeasureKeyValueWrap(key, value string) int {
if r.W < 3 {
return 1
}
keyLen := RuneLen(key)
maxKeyW := (r.W * 2) / 5
minValW := (r.W * 3) / 10
keyW := keyLen
if keyW > maxKeyW {
keyW = maxKeyW
}
if keyW < 1 {
keyW = 1
}
valW := r.W - keyW - 1
if valW < minValW && r.W > minValW+2 {
valW = minValW
keyW = r.W - valW - 1
if keyW < 1 {
keyW = 1
valW = r.W - 2
}
}
if valW < 1 {
valW = 1
}
lines := WrapText(value, valW)
if len(lines) == 0 {
return 1
}
return len(lines)
}
+309
View File
@@ -0,0 +1,309 @@
package tui
// --- Centering ---
// Center returns a centered region of given size within outer
func Center(outer Region, w, h int) Region {
x := (outer.W - w) / 2
y := (outer.H - h) / 2
return outer.Sub(x, y, w, h)
}
// --- Ratio-based splitting ---
// Ratios are normalized if they don't sum to 1.0
// SplitH splits region horizontally by ratios (0.0-1.0)
func SplitH(r Region, ratios ...float64) []Region {
if len(ratios) == 0 {
return nil
}
// Normalize ratios
var sum float64
for _, ratio := range ratios {
sum += ratio
}
if sum <= 0 {
sum = 1
}
regions := make([]Region, len(ratios))
x := 0
remaining := r.W
for i, ratio := range ratios {
var w int
if i == len(ratios)-1 {
w = remaining // Last one gets remainder to avoid rounding gaps
} else {
w = int((float64(r.W) * ratio / sum) + 0.5) // Round to nearest cell
if w > remaining {
w = remaining
}
}
regions[i] = r.Sub(x, 0, w, r.H)
x += w
remaining -= w
}
return regions
}
// SplitV splits region vertically by ratios (0.0-1.0)
func SplitV(r Region, ratios ...float64) []Region {
if len(ratios) == 0 {
return nil
}
var sum float64
for _, ratio := range ratios {
sum += ratio
}
if sum <= 0 {
sum = 1
}
regions := make([]Region, len(ratios))
y := 0
remaining := r.H
for i, ratio := range ratios {
var h int
if i == len(ratios)-1 {
h = remaining
} else {
h = int(float64(r.H) * ratio / sum)
}
regions[i] = r.Sub(0, y, r.W, h)
y += h
remaining -= h
}
return regions
}
// --- Fixed-size splitting ---
// SplitHFixed splits with fixed left width, rest to right
func SplitHFixed(r Region, leftW int) (left, right Region) {
if leftW > r.W {
leftW = r.W
}
if leftW < 0 {
leftW = 0
}
left = r.Sub(0, 0, leftW, r.H)
right = r.Sub(leftW, 0, r.W-leftW, r.H)
return
}
// SplitVFixed splits with fixed top height, rest to bottom
func SplitVFixed(r Region, topH int) (top, bottom Region) {
if topH > r.H {
topH = r.H
}
if topH < 0 {
topH = 0
}
top = r.Sub(0, 0, r.W, topH)
bottom = r.Sub(0, topH, r.W, r.H-topH)
return
}
// --- Equal splitting ---
// SplitHEqual splits region into n equal-width columns
// gap specifies spacing between columns (e.g., 1 for divider lines)
// Returns n regions positioned with gaps between them
func SplitHEqual(r Region, n, gap int) []Region {
if n <= 0 {
return nil
}
if n == 1 {
return []Region{r}
}
totalGaps := gap * (n - 1)
availW := r.W - totalGaps
if availW < n {
availW = n
}
baseW := availW / n
extra := availW % n
regions := make([]Region, n)
x := 0
for i := 0; i < n; i++ {
w := baseW
if i < extra {
w++
}
regions[i] = r.Sub(x, 0, w, r.H)
x += w + gap
}
return regions
}
// SplitVEqual splits region into n equal-height rows
// gap specifies spacing between rows
func SplitVEqual(r Region, n, gap int) []Region {
if n <= 0 {
return nil
}
if n == 1 {
return []Region{r}
}
totalGaps := gap * (n - 1)
availH := r.H - totalGaps
if availH < n {
availH = n
}
baseH := availH / n
extra := availH % n
regions := make([]Region, n)
y := 0
for i := 0; i < n; i++ {
h := baseH
if i < extra {
h++
}
regions[i] = r.Sub(0, y, r.W, h)
y += h + gap
}
return regions
}
// --- Grid layout ---
// Columns calculates how many columns fit in width
func Columns(availableW, itemW, gap int) int {
if itemW <= 0 {
return 0
}
if availableW < itemW {
return 0
}
// First item has no gap, subsequent items need gap + itemW
cols := 1 + (availableW-itemW)/(itemW+gap)
if cols < 0 {
cols = 0
}
return cols
}
// GridLayout returns a grid of equally sized regions
func GridLayout(r Region, cols, rows, gapX, gapY int) []Region {
if cols <= 0 || rows <= 0 {
return nil
}
cellW := (r.W - gapX*(cols-1)) / cols
cellH := (r.H - gapY*(rows-1)) / rows
if cellW < 1 {
cellW = 1
}
if cellH < 1 {
cellH = 1
}
regions := make([]Region, cols*rows)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
x := col * (cellW + gapX)
y := row * (cellH + gapY)
regions[row*cols+col] = r.Sub(x, y, cellW, cellH)
}
}
return regions
}
// --- Divider positioning ---
// DividerPositions returns X coordinates for vertical dividers between equal columns
// Use with VLine to draw dividers in gaps created by SplitHEqual
func DividerPositions(regionW, n, gap int) []int {
if n <= 1 || gap <= 0 {
return nil
}
totalGaps := gap * (n - 1)
availW := regionW - totalGaps
if availW < n {
availW = n
}
baseW := availW / n
extra := availW % n
positions := make([]int, n-1)
x := 0
for i := 0; i < n-1; i++ {
w := baseW
if i < extra {
w++
}
x += w
positions[i] = x
x += gap
}
return positions
}
// HDividerPositions returns Y coordinates for horizontal dividers between equal rows
func HDividerPositions(regionH, n, gap int) []int {
if n <= 1 || gap <= 0 {
return nil
}
totalGaps := gap * (n - 1)
availH := regionH - totalGaps
if availH < n {
availH = n
}
baseH := availH / n
extra := availH % n
positions := make([]int, n-1)
y := 0
for i := 0; i < n-1; i++ {
h := baseH
if i < extra {
h++
}
y += h
positions[i] = y
y += gap
}
return positions
}
// --- Responsive utilities ---
// FitOrScroll returns true if content exceeds available height
func FitOrScroll(contentH, availableH int) bool {
return contentH > availableH
}
// Breakpoints should be in descending order
// BreakpointH returns index of first breakpoint <= w, returns len(breakpoints) if w is less than all breakpoints
func BreakpointH(w int, breakpoints ...int) int {
for i, bp := range breakpoints {
if w >= bp {
return i
}
}
return len(breakpoints)
}
// BreakpointV returns index of first breakpoint <= h
func BreakpointV(h int, breakpoints ...int) int {
return BreakpointH(h, breakpoints...)
}
+99
View File
@@ -0,0 +1,99 @@
package tui
import "github.com/lixenwraith/terminal"
// ListItem represents a single row in a scrollable list
type ListItem struct {
Indent int // Left padding in cells
Icon rune // Expand indicator or bullet, 0 = none
IconFg terminal.RGB
Check CheckState // CheckNone to skip checkbox
CheckFg terminal.RGB
Text string
TextStyle Style
}
// ListOpts configures list rendering
type ListOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
IconWidth int // Width reserved for icon, default 2
}
// List renders scrollable list items within region, returns number of rows rendered
func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
if r.H < 1 || len(items) == 0 {
return 0
}
iconW := opts.IconWidth
if iconW == 0 {
iconW = 2
}
rendered := 0
for y := 0; y < r.H; y++ {
idx := scroll + y
if idx >= len(items) {
break
}
item := items[idx]
isCursor := idx == cursor
// Row background
bg := opts.DefaultBg
if isCursor {
bg = opts.CursorBg
}
// Clear row
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
x := item.Indent
// Icon
if item.Icon != 0 && x < r.W {
r.Cell(x, y, item.Icon, item.IconFg, bg, terminal.AttrNone)
}
x += iconW
// Checkbox
if item.Check != CheckNone || item.CheckFg != (terminal.RGB{}) {
if x+3 <= r.W {
var ch rune
switch item.Check {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', item.CheckFg, bg, terminal.AttrNone)
r.Cell(x+1, y, ch, item.CheckFg, bg, terminal.AttrNone)
r.Cell(x+2, y, ']', item.CheckFg, bg, terminal.AttrNone)
}
x += 4
}
// Text
textStyle := item.TextStyle
if textStyle.Bg == (terminal.RGB{}) {
textStyle.Bg = bg
}
text := item.Text
if x+RuneLen(text) > r.W {
text = Truncate(text, r.W-x)
}
r.TextStyled(x, y, text, textStyle)
rendered++
}
return rendered
}
+170
View File
@@ -0,0 +1,170 @@
package tui
// MasonryItem represents a single item in masonry layout
type MasonryItem struct {
Key string
Height int
Data any
}
// MasonryLayout holds calculated position for an item
type MasonryLayout struct {
X, Y, W, H int
Item MasonryItem
}
// MasonryOpts configures masonry layout
type MasonryOpts struct {
Columns int
Gap int
MinColW int
Breakpoints map[int]int
}
// DefaultMasonryOpts returns sensible defaults
func DefaultMasonryOpts() MasonryOpts {
return MasonryOpts{
Gap: 1,
MinColW: 30,
Breakpoints: map[int]int{
140: 4,
100: 3,
60: 2,
},
}
}
// MasonryState manages masonry layout with viewport scroll
type MasonryState struct {
Viewport *ViewportScroll
Layouts []MasonryLayout
}
// NewMasonryState creates masonry state
func NewMasonryState() *MasonryState {
return &MasonryState{
Viewport: NewViewportScroll(),
}
}
// CalculateLayout computes item positions
func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts MasonryOpts) {
cols := opts.Columns
if cols <= 0 {
cols = m.autoColumns(width, opts)
}
gap := opts.Gap
if gap < 0 {
gap = 1
}
colW := (width - (cols-1)*gap) / cols
if colW < 1 {
colW = 1
}
m.Layouts = make([]MasonryLayout, 0, len(items))
colHeights := make([]int, cols)
for _, item := range items {
minCol := 0
minH := colHeights[0]
for i := 1; i < cols; i++ {
if colHeights[i] < minH {
minH = colHeights[i]
minCol = i
}
}
x := minCol * (colW + gap)
y := colHeights[minCol]
m.Layouts = append(m.Layouts, MasonryLayout{
X: x, Y: y, W: colW, H: item.Height,
Item: item,
})
colHeights[minCol] += item.Height + gap
}
totalH := 0
for _, h := range colHeights {
if h > totalH {
totalH = h
}
}
if totalH > 0 {
totalH -= gap
}
m.Viewport.ContentH = totalH
}
func (m *MasonryState) autoColumns(width int, opts MasonryOpts) int {
if opts.Breakpoints != nil {
best := 1
bestThresh := 0
for thresh, cols := range opts.Breakpoints {
if width >= thresh && thresh > bestThresh {
best = cols
bestThresh = thresh
}
}
return best
}
cols := width / opts.MinColW
if cols < 1 {
cols = 1
}
return cols
}
// SetViewport updates viewport height
func (m *MasonryState) SetViewport(h int) {
m.Viewport.ViewportH = h
m.Viewport.clamp()
}
// MasonryRenderFunc renders a single item
type MasonryRenderFunc func(region Region, layout MasonryLayout, contentOffset int)
// Masonry renders visible items via callback
func (r Region) Masonry(state *MasonryState, render MasonryRenderFunc) {
state.SetViewport(r.H)
for _, l := range state.Layouts {
viewY, viewH, offset, visible := state.Viewport.ClipToViewport(l.Y, l.H)
if !visible {
continue
}
itemRegion := r.Sub(l.X, viewY, l.W, viewH)
render(itemRegion, l, offset)
}
}
// ScrollIndicator renders scroll position indicator for masonry
func (m *MasonryState) ScrollIndicator() string {
if !m.Viewport.CanScroll() {
return ""
}
pos := m.Viewport.Offset + 1
maxPos := m.Viewport.MaxOffset() + 1
return "[" + itoa(pos) + "/" + itoa(maxPos) + "]"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
+66
View File
@@ -0,0 +1,66 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// ModalOpts configures modal overlay rendering
type ModalOpts struct {
Title string
Hint string // Right-aligned hint text
Border LineType
BorderFg terminal.RGB
TitleFg terminal.RGB
HintFg terminal.RGB
Bg terminal.RGB
}
// Modal fills region with background, draws border with title/hint, returns content region
func (r Region) Modal(opts ModalOpts) Region {
if r.W < 5 || r.H < 3 {
return r.Sub(1, 1, 0, 0)
}
// Fill entire region
r.Fill(opts.Bg)
// Draw border
r.Box(opts.Border, opts.BorderFg)
// Title centered on top edge
if opts.Title != "" {
title := " " + opts.Title + " "
titleLen := RuneLen(title)
if titleLen > r.W-4 {
title = Truncate(title, r.W-4)
titleLen = RuneLen(title)
}
x := (r.W - titleLen) / 2
for i, ch := range title {
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
}
// Hint right-aligned on top edge
if opts.Hint != "" {
hint := opts.Hint
hintLen := RuneLen(hint)
if hintLen > r.W/3 {
hint = Truncate(hint, r.W/3)
hintLen = RuneLen(hint)
}
x := r.W - hintLen - 2
if x < r.W/2 {
x = r.W / 2
}
for i, ch := range hint {
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
}
}
// Return content region
return r.Sub(1, 1, r.W-2, r.H-2)
}
+373
View File
@@ -0,0 +1,373 @@
package tui
import "github.com/lixenwraith/terminal"
// OverlayStyle specifies overlay appearance
type OverlayStyle uint8
const (
OverlayFullscreen OverlayStyle = iota // No border, fills region
OverlayModal // Centered box with border
OverlayFloating // Positioned box with shadow
OverlayBorderTitle // Title embedded in top border line
)
// OverlayOpts configures overlay rendering
type OverlayOpts struct {
Style OverlayStyle
Title string
Border LineType
Bg terminal.RGB
Fg terminal.RGB // Border and title color
TitleBg terminal.RGB // Title bar background, zero = same as Fg
TitleFg terminal.RGB // Title text color, zero = same as Bg
// Modal/Floating positioning (ignored for Fullscreen)
Width int // 0 = 80% of region
Height int // 0 = 80% of region
X, Y int // Offset from center, 0 = centered
// Shadow for Floating style
ShadowColor terminal.RGB
}
// DefaultOverlayOpts returns sensible defaults for modal overlay
func DefaultOverlayOpts(title string) OverlayOpts {
return OverlayOpts{
Style: OverlayModal,
Title: title,
Border: LineDouble,
Bg: terminal.RGB{R: 25, G: 25, B: 35},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
}
}
// FullscreenOverlayOpts returns opts for fullscreen overlay with title bar
func FullscreenOverlayOpts(title string) OverlayOpts {
return OverlayOpts{
Style: OverlayFullscreen,
Title: title,
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
}
}
// OverlayResult contains regions returned by Overlay rendering
type OverlayResult struct {
Outer Region // Full overlay bounds (including border/title)
Content Region // Inner content area
TitleY int // Y position of title bar in Outer, -1 if no title
}
// Overlay renders an overlay and returns content region for caller to populate
// Caller should render content into result.Content after this call
func (r Region) Overlay(opts OverlayOpts) OverlayResult {
if r.W < 3 || r.H < 3 {
return OverlayResult{}
}
switch opts.Style {
case OverlayFullscreen:
return r.renderFullscreenOverlay(opts)
case OverlayModal:
return r.renderModalOverlay(opts)
case OverlayFloating:
return r.renderFloatingOverlay(opts)
case OverlayBorderTitle:
return r.renderBorderTitleOverlay(opts)
}
return OverlayResult{}
}
func (r Region) renderFullscreenOverlay(opts OverlayOpts) OverlayResult {
r.Fill(opts.Bg)
result := OverlayResult{
Outer: r,
TitleY: -1,
}
contentY := 0
contentH := r.H
// Title bar
if opts.Title != "" {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
// Fill title row
for x := 0; x < r.W; x++ {
r.Cell(x, 0, ' ', titleFg, titleBg, terminal.AttrNone)
}
// Center title text
title := opts.Title
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
}
titleX := (r.W - RuneLen(title)) / 2
r.Text(titleX, 0, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = 0
contentY = 1
contentH = r.H - 1
}
result.Content = r.Sub(0, contentY, r.W, contentH)
return result
}
func (r Region) renderModalOverlay(opts OverlayOpts) OverlayResult {
// Calculate dimensions
w := opts.Width
if w <= 0 {
w = r.W * 80 / 100
}
h := opts.Height
if h <= 0 {
h = r.H * 80 / 100
}
// Clamp to region
if w > r.W-2 {
w = r.W - 2
}
if h > r.H-2 {
h = r.H - 2
}
if w < 5 {
w = 5
}
if h < 3 {
h = 3
}
// Center with offset
x := (r.W-w)/2 + opts.X
y := (r.H-h)/2 + opts.Y
// Clamp position
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x+w > r.W {
x = r.W - w
}
if y+h > r.H {
y = r.H - h
}
outer := r.Sub(x, y, w, h)
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: outer,
TitleY: -1,
}
// Content inset by border
contentX := 1
contentY := 1
contentW := w - 2
contentH := h - 2
// Title in top border
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
// Fill title row inside border
for i := 0; i < contentW; i++ {
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
}
title := opts.Title
if RuneLen(title) > contentW-2 {
title = Truncate(title, contentW-2)
}
titleX := contentX + (contentW-RuneLen(title))/2
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = contentY
contentY++
contentH--
}
if contentH < 1 {
contentH = 1
}
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
return result
}
func (r Region) renderFloatingOverlay(opts OverlayOpts) OverlayResult {
// Same as modal but with shadow
shadowColor := opts.ShadowColor
if shadowColor == (terminal.RGB{}) {
shadowColor = terminal.RGB{R: 10, G: 10, B: 15}
}
// Calculate dimensions (same as modal)
w := opts.Width
if w <= 0 {
w = r.W * 80 / 100
}
h := opts.Height
if h <= 0 {
h = r.H * 80 / 100
}
if w > r.W-3 {
w = r.W - 3
}
if h > r.H-2 {
h = r.H - 2
}
if w < 5 {
w = 5
}
if h < 3 {
h = 3
}
x := (r.W-w)/2 + opts.X - 1 // Offset for shadow
y := (r.H-h)/2 + opts.Y
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
if x+w+1 > r.W {
x = r.W - w - 1
}
if y+h+1 > r.H {
y = r.H - h - 1
}
// Draw shadow
shadowR := r.Sub(x+1, y+1, w, h)
shadowR.Fill(shadowColor)
// Draw box over shadow
outer := r.Sub(x, y, w, h)
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: outer,
TitleY: -1,
}
contentX := 1
contentY := 1
contentW := w - 2
contentH := h - 2
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Bg
}
for i := 0; i < contentW; i++ {
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
}
title := opts.Title
if RuneLen(title) > contentW-2 {
title = Truncate(title, contentW-2)
}
titleX := contentX + (contentW-RuneLen(title))/2
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
result.TitleY = contentY
contentY++
contentH--
}
if contentH < 1 {
contentH = 1
}
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
return result
}
func (r Region) renderBorderTitleOverlay(opts OverlayOpts) OverlayResult {
r.BoxFilled(opts.Border, opts.Fg, opts.Bg)
result := OverlayResult{
Outer: r,
TitleY: 0,
}
if opts.Title != "" && r.W > 6 {
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
titleFg = opts.Fg
}
title := " " + opts.Title + " "
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
}
titleX := (r.W - RuneLen(title)) / 2
r.Text(titleX, 0, title, titleFg, opts.Bg, terminal.AttrBold)
}
result.Content = r.Sub(1, 1, r.W-2, r.H-2)
return result
}
// OverlayState manages overlay visibility and content state
type OverlayState struct {
Visible bool
Opts OverlayOpts
Data any // Application-specific state
}
// NewOverlayState creates hidden overlay state
func NewOverlayState(opts OverlayOpts) *OverlayState {
return &OverlayState{
Visible: false,
Opts: opts,
}
}
// Show makes overlay visible
func (o *OverlayState) Show() {
o.Visible = true
}
// Hide makes overlay invisible
func (o *OverlayState) Hide() {
o.Visible = false
}
// Toggle switches visibility
func (o *OverlayState) Toggle() {
o.Visible = !o.Visible
}
+69
View File
@@ -0,0 +1,69 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// PaneOpts configures pane rendering
type PaneOpts struct {
Title string
Border LineType
BorderFg terminal.RGB
Bg terminal.RGB
TitleFg terminal.RGB
}
// Pane draws bordered pane with optional title, returns content region
// Content region is inside border, below title row if present
func (r Region) Pane(opts PaneOpts) Region {
if r.W < 3 || r.H < 3 {
return r.Sub(1, 1, 0, 0)
}
// Fill background
r.Fill(opts.Bg)
// Draw border
r.Box(opts.Border, opts.BorderFg)
// Title on top edge
headerH := 0
if opts.Title != "" {
headerH = 1
title := " " + opts.Title + " "
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
}
x := 2
for i, ch := range title {
if x+i >= r.W-1 {
break
}
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
}
}
// Return content region (inside border, below title)
return r.Sub(1, 1+headerH, r.W-2, r.H-2-headerH)
}
// TitledPane fills region with background, draws centered title at top, returns content region
// Content region starts at row 1 with full width
func (r Region) TitledPane(title string, titleFg, bg terminal.RGB) Region {
r.Fill(bg)
if title != "" && r.H > 0 {
r.TextCenter(0, title, titleFg, bg, terminal.AttrBold)
}
if r.H <= 1 {
return r.Sub(0, 0, r.W, 0)
}
return r.Sub(0, 1, r.W, r.H-1)
}
// TitledPaneFocused is TitledPane with focus-dependent background
func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg terminal.RGB, focused bool) Region {
if focused {
bg = focusBg
}
return r.TitledPane(title, titleFg, bg)
}
+124
View File
@@ -0,0 +1,124 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// Progress bar characters
const (
progressFull = '█'
progressEmpty = '░'
progressHalf = '▌'
)
// Progress draws horizontal progress bar (0.0-1.0)
func (r Region) Progress(x, y, w int, pct float64, fg, bg terminal.RGB) {
if y < 0 || y >= r.H || w <= 0 {
return
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(w) * pct)
remainder := float64(w)*pct - float64(filled)
for i := 0; i < w; i++ {
if x+i >= r.W {
break
}
var ch rune
if i < filled {
ch = progressFull
} else if i == filled && remainder >= 0.5 {
ch = progressHalf
} else {
ch = progressEmpty
}
r.Cell(x+i, y, ch, fg, bg, terminal.AttrNone)
}
}
// ProgressV draws vertical progress bar (fills bottom-up)
func (r Region) ProgressV(x, y, h int, pct float64, fg, bg terminal.RGB) {
if x < 0 || x >= r.W || h <= 0 {
return
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(h) * pct)
for i := 0; i < h; i++ {
if y+i >= r.H {
break
}
var ch rune
// Fill from bottom up
if h-1-i < filled {
ch = progressFull
} else {
ch = progressEmpty
}
r.Cell(x, y+i, ch, fg, bg, terminal.AttrNone)
}
}
// Spinner draws spinner character based on frame counter
func (r Region) Spinner(x, y int, frame int, fg terminal.RGB) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
idx := frame % len(spinnerFrames)
if idx < 0 {
idx = -idx
}
r.Cell(x, y, spinnerFrames[idx], fg, terminal.RGB{}, terminal.AttrNone)
}
// Gauge draws labeled gauge with percentage
func (r Region) Gauge(x, y, w int, value, max int, fg, bg terminal.RGB) {
if w < 5 || y < 0 || y >= r.H {
return
}
var pct float64
if max > 0 {
pct = float64(value) / float64(max)
}
if pct > 1 {
pct = 1
}
if pct < 0 {
pct = 0
}
// Format: [████░░░░] 75%
labelW := 5 // " XXX%" or " 100%"
barW := w - labelW - 2
if barW < 1 {
barW = 1
}
r.Cell(x, y, '[', fg, bg, terminal.AttrNone)
r.Progress(x+1, y, barW, pct, fg, bg)
r.Cell(x+1+barW, y, ']', fg, bg, terminal.AttrNone)
pctInt := int(pct * 100)
var label string
if pctInt >= 100 {
label = " 100%"
} else if pctInt >= 10 {
label = " " + string(rune('0'+pctInt/10)) + string(rune('0'+pctInt%10)) + "%"
} else {
label = " " + string(rune('0'+pctInt)) + "%"
}
r.Text(x+2+barW, y, label, fg, bg, terminal.AttrNone)
}
+466
View File
@@ -0,0 +1,466 @@
package tui
import "github.com/lixenwraith/terminal"
// ProgressType specifies progress indicator variant
type ProgressType uint8
const (
ProgressSpinner ProgressType = iota // Animated spinner
ProgressDeterminate // Bar with percentage
ProgressIndeterminate // Marquee animation
ProgressPulse // Pulsing bar
ProgressDots // Animated dots
)
// ProgressStyle defines visual appearance
type ProgressStyle uint8
const (
ProgressStyleMinimal ProgressStyle = iota // Text only
ProgressStyleBox // Single border
ProgressStyleDouble // Double border
ProgressStyleRounded // Rounded border
ProgressStyleShadow // Box with shadow
ProgressStyleNeon // Bright colors
ProgressStyleRetro // Block characters
)
// SpinnerStyle defines spinner animation type
type SpinnerStyle uint8
const (
SpinnerBraille SpinnerStyle = iota // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
SpinnerDots // ⣾⣽⣻⢿⡿⣟⣯⣷
SpinnerLine // |/-\
SpinnerBlock // ▖▘▝▗
SpinnerCircle // ◐◓◑◒
SpinnerArc // ◜◠◝◞◡◟
SpinnerBounce // ⠁⠂⠄⠂
SpinnerGrow // ▁▃▄▅▆▇█▇▆▅▄▃
)
// Spinner frame sets
var spinnerSets = map[SpinnerStyle][]rune{
SpinnerBraille: {'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'},
SpinnerDots: {'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'},
SpinnerLine: {'|', '/', '-', '\\'},
SpinnerBlock: {'▖', '▘', '▝', '▗'},
SpinnerCircle: {'◐', '◓', '◑', '◒'},
SpinnerArc: {'◜', '◠', '◝', '◞', '◡', '◟'},
SpinnerBounce: {'⠁', '⠂', '⠄', '⠂'},
SpinnerGrow: {'▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'},
}
// BarStyle defines progress bar appearance
type BarStyle uint8
const (
BarStyleBlock BarStyle = iota // █░
BarStyleShade // ▓▒░
BarStyleArrow // =>-
BarStyleDot // ●○
BarStyleBracket // [### ]
BarStylePipe // |=== |
BarStyleThin // ━╺
BarStyleThick // ▰▱
)
// Bar character sets: [filled, partial, empty]
var barCharSets = map[BarStyle][3]rune{
BarStyleBlock: {'█', '▌', '░'},
BarStyleShade: {'▓', '▒', '░'},
BarStyleArrow: {'=', '>', '-'},
BarStyleDot: {'●', '◐', '○'},
BarStyleBracket: {'#', '#', ' '},
BarStylePipe: {'=', '=', ' '},
BarStyleThin: {'━', '╸', '╺'},
BarStyleThick: {'▰', '▰', '▱'},
}
// ProgressOverlayOpts configures progress overlay
type ProgressOverlayOpts struct {
Title string
Message string
Type ProgressType
Style ProgressStyle
SpinnerStyle SpinnerStyle
BarStyle BarStyle
Progress float64 // 0.0-1.0 for determinate
Frame int // Animation frame counter
ShowPercent bool // Show percentage text
ShowETA string // Optional ETA string
Width int // Overlay width, 0 = auto
Cancelable bool // Show cancel hint
CancelKey string // e.g., "Esc"
Fg terminal.RGB
Bg terminal.RGB
BarFg terminal.RGB
BarBg terminal.RGB
AccentFg terminal.RGB // Spinner/highlight color
}
// DefaultProgressOpts returns sensible defaults
func DefaultProgressOpts(title, message string, ptype ProgressType) ProgressOverlayOpts {
return ProgressOverlayOpts{
Title: title,
Message: message,
Type: ptype,
Style: ProgressStyleBox,
SpinnerStyle: SpinnerBraille,
BarStyle: BarStyleBlock,
ShowPercent: true,
Width: 40,
Fg: terminal.RGB{R: 220, G: 220, B: 220},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
BarFg: terminal.RGB{R: 80, G: 160, B: 255},
BarBg: terminal.RGB{R: 50, G: 50, B: 60},
AccentFg: terminal.RGB{R: 100, G: 200, B: 255},
}
}
// ProgressOverlay renders centered progress overlay
func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
if r.W < 10 || r.H < 5 {
return Region{}
}
// Calculate overlay dimensions
overlayW := opts.Width
if overlayW == 0 {
overlayW = 40
}
if overlayW > r.W-4 {
overlayW = r.W - 4
}
// Height: title + message + progress + cancel hint
overlayH := 3 // border top/bottom + 1 content
if opts.Title != "" {
overlayH++ // Title takes space on border
}
if opts.Message != "" {
overlayH++
}
if opts.Type != ProgressSpinner && opts.Type != ProgressDots {
overlayH++ // Progress bar row
}
if opts.Cancelable {
overlayH++
}
if overlayH > r.H-2 {
overlayH = r.H - 2
}
// Center overlay
overlay := Center(r, overlayW, overlayH)
// Determine border type
var borderLine LineType
switch opts.Style {
case ProgressStyleMinimal:
borderLine = LineNone
case ProgressStyleBox:
borderLine = LineSingle
case ProgressStyleDouble:
borderLine = LineDouble
case ProgressStyleRounded:
borderLine = LineRounded
case ProgressStyleShadow:
// Draw shadow first
shadow := r.Sub(overlay.X-r.X+1, overlay.Y-r.Y+1, overlayW, overlayH)
shadow.Fill(terminal.RGB{R: 10, G: 10, B: 15})
borderLine = LineSingle
case ProgressStyleNeon:
borderLine = LineDouble
opts.AccentFg = terminal.RGB{R: 0, G: 255, B: 200}
opts.BarFg = terminal.RGB{R: 255, G: 0, B: 255}
case ProgressStyleRetro:
borderLine = LineHeavy
opts.BarStyle = BarStyleBlock
}
// Draw frame
overlay.BoxFilled(borderLine, opts.Fg, opts.Bg)
// Title on border
if opts.Title != "" {
title := " " + opts.Title + " "
titleLen := RuneLen(title)
if titleLen > overlayW-4 {
title = Truncate(title, overlayW-4)
titleLen = RuneLen(title)
}
titleX := (overlayW - titleLen) / 2
for i, ch := range title {
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
}
}
// Content area
content := overlay.Inset(1)
y := 0
// Spinner/dots for spinner types - inline with message
if opts.Type == ProgressSpinner || opts.Type == ProgressDots {
spinnerChar := r.getSpinnerChar(opts)
if content.W > 2 {
content.Cell(0, y, spinnerChar, opts.AccentFg, opts.Bg, terminal.AttrBold)
}
// Message after spinner
if opts.Message != "" {
msg := opts.Message
availW := content.W - 2
if RuneLen(msg) > availW {
msg = Truncate(msg, availW)
}
content.Text(2, y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
}
y++
} else {
// Message on its own line
if opts.Message != "" {
msg := opts.Message
if RuneLen(msg) > content.W {
msg = Truncate(msg, content.W)
}
content.TextCenter(y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
y++
}
// Progress bar
if y < content.H {
r.renderProgressBar(content.Sub(0, y, content.W, 1), opts)
y++
}
}
// Cancel hint
if opts.Cancelable && y < content.H {
hint := opts.CancelKey + " to cancel"
if opts.CancelKey == "" {
hint = "Esc to cancel"
}
content.TextCenter(y, hint, terminal.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
}
return overlay
}
func (r Region) getSpinnerChar(opts ProgressOverlayOpts) rune {
frames := spinnerSets[opts.SpinnerStyle]
if len(frames) == 0 {
frames = spinnerSets[SpinnerBraille]
}
idx := opts.Frame % len(frames)
if idx < 0 {
idx = -idx
}
return frames[idx]
}
func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
if bar.W < 3 || bar.H < 1 {
return
}
barW := bar.W
labelW := 0
// Reserve space for percentage
if opts.ShowPercent {
labelW = 5 // " 100%"
barW -= labelW
}
// Reserve space for ETA
if opts.ShowETA != "" {
etaW := RuneLen(opts.ShowETA) + 1
barW -= etaW
}
if barW < 3 {
barW = 3
}
chars := barCharSets[opts.BarStyle]
switch opts.Type {
case ProgressDeterminate:
pct := opts.Progress
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(barW) * pct)
remainder := float64(barW)*pct - float64(filled)
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x < filled {
ch = chars[0]
fg = opts.BarFg
} else if x == filled && remainder >= 0.5 {
ch = chars[1]
fg = opts.BarFg
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
// Percentage
if opts.ShowPercent {
pctStr := formatPercent(int(pct * 100))
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
}
case ProgressIndeterminate:
// Marquee effect
pos := opts.Frame % (barW * 2)
if pos >= barW {
pos = barW*2 - pos - 1
}
markerW := barW / 4
if markerW < 2 {
markerW = 2
}
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x >= pos && x < pos+markerW {
ch = chars[0]
fg = opts.BarFg
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
case ProgressPulse:
// Pulsing intensity based on frame
pulsePhase := opts.Frame % 20
intensity := float64(pulsePhase) / 20.0
if pulsePhase > 10 {
intensity = 1.0 - float64(pulsePhase-10)/10.0
}
pct := opts.Progress
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(float64(barW) * pct)
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
if x < filled {
ch = chars[0]
// Pulse the color
fg = terminal.RGB{
R: uint8(float64(opts.BarFg.R) * (0.5 + intensity*0.5)),
G: uint8(float64(opts.BarFg.G) * (0.5 + intensity*0.5)),
B: uint8(float64(opts.BarFg.B) * (0.5 + intensity*0.5)),
}
} else {
ch = chars[2]
fg = opts.BarBg
}
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
}
if opts.ShowPercent {
pctStr := formatPercent(int(pct * 100))
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
}
}
// ETA
if opts.ShowETA != "" {
etaX := bar.W - RuneLen(opts.ShowETA)
bar.Text(etaX, 0, opts.ShowETA, terminal.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
}
}
func formatPercent(pct int) string {
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
if pct == 100 {
return "100%"
}
if pct >= 10 {
return " " + string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
}
return " " + string(rune('0'+pct)) + "%"
}
// ProgressState manages progress overlay state
type ProgressState struct {
Visible bool
Opts ProgressOverlayOpts
Frame int
Progress float64
}
// NewProgressState creates progress overlay state
func NewProgressState(opts ProgressOverlayOpts) *ProgressState {
return &ProgressState{
Visible: true,
Opts: opts,
Progress: opts.Progress,
}
}
// Tick advances animation frame
func (p *ProgressState) Tick() {
p.Frame++
p.Opts.Frame = p.Frame
}
// SetProgress updates progress value (0.0-1.0)
func (p *ProgressState) SetProgress(pct float64) {
p.Progress = pct
p.Opts.Progress = pct
}
// SetMessage updates message text
func (p *ProgressState) SetMessage(msg string) {
p.Opts.Message = msg
}
// SetETA updates ETA string
func (p *ProgressState) SetETA(eta string) {
p.Opts.ShowETA = eta
}
// Complete marks progress as done
func (p *ProgressState) Complete() {
p.Progress = 1.0
p.Opts.Progress = 1.0
}
// Dismiss hides the progress overlay
func (p *ProgressState) Dismiss() {
p.Visible = false
}
// Show displays the progress overlay
func (p *ProgressState) Show() {
p.Visible = true
}
+112
View File
@@ -0,0 +1,112 @@
package tui
import "github.com/lixenwraith/terminal"
// Region represents a rectangular area within a cell buffer
// All coordinates are relative to the region's origin
type Region struct {
Cells []terminal.Cell
TotalW int // Total width of the underlying cell buffer
X, Y int // Absolute position in cell buffer
W, H int // Region dimensions
}
// NewRegion creates a region referencing a cell slice with bounds
func NewRegion(cells []terminal.Cell, totalW, x, y, w, h int) Region {
return Region{
Cells: cells,
TotalW: totalW,
X: x,
Y: y,
W: w,
H: h,
}
}
// Sub returns a nested region with coordinates relative to parent, result is clipped to parent bounds
func (r Region) Sub(x, y, w, h int) Region {
// Clip to parent bounds
if x < 0 {
w += x
x = 0
}
if y < 0 {
h += y
y = 0
}
if x+w > r.W {
w = r.W - x
}
if y+h > r.H {
h = r.H - y
}
if w < 0 {
w = 0
}
if h < 0 {
h = 0
}
return Region{
Cells: r.Cells,
TotalW: r.TotalW,
X: r.X + x,
Y: r.Y + y,
W: w,
H: h,
}
}
// Inset returns a region shrunk by n cells on all sides
func (r Region) Inset(n int) Region {
return r.Sub(n, n, r.W-2*n, r.H-2*n)
}
// Cell sets a single cell with bounds checking
func (r Region) Cell(x, y int, ch rune, fg, bg terminal.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
absX := r.X + x
absY := r.Y + y
// Bounds check against the physical buffer dimensions
if uint(absX) >= uint(r.TotalW) {
return
}
idx := absY*r.TotalW + absX
// Single bounds check for the backing slice
if uint(idx) < uint(len(r.Cells)) {
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
}
}
// Fill fills entire region with background color
func (r Region) Fill(bg terminal.RGB) {
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
}
}
// Clear fills region with spaces and zero colors
func (r Region) Clear() {
r.Fill(terminal.RGB{})
}
// Width returns region width
func (r Region) Width() int {
return r.W
}
// Height returns region height
func (r Region) Height() int {
return r.H
}
// Bounds returns absolute position and dimensions
func (r Region) Bounds() (x, y, w, h int) {
return r.X, r.Y, r.W, r.H
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import "github.com/lixenwraith/terminal"
// Default spinner frames for Region.Spinner
var spinnerFrames = spinnerSets[SpinnerBraille]
// Text renders text at position, truncates at region edge
func (r Region) Text(x, y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
if y < 0 || y >= r.H {
return
}
col := 0
for _, ch := range s {
if x+col >= r.W {
break
}
if x+col >= 0 {
r.Cell(x+col, y, ch, fg, bg, attr)
}
col++
}
}
// TextStyled renders text using Style struct
func (r Region) TextStyled(x, y int, s string, style Style) {
if y < 0 || y >= r.H {
return
}
col := 0
for _, ch := range s {
if x+col >= r.W {
break
}
if x+col >= 0 {
r.Cell(x+col, y, ch, style.Fg, style.Bg, style.Attr)
}
col++
}
}
// TextRight renders text right-aligned on row
func (r Region) TextRight(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
x := r.W - RuneLen(s)
r.Text(x, y, s, fg, bg, attr)
}
// TextCenter renders text centered on row
func (r Region) TextCenter(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
x := (r.W - RuneLen(s)) / 2
r.Text(x, y, s, fg, bg, attr)
}
// TextBlock renders wrapped text within region bounds, returns number of lines rendered
func (r Region) TextBlock(x, y int, text string, fg, bg terminal.RGB, attr terminal.Attr) int {
if x >= r.W || y >= r.H || text == "" {
return 0
}
availW := r.W - x
if availW < 1 {
return 0
}
lines := WrapText(text, availW)
rendered := 0
for i, line := range lines {
lineY := y + i
if lineY >= r.H {
break
}
r.Text(x, lineY, line, fg, bg, attr)
rendered++
}
return rendered
}
// TextBlockStyled renders wrapped text using Style struct, returns number of lines rendered
func (r Region) TextBlockStyled(x, y int, text string, style Style) int {
return r.TextBlock(x, y, text, style.Fg, style.Bg, style.Attr)
}
+76
View File
@@ -0,0 +1,76 @@
package tui
// --- Scroll position calculation ---
// AdjustScroll returns new scroll offset keeping cursor visible
func AdjustScroll(cursor, scroll, visible, total int) int {
if total <= visible {
return 0
}
if cursor < scroll {
return cursor
}
if cursor >= scroll+visible {
return cursor - visible + 1
}
return scroll
}
// ScrollPercent returns scroll position as 0-100 percentage
func ScrollPercent(scroll, visible, total int) int {
if total <= visible {
return 0
}
maxScroll := total - visible
if maxScroll <= 0 {
return 0
}
pct := (scroll * 100) / maxScroll
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
return pct
}
// --- Clamping utilities ---
// ClampScroll ensures scroll offset is within valid range
func ClampScroll(scroll, visible, total int) int {
if total <= visible {
return 0
}
maxScroll := total - visible
if scroll < 0 {
return 0
}
if scroll > maxScroll {
return maxScroll
}
return scroll
}
// ClampCursor ensures cursor is within valid range
func ClampCursor(cursor, total int) int {
if total <= 0 {
return 0
}
if cursor < 0 {
return 0
}
if cursor >= total {
return total - 1
}
return cursor
}
// PageDelta returns recommended page scroll amount
func PageDelta(visible int) int {
delta := visible / 2
if delta < 1 {
delta = 1
}
return delta
}
+115
View File
@@ -0,0 +1,115 @@
package tui
// ScrollState tracks scroll position for a scrollable container
type ScrollState struct {
Offset int // First visible item index
Total int // Total item count
Visible int // Visible item count (viewport height)
Selection int // Currently selected item, -1 if none
}
// NewScrollState creates initialized scroll state
func NewScrollState(total, visible int) *ScrollState {
return &ScrollState{
Total: total,
Visible: visible,
Selection: -1,
}
}
// --- Scroll manipulation ---
// ScrollBy adjusts offset by delta, clamping to valid range
func (s *ScrollState) ScrollBy(delta int) {
s.Offset += delta
s.Clamp()
}
// ScrollTo sets offset to specific position
func (s *ScrollState) ScrollTo(pos int) {
s.Offset = pos
s.Clamp()
}
// EnsureVisible adjusts offset to make item at pos visible
func (s *ScrollState) EnsureVisible(pos int) {
if pos < s.Offset {
s.Offset = pos
} else if pos >= s.Offset+s.Visible {
s.Offset = pos - s.Visible + 1
}
s.Clamp()
}
// Clamp ensures offset is within valid range
func (s *ScrollState) Clamp() {
s.Offset = ClampScroll(s.Offset, s.Visible, s.Total)
}
// --- Page navigation ---
// PageUp scrolls up by half visible height
func (s *ScrollState) PageUp() {
s.ScrollBy(-PageDelta(s.Visible))
}
// PageDown scrolls down by half visible height
func (s *ScrollState) PageDown() {
s.ScrollBy(PageDelta(s.Visible))
}
// --- State updates ---
// SetTotal updates total count and reclamps
func (s *ScrollState) SetTotal(total int) {
s.Total = total
s.Clamp()
if s.Selection >= total {
s.Selection = total - 1
}
}
// SetVisible updates visible count and reclamps
func (s *ScrollState) SetVisible(visible int) {
s.Visible = visible
s.Clamp()
}
// --- Positions queries ---
// AtTop returns true if scrolled to top
func (s *ScrollState) AtTop() bool {
return s.Offset == 0
}
// AtBottom returns true if scrolled to bottom
func (s *ScrollState) AtBottom() bool {
if s.Total <= s.Visible {
return true
}
return s.Offset >= s.Total-s.Visible
}
// --- Selection management ---
// Select sets selection and ensures it's visible
func (s *ScrollState) Select(idx int) {
s.Selection = ClampCursor(idx, s.Total)
s.EnsureVisible(s.Selection)
}
// SelectNext moves selection down
func (s *ScrollState) SelectNext() {
if s.Selection < s.Total-1 {
s.Selection++
s.EnsureVisible(s.Selection)
}
}
// SelectPrev moves selection up
func (s *ScrollState) SelectPrev() {
if s.Selection > 0 {
s.Selection--
s.EnsureVisible(s.Selection)
}
}
+73
View File
@@ -0,0 +1,73 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// ScrollBar draws vertical scrollbar track with thumb
func (r Region) ScrollBar(x int, offset, visible, total int, fg terminal.RGB) {
if x < 0 || x >= r.W || r.H < 1 {
return
}
trackH := r.H
if total <= visible || trackH < 3 {
// No scrolling needed or track too small
for y := range trackH {
r.Cell(x, y, '│', fg, terminal.RGB{}, terminal.AttrDim)
}
return
}
// Calculate thumb size and position
thumbH := min(max((visible*trackH)/total, 1), trackH)
maxScroll := total - visible
thumbY := 0
if maxScroll > 0 {
thumbY = (offset * (trackH - thumbH)) / maxScroll
}
if thumbY < 0 {
thumbY = 0
}
if thumbY+thumbH > trackH {
thumbY = trackH - thumbH
}
// Draw track and thumb
for y := range trackH {
var ch rune
if y >= thumbY && y < thumbY+thumbH {
ch = '█'
} else {
ch = '░'
}
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
}
}
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg terminal.RGB) {
if y < 0 || y >= r.H {
return
}
var text string
if total <= visible || offset <= 0 {
text = "Top"
} else if offset+visible >= total {
text = "Bot"
} else {
pct := ScrollPercent(offset, visible, total)
if pct >= 100 {
text = "99%"
} else if pct >= 10 {
text = string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
} else {
text = " " + string(rune('0'+pct)) + "%"
}
}
r.TextRight(y, text, fg, terminal.RGB{}, terminal.AttrDim)
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import "github.com/lixenwraith/terminal"
// SparklineChars provides 8-level vertical resolution
var SparklineChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
// SparklineOpts configures sparkline rendering
type SparklineOpts struct {
Min, Max float64 // Range bounds, auto-scale if both 0
Style Style
}
// Sparkline renders an inline graph of values, values are mapped to 8-level block characters
func (r Region) Sparkline(x, y, width int, values []float64, opts SparklineOpts) {
if y < 0 || y >= r.H || width <= 0 || len(values) == 0 {
return
}
// Determine range
min, max := opts.Min, opts.Max
if min == 0 && max == 0 {
min, max = values[0], values[0]
for _, v := range values {
if v < min {
min = v
}
if v > max {
max = v
}
}
}
// Handle flat line
rangeV := max - min
if rangeV == 0 {
rangeV = 1
}
// Sample or use last N values if more than width
var sampled []float64
if len(values) <= width {
sampled = values
} else {
sampled = values[len(values)-width:]
}
// Render each value
for i, v := range sampled {
if x+i >= r.W {
break
}
// Normalize to 0-1
norm := (v - min) / rangeV
if norm < 0 {
norm = 0
}
if norm > 1 {
norm = 1
}
// Map to character index (0-7)
idx := int(norm * 7.99)
if idx > 7 {
idx = 7
}
r.Cell(x+i, y, SparklineChars[idx], opts.Style.Fg, opts.Style.Bg, opts.Style.Attr)
}
// Pad remaining width with lowest char if values shorter than width
for i := len(sampled); i < width && x+i < r.W; i++ {
r.Cell(x+i, y, SparklineChars[0], opts.Style.Fg, opts.Style.Bg, terminal.AttrDim)
}
}
// SparklineV renders vertical sparkline (bottom to top)
func (r Region) SparklineV(x, y, height int, values []float64, opts SparklineOpts) {
if x < 0 || x >= r.W || height <= 0 || len(values) == 0 {
return
}
min, max := opts.Min, opts.Max
if min == 0 && max == 0 {
min, max = values[0], values[0]
for _, v := range values {
if v < min {
min = v
}
if v > max {
max = v
}
}
}
rangeV := max - min
if rangeV == 0 {
rangeV = 1
}
var sampled []float64
if len(values) <= height {
sampled = values
} else {
sampled = values[len(values)-height:]
}
// Render bottom-up
for i, v := range sampled {
yPos := y + height - 1 - i
if yPos < y || yPos >= r.H {
continue
}
norm := (v - min) / rangeV
if norm < 0 {
norm = 0
}
if norm > 1 {
norm = 1
}
idx := int(norm * 7.99)
if idx > 7 {
idx = 7
}
r.Cell(x, yPos, SparklineChars[idx], opts.Style.Fg, opts.Style.Bg, opts.Style.Attr)
}
}
+210
View File
@@ -0,0 +1,210 @@
package tui
import "github.com/lixenwraith/terminal"
// BarSection represents one segment of a status bar
type BarSection struct {
Label string
Value string
LabelStyle Style
ValueStyle Style
Priority int // Higher = survives truncation
}
// BarAlign specifies status bar alignment mode
type BarAlign uint8
const (
BarAlignRight BarAlign = iota // Pack sections from right
BarAlignLeft // Pack sections from left
BarAlignDistribute // Evenly space sections
BarAlignCenter // Center all sections as a group
)
// BarOpts configures status bar rendering
type BarOpts struct {
Separator string // Between sections, default " │ "
SepStyle Style // Separator styling
Bg terminal.RGB
Align BarAlign
Padding int // Left/right padding, default 1
}
// DefaultBarOpts returns sensible defaults
func DefaultBarOpts() BarOpts {
return BarOpts{
Separator: " │ ",
SepStyle: Style{Fg: terminal.RGB{R: 80, G: 80, B: 100}},
Padding: 1,
Align: BarAlignRight,
}
}
// StatusBar renders horizontal status bar on row y
func (r Region) StatusBar(y int, sections []BarSection, opts BarOpts) {
if y < 0 || y >= r.H || len(sections) == 0 {
return
}
if opts.Separator == "" {
opts.Separator = " │ "
}
if opts.Padding == 0 {
opts.Padding = 1
}
// Fill background
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, opts.Bg, terminal.AttrNone)
}
sepLen := RuneLen(opts.Separator)
// Calculate total width needed
totalW := 0
sectionWidths := make([]int, len(sections))
for i, sec := range sections {
w := RuneLen(sec.Label) + RuneLen(sec.Value)
sectionWidths[i] = w
totalW += w
if i < len(sections)-1 {
totalW += sepLen
}
}
availW := r.W - opts.Padding*2
// Truncate low-priority sections if needed
if totalW > availW {
sections, sectionWidths = truncateSections(sections, sectionWidths, sepLen, availW)
totalW = 0
for i, w := range sectionWidths {
totalW += w
if i < len(sectionWidths)-1 {
totalW += sepLen
}
}
}
// Calculate starting position based on alignment
var x int
switch opts.Align {
case BarAlignLeft:
x = opts.Padding
case BarAlignRight:
x = r.W - opts.Padding - totalW
if x < opts.Padding {
x = opts.Padding
}
case BarAlignDistribute:
x = opts.Padding
// Handled specially below
case BarAlignCenter:
x = (r.W - totalW) / 2
if x < opts.Padding {
x = opts.Padding
}
}
// Render sections
if opts.Align == BarAlignDistribute && len(sections) > 1 {
gap := (availW - totalW) / (len(sections) - 1)
if gap < 0 {
gap = 0
}
for i, sec := range sections {
x = r.renderBarSection(x, y, sec, opts)
if i < len(sections)-1 {
x += gap
}
}
} else {
for i, sec := range sections {
x = r.renderBarSection(x, y, sec, opts)
if i < len(sections)-1 {
// Separator
for j, ch := range opts.Separator {
if x+j < r.W-opts.Padding {
r.Cell(x+j, y, ch, opts.SepStyle.Fg, opts.Bg, opts.SepStyle.Attr)
}
}
x += sepLen
}
}
}
}
func (r Region) renderBarSection(x, y int, sec BarSection, opts BarOpts) int {
// Label
for _, ch := range sec.Label {
if x >= r.W-opts.Padding {
break
}
r.Cell(x, y, ch, sec.LabelStyle.Fg, opts.Bg, sec.LabelStyle.Attr)
x++
}
// Value
for _, ch := range sec.Value {
if x >= r.W-opts.Padding {
break
}
r.Cell(x, y, ch, sec.ValueStyle.Fg, opts.Bg, sec.ValueStyle.Attr)
x++
}
return x
}
// truncateSections removes lowest priority sections until fit
func truncateSections(sections []BarSection, widths []int, sepLen, availW int) ([]BarSection, []int) {
// Copy to avoid modifying original
secs := make([]BarSection, len(sections))
copy(secs, sections)
ws := make([]int, len(widths))
copy(ws, widths)
for {
total := 0
for i, w := range ws {
total += w
if i < len(ws)-1 {
total += sepLen
}
}
if total <= availW || len(secs) <= 1 {
break
}
// Find lowest priority
minIdx := 0
minPrio := secs[0].Priority
for i, sec := range secs {
if sec.Priority < minPrio {
minPrio = sec.Priority
minIdx = i
}
}
// RemoveEntityAt it
secs = append(secs[:minIdx], secs[minIdx+1:]...)
ws = append(ws[:minIdx], ws[minIdx+1:]...)
}
return secs, ws
}
// QuickStatusBar renders simple label:value pairs right-aligned
func (r Region) QuickStatusBar(y int, pairs [][2]string, labelFg, valueFg, bg terminal.RGB) {
sections := make([]BarSection, len(pairs))
for i, p := range pairs {
sections[i] = BarSection{
Label: p[0],
Value: p[1],
LabelStyle: Style{Fg: labelFg},
ValueStyle: Style{Fg: valueFg},
}
}
r.StatusBar(y, sections, BarOpts{
Bg: bg,
Align: BarAlignRight,
})
}
+22
View File
@@ -0,0 +1,22 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// Style bundles foreground, background, and attributes for text rendering
type Style struct {
Fg terminal.RGB
Bg terminal.RGB
Attr terminal.Attr
}
// DefaultStyle returns style with zero values (transparent bg)
func DefaultStyle(fg terminal.RGB) Style {
return Style{Fg: fg}
}
// IsZero returns true if style has no colors or attributes set
func (s Style) IsZero() bool {
return s.Fg == (terminal.RGB{}) && s.Bg == (terminal.RGB{}) && s.Attr == terminal.AttrNone
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import "github.com/lixenwraith/terminal"
// TabBounds stores position and size of a rendered tab
type TabBounds struct {
X, W int
}
// TabBarOpts configures tab bar rendering
type TabBarOpts struct {
ActiveStyle Style
InactiveStyle Style
Separator string // Between tabs, default " │ "
Padding int // Horizontal padding inside each tab, default 1
}
// DefaultTabBarOpts returns sensible defaults
func DefaultTabBarOpts() TabBarOpts {
return TabBarOpts{
ActiveStyle: Style{Attr: terminal.AttrBold | terminal.AttrReverse},
InactiveStyle: Style{Attr: terminal.AttrNone},
Separator: " │ ",
Padding: 1,
}
}
// TabBar renders horizontal tab strip at row y
// Returns bounds of each tab for hit testing / navigation
func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
if y < 0 || y >= r.H || len(titles) == 0 {
return nil
}
if opts.Separator == "" {
opts.Separator = " │ "
}
bounds := make([]TabBounds, len(titles))
x := 0
sepLen := RuneLen(opts.Separator)
for i, title := range titles {
if x >= r.W {
break
}
// Calculate tab width
tabW := RuneLen(title) + opts.Padding*2
if x+tabW > r.W {
tabW = r.W - x
}
bounds[i] = TabBounds{X: x, W: tabW}
// Select style
style := opts.InactiveStyle
if i == active {
style = opts.ActiveStyle
}
// Render padding + title + padding
for j := 0; j < opts.Padding && x+j < r.W; j++ {
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
}
titleStart := x + opts.Padding
for j, ch := range title {
if titleStart+j >= r.W {
break
}
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
}
for j := 0; j < opts.Padding; j++ {
pos := x + opts.Padding + RuneLen(title) + j
if pos < r.W {
r.Cell(pos, y, ' ', style.Fg, style.Bg, style.Attr)
}
}
x += tabW
// Separator between tabs
if i < len(titles)-1 && x+sepLen <= r.W {
for j, ch := range opts.Separator {
r.Cell(x+j, y, ch, opts.InactiveStyle.Fg, opts.InactiveStyle.Bg, terminal.AttrDim)
}
x += sepLen
}
}
return bounds
}
// TabBarCentered renders tab bar centered horizontally
func (r Region) TabBarCentered(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
if len(titles) == 0 {
return nil
}
if opts.Separator == "" {
opts.Separator = " │ "
}
// Calculate total width
totalW := 0
sepLen := RuneLen(opts.Separator)
for i, title := range titles {
totalW += RuneLen(title) + opts.Padding*2
if i < len(titles)-1 {
totalW += sepLen
}
}
// Create sub-region for centering
startX := (r.W - totalW) / 2
if startX < 0 {
startX = 0
}
subR := r.Sub(startX, y, totalW, 1)
bounds := subR.TabBar(0, titles, active, opts)
// Adjust bounds to parent coordinates
for i := range bounds {
bounds[i].X += startX
}
return bounds
}
+180
View File
@@ -0,0 +1,180 @@
package tui
import "github.com/lixenwraith/terminal"
// Align specifies text alignment within a column
type Align uint8
const (
AlignLeft Align = iota
AlignRight
AlignCenter
)
// TableOpts configures table rendering
type TableOpts struct {
ColWidths []int // Fixed widths per column, 0 = auto
ColAligns []Align // Alignment per column, default AlignLeft
HeaderStyle Style
RowStyle Style
AltRowStyle Style // Alternating row style, zero = same as RowStyle
ColSeparator rune // Between columns, 0 = space
RowSeparator LineType // Between rows, LineNone = no separator
}
// DefaultTableOpts returns sensible defaults
func DefaultTableOpts() TableOpts {
return TableOpts{
HeaderStyle: Style{Attr: terminal.AttrBold},
ColSeparator: ' ',
RowSeparator: LineNone,
}
}
// CalculateColumnWidths computes optimal column widths for given data
// Returns widths that fit within availableW, respecting fixed widths in opts
func CalculateColumnWidths(availableW int, headers []string, rows [][]string, opts TableOpts) []int {
if len(headers) == 0 {
return nil
}
cols := len(headers)
widths := make([]int, cols)
separatorW := 1 // space between columns
// Start with header widths
for i, h := range headers {
widths[i] = RuneLen(h)
}
// Expand to fit data
for _, row := range rows {
for i := 0; i < cols && i < len(row); i++ {
w := RuneLen(row[i])
if w > widths[i] {
widths[i] = w
}
}
}
// Apply fixed widths from opts
for i := 0; i < cols && i < len(opts.ColWidths); i++ {
if opts.ColWidths[i] > 0 {
widths[i] = opts.ColWidths[i]
}
}
// Calculate total and scale if needed
total := 0
for _, w := range widths {
total += w
}
total += (cols - 1) * separatorW
if total > availableW && availableW > cols {
// Proportionally shrink
contentW := availableW - (cols-1)*separatorW
scale := float64(contentW) / float64(total-(cols-1)*separatorW)
for i := range widths {
widths[i] = int(float64(widths[i]) * scale)
if widths[i] < 1 {
widths[i] = 1
}
}
}
return widths
}
// Table renders a table with headers and rows
func (r Region) Table(headers []string, rows [][]string, opts TableOpts) {
if r.H < 1 || r.W < 1 || len(headers) == 0 {
return
}
widths := CalculateColumnWidths(r.W, headers, rows, opts)
sep := opts.ColSeparator
if sep == 0 {
sep = ' '
}
y := 0
// Header row
if y < r.H {
r.renderTableRow(y, headers, widths, opts.ColAligns, sep, opts.HeaderStyle)
y++
}
// Header separator
if opts.RowSeparator != LineNone && y < r.H {
r.HLine(y, opts.RowSeparator, opts.HeaderStyle.Fg)
y++
}
// Data rows
for rowIdx, row := range rows {
if y >= r.H {
break
}
style := opts.RowStyle
if !opts.AltRowStyle.IsZero() && rowIdx%2 == 1 {
style = opts.AltRowStyle
}
r.renderTableRow(y, row, widths, opts.ColAligns, sep, style)
y++
}
}
// renderTableRow renders a single table row
func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Align, sep rune, style Style) {
x := 0
for i, w := range widths {
if x >= r.W {
break
}
text := ""
if i < len(cells) {
text = cells[i]
}
align := AlignLeft
if i < len(aligns) {
align = aligns[i]
}
// Truncate if needed
if RuneLen(text) > w {
text = Truncate(text, w)
}
// Render with alignment
textLen := RuneLen(text)
var startX int
switch align {
case AlignRight:
startX = x + w - textLen
case AlignCenter:
startX = x + (w-textLen)/2
default:
startX = x
}
for j, ch := range text {
if startX+j < r.W {
r.Cell(startX+j, y, ch, style.Fg, style.Bg, style.Attr)
}
}
x += w
// Column separator
if i < len(widths)-1 && x < r.W {
r.Cell(x, y, sep, style.Fg, style.Bg, terminal.AttrDim)
x++
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package tui
import (
"unicode/utf8"
)
// --- Length calculation ---
// RuneLen returns rune count, used as display width under the package-wide assumption of 1 cell per rune
// Wide (CJK), emoji, and combining characters are not handled
func RuneLen(s string) int {
return utf8.RuneCountInString(s)
}
// --- Truncation ---
// Truncate truncates string with … suffix if exceeds maxLen
func Truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxLen {
return s
}
// Boundary-safe truncation for UTF-8
count := 0
for i := range s {
if count == maxLen-1 {
return s[:i] + "…"
}
count++
}
return s
}
// TruncateLeft truncates with … prefix, keeps end of string
func TruncateLeft(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 1 {
return "…"
}
return "…" + string(runes[len(runes)-maxLen+1:])
}
// TruncateMiddle keeps start and end, … in middle
func TruncateMiddle(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return Truncate(s, maxLen)
}
// Split remaining space between start and end
// Favor start slightly: (maxLen-1)/2 for start, rest for end
startLen := (maxLen - 1) / 2
endLen := maxLen - 1 - startLen
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
}
// --- Padding ---
// PadRight pads string with spaces to width
func PadRight(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
copy(result, runes)
for i := len(runes); i < width; i++ {
result[i] = ' '
}
return string(result)
}
// PadLeft left-pads string with spaces to width
func PadLeft(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
padding := width - len(runes)
for i := 0; i < padding; i++ {
result[i] = ' '
}
copy(result[padding:], runes)
return string(result)
}
// PadCenter centers string within width
func PadCenter(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return s
}
result := make([]rune, width)
leftPad := (width - len(runes)) / 2
for i := range result {
result[i] = ' '
}
copy(result[leftPad:], runes)
return string(result)
}
// --- Text wrapping ---
// WrapText wraps text at word boundaries to fit width
// Returns slice of lines, each no longer than width
func WrapText(s string, width int) []string {
if width <= 0 {
return nil
}
runes := []rune(s)
if len(runes) == 0 {
return []string{""}
}
var lines []string
lineStart := 0
lastSpace := -1
for i := 0; i <= len(runes); i++ {
// Check if we need to wrap
if i-lineStart >= width || i == len(runes) {
if i == len(runes) {
// End of string
if lineStart < len(runes) {
lines = append(lines, string(runes[lineStart:]))
}
break
}
// Need to wrap
wrapAt := i
if lastSpace > lineStart {
// Wrap at last space
wrapAt = lastSpace
}
lines = append(lines, string(runes[lineStart:wrapAt]))
// Skip space at wrap point
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
lineStart = wrapAt + 1
} else {
lineStart = wrapAt
}
lastSpace = -1
}
// Track spaces for word wrapping
if i < len(runes) && runes[i] == ' ' {
lastSpace = i
}
}
if len(lines) == 0 {
lines = []string{""}
}
return lines
}
// --- Repetition ---
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
func RepeatRune(r rune, n int) string {
if n <= 0 {
return ""
}
runes := make([]rune, n)
for i := range runes {
runes[i] = r
}
return string(runes)
}
+171
View File
@@ -0,0 +1,171 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// TextFieldOpts configures text field rendering
type TextFieldOpts struct {
Placeholder string // Shown when empty
Prefix string // Left prompt (e.g., "> ")
Mask rune // Password mask, 0 = none
MaxLen int // Max runes, 0 = unlimited
Border LineType // Border style, LineNone = no border
Focused bool // Show cursor and accept input
Style TextFieldStyle
}
// DefaultTextFieldStyle returns default colors
func DefaultTextFieldStyle() TextFieldStyle {
return TextFieldStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 30, G: 30, B: 40},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
PlaceholderFg: terminal.RGB{R: 100, G: 100, B: 110},
PrefixFg: terminal.RGB{R: 150, G: 150, B: 180},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// TextFieldStyle defines text field colors
type TextFieldStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
PlaceholderFg terminal.RGB
PrefixFg terminal.RGB
BorderFg terminal.RGB
}
// TextField renders text field and returns content height used
func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
if r.W < 3 || r.H < 1 {
return 0
}
style := opts.Style
if style == (TextFieldStyle{}) {
style = DefaultTextFieldStyle()
}
// Calculate content area
contentY := 0
contentX := 0
contentW := r.W
contentH := 1
if opts.Border != LineNone {
if r.H < 3 {
return 0
}
r.Box(opts.Border, style.BorderFg)
contentY = 1
contentX = 1
contentW = r.W - 2
contentH = r.H - 2
if contentH > 1 {
contentH = 1
}
}
// Fill background
for x := contentX; x < contentX+contentW; x++ {
r.Cell(x, contentY, ' ', style.TextFg, style.TextBg, terminal.AttrNone)
}
x := contentX
// Prefix
if opts.Prefix != "" {
for _, ch := range opts.Prefix {
if x >= contentX+contentW {
break
}
r.Cell(x, contentY, ch, style.PrefixFg, style.TextBg, terminal.AttrNone)
x++
}
}
// Calculate viewport
viewportW := contentX + contentW - x
if viewportW < 1 {
return contentH + 2*boolToInt(opts.Border != LineNone)
}
// Adjust scroll
state.AdjustScroll(viewportW)
// Render text or placeholder
text := state.Text
isEmpty := len(text) == 0
if isEmpty && opts.Placeholder != "" && !opts.Focused {
// Placeholder
placeholder := opts.Placeholder
if RuneLen(placeholder) > viewportW {
placeholder = Truncate(placeholder, viewportW)
}
for i, ch := range placeholder {
if x+i >= contentX+contentW {
break
}
r.Cell(x+i, contentY, ch, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
}
} else {
// Scroll indicators
if state.Scroll > 0 && x > contentX {
r.Cell(x-1, contentY, '◀', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
// Text content
for i := 0; i < viewportW; i++ {
runeIdx := state.Scroll + i
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
if opts.Mask != 0 {
ch = opts.Mask
}
}
fg := style.TextFg
bg := style.TextBg
// Cursor highlighting
if opts.Focused && runeIdx == state.Cursor {
fg = style.CursorFg
bg = style.CursorBg
}
r.Cell(x+i, contentY, ch, fg, bg, terminal.AttrNone)
}
// Cursor at end
if opts.Focused && state.Cursor == len(text) {
cursorX := x + state.Cursor - state.Scroll
if cursorX >= x && cursorX < contentX+contentW {
r.Cell(cursorX, contentY, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
// Right scroll indicator
if state.Scroll+viewportW < len(text) {
r.Cell(contentX+contentW-1, contentY, '▶', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
}
if opts.Border != LineNone {
return 3
}
return 1
}
// boolToInt converts boolean to integer (0 or 1)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+280
View File
@@ -0,0 +1,280 @@
package tui
import (
"unicode"
"github.com/lixenwraith/terminal"
)
// isWordChar returns true for word-constituent characters
func isWordChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
// TextFieldState holds editable text field state
type TextFieldState struct {
Text []rune
Cursor int // Positions before which cursor sits (0 = before first char)
Scroll int // First visible rune index
}
// NewTextFieldState creates initialized text field state
func NewTextFieldState(initial string) *TextFieldState {
runes := []rune(initial)
return &TextFieldState{
Text: runes,
Cursor: len(runes),
Scroll: 0,
}
}
// --- Value access ---
// Value returns current text as string
func (t *TextFieldState) Value() string {
return string(t.Text)
}
// SetValue replaces text and moves cursor to end
func (t *TextFieldState) SetValue(s string) {
t.Text = []rune(s)
t.Cursor = len(t.Text)
t.Scroll = 0
}
// Clear empties the field
func (t *TextFieldState) Clear() {
t.Text = nil
t.Cursor = 0
t.Scroll = 0
}
// --- Character insertion ---
// Insert adds rune at cursor position
func (t *TextFieldState) Insert(r rune) {
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) {
runes := []rune(s)
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
t.Cursor += len(runes)
}
// --- Character deletion ---
// DeleteBackward removes rune before cursor
func (t *TextFieldState) DeleteBackward() bool {
if t.Cursor > 0 {
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
t.Cursor--
return true
}
return false
}
// DeleteForward removes rune at cursor
func (t *TextFieldState) DeleteForward() bool {
if t.Cursor < len(t.Text) {
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
return true
}
return false
}
// --- Word deletion ---
// DeleteWordBackward removes word before cursor
func (t *TextFieldState) DeleteWordBackward() bool {
if t.Cursor == 0 {
return false
}
// Skip trailing non-word chars
end := t.Cursor
for end > 0 && !isWordChar(t.Text[end-1]) {
end--
}
// Skip word chars
start := end
for start > 0 && isWordChar(t.Text[start-1]) {
start--
}
if start == t.Cursor {
start = t.Cursor - 1
}
t.Text = append(t.Text[:start], t.Text[t.Cursor:]...)
t.Cursor = start
return true
}
// DeleteWordForward removes word after cursor
func (t *TextFieldState) DeleteWordForward() bool {
if t.Cursor >= len(t.Text) {
return false
}
// Skip word chars
end := t.Cursor
for end < len(t.Text) && isWordChar(t.Text[end]) {
end++
}
// Skip trailing non-word chars
for end < len(t.Text) && !isWordChar(t.Text[end]) {
end++
}
if end == t.Cursor {
end = t.Cursor + 1
}
t.Text = append(t.Text[:t.Cursor], t.Text[end:]...)
return true
}
// DeleteToEnd removes from cursor to end
func (t *TextFieldState) DeleteToEnd() bool {
if t.Cursor < len(t.Text) {
t.Text = t.Text[:t.Cursor]
return true
}
return false
}
// DeleteToStart removes from start to cursor
func (t *TextFieldState) DeleteToStart() bool {
if t.Cursor > 0 {
t.Text = t.Text[t.Cursor:]
t.Cursor = 0
t.Scroll = 0
return true
}
return false
}
// --- Character movement ---
// MoveLeft moves cursor left
func (t *TextFieldState) MoveLeft() {
if t.Cursor > 0 {
t.Cursor--
}
}
// MoveRight moves cursor right
func (t *TextFieldState) MoveRight() {
if t.Cursor < len(t.Text) {
t.Cursor++
}
}
// --- Word movement ---
// MoveWordLeft moves cursor to previous word boundary
func (t *TextFieldState) MoveWordLeft() {
if t.Cursor == 0 {
return
}
// Skip non-word chars
for t.Cursor > 0 && !isWordChar(t.Text[t.Cursor-1]) {
t.Cursor--
}
// Skip word chars
for t.Cursor > 0 && isWordChar(t.Text[t.Cursor-1]) {
t.Cursor--
}
}
// MoveWordRight moves cursor to next word boundary
func (t *TextFieldState) MoveWordRight() {
if t.Cursor >= len(t.Text) {
return
}
// Skip word chars
for t.Cursor < len(t.Text) && isWordChar(t.Text[t.Cursor]) {
t.Cursor++
}
// Skip non-word chars
for t.Cursor < len(t.Text) && !isWordChar(t.Text[t.Cursor]) {
t.Cursor++
}
}
// --- Line movement ---
// MoveToStart moves cursor to beginning
func (t *TextFieldState) MoveToStart() {
t.Cursor = 0
}
// MoveToEnd moves cursor to end
func (t *TextFieldState) MoveToEnd() {
t.Cursor = len(t.Text)
}
// --- Scroll management ---
// AdjustScroll updates scroll to keep cursor visible within viewport width
func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 {
return
}
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
if t.Cursor >= t.Scroll+viewportW {
t.Scroll = t.Cursor - viewportW + 1
}
if t.Scroll < 0 {
t.Scroll = 0
}
}
// --- Input handling ---
// HandleKey processes keyboard input, returns true if state changed
func (t *TextFieldState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyLeft:
if mod&terminal.ModCtrl != 0 {
t.MoveWordLeft()
} else {
t.MoveLeft()
}
return true
case terminal.KeyRight:
if mod&terminal.ModCtrl != 0 {
t.MoveWordRight()
} else {
t.MoveRight()
}
return true
case terminal.KeyHome, terminal.KeyCtrlA:
t.MoveToStart()
return true
case terminal.KeyEnd, terminal.KeyCtrlE:
t.MoveToEnd()
return true
case terminal.KeyBackspace:
if mod&terminal.ModCtrl != 0 {
return t.DeleteWordBackward()
}
return t.DeleteBackward()
case terminal.KeyDelete:
if mod&terminal.ModCtrl != 0 {
return t.DeleteWordForward()
}
return t.DeleteForward()
case terminal.KeyCtrlK:
return t.DeleteToEnd()
case terminal.KeyCtrlU:
return t.DeleteToStart()
case terminal.KeyCtrlW:
return t.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 { // Printable
t.Insert(r)
return true
}
}
return false
}
+63
View File
@@ -0,0 +1,63 @@
package tui
import "github.com/lixenwraith/terminal"
// Theme defines semantic colors for TUI components
type Theme struct {
Bg terminal.RGB
Fg terminal.RGB
FocusBg terminal.RGB
CursorBg terminal.RGB
Selected terminal.RGB
Unselected terminal.RGB
Partial terminal.RGB
Error terminal.RGB
Warning terminal.RGB
Border terminal.RGB
HeaderBg terminal.RGB
HeaderFg terminal.RGB
StatusFg terminal.RGB
HintFg terminal.RGB
InputBg terminal.RGB
DirFg terminal.RGB
FileFg terminal.RGB
SymbolFg terminal.RGB
SyntaxComment terminal.RGB
SyntaxString terminal.RGB
SyntaxKeyword terminal.RGB
SyntaxType terminal.RGB
SyntaxNumber terminal.RGB
SyntaxSymbol terminal.RGB
}
// DefaultTheme provides reasonable defaults
var DefaultTheme = Theme{
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 200, G: 200, B: 200},
FocusBg: terminal.RGB{R: 30, G: 35, B: 45},
CursorBg: terminal.RGB{R: 50, G: 50, B: 70},
Selected: terminal.RGB{R: 80, G: 200, B: 80},
Unselected: terminal.RGB{R: 100, G: 100, B: 100},
Partial: terminal.RGB{R: 80, G: 160, B: 220},
Error: terminal.RGB{R: 255, G: 80, B: 80},
Warning: terminal.RGB{R: 255, G: 80, B: 80},
Border: terminal.RGB{R: 60, G: 80, B: 100},
HeaderBg: terminal.RGB{R: 40, G: 60, B: 90},
HeaderFg: terminal.RGB{R: 255, G: 255, B: 255},
StatusFg: terminal.RGB{R: 140, G: 140, B: 140},
HintFg: terminal.RGB{R: 100, G: 180, B: 200},
InputBg: terminal.RGB{R: 30, G: 30, B: 50},
DirFg: terminal.RGB{R: 130, G: 170, B: 220},
FileFg: terminal.RGB{R: 200, G: 200, B: 200},
SymbolFg: terminal.RGB{R: 180, G: 220, B: 220},
SyntaxComment: terminal.RGB{R: 100, G: 110, B: 120},
SyntaxString: terminal.RGB{R: 180, G: 220, B: 140},
SyntaxKeyword: terminal.RGB{R: 180, G: 140, B: 220},
SyntaxType: terminal.RGB{R: 80, G: 200, B: 200},
SyntaxNumber: terminal.RGB{R: 220, G: 180, B: 120},
SyntaxSymbol: terminal.RGB{R: 220, G: 180, B: 80},
}
+301
View File
@@ -0,0 +1,301 @@
package tui
import "github.com/lixenwraith/terminal"
// ToastPosition specifies where toast renders
type ToastPosition uint8
const (
ToastBottom ToastPosition = iota // Full-width bar at bottom
ToastTop // Full-width bar at top
ToastBottomRight // Floating box bottom-right
ToastBottomLeft // Floating box bottom-left
ToastTopRight // Floating box top-right
ToastTopLeft // Floating box top-left
ToastCenter // Centered floating box
)
// ToastSeverity defines message type for styling
type ToastSeverity uint8
const (
ToastInfo ToastSeverity = iota // Default, neutral
ToastSuccess // Green, positive
ToastWarning // Yellow, caution
ToastError // Red, failure
)
// ToastStyle defines visual appearance
type ToastStyle uint8
const (
ToastStyleMinimal ToastStyle = iota // No border, just text
ToastStyleBar // Full-width background bar
ToastStyleBox // Bordered box
ToastStyleRounded // Rounded border box
ToastStyleDouble // Double-line border
ToastStyleShadow // Box with shadow effect
)
// ToastIcons for severity levels
var ToastIcons = map[ToastSeverity]rune{
ToastInfo: '',
ToastSuccess: '✓',
ToastWarning: '⚠',
ToastError: '✗',
}
// ToastColors default colors per severity
var ToastColors = map[ToastSeverity]struct{ Fg, Bg, Icon terminal.RGB }{
ToastInfo: {
Fg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 40, G: 40, B: 50},
Icon: terminal.RGB{R: 100, G: 150, B: 255},
},
ToastSuccess: {
Fg: terminal.RGB{R: 220, G: 255, B: 220},
Bg: terminal.RGB{R: 30, G: 60, B: 30},
Icon: terminal.RGB{R: 80, G: 220, B: 80},
},
ToastWarning: {
Fg: terminal.RGB{R: 255, G: 240, B: 200},
Bg: terminal.RGB{R: 60, G: 50, B: 20},
Icon: terminal.RGB{R: 255, G: 200, B: 60},
},
ToastError: {
Fg: terminal.RGB{R: 255, G: 220, B: 220},
Bg: terminal.RGB{R: 60, G: 25, B: 25},
Icon: terminal.RGB{R: 255, G: 80, B: 80},
},
}
// ToastOpts configures toast rendering
type ToastOpts struct {
Message string
Severity ToastSeverity
Position ToastPosition
Style ToastStyle
ShowIcon bool
MinWidth int // Minimum width for floating toasts, 0 = auto
MaxWidth int // Maximum width, 0 = region width
Padding int // Horizontal padding, default 1
MarginX int // Margin from edge for floating positions
MarginY int // Margin from edge for floating positions
CustomFg terminal.RGB
CustomBg terminal.RGB
CustomIcon terminal.RGB
}
// DefaultToastOpts returns sensible defaults
func DefaultToastOpts(message string, severity ToastSeverity) ToastOpts {
return ToastOpts{
Message: message,
Severity: severity,
Position: ToastBottom,
Style: ToastStyleBar,
ShowIcon: true,
Padding: 1,
MarginX: 2,
MarginY: 1,
}
}
// Toast renders a toast message overlay
// Returns the region occupied by the toast for hit testing
func (r Region) Toast(opts ToastOpts) Region {
if r.W < 5 || r.H < 1 || opts.Message == "" {
return Region{}
}
// Resolve colors
fg, bg, iconFg := opts.CustomFg, opts.CustomBg, opts.CustomIcon
if fg == (terminal.RGB{}) {
fg = ToastColors[opts.Severity].Fg
}
if bg == (terminal.RGB{}) {
bg = ToastColors[opts.Severity].Bg
}
if iconFg == (terminal.RGB{}) {
iconFg = ToastColors[opts.Severity].Icon
}
padding := opts.Padding
if padding == 0 {
padding = 1
}
// Calculate content width
iconW := 0
if opts.ShowIcon {
iconW = 2 // icon + space
}
msgLen := RuneLen(opts.Message)
contentW := iconW + msgLen + padding*2
// Determine toast dimensions based on style
borderW := 0
if opts.Style >= ToastStyleBox {
borderW = 2
}
toastW := contentW + borderW
toastH := 1 + borderW
// Apply width constraints
maxW := opts.MaxWidth
if maxW == 0 || maxW > r.W {
maxW = r.W
}
if toastW > maxW {
toastW = maxW
}
if opts.MinWidth > 0 && toastW < opts.MinWidth {
toastW = opts.MinWidth
}
// Calculate position
var toastX, toastY int
marginX := opts.MarginX
marginY := opts.MarginY
switch opts.Position {
case ToastBottom:
toastX = 0
toastY = r.H - toastH
toastW = r.W // Full width for bar positions
case ToastTop:
toastX = 0
toastY = 0
toastW = r.W
case ToastBottomRight:
toastX = r.W - toastW - marginX
toastY = r.H - toastH - marginY
case ToastBottomLeft:
toastX = marginX
toastY = r.H - toastH - marginY
case ToastTopRight:
toastX = r.W - toastW - marginX
toastY = marginY
case ToastTopLeft:
toastX = marginX
toastY = marginY
case ToastCenter:
toastX = (r.W - toastW) / 2
toastY = (r.H - toastH) / 2
}
// Clamp position
if toastX < 0 {
toastX = 0
}
if toastY < 0 {
toastY = 0
}
toastRegion := r.Sub(toastX, toastY, toastW, toastH)
// Render based on style
switch opts.Style {
case ToastStyleMinimal:
r.renderToastContent(toastRegion, opts, fg, bg, iconFg, 0)
case ToastStyleBar:
toastRegion.Fill(bg)
r.renderToastContent(toastRegion, opts, fg, bg, iconFg, 0)
case ToastStyleBox:
toastRegion.BoxFilled(LineSingle, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleRounded:
toastRegion.BoxFilled(LineRounded, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleDouble:
toastRegion.BoxFilled(LineDouble, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
case ToastStyleShadow:
// Shadow offset
shadowRegion := r.Sub(toastX+1, toastY+1, toastW, toastH)
shadowRegion.Fill(terminal.RGB{R: 10, G: 10, B: 15})
toastRegion.BoxFilled(LineSingle, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
}
return toastRegion
}
func (r Region) renderToastContent(content Region, opts ToastOpts, fg, bg, iconFg terminal.RGB, _ int) {
if content.W < 1 || content.H < 1 {
return
}
x := opts.Padding
y := 0
// Icon
if opts.ShowIcon {
icon := ToastIcons[opts.Severity]
if x < content.W {
content.Cell(x, y, icon, iconFg, bg, terminal.AttrBold)
}
x += 2
}
// Message
msg := opts.Message
availW := content.W - x - opts.Padding
if availW < 1 {
return
}
if RuneLen(msg) > availW {
msg = Truncate(msg, availW)
}
content.Text(x, y, msg, fg, bg, terminal.AttrNone)
}
// ToastState manages toast lifecycle
type ToastState struct {
Visible bool
Opts ToastOpts
FramesLeft int // Countdown to auto-dismiss, -1 = persistent
}
// NewToastState creates a toast that auto-dismisses after frames
// Use frames=-1 for persistent toast
func NewToastState(opts ToastOpts, frames int) *ToastState {
return &ToastState{
Visible: true,
Opts: opts,
FramesLeft: frames,
}
}
// Tick decrements frame counter, returns true if toast should dismiss
func (t *ToastState) Tick() bool {
if !t.Visible {
return false
}
if t.FramesLeft < 0 {
return false // Persistent
}
t.FramesLeft--
if t.FramesLeft <= 0 {
t.Visible = false
return true
}
return false
}
// Dismiss hides the toast
func (t *ToastState) Dismiss() {
t.Visible = false
}
// Show displays a new toast
func (t *ToastState) Show(opts ToastOpts, frames int) {
t.Opts = opts
t.FramesLeft = frames
t.Visible = true
}
+64
View File
@@ -0,0 +1,64 @@
package tui
// TreeExpansion manages expand/collapse state
type TreeExpansion struct {
State map[string]bool
}
// NewTreeExpansion creates initialized expansion state
func NewTreeExpansion() *TreeExpansion {
return &TreeExpansion{
State: make(map[string]bool),
}
}
// --- State queries ---
// IsExpanded returns expansion state for key
func (e *TreeExpansion) IsExpanded(key string) bool {
return e.State[key]
}
// --- State modification ---
// SetExpanded sets expansion state for key
func (e *TreeExpansion) SetExpanded(key string, expanded bool) {
e.State[key] = expanded
}
// Toggle toggles expansion state for key
func (e *TreeExpansion) Toggle(key string) bool {
e.State[key] = !e.State[key]
return e.State[key]
}
// Expand sets key to expanded
func (e *TreeExpansion) Expand(key string) {
e.State[key] = true
}
// Collapse sets key to collapsed
func (e *TreeExpansion) Collapse(key string) {
e.State[key] = false
}
// --- Bulk operations ---
// ExpandAll expands all provided keys
func (e *TreeExpansion) ExpandAll(keys []string) {
for _, k := range keys {
e.State[k] = true
}
}
// CollapseAll collapses all keys
func (e *TreeExpansion) CollapseAll() {
for k := range e.State {
e.State[k] = false
}
}
// Clear removes all expansion state
func (e *TreeExpansion) Clear() {
e.State = make(map[string]bool)
}
+337
View File
@@ -0,0 +1,337 @@
package tui
import "github.com/lixenwraith/terminal"
// ExpandIcon chars
const (
IconExpanded = '▼'
IconCollapsed = '▶'
IconBullet = '•'
)
// ExpandIconRune returns appropriate expand/collapse indicator
func ExpandIconRune(expanded bool) rune {
if expanded {
return IconExpanded
}
return IconCollapsed
}
// TreeLineMode specifies connector line rendering
type TreeLineMode uint8
const (
TreeLinesNone TreeLineMode = iota // Indent only, no lines
TreeLinesSimple // │ continuation, └ for last
)
// TreeNode represents a node in a hierarchical tree
type TreeNode struct {
Key string // Unique identifier for expansion state
Label string // Display text
Icon rune // Custom icon, 0 = auto (▶/▼ for expandable, • for leaf)
IconFg terminal.RGB
Expandable bool // Has children
Expanded bool // Currently expanded
Depth int // Nesting level (0 = root)
Check CheckState // Optional checkbox, CheckNone to skip
CheckFg terminal.RGB
Style Style // Text styling
IsLast bool // Last sibling at this depth (for tree lines)
Data any // Application payload
Suffix string // Secondary text after label (e.g., "(5 files)")
SuffixStyle Style // Styling for suffix, zero = dimmed version of Style
Badge rune // Icon between checkbox and label (e.g., '★'), 0 = none
BadgeFg terminal.RGB // Badge color
}
// ancestorHasMoreSiblings checks if there are more nodes at given depth after idx
func (r Region) ancestorHasMoreSiblings(nodes []TreeNode, idx, depth int) bool {
targetDepth := depth
for i := idx + 1; i < len(nodes); i++ {
if nodes[i].Depth <= targetDepth {
return nodes[i].Depth == targetDepth
}
}
return false
}
// TreeOpts configures tree rendering
type TreeOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
IndentWidth int // Cells per depth, default 2
IconWidth int // Width for icon column, default 2
LineMode TreeLineMode
LineFg terminal.RGB
}
// DefaultTreeOpts returns sensible defaults
func DefaultTreeOpts() TreeOpts {
return TreeOpts{
IndentWidth: 2,
IconWidth: 2,
LineMode: TreeLinesNone,
LineFg: terminal.RGB{R: 80, G: 80, B: 100},
}
}
// Tree renders hierarchical tree nodes within region, returns number of rows rendered
// Nodes must be pre-flattened (only visible/expanded nodes included)
func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
if r.H < 1 || len(nodes) == 0 {
return 0
}
indentW := opts.IndentWidth
if indentW < 1 {
indentW = 2
}
iconW := opts.IconWidth
if iconW < 1 {
iconW = 2
}
lineFg := opts.LineFg
if lineFg == (terminal.RGB{}) {
lineFg = DefaultTheme.Border
}
rendered := 0
for y := 0; y < r.H; y++ {
idx := scroll + y
if idx >= len(nodes) {
break
}
node := nodes[idx]
isCursor := idx == cursor
bg := opts.DefaultBg
if isCursor {
bg = opts.CursorBg
}
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
}
x := 0
if opts.LineMode == TreeLinesSimple && node.Depth > 0 {
x = r.renderTreeLines(y, x, node, nodes, idx, scroll, indentW, lineFg, bg)
} else {
x = node.Depth * indentW
}
// Icon (expand indicator or bullet)
icon := node.Icon
iconFg := node.IconFg
if icon == 0 {
if node.Expandable {
if node.Expanded {
icon = IconExpanded
} else {
icon = IconCollapsed
}
} else {
icon = IconBullet
}
}
if iconFg == (terminal.RGB{}) {
iconFg = lineFg
}
if x < r.W {
r.Cell(x, y, icon, iconFg, bg, terminal.AttrNone)
}
x += iconW
// Checkbox
if node.Check != CheckNone || node.CheckFg != (terminal.RGB{}) {
if x+3 <= r.W {
checkFg := node.CheckFg
if checkFg == (terminal.RGB{}) {
checkFg = node.Style.Fg
}
var ch rune
switch node.Check {
case CheckNone:
ch = ' '
case CheckPartial:
ch = 'o'
case CheckFull:
ch = 'x'
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', checkFg, bg, terminal.AttrNone)
r.Cell(x+1, y, ch, checkFg, bg, terminal.AttrNone)
r.Cell(x+2, y, ']', checkFg, bg, terminal.AttrNone)
}
x += 4
}
// Badge (optional icon between checkbox and label)
if node.Badge != 0 {
if x < r.W {
badgeFg := node.BadgeFg
if badgeFg == (terminal.RGB{}) {
badgeFg = node.Style.Fg
}
r.Cell(x, y, node.Badge, badgeFg, bg, terminal.AttrNone)
}
x += 2
}
// Label
style := node.Style
if style.Bg == (terminal.RGB{}) {
style.Bg = bg
}
// Calculate available width for label + suffix
availW := r.W - x - 1
labelLen := RuneLen(node.Label)
suffixLen := RuneLen(node.Suffix)
label := node.Label
suffix := node.Suffix
// Truncate if needed, prioritizing label over suffix
if labelLen+suffixLen > availW {
if labelLen > availW {
label = Truncate(label, availW)
suffix = ""
} else {
suffix = Truncate(suffix, availW-labelLen)
}
}
r.TextStyled(x, y, label, style)
x += RuneLen(label)
// Suffix
if suffix != "" {
suffixStyle := node.SuffixStyle
if suffixStyle.Fg == (terminal.RGB{}) {
// Default: dimmed version of label style
suffixStyle.Fg = terminal.RGB{
R: node.Style.Fg.R / 2,
G: node.Style.Fg.G / 2,
B: node.Style.Fg.B / 2,
}
}
if suffixStyle.Bg == (terminal.RGB{}) {
suffixStyle.Bg = bg
}
r.TextStyled(x, y, suffix, suffixStyle)
}
rendered++
}
return rendered
}
// renderTreeLines draws connector lines for tree structure, returns x position after lines
func (r Region) renderTreeLines(y, startX int, node TreeNode, nodes []TreeNode, idx, scroll, indentW int, fg terminal.RGB, bg terminal.RGB) int {
x := startX
for d := 0; d < node.Depth; d++ {
// Determine if ancestor at this depth has more siblings below
hasMore := r.ancestorHasMoreSiblings(nodes, idx, d)
if d == node.Depth-1 {
// Direct parent level - show branch
if node.IsLast {
r.Cell(x, y, '└', fg, bg, terminal.AttrNone)
} else {
r.Cell(x, y, '├', fg, bg, terminal.AttrNone)
}
// Horizontal connector
for i := 1; i < indentW; i++ {
if x+i < r.W {
r.Cell(x+i, y, '─', fg, bg, terminal.AttrNone)
}
}
} else {
// Ancestor level - show continuation or space
if hasMore {
r.Cell(x, y, '│', fg, bg, terminal.AttrNone)
}
// Fill rest with spaces (already cleared)
}
x += indentW
}
return x
}
// --- Navigation helpers ---
// FindParentIndex returns index of parent node for node at idx, or -1 if root
func FindParentIndex(nodes []TreeNode, idx int) int {
if idx <= 0 || idx >= len(nodes) {
return -1
}
targetDepth := nodes[idx].Depth - 1
if targetDepth < 0 {
return -1
}
for i := idx - 1; i >= 0; i-- {
if nodes[i].Depth == targetDepth {
return i
}
}
return -1
}
// FindFirstChildIndex returns index of first child for node at idx, or -1 if none
func FindFirstChildIndex(nodes []TreeNode, idx int) int {
if idx < 0 || idx >= len(nodes)-1 {
return -1
}
if !nodes[idx].Expandable || !nodes[idx].Expanded {
return -1
}
childDepth := nodes[idx].Depth + 1
if idx+1 < len(nodes) && nodes[idx+1].Depth == childDepth {
return idx + 1
}
return -1
}
// FindNextSiblingIndex returns index of next sibling at same depth, or -1
func FindNextSiblingIndex(nodes []TreeNode, idx int) int {
if idx < 0 || idx >= len(nodes) {
return -1
}
depth := nodes[idx].Depth
for i := idx + 1; i < len(nodes); i++ {
if nodes[i].Depth < depth {
return -1 // Went up, no more siblings
}
if nodes[i].Depth == depth {
return i
}
}
return -1
}
// FindPrevSiblingIndex returns index of previous sibling at same depth, or -1
func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
if idx <= 0 || idx >= len(nodes) {
return -1
}
depth := nodes[idx].Depth
for i := idx - 1; i >= 0; i-- {
if nodes[i].Depth < depth {
return -1 // Went up, no more siblings
}
if nodes[i].Depth == depth {
return i
}
}
return -1
}
+52
View File
@@ -0,0 +1,52 @@
package tui
// TreeBuilder helps construct flattened visible node list from hierarchical data
type TreeBuilder struct {
nodes []TreeNode
expansion *TreeExpansion
}
// NewTreeBuilder creates a builder with expansion state
func NewTreeBuilder(expansion *TreeExpansion) *TreeBuilder {
return &TreeBuilder{
expansion: expansion,
}
}
// Reset clears accumulated nodes
func (b *TreeBuilder) Reset() {
b.nodes = b.nodes[:0]
}
// Add adds a node if visible (parent expanded)
// parentExpanded should be true for root-level nodes
func (b *TreeBuilder) Add(node TreeNode, parentExpanded bool) {
if !parentExpanded {
return
}
node.Expanded = b.expansion.IsExpanded(node.Key)
b.nodes = append(b.nodes, node)
}
// Nodes returns accumulated visible nodes
func (b *TreeBuilder) Nodes() []TreeNode {
return b.nodes
}
// MarkLastSiblings sets IsLast flag on nodes that are last at their depth
// Call after all nodes added, before rendering
func (b *TreeBuilder) MarkLastSiblings() {
// Backward scan: seen[d] tracks whether a sibling at depth d was encountered
// within the current subtree. Crossing depth d invalidates deeper entries.
seen := make([]bool, 0, 8)
for i := len(b.nodes) - 1; i >= 0; i-- {
d := b.nodes[i].Depth
for len(seen) <= d {
seen = append(seen, false)
}
seen = seen[:d+1] // deeper entries belong to a later subtree
b.nodes[i].IsLast = !seen[d]
seen[d] = true
}
}
+97
View File
@@ -0,0 +1,97 @@
package tui
// TreeState manages navigation state for a tree
type TreeState struct {
Cursor int
Scroll int
Visible int // Viewport height
}
// NewTreeState creates initialized tree state
func NewTreeState(visible int) *TreeState {
return &TreeState{
Visible: visible,
}
}
// --- Cursor movement ---
// MoveCursor adjusts cursor position by delta
func (t *TreeState) MoveCursor(delta, total int) {
t.Cursor += delta
if t.Cursor < 0 {
t.Cursor = 0
}
if t.Cursor >= total {
t.Cursor = total - 1
}
if t.Cursor < 0 {
t.Cursor = 0
}
t.AdjustScroll(total)
}
// AdjustScroll ensures cursor is visible
func (t *TreeState) AdjustScroll(total int) {
if t.Visible <= 0 {
return
}
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
if t.Cursor >= t.Scroll+t.Visible {
t.Scroll = t.Cursor - t.Visible + 1
}
// Clamp scroll
maxScroll := total - t.Visible
if maxScroll < 0 {
maxScroll = 0
}
if t.Scroll > maxScroll {
t.Scroll = maxScroll
}
if t.Scroll < 0 {
t.Scroll = 0
}
}
// --- Jump navigation ---
// JumpStart moves cursor to first item
func (t *TreeState) JumpStart() {
t.Cursor = 0
t.Scroll = 0
}
// JumpEnd moves cursor to last item
func (t *TreeState) JumpEnd(total int) {
if total > 0 {
t.Cursor = total - 1
}
t.AdjustScroll(total)
}
// --- Page navigation ---
// PageUp scrolls up by half viewport
func (t *TreeState) PageUp(total int) {
delta := t.Visible / 2
if delta < 1 {
delta = 1
}
t.MoveCursor(-delta, total)
}
// PageDown scrolls down by half viewport
func (t *TreeState) PageDown(total int) {
delta := t.Visible / 2
if delta < 1 {
delta = 1
}
t.MoveCursor(delta, total)
}
// SetVisible updates viewport height
func (t *TreeState) SetVisible(visible int) {
t.Visible = visible
}
+107
View File
@@ -0,0 +1,107 @@
package tui
// ViewportScroll manages row-based scroll for content regions
// Distinct from ScrollState which is item-index based
type ViewportScroll struct {
Offset int // Row offset from top of content
ContentH int // Total content height in rows
ViewportH int // Visible viewport height
}
// NewViewportScroll creates viewport scroll state
func NewViewportScroll() *ViewportScroll {
return &ViewportScroll{}
}
// SetDimensions updates content and viewport heights, clamps offset
func (v *ViewportScroll) SetDimensions(contentH, viewportH int) {
v.ContentH = contentH
v.ViewportH = viewportH
v.clamp()
}
// MaxOffset returns maximum valid scroll offset
func (v *ViewportScroll) MaxOffset() int {
maxOffset := v.ContentH - v.ViewportH
if maxOffset < 0 {
return 0
}
return maxOffset
}
// CanScroll returns true if content exceeds viewport
func (v *ViewportScroll) CanScroll() bool {
return v.ContentH > v.ViewportH
}
// ScrollBy adjusts offset by delta
func (v *ViewportScroll) ScrollBy(delta int) {
v.Offset += delta
v.clamp()
}
// ScrollTo sets absolute offset
func (v *ViewportScroll) ScrollTo(pos int) {
v.Offset = pos
v.clamp()
}
// PageUp scrolls up by viewport height
func (v *ViewportScroll) PageUp() {
v.ScrollBy(-v.ViewportH)
}
// PageDown scrolls down by viewport height
func (v *ViewportScroll) PageDown() {
v.ScrollBy(v.ViewportH)
}
// Home scrolls to top
func (v *ViewportScroll) Home() {
v.Offset = 0
}
// End scrolls to bottom
func (v *ViewportScroll) End() {
v.Offset = v.MaxOffset()
}
func (v *ViewportScroll) clamp() {
max := v.MaxOffset()
if v.Offset > max {
v.Offset = max
}
if v.Offset < 0 {
v.Offset = 0
}
}
// IsVisible returns true if content row range intersects viewport
func (v *ViewportScroll) IsVisible(y, h int) bool {
return y+h > v.Offset && y < v.Offset+v.ViewportH
}
// ClipToViewport maps content coordinates to viewport coordinates
// Returns viewY (in viewport), viewH (visible height), contentOffset (rows clipped from top)
// visible=false if entirely outside viewport
func (v *ViewportScroll) ClipToViewport(y, h int) (viewY, viewH, contentOffset int, visible bool) {
if !v.IsVisible(y, h) {
return 0, 0, 0, false
}
viewY = y - v.Offset
viewH = h
contentOffset = 0
if viewY < 0 {
contentOffset = -viewY
viewH += viewY
viewY = 0
}
if viewY+viewH > v.ViewportH {
viewH = v.ViewportH - viewY
}
return viewY, viewH, contentOffset, viewH > 0
}