From c3ca2ebb63d92201d62ffec3cce7bc2aa97388b6 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sun, 12 Jul 2026 16:50:45 -0400 Subject: [PATCH] v0.1.0 initial commit --- .gitignore | 7 + LICENSE | 28 ++ README.md | 222 +++++++++++++++ ansi.go | 111 ++++++++ backend.go | 24 ++ backend_unix.go | 152 ++++++++++ backend_wasm.go | 147 ++++++++++ backend_windows.go | 166 +++++++++++ blend.go | 184 ++++++++++++ color.go | 116 ++++++++ detect_unix.go | 52 ++++ detect_wasm.go | 12 + detect_windows.go | 50 ++++ doc.go | 48 ++++ go.mod | 8 + go.sum | 4 + input.go | 601 ++++++++++++++++++++++++++++++++++++++++ key.go | 395 ++++++++++++++++++++++++++ keynames.go | 93 +++++++ mouse.go | 74 +++++ output.go | 456 ++++++++++++++++++++++++++++++ rgb_256.go | 93 +++++++ rgb_truecolor.go | 159 +++++++++++ service.go | 146 ++++++++++ terminal.go | 458 ++++++++++++++++++++++++++++++ tui/README.md | 206 ++++++++++++++ tui/border.go | 159 +++++++++++ tui/button.go | 127 +++++++++ tui/checkbox.go | 36 +++ tui/coverage.go | 67 +++++ tui/dialog.go | 324 ++++++++++++++++++++++ tui/editor.go | 197 +++++++++++++ tui/editor_state.go | 535 +++++++++++++++++++++++++++++++++++ tui/form.go | 228 +++++++++++++++ tui/input.go | 78 ++++++ tui/keyvalue.go | 199 +++++++++++++ tui/layout.go | 309 +++++++++++++++++++++ tui/list.go | 99 +++++++ tui/masonry.go | 170 ++++++++++++ tui/modal.go | 66 +++++ tui/overlay.go | 373 +++++++++++++++++++++++++ tui/pane.go | 69 +++++ tui/progress.go | 124 +++++++++ tui/progress_overlay.go | 466 +++++++++++++++++++++++++++++++ tui/region.go | 112 ++++++++ tui/render.go | 83 ++++++ tui/scroll.go | 76 +++++ tui/scroll_state.go | 115 ++++++++ tui/scrollbar.go | 78 ++++++ tui/sparkline.go | 131 +++++++++ tui/status_bar.go | 210 ++++++++++++++ tui/style.go | 22 ++ tui/tab.go | 131 +++++++++ tui/table.go | 180 ++++++++++++ tui/text.go | 190 +++++++++++++ tui/textfield.go | 171 ++++++++++++ tui/textfield_state.go | 280 +++++++++++++++++++ tui/theme.go | 63 +++++ tui/toast.go | 301 ++++++++++++++++++++ tui/tree-expansion.go | 64 +++++ tui/tree.go | 337 ++++++++++++++++++++++ tui/tree_builder.go | 67 +++++ tui/tree_state.go | 97 +++++++ tui/viewport.go | 107 +++++++ 64 files changed, 10453 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ansi.go create mode 100644 backend.go create mode 100644 backend_unix.go create mode 100644 backend_wasm.go create mode 100644 backend_windows.go create mode 100644 blend.go create mode 100644 color.go create mode 100644 detect_unix.go create mode 100644 detect_wasm.go create mode 100644 detect_windows.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 input.go create mode 100644 key.go create mode 100644 keynames.go create mode 100644 mouse.go create mode 100644 output.go create mode 100644 rgb_256.go create mode 100644 rgb_truecolor.go create mode 100644 service.go create mode 100644 terminal.go create mode 100644 tui/README.md create mode 100644 tui/border.go create mode 100644 tui/button.go create mode 100644 tui/checkbox.go create mode 100644 tui/coverage.go create mode 100644 tui/dialog.go create mode 100644 tui/editor.go create mode 100644 tui/editor_state.go create mode 100644 tui/form.go create mode 100644 tui/input.go create mode 100644 tui/keyvalue.go create mode 100644 tui/layout.go create mode 100644 tui/list.go create mode 100644 tui/masonry.go create mode 100644 tui/modal.go create mode 100644 tui/overlay.go create mode 100644 tui/pane.go create mode 100644 tui/progress.go create mode 100644 tui/progress_overlay.go create mode 100644 tui/region.go create mode 100644 tui/render.go create mode 100644 tui/scroll.go create mode 100644 tui/scroll_state.go create mode 100644 tui/scrollbar.go create mode 100644 tui/sparkline.go create mode 100644 tui/status_bar.go create mode 100644 tui/style.go create mode 100644 tui/tab.go create mode 100644 tui/table.go create mode 100644 tui/text.go create mode 100644 tui/textfield.go create mode 100644 tui/textfield_state.go create mode 100644 tui/theme.go create mode 100644 tui/toast.go create mode 100644 tui/tree-expansion.go create mode 100644 tui/tree.go create mode 100644 tui/tree_builder.go create mode 100644 tui/tree_state.go create mode 100644 tui/viewport.go 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