commit c3ca2ebb63d92201d62ffec3cce7bc2aa97388b6 Author: Lixen Wraith Date: Sun Jul 12 16:50:45 2026 -0400 v0.1.0 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b80573f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +bin +dev +logs +log +catalog.txt +combined.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a36bbd --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c57081 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# terminal + +Direct ANSI terminal control for Go with zero-allocation rendering. Built for +sustained 60fps full-screen redraws in cell-based applications (games, dashboards, +TUIs). Depends only on the standard library and `golang.org/x/sys` (Unix builds). + +The package bypasses terminfo/termcap entirely and emits ANSI sequences directly. +Target environments: xterm-compatible terminals on Linux and BSDs, and browsers +via xterm.js (WASM builds). + +## Features + +- True color (24-bit) and 256-color palette output with automatic capability detection +- Double-buffered output with cell-level diffing — only changed cells emit sequences +- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize +- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT +- Color blending library: alpha, additive, screen, overlay, soft light +- Named color palettes for true color and xterm-256 +- Panic-safe terminal restoration (`Fini`, `EmergencyReset`) +- Unix and WASM backends behind a common interface + +## Architecture + + Terminal (interface) + └── termImpl + ├── outputBuffer diffing, ANSI generation, 128KB buffered writer + ├── inputReader escape sequence parser, event channel + └── Backend (interface) + ├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH + └── wasmBackend //go:build wasm — syscall/js, xterm.js bridge + +Shared code carries no build tags: cell diffing, ANSI generation, escape parsing, +service lifecycle. Platform specifics are isolated in the `Backend` implementations. + +### Rendering pipeline + +The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and +passes it to `Flush`. The output buffer diffs against the previously flushed frame: + +- Rows are scanned with early termination (trailing unchanged cells skipped). +- Cursor moves are emitted only when the write position is non-contiguous. +- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences + are suppressed. +- If the backend size changed between buffer preparation and `Flush`, the frame + is dropped to prevent resize-race corruption. The next frame (built at the new + size) renders normally. + +`Sync()` clears the screen and invalidates the front buffer, forcing a full +redraw — required after any external process writes to the terminal. + +Auto-wrap is disabled during the session, making the bottom-right cell writable +without scroll side effects. + +## Quick start + +```go +package main + +import "github.com/lixenwraith/terminal" + +func main() { + term := terminal.New() // color mode auto-detected + if err := term.Init(); err != nil { + panic(err) + } + defer term.Fini() + + w, h := term.Size() + cells := make([]terminal.Cell, w*h) + + for { + // Build frame + for i := range cells { + cells[i] = terminal.Cell{Rune: ' ', Bg: terminal.Gunmetal} + } + msg := "hello" + for i, ch := range msg { + cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{ + Rune: ch, Fg: terminal.Amber, Bg: terminal.Gunmetal, + Attrs: terminal.AttrBold, + } + } + term.Flush(cells, w, h) + + // Handle input + ev := term.PollEvent() + switch ev.Type { + case terminal.EventKey: + if ev.Key == terminal.KeyEscape || ev.Rune == 'q' { + return + } + case terminal.EventResize: + w, h = ev.Width, ev.Height + cells = make([]terminal.Cell, w*h) + } + } +} +``` + +## Cells and attributes + +```go +type Cell struct { + Rune rune + Fg RGB + Bg RGB + Attrs Attr +} +``` + +`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`, +`AttrBlink`, `AttrReverse`. + +Two flag bits change color interpretation: with `AttrFg256` / `AttrBg256` set, +`Fg.R` / `Bg.R` holds an xterm-256 palette index directly and `G`/`B` are +ignored. This allows exact palette output on true color terminals and skips +RGB → palette conversion. + +## Color system + +### Modes + +`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits +`38;5;N` after mapping. `DetectColorMode()` inspects the environment +(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`. + +### RGB → 256 mapping + +`RGBTo256` maps any `RGB` to the nearest xterm-256 index using perceptually +weighted Redmean distance. The full mapping is pre-computed at init into a +6-bit-quantized LUT (256KB, L2-resident), making per-cell conversion a single +array load. Applications targeting 256-color terminals can render in RGB +throughout; degradation is automatic. + +Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math, +`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`, +`P256SteelBlue`, ...) in `rgb_256.go` and named true color values (`Amber`, +`Gunmetal`, `Obsidian`, ...) in `rgb_truecolor.go`. + +### Blending + +`blend.go` provides compositing primitives operating on `RGB`. All take +destination first and are branch-free in the hot path or LUT-backed; suitable +for per-cell use at frame rate. + +| Function | Operation | Character | +|---|---|---| +| `Blend(dst, src, alpha)` | linear interpolation | standard transparency | +| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips | +| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips | +| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure | +| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow | +| `Max(dst, src, alpha)` | per-channel max | non-additive highlight | +| `Scale(c, factor)` | channel multiply | dim/brighten | +| `Grayscale(c)` | Rec. 601 luma | desaturation | +| `c.Lerp(other, t)` | method on `RGB` | gradients, animation | + +`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1 +short-circuits without float math. + +```go +bg := terminal.Gunmetal +glow := terminal.RGB{R: 255, G: 160, B: 40} + +cell.Bg = terminal.Screen(bg, terminal.Scale(glow, pulse), 1.0) // pulsing glow +cell.Bg = terminal.Blend(cell.Bg, terminal.Black, 0.6) // dim overlay backdrop +cell.Fg = terminal.SoftLight(cell.Fg, tint, 0.4) // subtle recolor +bar := cold.Lerp(hot, load) // value-mapped gradient +``` + +Integer paths (`Add`, `Screen`, `Overlay`) use a `(x + (x>>8) + 1) >> 8` +division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`. + +## Input + +`PollEvent()` blocks on a unified channel. `Event.Type` values: + +- `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...), + `Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask + (`ModShift`, `ModAlt`, `ModCtrl`) +- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel), + `MouseAction` (press/release/move/drag), modifiers. Enable via + `SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only. +- `EventResize` — new `Width`/`Height` +- `EventError`, `EventClosed` + +A standalone ESC press is disambiguated from escape sequences by a 50ms +timeout. Partial UTF-8 and escape sequences at read boundaries are reassembled +in a persistent buffer. `PostEvent` injects synthetic events (used for clean +shutdown of blocked `PollEvent`). + +## Service wrapper + +`TerminalService` packages lifecycle (init, input goroutine, panic-safe +teardown) behind `Init/Start/Stop` for service-registry architectures: + +```go +svc := terminal.NewService() +svc.Init() +svc.Start() +defer svc.Stop() + +term := svc.Terminal() +for ev := range svc.Events() { /* ... */ } +``` + +Input-goroutine panics trigger `EmergencyReset` (restores cooked mode, main +screen, cursor) before printing the stack trace, keeping the shell usable. + +## WASM + +WASM builds bridge to xterm.js via JS globals: + + goTerminalWrite(Uint8Array) // Go → JS terminal output + goTerminalInput(Uint8Array) // JS → Go keyboard input + goTerminalResize(cols, rows) // JS → Go resize + xterm.cols, xterm.rows // initial size query + +## Sub-packages + +- [`tui`](tui/README.md) — immediate-mode widget toolkit (regions, layout, + widgets, scroll/editor state) built on the cell buffer model. diff --git a/ansi.go b/ansi.go new file mode 100644 index 0000000..54c4174 --- /dev/null +++ b/ansi.go @@ -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') +} \ No newline at end of file diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..737d2ff --- /dev/null +++ b/backend.go @@ -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)) +} \ No newline at end of file diff --git a/backend_unix.go b/backend_unix.go new file mode 100644 index 0000000..4e94bfe --- /dev/null +++ b/backend_unix.go @@ -0,0 +1,152 @@ +//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) + } +} + +func (b *unixBackend) Size() (int, int) { + return getTerminalSize(b.outFd) +} + +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 single space and space 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) + } + } + }() +} + +// getTerminalSize returns the terminal size for a given fd +func getTerminalSize(fd int) (int, int) { + ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) + if err != nil { + return 80, 24 // Fallback + } + return int(ws.Col), int(ws.Row) +} \ No newline at end of file diff --git a/backend_wasm.go b/backend_wasm.go new file mode 100644 index 0000000..507ce61 --- /dev/null +++ b/backend_wasm.go @@ -0,0 +1,147 @@ +//go:build wasm + +package terminal + +import ( + "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 +} + +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) +} \ No newline at end of file diff --git a/backend_windows.go b/backend_windows.go new file mode 100644 index 0000000..5af8506 --- /dev/null +++ b/backend_windows.go @@ -0,0 +1,166 @@ +//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 (b *windowsBackend) Size() (int, int) { + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(b.stdout, &info); err != nil { + return 80, 24 + } + w := int(info.Window.Right-info.Window.Left) + 1 + h := int(info.Window.Bottom-info.Window.Top) + 1 + if w < 1 { + w = 80 + } + if h < 1 { + h = 24 + } + return w, h +} + +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) + } + } + } + }() +} diff --git a/blend.go b/blend.go new file mode 100644 index 0000000..a9afddf --- /dev/null +++ b/blend.go @@ -0,0 +1,184 @@ +package terminal + +import "math" + +// Perez SoftLight lookup tables (array access, no pointers) +// Pre-computed at init to avoid sqrt/division in per-cell loops +var ( + softLightG [256]float64 + softLightDF [256]float64 +) + +func init() { + for i := 0; i < 256; i++ { + 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 + } + return uint8(v) +} + +// 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 + 0.5) // +0.5 for rounding +} + +// 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: uint8(float64(src.R)*alpha + float64(dst.R)*inv), + G: uint8(float64(src.G)*alpha + float64(dst.G)*inv), + B: uint8(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} +} diff --git a/color.go b/color.go new file mode 100644 index 0000000..a1fb56b --- /dev/null +++ b/color.go @@ -0,0 +1,116 @@ +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: uint8(float64(c.R) + (float64(other.R)-float64(c.R))*t), + G: uint8(float64(c.G) + (float64(other.G)-float64(c.G))*t), + B: uint8(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)] +} + diff --git a/detect_unix.go b/detect_unix.go new file mode 100644 index 0000000..0342918 --- /dev/null +++ b/detect_unix.go @@ -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) + } + } +} \ No newline at end of file diff --git a/detect_wasm.go b/detect_wasm.go new file mode 100644 index 0000000..ffa5552 --- /dev/null +++ b/detect_wasm.go @@ -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() {} \ No newline at end of file diff --git a/detect_windows.go b/detect_windows.go new file mode 100644 index 0000000..5a5b648 --- /dev/null +++ b/detect_windows.go @@ -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, + ) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..fd3566f --- /dev/null +++ b/doc.go @@ -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 + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..01d3952 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d2468be --- /dev/null +++ b/go.sum @@ -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= diff --git a/input.go b/input.go new file mode 100644 index 0000000..1dbaabc --- /dev/null +++ b/input.go @@ -0,0 +1,601 @@ +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 // Future: mouse support + 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{} + mu sync.Mutex + running bool + + // Persistent buffer for stream assembly, not fixed size zero-alloc to avoid corrupting partial UTF-8 at boundary + buf []byte +} + +// escapeTimeout is the duration to wait after ESC to distinguish +// standalone ESC from escape sequence start +const escapeTimeout = 50 * time.Millisecond + +// 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 +} \ No newline at end of file diff --git a/key.go b/key.go new file mode 100644 index 0000000..f45c402 --- /dev/null +++ b/key.go @@ -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 +} \ No newline at end of file diff --git a/keynames.go b/keynames.go new file mode 100644 index 0000000..f666c20 --- /dev/null +++ b/keynames.go @@ -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 +} \ No newline at end of file diff --git a/mouse.go b/mouse.go new file mode 100644 index 0000000..98c6d42 --- /dev/null +++ b/mouse.go @@ -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" + } +} \ No newline at end of file diff --git a/output.go b/output.go new file mode 100644 index 0000000..38b16f7 --- /dev/null +++ b/output.go @@ -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 +} \ No newline at end of file diff --git a/rgb_256.go b/rgb_256.go new file mode 100644 index 0000000..52efe94 --- /dev/null +++ b/rgb_256.go @@ -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 +} \ No newline at end of file diff --git a/rgb_truecolor.go b/rgb_truecolor.go new file mode 100644 index 0000000..1d8db87 --- /dev/null +++ b/rgb_truecolor.go @@ -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} +) \ No newline at end of file diff --git a/service.go b/service.go new file mode 100644 index 0000000..2796ce7 --- /dev/null +++ b/service.go @@ -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 +} \ No newline at end of file diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..1ced627 --- /dev/null +++ b/terminal.go @@ -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() +} \ No newline at end of file diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..4442f1a --- /dev/null +++ b/tui/README.md @@ -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, + }) + tui.ScrollBar(content, 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}) +tui.ScrollBar(r, 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. diff --git a/tui/border.go b/tui/border.go new file mode 100644 index 0000000..4039950 --- /dev/null +++ b/tui/border.go @@ -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) +} \ No newline at end of file diff --git a/tui/button.go b/tui/button.go new file mode 100644 index 0000000..2dbc996 --- /dev/null +++ b/tui/button.go @@ -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 + } + } +} \ No newline at end of file diff --git a/tui/checkbox.go b/tui/checkbox.go new file mode 100644 index 0000000..9dadcf4 --- /dev/null +++ b/tui/checkbox.go @@ -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) +} \ No newline at end of file diff --git a/tui/coverage.go b/tui/coverage.go new file mode 100644 index 0000000..3ae29d0 --- /dev/null +++ b/tui/coverage.go @@ -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:]) +} \ No newline at end of file diff --git a/tui/dialog.go b/tui/dialog.go new file mode 100644 index 0000000..d942601 --- /dev/null +++ b/tui/dialog.go @@ -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) +} \ No newline at end of file diff --git a/tui/editor.go b/tui/editor.go new file mode 100644 index 0000000..618f993 --- /dev/null +++ b/tui/editor.go @@ -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 +} \ No newline at end of file diff --git a/tui/editor_state.go b/tui/editor_state.go new file mode 100644 index 0000000..f507e3b --- /dev/null +++ b/tui/editor_state.go @@ -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 +} \ No newline at end of file diff --git a/tui/form.go b/tui/form.go new file mode 100644 index 0000000..5a7ed36 --- /dev/null +++ b/tui/form.go @@ -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 +} \ No newline at end of file diff --git a/tui/input.go b/tui/input.go new file mode 100644 index 0000000..b5c28f8 --- /dev/null +++ b/tui/input.go @@ -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) + } +} \ No newline at end of file diff --git a/tui/keyvalue.go b/tui/keyvalue.go new file mode 100644 index 0000000..9559dd9 --- /dev/null +++ b/tui/keyvalue.go @@ -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) +} \ No newline at end of file diff --git a/tui/layout.go b/tui/layout.go new file mode 100644 index 0000000..024c0ed --- /dev/null +++ b/tui/layout.go @@ -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...) +} \ No newline at end of file diff --git a/tui/list.go b/tui/list.go new file mode 100644 index 0000000..d0ab82c --- /dev/null +++ b/tui/list.go @@ -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 +} \ No newline at end of file diff --git a/tui/masonry.go b/tui/masonry.go new file mode 100644 index 0000000..fa7598e --- /dev/null +++ b/tui/masonry.go @@ -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:]) +} \ No newline at end of file diff --git a/tui/modal.go b/tui/modal.go new file mode 100644 index 0000000..0d28c54 --- /dev/null +++ b/tui/modal.go @@ -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) +} \ No newline at end of file diff --git a/tui/overlay.go b/tui/overlay.go new file mode 100644 index 0000000..f4411c2 --- /dev/null +++ b/tui/overlay.go @@ -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 +} \ No newline at end of file diff --git a/tui/pane.go b/tui/pane.go new file mode 100644 index 0000000..3669baa --- /dev/null +++ b/tui/pane.go @@ -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) +} \ No newline at end of file diff --git a/tui/progress.go b/tui/progress.go new file mode 100644 index 0000000..c710032 --- /dev/null +++ b/tui/progress.go @@ -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) +} \ No newline at end of file diff --git a/tui/progress_overlay.go b/tui/progress_overlay.go new file mode 100644 index 0000000..193639a --- /dev/null +++ b/tui/progress_overlay.go @@ -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 +} \ No newline at end of file diff --git a/tui/region.go b/tui/region.go new file mode 100644 index 0000000..9da97f3 --- /dev/null +++ b/tui/region.go @@ -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 +} \ No newline at end of file diff --git a/tui/render.go b/tui/render.go new file mode 100644 index 0000000..937811a --- /dev/null +++ b/tui/render.go @@ -0,0 +1,83 @@ +package tui + +import "github.com/lixenwraith/terminal" + +// Spinner frames +var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} + +// 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) +} \ No newline at end of file diff --git a/tui/scroll.go b/tui/scroll.go new file mode 100644 index 0000000..182e803 --- /dev/null +++ b/tui/scroll.go @@ -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 +} \ No newline at end of file diff --git a/tui/scroll_state.go b/tui/scroll_state.go new file mode 100644 index 0000000..fcc6057 --- /dev/null +++ b/tui/scroll_state.go @@ -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) + } +} \ No newline at end of file diff --git a/tui/scrollbar.go b/tui/scrollbar.go new file mode 100644 index 0000000..5e9d199 --- /dev/null +++ b/tui/scrollbar.go @@ -0,0 +1,78 @@ +package tui + +import ( + "github.com/lixenwraith/terminal" +) + +// ScrollBar draws vertical scrollbar track with thumb +func ScrollBar(r Region, 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 := 0; y < trackH; y++ { + r.Cell(x, y, '│', fg, terminal.RGB{}, terminal.AttrDim) + } + return + } + + // Calculate thumb size and position + thumbH := (visible * trackH) / total + if thumbH < 1 { + thumbH = 1 + } + if thumbH > trackH { + thumbH = 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 := 0; y < trackH; y++ { + 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 ScrollIndicator(r Region, 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) +} \ No newline at end of file diff --git a/tui/sparkline.go b/tui/sparkline.go new file mode 100644 index 0000000..e8ef193 --- /dev/null +++ b/tui/sparkline.go @@ -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) + } +} \ No newline at end of file diff --git a/tui/status_bar.go b/tui/status_bar.go new file mode 100644 index 0000000..6037cae --- /dev/null +++ b/tui/status_bar.go @@ -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, + }) +} \ No newline at end of file diff --git a/tui/style.go b/tui/style.go new file mode 100644 index 0000000..1dd0d7b --- /dev/null +++ b/tui/style.go @@ -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 +} \ No newline at end of file diff --git a/tui/tab.go b/tui/tab.go new file mode 100644 index 0000000..dd0b996 --- /dev/null +++ b/tui/tab.go @@ -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 +} \ No newline at end of file diff --git a/tui/table.go b/tui/table.go new file mode 100644 index 0000000..f2d7ad8 --- /dev/null +++ b/tui/table.go @@ -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++ + } + } +} \ No newline at end of file diff --git a/tui/text.go b/tui/text.go new file mode 100644 index 0000000..aadc037 --- /dev/null +++ b/tui/text.go @@ -0,0 +1,190 @@ +package tui + +import ( + "unicode/utf8" +) + +// --- Length calculation --- + +// RuneLen returns display width (rune count, not byte count) +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) +} \ No newline at end of file diff --git a/tui/textfield.go b/tui/textfield.go new file mode 100644 index 0000000..6a79260 --- /dev/null +++ b/tui/textfield.go @@ -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 +} \ No newline at end of file diff --git a/tui/textfield_state.go b/tui/textfield_state.go new file mode 100644 index 0000000..903deff --- /dev/null +++ b/tui/textfield_state.go @@ -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 +} \ No newline at end of file diff --git a/tui/theme.go b/tui/theme.go new file mode 100644 index 0000000..02591e3 --- /dev/null +++ b/tui/theme.go @@ -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}, +} \ No newline at end of file diff --git a/tui/toast.go b/tui/toast.go new file mode 100644 index 0000000..d8489ef --- /dev/null +++ b/tui/toast.go @@ -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 +} \ No newline at end of file diff --git a/tui/tree-expansion.go b/tui/tree-expansion.go new file mode 100644 index 0000000..dbc60ed --- /dev/null +++ b/tui/tree-expansion.go @@ -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) +} \ No newline at end of file diff --git a/tui/tree.go b/tui/tree.go new file mode 100644 index 0000000..65d5099 --- /dev/null +++ b/tui/tree.go @@ -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 +} \ No newline at end of file diff --git a/tui/tree_builder.go b/tui/tree_builder.go new file mode 100644 index 0000000..a8a61b0 --- /dev/null +++ b/tui/tree_builder.go @@ -0,0 +1,67 @@ +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() { + n := len(b.nodes) + if n == 0 { + return + } + + // Track last seen index at each depth + depthLast := make(map[int]int) + + for i := 0; i < n; i++ { + depth := b.nodes[i].Depth + depthLast[depth] = i + + // When we see a node, all deeper nodes before it are "last" at their levels + // Actually we need to mark when depth decreases or changes + } + + // Simpler approach: scan backwards, mark last at each depth transition + for i := n - 1; i >= 0; i-- { + depth := b.nodes[i].Depth + + // Check if next node (i+1) is at same or lesser depth + if i == n-1 { + b.nodes[i].IsLast = true + } else { + nextDepth := b.nodes[i+1].Depth + b.nodes[i].IsLast = nextDepth <= depth + } + } +} \ No newline at end of file diff --git a/tui/tree_state.go b/tui/tree_state.go new file mode 100644 index 0000000..174f303 --- /dev/null +++ b/tui/tree_state.go @@ -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 +} \ No newline at end of file diff --git a/tui/viewport.go b/tui/viewport.go new file mode 100644 index 0000000..e446fc1 --- /dev/null +++ b/tui/viewport.go @@ -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 +} \ No newline at end of file