v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.idea
|
||||||
|
bin
|
||||||
|
dev
|
||||||
|
logs
|
||||||
|
log
|
||||||
|
catalog.txt
|
||||||
|
combined.txt
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
|
||||||
|
var (
|
||||||
|
// CSI sequences
|
||||||
|
csi = []byte("\x1b[")
|
||||||
|
csiEnd = []byte("m")
|
||||||
|
csiReset = []byte("\x1b[0m")
|
||||||
|
csiClear = []byte("\x1b[2J\x1b[H")
|
||||||
|
csiHome = []byte("\x1b[H")
|
||||||
|
csiRIS = []byte("\x1bc") // Reset to Initial GameState (emergency)
|
||||||
|
csiSGR0 = []byte("\x1b[0m")
|
||||||
|
|
||||||
|
// Cursor control
|
||||||
|
csiCursorHide = []byte("\x1b[?25l")
|
||||||
|
csiCursorShow = []byte("\x1b[?25h")
|
||||||
|
csiCursorPos = []byte("\x1b[") // followed by row;colH
|
||||||
|
|
||||||
|
// Screen modes
|
||||||
|
csiAltScreenEnter = []byte("\x1b[?1049h")
|
||||||
|
csiAltScreenExit = []byte("\x1b[?1049l")
|
||||||
|
// DECAWM: Auto-Wrap Mode
|
||||||
|
// ?7l disables wrapping (cursor sticks at right edge), preventing scroll when writing to bottom-right corner
|
||||||
|
csiAutoWrapOn = []byte("\x1b[?7h")
|
||||||
|
csiAutoWrapOff = []byte("\x1b[?7l")
|
||||||
|
|
||||||
|
// Color prefixes
|
||||||
|
csiFg256 = []byte("\x1b[38;5;") // followed by N;m
|
||||||
|
csiBg256 = []byte("\x1b[48;5;") // followed by N;m
|
||||||
|
csiFgRGB = []byte("\x1b[38;2;") // followed by R;G;B;m
|
||||||
|
csiBgRGB = []byte("\x1b[48;2;") // followed by R;G;B;m
|
||||||
|
csiDefaultFg = []byte("\x1b[39m")
|
||||||
|
csiDefaultBg = []byte("\x1b[49m")
|
||||||
|
|
||||||
|
// Attribute sequences
|
||||||
|
csiAttrBold = []byte("\x1b[1m")
|
||||||
|
csiAttrDim = []byte("\x1b[2m")
|
||||||
|
csiAttrItalic = []byte("\x1b[3m")
|
||||||
|
csiAttrUnderline = []byte("\x1b[4m")
|
||||||
|
csiAttrBlink = []byte("\x1b[5m")
|
||||||
|
csiAttrReverse = []byte("\x1b[7m")
|
||||||
|
|
||||||
|
// Mouse mode sequences (SGR 1006 for extended coordinates)
|
||||||
|
csiMouseClickOn = []byte("\x1b[?1000h") // Enable click reporting
|
||||||
|
csiMouseClickOff = []byte("\x1b[?1000l")
|
||||||
|
csiMouseDragOn = []byte("\x1b[?1002h") // Enable button-event (drag) tracking
|
||||||
|
csiMouseDragOff = []byte("\x1b[?1002l")
|
||||||
|
csiMouseMotionOn = []byte("\x1b[?1003h") // Enable any-event (all motion) tracking
|
||||||
|
csiMouseMotionOff = []byte("\x1b[?1003l")
|
||||||
|
csiMouseSGROn = []byte("\x1b[?1006h") // Enable SGR extended mode
|
||||||
|
csiMouseSGROff = []byte("\x1b[?1006l")
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeInt writes an integer without allocation
|
||||||
|
// Optimized for terminal values (0-255 common, 0-999 typical max)
|
||||||
|
func writeInt(w *bufio.Writer, n int) {
|
||||||
|
if n < 0 {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
if n < 10 {
|
||||||
|
w.WriteByte(byte(n) + '0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n < 100 {
|
||||||
|
w.WriteByte(byte(n/10) + '0')
|
||||||
|
w.WriteByte(byte(n%10) + '0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n < 1000 {
|
||||||
|
w.WriteByte(byte(n/100) + '0')
|
||||||
|
w.WriteByte(byte(n/10%10) + '0')
|
||||||
|
w.WriteByte(byte(n%10) + '0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback for >999 (rare)
|
||||||
|
var buf [5]byte
|
||||||
|
i := 4
|
||||||
|
for n > 0 {
|
||||||
|
buf[i] = byte(n%10) + '0'
|
||||||
|
n /= 10
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
w.Write(buf[i+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCursorPos writes cursor positioning sequence (0-indexed input)
|
||||||
|
func writeCursorPos(w *bufio.Writer, x, y int) {
|
||||||
|
w.Write(csiCursorPos)
|
||||||
|
writeInt(w, y+1)
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, x+1)
|
||||||
|
w.WriteByte('H')
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCursorForward writes cursor forward N positions
|
||||||
|
func writeCursorForward(w *bufio.Writer, n int) {
|
||||||
|
if n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
w.Write([]byte("\x1b[C"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(csi)
|
||||||
|
writeInt(w, n)
|
||||||
|
w.WriteByte('C')
|
||||||
|
}
|
||||||
+24
@@ -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))
|
||||||
|
}
|
||||||
+152
@@ -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)
|
||||||
|
}
|
||||||
+147
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
// Key represents a parsed input key
|
||||||
|
type Key uint16
|
||||||
|
|
||||||
|
// Key constants - designed for expansion
|
||||||
|
const (
|
||||||
|
KeyNone Key = iota
|
||||||
|
KeyRune // Printable character (check Event.Rune)
|
||||||
|
|
||||||
|
// Control keys
|
||||||
|
KeyEscape
|
||||||
|
KeyEnter
|
||||||
|
KeyTab
|
||||||
|
KeyBacktab // Shift+Tab
|
||||||
|
KeyShiftTab // Same as KeyBacktab,for clarity
|
||||||
|
KeyBackspace
|
||||||
|
KeyDelete
|
||||||
|
KeySpace
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
KeyUp
|
||||||
|
KeyDown
|
||||||
|
KeyLeft
|
||||||
|
KeyRight
|
||||||
|
KeyHome
|
||||||
|
KeyEnd
|
||||||
|
KeyPageUp
|
||||||
|
KeyPageDown
|
||||||
|
KeyInsert
|
||||||
|
|
||||||
|
// Function keys
|
||||||
|
KeyF1
|
||||||
|
KeyF2
|
||||||
|
KeyF3
|
||||||
|
KeyF4
|
||||||
|
KeyF5
|
||||||
|
KeyF6
|
||||||
|
KeyF7
|
||||||
|
KeyF8
|
||||||
|
KeyF9
|
||||||
|
KeyF10
|
||||||
|
KeyF11
|
||||||
|
KeyF12
|
||||||
|
|
||||||
|
// Ctrl+letter (Ctrl+A = 0x01, Ctrl+Z = 0x1A)
|
||||||
|
KeyCtrlA
|
||||||
|
KeyCtrlB
|
||||||
|
KeyCtrlC
|
||||||
|
KeyCtrlD
|
||||||
|
KeyCtrlE
|
||||||
|
KeyCtrlF
|
||||||
|
KeyCtrlG
|
||||||
|
KeyCtrlH // Often same as Backspace
|
||||||
|
KeyCtrlI // Often same as Tab
|
||||||
|
KeyCtrlJ // Often same as Enter
|
||||||
|
KeyCtrlK
|
||||||
|
KeyCtrlL
|
||||||
|
KeyCtrlM // Often same as Enter
|
||||||
|
KeyCtrlN
|
||||||
|
KeyCtrlO
|
||||||
|
KeyCtrlP
|
||||||
|
KeyCtrlQ
|
||||||
|
KeyCtrlR
|
||||||
|
KeyCtrlS
|
||||||
|
KeyCtrlT
|
||||||
|
KeyCtrlU
|
||||||
|
KeyCtrlV
|
||||||
|
KeyCtrlW
|
||||||
|
KeyCtrlX
|
||||||
|
KeyCtrlY
|
||||||
|
KeyCtrlZ
|
||||||
|
|
||||||
|
// Ctrl+special
|
||||||
|
KeyCtrlSpace // Ctrl+@ / Ctrl+Space (Ctrl+@ produces NUL byte 0x00)
|
||||||
|
KeyCtrlBackslash
|
||||||
|
KeyCtrlBracketLeft
|
||||||
|
KeyCtrlBracketRight
|
||||||
|
KeyCtrlCaret
|
||||||
|
KeyCtrlUnderscore
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modifier flags
|
||||||
|
type Modifier uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModNone Modifier = 0
|
||||||
|
ModShift Modifier = 1 << 0
|
||||||
|
ModAlt Modifier = 1 << 1
|
||||||
|
ModCtrl Modifier = 1 << 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// escapeSequence maps escape sequences to keys
|
||||||
|
// Key: sequence after ESC [ (e.g., "A" for up arrow)
|
||||||
|
type escapeSequence struct {
|
||||||
|
seq string
|
||||||
|
key Key
|
||||||
|
mod Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known escape sequences (CSI sequences: ESC [ ...)
|
||||||
|
var csiSequences = []escapeSequence{
|
||||||
|
// Arrow keys
|
||||||
|
{"A", KeyUp, ModNone},
|
||||||
|
{"B", KeyDown, ModNone},
|
||||||
|
{"C", KeyRight, ModNone},
|
||||||
|
{"D", KeyLeft, ModNone},
|
||||||
|
{"Z", KeyBacktab, ModShift}, // Shift+Tab
|
||||||
|
|
||||||
|
// Arrow keys with modifiers (xterm style: ESC [ 1 ; mod X)
|
||||||
|
{"1;2A", KeyUp, ModShift},
|
||||||
|
{"1;2B", KeyDown, ModShift},
|
||||||
|
{"1;2C", KeyRight, ModShift},
|
||||||
|
{"1;2D", KeyLeft, ModShift},
|
||||||
|
{"1;3A", KeyUp, ModAlt},
|
||||||
|
{"1;3B", KeyDown, ModAlt},
|
||||||
|
{"1;3C", KeyRight, ModAlt},
|
||||||
|
{"1;3D", KeyLeft, ModAlt},
|
||||||
|
{"1;5A", KeyUp, ModCtrl},
|
||||||
|
{"1;5B", KeyDown, ModCtrl},
|
||||||
|
{"1;5C", KeyRight, ModCtrl},
|
||||||
|
{"1;5D", KeyLeft, ModCtrl},
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
{"H", KeyHome, ModNone},
|
||||||
|
{"F", KeyEnd, ModNone},
|
||||||
|
{"1~", KeyHome, ModNone},
|
||||||
|
{"4~", KeyEnd, ModNone},
|
||||||
|
{"5~", KeyPageUp, ModNone},
|
||||||
|
{"6~", KeyPageDown, ModNone},
|
||||||
|
{"2~", KeyInsert, ModNone},
|
||||||
|
{"3~", KeyDelete, ModNone},
|
||||||
|
{"7~", KeyHome, ModNone},
|
||||||
|
{"8~", KeyEnd, ModNone},
|
||||||
|
|
||||||
|
// Function keys (xterm)
|
||||||
|
{"11~", KeyF1, ModNone},
|
||||||
|
{"12~", KeyF2, ModNone},
|
||||||
|
{"13~", KeyF3, ModNone},
|
||||||
|
{"14~", KeyF4, ModNone},
|
||||||
|
{"15~", KeyF5, ModNone},
|
||||||
|
{"17~", KeyF6, ModNone},
|
||||||
|
{"18~", KeyF7, ModNone},
|
||||||
|
{"19~", KeyF8, ModNone},
|
||||||
|
{"20~", KeyF9, ModNone},
|
||||||
|
{"21~", KeyF10, ModNone},
|
||||||
|
{"23~", KeyF11, ModNone},
|
||||||
|
{"24~", KeyF12, ModNone},
|
||||||
|
|
||||||
|
// Function keys (vt style)
|
||||||
|
{"[A", KeyF1, ModNone},
|
||||||
|
{"[B", KeyF2, ModNone},
|
||||||
|
{"[C", KeyF3, ModNone},
|
||||||
|
{"[D", KeyF4, ModNone},
|
||||||
|
{"[E", KeyF5, ModNone},
|
||||||
|
|
||||||
|
// Shift+Navigation (mod=2)
|
||||||
|
{"1;2H", KeyHome, ModShift},
|
||||||
|
{"1;2F", KeyEnd, ModShift},
|
||||||
|
{"2;2~", KeyInsert, ModShift},
|
||||||
|
{"3;2~", KeyDelete, ModShift},
|
||||||
|
{"5;2~", KeyPageUp, ModShift},
|
||||||
|
{"6;2~", KeyPageDown, ModShift},
|
||||||
|
|
||||||
|
// Alt+Arrows (mod=3) - already have 1;3A-D
|
||||||
|
// Alt+Navigation (mod=3)
|
||||||
|
{"1;3H", KeyHome, ModAlt},
|
||||||
|
{"1;3F", KeyEnd, ModAlt},
|
||||||
|
{"2;3~", KeyInsert, ModAlt},
|
||||||
|
{"3;3~", KeyDelete, ModAlt},
|
||||||
|
{"5;3~", KeyPageUp, ModAlt},
|
||||||
|
{"6;3~", KeyPageDown, ModAlt},
|
||||||
|
|
||||||
|
// Shift+Alt (mod=4)
|
||||||
|
{"1;4A", KeyUp, ModShift | ModAlt},
|
||||||
|
{"1;4B", KeyDown, ModShift | ModAlt},
|
||||||
|
{"1;4C", KeyRight, ModShift | ModAlt},
|
||||||
|
{"1;4D", KeyLeft, ModShift | ModAlt},
|
||||||
|
{"1;4H", KeyHome, ModShift | ModAlt},
|
||||||
|
{"1;4F", KeyEnd, ModShift | ModAlt},
|
||||||
|
{"2;4~", KeyInsert, ModShift | ModAlt},
|
||||||
|
{"3;4~", KeyDelete, ModShift | ModAlt},
|
||||||
|
{"5;4~", KeyPageUp, ModShift | ModAlt},
|
||||||
|
{"6;4~", KeyPageDown, ModShift | ModAlt},
|
||||||
|
|
||||||
|
// Ctrl+Arrows (mod=5) - already have 1;5A-D
|
||||||
|
// Ctrl+Navigation (mod=5)
|
||||||
|
{"1;5H", KeyHome, ModCtrl},
|
||||||
|
{"1;5F", KeyEnd, ModCtrl},
|
||||||
|
{"2;5~", KeyInsert, ModCtrl},
|
||||||
|
{"3;5~", KeyDelete, ModCtrl},
|
||||||
|
{"5;5~", KeyPageUp, ModCtrl},
|
||||||
|
{"6;5~", KeyPageDown, ModCtrl},
|
||||||
|
|
||||||
|
// Shift+Ctrl (mod=6)
|
||||||
|
{"1;6A", KeyUp, ModShift | ModCtrl},
|
||||||
|
{"1;6B", KeyDown, ModShift | ModCtrl},
|
||||||
|
{"1;6C", KeyRight, ModShift | ModCtrl},
|
||||||
|
{"1;6D", KeyLeft, ModShift | ModCtrl},
|
||||||
|
{"1;6H", KeyHome, ModShift | ModCtrl},
|
||||||
|
{"1;6F", KeyEnd, ModShift | ModCtrl},
|
||||||
|
{"2;6~", KeyInsert, ModShift | ModCtrl},
|
||||||
|
{"3;6~", KeyDelete, ModShift | ModCtrl},
|
||||||
|
{"5;6~", KeyPageUp, ModShift | ModCtrl},
|
||||||
|
{"6;6~", KeyPageDown, ModShift | ModCtrl},
|
||||||
|
|
||||||
|
// Alt+Ctrl (mod=7)
|
||||||
|
{"1;7A", KeyUp, ModAlt | ModCtrl},
|
||||||
|
{"1;7B", KeyDown, ModAlt | ModCtrl},
|
||||||
|
{"1;7C", KeyRight, ModAlt | ModCtrl},
|
||||||
|
{"1;7D", KeyLeft, ModAlt | ModCtrl},
|
||||||
|
{"1;7H", KeyHome, ModAlt | ModCtrl},
|
||||||
|
{"1;7F", KeyEnd, ModAlt | ModCtrl},
|
||||||
|
{"2;7~", KeyInsert, ModAlt | ModCtrl},
|
||||||
|
{"3;7~", KeyDelete, ModAlt | ModCtrl},
|
||||||
|
{"5;7~", KeyPageUp, ModAlt | ModCtrl},
|
||||||
|
{"6;7~", KeyPageDown, ModAlt | ModCtrl},
|
||||||
|
|
||||||
|
// Shift+Alt+Ctrl (mod=8)
|
||||||
|
{"1;8A", KeyUp, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8B", KeyDown, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8C", KeyRight, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8D", KeyLeft, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8H", KeyHome, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8F", KeyEnd, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"2;8~", KeyInsert, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"3;8~", KeyDelete, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"5;8~", KeyPageUp, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"6;8~", KeyPageDown, ModShift | ModAlt | ModCtrl},
|
||||||
|
|
||||||
|
// F-keys with modifiers (CSI style: ESC [ 1 ; mod P/Q/R/S for F1-F4)
|
||||||
|
// F1-F4 with Shift (mod=2)
|
||||||
|
{"1;2P", KeyF1, ModShift},
|
||||||
|
{"1;2Q", KeyF2, ModShift},
|
||||||
|
{"1;2R", KeyF3, ModShift},
|
||||||
|
{"1;2S", KeyF4, ModShift},
|
||||||
|
// F1-F4 with Alt (mod=3)
|
||||||
|
{"1;3P", KeyF1, ModAlt},
|
||||||
|
{"1;3Q", KeyF2, ModAlt},
|
||||||
|
{"1;3R", KeyF3, ModAlt},
|
||||||
|
{"1;3S", KeyF4, ModAlt},
|
||||||
|
// F1-F4 with Ctrl (mod=5)
|
||||||
|
{"1;5P", KeyF1, ModCtrl},
|
||||||
|
{"1;5Q", KeyF2, ModCtrl},
|
||||||
|
{"1;5R", KeyF3, ModCtrl},
|
||||||
|
{"1;5S", KeyF4, ModCtrl},
|
||||||
|
// F1-F4 with Shift+Alt (mod=4)
|
||||||
|
{"1;4P", KeyF1, ModShift | ModAlt},
|
||||||
|
{"1;4Q", KeyF2, ModShift | ModAlt},
|
||||||
|
{"1;4R", KeyF3, ModShift | ModAlt},
|
||||||
|
{"1;4S", KeyF4, ModShift | ModAlt},
|
||||||
|
// F1-F4 with Shift+Ctrl (mod=6)
|
||||||
|
{"1;6P", KeyF1, ModShift | ModCtrl},
|
||||||
|
{"1;6Q", KeyF2, ModShift | ModCtrl},
|
||||||
|
{"1;6R", KeyF3, ModShift | ModCtrl},
|
||||||
|
{"1;6S", KeyF4, ModShift | ModCtrl},
|
||||||
|
// F1-F4 with Alt+Ctrl (mod=7)
|
||||||
|
{"1;7P", KeyF1, ModAlt | ModCtrl},
|
||||||
|
{"1;7Q", KeyF2, ModAlt | ModCtrl},
|
||||||
|
{"1;7R", KeyF3, ModAlt | ModCtrl},
|
||||||
|
{"1;7S", KeyF4, ModAlt | ModCtrl},
|
||||||
|
// F1-F4 with Shift+Alt+Ctrl (mod=8)
|
||||||
|
{"1;8P", KeyF1, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8Q", KeyF2, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8R", KeyF3, ModShift | ModAlt | ModCtrl},
|
||||||
|
{"1;8S", KeyF4, ModShift | ModAlt | ModCtrl},
|
||||||
|
|
||||||
|
// F5-F12 with modifiers (ESC [ N ; mod ~)
|
||||||
|
// F5 (15~)
|
||||||
|
{"15;2~", KeyF5, ModShift},
|
||||||
|
{"15;3~", KeyF5, ModAlt},
|
||||||
|
{"15;4~", KeyF5, ModShift | ModAlt},
|
||||||
|
{"15;5~", KeyF5, ModCtrl},
|
||||||
|
{"15;6~", KeyF5, ModShift | ModCtrl},
|
||||||
|
{"15;7~", KeyF5, ModAlt | ModCtrl},
|
||||||
|
{"15;8~", KeyF5, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F6 (17~)
|
||||||
|
{"17;2~", KeyF6, ModShift},
|
||||||
|
{"17;3~", KeyF6, ModAlt},
|
||||||
|
{"17;4~", KeyF6, ModShift | ModAlt},
|
||||||
|
{"17;5~", KeyF6, ModCtrl},
|
||||||
|
{"17;6~", KeyF6, ModShift | ModCtrl},
|
||||||
|
{"17;7~", KeyF6, ModAlt | ModCtrl},
|
||||||
|
{"17;8~", KeyF6, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F7 (18~)
|
||||||
|
{"18;2~", KeyF7, ModShift},
|
||||||
|
{"18;3~", KeyF7, ModAlt},
|
||||||
|
{"18;4~", KeyF7, ModShift | ModAlt},
|
||||||
|
{"18;5~", KeyF7, ModCtrl},
|
||||||
|
{"18;6~", KeyF7, ModShift | ModCtrl},
|
||||||
|
{"18;7~", KeyF7, ModAlt | ModCtrl},
|
||||||
|
{"18;8~", KeyF7, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F8 (19~)
|
||||||
|
{"19;2~", KeyF8, ModShift},
|
||||||
|
{"19;3~", KeyF8, ModAlt},
|
||||||
|
{"19;4~", KeyF8, ModShift | ModAlt},
|
||||||
|
{"19;5~", KeyF8, ModCtrl},
|
||||||
|
{"19;6~", KeyF8, ModShift | ModCtrl},
|
||||||
|
{"19;7~", KeyF8, ModAlt | ModCtrl},
|
||||||
|
{"19;8~", KeyF8, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F9 (20~)
|
||||||
|
{"20;2~", KeyF9, ModShift},
|
||||||
|
{"20;3~", KeyF9, ModAlt},
|
||||||
|
{"20;4~", KeyF9, ModShift | ModAlt},
|
||||||
|
{"20;5~", KeyF9, ModCtrl},
|
||||||
|
{"20;6~", KeyF9, ModShift | ModCtrl},
|
||||||
|
{"20;7~", KeyF9, ModAlt | ModCtrl},
|
||||||
|
{"20;8~", KeyF9, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F10 (21~)
|
||||||
|
{"21;2~", KeyF10, ModShift},
|
||||||
|
{"21;3~", KeyF10, ModAlt},
|
||||||
|
{"21;4~", KeyF10, ModShift | ModAlt},
|
||||||
|
{"21;5~", KeyF10, ModCtrl},
|
||||||
|
{"21;6~", KeyF10, ModShift | ModCtrl},
|
||||||
|
{"21;7~", KeyF10, ModAlt | ModCtrl},
|
||||||
|
{"21;8~", KeyF10, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F11 (23~)
|
||||||
|
{"23;2~", KeyF11, ModShift},
|
||||||
|
{"23;3~", KeyF11, ModAlt},
|
||||||
|
{"23;4~", KeyF11, ModShift | ModAlt},
|
||||||
|
{"23;5~", KeyF11, ModCtrl},
|
||||||
|
{"23;6~", KeyF11, ModShift | ModCtrl},
|
||||||
|
{"23;7~", KeyF11, ModAlt | ModCtrl},
|
||||||
|
{"23;8~", KeyF11, ModShift | ModAlt | ModCtrl},
|
||||||
|
// F12 (24~)
|
||||||
|
{"24;2~", KeyF12, ModShift},
|
||||||
|
{"24;3~", KeyF12, ModAlt},
|
||||||
|
{"24;4~", KeyF12, ModShift | ModAlt},
|
||||||
|
{"24;5~", KeyF12, ModCtrl},
|
||||||
|
{"24;6~", KeyF12, ModShift | ModCtrl},
|
||||||
|
{"24;7~", KeyF12, ModAlt | ModCtrl},
|
||||||
|
{"24;8~", KeyF12, ModShift | ModAlt | ModCtrl},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SS3 sequences (ESC O ...)
|
||||||
|
var ss3Sequences = []escapeSequence{
|
||||||
|
{"A", KeyUp, ModNone},
|
||||||
|
{"B", KeyDown, ModNone},
|
||||||
|
{"C", KeyRight, ModNone},
|
||||||
|
{"D", KeyLeft, ModNone},
|
||||||
|
{"H", KeyHome, ModNone},
|
||||||
|
{"F", KeyEnd, ModNone},
|
||||||
|
{"P", KeyF1, ModNone},
|
||||||
|
{"Q", KeyF2, ModNone},
|
||||||
|
{"R", KeyF3, ModNone},
|
||||||
|
{"S", KeyF4, ModNone},
|
||||||
|
|
||||||
|
// Numeric Keypad (Application Mode)
|
||||||
|
{"M", KeyEnter, ModNone}, // Keypad Enter
|
||||||
|
{"X", KeyRune, ModNone}, // Keypad = (some terminals)
|
||||||
|
{"j", KeyRune, ModNone}, // Keypad *
|
||||||
|
{"k", KeyRune, ModNone}, // Keypad +
|
||||||
|
{"l", KeyRune, ModNone}, // Keypad ,
|
||||||
|
{"m", KeyRune, ModNone}, // Keypad -
|
||||||
|
{"n", KeyRune, ModNone}, // Keypad .
|
||||||
|
{"o", KeyRune, ModNone}, // Keypad /
|
||||||
|
{"p", KeyRune, ModNone}, // Keypad 0
|
||||||
|
{"q", KeyRune, ModNone}, // Keypad 1
|
||||||
|
{"r", KeyRune, ModNone}, // Keypad 2
|
||||||
|
{"s", KeyRune, ModNone}, // Keypad 3
|
||||||
|
{"t", KeyRune, ModNone}, // Keypad 4
|
||||||
|
{"u", KeyRune, ModNone}, // Keypad 5
|
||||||
|
{"v", KeyRune, ModNone}, // Keypad 6
|
||||||
|
{"w", KeyRune, ModNone}, // Keypad 7
|
||||||
|
{"x", KeyRune, ModNone}, // Keypad 8
|
||||||
|
{"y", KeyRune, ModNone}, // Keypad 9
|
||||||
|
}
|
||||||
|
|
||||||
|
var csiMap = buildSequenceMap(csiSequences)
|
||||||
|
var ss3Map = buildSequenceMap(ss3Sequences)
|
||||||
|
|
||||||
|
func buildSequenceMap(seqs []escapeSequence) map[string]escapeSequence {
|
||||||
|
m := make(map[string]escapeSequence, len(seqs))
|
||||||
|
for _, s := range seqs {
|
||||||
|
m[s.seq] = s
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupCSI performs zero-alloc map lookup via compiler optimization
|
||||||
|
// The string([]byte) conversion inline in map access does not allocate
|
||||||
|
func lookupCSI(seq []byte) (Key, Modifier, bool) {
|
||||||
|
if s, ok := csiMap[string(seq)]; ok {
|
||||||
|
return s.key, s.mod, true
|
||||||
|
}
|
||||||
|
return KeyNone, ModNone, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupSS3 performs zero-alloc map lookup
|
||||||
|
func lookupSS3(seq []byte) (Key, Modifier, bool) {
|
||||||
|
if s, ok := ss3Map[string(seq)]; ok {
|
||||||
|
return s.key, s.mod, true
|
||||||
|
}
|
||||||
|
return KeyNone, ModNone, false
|
||||||
|
}
|
||||||
+93
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// outputBuffer manages double-buffered terminal output with diffing
|
||||||
|
type outputBuffer struct {
|
||||||
|
front []Cell
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
colorMode ColorMode
|
||||||
|
writer *bufio.Writer
|
||||||
|
|
||||||
|
cursorX int
|
||||||
|
cursorY int
|
||||||
|
cursorValid bool
|
||||||
|
|
||||||
|
// Style state for coalescing
|
||||||
|
lastFg RGB
|
||||||
|
lastBg RGB
|
||||||
|
lastAttr Attr
|
||||||
|
lastValid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerAdapter adapts Backend to io.Writer for bufio
|
||||||
|
type writerAdapter struct {
|
||||||
|
b Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wa writerAdapter) Write(p []byte) (int, error) {
|
||||||
|
err := wa.b.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOutputBuffer creates a new output buffer
|
||||||
|
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
||||||
|
// Use 128KB buffer for minimal calls to backend
|
||||||
|
adapter := writerAdapter{b: backend}
|
||||||
|
return &outputBuffer{
|
||||||
|
writer: bufio.NewWriterSize(adapter, 131072),
|
||||||
|
colorMode: colorMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize updates buffer dimensions
|
||||||
|
func (o *outputBuffer) resize(width, height int) {
|
||||||
|
size := width * height
|
||||||
|
if cap(o.front) < size {
|
||||||
|
o.front = make([]Cell, size)
|
||||||
|
} else {
|
||||||
|
o.front = o.front[:size]
|
||||||
|
}
|
||||||
|
o.width = width
|
||||||
|
o.height = height
|
||||||
|
|
||||||
|
for i := range o.front {
|
||||||
|
o.front[i] = Cell{Rune: 0}
|
||||||
|
}
|
||||||
|
o.lastValid = false
|
||||||
|
o.cursorValid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellEqual compares two cells for equality (standalone for inlining)
|
||||||
|
func cellEqual(a, b Cell) bool {
|
||||||
|
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
|
||||||
|
return a.Rune == b.Rune &&
|
||||||
|
a.Bg == b.Bg &&
|
||||||
|
a.Fg == b.Fg &&
|
||||||
|
a.Attrs == b.Attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush writes the back buffer to terminal, diffing against front buffer
|
||||||
|
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||||
|
if width != o.width || height != o.height {
|
||||||
|
o.resize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := width * height
|
||||||
|
if len(cells) < expectedSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := o.writer
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
rowStart := y * width
|
||||||
|
|
||||||
|
// Early termination: find last dirty cell in row (scan backward)
|
||||||
|
rowEnd := width
|
||||||
|
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
||||||
|
rowEnd--
|
||||||
|
}
|
||||||
|
if rowEnd == 0 {
|
||||||
|
continue // Entire row unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
x := 0
|
||||||
|
for x < rowEnd {
|
||||||
|
idx := rowStart + x
|
||||||
|
|
||||||
|
if cellEqual(cells[idx], o.front[idx]) {
|
||||||
|
x++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found dirty cell - check for small gaps ahead to potentially merge segments
|
||||||
|
segStart := x
|
||||||
|
segEnd := x + 1
|
||||||
|
|
||||||
|
// Extend segment through small gaps (≤3 unchanged cells)
|
||||||
|
for segEnd < rowEnd {
|
||||||
|
// Find gap size
|
||||||
|
gapStart := segEnd
|
||||||
|
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
|
||||||
|
gapStart++
|
||||||
|
}
|
||||||
|
gapSize := gapStart - segEnd
|
||||||
|
|
||||||
|
if gapSize == 0 {
|
||||||
|
// No gap, extend to next unchanged
|
||||||
|
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||||
|
segEnd++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if gapSize > 3 {
|
||||||
|
break // Gap too large, end segment here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move
|
||||||
|
gapCompatible := true
|
||||||
|
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment
|
||||||
|
|
||||||
|
for k := 0; k < gapSize; k++ {
|
||||||
|
gCell := cells[rowStart+segEnd+k]
|
||||||
|
// Strict equality on style/color to ensure no SGR emission
|
||||||
|
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
|
||||||
|
gapCompatible = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gapCompatible {
|
||||||
|
break // Gap has different style, cheaper to jump
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's more dirty content after gap
|
||||||
|
if gapStart >= rowEnd {
|
||||||
|
break // Gap extends to row end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small gap with content after - include gap in segment
|
||||||
|
segEnd = gapStart
|
||||||
|
// Continue to find more dirty cells
|
||||||
|
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||||
|
segEnd++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positions cursor to segment start
|
||||||
|
o.moveCursorTo(w, segStart, y)
|
||||||
|
|
||||||
|
// Write segment [segStart, segEnd)
|
||||||
|
for sx := segStart; sx < segEnd; sx++ {
|
||||||
|
cidx := rowStart + sx
|
||||||
|
c := cells[cidx]
|
||||||
|
|
||||||
|
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
||||||
|
|
||||||
|
r := c.Rune
|
||||||
|
if r == 0 {
|
||||||
|
r = ' '
|
||||||
|
}
|
||||||
|
if r < 0x80 {
|
||||||
|
w.WriteByte(byte(r))
|
||||||
|
} else {
|
||||||
|
w.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
o.front[cidx] = c
|
||||||
|
o.cursorX++
|
||||||
|
}
|
||||||
|
|
||||||
|
x = segEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(csiSGR0)
|
||||||
|
o.lastValid = false
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursorForwardCost returns byte cost of cursor forward sequence
|
||||||
|
func cursorForwardCost(n int) int {
|
||||||
|
if n == 1 {
|
||||||
|
return 3 // \x1b[C
|
||||||
|
}
|
||||||
|
return 3 + digitCount(n) // \x1b[nC
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursorAbsoluteCost returns byte cost of absolute cursor position
|
||||||
|
func cursorAbsoluteCost(x, y int) int {
|
||||||
|
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
|
||||||
|
return 4 + digitCount(y+1) + digitCount(x+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// digitCount returns number of decimal digits in n
|
||||||
|
func digitCount(n int) int {
|
||||||
|
if n < 10 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if n < 100 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if n < 1000 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveCursorTo positions cursor using most efficient method
|
||||||
|
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
||||||
|
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moved := false
|
||||||
|
if o.cursorValid && o.cursorY == y && x > o.cursorX {
|
||||||
|
gap := x - o.cursorX
|
||||||
|
fwdCost := cursorForwardCost(gap)
|
||||||
|
absCost := cursorAbsoluteCost(x, y)
|
||||||
|
|
||||||
|
if fwdCost < absCost {
|
||||||
|
writeCursorForward(w, gap)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !moved {
|
||||||
|
writeCursorPos(w, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
o.cursorX = x
|
||||||
|
o.cursorY = y
|
||||||
|
o.cursorValid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
||||||
|
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg RGB, attr Attr) {
|
||||||
|
// Check what changed
|
||||||
|
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFg256) != (o.lastAttr&AttrFg256)
|
||||||
|
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBg256) != (o.lastAttr&AttrBg256)
|
||||||
|
styleAttr := attr & AttrStyle
|
||||||
|
lastStyleAttr := o.lastAttr & AttrStyle
|
||||||
|
attrChanged := !o.lastValid || styleAttr != lastStyleAttr
|
||||||
|
|
||||||
|
if !fgChanged && !bgChanged && !attrChanged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If attributes changed, must reset first
|
||||||
|
if attrChanged {
|
||||||
|
w.Write(csi) // \x1b[
|
||||||
|
first := true
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
w.WriteByte('0')
|
||||||
|
first = false
|
||||||
|
|
||||||
|
// Style attributes
|
||||||
|
if styleAttr&AttrBold != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('1')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if styleAttr&AttrDim != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('2')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if styleAttr&AttrItalic != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('3')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if styleAttr&AttrUnderline != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('4')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if styleAttr&AttrBlink != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('5')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if styleAttr&AttrReverse != 0 {
|
||||||
|
if !first {
|
||||||
|
w.WriteByte(';')
|
||||||
|
}
|
||||||
|
w.WriteByte('7')
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
|
||||||
|
o.writeFgInline(w, fg, attr)
|
||||||
|
o.writeBgInline(w, bg, attr)
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else {
|
||||||
|
// Only colors changed, emit minimal sequence
|
||||||
|
if fgChanged && bgChanged {
|
||||||
|
w.Write(csi)
|
||||||
|
o.writeFgInline(w, fg, attr)
|
||||||
|
o.writeBgInline(w, bg, attr)
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else if fgChanged {
|
||||||
|
o.writeFgFull(w, fg, attr)
|
||||||
|
} else if bgChanged {
|
||||||
|
o.writeBgFull(w, bg, attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
o.lastFg = fg
|
||||||
|
o.lastBg = bg
|
||||||
|
o.lastAttr = attr
|
||||||
|
o.lastValid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
|
||||||
|
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg RGB, attr Attr) {
|
||||||
|
w.WriteByte(';')
|
||||||
|
if attr&AttrFg256 != 0 {
|
||||||
|
// 256-color: 38;5;N
|
||||||
|
w.Write([]byte("38;5;"))
|
||||||
|
writeInt(w, int(fg.R))
|
||||||
|
} else if o.colorMode == ColorModeTrueColor {
|
||||||
|
// True color: 38;2;R;G;B
|
||||||
|
w.Write([]byte("38;2;"))
|
||||||
|
writeInt(w, int(fg.R))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(fg.G))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(fg.B))
|
||||||
|
} else {
|
||||||
|
// Fallback 256: 38;5;N
|
||||||
|
w.Write([]byte("38;5;"))
|
||||||
|
writeInt(w, int(RGBTo256(fg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
|
||||||
|
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg RGB, attr Attr) {
|
||||||
|
w.WriteByte(';')
|
||||||
|
if attr&AttrBg256 != 0 {
|
||||||
|
// 256-color: 48;5;N
|
||||||
|
w.Write([]byte("48;5;"))
|
||||||
|
writeInt(w, int(bg.R))
|
||||||
|
} else if o.colorMode == ColorModeTrueColor {
|
||||||
|
// True color: 48;2;R;G;B
|
||||||
|
w.Write([]byte("48;2;"))
|
||||||
|
writeInt(w, int(bg.R))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(bg.G))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(bg.B))
|
||||||
|
} else {
|
||||||
|
// Fallback 256: 48;5;N
|
||||||
|
w.Write([]byte("48;5;"))
|
||||||
|
writeInt(w, int(RGBTo256(bg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFgFull writes complete fg color sequence
|
||||||
|
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg RGB, attr Attr) {
|
||||||
|
if attr&AttrFg256 != 0 {
|
||||||
|
w.Write(csiFg256)
|
||||||
|
writeInt(w, int(fg.R))
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else if o.colorMode == ColorModeTrueColor {
|
||||||
|
w.Write(csiFgRGB)
|
||||||
|
writeInt(w, int(fg.R))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(fg.G))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(fg.B))
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else {
|
||||||
|
w.Write(csiFg256)
|
||||||
|
writeInt(w, int(RGBTo256(fg)))
|
||||||
|
w.WriteByte('m')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBgFull writes complete bg color sequence
|
||||||
|
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg RGB, attr Attr) {
|
||||||
|
if attr&AttrBg256 != 0 {
|
||||||
|
w.Write(csiBg256)
|
||||||
|
writeInt(w, int(bg.R))
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else if o.colorMode == ColorModeTrueColor {
|
||||||
|
w.Write(csiBgRGB)
|
||||||
|
writeInt(w, int(bg.R))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(bg.G))
|
||||||
|
w.WriteByte(';')
|
||||||
|
writeInt(w, int(bg.B))
|
||||||
|
w.WriteByte('m')
|
||||||
|
} else {
|
||||||
|
w.Write(csiBg256)
|
||||||
|
writeInt(w, int(RGBTo256(bg)))
|
||||||
|
w.WriteByte('m')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceFullRedraw clears front buffer to force complete redraw
|
||||||
|
func (o *outputBuffer) forceFullRedraw() {
|
||||||
|
for i := range o.front {
|
||||||
|
o.front[i] = Cell{Rune: 0}
|
||||||
|
}
|
||||||
|
o.lastValid = false
|
||||||
|
o.cursorValid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear writes a clear screen with specified background
|
||||||
|
func (o *outputBuffer) clear(bg RGB) {
|
||||||
|
w := o.writer
|
||||||
|
w.Write(csiSGR0)
|
||||||
|
o.writeBgFull(w, bg, 0)
|
||||||
|
w.Write(csiClear)
|
||||||
|
|
||||||
|
o.lastValid = false
|
||||||
|
o.cursorValid = false
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
for i := range o.front {
|
||||||
|
o.front[i] = Cell{Rune: ' ', Bg: bg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateCursor marks cursor position as unknown
|
||||||
|
func (o *outputBuffer) invalidateCursor() {
|
||||||
|
o.cursorValid = false
|
||||||
|
}
|
||||||
+93
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
// Generic TrueColor palette — pure RGB definitions without game semantics
|
||||||
|
// Game systems and renderers reference these via aliases in their own parameter files
|
||||||
|
//
|
||||||
|
// Naming: standard color names where RGB closely matches (CSS, X11, Pantone-adjacent),
|
||||||
|
// descriptive compound names otherwise. Ordered dark-to-light within each hue group.
|
||||||
|
|
||||||
|
var (
|
||||||
|
// --- Achromatic ---
|
||||||
|
Black = RGB{0, 0, 0}
|
||||||
|
Charcoal = RGB{5, 5, 5}
|
||||||
|
Obsidian = RGB{20, 20, 30} // Blue-black
|
||||||
|
Gunmetal = RGB{26, 27, 38} // Blue-tinted near-black
|
||||||
|
DarkSlate = RGB{35, 36, 48} // Blue-gray near-black
|
||||||
|
DimGray = RGB{55, 55, 55}
|
||||||
|
DarkGray = RGB{60, 60, 60}
|
||||||
|
IronGray = RGB{80, 80, 80}
|
||||||
|
SlateGray = RGB{80, 80, 90} // Cool-tinted
|
||||||
|
Taupe = RGB{100, 95, 85} // Warm gray
|
||||||
|
Gray = RGB{120, 120, 120}
|
||||||
|
MidGray = RGB{128, 128, 128}
|
||||||
|
CoolSilver = RGB{140, 145, 155} // Blue-tinted silver
|
||||||
|
DimSilver = RGB{155, 155, 155}
|
||||||
|
Silver = RGB{180, 180, 180}
|
||||||
|
LightGray = RGB{200, 200, 200}
|
||||||
|
NearWhite = RGB{250, 250, 250}
|
||||||
|
White = RGB{255, 255, 255}
|
||||||
|
|
||||||
|
// --- Brown / Earth ---
|
||||||
|
Chocolate = RGB{90, 25, 15}
|
||||||
|
SaddleBrown = RGB{101, 67, 33}
|
||||||
|
DarkRust = RGB{140, 35, 25}
|
||||||
|
Sienna = RGB{140, 60, 0}
|
||||||
|
DarkPlum = RGB{60, 30, 40} // Warm dark purple-brown
|
||||||
|
BlueCharcoal = RGB{40, 45, 60} // Cool dark blue-gray
|
||||||
|
|
||||||
|
// --- Red ---
|
||||||
|
BlackRed = RGB{50, 15, 15}
|
||||||
|
Oxblood = RGB{100, 20, 20}
|
||||||
|
DarkBurgundy = RGB{100, 25, 20}
|
||||||
|
DarkCrimson = RGB{139, 0, 0}
|
||||||
|
Brick = RGB{180, 40, 40}
|
||||||
|
Cinnabar = RGB{200, 60, 50}
|
||||||
|
IndianRed = RGB{180, 60, 60}
|
||||||
|
BurntSienna = RGB{200, 60, 25}
|
||||||
|
Vermilion = RGB{227, 66, 82}
|
||||||
|
Red = RGB{255, 0, 0}
|
||||||
|
BrightRed = RGB{255, 60, 60}
|
||||||
|
Coral = RGB{255, 80, 80}
|
||||||
|
Salmon = RGB{255, 100, 100}
|
||||||
|
LightCoral = RGB{255, 140, 140}
|
||||||
|
LightRose = RGB{255, 150, 150}
|
||||||
|
MistyRose = RGB{255, 200, 200}
|
||||||
|
|
||||||
|
// --- Orange ---
|
||||||
|
DarkAmber = RGB{60, 40, 0}
|
||||||
|
Rust = RGB{180, 60, 20}
|
||||||
|
Amber = RGB{180, 120, 0}
|
||||||
|
Bronze = RGB{200, 100, 0}
|
||||||
|
BurntOrange = RGB{200, 110, 0}
|
||||||
|
Terracotta = RGB{220, 100, 50}
|
||||||
|
FlameOrange = RGB{240, 100, 30}
|
||||||
|
OrangeRed = RGB{255, 69, 0}
|
||||||
|
RedOrange = RGB{255, 80, 40}
|
||||||
|
Mango = RGB{255, 120, 50}
|
||||||
|
TigerOrange = RGB{255, 140, 0}
|
||||||
|
WarmOrange = RGB{255, 140, 40}
|
||||||
|
Apricot = RGB{255, 160, 60}
|
||||||
|
Orange = RGB{255, 165, 0}
|
||||||
|
|
||||||
|
// --- Yellow ---
|
||||||
|
DarkGold = RGB{200, 150, 0}
|
||||||
|
OliveYellow = RGB{200, 180, 60}
|
||||||
|
Gold = RGB{255, 215, 0}
|
||||||
|
LemonYellow = RGB{255, 240, 60}
|
||||||
|
Yellow = RGB{255, 255, 0}
|
||||||
|
PaleGold = RGB{255, 200, 100}
|
||||||
|
Buttercream = RGB{255, 250, 150}
|
||||||
|
PaleLemon = RGB{255, 255, 100}
|
||||||
|
Ivory = RGB{255, 255, 220}
|
||||||
|
Cream = RGB{255, 255, 200}
|
||||||
|
|
||||||
|
// --- Green ---
|
||||||
|
BlackGreen = RGB{0, 40, 0}
|
||||||
|
DarkFern = RGB{30, 80, 25}
|
||||||
|
DeepForest = RGB{25, 80, 35}
|
||||||
|
HunterGreen = RGB{35, 90, 30}
|
||||||
|
DarkGreen = RGB{15, 130, 15}
|
||||||
|
ForestGreen = RGB{34, 139, 34}
|
||||||
|
MediumGreen = RGB{40, 150, 40}
|
||||||
|
FernGreen = RGB{50, 140, 45}
|
||||||
|
LeafGreen = RGB{60, 160, 60}
|
||||||
|
SeaGreen = RGB{60, 180, 80}
|
||||||
|
SageGreen = RGB{70, 170, 100}
|
||||||
|
GrassGreen = RGB{70, 180, 55}
|
||||||
|
EmeraldGreen = RGB{60, 220, 100}
|
||||||
|
MintGreen = RGB{100, 220, 130}
|
||||||
|
BrightGreen = RGB{20, 200, 20}
|
||||||
|
YellowGreen = RGB{100, 220, 80}
|
||||||
|
LimeGreen = RGB{50, 205, 50}
|
||||||
|
NeonGreen = RGB{50, 255, 50}
|
||||||
|
BrightLime = RGB{120, 255, 80}
|
||||||
|
Lime = RGB{0, 255, 0}
|
||||||
|
LightGreen = RGB{144, 238, 144}
|
||||||
|
PaleGreen = RGB{120, 255, 120}
|
||||||
|
PastelGreen = RGB{100, 220, 100}
|
||||||
|
PaleMint = RGB{150, 255, 180}
|
||||||
|
Honeydew = RGB{200, 255, 200}
|
||||||
|
|
||||||
|
// --- Cyan / Teal ---
|
||||||
|
Teal = RGB{0, 139, 139}
|
||||||
|
DimCyan = RGB{0, 160, 160}
|
||||||
|
VibrantCyan = RGB{0, 200, 200}
|
||||||
|
DarkTurquoise = RGB{0, 206, 209}
|
||||||
|
BrightCyan = RGB{0, 220, 220}
|
||||||
|
Cyan = RGB{0, 255, 255}
|
||||||
|
SkyTeal = RGB{80, 200, 220}
|
||||||
|
PaleCyan = RGB{200, 255, 255}
|
||||||
|
AliceBlue = RGB{230, 245, 255}
|
||||||
|
IceCyan = RGB{240, 255, 255}
|
||||||
|
|
||||||
|
// --- Blue ---
|
||||||
|
DeepNavy = RGB{15, 25, 50}
|
||||||
|
DeepIndigo = RGB{40, 0, 180}
|
||||||
|
NavyBlue = RGB{30, 60, 120}
|
||||||
|
CobaltBlue = RGB{50, 80, 200}
|
||||||
|
SteelBlue = RGB{60, 100, 180}
|
||||||
|
MediumBlue = RGB{60, 120, 200}
|
||||||
|
RoyalBlue = RGB{65, 105, 225}
|
||||||
|
CeruleanBlue = RGB{80, 140, 220}
|
||||||
|
Cornflower = RGB{80, 130, 255}
|
||||||
|
DodgerBlue = RGB{40, 180, 255}
|
||||||
|
LightBlue = RGB{120, 170, 255}
|
||||||
|
LightSkyBlue = RGB{135, 206, 250}
|
||||||
|
BabyBlue = RGB{160, 210, 255}
|
||||||
|
Blue = RGB{0, 0, 255}
|
||||||
|
|
||||||
|
// --- Purple / Violet ---
|
||||||
|
DeepPurple = RGB{60, 20, 80}
|
||||||
|
DarkViolet = RGB{120, 40, 180}
|
||||||
|
MutedPurple = RGB{160, 100, 160}
|
||||||
|
MediumPurple = RGB{170, 100, 210}
|
||||||
|
ElectricViolet = RGB{180, 130, 255}
|
||||||
|
LightOrchid = RGB{200, 130, 210}
|
||||||
|
Orchid = RGB{200, 120, 220}
|
||||||
|
PaleVioletRed = RGB{219, 112, 147}
|
||||||
|
SoftLavender = RGB{220, 150, 230}
|
||||||
|
PaleLavender = RGB{220, 180, 255}
|
||||||
|
|
||||||
|
// --- Pink / Rose ---
|
||||||
|
RoseRed = RGB{255, 60, 120}
|
||||||
|
HotMagenta = RGB{255, 60, 200}
|
||||||
|
HotPink = RGB{255, 140, 200}
|
||||||
|
PalePink = RGB{255, 145, 220}
|
||||||
|
LightPink = RGB{255, 182, 193}
|
||||||
|
Pink = RGB{255, 192, 203}
|
||||||
|
Magenta = RGB{255, 0, 255}
|
||||||
|
)
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TerminalService manages terminal lifecycle and input polling
|
||||||
|
type TerminalService struct {
|
||||||
|
term Terminal
|
||||||
|
colorMode ColorMode
|
||||||
|
eventCh chan Event
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new terminal service
|
||||||
|
func NewService() *TerminalService {
|
||||||
|
return &TerminalService{
|
||||||
|
eventCh: make(chan Event, 256),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements Service
|
||||||
|
func (s *TerminalService) Name() string {
|
||||||
|
return "terminal"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies implements Service
|
||||||
|
func (s *TerminalService) Dependencies() []string {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init implements Service
|
||||||
|
// args[0]: ColorMode (optional, defaults to DetectColorMode())
|
||||||
|
func (s *TerminalService) Init(args ...any) error {
|
||||||
|
s.colorMode = DetectColorMode()
|
||||||
|
if len(args) > 0 {
|
||||||
|
if cm, ok := args[0].(ColorMode); ok {
|
||||||
|
s.colorMode = cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.term = New(s.colorMode)
|
||||||
|
if err := s.term.Init(); err != nil {
|
||||||
|
return fmt.Errorf("terminal init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable mouse click reporting
|
||||||
|
if err := s.term.SetMouseMode(MouseModeClick); err != nil {
|
||||||
|
// No-op for mouse error, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start implements Service - launches input polling goroutine
|
||||||
|
func (s *TerminalService) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.running = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go s.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop reads input events until stop signal
|
||||||
|
func (s *TerminalService) pollLoop() {
|
||||||
|
defer close(s.doneCh)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
EmergencyReset(os.Stdout)
|
||||||
|
os.Stdout.Sync()
|
||||||
|
os.Stderr.Sync()
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\n\x1b[31mTERMINAL POLL CRASHED: %v\x1b[0m\r\n", r)
|
||||||
|
fmt.Fprintf(os.Stderr, "Stack Trace:\r\n%s\r\n", debug.Stack())
|
||||||
|
os.Stderr.Sync()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := s.term.PollEvent()
|
||||||
|
if ev.Type == EventClosed || ev.Type == EventError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.eventCh <- ev:
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop implements Service - signals stop and restores terminal
|
||||||
|
func (s *TerminalService) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
close(s.stopCh)
|
||||||
|
|
||||||
|
// Post synthetic close event to unblock PollEvent
|
||||||
|
if s.term != nil {
|
||||||
|
s.term.PostEvent(Event{Type: EventClosed})
|
||||||
|
}
|
||||||
|
|
||||||
|
<-s.doneCh
|
||||||
|
|
||||||
|
if s.term != nil {
|
||||||
|
s.term.Fini()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal returns the wrapped terminal instance
|
||||||
|
func (s *TerminalService) Terminal() Terminal {
|
||||||
|
return s.term
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns the input event channel
|
||||||
|
func (s *TerminalService) Events() <-chan Event {
|
||||||
|
return s.eventCh
|
||||||
|
}
|
||||||
+458
@@ -0,0 +1,458 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attr represents text attributes (bitmask)
|
||||||
|
type Attr uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
AttrNone Attr = 0
|
||||||
|
AttrBold Attr = 1 << 0
|
||||||
|
AttrDim Attr = 1 << 1
|
||||||
|
AttrItalic Attr = 1 << 2
|
||||||
|
AttrUnderline Attr = 1 << 3
|
||||||
|
AttrBlink Attr = 1 << 4
|
||||||
|
AttrReverse Attr = 1 << 5
|
||||||
|
AttrFg256 Attr = 1 << 6 // Fg.R is 256-color palette index
|
||||||
|
AttrBg256 Attr = 1 << 7 // Bg.R is 256-color palette index
|
||||||
|
)
|
||||||
|
|
||||||
|
// AttrStyle masks only the style bits (excludes color mode flags)
|
||||||
|
const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBlink | AttrReverse
|
||||||
|
|
||||||
|
// Cell represents a single terminal cell
|
||||||
|
type Cell struct {
|
||||||
|
Rune rune
|
||||||
|
Fg RGB
|
||||||
|
Bg RGB
|
||||||
|
Attrs Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal provides low-level terminal access
|
||||||
|
type Terminal interface {
|
||||||
|
// Init enters raw mode, alternate screen buffer, hides cursor
|
||||||
|
Init() error
|
||||||
|
|
||||||
|
// Fini restores terminal state. Safe to call multiple times
|
||||||
|
Fini()
|
||||||
|
|
||||||
|
// Size returns current terminal dimensions
|
||||||
|
Size() (width, height int)
|
||||||
|
|
||||||
|
// ResizeChan returns channel that receives resize events
|
||||||
|
ResizeChan() <-chan ResizeEvent
|
||||||
|
|
||||||
|
// ColorMode returns detected color capability
|
||||||
|
ColorMode() ColorMode
|
||||||
|
|
||||||
|
// Flush writes cell buffer to terminal
|
||||||
|
// Cells are row-major: cells[y*width + x]
|
||||||
|
Flush(cells []Cell, width, height int)
|
||||||
|
|
||||||
|
// Clear fills screen with specified background color
|
||||||
|
Clear(bg RGB)
|
||||||
|
|
||||||
|
// SetCursorVisible shows/hides cursor
|
||||||
|
SetCursorVisible(visible bool)
|
||||||
|
|
||||||
|
// MoveCursor positions cursor (0-indexed)
|
||||||
|
MoveCursor(x, y int)
|
||||||
|
|
||||||
|
// Sync forces full redraw
|
||||||
|
Sync()
|
||||||
|
|
||||||
|
// PollEvent blocks until next input event
|
||||||
|
PollEvent() Event
|
||||||
|
|
||||||
|
// PostEvent injects a synthetic event
|
||||||
|
PostEvent(Event)
|
||||||
|
|
||||||
|
// SetMouseMode enables/disables mouse event reporting
|
||||||
|
// Modes can be combined: MouseModeClick | MouseModeDrag
|
||||||
|
SetMouseMode(mode MouseMode) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeEvent represents a terminal resize
|
||||||
|
type ResizeEvent struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// termImpl implements Terminal using the Backend interface
|
||||||
|
type termImpl struct {
|
||||||
|
backend Backend
|
||||||
|
|
||||||
|
output *outputBuffer
|
||||||
|
input *inputReader
|
||||||
|
resizeCh chan ResizeEvent
|
||||||
|
syntheticCh chan Event
|
||||||
|
|
||||||
|
cursorVisible atomic.Bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
initialized bool
|
||||||
|
finalized bool
|
||||||
|
mouseMode MouseMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Terminal instance
|
||||||
|
func New(colorMode ...ColorMode) Terminal {
|
||||||
|
b := newBackend()
|
||||||
|
|
||||||
|
var c ColorMode
|
||||||
|
if len(colorMode) == 0 {
|
||||||
|
// Use backend detection or fallback env detection for unix
|
||||||
|
c = DetectColorMode()
|
||||||
|
} else {
|
||||||
|
c = colorMode[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &termImpl{
|
||||||
|
backend: b,
|
||||||
|
syntheticCh: make(chan Event, 16),
|
||||||
|
resizeCh: make(chan ResizeEvent, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize output buffer with backend
|
||||||
|
t.output = newOutputBuffer(b, c)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init enters raw mode and sets up terminal
|
||||||
|
func (t *termImpl) Init() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if t.initialized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backend (raw mode)
|
||||||
|
if err := t.backend.Init(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w, h := t.backend.Size()
|
||||||
|
t.output.resize(w, h)
|
||||||
|
|
||||||
|
// Create input reader wrapping backend
|
||||||
|
t.input = newInputReader(t.backend)
|
||||||
|
|
||||||
|
// Set resize handler on backend
|
||||||
|
t.backend.SetResizeHandler(func(w, h int) {
|
||||||
|
// Non-blocking send to avoid backend blocking
|
||||||
|
select {
|
||||||
|
case t.resizeCh <- ResizeEvent{Width: w, Height: h}:
|
||||||
|
default:
|
||||||
|
// Drain and replace to ensure latest size is pending
|
||||||
|
select {
|
||||||
|
case <-t.resizeCh:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case t.resizeCh <- ResizeEvent{Width: w, Height: h}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enter alternate screen, hide cursor
|
||||||
|
t.writeRaw(csiAltScreenEnter)
|
||||||
|
t.writeRaw(csiCursorHide)
|
||||||
|
|
||||||
|
// DISABLE AUTO-WRAP
|
||||||
|
// Prevents terminal scroll/wrap on bottom-right corner write
|
||||||
|
t.writeRaw(csiAutoWrapOff)
|
||||||
|
|
||||||
|
// Invisible cursor
|
||||||
|
t.cursorVisible.Store(false)
|
||||||
|
|
||||||
|
// Clear screen
|
||||||
|
t.output.clear(RGBBlack)
|
||||||
|
|
||||||
|
// Start input reader
|
||||||
|
t.input.start()
|
||||||
|
|
||||||
|
t.initialized = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fini restores terminal state
|
||||||
|
func (t *termImpl) Fini() {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable mouse before other cleanup
|
||||||
|
if t.mouseMode != MouseModeNone {
|
||||||
|
w := t.output.writer
|
||||||
|
w.Write(csiMouseMotionOff)
|
||||||
|
w.Write(csiMouseDragOff)
|
||||||
|
w.Write(csiMouseClickOff)
|
||||||
|
w.Write(csiMouseSGROff)
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop handlers
|
||||||
|
if t.input != nil {
|
||||||
|
t.input.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show cursor
|
||||||
|
t.writeRaw(csiCursorShow)
|
||||||
|
|
||||||
|
// Exit alternate screen
|
||||||
|
t.writeRaw(csiAltScreenExit)
|
||||||
|
|
||||||
|
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
|
||||||
|
t.writeRaw(csiAutoWrapOn)
|
||||||
|
|
||||||
|
// Reset attributes
|
||||||
|
t.writeRaw(csiSGR0)
|
||||||
|
|
||||||
|
// Backend cleanup
|
||||||
|
t.backend.Fini()
|
||||||
|
|
||||||
|
t.finalized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns current terminal dimensions
|
||||||
|
func (t *termImpl) Size() (int, int) {
|
||||||
|
return t.backend.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeChan returns the resize event channel
|
||||||
|
func (t *termImpl) ResizeChan() <-chan ResizeEvent {
|
||||||
|
return t.resizeCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorMode returns detected color capability
|
||||||
|
func (t *termImpl) ColorMode() ColorMode {
|
||||||
|
return t.output.colorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes cell buffer to terminal
|
||||||
|
// Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||||
|
func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption
|
||||||
|
currW, currH := t.backend.Size()
|
||||||
|
if currW != width || currH != height {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.output.flush(cells, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear fills screen with background color
|
||||||
|
func (t *termImpl) Clear(bg RGB) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.output.clear(bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCursorVisible shows/hides cursor
|
||||||
|
func (t *termImpl) SetCursorVisible(visible bool) {
|
||||||
|
if t.cursorVisible.Swap(visible) == visible {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := t.output.writer
|
||||||
|
if visible {
|
||||||
|
w.Write(csiCursorShow)
|
||||||
|
} else {
|
||||||
|
w.Write(csiCursorHide)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveCursor positions cursor (0-indexed)
|
||||||
|
func (t *termImpl) MoveCursor(x, y int) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.output != nil {
|
||||||
|
t.output.invalidateCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
w, h := t.backend.Size()
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
if x >= w {
|
||||||
|
x = w - 1
|
||||||
|
}
|
||||||
|
if y >= h {
|
||||||
|
y = h - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write through buffered writer to maintain stream order
|
||||||
|
wBuf := t.output.writer
|
||||||
|
writeCursorPos(wBuf, x, y)
|
||||||
|
wBuf.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync forces full redraw
|
||||||
|
func (t *termImpl) Sync() {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear terminal before full redraw
|
||||||
|
// Diff-based rendering assumes physical terminal matches front buffer state
|
||||||
|
t.output.clear(RGBBlack)
|
||||||
|
t.output.forceFullRedraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollEvent blocks until next input event
|
||||||
|
func (t *termImpl) PollEvent() Event {
|
||||||
|
// Check synthetic events first
|
||||||
|
select {
|
||||||
|
case ev := <-t.syntheticCh:
|
||||||
|
return ev
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for input or resize
|
||||||
|
select {
|
||||||
|
case ev := <-t.syntheticCh:
|
||||||
|
return ev
|
||||||
|
case ev := <-t.input.events():
|
||||||
|
return ev
|
||||||
|
case re := <-t.resizeCh:
|
||||||
|
// We can return resize event directly
|
||||||
|
return Event{
|
||||||
|
Type: EventResize,
|
||||||
|
Width: re.Width,
|
||||||
|
Height: re.Height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostEvent injects a synthetic event
|
||||||
|
func (t *termImpl) PostEvent(ev Event) {
|
||||||
|
select {
|
||||||
|
case t.syntheticCh <- ev:
|
||||||
|
default:
|
||||||
|
// Channel full, drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMouseMode enables or disables mouse mode
|
||||||
|
func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.initialized || t.finalized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldMode := t.mouseMode
|
||||||
|
t.mouseMode = mode
|
||||||
|
|
||||||
|
w := t.output.writer
|
||||||
|
|
||||||
|
// Disable modes no longer needed (reverse order of enable)
|
||||||
|
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
||||||
|
w.Write(csiMouseMotionOff)
|
||||||
|
}
|
||||||
|
if oldMode&MouseModeDrag != 0 && mode&MouseModeDrag == 0 {
|
||||||
|
w.Write(csiMouseDragOff)
|
||||||
|
}
|
||||||
|
if oldMode&MouseModeClick != 0 && mode&MouseModeClick == 0 {
|
||||||
|
w.Write(csiMouseClickOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable SGR if disabling all mouse
|
||||||
|
if mode == MouseModeNone && oldMode != MouseModeNone {
|
||||||
|
w.Write(csiMouseSGROff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable SGR first if enabling any mouse mode
|
||||||
|
if mode != MouseModeNone && oldMode == MouseModeNone {
|
||||||
|
w.Write(csiMouseSGROn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable new modes (click is base, drag extends, motion extends further)
|
||||||
|
if mode&MouseModeClick != 0 && oldMode&MouseModeClick == 0 {
|
||||||
|
w.Write(csiMouseClickOn)
|
||||||
|
}
|
||||||
|
if mode&MouseModeDrag != 0 && oldMode&MouseModeDrag == 0 {
|
||||||
|
w.Write(csiMouseDragOn)
|
||||||
|
}
|
||||||
|
if mode&MouseModeMotion != 0 && oldMode&MouseModeMotion == 0 {
|
||||||
|
w.Write(csiMouseMotionOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRaw writes raw bytes to output
|
||||||
|
func (t *termImpl) writeRaw(data []byte) {
|
||||||
|
t.backend.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmergencyReset attempts to restore terminal to sane state
|
||||||
|
// Call this from panic recovery if Fini() cannot be called normally
|
||||||
|
// EmergencyReset attempts to restore terminal to sane state
|
||||||
|
// Call this from panic recovery if Fini() cannot be called normally
|
||||||
|
func EmergencyReset(w io.Writer) {
|
||||||
|
// Disable mouse tracking
|
||||||
|
w.Write(csiMouseMotionOff)
|
||||||
|
w.Write(csiMouseDragOff)
|
||||||
|
w.Write(csiMouseClickOff)
|
||||||
|
w.Write(csiMouseSGROff)
|
||||||
|
|
||||||
|
// Write sequences to provided writer
|
||||||
|
w.Write(csiCursorShow)
|
||||||
|
w.Write(csiAltScreenExit)
|
||||||
|
w.Write(csiSGR0)
|
||||||
|
w.Write(csiAutoWrapOn)
|
||||||
|
w.Write(csiRIS)
|
||||||
|
|
||||||
|
// Flush if it's a file
|
||||||
|
if f, ok := w.(*os.File); ok {
|
||||||
|
f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt raw mode reset via stty - escape sequences alone don't restore termios
|
||||||
|
// This is best-effort; ignore errors in crash context
|
||||||
|
resetTerminalMode()
|
||||||
|
}
|
||||||
+206
@@ -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.
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineType specifies box drawing character style
|
||||||
|
type LineType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
LineSingle LineType = iota // ┌─┐│└┘
|
||||||
|
LineDouble // ╔═╗║╚╝
|
||||||
|
LineRounded // ╭─╮│╰╯
|
||||||
|
LineHeavy // ┏━┓┃┗┛
|
||||||
|
LineNone // spaces (invisible border with padding)
|
||||||
|
)
|
||||||
|
|
||||||
|
// boxChars contains box drawing character sets indexed by LineType
|
||||||
|
var boxChars = [...][6]rune{
|
||||||
|
LineSingle: {'┌', '─', '┐', '│', '└', '┘'},
|
||||||
|
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'},
|
||||||
|
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'},
|
||||||
|
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'},
|
||||||
|
LineNone: {' ', ' ', ' ', ' ', ' ', ' '},
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
boxTL = 0 // top-left
|
||||||
|
boxH = 1 // horizontal
|
||||||
|
boxTR = 2 // top-right
|
||||||
|
boxV = 3 // vertical
|
||||||
|
boxBL = 4 // bottom-left
|
||||||
|
boxBR = 5 // bottom-right
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Box Rendering ---
|
||||||
|
|
||||||
|
// Box draws border around region edge
|
||||||
|
func (r Region) Box(line LineType, fg terminal.RGB) {
|
||||||
|
if r.W < 2 || r.H < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line >= LineType(len(boxChars)) {
|
||||||
|
line = LineSingle
|
||||||
|
}
|
||||||
|
|
||||||
|
chars := boxChars[line]
|
||||||
|
bg := terminal.RGB{} // Transparent (use existing bg)
|
||||||
|
|
||||||
|
// Corners
|
||||||
|
r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(r.W-1, 0, chars[boxTR], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(0, r.H-1, chars[boxBL], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(r.W-1, r.H-1, chars[boxBR], fg, bg, terminal.AttrNone)
|
||||||
|
|
||||||
|
// Horizontal edges
|
||||||
|
for x := 1; x < r.W-1; x++ {
|
||||||
|
r.Cell(x, 0, chars[boxH], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(x, r.H-1, chars[boxH], fg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical edges
|
||||||
|
for y := 1; y < r.H-1; y++ {
|
||||||
|
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxFilled draws border and fills interior with background
|
||||||
|
func (r Region) BoxFilled(line LineType, fg, bg terminal.RGB) {
|
||||||
|
// Fill interior first
|
||||||
|
for y := 1; y < r.H-1; y++ {
|
||||||
|
for x := 1; x < r.W-1; x++ {
|
||||||
|
r.Cell(x, y, ' ', fg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Draw border on top
|
||||||
|
r.Box(line, fg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Line rendering ---
|
||||||
|
|
||||||
|
// HLine draws horizontal line across region width at row y
|
||||||
|
func (r Region) HLine(y int, line LineType, fg terminal.RGB) {
|
||||||
|
if y < 0 || y >= r.H {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line >= LineType(len(boxChars)) {
|
||||||
|
line = LineSingle
|
||||||
|
}
|
||||||
|
ch := boxChars[line][boxH]
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLine draws vertical line across region height at column x
|
||||||
|
func (r Region) VLine(x int, line LineType, fg terminal.RGB) {
|
||||||
|
if x < 0 || x >= r.W {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line >= LineType(len(boxChars)) {
|
||||||
|
line = LineSingle
|
||||||
|
}
|
||||||
|
ch := boxChars[line][boxV]
|
||||||
|
for y := 0; y < r.H; y++ {
|
||||||
|
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider draws horizontal line with optional centered label
|
||||||
|
func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
|
||||||
|
if y < 0 || y >= r.H {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line >= LineType(len(boxChars)) {
|
||||||
|
line = LineSingle
|
||||||
|
}
|
||||||
|
|
||||||
|
hChar := boxChars[line][boxH]
|
||||||
|
|
||||||
|
// Fill with horizontal line
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
r.Cell(x, y, hChar, fg, terminal.RGB{}, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center label if provided
|
||||||
|
if label != "" && r.W > 4 {
|
||||||
|
text := " " + label + " "
|
||||||
|
textLen := RuneLen(text)
|
||||||
|
if textLen > r.W-2 {
|
||||||
|
text = Truncate(text, r.W-2)
|
||||||
|
textLen = RuneLen(text)
|
||||||
|
}
|
||||||
|
startX := (r.W - textLen) / 2
|
||||||
|
for i, ch := range text {
|
||||||
|
r.Cell(startX+i, y, ch, fg, terminal.RGB{}, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Card rendering ---
|
||||||
|
|
||||||
|
// Card draws titled border and returns inner content region
|
||||||
|
func (r Region) Card(title string, line LineType, fg terminal.RGB) Region {
|
||||||
|
r.Box(line, fg)
|
||||||
|
|
||||||
|
if title != "" && r.W > 4 {
|
||||||
|
maxTitleLen := r.W - 4
|
||||||
|
displayTitle := title
|
||||||
|
if RuneLen(displayTitle) > maxTitleLen {
|
||||||
|
displayTitle = Truncate(displayTitle, maxTitleLen)
|
||||||
|
}
|
||||||
|
titleX := (r.W - RuneLen(displayTitle) - 2) / 2
|
||||||
|
r.Text(titleX, 0, " "+displayTitle+" ", fg, terminal.RGB{}, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Inset(1)
|
||||||
|
}
|
||||||
+127
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// Coverage represents count/total for display in tree nodes
|
||||||
|
type Coverage struct {
|
||||||
|
Count int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAll returns true if count equals total and total is positive
|
||||||
|
func (c Coverage) IsAll() bool {
|
||||||
|
return c.Count == c.Total && c.Total > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPartial returns true if count is between zero and total (exclusive)
|
||||||
|
func (c Coverage) IsPartial() bool {
|
||||||
|
return c.Count > 0 && c.Count < c.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNone returns true if count is zero
|
||||||
|
func (c Coverage) IsNone() bool {
|
||||||
|
return c.Count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns coverage as "[count/total]", "[ALL]", or empty string if total is zero
|
||||||
|
func (c Coverage) String() string {
|
||||||
|
if c.Total == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if c.IsAll() {
|
||||||
|
return "[ALL]"
|
||||||
|
}
|
||||||
|
return "[" + intStr(c.Count) + "/" + intStr(c.Total) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCoverageSuffix returns a suffix string for TreeNode.Suffix
|
||||||
|
func FormatCoverageSuffix(count, total int) string {
|
||||||
|
if total == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if count == total {
|
||||||
|
return " [ALL]"
|
||||||
|
}
|
||||||
|
return " [" + intStr(count) + "/" + intStr(total) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// intStr converts int to string without fmt dependency
|
||||||
|
func intStr(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// ConfirmResult represents dialog outcome
|
||||||
|
type ConfirmResult uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfirmPending ConfirmResult = iota
|
||||||
|
ConfirmYes
|
||||||
|
ConfirmNo
|
||||||
|
ConfirmCancel
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfirmState holds confirmation dialog state
|
||||||
|
type ConfirmState struct {
|
||||||
|
FocusYes bool // true = Yes focused, false = No focused
|
||||||
|
Result ConfirmResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfirmState creates dialog state with default selection
|
||||||
|
func NewConfirmState(defaultYes bool) *ConfirmState {
|
||||||
|
return &ConfirmState{
|
||||||
|
FocusYes: defaultYes,
|
||||||
|
Result: ConfirmPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switches focus between Yes and No
|
||||||
|
func (c *ConfirmState) Toggle() {
|
||||||
|
c.FocusYes = !c.FocusYes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm selects currently focused button
|
||||||
|
func (c *ConfirmState) Confirm() {
|
||||||
|
if c.FocusYes {
|
||||||
|
c.Result = ConfirmYes
|
||||||
|
} else {
|
||||||
|
c.Result = ConfirmNo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectYes directly selects Yes
|
||||||
|
func (c *ConfirmState) SelectYes() {
|
||||||
|
c.Result = ConfirmYes
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectNo directly selects No
|
||||||
|
func (c *ConfirmState) SelectNo() {
|
||||||
|
c.Result = ConfirmNo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cancels the dialog
|
||||||
|
func (c *ConfirmState) Cancel() {
|
||||||
|
c.Result = ConfirmCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleKey processes input, returns true if dialog should close
|
||||||
|
func (c *ConfirmState) HandleKey(key terminal.Key, r rune) bool {
|
||||||
|
switch key {
|
||||||
|
case terminal.KeyLeft, terminal.KeyRight, terminal.KeyTab:
|
||||||
|
c.Toggle()
|
||||||
|
return false
|
||||||
|
case terminal.KeyEnter:
|
||||||
|
c.Confirm()
|
||||||
|
return true
|
||||||
|
case terminal.KeyEscape:
|
||||||
|
c.Cancel()
|
||||||
|
return true
|
||||||
|
case terminal.KeyRune:
|
||||||
|
switch r {
|
||||||
|
case 'y', 'Y':
|
||||||
|
c.SelectYes()
|
||||||
|
return true
|
||||||
|
case 'n', 'N':
|
||||||
|
c.SelectNo()
|
||||||
|
return true
|
||||||
|
case 'h':
|
||||||
|
c.FocusYes = true
|
||||||
|
return false
|
||||||
|
case 'l':
|
||||||
|
c.FocusYes = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmOpts configures confirmation dialog
|
||||||
|
type ConfirmOpts struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
YesLabel string // Default "Yes"
|
||||||
|
NoLabel string // Default "No"
|
||||||
|
Destructive bool // Style Yes as warning
|
||||||
|
Style ConfirmStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmStyle defines dialog colors
|
||||||
|
type ConfirmStyle struct {
|
||||||
|
BorderFg terminal.RGB
|
||||||
|
TitleFg terminal.RGB
|
||||||
|
MessageFg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
ButtonFg terminal.RGB
|
||||||
|
ButtonBg terminal.RGB
|
||||||
|
ButtonFocusFg terminal.RGB
|
||||||
|
ButtonFocusBg terminal.RGB
|
||||||
|
DestructiveFg terminal.RGB
|
||||||
|
DestructiveBg terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfirmStyle returns default dialog colors
|
||||||
|
func DefaultConfirmStyle() ConfirmStyle {
|
||||||
|
return ConfirmStyle{
|
||||||
|
BorderFg: terminal.RGB{R: 100, G: 100, B: 120},
|
||||||
|
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
MessageFg: terminal.RGB{R: 200, G: 200, B: 200},
|
||||||
|
Bg: terminal.RGB{R: 30, G: 30, B: 40},
|
||||||
|
ButtonFg: terminal.RGB{R: 180, G: 180, B: 180},
|
||||||
|
ButtonBg: terminal.RGB{R: 50, G: 50, B: 60},
|
||||||
|
ButtonFocusFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
ButtonFocusBg: terminal.RGB{R: 60, G: 80, B: 120},
|
||||||
|
DestructiveFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
DestructiveBg: terminal.RGB{R: 180, G: 60, B: 60},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmDialog renders confirmation dialog centered in region
|
||||||
|
// Returns content region for additional content if needed
|
||||||
|
func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
|
||||||
|
style := opts.Style
|
||||||
|
if style == (ConfirmStyle{}) {
|
||||||
|
style = DefaultConfirmStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.YesLabel == "" {
|
||||||
|
opts.YesLabel = "Yes"
|
||||||
|
}
|
||||||
|
if opts.NoLabel == "" {
|
||||||
|
opts.NoLabel = "No"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dialog size
|
||||||
|
msgLines := WrapText(opts.Message, r.W-8)
|
||||||
|
if len(msgLines) == 0 {
|
||||||
|
msgLines = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogW := 40
|
||||||
|
msgMaxW := 0
|
||||||
|
for _, line := range msgLines {
|
||||||
|
if w := RuneLen(line); w > msgMaxW {
|
||||||
|
msgMaxW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msgMaxW+6 > dialogW {
|
||||||
|
dialogW = msgMaxW + 6
|
||||||
|
}
|
||||||
|
if dialogW > r.W-4 {
|
||||||
|
dialogW = r.W - 4
|
||||||
|
}
|
||||||
|
if dialogW < 20 {
|
||||||
|
dialogW = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogH := 3 + len(msgLines) + 3 // border + message + spacing + buttons + border
|
||||||
|
if dialogH > r.H-2 {
|
||||||
|
dialogH = r.H - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center dialog
|
||||||
|
dialog := Center(r, dialogW, dialogH)
|
||||||
|
|
||||||
|
// Draw modal frame
|
||||||
|
content := dialog.Modal(ModalOpts{
|
||||||
|
Title: opts.Title,
|
||||||
|
Border: LineDouble,
|
||||||
|
BorderFg: style.BorderFg,
|
||||||
|
TitleFg: style.TitleFg,
|
||||||
|
Bg: style.Bg,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Message
|
||||||
|
y := 0
|
||||||
|
for _, line := range msgLines {
|
||||||
|
if y >= content.H-2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons row
|
||||||
|
buttonY := content.H - 1
|
||||||
|
if buttonY < y+1 {
|
||||||
|
buttonY = y + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
yesLabel := " " + opts.YesLabel + " "
|
||||||
|
noLabel := " " + opts.NoLabel + " "
|
||||||
|
|
||||||
|
yesW := RuneLen(yesLabel)
|
||||||
|
noW := RuneLen(noLabel)
|
||||||
|
buttonGap := 4
|
||||||
|
totalButtonW := yesW + buttonGap + noW
|
||||||
|
|
||||||
|
buttonX := (content.W - totalButtonW) / 2
|
||||||
|
if buttonX < 0 {
|
||||||
|
buttonX = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes button
|
||||||
|
yesFg := style.ButtonFg
|
||||||
|
yesBg := style.ButtonBg
|
||||||
|
if state.FocusYes {
|
||||||
|
if opts.Destructive {
|
||||||
|
yesFg = style.DestructiveFg
|
||||||
|
yesBg = style.DestructiveBg
|
||||||
|
} else {
|
||||||
|
yesFg = style.ButtonFocusFg
|
||||||
|
yesBg = style.ButtonFocusBg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ch := range yesLabel {
|
||||||
|
if buttonX+i < content.W {
|
||||||
|
content.Cell(buttonX+i, buttonY, ch, yesFg, yesBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No button
|
||||||
|
noX := buttonX + yesW + buttonGap
|
||||||
|
noFg := style.ButtonFg
|
||||||
|
noBg := style.ButtonBg
|
||||||
|
if !state.FocusYes {
|
||||||
|
noFg = style.ButtonFocusFg
|
||||||
|
noBg = style.ButtonFocusBg
|
||||||
|
}
|
||||||
|
for i, ch := range noLabel {
|
||||||
|
if noX+i < content.W {
|
||||||
|
content.Cell(noX+i, buttonY, ch, noFg, noBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.Sub(0, 0, content.W, buttonY-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertOpts configures single-button alert dialog
|
||||||
|
type AlertOpts struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Button string // Default "OK"
|
||||||
|
Style ConfirmStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertDialog renders single-button alert, returns true when dismissed
|
||||||
|
func (r Region) AlertDialog(opts AlertOpts) Region {
|
||||||
|
style := opts.Style
|
||||||
|
if style == (ConfirmStyle{}) {
|
||||||
|
style = DefaultConfirmStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Button == "" {
|
||||||
|
opts.Button = "OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
msgLines := WrapText(opts.Message, r.W-8)
|
||||||
|
if len(msgLines) == 0 {
|
||||||
|
msgLines = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogW := 36
|
||||||
|
msgMaxW := 0
|
||||||
|
for _, line := range msgLines {
|
||||||
|
if w := RuneLen(line); w > msgMaxW {
|
||||||
|
msgMaxW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msgMaxW+6 > dialogW {
|
||||||
|
dialogW = msgMaxW + 6
|
||||||
|
}
|
||||||
|
if dialogW > r.W-4 {
|
||||||
|
dialogW = r.W - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogH := 3 + len(msgLines) + 3
|
||||||
|
if dialogH > r.H-2 {
|
||||||
|
dialogH = r.H - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog := Center(r, dialogW, dialogH)
|
||||||
|
|
||||||
|
content := dialog.Modal(ModalOpts{
|
||||||
|
Title: opts.Title,
|
||||||
|
Border: LineDouble,
|
||||||
|
BorderFg: style.BorderFg,
|
||||||
|
TitleFg: style.TitleFg,
|
||||||
|
Bg: style.Bg,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Message
|
||||||
|
y := 0
|
||||||
|
for _, line := range msgLines {
|
||||||
|
if y >= content.H-2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button
|
||||||
|
buttonY := content.H - 1
|
||||||
|
buttonLabel := " " + opts.Button + " "
|
||||||
|
buttonW := RuneLen(buttonLabel)
|
||||||
|
buttonX := (content.W - buttonW) / 2
|
||||||
|
|
||||||
|
for i, ch := range buttonLabel {
|
||||||
|
if buttonX+i < content.W {
|
||||||
|
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.Sub(0, 0, content.W, buttonY-1)
|
||||||
|
}
|
||||||
+197
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// splitLines splits string into lines by newline character
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
lines = append(lines, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, s[start:])
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditorState holds multi-line text editor state
|
||||||
|
type EditorState struct {
|
||||||
|
Lines []string
|
||||||
|
CursorLine int
|
||||||
|
CursorCol int
|
||||||
|
ScrollX int
|
||||||
|
ScrollY int
|
||||||
|
ViewportW int // Updated during render
|
||||||
|
ViewportH int // Updated during render
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEditorState creates initialized editor state
|
||||||
|
func NewEditorState(initial string) *EditorState {
|
||||||
|
lines := splitLines(initial)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
lines = []string{""}
|
||||||
|
}
|
||||||
|
return &EditorState{
|
||||||
|
Lines: lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Value access ---
|
||||||
|
|
||||||
|
// Value returns all lines joined with newlines
|
||||||
|
func (e *EditorState) Value() string {
|
||||||
|
if len(e.Lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := e.Lines[0]
|
||||||
|
for i := 1; i < len(e.Lines); i++ {
|
||||||
|
result += "\n" + e.Lines[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue replaces all content and resets cursor
|
||||||
|
func (e *EditorState) SetValue(s string) {
|
||||||
|
e.Lines = splitLines(s)
|
||||||
|
if len(e.Lines) == 0 {
|
||||||
|
e.Lines = []string{""}
|
||||||
|
}
|
||||||
|
e.CursorLine = 0
|
||||||
|
e.CursorCol = 0
|
||||||
|
e.ScrollX = 0
|
||||||
|
e.ScrollY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear empties the editor
|
||||||
|
func (e *EditorState) Clear() {
|
||||||
|
e.Lines = []string{""}
|
||||||
|
e.CursorLine = 0
|
||||||
|
e.CursorCol = 0
|
||||||
|
e.ScrollX = 0
|
||||||
|
e.ScrollY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Line queries ---
|
||||||
|
|
||||||
|
// LineCount returns number of lines
|
||||||
|
func (e *EditorState) LineCount() int {
|
||||||
|
return len(e.Lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentLine returns current line text
|
||||||
|
func (e *EditorState) CurrentLine() string {
|
||||||
|
if e.CursorLine < 0 || e.CursorLine >= len(e.Lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Lines[e.CursorLine]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cursor clamping ---
|
||||||
|
|
||||||
|
// clampCursor ensures cursor is within valid bounds
|
||||||
|
func (e *EditorState) clampCursor() {
|
||||||
|
if len(e.Lines) == 0 {
|
||||||
|
e.Lines = []string{""}
|
||||||
|
}
|
||||||
|
if e.CursorLine < 0 {
|
||||||
|
e.CursorLine = 0
|
||||||
|
}
|
||||||
|
if e.CursorLine >= len(e.Lines) {
|
||||||
|
e.CursorLine = len(e.Lines) - 1
|
||||||
|
}
|
||||||
|
lineLen := len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
if e.CursorCol < 0 {
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
if e.CursorCol > lineLen {
|
||||||
|
e.CursorCol = lineLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Character insertion ---
|
||||||
|
|
||||||
|
// Insert adds a rune at cursor position
|
||||||
|
func (e *EditorState) Insert(r rune) {
|
||||||
|
e.clampCursor()
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
line = append(line[:e.CursorCol], append([]rune{r}, line[e.CursorCol:]...)...)
|
||||||
|
e.Lines[e.CursorLine] = string(line)
|
||||||
|
e.CursorCol++
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertString adds string at cursor, handling newlines
|
||||||
|
func (e *EditorState) InsertString(s string) {
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\n' {
|
||||||
|
e.InsertNewline()
|
||||||
|
} else {
|
||||||
|
e.Insert(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertNewline splits current line at cursor
|
||||||
|
func (e *EditorState) InsertNewline() {
|
||||||
|
e.clampCursor()
|
||||||
|
runes := []rune(e.Lines[e.CursorLine])
|
||||||
|
|
||||||
|
before := string(runes[:e.CursorCol])
|
||||||
|
after := string(runes[e.CursorCol:])
|
||||||
|
|
||||||
|
e.Lines[e.CursorLine] = before
|
||||||
|
e.Lines = append(e.Lines[:e.CursorLine+1], append([]string{after}, e.Lines[e.CursorLine+1:]...)...)
|
||||||
|
e.CursorLine++
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Character deletion ---
|
||||||
|
|
||||||
|
// DeleteBackward deletes character before cursor or merges lines
|
||||||
|
func (e *EditorState) DeleteBackward() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
if e.CursorCol > 0 {
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
line = append(line[:e.CursorCol-1], line[e.CursorCol:]...)
|
||||||
|
e.Lines[e.CursorLine] = string(line)
|
||||||
|
e.CursorCol--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.CursorLine > 0 {
|
||||||
|
prevLine := e.Lines[e.CursorLine-1]
|
||||||
|
curLine := e.Lines[e.CursorLine]
|
||||||
|
newCol := len([]rune(prevLine))
|
||||||
|
e.Lines[e.CursorLine-1] = prevLine + curLine
|
||||||
|
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
|
||||||
|
e.CursorLine--
|
||||||
|
e.CursorCol = newCol
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteForward deletes character at cursor or merges with next line
|
||||||
|
func (e *EditorState) DeleteForward() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
if e.CursorCol < len(line) {
|
||||||
|
line = append(line[:e.CursorCol], line[e.CursorCol+1:]...)
|
||||||
|
e.Lines[e.CursorLine] = string(line)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.CursorLine < len(e.Lines)-1 {
|
||||||
|
e.Lines[e.CursorLine] = e.Lines[e.CursorLine] + e.Lines[e.CursorLine+1]
|
||||||
|
e.Lines = append(e.Lines[:e.CursorLine+1], e.Lines[e.CursorLine+2:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Word deletion ---
|
||||||
|
|
||||||
|
// DeleteWordBackward deletes word before cursor
|
||||||
|
func (e *EditorState) DeleteWordBackward() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
if e.CursorCol == 0 {
|
||||||
|
return e.DeleteBackward()
|
||||||
|
}
|
||||||
|
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
end := e.CursorCol
|
||||||
|
|
||||||
|
// Skip trailing non-word chars
|
||||||
|
for end > 0 && !isWordChar(line[end-1]) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
// Skip word chars
|
||||||
|
start := end
|
||||||
|
for start > 0 && isWordChar(line[start-1]) {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
if start == e.CursorCol {
|
||||||
|
start = e.CursorCol - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
line = append(line[:start], line[e.CursorCol:]...)
|
||||||
|
e.Lines[e.CursorLine] = string(line)
|
||||||
|
e.CursorCol = start
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWordForward deletes word after cursor
|
||||||
|
func (e *EditorState) DeleteWordForward() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
if e.CursorCol >= len(line) {
|
||||||
|
return e.DeleteForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
start := e.CursorCol
|
||||||
|
end := start
|
||||||
|
|
||||||
|
// Skip word chars
|
||||||
|
for end < len(line) && isWordChar(line[end]) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
// Skip trailing non-word chars
|
||||||
|
for end < len(line) && !isWordChar(line[end]) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end == start {
|
||||||
|
end = start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
line = append(line[:start], line[end:]...)
|
||||||
|
e.Lines[e.CursorLine] = string(line)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line deletion
|
||||||
|
|
||||||
|
// DeleteToEndOfLine deletes from cursor to end of line
|
||||||
|
func (e *EditorState) DeleteToEndOfLine() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
if e.CursorCol < len(line) {
|
||||||
|
e.Lines[e.CursorLine] = string(line[:e.CursorCol])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// At end of line - merge with next
|
||||||
|
return e.DeleteForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteToStartOfLine deletes from start to cursor
|
||||||
|
func (e *EditorState) DeleteToStartOfLine() bool {
|
||||||
|
e.clampCursor()
|
||||||
|
if e.CursorCol > 0 {
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
e.Lines[e.CursorLine] = string(line[e.CursorCol:])
|
||||||
|
e.CursorCol = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLine removes current line
|
||||||
|
func (e *EditorState) DeleteLine() bool {
|
||||||
|
if len(e.Lines) == 1 {
|
||||||
|
e.Lines[0] = ""
|
||||||
|
e.CursorCol = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
e.Lines = append(e.Lines[:e.CursorLine], e.Lines[e.CursorLine+1:]...)
|
||||||
|
e.clampCursor()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Line navigation ---
|
||||||
|
|
||||||
|
// MoveUp moves cursor to previous line
|
||||||
|
func (e *EditorState) MoveUp() {
|
||||||
|
if e.CursorLine > 0 {
|
||||||
|
e.CursorLine--
|
||||||
|
e.clampCursor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDown moves cursor to next line
|
||||||
|
func (e *EditorState) MoveDown() {
|
||||||
|
if e.CursorLine < len(e.Lines)-1 {
|
||||||
|
e.CursorLine++
|
||||||
|
e.clampCursor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Character navigation ---
|
||||||
|
|
||||||
|
// MoveLeft moves cursor left, wrapping to previous line
|
||||||
|
func (e *EditorState) MoveLeft() {
|
||||||
|
if e.CursorCol > 0 {
|
||||||
|
e.CursorCol--
|
||||||
|
} else if e.CursorLine > 0 {
|
||||||
|
e.CursorLine--
|
||||||
|
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveRight moves cursor right, wrapping to next line
|
||||||
|
func (e *EditorState) MoveRight() {
|
||||||
|
lineLen := len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
if e.CursorCol < lineLen {
|
||||||
|
e.CursorCol++
|
||||||
|
} else if e.CursorLine < len(e.Lines)-1 {
|
||||||
|
e.CursorLine++
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Word navigation ---
|
||||||
|
|
||||||
|
// MoveWordLeft moves cursor to previous word boundary
|
||||||
|
func (e *EditorState) MoveWordLeft() {
|
||||||
|
if e.CursorCol == 0 {
|
||||||
|
if e.CursorLine > 0 {
|
||||||
|
e.CursorLine--
|
||||||
|
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
for e.CursorCol > 0 && !isWordChar(line[e.CursorCol-1]) {
|
||||||
|
e.CursorCol--
|
||||||
|
}
|
||||||
|
for e.CursorCol > 0 && isWordChar(line[e.CursorCol-1]) {
|
||||||
|
e.CursorCol--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveWordRight moves cursor to next word boundary
|
||||||
|
func (e *EditorState) MoveWordRight() {
|
||||||
|
line := []rune(e.Lines[e.CursorLine])
|
||||||
|
lineLen := len(line)
|
||||||
|
|
||||||
|
if e.CursorCol >= lineLen {
|
||||||
|
if e.CursorLine < len(e.Lines)-1 {
|
||||||
|
e.CursorLine++
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for e.CursorCol < lineLen && isWordChar(line[e.CursorCol]) {
|
||||||
|
e.CursorCol++
|
||||||
|
}
|
||||||
|
for e.CursorCol < lineLen && !isWordChar(line[e.CursorCol]) {
|
||||||
|
e.CursorCol++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Positions navigation ---
|
||||||
|
|
||||||
|
// MoveToLineStart moves cursor to start of line
|
||||||
|
func (e *EditorState) MoveToLineStart() {
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToLineEnd moves cursor to end of line
|
||||||
|
func (e *EditorState) MoveToLineEnd() {
|
||||||
|
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToStart moves cursor to start of document
|
||||||
|
func (e *EditorState) MoveToStart() {
|
||||||
|
e.CursorLine = 0
|
||||||
|
e.CursorCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToEnd moves cursor to end of document
|
||||||
|
func (e *EditorState) MoveToEnd() {
|
||||||
|
e.CursorLine = len(e.Lines) - 1
|
||||||
|
e.CursorCol = len([]rune(e.Lines[e.CursorLine]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page navigation ---
|
||||||
|
|
||||||
|
// PageUp moves cursor up by half viewport
|
||||||
|
func (e *EditorState) PageUp() {
|
||||||
|
delta := e.ViewportH / 2
|
||||||
|
if delta < 1 {
|
||||||
|
delta = 1
|
||||||
|
}
|
||||||
|
e.CursorLine -= delta
|
||||||
|
if e.CursorLine < 0 {
|
||||||
|
e.CursorLine = 0
|
||||||
|
}
|
||||||
|
e.clampCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageDown moves cursor down by half viewport
|
||||||
|
func (e *EditorState) PageDown() {
|
||||||
|
delta := e.ViewportH / 2
|
||||||
|
if delta < 1 {
|
||||||
|
delta = 1
|
||||||
|
}
|
||||||
|
e.CursorLine += delta
|
||||||
|
if e.CursorLine >= len(e.Lines) {
|
||||||
|
e.CursorLine = len(e.Lines) - 1
|
||||||
|
}
|
||||||
|
e.clampCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scroll management ---
|
||||||
|
|
||||||
|
// AdjustScroll updates scroll to keep cursor visible
|
||||||
|
func (e *EditorState) AdjustScroll(viewportW, viewportH int) {
|
||||||
|
e.ViewportW = viewportW
|
||||||
|
e.ViewportH = viewportH
|
||||||
|
|
||||||
|
// Vertical
|
||||||
|
if e.CursorLine < e.ScrollY {
|
||||||
|
e.ScrollY = e.CursorLine
|
||||||
|
}
|
||||||
|
if e.CursorLine >= e.ScrollY+viewportH {
|
||||||
|
e.ScrollY = e.CursorLine - viewportH + 1
|
||||||
|
}
|
||||||
|
if e.ScrollY < 0 {
|
||||||
|
e.ScrollY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal
|
||||||
|
if e.CursorCol < e.ScrollX {
|
||||||
|
e.ScrollX = e.CursorCol
|
||||||
|
}
|
||||||
|
if e.CursorCol >= e.ScrollX+viewportW {
|
||||||
|
e.ScrollX = e.CursorCol - viewportW + 1
|
||||||
|
}
|
||||||
|
if e.ScrollX < 0 {
|
||||||
|
e.ScrollX = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input handling ---
|
||||||
|
|
||||||
|
// HandleKey processes keyboard input, returns true if state changed
|
||||||
|
func (e *EditorState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
|
||||||
|
switch key {
|
||||||
|
case terminal.KeyUp:
|
||||||
|
e.MoveUp()
|
||||||
|
return true
|
||||||
|
case terminal.KeyDown:
|
||||||
|
e.MoveDown()
|
||||||
|
return true
|
||||||
|
case terminal.KeyLeft:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
e.MoveWordLeft()
|
||||||
|
} else {
|
||||||
|
e.MoveLeft()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case terminal.KeyRight:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
e.MoveWordRight()
|
||||||
|
} else {
|
||||||
|
e.MoveRight()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case terminal.KeyHome:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
e.MoveToStart()
|
||||||
|
} else {
|
||||||
|
e.MoveToLineStart()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case terminal.KeyEnd:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
e.MoveToEnd()
|
||||||
|
} else {
|
||||||
|
e.MoveToLineEnd()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case terminal.KeyPageUp:
|
||||||
|
e.PageUp()
|
||||||
|
return true
|
||||||
|
case terminal.KeyPageDown:
|
||||||
|
e.PageDown()
|
||||||
|
return true
|
||||||
|
case terminal.KeyEnter:
|
||||||
|
e.InsertNewline()
|
||||||
|
return true
|
||||||
|
case terminal.KeyBackspace:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
return e.DeleteWordBackward()
|
||||||
|
}
|
||||||
|
return e.DeleteBackward()
|
||||||
|
case terminal.KeyDelete:
|
||||||
|
if mod&terminal.ModCtrl != 0 {
|
||||||
|
return e.DeleteWordForward()
|
||||||
|
}
|
||||||
|
return e.DeleteForward()
|
||||||
|
case terminal.KeyCtrlA:
|
||||||
|
e.MoveToLineStart()
|
||||||
|
return true
|
||||||
|
case terminal.KeyCtrlE:
|
||||||
|
e.MoveToLineEnd()
|
||||||
|
return true
|
||||||
|
case terminal.KeyCtrlK:
|
||||||
|
return e.DeleteToEndOfLine()
|
||||||
|
case terminal.KeyCtrlU:
|
||||||
|
return e.DeleteToStartOfLine()
|
||||||
|
case terminal.KeyCtrlW:
|
||||||
|
return e.DeleteWordBackward()
|
||||||
|
case terminal.KeyRune:
|
||||||
|
if r >= 32 {
|
||||||
|
e.Insert(r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+228
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputOpts configures single-line input field
|
||||||
|
type InputOpts struct {
|
||||||
|
Label string
|
||||||
|
LabelFg terminal.RGB
|
||||||
|
Text string
|
||||||
|
Cursor int // Cursor position in text (rune index)
|
||||||
|
CursorBg terminal.RGB
|
||||||
|
TextFg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input renders labeled text input field on row y, handling cursor display and horizontal scrolling
|
||||||
|
func (r Region) Input(y int, opts InputOpts) {
|
||||||
|
if y < 0 || y >= r.H || r.W < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
x := 0
|
||||||
|
|
||||||
|
// Label
|
||||||
|
if opts.Label != "" {
|
||||||
|
for _, ch := range opts.Label {
|
||||||
|
if x >= r.W {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.Cell(x, y, ch, opts.LabelFg, opts.Bg, terminal.AttrNone)
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available width for input text
|
||||||
|
inputW := r.W - x
|
||||||
|
if inputW < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(opts.Text)
|
||||||
|
cursor := opts.Cursor
|
||||||
|
if cursor > len(runes) {
|
||||||
|
cursor = len(runes)
|
||||||
|
}
|
||||||
|
if cursor < 0 {
|
||||||
|
cursor = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal scroll to keep cursor visible
|
||||||
|
scroll := 0
|
||||||
|
if cursor >= inputW-1 {
|
||||||
|
scroll = cursor - inputW + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render visible portion
|
||||||
|
for i := 0; i < inputW; i++ {
|
||||||
|
runeIdx := scroll + i
|
||||||
|
ch := ' '
|
||||||
|
if runeIdx < len(runes) {
|
||||||
|
ch = runes[runeIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
bg := opts.Bg
|
||||||
|
if runeIdx == cursor {
|
||||||
|
bg = opts.CursorBg
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Cell(x+i, y, ch, opts.TextFg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor at end if past text
|
||||||
|
if cursor == len(runes) && cursor-scroll < inputW {
|
||||||
|
r.Cell(x+cursor-scroll, y, ' ', opts.TextFg, opts.CursorBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyValue renders right-aligned key, separator, left-aligned value on row
|
||||||
|
// Key width auto-sizes based on content, capped at 40% of region width
|
||||||
|
// Value gets remainder, minimum 30% of region width
|
||||||
|
func (r Region) KeyValue(y int, key, value string, keyStyle, valStyle Style, sep rune) {
|
||||||
|
if y < 0 || y >= r.H || r.W < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyLen := RuneLen(key)
|
||||||
|
|
||||||
|
// Dynamic allocation: key gets what it needs up to 40%
|
||||||
|
maxKeyW := (r.W * 2) / 5 // 40%
|
||||||
|
minValW := (r.W * 3) / 10 // 30%
|
||||||
|
|
||||||
|
keyW := keyLen
|
||||||
|
if keyW > maxKeyW {
|
||||||
|
keyW = maxKeyW
|
||||||
|
}
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
valW := r.W - keyW - 1 // -1 for separator
|
||||||
|
if valW < minValW && r.W > minValW+2 {
|
||||||
|
// Reclaim from key to meet minimum value width
|
||||||
|
valW = minValW
|
||||||
|
keyW = r.W - valW - 1
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
valW = r.W - 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valW < 1 {
|
||||||
|
valW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate key if needed
|
||||||
|
keyRunes := []rune(key)
|
||||||
|
if len(keyRunes) > keyW {
|
||||||
|
if keyW > 1 {
|
||||||
|
keyRunes = keyRunes[:keyW-1]
|
||||||
|
keyRunes = append(keyRunes, '…')
|
||||||
|
} else {
|
||||||
|
keyRunes = keyRunes[:1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate value if needed
|
||||||
|
valRunes := []rune(value)
|
||||||
|
if len(valRunes) > valW {
|
||||||
|
if valW > 1 {
|
||||||
|
valRunes = valRunes[:valW-1]
|
||||||
|
valRunes = append(valRunes, '…')
|
||||||
|
} else {
|
||||||
|
valRunes = valRunes[:1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-align key within allocated width
|
||||||
|
keyX := keyW - len(keyRunes)
|
||||||
|
for i, ch := range keyRunes {
|
||||||
|
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
||||||
|
|
||||||
|
// Left-align value
|
||||||
|
for i, ch := range valRunes {
|
||||||
|
r.Cell(keyW+1+i, y, ch, valStyle.Fg, valStyle.Bg, valStyle.Attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyValueWrap renders key-value with value wrapping to subsequent lines
|
||||||
|
// Returns number of lines used
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// key: value text that is
|
||||||
|
// long and wraps to
|
||||||
|
// next line
|
||||||
|
func (r Region) KeyValueWrap(y int, key, value string, keyStyle, valStyle Style, sep rune) int {
|
||||||
|
if y < 0 || y >= r.H || r.W < 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
keyLen := RuneLen(key)
|
||||||
|
|
||||||
|
// Dynamic allocation same as KeyValue
|
||||||
|
maxKeyW := (r.W * 2) / 5 // 40%
|
||||||
|
minValW := (r.W * 3) / 10 // 30%
|
||||||
|
|
||||||
|
keyW := keyLen
|
||||||
|
if keyW > maxKeyW {
|
||||||
|
keyW = maxKeyW
|
||||||
|
}
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
valW := r.W - keyW - 1 // -1 for separator
|
||||||
|
if valW < minValW && r.W > minValW+2 {
|
||||||
|
valW = minValW
|
||||||
|
keyW = r.W - valW - 1
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
valW = r.W - 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valW < 1 {
|
||||||
|
valW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate key if needed
|
||||||
|
keyRunes := []rune(key)
|
||||||
|
if len(keyRunes) > keyW {
|
||||||
|
if keyW > 1 {
|
||||||
|
keyRunes = keyRunes[:keyW-1]
|
||||||
|
keyRunes = append(keyRunes, '…')
|
||||||
|
} else {
|
||||||
|
keyRunes = keyRunes[:1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-align key within allocated width
|
||||||
|
keyX := keyW - len(keyRunes)
|
||||||
|
for i, ch := range keyRunes {
|
||||||
|
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
||||||
|
|
||||||
|
// Wrap value text
|
||||||
|
valueX := keyW + 1
|
||||||
|
lines := WrapText(value, valW)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := 0
|
||||||
|
for i, line := range lines {
|
||||||
|
lineY := y + i
|
||||||
|
if lineY >= r.H {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.Text(valueX, lineY, line, valStyle.Fg, valStyle.Bg, valStyle.Attr)
|
||||||
|
rendered++
|
||||||
|
}
|
||||||
|
|
||||||
|
if rendered < 1 {
|
||||||
|
rendered = 1
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeasureKeyValueWrap calculates lines needed for KeyValueWrap without rendering
|
||||||
|
// Useful for layout pre-calculation
|
||||||
|
func (r Region) MeasureKeyValueWrap(key, value string) int {
|
||||||
|
if r.W < 3 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
keyLen := RuneLen(key)
|
||||||
|
maxKeyW := (r.W * 2) / 5
|
||||||
|
minValW := (r.W * 3) / 10
|
||||||
|
|
||||||
|
keyW := keyLen
|
||||||
|
if keyW > maxKeyW {
|
||||||
|
keyW = maxKeyW
|
||||||
|
}
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
valW := r.W - keyW - 1
|
||||||
|
if valW < minValW && r.W > minValW+2 {
|
||||||
|
valW = minValW
|
||||||
|
keyW = r.W - valW - 1
|
||||||
|
if keyW < 1 {
|
||||||
|
keyW = 1
|
||||||
|
valW = r.W - 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valW < 1 {
|
||||||
|
valW = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := WrapText(value, valW)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return len(lines)
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// --- Centering ---
|
||||||
|
|
||||||
|
// Center returns a centered region of given size within outer
|
||||||
|
func Center(outer Region, w, h int) Region {
|
||||||
|
x := (outer.W - w) / 2
|
||||||
|
y := (outer.H - h) / 2
|
||||||
|
return outer.Sub(x, y, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ratio-based splitting ---
|
||||||
|
// Ratios are normalized if they don't sum to 1.0
|
||||||
|
|
||||||
|
// SplitH splits region horizontally by ratios (0.0-1.0)
|
||||||
|
func SplitH(r Region, ratios ...float64) []Region {
|
||||||
|
if len(ratios) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize ratios
|
||||||
|
var sum float64
|
||||||
|
for _, ratio := range ratios {
|
||||||
|
sum += ratio
|
||||||
|
}
|
||||||
|
if sum <= 0 {
|
||||||
|
sum = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
regions := make([]Region, len(ratios))
|
||||||
|
x := 0
|
||||||
|
remaining := r.W
|
||||||
|
|
||||||
|
for i, ratio := range ratios {
|
||||||
|
var w int
|
||||||
|
if i == len(ratios)-1 {
|
||||||
|
w = remaining // Last one gets remainder to avoid rounding gaps
|
||||||
|
} else {
|
||||||
|
w = int((float64(r.W) * ratio / sum) + 0.5) // Round to nearest cell
|
||||||
|
if w > remaining {
|
||||||
|
w = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
regions[i] = r.Sub(x, 0, w, r.H)
|
||||||
|
x += w
|
||||||
|
remaining -= w
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitV splits region vertically by ratios (0.0-1.0)
|
||||||
|
func SplitV(r Region, ratios ...float64) []Region {
|
||||||
|
if len(ratios) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum float64
|
||||||
|
for _, ratio := range ratios {
|
||||||
|
sum += ratio
|
||||||
|
}
|
||||||
|
if sum <= 0 {
|
||||||
|
sum = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
regions := make([]Region, len(ratios))
|
||||||
|
y := 0
|
||||||
|
remaining := r.H
|
||||||
|
|
||||||
|
for i, ratio := range ratios {
|
||||||
|
var h int
|
||||||
|
if i == len(ratios)-1 {
|
||||||
|
h = remaining
|
||||||
|
} else {
|
||||||
|
h = int(float64(r.H) * ratio / sum)
|
||||||
|
}
|
||||||
|
regions[i] = r.Sub(0, y, r.W, h)
|
||||||
|
y += h
|
||||||
|
remaining -= h
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fixed-size splitting ---
|
||||||
|
|
||||||
|
// SplitHFixed splits with fixed left width, rest to right
|
||||||
|
func SplitHFixed(r Region, leftW int) (left, right Region) {
|
||||||
|
if leftW > r.W {
|
||||||
|
leftW = r.W
|
||||||
|
}
|
||||||
|
if leftW < 0 {
|
||||||
|
leftW = 0
|
||||||
|
}
|
||||||
|
left = r.Sub(0, 0, leftW, r.H)
|
||||||
|
right = r.Sub(leftW, 0, r.W-leftW, r.H)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitVFixed splits with fixed top height, rest to bottom
|
||||||
|
func SplitVFixed(r Region, topH int) (top, bottom Region) {
|
||||||
|
if topH > r.H {
|
||||||
|
topH = r.H
|
||||||
|
}
|
||||||
|
if topH < 0 {
|
||||||
|
topH = 0
|
||||||
|
}
|
||||||
|
top = r.Sub(0, 0, r.W, topH)
|
||||||
|
bottom = r.Sub(0, topH, r.W, r.H-topH)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Equal splitting ---
|
||||||
|
|
||||||
|
// SplitHEqual splits region into n equal-width columns
|
||||||
|
// gap specifies spacing between columns (e.g., 1 for divider lines)
|
||||||
|
// Returns n regions positioned with gaps between them
|
||||||
|
func SplitHEqual(r Region, n, gap int) []Region {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
return []Region{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGaps := gap * (n - 1)
|
||||||
|
availW := r.W - totalGaps
|
||||||
|
if availW < n {
|
||||||
|
availW = n
|
||||||
|
}
|
||||||
|
|
||||||
|
baseW := availW / n
|
||||||
|
extra := availW % n
|
||||||
|
|
||||||
|
regions := make([]Region, n)
|
||||||
|
x := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
w := baseW
|
||||||
|
if i < extra {
|
||||||
|
w++
|
||||||
|
}
|
||||||
|
regions[i] = r.Sub(x, 0, w, r.H)
|
||||||
|
x += w + gap
|
||||||
|
}
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitVEqual splits region into n equal-height rows
|
||||||
|
// gap specifies spacing between rows
|
||||||
|
func SplitVEqual(r Region, n, gap int) []Region {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
return []Region{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGaps := gap * (n - 1)
|
||||||
|
availH := r.H - totalGaps
|
||||||
|
if availH < n {
|
||||||
|
availH = n
|
||||||
|
}
|
||||||
|
|
||||||
|
baseH := availH / n
|
||||||
|
extra := availH % n
|
||||||
|
|
||||||
|
regions := make([]Region, n)
|
||||||
|
y := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
h := baseH
|
||||||
|
if i < extra {
|
||||||
|
h++
|
||||||
|
}
|
||||||
|
regions[i] = r.Sub(0, y, r.W, h)
|
||||||
|
y += h + gap
|
||||||
|
}
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid layout ---
|
||||||
|
|
||||||
|
// Columns calculates how many columns fit in width
|
||||||
|
func Columns(availableW, itemW, gap int) int {
|
||||||
|
if itemW <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if availableW < itemW {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// First item has no gap, subsequent items need gap + itemW
|
||||||
|
cols := 1 + (availableW-itemW)/(itemW+gap)
|
||||||
|
if cols < 0 {
|
||||||
|
cols = 0
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridLayout returns a grid of equally sized regions
|
||||||
|
func GridLayout(r Region, cols, rows, gapX, gapY int) []Region {
|
||||||
|
if cols <= 0 || rows <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cellW := (r.W - gapX*(cols-1)) / cols
|
||||||
|
cellH := (r.H - gapY*(rows-1)) / rows
|
||||||
|
|
||||||
|
if cellW < 1 {
|
||||||
|
cellW = 1
|
||||||
|
}
|
||||||
|
if cellH < 1 {
|
||||||
|
cellH = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
regions := make([]Region, cols*rows)
|
||||||
|
for row := 0; row < rows; row++ {
|
||||||
|
for col := 0; col < cols; col++ {
|
||||||
|
x := col * (cellW + gapX)
|
||||||
|
y := row * (cellH + gapY)
|
||||||
|
regions[row*cols+col] = r.Sub(x, y, cellW, cellH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Divider positioning ---
|
||||||
|
|
||||||
|
// DividerPositions returns X coordinates for vertical dividers between equal columns
|
||||||
|
// Use with VLine to draw dividers in gaps created by SplitHEqual
|
||||||
|
func DividerPositions(regionW, n, gap int) []int {
|
||||||
|
if n <= 1 || gap <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGaps := gap * (n - 1)
|
||||||
|
availW := regionW - totalGaps
|
||||||
|
if availW < n {
|
||||||
|
availW = n
|
||||||
|
}
|
||||||
|
|
||||||
|
baseW := availW / n
|
||||||
|
extra := availW % n
|
||||||
|
|
||||||
|
positions := make([]int, n-1)
|
||||||
|
x := 0
|
||||||
|
for i := 0; i < n-1; i++ {
|
||||||
|
w := baseW
|
||||||
|
if i < extra {
|
||||||
|
w++
|
||||||
|
}
|
||||||
|
x += w
|
||||||
|
positions[i] = x
|
||||||
|
x += gap
|
||||||
|
}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
// HDividerPositions returns Y coordinates for horizontal dividers between equal rows
|
||||||
|
func HDividerPositions(regionH, n, gap int) []int {
|
||||||
|
if n <= 1 || gap <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGaps := gap * (n - 1)
|
||||||
|
availH := regionH - totalGaps
|
||||||
|
if availH < n {
|
||||||
|
availH = n
|
||||||
|
}
|
||||||
|
|
||||||
|
baseH := availH / n
|
||||||
|
extra := availH % n
|
||||||
|
|
||||||
|
positions := make([]int, n-1)
|
||||||
|
y := 0
|
||||||
|
for i := 0; i < n-1; i++ {
|
||||||
|
h := baseH
|
||||||
|
if i < extra {
|
||||||
|
h++
|
||||||
|
}
|
||||||
|
y += h
|
||||||
|
positions[i] = y
|
||||||
|
y += gap
|
||||||
|
}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Responsive utilities ---
|
||||||
|
|
||||||
|
// FitOrScroll returns true if content exceeds available height
|
||||||
|
func FitOrScroll(contentH, availableH int) bool {
|
||||||
|
return contentH > availableH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakpoints should be in descending order
|
||||||
|
|
||||||
|
// BreakpointH returns index of first breakpoint <= w, returns len(breakpoints) if w is less than all breakpoints
|
||||||
|
func BreakpointH(w int, breakpoints ...int) int {
|
||||||
|
for i, bp := range breakpoints {
|
||||||
|
if w >= bp {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(breakpoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreakpointV returns index of first breakpoint <= h
|
||||||
|
func BreakpointV(h int, breakpoints ...int) int {
|
||||||
|
return BreakpointH(h, breakpoints...)
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// ListItem represents a single row in a scrollable list
|
||||||
|
type ListItem struct {
|
||||||
|
Indent int // Left padding in cells
|
||||||
|
Icon rune // Expand indicator or bullet, 0 = none
|
||||||
|
IconFg terminal.RGB
|
||||||
|
Check CheckState // CheckNone to skip checkbox
|
||||||
|
CheckFg terminal.RGB
|
||||||
|
Text string
|
||||||
|
TextStyle Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOpts configures list rendering
|
||||||
|
type ListOpts struct {
|
||||||
|
CursorBg terminal.RGB
|
||||||
|
DefaultBg terminal.RGB
|
||||||
|
IconWidth int // Width reserved for icon, default 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// List renders scrollable list items within region, returns number of rows rendered
|
||||||
|
func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
|
||||||
|
if r.H < 1 || len(items) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
iconW := opts.IconWidth
|
||||||
|
if iconW == 0 {
|
||||||
|
iconW = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := 0
|
||||||
|
for y := 0; y < r.H; y++ {
|
||||||
|
idx := scroll + y
|
||||||
|
if idx >= len(items) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
item := items[idx]
|
||||||
|
isCursor := idx == cursor
|
||||||
|
|
||||||
|
// Row background
|
||||||
|
bg := opts.DefaultBg
|
||||||
|
if isCursor {
|
||||||
|
bg = opts.CursorBg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear row
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := item.Indent
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
if item.Icon != 0 && x < r.W {
|
||||||
|
r.Cell(x, y, item.Icon, item.IconFg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
x += iconW
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
if item.Check != CheckNone || item.CheckFg != (terminal.RGB{}) {
|
||||||
|
if x+3 <= r.W {
|
||||||
|
var ch rune
|
||||||
|
switch item.Check {
|
||||||
|
case CheckNone:
|
||||||
|
ch = ' '
|
||||||
|
case CheckPartial:
|
||||||
|
ch = 'o'
|
||||||
|
case CheckFull:
|
||||||
|
ch = 'x'
|
||||||
|
case CheckPlus:
|
||||||
|
ch = '+'
|
||||||
|
}
|
||||||
|
r.Cell(x, y, '[', item.CheckFg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(x+1, y, ch, item.CheckFg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(x+2, y, ']', item.CheckFg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
x += 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text
|
||||||
|
textStyle := item.TextStyle
|
||||||
|
if textStyle.Bg == (terminal.RGB{}) {
|
||||||
|
textStyle.Bg = bg
|
||||||
|
}
|
||||||
|
text := item.Text
|
||||||
|
if x+RuneLen(text) > r.W {
|
||||||
|
text = Truncate(text, r.W-x)
|
||||||
|
}
|
||||||
|
r.TextStyled(x, y, text, textStyle)
|
||||||
|
|
||||||
|
rendered++
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
+170
@@ -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:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModalOpts configures modal overlay rendering
|
||||||
|
type ModalOpts struct {
|
||||||
|
Title string
|
||||||
|
Hint string // Right-aligned hint text
|
||||||
|
Border LineType
|
||||||
|
BorderFg terminal.RGB
|
||||||
|
TitleFg terminal.RGB
|
||||||
|
HintFg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal fills region with background, draws border with title/hint, returns content region
|
||||||
|
func (r Region) Modal(opts ModalOpts) Region {
|
||||||
|
if r.W < 5 || r.H < 3 {
|
||||||
|
return r.Sub(1, 1, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill entire region
|
||||||
|
r.Fill(opts.Bg)
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
r.Box(opts.Border, opts.BorderFg)
|
||||||
|
|
||||||
|
// Title centered on top edge
|
||||||
|
if opts.Title != "" {
|
||||||
|
title := " " + opts.Title + " "
|
||||||
|
titleLen := RuneLen(title)
|
||||||
|
if titleLen > r.W-4 {
|
||||||
|
title = Truncate(title, r.W-4)
|
||||||
|
titleLen = RuneLen(title)
|
||||||
|
}
|
||||||
|
x := (r.W - titleLen) / 2
|
||||||
|
for i, ch := range title {
|
||||||
|
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint right-aligned on top edge
|
||||||
|
if opts.Hint != "" {
|
||||||
|
hint := opts.Hint
|
||||||
|
hintLen := RuneLen(hint)
|
||||||
|
if hintLen > r.W/3 {
|
||||||
|
hint = Truncate(hint, r.W/3)
|
||||||
|
hintLen = RuneLen(hint)
|
||||||
|
}
|
||||||
|
x := r.W - hintLen - 2
|
||||||
|
if x < r.W/2 {
|
||||||
|
x = r.W / 2
|
||||||
|
}
|
||||||
|
for i, ch := range hint {
|
||||||
|
if x+i >= r.W-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.Cell(x+i, 0, ch, opts.HintFg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return content region
|
||||||
|
return r.Sub(1, 1, r.W-2, r.H-2)
|
||||||
|
}
|
||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// OverlayStyle specifies overlay appearance
|
||||||
|
type OverlayStyle uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
OverlayFullscreen OverlayStyle = iota // No border, fills region
|
||||||
|
OverlayModal // Centered box with border
|
||||||
|
OverlayFloating // Positioned box with shadow
|
||||||
|
OverlayBorderTitle // Title embedded in top border line
|
||||||
|
)
|
||||||
|
|
||||||
|
// OverlayOpts configures overlay rendering
|
||||||
|
type OverlayOpts struct {
|
||||||
|
Style OverlayStyle
|
||||||
|
Title string
|
||||||
|
Border LineType
|
||||||
|
Bg terminal.RGB
|
||||||
|
Fg terminal.RGB // Border and title color
|
||||||
|
TitleBg terminal.RGB // Title bar background, zero = same as Fg
|
||||||
|
TitleFg terminal.RGB // Title text color, zero = same as Bg
|
||||||
|
|
||||||
|
// Modal/Floating positioning (ignored for Fullscreen)
|
||||||
|
Width int // 0 = 80% of region
|
||||||
|
Height int // 0 = 80% of region
|
||||||
|
X, Y int // Offset from center, 0 = centered
|
||||||
|
|
||||||
|
// Shadow for Floating style
|
||||||
|
ShadowColor terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOverlayOpts returns sensible defaults for modal overlay
|
||||||
|
func DefaultOverlayOpts(title string) OverlayOpts {
|
||||||
|
return OverlayOpts{
|
||||||
|
Style: OverlayModal,
|
||||||
|
Title: title,
|
||||||
|
Border: LineDouble,
|
||||||
|
Bg: terminal.RGB{R: 25, G: 25, B: 35},
|
||||||
|
Fg: terminal.RGB{R: 100, G: 140, B: 180},
|
||||||
|
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
|
||||||
|
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullscreenOverlayOpts returns opts for fullscreen overlay with title bar
|
||||||
|
func FullscreenOverlayOpts(title string) OverlayOpts {
|
||||||
|
return OverlayOpts{
|
||||||
|
Style: OverlayFullscreen,
|
||||||
|
Title: title,
|
||||||
|
Bg: terminal.RGB{R: 20, G: 20, B: 30},
|
||||||
|
Fg: terminal.RGB{R: 100, G: 140, B: 180},
|
||||||
|
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
|
||||||
|
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayResult contains regions returned by Overlay rendering
|
||||||
|
type OverlayResult struct {
|
||||||
|
Outer Region // Full overlay bounds (including border/title)
|
||||||
|
Content Region // Inner content area
|
||||||
|
TitleY int // Y position of title bar in Outer, -1 if no title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay renders an overlay and returns content region for caller to populate
|
||||||
|
// Caller should render content into result.Content after this call
|
||||||
|
func (r Region) Overlay(opts OverlayOpts) OverlayResult {
|
||||||
|
if r.W < 3 || r.H < 3 {
|
||||||
|
return OverlayResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opts.Style {
|
||||||
|
case OverlayFullscreen:
|
||||||
|
return r.renderFullscreenOverlay(opts)
|
||||||
|
case OverlayModal:
|
||||||
|
return r.renderModalOverlay(opts)
|
||||||
|
case OverlayFloating:
|
||||||
|
return r.renderFloatingOverlay(opts)
|
||||||
|
case OverlayBorderTitle:
|
||||||
|
return r.renderBorderTitleOverlay(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OverlayResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) renderFullscreenOverlay(opts OverlayOpts) OverlayResult {
|
||||||
|
r.Fill(opts.Bg)
|
||||||
|
|
||||||
|
result := OverlayResult{
|
||||||
|
Outer: r,
|
||||||
|
TitleY: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
contentY := 0
|
||||||
|
contentH := r.H
|
||||||
|
|
||||||
|
// Title bar
|
||||||
|
if opts.Title != "" {
|
||||||
|
titleBg := opts.TitleBg
|
||||||
|
if titleBg == (terminal.RGB{}) {
|
||||||
|
titleBg = opts.Fg
|
||||||
|
}
|
||||||
|
titleFg := opts.TitleFg
|
||||||
|
if titleFg == (terminal.RGB{}) {
|
||||||
|
titleFg = opts.Bg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill title row
|
||||||
|
for x := 0; x < r.W; x++ {
|
||||||
|
r.Cell(x, 0, ' ', titleFg, titleBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center title text
|
||||||
|
title := opts.Title
|
||||||
|
if RuneLen(title) > r.W-4 {
|
||||||
|
title = Truncate(title, r.W-4)
|
||||||
|
}
|
||||||
|
titleX := (r.W - RuneLen(title)) / 2
|
||||||
|
r.Text(titleX, 0, title, titleFg, titleBg, terminal.AttrBold)
|
||||||
|
|
||||||
|
result.TitleY = 0
|
||||||
|
contentY = 1
|
||||||
|
contentH = r.H - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Content = r.Sub(0, contentY, r.W, contentH)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) renderModalOverlay(opts OverlayOpts) OverlayResult {
|
||||||
|
// Calculate dimensions
|
||||||
|
w := opts.Width
|
||||||
|
if w <= 0 {
|
||||||
|
w = r.W * 80 / 100
|
||||||
|
}
|
||||||
|
h := opts.Height
|
||||||
|
if h <= 0 {
|
||||||
|
h = r.H * 80 / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to region
|
||||||
|
if w > r.W-2 {
|
||||||
|
w = r.W - 2
|
||||||
|
}
|
||||||
|
if h > r.H-2 {
|
||||||
|
h = r.H - 2
|
||||||
|
}
|
||||||
|
if w < 5 {
|
||||||
|
w = 5
|
||||||
|
}
|
||||||
|
if h < 3 {
|
||||||
|
h = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center with offset
|
||||||
|
x := (r.W-w)/2 + opts.X
|
||||||
|
y := (r.H-h)/2 + opts.Y
|
||||||
|
|
||||||
|
// Clamp position
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
if x+w > r.W {
|
||||||
|
x = r.W - w
|
||||||
|
}
|
||||||
|
if y+h > r.H {
|
||||||
|
y = r.H - h
|
||||||
|
}
|
||||||
|
|
||||||
|
outer := r.Sub(x, y, w, h)
|
||||||
|
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
|
||||||
|
|
||||||
|
result := OverlayResult{
|
||||||
|
Outer: outer,
|
||||||
|
TitleY: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content inset by border
|
||||||
|
contentX := 1
|
||||||
|
contentY := 1
|
||||||
|
contentW := w - 2
|
||||||
|
contentH := h - 2
|
||||||
|
|
||||||
|
// Title in top border
|
||||||
|
if opts.Title != "" && contentW > 2 {
|
||||||
|
titleBg := opts.TitleBg
|
||||||
|
if titleBg == (terminal.RGB{}) {
|
||||||
|
titleBg = opts.Fg
|
||||||
|
}
|
||||||
|
titleFg := opts.TitleFg
|
||||||
|
if titleFg == (terminal.RGB{}) {
|
||||||
|
titleFg = opts.Bg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill title row inside border
|
||||||
|
for i := 0; i < contentW; i++ {
|
||||||
|
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := opts.Title
|
||||||
|
if RuneLen(title) > contentW-2 {
|
||||||
|
title = Truncate(title, contentW-2)
|
||||||
|
}
|
||||||
|
titleX := contentX + (contentW-RuneLen(title))/2
|
||||||
|
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
|
||||||
|
|
||||||
|
result.TitleY = contentY
|
||||||
|
contentY++
|
||||||
|
contentH--
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentH < 1 {
|
||||||
|
contentH = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) renderFloatingOverlay(opts OverlayOpts) OverlayResult {
|
||||||
|
// Same as modal but with shadow
|
||||||
|
shadowColor := opts.ShadowColor
|
||||||
|
if shadowColor == (terminal.RGB{}) {
|
||||||
|
shadowColor = terminal.RGB{R: 10, G: 10, B: 15}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dimensions (same as modal)
|
||||||
|
w := opts.Width
|
||||||
|
if w <= 0 {
|
||||||
|
w = r.W * 80 / 100
|
||||||
|
}
|
||||||
|
h := opts.Height
|
||||||
|
if h <= 0 {
|
||||||
|
h = r.H * 80 / 100
|
||||||
|
}
|
||||||
|
if w > r.W-3 {
|
||||||
|
w = r.W - 3
|
||||||
|
}
|
||||||
|
if h > r.H-2 {
|
||||||
|
h = r.H - 2
|
||||||
|
}
|
||||||
|
if w < 5 {
|
||||||
|
w = 5
|
||||||
|
}
|
||||||
|
if h < 3 {
|
||||||
|
h = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
x := (r.W-w)/2 + opts.X - 1 // Offset for shadow
|
||||||
|
y := (r.H-h)/2 + opts.Y
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
if x+w+1 > r.W {
|
||||||
|
x = r.W - w - 1
|
||||||
|
}
|
||||||
|
if y+h+1 > r.H {
|
||||||
|
y = r.H - h - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw shadow
|
||||||
|
shadowR := r.Sub(x+1, y+1, w, h)
|
||||||
|
shadowR.Fill(shadowColor)
|
||||||
|
|
||||||
|
// Draw box over shadow
|
||||||
|
outer := r.Sub(x, y, w, h)
|
||||||
|
outer.BoxFilled(opts.Border, opts.Fg, opts.Bg)
|
||||||
|
|
||||||
|
result := OverlayResult{
|
||||||
|
Outer: outer,
|
||||||
|
TitleY: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
contentX := 1
|
||||||
|
contentY := 1
|
||||||
|
contentW := w - 2
|
||||||
|
contentH := h - 2
|
||||||
|
|
||||||
|
if opts.Title != "" && contentW > 2 {
|
||||||
|
titleBg := opts.TitleBg
|
||||||
|
if titleBg == (terminal.RGB{}) {
|
||||||
|
titleBg = opts.Fg
|
||||||
|
}
|
||||||
|
titleFg := opts.TitleFg
|
||||||
|
if titleFg == (terminal.RGB{}) {
|
||||||
|
titleFg = opts.Bg
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < contentW; i++ {
|
||||||
|
outer.Cell(contentX+i, contentY, ' ', titleFg, titleBg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := opts.Title
|
||||||
|
if RuneLen(title) > contentW-2 {
|
||||||
|
title = Truncate(title, contentW-2)
|
||||||
|
}
|
||||||
|
titleX := contentX + (contentW-RuneLen(title))/2
|
||||||
|
outer.Text(titleX, contentY, title, titleFg, titleBg, terminal.AttrBold)
|
||||||
|
|
||||||
|
result.TitleY = contentY
|
||||||
|
contentY++
|
||||||
|
contentH--
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentH < 1 {
|
||||||
|
contentH = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Content = outer.Sub(contentX, contentY, contentW, contentH)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) renderBorderTitleOverlay(opts OverlayOpts) OverlayResult {
|
||||||
|
r.BoxFilled(opts.Border, opts.Fg, opts.Bg)
|
||||||
|
|
||||||
|
result := OverlayResult{
|
||||||
|
Outer: r,
|
||||||
|
TitleY: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Title != "" && r.W > 6 {
|
||||||
|
titleFg := opts.TitleFg
|
||||||
|
if titleFg == (terminal.RGB{}) {
|
||||||
|
titleFg = opts.Fg
|
||||||
|
}
|
||||||
|
title := " " + opts.Title + " "
|
||||||
|
if RuneLen(title) > r.W-4 {
|
||||||
|
title = Truncate(title, r.W-4)
|
||||||
|
}
|
||||||
|
titleX := (r.W - RuneLen(title)) / 2
|
||||||
|
r.Text(titleX, 0, title, titleFg, opts.Bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Content = r.Sub(1, 1, r.W-2, r.H-2)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayState manages overlay visibility and content state
|
||||||
|
type OverlayState struct {
|
||||||
|
Visible bool
|
||||||
|
Opts OverlayOpts
|
||||||
|
Data any // Application-specific state
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOverlayState creates hidden overlay state
|
||||||
|
func NewOverlayState(opts OverlayOpts) *OverlayState {
|
||||||
|
return &OverlayState{
|
||||||
|
Visible: false,
|
||||||
|
Opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show makes overlay visible
|
||||||
|
func (o *OverlayState) Show() {
|
||||||
|
o.Visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide makes overlay invisible
|
||||||
|
func (o *OverlayState) Hide() {
|
||||||
|
o.Visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switches visibility
|
||||||
|
func (o *OverlayState) Toggle() {
|
||||||
|
o.Visible = !o.Visible
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaneOpts configures pane rendering
|
||||||
|
type PaneOpts struct {
|
||||||
|
Title string
|
||||||
|
Border LineType
|
||||||
|
BorderFg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
TitleFg terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pane draws bordered pane with optional title, returns content region
|
||||||
|
// Content region is inside border, below title row if present
|
||||||
|
func (r Region) Pane(opts PaneOpts) Region {
|
||||||
|
if r.W < 3 || r.H < 3 {
|
||||||
|
return r.Sub(1, 1, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
r.Fill(opts.Bg)
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
r.Box(opts.Border, opts.BorderFg)
|
||||||
|
|
||||||
|
// Title on top edge
|
||||||
|
headerH := 0
|
||||||
|
if opts.Title != "" {
|
||||||
|
headerH = 1
|
||||||
|
title := " " + opts.Title + " "
|
||||||
|
if RuneLen(title) > r.W-4 {
|
||||||
|
title = Truncate(title, r.W-4)
|
||||||
|
}
|
||||||
|
x := 2
|
||||||
|
for i, ch := range title {
|
||||||
|
if x+i >= r.W-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.Cell(x+i, 0, ch, opts.TitleFg, opts.Bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return content region (inside border, below title)
|
||||||
|
return r.Sub(1, 1+headerH, r.W-2, r.H-2-headerH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TitledPane fills region with background, draws centered title at top, returns content region
|
||||||
|
// Content region starts at row 1 with full width
|
||||||
|
func (r Region) TitledPane(title string, titleFg, bg terminal.RGB) Region {
|
||||||
|
r.Fill(bg)
|
||||||
|
if title != "" && r.H > 0 {
|
||||||
|
r.TextCenter(0, title, titleFg, bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
if r.H <= 1 {
|
||||||
|
return r.Sub(0, 0, r.W, 0)
|
||||||
|
}
|
||||||
|
return r.Sub(0, 1, r.W, r.H-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TitledPaneFocused is TitledPane with focus-dependent background
|
||||||
|
func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg terminal.RGB, focused bool) Region {
|
||||||
|
if focused {
|
||||||
|
bg = focusBg
|
||||||
|
}
|
||||||
|
return r.TitledPane(title, titleFg, bg)
|
||||||
|
}
|
||||||
+124
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// ProgressType specifies progress indicator variant
|
||||||
|
type ProgressType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProgressSpinner ProgressType = iota // Animated spinner
|
||||||
|
ProgressDeterminate // Bar with percentage
|
||||||
|
ProgressIndeterminate // Marquee animation
|
||||||
|
ProgressPulse // Pulsing bar
|
||||||
|
ProgressDots // Animated dots
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressStyle defines visual appearance
|
||||||
|
type ProgressStyle uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProgressStyleMinimal ProgressStyle = iota // Text only
|
||||||
|
ProgressStyleBox // Single border
|
||||||
|
ProgressStyleDouble // Double border
|
||||||
|
ProgressStyleRounded // Rounded border
|
||||||
|
ProgressStyleShadow // Box with shadow
|
||||||
|
ProgressStyleNeon // Bright colors
|
||||||
|
ProgressStyleRetro // Block characters
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpinnerStyle defines spinner animation type
|
||||||
|
type SpinnerStyle uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpinnerBraille SpinnerStyle = iota // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
|
||||||
|
SpinnerDots // ⣾⣽⣻⢿⡿⣟⣯⣷
|
||||||
|
SpinnerLine // |/-\
|
||||||
|
SpinnerBlock // ▖▘▝▗
|
||||||
|
SpinnerCircle // ◐◓◑◒
|
||||||
|
SpinnerArc // ◜◠◝◞◡◟
|
||||||
|
SpinnerBounce // ⠁⠂⠄⠂
|
||||||
|
SpinnerGrow // ▁▃▄▅▆▇█▇▆▅▄▃
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spinner frame sets
|
||||||
|
var spinnerSets = map[SpinnerStyle][]rune{
|
||||||
|
SpinnerBraille: {'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'},
|
||||||
|
SpinnerDots: {'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'},
|
||||||
|
SpinnerLine: {'|', '/', '-', '\\'},
|
||||||
|
SpinnerBlock: {'▖', '▘', '▝', '▗'},
|
||||||
|
SpinnerCircle: {'◐', '◓', '◑', '◒'},
|
||||||
|
SpinnerArc: {'◜', '◠', '◝', '◞', '◡', '◟'},
|
||||||
|
SpinnerBounce: {'⠁', '⠂', '⠄', '⠂'},
|
||||||
|
SpinnerGrow: {'▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarStyle defines progress bar appearance
|
||||||
|
type BarStyle uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
BarStyleBlock BarStyle = iota // █░
|
||||||
|
BarStyleShade // ▓▒░
|
||||||
|
BarStyleArrow // =>-
|
||||||
|
BarStyleDot // ●○
|
||||||
|
BarStyleBracket // [### ]
|
||||||
|
BarStylePipe // |=== |
|
||||||
|
BarStyleThin // ━╺
|
||||||
|
BarStyleThick // ▰▱
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bar character sets: [filled, partial, empty]
|
||||||
|
var barCharSets = map[BarStyle][3]rune{
|
||||||
|
BarStyleBlock: {'█', '▌', '░'},
|
||||||
|
BarStyleShade: {'▓', '▒', '░'},
|
||||||
|
BarStyleArrow: {'=', '>', '-'},
|
||||||
|
BarStyleDot: {'●', '◐', '○'},
|
||||||
|
BarStyleBracket: {'#', '#', ' '},
|
||||||
|
BarStylePipe: {'=', '=', ' '},
|
||||||
|
BarStyleThin: {'━', '╸', '╺'},
|
||||||
|
BarStyleThick: {'▰', '▰', '▱'},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressOverlayOpts configures progress overlay
|
||||||
|
type ProgressOverlayOpts struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Type ProgressType
|
||||||
|
Style ProgressStyle
|
||||||
|
SpinnerStyle SpinnerStyle
|
||||||
|
BarStyle BarStyle
|
||||||
|
Progress float64 // 0.0-1.0 for determinate
|
||||||
|
Frame int // Animation frame counter
|
||||||
|
ShowPercent bool // Show percentage text
|
||||||
|
ShowETA string // Optional ETA string
|
||||||
|
Width int // Overlay width, 0 = auto
|
||||||
|
Cancelable bool // Show cancel hint
|
||||||
|
CancelKey string // e.g., "Esc"
|
||||||
|
Fg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
BarFg terminal.RGB
|
||||||
|
BarBg terminal.RGB
|
||||||
|
AccentFg terminal.RGB // Spinner/highlight color
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultProgressOpts returns sensible defaults
|
||||||
|
func DefaultProgressOpts(title, message string, ptype ProgressType) ProgressOverlayOpts {
|
||||||
|
return ProgressOverlayOpts{
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Type: ptype,
|
||||||
|
Style: ProgressStyleBox,
|
||||||
|
SpinnerStyle: SpinnerBraille,
|
||||||
|
BarStyle: BarStyleBlock,
|
||||||
|
ShowPercent: true,
|
||||||
|
Width: 40,
|
||||||
|
Fg: terminal.RGB{R: 220, G: 220, B: 220},
|
||||||
|
Bg: terminal.RGB{R: 30, G: 30, B: 40},
|
||||||
|
BarFg: terminal.RGB{R: 80, G: 160, B: 255},
|
||||||
|
BarBg: terminal.RGB{R: 50, G: 50, B: 60},
|
||||||
|
AccentFg: terminal.RGB{R: 100, G: 200, B: 255},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressOverlay renders centered progress overlay
|
||||||
|
func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
|
||||||
|
if r.W < 10 || r.H < 5 {
|
||||||
|
return Region{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overlay dimensions
|
||||||
|
overlayW := opts.Width
|
||||||
|
if overlayW == 0 {
|
||||||
|
overlayW = 40
|
||||||
|
}
|
||||||
|
if overlayW > r.W-4 {
|
||||||
|
overlayW = r.W - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height: title + message + progress + cancel hint
|
||||||
|
overlayH := 3 // border top/bottom + 1 content
|
||||||
|
if opts.Title != "" {
|
||||||
|
overlayH++ // Title takes space on border
|
||||||
|
}
|
||||||
|
if opts.Message != "" {
|
||||||
|
overlayH++
|
||||||
|
}
|
||||||
|
if opts.Type != ProgressSpinner && opts.Type != ProgressDots {
|
||||||
|
overlayH++ // Progress bar row
|
||||||
|
}
|
||||||
|
if opts.Cancelable {
|
||||||
|
overlayH++
|
||||||
|
}
|
||||||
|
if overlayH > r.H-2 {
|
||||||
|
overlayH = r.H - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center overlay
|
||||||
|
overlay := Center(r, overlayW, overlayH)
|
||||||
|
|
||||||
|
// Determine border type
|
||||||
|
var borderLine LineType
|
||||||
|
switch opts.Style {
|
||||||
|
case ProgressStyleMinimal:
|
||||||
|
borderLine = LineNone
|
||||||
|
case ProgressStyleBox:
|
||||||
|
borderLine = LineSingle
|
||||||
|
case ProgressStyleDouble:
|
||||||
|
borderLine = LineDouble
|
||||||
|
case ProgressStyleRounded:
|
||||||
|
borderLine = LineRounded
|
||||||
|
case ProgressStyleShadow:
|
||||||
|
// Draw shadow first
|
||||||
|
shadow := r.Sub(overlay.X-r.X+1, overlay.Y-r.Y+1, overlayW, overlayH)
|
||||||
|
shadow.Fill(terminal.RGB{R: 10, G: 10, B: 15})
|
||||||
|
borderLine = LineSingle
|
||||||
|
case ProgressStyleNeon:
|
||||||
|
borderLine = LineDouble
|
||||||
|
opts.AccentFg = terminal.RGB{R: 0, G: 255, B: 200}
|
||||||
|
opts.BarFg = terminal.RGB{R: 255, G: 0, B: 255}
|
||||||
|
case ProgressStyleRetro:
|
||||||
|
borderLine = LineHeavy
|
||||||
|
opts.BarStyle = BarStyleBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw frame
|
||||||
|
overlay.BoxFilled(borderLine, opts.Fg, opts.Bg)
|
||||||
|
|
||||||
|
// Title on border
|
||||||
|
if opts.Title != "" {
|
||||||
|
title := " " + opts.Title + " "
|
||||||
|
titleLen := RuneLen(title)
|
||||||
|
if titleLen > overlayW-4 {
|
||||||
|
title = Truncate(title, overlayW-4)
|
||||||
|
titleLen = RuneLen(title)
|
||||||
|
}
|
||||||
|
titleX := (overlayW - titleLen) / 2
|
||||||
|
for i, ch := range title {
|
||||||
|
overlay.Cell(titleX+i, 0, ch, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
content := overlay.Inset(1)
|
||||||
|
y := 0
|
||||||
|
|
||||||
|
// Spinner/dots for spinner types - inline with message
|
||||||
|
if opts.Type == ProgressSpinner || opts.Type == ProgressDots {
|
||||||
|
spinnerChar := r.getSpinnerChar(opts)
|
||||||
|
if content.W > 2 {
|
||||||
|
content.Cell(0, y, spinnerChar, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message after spinner
|
||||||
|
if opts.Message != "" {
|
||||||
|
msg := opts.Message
|
||||||
|
availW := content.W - 2
|
||||||
|
if RuneLen(msg) > availW {
|
||||||
|
msg = Truncate(msg, availW)
|
||||||
|
}
|
||||||
|
content.Text(2, y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
} else {
|
||||||
|
// Message on its own line
|
||||||
|
if opts.Message != "" {
|
||||||
|
msg := opts.Message
|
||||||
|
if RuneLen(msg) > content.W {
|
||||||
|
msg = Truncate(msg, content.W)
|
||||||
|
}
|
||||||
|
content.TextCenter(y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
if y < content.H {
|
||||||
|
r.renderProgressBar(content.Sub(0, y, content.W, 1), opts)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel hint
|
||||||
|
if opts.Cancelable && y < content.H {
|
||||||
|
hint := opts.CancelKey + " to cancel"
|
||||||
|
if opts.CancelKey == "" {
|
||||||
|
hint = "Esc to cancel"
|
||||||
|
}
|
||||||
|
content.TextCenter(y, hint, terminal.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) getSpinnerChar(opts ProgressOverlayOpts) rune {
|
||||||
|
frames := spinnerSets[opts.SpinnerStyle]
|
||||||
|
if len(frames) == 0 {
|
||||||
|
frames = spinnerSets[SpinnerBraille]
|
||||||
|
}
|
||||||
|
idx := opts.Frame % len(frames)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = -idx
|
||||||
|
}
|
||||||
|
return frames[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
|
||||||
|
if bar.W < 3 || bar.H < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
barW := bar.W
|
||||||
|
labelW := 0
|
||||||
|
|
||||||
|
// Reserve space for percentage
|
||||||
|
if opts.ShowPercent {
|
||||||
|
labelW = 5 // " 100%"
|
||||||
|
barW -= labelW
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve space for ETA
|
||||||
|
if opts.ShowETA != "" {
|
||||||
|
etaW := RuneLen(opts.ShowETA) + 1
|
||||||
|
barW -= etaW
|
||||||
|
}
|
||||||
|
|
||||||
|
if barW < 3 {
|
||||||
|
barW = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
chars := barCharSets[opts.BarStyle]
|
||||||
|
|
||||||
|
switch opts.Type {
|
||||||
|
case ProgressDeterminate:
|
||||||
|
pct := opts.Progress
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
filled := int(float64(barW) * pct)
|
||||||
|
remainder := float64(barW)*pct - float64(filled)
|
||||||
|
|
||||||
|
for x := 0; x < barW; x++ {
|
||||||
|
var ch rune
|
||||||
|
var fg terminal.RGB
|
||||||
|
if x < filled {
|
||||||
|
ch = chars[0]
|
||||||
|
fg = opts.BarFg
|
||||||
|
} else if x == filled && remainder >= 0.5 {
|
||||||
|
ch = chars[1]
|
||||||
|
fg = opts.BarFg
|
||||||
|
} else {
|
||||||
|
ch = chars[2]
|
||||||
|
fg = opts.BarBg
|
||||||
|
}
|
||||||
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage
|
||||||
|
if opts.ShowPercent {
|
||||||
|
pctStr := formatPercent(int(pct * 100))
|
||||||
|
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
case ProgressIndeterminate:
|
||||||
|
// Marquee effect
|
||||||
|
pos := opts.Frame % (barW * 2)
|
||||||
|
if pos >= barW {
|
||||||
|
pos = barW*2 - pos - 1
|
||||||
|
}
|
||||||
|
markerW := barW / 4
|
||||||
|
if markerW < 2 {
|
||||||
|
markerW = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for x := 0; x < barW; x++ {
|
||||||
|
var ch rune
|
||||||
|
var fg terminal.RGB
|
||||||
|
if x >= pos && x < pos+markerW {
|
||||||
|
ch = chars[0]
|
||||||
|
fg = opts.BarFg
|
||||||
|
} else {
|
||||||
|
ch = chars[2]
|
||||||
|
fg = opts.BarBg
|
||||||
|
}
|
||||||
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
case ProgressPulse:
|
||||||
|
// Pulsing intensity based on frame
|
||||||
|
pulsePhase := opts.Frame % 20
|
||||||
|
intensity := float64(pulsePhase) / 20.0
|
||||||
|
if pulsePhase > 10 {
|
||||||
|
intensity = 1.0 - float64(pulsePhase-10)/10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pct := opts.Progress
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 1 {
|
||||||
|
pct = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
filled := int(float64(barW) * pct)
|
||||||
|
|
||||||
|
for x := 0; x < barW; x++ {
|
||||||
|
var ch rune
|
||||||
|
var fg terminal.RGB
|
||||||
|
if x < filled {
|
||||||
|
ch = chars[0]
|
||||||
|
// Pulse the color
|
||||||
|
fg = terminal.RGB{
|
||||||
|
R: uint8(float64(opts.BarFg.R) * (0.5 + intensity*0.5)),
|
||||||
|
G: uint8(float64(opts.BarFg.G) * (0.5 + intensity*0.5)),
|
||||||
|
B: uint8(float64(opts.BarFg.B) * (0.5 + intensity*0.5)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch = chars[2]
|
||||||
|
fg = opts.BarBg
|
||||||
|
}
|
||||||
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ShowPercent {
|
||||||
|
pctStr := formatPercent(int(pct * 100))
|
||||||
|
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETA
|
||||||
|
if opts.ShowETA != "" {
|
||||||
|
etaX := bar.W - RuneLen(opts.ShowETA)
|
||||||
|
bar.Text(etaX, 0, opts.ShowETA, terminal.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPercent(pct int) string {
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct == 100 {
|
||||||
|
return "100%"
|
||||||
|
}
|
||||||
|
if pct >= 10 {
|
||||||
|
return " " + string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
|
||||||
|
}
|
||||||
|
return " " + string(rune('0'+pct)) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressState manages progress overlay state
|
||||||
|
type ProgressState struct {
|
||||||
|
Visible bool
|
||||||
|
Opts ProgressOverlayOpts
|
||||||
|
Frame int
|
||||||
|
Progress float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgressState creates progress overlay state
|
||||||
|
func NewProgressState(opts ProgressOverlayOpts) *ProgressState {
|
||||||
|
return &ProgressState{
|
||||||
|
Visible: true,
|
||||||
|
Opts: opts,
|
||||||
|
Progress: opts.Progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick advances animation frame
|
||||||
|
func (p *ProgressState) Tick() {
|
||||||
|
p.Frame++
|
||||||
|
p.Opts.Frame = p.Frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProgress updates progress value (0.0-1.0)
|
||||||
|
func (p *ProgressState) SetProgress(pct float64) {
|
||||||
|
p.Progress = pct
|
||||||
|
p.Opts.Progress = pct
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMessage updates message text
|
||||||
|
func (p *ProgressState) SetMessage(msg string) {
|
||||||
|
p.Opts.Message = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetETA updates ETA string
|
||||||
|
func (p *ProgressState) SetETA(eta string) {
|
||||||
|
p.Opts.ShowETA = eta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete marks progress as done
|
||||||
|
func (p *ProgressState) Complete() {
|
||||||
|
p.Progress = 1.0
|
||||||
|
p.Opts.Progress = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss hides the progress overlay
|
||||||
|
func (p *ProgressState) Dismiss() {
|
||||||
|
p.Visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show displays the progress overlay
|
||||||
|
func (p *ProgressState) Show() {
|
||||||
|
p.Visible = true
|
||||||
|
}
|
||||||
+112
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lixenwraith/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Style bundles foreground, background, and attributes for text rendering
|
||||||
|
type Style struct {
|
||||||
|
Fg terminal.RGB
|
||||||
|
Bg terminal.RGB
|
||||||
|
Attr terminal.Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStyle returns style with zero values (transparent bg)
|
||||||
|
func DefaultStyle(fg terminal.RGB) Style {
|
||||||
|
return Style{Fg: fg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero returns true if style has no colors or attributes set
|
||||||
|
func (s Style) IsZero() bool {
|
||||||
|
return s.Fg == (terminal.RGB{}) && s.Bg == (terminal.RGB{}) && s.Attr == terminal.AttrNone
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// TabBounds stores position and size of a rendered tab
|
||||||
|
type TabBounds struct {
|
||||||
|
X, W int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabBarOpts configures tab bar rendering
|
||||||
|
type TabBarOpts struct {
|
||||||
|
ActiveStyle Style
|
||||||
|
InactiveStyle Style
|
||||||
|
Separator string // Between tabs, default " │ "
|
||||||
|
Padding int // Horizontal padding inside each tab, default 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTabBarOpts returns sensible defaults
|
||||||
|
func DefaultTabBarOpts() TabBarOpts {
|
||||||
|
return TabBarOpts{
|
||||||
|
ActiveStyle: Style{Attr: terminal.AttrBold | terminal.AttrReverse},
|
||||||
|
InactiveStyle: Style{Attr: terminal.AttrNone},
|
||||||
|
Separator: " │ ",
|
||||||
|
Padding: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabBar renders horizontal tab strip at row y
|
||||||
|
// Returns bounds of each tab for hit testing / navigation
|
||||||
|
func (r Region) TabBar(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
|
||||||
|
if y < 0 || y >= r.H || len(titles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Separator == "" {
|
||||||
|
opts.Separator = " │ "
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := make([]TabBounds, len(titles))
|
||||||
|
x := 0
|
||||||
|
sepLen := RuneLen(opts.Separator)
|
||||||
|
|
||||||
|
for i, title := range titles {
|
||||||
|
if x >= r.W {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate tab width
|
||||||
|
tabW := RuneLen(title) + opts.Padding*2
|
||||||
|
if x+tabW > r.W {
|
||||||
|
tabW = r.W - x
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds[i] = TabBounds{X: x, W: tabW}
|
||||||
|
|
||||||
|
// Select style
|
||||||
|
style := opts.InactiveStyle
|
||||||
|
if i == active {
|
||||||
|
style = opts.ActiveStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render padding + title + padding
|
||||||
|
for j := 0; j < opts.Padding && x+j < r.W; j++ {
|
||||||
|
r.Cell(x+j, y, ' ', style.Fg, style.Bg, style.Attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleStart := x + opts.Padding
|
||||||
|
for j, ch := range title {
|
||||||
|
if titleStart+j >= r.W {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.Cell(titleStart+j, y, ch, style.Fg, style.Bg, style.Attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
for j := 0; j < opts.Padding; j++ {
|
||||||
|
pos := x + opts.Padding + RuneLen(title) + j
|
||||||
|
if pos < r.W {
|
||||||
|
r.Cell(pos, y, ' ', style.Fg, style.Bg, style.Attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x += tabW
|
||||||
|
|
||||||
|
// Separator between tabs
|
||||||
|
if i < len(titles)-1 && x+sepLen <= r.W {
|
||||||
|
for j, ch := range opts.Separator {
|
||||||
|
r.Cell(x+j, y, ch, opts.InactiveStyle.Fg, opts.InactiveStyle.Bg, terminal.AttrDim)
|
||||||
|
}
|
||||||
|
x += sepLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabBarCentered renders tab bar centered horizontally
|
||||||
|
func (r Region) TabBarCentered(y int, titles []string, active int, opts TabBarOpts) []TabBounds {
|
||||||
|
if len(titles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Separator == "" {
|
||||||
|
opts.Separator = " │ "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total width
|
||||||
|
totalW := 0
|
||||||
|
sepLen := RuneLen(opts.Separator)
|
||||||
|
for i, title := range titles {
|
||||||
|
totalW += RuneLen(title) + opts.Padding*2
|
||||||
|
if i < len(titles)-1 {
|
||||||
|
totalW += sepLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sub-region for centering
|
||||||
|
startX := (r.W - totalW) / 2
|
||||||
|
if startX < 0 {
|
||||||
|
startX = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
subR := r.Sub(startX, y, totalW, 1)
|
||||||
|
bounds := subR.TabBar(0, titles, active, opts)
|
||||||
|
|
||||||
|
// Adjust bounds to parent coordinates
|
||||||
|
for i := range bounds {
|
||||||
|
bounds[i].X += startX
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
+180
@@ -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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/lixenwraith/terminal"
|
||||||
|
|
||||||
|
// Theme defines semantic colors for TUI components
|
||||||
|
type Theme struct {
|
||||||
|
Bg terminal.RGB
|
||||||
|
Fg terminal.RGB
|
||||||
|
FocusBg terminal.RGB
|
||||||
|
CursorBg terminal.RGB
|
||||||
|
|
||||||
|
Selected terminal.RGB
|
||||||
|
Unselected terminal.RGB
|
||||||
|
Partial terminal.RGB
|
||||||
|
Error terminal.RGB
|
||||||
|
Warning terminal.RGB
|
||||||
|
|
||||||
|
Border terminal.RGB
|
||||||
|
HeaderBg terminal.RGB
|
||||||
|
HeaderFg terminal.RGB
|
||||||
|
StatusFg terminal.RGB
|
||||||
|
HintFg terminal.RGB
|
||||||
|
InputBg terminal.RGB
|
||||||
|
|
||||||
|
DirFg terminal.RGB
|
||||||
|
FileFg terminal.RGB
|
||||||
|
SymbolFg terminal.RGB
|
||||||
|
|
||||||
|
SyntaxComment terminal.RGB
|
||||||
|
SyntaxString terminal.RGB
|
||||||
|
SyntaxKeyword terminal.RGB
|
||||||
|
SyntaxType terminal.RGB
|
||||||
|
SyntaxNumber terminal.RGB
|
||||||
|
SyntaxSymbol terminal.RGB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTheme provides reasonable defaults
|
||||||
|
var DefaultTheme = Theme{
|
||||||
|
Bg: terminal.RGB{R: 20, G: 20, B: 30},
|
||||||
|
Fg: terminal.RGB{R: 200, G: 200, B: 200},
|
||||||
|
FocusBg: terminal.RGB{R: 30, G: 35, B: 45},
|
||||||
|
CursorBg: terminal.RGB{R: 50, G: 50, B: 70},
|
||||||
|
Selected: terminal.RGB{R: 80, G: 200, B: 80},
|
||||||
|
Unselected: terminal.RGB{R: 100, G: 100, B: 100},
|
||||||
|
Partial: terminal.RGB{R: 80, G: 160, B: 220},
|
||||||
|
Error: terminal.RGB{R: 255, G: 80, B: 80},
|
||||||
|
Warning: terminal.RGB{R: 255, G: 80, B: 80},
|
||||||
|
Border: terminal.RGB{R: 60, G: 80, B: 100},
|
||||||
|
HeaderBg: terminal.RGB{R: 40, G: 60, B: 90},
|
||||||
|
HeaderFg: terminal.RGB{R: 255, G: 255, B: 255},
|
||||||
|
StatusFg: terminal.RGB{R: 140, G: 140, B: 140},
|
||||||
|
HintFg: terminal.RGB{R: 100, G: 180, B: 200},
|
||||||
|
InputBg: terminal.RGB{R: 30, G: 30, B: 50},
|
||||||
|
DirFg: terminal.RGB{R: 130, G: 170, B: 220},
|
||||||
|
FileFg: terminal.RGB{R: 200, G: 200, B: 200},
|
||||||
|
SymbolFg: terminal.RGB{R: 180, G: 220, B: 220},
|
||||||
|
SyntaxComment: terminal.RGB{R: 100, G: 110, B: 120},
|
||||||
|
SyntaxString: terminal.RGB{R: 180, G: 220, B: 140},
|
||||||
|
SyntaxKeyword: terminal.RGB{R: 180, G: 140, B: 220},
|
||||||
|
SyntaxType: terminal.RGB{R: 80, G: 200, B: 200},
|
||||||
|
SyntaxNumber: terminal.RGB{R: 220, G: 180, B: 120},
|
||||||
|
SyntaxSymbol: terminal.RGB{R: 220, G: 180, B: 80},
|
||||||
|
}
|
||||||
+301
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// TreeExpansion manages expand/collapse state
|
||||||
|
type TreeExpansion struct {
|
||||||
|
State map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTreeExpansion creates initialized expansion state
|
||||||
|
func NewTreeExpansion() *TreeExpansion {
|
||||||
|
return &TreeExpansion{
|
||||||
|
State: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State queries ---
|
||||||
|
|
||||||
|
// IsExpanded returns expansion state for key
|
||||||
|
func (e *TreeExpansion) IsExpanded(key string) bool {
|
||||||
|
return e.State[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State modification ---
|
||||||
|
|
||||||
|
// SetExpanded sets expansion state for key
|
||||||
|
func (e *TreeExpansion) SetExpanded(key string, expanded bool) {
|
||||||
|
e.State[key] = expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle toggles expansion state for key
|
||||||
|
func (e *TreeExpansion) Toggle(key string) bool {
|
||||||
|
e.State[key] = !e.State[key]
|
||||||
|
return e.State[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand sets key to expanded
|
||||||
|
func (e *TreeExpansion) Expand(key string) {
|
||||||
|
e.State[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse sets key to collapsed
|
||||||
|
func (e *TreeExpansion) Collapse(key string) {
|
||||||
|
e.State[key] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulk operations ---
|
||||||
|
|
||||||
|
// ExpandAll expands all provided keys
|
||||||
|
func (e *TreeExpansion) ExpandAll(keys []string) {
|
||||||
|
for _, k := range keys {
|
||||||
|
e.State[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollapseAll collapses all keys
|
||||||
|
func (e *TreeExpansion) CollapseAll() {
|
||||||
|
for k := range e.State {
|
||||||
|
e.State[k] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all expansion state
|
||||||
|
func (e *TreeExpansion) Clear() {
|
||||||
|
e.State = make(map[string]bool)
|
||||||
|
}
|
||||||
+337
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// TreeState manages navigation state for a tree
|
||||||
|
type TreeState struct {
|
||||||
|
Cursor int
|
||||||
|
Scroll int
|
||||||
|
Visible int // Viewport height
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTreeState creates initialized tree state
|
||||||
|
func NewTreeState(visible int) *TreeState {
|
||||||
|
return &TreeState{
|
||||||
|
Visible: visible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cursor movement ---
|
||||||
|
|
||||||
|
// MoveCursor adjusts cursor position by delta
|
||||||
|
func (t *TreeState) MoveCursor(delta, total int) {
|
||||||
|
t.Cursor += delta
|
||||||
|
if t.Cursor < 0 {
|
||||||
|
t.Cursor = 0
|
||||||
|
}
|
||||||
|
if t.Cursor >= total {
|
||||||
|
t.Cursor = total - 1
|
||||||
|
}
|
||||||
|
if t.Cursor < 0 {
|
||||||
|
t.Cursor = 0
|
||||||
|
}
|
||||||
|
t.AdjustScroll(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustScroll ensures cursor is visible
|
||||||
|
func (t *TreeState) AdjustScroll(total int) {
|
||||||
|
if t.Visible <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.Cursor < t.Scroll {
|
||||||
|
t.Scroll = t.Cursor
|
||||||
|
}
|
||||||
|
if t.Cursor >= t.Scroll+t.Visible {
|
||||||
|
t.Scroll = t.Cursor - t.Visible + 1
|
||||||
|
}
|
||||||
|
// Clamp scroll
|
||||||
|
maxScroll := total - t.Visible
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
if t.Scroll > maxScroll {
|
||||||
|
t.Scroll = maxScroll
|
||||||
|
}
|
||||||
|
if t.Scroll < 0 {
|
||||||
|
t.Scroll = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Jump navigation ---
|
||||||
|
|
||||||
|
// JumpStart moves cursor to first item
|
||||||
|
func (t *TreeState) JumpStart() {
|
||||||
|
t.Cursor = 0
|
||||||
|
t.Scroll = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// JumpEnd moves cursor to last item
|
||||||
|
func (t *TreeState) JumpEnd(total int) {
|
||||||
|
if total > 0 {
|
||||||
|
t.Cursor = total - 1
|
||||||
|
}
|
||||||
|
t.AdjustScroll(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page navigation ---
|
||||||
|
|
||||||
|
// PageUp scrolls up by half viewport
|
||||||
|
func (t *TreeState) PageUp(total int) {
|
||||||
|
delta := t.Visible / 2
|
||||||
|
if delta < 1 {
|
||||||
|
delta = 1
|
||||||
|
}
|
||||||
|
t.MoveCursor(-delta, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageDown scrolls down by half viewport
|
||||||
|
func (t *TreeState) PageDown(total int) {
|
||||||
|
delta := t.Visible / 2
|
||||||
|
if delta < 1 {
|
||||||
|
delta = 1
|
||||||
|
}
|
||||||
|
t.MoveCursor(delta, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisible updates viewport height
|
||||||
|
func (t *TreeState) SetVisible(visible int) {
|
||||||
|
t.Visible = visible
|
||||||
|
}
|
||||||
+107
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user