v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.idea
|
||||
bin/
|
||||
dev/
|
||||
logs/
|
||||
log/
|
||||
examples/
|
||||
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,223 @@
|
||||
# terminal
|
||||
|
||||
Direct ANSI terminal control for Go with zero-allocation rendering. Built for
|
||||
sustained 60fps full-screen redraws in cell-based applications (games, dashboards,
|
||||
TUIs). Depends only on the standard library and `golang.org/x/sys` (Unix builds).
|
||||
|
||||
The package bypasses terminfo/termcap entirely and emits ANSI sequences directly.
|
||||
Target environments: xterm-compatible terminals on Linux and BSDs, and browsers
|
||||
via xterm.js (WASM builds).
|
||||
|
||||
## Features
|
||||
|
||||
- True color (24-bit) and 256-color palette output with automatic capability detection
|
||||
- Double-buffered output with cell-level diffing — only changed cells emit sequences
|
||||
- Raw stdin parsing: keys, modifiers, UTF-8 runes, SGR mouse, resize
|
||||
- Perceptual (Redmean) RGB → 256-palette mapping via O(1) LUT
|
||||
- Color blending library: alpha, additive, screen, overlay, soft light
|
||||
- Named color palettes for true color and xterm-256
|
||||
- Panic-safe terminal restoration (`Fini`, `EmergencyReset`)
|
||||
- Unix and WASM backends behind a common interface
|
||||
|
||||
## Architecture
|
||||
|
||||
Terminal (interface)
|
||||
└── termImpl
|
||||
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
|
||||
├── inputReader escape sequence parser, event channel
|
||||
└── Backend (interface)
|
||||
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH
|
||||
└── wasmBackend //go:build wasm — syscall/js, xterm.js bridge
|
||||
|
||||
Shared code carries no build tags: cell diffing, ANSI generation, escape parsing,
|
||||
service lifecycle. Platform specifics are isolated in the `Backend` implementations.
|
||||
|
||||
### Rendering pipeline
|
||||
|
||||
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
|
||||
passes it to `Flush`. The output buffer diffs against the previously flushed frame:
|
||||
|
||||
- Rows are scanned with early termination (trailing unchanged cells skipped).
|
||||
- Cursor moves are emitted only when the write position is non-contiguous.
|
||||
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
|
||||
are suppressed.
|
||||
- If the backend size changed between buffer preparation and `Flush`, the frame
|
||||
is dropped to prevent resize-race corruption. The next frame (built at the new
|
||||
size) renders normally.
|
||||
|
||||
`Sync()` clears the screen and invalidates the front buffer, forcing a full
|
||||
redraw — required after any external process writes to the terminal.
|
||||
|
||||
Auto-wrap is disabled during the session, making the bottom-right cell writable
|
||||
without scroll side effects.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/lixenwraith/terminal"
|
||||
|
||||
func main() {
|
||||
term := terminal.New() // color mode auto-detected
|
||||
if err := term.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer term.Fini()
|
||||
|
||||
w, h := term.Size()
|
||||
cells := make([]terminal.Cell, w*h)
|
||||
|
||||
for {
|
||||
// Build frame
|
||||
for i := range cells {
|
||||
cells[i] = terminal.Cell{Rune: ' ', Bg: terminal.Gunmetal}
|
||||
}
|
||||
msg := "hello"
|
||||
for i, ch := range msg {
|
||||
// len(msg) to utf8.RuneCountIdString(msg) for non-ASCII
|
||||
cells[(h/2)*w+(w-len(msg))/2+i] = terminal.Cell{
|
||||
Rune: ch, Fg: terminal.Amber, Bg: terminal.Gunmetal,
|
||||
Attrs: terminal.AttrBold,
|
||||
}
|
||||
}
|
||||
term.Flush(cells, w, h)
|
||||
|
||||
// Handle input
|
||||
ev := term.PollEvent()
|
||||
switch ev.Type {
|
||||
case terminal.EventKey:
|
||||
if ev.Key == terminal.KeyEscape || ev.Rune == 'q' {
|
||||
return
|
||||
}
|
||||
case terminal.EventResize:
|
||||
w, h = ev.Width, ev.Height
|
||||
cells = make([]terminal.Cell, w*h)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cells and attributes
|
||||
|
||||
```go
|
||||
type Cell struct {
|
||||
Rune rune
|
||||
Fg RGB
|
||||
Bg RGB
|
||||
Attrs Attr
|
||||
}
|
||||
```
|
||||
|
||||
`Attr` is a bitmask: `AttrBold`, `AttrDim`, `AttrItalic`, `AttrUnderline`,
|
||||
`AttrBlink`, `AttrReverse`.
|
||||
|
||||
Two flag bits change color interpretation: with `AttrFg256` / `AttrBg256` set,
|
||||
`Fg.R` / `Bg.R` holds an xterm-256 palette index directly and `G`/`B` are
|
||||
ignored. This allows exact palette output on true color terminals and skips
|
||||
RGB → palette conversion.
|
||||
|
||||
## Color system
|
||||
|
||||
### Modes
|
||||
|
||||
`ColorModeTrueColor` emits `38;2;R;G;B` sequences; `ColorMode256` emits
|
||||
`38;5;N` after mapping. `DetectColorMode()` inspects the environment
|
||||
(`COLORTERM`, `TERM`). Explicit override: `terminal.New(terminal.ColorMode256)`.
|
||||
|
||||
### RGB → 256 mapping
|
||||
|
||||
`RGBTo256` maps any `RGB` to the nearest xterm-256 index using perceptually
|
||||
weighted Redmean distance. The full mapping is pre-computed at init into a
|
||||
6-bit-quantized LUT (256KB, L2-resident), making per-cell conversion a single
|
||||
array load. Applications targeting 256-color terminals can render in RGB
|
||||
throughout; degradation is automatic.
|
||||
|
||||
Palette helpers: `Cube256(r,g,b)` / `CubeRGB256(idx)` for 6×6×6 cube math,
|
||||
`Gray256(step)` for the grayscale ramp, plus named constants (`P256Amber`,
|
||||
`P256SteelBlue`, ...) in `rgb_256.go` and named true color values (`Amber`,
|
||||
`Gunmetal`, `Obsidian`, ...) in `rgb_truecolor.go`.
|
||||
|
||||
### Blending
|
||||
|
||||
`blend.go` provides compositing primitives operating on `RGB`. All take
|
||||
destination first and are branch-free in the hot path or LUT-backed; suitable
|
||||
for per-cell use at frame rate.
|
||||
|
||||
| Function | Operation | Character |
|
||||
|---|---|---|
|
||||
| `Blend(dst, src, alpha)` | linear interpolation | standard transparency |
|
||||
| `Add(dst, src, alpha)` | saturating add | bright accumulation, clips |
|
||||
| `Screen(dst, src, alpha)` | `1-(1-d)(1-s)` | lightens, never clips |
|
||||
| `Overlay(dst, src, alpha)` | multiply/screen split at 0.5 | contrast, keeps dst structure |
|
||||
| `SoftLight(dst, src, intensity)` | Perez soft light | gentle tint/glow |
|
||||
| `Max(dst, src, alpha)` | per-channel max | non-additive highlight |
|
||||
| `Scale(c, factor)` | channel multiply | dim/brighten |
|
||||
| `Grayscale(c)` | Rec. 601 luma | desaturation |
|
||||
| `c.Lerp(other, t)` | method on `RGB` | gradients, animation |
|
||||
|
||||
`alpha`/`intensity`/`t` are `[0,1]`; out-of-range values clamp. `alpha` of 0 or 1
|
||||
short-circuits without float math. All float→channel conversions round half-up,
|
||||
so gradients from `Blend`, `Scale`, `Lerp`, and `SoftLight` are bit-consistent.
|
||||
|
||||
```go
|
||||
bg := terminal.Gunmetal
|
||||
glow := terminal.RGB{R: 255, G: 160, B: 40}
|
||||
|
||||
cell.Bg = terminal.Screen(bg, terminal.Scale(glow, pulse), 1.0) // pulsing glow
|
||||
cell.Bg = terminal.Blend(cell.Bg, terminal.Black, 0.6) // dim overlay backdrop
|
||||
cell.Fg = terminal.SoftLight(cell.Fg, tint, 0.4) // subtle recolor
|
||||
bar := cold.Lerp(hot, load) // value-mapped gradient
|
||||
```
|
||||
|
||||
Integer paths (`Add`, `Screen`, `Overlay`) use a `(x + (x>>8) + 1) >> 8`
|
||||
division approximation; `SoftLight` uses init-time LUTs replacing `math.Sqrt`.
|
||||
|
||||
## Input
|
||||
|
||||
`PollEvent()` blocks on a unified channel. `Event.Type` values:
|
||||
|
||||
- `EventKey` — `Key` for named keys (`KeyEnter`, `KeyUp`, `KeyCtrlC`, ...),
|
||||
`Key == KeyRune` with `Rune` set for printable input, `Modifiers` bitmask
|
||||
(`ModShift`, `ModAlt`, `ModCtrl`)
|
||||
- `EventMouse` — 0-indexed `MouseX/Y`, `MouseBtn` (buttons, wheel),
|
||||
`MouseAction` (press/release/move/drag), modifiers. Enable via
|
||||
`SetMouseMode(MouseModeClick | MouseModeDrag)`; SGR protocol only.
|
||||
- `EventResize` — new `Width`/`Height`
|
||||
- `EventError`, `EventClosed`
|
||||
|
||||
A standalone ESC press is disambiguated from escape sequences by a short input-idle timeout (one ~10ms poll cycle).
|
||||
Partial UTF-8 and escape sequences at read boundaries are reassembled in a persistent buffer.
|
||||
`PostEvent` injects synthetic events (used for clean shutdown of blocked `PollEvent`).
|
||||
|
||||
## Service wrapper
|
||||
|
||||
`TerminalService` packages lifecycle (init, input goroutine, panic-safe
|
||||
teardown) behind `Init/Start/Stop` for service-registry architectures:
|
||||
|
||||
```go
|
||||
svc := terminal.NewService()
|
||||
svc.Init()
|
||||
svc.Start()
|
||||
defer svc.Stop()
|
||||
|
||||
term := svc.Terminal()
|
||||
for ev := range svc.Events() { /* ... */ }
|
||||
```
|
||||
|
||||
Input-goroutine panics trigger `EmergencyReset` (restores cooked mode, main
|
||||
screen, cursor) before printing the stack trace, keeping the shell usable.
|
||||
|
||||
## WASM
|
||||
|
||||
WASM builds bridge to xterm.js via JS globals:
|
||||
|
||||
goTerminalWrite(Uint8Array) // Go → JS terminal output
|
||||
goTerminalInput(Uint8Array) // JS → Go keyboard input
|
||||
goTerminalResize(cols, rows) // JS → Go resize
|
||||
xterm.cols, xterm.rows // initial size query
|
||||
|
||||
## Sub-packages
|
||||
|
||||
- [`tui`](tui/README.md) — immediate-mode widget toolkit (regions, layout,
|
||||
widgets, scroll/editor state) built on the cell buffer model.
|
||||
@@ -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))
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
//go:build unix
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type unixBackend struct {
|
||||
in *os.File
|
||||
out *os.File
|
||||
inFd int
|
||||
outFd int
|
||||
oldTerm *term.State
|
||||
|
||||
resizeStopCh chan struct{}
|
||||
resizeDoneCh chan struct{}
|
||||
}
|
||||
|
||||
const escapeTimeoutMs = 10
|
||||
|
||||
func newBackend() Backend {
|
||||
return &unixBackend{
|
||||
in: os.Stdin,
|
||||
out: os.Stdout,
|
||||
inFd: int(os.Stdin.Fd()),
|
||||
outFd: int(os.Stdout.Fd()),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *unixBackend) Init() error {
|
||||
if !term.IsTerminal(b.inFd) {
|
||||
return fmt.Errorf("stdin is not a terminal")
|
||||
}
|
||||
|
||||
old, err := term.MakeRaw(b.inFd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.oldTerm = old
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *unixBackend) Fini() {
|
||||
if b.resizeStopCh != nil {
|
||||
close(b.resizeStopCh)
|
||||
<-b.resizeDoneCh
|
||||
b.resizeStopCh = nil
|
||||
}
|
||||
if b.oldTerm != nil {
|
||||
term.Restore(b.inFd, b.oldTerm)
|
||||
}
|
||||
}
|
||||
|
||||
// Size delegates to exported WindowSize; getTerminalSize deleted
|
||||
func (b *unixBackend) Size() (int, int) {
|
||||
if w, h, ok := WindowSize(b.out); ok {
|
||||
return w, h
|
||||
}
|
||||
return 80, 24 // Fallback
|
||||
}
|
||||
|
||||
// WindowSize queries terminal dimensions for f without raw mode or
|
||||
// Terminal lifecycle. ok=false when f is not a terminal.
|
||||
func WindowSize(f *os.File) (w, h int, ok bool) {
|
||||
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
|
||||
if err != nil || ws.Col == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int(ws.Col), int(ws.Row), true
|
||||
}
|
||||
|
||||
func (b *unixBackend) Write(p []byte) error {
|
||||
_, err := b.out.Write(p)
|
||||
return err
|
||||
}
|
||||
|
||||
// Read implements the polling logic previously in input.go
|
||||
func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
||||
// Buffer for single read
|
||||
buf := make([]byte, 256)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return nil, nil
|
||||
default:
|
||||
}
|
||||
|
||||
// Poll with timeout to allow checking stopCh
|
||||
fds := []unix.PollFd{
|
||||
{Fd: int32(b.inFd), Events: unix.POLLIN},
|
||||
}
|
||||
|
||||
// Timeout to differentiate standalone ESC from escape sequences
|
||||
n, err := unix.Poll(fds, escapeTimeoutMs)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
// Timeout - return empty to let readLoop handle pending ESC
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Read data
|
||||
rn, err := unix.Read(b.inFd, buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rn == 0 {
|
||||
// EOF
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return copy of data
|
||||
ret := make([]byte, rn)
|
||||
copy(ret, buf[:rn])
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
|
||||
b.resizeStopCh = make(chan struct{})
|
||||
b.resizeDoneCh = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(b.resizeDoneCh)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.resizeStopCh:
|
||||
return
|
||||
case <-sigCh:
|
||||
w, h := b.Size()
|
||||
handler(w, h)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
//go:build wasm
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
type wasmBackend struct {
|
||||
width, height int
|
||||
inputCh chan []byte
|
||||
jsCallbacks []js.Func
|
||||
returnEmptyNext bool // Signal to return empty on next Read() for standalone ESC
|
||||
}
|
||||
|
||||
const escapeTimeoutMs = 10
|
||||
|
||||
func newBackend() Backend {
|
||||
return &wasmBackend{
|
||||
width: 80,
|
||||
height: 24,
|
||||
inputCh: make(chan []byte, 256),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *wasmBackend) Init() error {
|
||||
// Register JS callbacks
|
||||
inputCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) > 0 {
|
||||
data := make([]byte, args[0].Length())
|
||||
js.CopyBytesToGo(data, args[0])
|
||||
select {
|
||||
case b.inputCh <- data:
|
||||
default:
|
||||
// Buffer full, drop input
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
b.jsCallbacks = append(b.jsCallbacks, inputCb)
|
||||
js.Global().Set("goTerminalInput", inputCb)
|
||||
|
||||
resizeCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) >= 2 {
|
||||
w, h := args[0].Int(), args[1].Int()
|
||||
b.width, b.height = w, h
|
||||
// Resize handler is set via SetResizeHandler, but we need to store it
|
||||
// or have this callback call a method. For simplicity, we'll assign
|
||||
// the handler to a struct field if we need dynamic updates,
|
||||
// but here we rely on the struct field set by SetResizeHandler.
|
||||
// However, SetResizeHandler might be called after Init.
|
||||
// See below for corrected flow.
|
||||
}
|
||||
return nil
|
||||
})
|
||||
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
|
||||
js.Global().Set("goTerminalResize", resizeCb)
|
||||
|
||||
// Initial size query
|
||||
if xterm := js.Global().Get("xterm"); !xterm.IsUndefined() {
|
||||
b.width = xterm.Get("cols").Int()
|
||||
b.height = xterm.Get("rows").Int()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *wasmBackend) Fini() {
|
||||
for _, cb := range b.jsCallbacks {
|
||||
cb.Release()
|
||||
}
|
||||
js.Global().Delete("goTerminalInput")
|
||||
js.Global().Delete("goTerminalResize")
|
||||
}
|
||||
|
||||
func (b *wasmBackend) Size() (int, int) {
|
||||
return b.width, b.height
|
||||
}
|
||||
|
||||
// WindowSize queries xterm.js dimensions. The file argument is ignored;
|
||||
// WASM has a single terminal. ok=false when the xterm global is absent.
|
||||
func WindowSize(_ *os.File) (w, h int, ok bool) {
|
||||
xterm := js.Global().Get("xterm")
|
||||
if xterm.IsUndefined() || xterm.IsNull() {
|
||||
return 0, 0, false
|
||||
}
|
||||
return xterm.Get("cols").Int(), xterm.Get("rows").Int(), true
|
||||
}
|
||||
|
||||
func (b *wasmBackend) Write(p []byte) error {
|
||||
arr := js.Global().Get("Uint8Array").New(len(p))
|
||||
js.CopyBytesToJS(arr, p)
|
||||
js.Global().Call("goTerminalWrite", arr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *wasmBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
||||
// After standalone ESC timeout, return empty to trigger readLoop's ESC emission
|
||||
if b.returnEmptyNext {
|
||||
b.returnEmptyNext = false
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-b.inputCh:
|
||||
// If we received exactly ESC, wait briefly for more data
|
||||
// (in case it's start of escape sequence split across callbacks)
|
||||
if len(data) == 1 && data[0] == 0x1b {
|
||||
// Use JS setTimeout via a promise-based wait
|
||||
moreCh := make(chan []byte, 1)
|
||||
|
||||
// Schedule timeout callback
|
||||
var timeoutCb js.Func
|
||||
timeoutCb = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
select {
|
||||
case moreCh <- nil:
|
||||
default:
|
||||
}
|
||||
timeoutCb.Release()
|
||||
return nil
|
||||
})
|
||||
js.Global().Call("setTimeout", timeoutCb, escapeTimeoutMs)
|
||||
|
||||
// Wait for more data or timeout
|
||||
select {
|
||||
case more := <-b.inputCh:
|
||||
// More data arrived, combine
|
||||
return append(data, more...), nil
|
||||
case <-moreCh:
|
||||
// Timeout, standalone ESC confirmed
|
||||
// Signal next Read() to return empty (triggers readLoop standalone ESC logic)
|
||||
b.returnEmptyNext = true
|
||||
return data, nil
|
||||
case <-stopCh:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
case <-stopCh:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *wasmBackend) SetResizeHandler(handler func(width, height int)) {
|
||||
// Overwrite the resize callback to include the handler invocation
|
||||
// This ensures the handler acts on the latest registration
|
||||
resizeCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
if len(args) >= 2 {
|
||||
w, h := args[0].Int(), args[1].Int()
|
||||
b.width, b.height = w, h
|
||||
handler(w, h)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
b.jsCallbacks = append(b.jsCallbacks, resizeCb)
|
||||
js.Global().Set("goTerminalResize", resizeCb)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//go:build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const escapeTimeoutMs = 10
|
||||
|
||||
type windowsBackend struct {
|
||||
stdin windows.Handle
|
||||
stdout windows.Handle
|
||||
|
||||
oldStdinMode uint32
|
||||
oldStdoutMode uint32
|
||||
oldInputCP uint32
|
||||
oldOutputCP uint32
|
||||
|
||||
resizeStopCh chan struct{}
|
||||
resizeDoneCh chan struct{}
|
||||
}
|
||||
|
||||
func newBackend() Backend {
|
||||
return &windowsBackend{
|
||||
stdin: windows.Handle(os.Stdin.Fd()),
|
||||
stdout: windows.Handle(os.Stdout.Fd()),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *windowsBackend) Init() error {
|
||||
if os.Getenv("WT_SESSION") == "" && os.Getenv("WT_PROFILE_ID") == "" {
|
||||
return fmt.Errorf("Windows Terminal required: WT_SESSION unset; conhost lacks alt screen")
|
||||
}
|
||||
|
||||
if err := windows.GetConsoleMode(b.stdin, &b.oldStdinMode); err != nil {
|
||||
return fmt.Errorf("GetConsoleMode stdin: %w", err)
|
||||
}
|
||||
if err := windows.GetConsoleMode(b.stdout, &b.oldStdoutMode); err != nil {
|
||||
return fmt.Errorf("GetConsoleMode stdout: %w", err)
|
||||
}
|
||||
b.oldInputCP, _ = windows.GetConsoleCP()
|
||||
b.oldOutputCP, _ = windows.GetConsoleOutputCP()
|
||||
|
||||
stdinMode := uint32(windows.ENABLE_MOUSE_INPUT |
|
||||
windows.ENABLE_EXTENDED_FLAGS |
|
||||
windows.ENABLE_VIRTUAL_TERMINAL_INPUT)
|
||||
if err := windows.SetConsoleMode(b.stdin, stdinMode); err != nil {
|
||||
return fmt.Errorf("SetConsoleMode stdin: %w", err)
|
||||
}
|
||||
|
||||
stdoutMode := uint32(windows.ENABLE_PROCESSED_OUTPUT |
|
||||
windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING |
|
||||
windows.DISABLE_NEWLINE_AUTO_RETURN)
|
||||
if err := windows.SetConsoleMode(b.stdout, stdoutMode); err != nil {
|
||||
windows.SetConsoleMode(b.stdin, b.oldStdinMode)
|
||||
return fmt.Errorf("SetConsoleMode stdout: %w", err)
|
||||
}
|
||||
|
||||
windows.SetConsoleCP(65001)
|
||||
windows.SetConsoleOutputCP(65001)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *windowsBackend) Fini() {
|
||||
if b.resizeStopCh != nil {
|
||||
close(b.resizeStopCh)
|
||||
<-b.resizeDoneCh
|
||||
b.resizeStopCh = nil
|
||||
}
|
||||
if b.oldStdinMode != 0 {
|
||||
windows.SetConsoleMode(b.stdin, b.oldStdinMode)
|
||||
}
|
||||
if b.oldStdoutMode != 0 {
|
||||
windows.SetConsoleMode(b.stdout, b.oldStdoutMode)
|
||||
}
|
||||
if b.oldInputCP != 0 {
|
||||
windows.SetConsoleCP(b.oldInputCP)
|
||||
}
|
||||
if b.oldOutputCP != 0 {
|
||||
windows.SetConsoleOutputCP(b.oldOutputCP)
|
||||
}
|
||||
}
|
||||
|
||||
func windowSizeHandle(h windows.Handle) (int, int, bool) {
|
||||
var info windows.ConsoleScreenBufferInfo
|
||||
if err := windows.GetConsoleScreenBufferInfo(h, &info); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
w := int(info.Window.Right-info.Window.Left) + 1
|
||||
ht := int(info.Window.Bottom-info.Window.Top) + 1
|
||||
if w < 1 || ht < 1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return w, ht, true
|
||||
}
|
||||
|
||||
// WindowSize queries terminal dimensions for f without console mode changes.
|
||||
// ok=false when f is not a console handle.
|
||||
func WindowSize(f *os.File) (w, h int, ok bool) {
|
||||
return windowSizeHandle(windows.Handle(f.Fd()))
|
||||
}
|
||||
|
||||
func (b *windowsBackend) Size() (int, int) {
|
||||
if w, h, ok := windowSizeHandle(b.stdout); ok {
|
||||
return w, h
|
||||
}
|
||||
return 80, 24
|
||||
}
|
||||
|
||||
func (b *windowsBackend) Write(p []byte) error {
|
||||
var written uint32
|
||||
return windows.WriteFile(b.stdout, p, &written, nil)
|
||||
}
|
||||
|
||||
func (b *windowsBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return nil, nil
|
||||
default:
|
||||
}
|
||||
|
||||
ev, err := windows.WaitForSingleObject(b.stdin, uint32(escapeTimeoutMs))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WaitForSingleObject: %w", err)
|
||||
}
|
||||
if ev == windows.WAIT_TIMEOUT {
|
||||
// Mirrors unix poll timeout: lets readLoop emit pending standalone ESC
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var n uint32
|
||||
if err := windows.ReadFile(b.stdin, buf, &n, nil); err != nil {
|
||||
return nil, fmt.Errorf("ReadFile: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
// VTP consumed a non-keyboard record (e.g. focus event) producing no bytes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ret := make([]byte, n)
|
||||
copy(ret, buf[:n])
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *windowsBackend) SetResizeHandler(handler func(int, int)) {
|
||||
b.resizeStopCh = make(chan struct{})
|
||||
b.resizeDoneCh = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(b.resizeDoneCh)
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
w, h := b.Size()
|
||||
for {
|
||||
select {
|
||||
case <-b.resizeStopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
nw, nh := b.Size()
|
||||
if nw != w || nh != h {
|
||||
w, h = nw, nh
|
||||
handler(w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package terminal
|
||||
|
||||
import "math"
|
||||
|
||||
const softLightLUTSize = 256
|
||||
|
||||
// Perez SoftLight lookup tables (array access, no pointers)
|
||||
// Pre-computed at init to avoid sqrt/division in per-cell loops
|
||||
var (
|
||||
softLightG [softLightLUTSize]float64
|
||||
softLightDF [softLightLUTSize]float64
|
||||
)
|
||||
|
||||
func init() {
|
||||
for i := range softLightLUTSize {
|
||||
df := float64(i) / 255.0
|
||||
softLightDF[i] = df
|
||||
if df <= 0.25 {
|
||||
softLightG[i] = ((16.0*df-12.0)*df + 4.0) * df
|
||||
} else {
|
||||
softLightG[i] = math.Sqrt(df)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clampU8 converts float to uint8 with saturation
|
||||
func clampU8(v float64) uint8 {
|
||||
if v >= 255.0 {
|
||||
return 255
|
||||
}
|
||||
if v <= 0.0 {
|
||||
return 0
|
||||
}
|
||||
// Round-half-up; unifies rounding across Blend/Scale/Lerp/SoftLight
|
||||
return uint8(v + 0.5)
|
||||
}
|
||||
|
||||
// addU8 is saturating uint8 addition
|
||||
func addU8(a, b uint8) uint8 {
|
||||
sum := int(a) + int(b)
|
||||
if sum > 255 {
|
||||
return 255
|
||||
}
|
||||
return uint8(sum)
|
||||
}
|
||||
|
||||
// fastDiv255 approximates x / 255 using integer math: (x + (x >> 8) + 1) >> 8
|
||||
// Faster than DIV instruction, exact for x in [0, 255*255]
|
||||
func fastDiv255(x int) int {
|
||||
return (x + (x >> 8) + 1) >> 8
|
||||
}
|
||||
|
||||
// softLightChannel applies Perez soft light to one channel via LUTs
|
||||
func softLightChannel(d, s uint8, intensity float64) uint8 {
|
||||
df := softLightDF[d]
|
||||
sf := softLightDF[s]
|
||||
|
||||
var result float64
|
||||
if sf < 0.5 {
|
||||
result = df - (1.0-2.0*sf)*df*(1.0-df)
|
||||
} else {
|
||||
// LUT replaces math.Sqrt
|
||||
result = df + (2.0*sf-1.0)*(softLightG[d]-df)
|
||||
}
|
||||
|
||||
// Lerp toward result by intensity, single dependency chain
|
||||
result = df + (result-df)*intensity
|
||||
|
||||
return clampU8(result * 255.0)
|
||||
}
|
||||
|
||||
// overlayChannel combines multiply (d < 128) and screen (d >= 128),
|
||||
// preserving destination highlights and shadows
|
||||
func overlayChannel(d, s uint8) uint8 {
|
||||
if d < 128 {
|
||||
return uint8(fastDiv255(2 * int(d) * int(s)))
|
||||
}
|
||||
return uint8(255 - fastDiv255(2*(255-int(d))*(255-int(s))))
|
||||
}
|
||||
|
||||
// Blend performs linear alpha blend of src over dst
|
||||
// alpha <= 0 returns dst, alpha >= 1 returns src
|
||||
func Blend(dst, src RGB, alpha float64) RGB {
|
||||
if alpha >= 1.0 {
|
||||
return src
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
return dst
|
||||
}
|
||||
inv := 1.0 - alpha
|
||||
return RGB{
|
||||
R: clampU8(float64(src.R)*alpha + float64(dst.R)*inv),
|
||||
G: clampU8(float64(src.G)*alpha + float64(dst.G)*inv),
|
||||
B: clampU8(float64(src.B)*alpha + float64(dst.B)*inv),
|
||||
}
|
||||
}
|
||||
|
||||
// SoftLight applies Perez soft light blend, gentler than linear alpha
|
||||
// intensity in [0,1] mixes between dst and the blended result
|
||||
func SoftLight(dst, src RGB, intensity float64) RGB {
|
||||
return RGB{
|
||||
R: softLightChannel(dst.R, src.R, intensity),
|
||||
G: softLightChannel(dst.G, src.G, intensity),
|
||||
B: softLightChannel(dst.B, src.B, intensity),
|
||||
}
|
||||
}
|
||||
|
||||
// Max returns per-channel maximum, alpha-blended over dst
|
||||
func Max(dst, src RGB, alpha float64) RGB {
|
||||
if alpha <= 0.0 {
|
||||
return dst
|
||||
}
|
||||
maxed := RGB{
|
||||
R: max(dst.R, src.R),
|
||||
G: max(dst.G, src.G),
|
||||
B: max(dst.B, src.B),
|
||||
}
|
||||
if alpha >= 1.0 {
|
||||
return maxed
|
||||
}
|
||||
return Blend(dst, maxed, alpha)
|
||||
}
|
||||
|
||||
// Add performs saturating additive blend, alpha-blended over dst
|
||||
func Add(dst, src RGB, alpha float64) RGB {
|
||||
if alpha <= 0.0 {
|
||||
return dst
|
||||
}
|
||||
added := RGB{
|
||||
R: addU8(dst.R, src.R),
|
||||
G: addU8(dst.G, src.G),
|
||||
B: addU8(dst.B, src.B),
|
||||
}
|
||||
if alpha >= 1.0 {
|
||||
return added
|
||||
}
|
||||
return Blend(dst, added, alpha)
|
||||
}
|
||||
|
||||
// Screen applies 1-(1-dst)*(1-src), alpha-blended over dst
|
||||
// Always lightens; useful for glow accumulation without clipping harshness of Add
|
||||
func Screen(dst, src RGB, alpha float64) RGB {
|
||||
if alpha <= 0.0 {
|
||||
return dst
|
||||
}
|
||||
screened := RGB{
|
||||
R: uint8(255 - fastDiv255((255-int(dst.R))*(255-int(src.R)))),
|
||||
G: uint8(255 - fastDiv255((255-int(dst.G))*(255-int(src.G)))),
|
||||
B: uint8(255 - fastDiv255((255-int(dst.B))*(255-int(src.B)))),
|
||||
}
|
||||
if alpha >= 1.0 {
|
||||
return screened
|
||||
}
|
||||
return Blend(dst, screened, alpha)
|
||||
}
|
||||
|
||||
// Overlay combines multiply (darks) and screen (lights), alpha-blended over dst
|
||||
func Overlay(dst, src RGB, alpha float64) RGB {
|
||||
if alpha <= 0.0 {
|
||||
return dst
|
||||
}
|
||||
overlaid := RGB{
|
||||
R: overlayChannel(dst.R, src.R),
|
||||
G: overlayChannel(dst.G, src.G),
|
||||
B: overlayChannel(dst.B, src.B),
|
||||
}
|
||||
if alpha >= 1.0 {
|
||||
return overlaid
|
||||
}
|
||||
return Blend(dst, overlaid, alpha)
|
||||
}
|
||||
|
||||
// Scale multiplies all channels by factor, saturating (factor > 1.0 brightens)
|
||||
func Scale(c RGB, factor float64) RGB {
|
||||
return RGB{
|
||||
R: clampU8(float64(c.R) * factor),
|
||||
G: clampU8(float64(c.G) * factor),
|
||||
B: clampU8(float64(c.B) * factor),
|
||||
}
|
||||
}
|
||||
|
||||
// Grayscale converts to grayscale using Rec. 601 luma coefficients
|
||||
// Y = R*0.299 + G*0.587 + B*0.114, integer math
|
||||
func Grayscale(c RGB) RGB {
|
||||
gray := uint8((int(c.R)*299 + int(c.G)*587 + int(c.B)*114) / 1000)
|
||||
return RGB{R: gray, G: gray, B: gray}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package terminal
|
||||
|
||||
// ColorMode indicates terminal color capability
|
||||
type ColorMode uint8
|
||||
|
||||
const (
|
||||
ColorMode256 ColorMode = iota // xterm-256 palette
|
||||
ColorModeTrueColor // 24-bit RGB
|
||||
)
|
||||
|
||||
// RGB represents a 24-bit color
|
||||
type RGB struct {
|
||||
R uint8 `toml:"r"`
|
||||
G uint8 `toml:"g"`
|
||||
B uint8 `toml:"b"`
|
||||
}
|
||||
|
||||
// Lerp linearly interpolates from c to other by t, clamped to [0,1]
|
||||
func (c RGB) Lerp(other RGB, t float64) RGB {
|
||||
if t <= 0 {
|
||||
return c
|
||||
}
|
||||
if t >= 1 {
|
||||
return other
|
||||
}
|
||||
return RGB{
|
||||
R: clampU8(float64(c.R) + (float64(other.R)-float64(c.R))*t),
|
||||
G: clampU8(float64(c.G) + (float64(other.G)-float64(c.G))*t),
|
||||
B: clampU8(float64(c.B) + (float64(other.B)-float64(c.B))*t),
|
||||
}
|
||||
}
|
||||
|
||||
// RGBBlack is the zero value black color
|
||||
var RGBBlack = RGB{0, 0, 0}
|
||||
|
||||
// 6-bit quantized LUT for Redmean-based 256-color mapping
|
||||
// 64×64×64 = 262,144 bytes, fits in L2 cache
|
||||
var lut256 [64 * 64 * 64]uint8
|
||||
|
||||
func init() {
|
||||
// Pre-compute Redmean-based palette mapping for all 6-bit quantized RGB values
|
||||
for r := 0; r < 64; r++ {
|
||||
for g := 0; g < 64; g++ {
|
||||
for b := 0; b < 64; b++ {
|
||||
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
|
||||
r8 := (r << 2) | 2
|
||||
g8 := (g << 2) | 2
|
||||
b8 := (b << 2) | 2
|
||||
lut256[r<<12|g<<6|b] = computeRedmean256(r8, g8, b8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// computeRedmean256 finds the nearest 256-palette index using Redmean distance
|
||||
// Called only at init() to populate LUT
|
||||
func computeRedmean256(r, g, b int) uint8 {
|
||||
// Grayscale fast path
|
||||
if r == g && g == b {
|
||||
if r < 8 {
|
||||
return 16
|
||||
}
|
||||
if r > 238 {
|
||||
return 231
|
||||
}
|
||||
return uint8(232 + (r-8)/10)
|
||||
}
|
||||
|
||||
bestIdx := uint8(16)
|
||||
minDist := 1 << 30
|
||||
|
||||
// Search 6×6×6 cube (indices 16-231)
|
||||
for i := 0; i < 216; i++ {
|
||||
cr := cubeValues[i/36]
|
||||
cg := cubeValues[(i/6)%6]
|
||||
cb := cubeValues[i%6]
|
||||
|
||||
d := redmeanDistance(r, g, b, cr, cg, cb)
|
||||
if d < minDist {
|
||||
minDist = d
|
||||
bestIdx = uint8(16 + i)
|
||||
}
|
||||
}
|
||||
|
||||
// Search grayscale ramp (indices 232-255)
|
||||
for i := 0; i < 24; i++ {
|
||||
gray := 8 + i*10
|
||||
d := redmeanDistance(r, g, b, gray, gray, gray)
|
||||
if d < minDist {
|
||||
minDist = d
|
||||
bestIdx = uint8(232 + i)
|
||||
}
|
||||
}
|
||||
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
// redmeanDistance calculates perceptually-weighted color distance
|
||||
// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB
|
||||
func redmeanDistance(r1, g1, b1, r2, g2, b2 int) int {
|
||||
rmean := (r1 + r2) / 2
|
||||
dr := r1 - r2
|
||||
dg := g1 - g2
|
||||
db := b1 - b2
|
||||
return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8)
|
||||
}
|
||||
|
||||
// Color cube values for 6×6×6 palette (indices 16-231)
|
||||
var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
|
||||
|
||||
// RGBTo256 converts RGB to nearest 256-color palette index
|
||||
// O(1) lookup via pre-computed Redmean LUT
|
||||
func RGBTo256(c RGB) uint8 {
|
||||
return lut256[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
|
||||
}
|
||||
@@ -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,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
"github.com/lixenwraith/terminal/inline"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := inline.New(os.Stdout)
|
||||
|
||||
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
|
||||
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
|
||||
dim := inline.Fg(terminal.IronGray)
|
||||
|
||||
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
|
||||
frame := 0
|
||||
|
||||
for i, pkg := range pkgs {
|
||||
const steps = 25
|
||||
for s := range steps {
|
||||
pct := (float64(i) + float64(s)/steps) / float64(len(pkgs))
|
||||
p.Update(
|
||||
inline.Spinner(frame)+" installing "+p.Paint(pkg, name),
|
||||
"["+inline.Bar(32, pct, inline.BarBlock)+"] "+
|
||||
p.Paint(fmt.Sprintf("%d/%d", i+1, len(pkgs)), dim),
|
||||
)
|
||||
frame++
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
}
|
||||
p.Log("%s %s", p.Paint("✓", okSt), pkg)
|
||||
}
|
||||
|
||||
p.Done(p.Paint("✓ 5 packages installed", okSt))
|
||||
}
|
||||
@@ -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,141 @@
|
||||
# inline
|
||||
|
||||
Styled text and in-place progress in the normal terminal scrollback. No raw
|
||||
mode, no alternate screen, no input handling, no cursor hiding — the shell
|
||||
keeps owning the terminal. Intended for CLI tools (package managers, service
|
||||
tooling, build scripts) that want color and live status without a full-screen
|
||||
TUI.
|
||||
|
||||
Unix only (`//go:build unix`). Depends on the parent `terminal` package for
|
||||
color types, capability detection, and RGB → 256 mapping.
|
||||
|
||||
## Model
|
||||
|
||||
Output is split into two zones:
|
||||
|
||||
installed openssl ← permanent lines (Log) — scroll normally
|
||||
installed zlib
|
||||
⠹ installing curl ← live block (Update) — rewritten in place
|
||||
[██████░░░░░░░░] 2/5
|
||||
|
||||
`Log` prints permanent lines above the live block; `Update` replaces the live
|
||||
block by cursor-up + clear + rewrite; `Done` erases the block and optionally
|
||||
prints final lines. Interleaving is handled internally — `Log` during an
|
||||
active live block erases, prints, and redraws in one flush.
|
||||
|
||||
## API
|
||||
|
||||
### Printer
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `New(w io.Writer) *Printer` | Creates a printer. Terminal detection via size probe; color defaults on for terminals with `NO_COLOR` unset. Safe for concurrent use. |
|
||||
| `Log(format string, a ...any)` | Prints one permanent line above the live block (`Printf` semantics, newline appended). |
|
||||
| `Update(lines ...string)` | Replaces the live block, rewriting in place. No-op on non-terminal output. |
|
||||
| `Done(final ...string)` | Erases the live block and prints final permanent lines. Call before exit. |
|
||||
| `Paint(s string, st Style) string` | Returns `s` wrapped in SGR codes for the detected color mode, or unchanged when color is off. |
|
||||
| `SetColor(on bool)` | Overrides color detection (e.g. force styling into a pipe for `less -R`). Affects `Paint` only; `Update` remains terminal-gated. |
|
||||
| `Size() (w, h int)` | Current terminal dimensions, 80×24 when unknown. |
|
||||
|
||||
### Style
|
||||
|
||||
Value type, zero value is unstyled, builder-composable:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `Fg(c terminal.RGB) Style` | Starts a style with foreground color. |
|
||||
| `(s Style) Bg(c terminal.RGB) Style` | Adds background color. |
|
||||
| `(s Style) Attr(a terminal.Attr) Style` | Adds attribute bits (`AttrBold`, `AttrDim`, ...). |
|
||||
|
||||
```go
|
||||
warn := inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
|
||||
p.Log("%s low disk space", p.Paint("warning:", warn))
|
||||
```
|
||||
|
||||
True color terminals get `38;2;R;G;B`; 256-color terminals get `38;5;N` via
|
||||
Redmean mapping — same degradation path as the parent package.
|
||||
|
||||
### Progress helpers
|
||||
|
||||
Pure string builders, no Printer required:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `Bar(width int, pct float64, chars [3]rune) string` | Progress bar of `width` cells, `pct` clamped to [0,1], half-cell resolution via the partial rune. |
|
||||
| `BarBlock` | Default character set `[3]rune{'█', '▌', '░'}`. |
|
||||
| `Spinner(frame int) string` | Braille spinner frame for a monotonic counter. |
|
||||
|
||||
Compose with `Paint` for colored bars:
|
||||
|
||||
```go
|
||||
line := "[" + p.Paint(inline.Bar(30, pct, inline.BarBlock), barStyle) + "]"
|
||||
```
|
||||
|
||||
## Non-terminal output
|
||||
|
||||
When output is a pipe or file (CI, redirection): `Update` is a no-op, `Paint`
|
||||
returns input unchanged, `Log` and `Done` print plain sequential text. A tool
|
||||
using inline degrades to ordinary log output with no code changes.
|
||||
|
||||
## Example
|
||||
|
||||
Simulated package installation — spinner, overall progress bar, permanent
|
||||
completion lines:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
"github.com/lixenwraith/terminal/inline"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := inline.New(os.Stdout)
|
||||
|
||||
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
|
||||
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
|
||||
dim := inline.Fg(terminal.IronGray)
|
||||
|
||||
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
|
||||
frame := 0
|
||||
|
||||
for i, pkg := range pkgs {
|
||||
const steps = 25
|
||||
for s := range steps {
|
||||
pct := (float64(i) + float64(s)/steps) / float64(len(pkgs))
|
||||
p.Update(
|
||||
inline.Spinner(frame)+" installing "+p.Paint(pkg, name),
|
||||
"["+inline.Bar(32, pct, inline.BarBlock)+"] "+
|
||||
p.Paint(fmt.Sprintf("%d/%d", i+1, len(pkgs)), dim),
|
||||
)
|
||||
frame++
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
}
|
||||
p.Log("%s %s", p.Paint("✓", okSt), pkg)
|
||||
}
|
||||
|
||||
p.Done(p.Paint("✓ 5 packages installed", okSt))
|
||||
}
|
||||
```
|
||||
|
||||
Run in a terminal: the two-line status block animates in place while
|
||||
completion lines accumulate above it. Piped (`go run . | cat`): only the
|
||||
completion lines and the final summary appear, unstyled.
|
||||
|
||||
## Notes
|
||||
|
||||
- Width is measured in runes (`unicode/utf8`); East Asian wide characters and
|
||||
combining marks are not width-aware — same limitation as `tui`.
|
||||
- Live block lines must occupy one visual row each: no `\n`, tabs, or control
|
||||
characters. Lines are truncated to terminal width automatically; embedded
|
||||
SGR from `Paint` is preserved through truncation.
|
||||
- The live block is clamped to terminal height − 1 rows (newest lines kept).
|
||||
- Pass external strings (package names, paths) as `Log` arguments, never as
|
||||
the format string.
|
||||
- Ctrl-C mid-update leaves the live block on screen but the terminal in a
|
||||
normal state — no raw mode or screen buffer to restore.
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build unix
|
||||
|
||||
// Package inline renders styled text and in-place progress in the normal
|
||||
// terminal scrollback: no raw mode, no alternate screen, no input handling,
|
||||
// no cursor hiding. Intended for CLI tools that want color and live status
|
||||
// without owning the screen.
|
||||
//
|
||||
// Model: permanent lines scroll via Log; a live block of status lines is
|
||||
// pinned below them and rewritten in place via Update. Done erases the
|
||||
// block and optionally prints final permanent lines.
|
||||
//
|
||||
// Non-terminal output (pipes, CI): Update is a no-op, styling is stripped
|
||||
// unless overridden with SetColor(true), Log and Done print plainly.
|
||||
//
|
||||
// Width is measured in runes (unicode/utf8); wide and combining characters
|
||||
// are not width-aware — same documented limitation as tui.
|
||||
package inline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
|
||||
// Printer manages styled output and the live block. Safe for concurrent use.
|
||||
type Printer struct {
|
||||
mu sync.Mutex
|
||||
w *bufio.Writer
|
||||
tty *os.File // non-nil when output is a terminal
|
||||
color bool
|
||||
mode terminal.ColorMode
|
||||
live []string // desired live block content
|
||||
drawn int // lines currently on screen (may be clamped below len(live))
|
||||
}
|
||||
|
||||
// New creates a Printer for w. Terminal detection via WindowSize probe;
|
||||
// styling defaults on for terminals with NO_COLOR unset.
|
||||
func New(w io.Writer) *Printer {
|
||||
p := &Printer{w: bufio.NewWriter(w)}
|
||||
if f, isFile := w.(*os.File); isFile {
|
||||
if _, _, ok := terminal.WindowSize(f); ok {
|
||||
p.tty = f
|
||||
}
|
||||
}
|
||||
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
|
||||
p.mode = terminal.DetectColorMode()
|
||||
return p
|
||||
}
|
||||
|
||||
// SetColor overrides style detection. Affects Paint only; live-block
|
||||
// updates remain terminal-gated.
|
||||
func (p *Printer) SetColor(on bool) {
|
||||
p.mu.Lock()
|
||||
p.color = on
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Size returns terminal dimensions, 80×24 when unknown
|
||||
func (p *Printer) Size() (w, h int) {
|
||||
if p.tty != nil {
|
||||
if w, h, ok := terminal.WindowSize(p.tty); ok {
|
||||
return w, h
|
||||
}
|
||||
}
|
||||
return 80, 24
|
||||
}
|
||||
|
||||
// Log prints a permanent line above the live block
|
||||
func (p *Printer) Log(format string, a ...any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.eraseLocked()
|
||||
fmt.Fprintf(p.w, format, a...)
|
||||
p.w.WriteByte('\n')
|
||||
p.redrawLocked()
|
||||
p.w.Flush()
|
||||
}
|
||||
|
||||
// Update replaces the live block, rewriting in place. No-op on non-terminal output.
|
||||
func (p *Printer) Update(lines ...string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.tty == nil {
|
||||
return
|
||||
}
|
||||
p.eraseLocked()
|
||||
p.live = append(p.live[:0], lines...)
|
||||
p.redrawLocked()
|
||||
p.w.Flush()
|
||||
}
|
||||
|
||||
// Done erases the live block and prints final permanent lines
|
||||
func (p *Printer) Done(final ...string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.eraseLocked()
|
||||
p.live = p.live[:0]
|
||||
for _, ln := range final {
|
||||
p.w.WriteString(ln)
|
||||
p.w.WriteByte('\n')
|
||||
}
|
||||
p.w.Flush()
|
||||
}
|
||||
|
||||
// eraseLocked removes the drawn live block; cursor ends at block origin.
|
||||
// Cursor sits one line below the block (redraw ends each line with '\n').
|
||||
func (p *Printer) eraseLocked() {
|
||||
if p.tty == nil || p.drawn == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(p.w, "\x1b[%dA\r\x1b[J", p.drawn)
|
||||
p.drawn = 0
|
||||
}
|
||||
|
||||
// redrawLocked writes the live block; assumes screen below cursor is clear.
|
||||
// Lines are truncated to terminal width so cursor-up arithmetic stays valid
|
||||
// (relies on xterm deferred autowrap for exact-width lines). Block is
|
||||
// clamped to height-1 rows, keeping newest lines.
|
||||
func (p *Printer) redrawLocked() {
|
||||
if p.tty == nil || len(p.live) == 0 {
|
||||
return
|
||||
}
|
||||
w, h := p.Size()
|
||||
lines := p.live
|
||||
if h > 1 && len(lines) > h-1 {
|
||||
lines = lines[len(lines)-(h-1):]
|
||||
}
|
||||
for _, ln := range lines {
|
||||
p.w.WriteString(truncVisible(ln, w))
|
||||
p.w.WriteByte('\n')
|
||||
}
|
||||
p.drawn = len(lines)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build unix
|
||||
|
||||
package inline
|
||||
|
||||
import "strings"
|
||||
|
||||
// BarBlock is the default bar character set [filled, partial, empty]
|
||||
var BarBlock = [3]rune{'█', '▌', '░'}
|
||||
|
||||
// Bar renders an unstyled progress bar of width cells, pct in [0,1]
|
||||
func Bar(width int, pct float64, chars [3]rune) string {
|
||||
if width < 1 {
|
||||
return ""
|
||||
}
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 1 {
|
||||
pct = 1
|
||||
}
|
||||
filled := int(float64(width) * pct)
|
||||
rem := float64(width)*pct - float64(filled)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(width * 3) // Worst-case UTF-8
|
||||
for i := range width {
|
||||
switch {
|
||||
case i < filled:
|
||||
b.WriteRune(chars[0])
|
||||
case i == filled && rem >= 0.5 && filled < width:
|
||||
b.WriteRune(chars[1])
|
||||
default:
|
||||
b.WriteRune(chars[2])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Braille frames; intentionally duplicated from tui (no tui dependency)
|
||||
var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// Spinner returns the frame for a monotonic counter
|
||||
func Spinner(frame int) string {
|
||||
i := frame % len(spinnerFrames)
|
||||
if i < 0 {
|
||||
i = -i
|
||||
}
|
||||
return spinnerFrames[i]
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//go:build unix
|
||||
|
||||
package inline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
|
||||
// Style describes text appearance; zero value is unstyled.
|
||||
// Composable: inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
|
||||
type Style struct {
|
||||
fg, bg terminal.RGB
|
||||
hasFg, hasBg bool
|
||||
attr terminal.Attr
|
||||
}
|
||||
|
||||
// Fg starts a style with foreground color
|
||||
func Fg(c terminal.RGB) Style { return Style{fg: c, hasFg: true} }
|
||||
|
||||
// Bg sets background color
|
||||
func (s Style) Bg(c terminal.RGB) Style { s.bg, s.hasBg = c, true; return s }
|
||||
|
||||
// Attr adds attribute bits
|
||||
func (s Style) Attr(a terminal.Attr) Style { s.attr |= a; return s }
|
||||
|
||||
// Paint returns s styled for the detected terminal, unchanged when color
|
||||
// is disabled. Composes with Log: p.Log("%s %s", p.Paint("ok", st), name)
|
||||
func (p *Printer) Paint(s string, st Style) string {
|
||||
if !p.color {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
p.writeSGR(&b, st)
|
||||
b.WriteString(s)
|
||||
b.WriteString("\x1b[0m")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *Printer) writeSGR(b *strings.Builder, s Style) {
|
||||
b.WriteString("\x1b[0")
|
||||
for _, m := range [...]struct {
|
||||
bit terminal.Attr
|
||||
code string
|
||||
}{
|
||||
{terminal.AttrBold, ";1"}, {terminal.AttrDim, ";2"},
|
||||
{terminal.AttrItalic, ";3"}, {terminal.AttrUnderline, ";4"},
|
||||
{terminal.AttrBlink, ";5"}, {terminal.AttrReverse, ";7"},
|
||||
} {
|
||||
if s.attr&m.bit != 0 {
|
||||
b.WriteString(m.code)
|
||||
}
|
||||
}
|
||||
if s.hasFg {
|
||||
if p.mode == terminal.ColorModeTrueColor {
|
||||
fmt.Fprintf(b, ";38;2;%d;%d;%d", s.fg.R, s.fg.G, s.fg.B)
|
||||
} else {
|
||||
fmt.Fprintf(b, ";38;5;%d", terminal.RGBTo256(s.fg))
|
||||
}
|
||||
}
|
||||
if s.hasBg {
|
||||
if p.mode == terminal.ColorModeTrueColor {
|
||||
fmt.Fprintf(b, ";48;2;%d;%d;%d", s.bg.R, s.bg.G, s.bg.B)
|
||||
} else {
|
||||
fmt.Fprintf(b, ";48;5;%d", terminal.RGBTo256(s.bg))
|
||||
}
|
||||
}
|
||||
b.WriteByte('m')
|
||||
}
|
||||
|
||||
// --- Width handling (internal, rune-count semantics) ---
|
||||
// Handles only 'm'-terminated escapes — this package's own SGR output.
|
||||
|
||||
// visibleLen counts runes excluding SGR sequences
|
||||
func visibleLen(s string) int {
|
||||
n := 0
|
||||
for {
|
||||
i := strings.IndexByte(s, 0x1b)
|
||||
if i < 0 {
|
||||
return n + utf8.RuneCountInString(s)
|
||||
}
|
||||
n += utf8.RuneCountInString(s[:i])
|
||||
m := strings.IndexByte(s[i:], 'm')
|
||||
if m < 0 {
|
||||
return n // Unterminated escape, remainder not visible
|
||||
}
|
||||
s = s[i+m+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// runePrefix returns up to k leading runes of s and the count taken
|
||||
func runePrefix(s string, k int) (string, int) {
|
||||
if k <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
n := 0
|
||||
for i := range s {
|
||||
if n == k {
|
||||
return s[:i], n
|
||||
}
|
||||
n++
|
||||
}
|
||||
return s, n
|
||||
}
|
||||
|
||||
// truncVisible truncates to max visible runes, preserving embedded SGR
|
||||
// sequences and appending a reset when cut
|
||||
func truncVisible(s string, max int) string {
|
||||
if visibleLen(s) <= max {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for len(s) > 0 {
|
||||
i := strings.IndexByte(s, 0x1b)
|
||||
if i != 0 {
|
||||
seg := s
|
||||
if i > 0 {
|
||||
seg = s[:i]
|
||||
}
|
||||
pre, taken := runePrefix(seg, max-n)
|
||||
b.WriteString(pre)
|
||||
n += taken
|
||||
if n >= max {
|
||||
break
|
||||
}
|
||||
s = s[len(seg):]
|
||||
continue
|
||||
}
|
||||
m := strings.IndexByte(s, 'm')
|
||||
if m < 0 {
|
||||
break // Unterminated escape, drop remainder
|
||||
}
|
||||
b.WriteString(s[:m+1])
|
||||
s = s[m+1:]
|
||||
}
|
||||
b.WriteString("\x1b[0m")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType distinguishes input event categories
|
||||
type EventType uint8
|
||||
|
||||
const (
|
||||
EventKey EventType = iota
|
||||
EventResize
|
||||
EventPaste // Future: bracketed paste
|
||||
EventMouse // SGR mouse reporting
|
||||
EventError // Read error
|
||||
EventClosed // Input closed
|
||||
)
|
||||
|
||||
// Event represents a terminal input event
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Key Key
|
||||
Rune rune
|
||||
Modifiers Modifier
|
||||
Width int // For EventResize
|
||||
Height int // For EventResize
|
||||
Err error // For EventError
|
||||
|
||||
// Mouse event fields
|
||||
MouseX int
|
||||
MouseY int
|
||||
MouseBtn MouseButton
|
||||
MouseAction MouseAction
|
||||
}
|
||||
|
||||
// inputReader handles raw stdin parsing
|
||||
type inputReader struct {
|
||||
backend Backend
|
||||
eventCh chan Event
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
|
||||
// Persistent buffer for stream assembly, not fixed size zero-alloc to avoid corrupting partial UTF-8 at boundary
|
||||
buf []byte
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// newInputReader creates a new input reader
|
||||
func newInputReader(backend Backend) *inputReader {
|
||||
return &inputReader{
|
||||
backend: backend,
|
||||
eventCh: make(chan Event, 256),
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
buf: make([]byte, 0, 256),
|
||||
}
|
||||
}
|
||||
|
||||
// start begins reading input in a goroutine
|
||||
func (r *inputReader) start() {
|
||||
r.mu.Lock()
|
||||
if r.running {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.running = true
|
||||
r.mu.Unlock()
|
||||
|
||||
go r.readLoop()
|
||||
}
|
||||
|
||||
// stop signals the reader to stop
|
||||
func (r *inputReader) stop() {
|
||||
r.mu.Lock()
|
||||
if !r.running {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.running = false
|
||||
r.mu.Unlock()
|
||||
|
||||
close(r.stopCh)
|
||||
// Wait with timeout - don't block forever if read is stuck
|
||||
select {
|
||||
case <-r.doneCh:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Reader stuck on blocking read, proceed anyway
|
||||
}
|
||||
}
|
||||
|
||||
// events returns the event channel
|
||||
func (r *inputReader) events() <-chan Event {
|
||||
return r.eventCh
|
||||
}
|
||||
|
||||
// readLoop is the main input reading goroutine
|
||||
func (r *inputReader) readLoop() {
|
||||
defer close(r.doneCh)
|
||||
|
||||
// Panic recovery for raw input reader
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
EmergencyReset(os.Stdout)
|
||||
// Use \r\n for clean output
|
||||
fmt.Fprintf(os.Stderr, "\r\n\x1b[31mINPUT READER CRASHED: %v\x1b[0m\r\n", r)
|
||||
fmt.Fprintf(os.Stderr, "Stack Trace:\r\n%s\r\n", debug.Stack())
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
// Blocking read from backend
|
||||
data, err := r.backend.Read(r.stopCh)
|
||||
if err != nil {
|
||||
r.sendEvent(Event{Type: EventError, Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
// Timeout (Unix poll) or empty read
|
||||
// Emit pending standalone ESC if present
|
||||
if len(r.buf) == 1 && r.buf[0] == 0x1b {
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyEscape})
|
||||
r.buf = r.buf[:0]
|
||||
}
|
||||
select {
|
||||
case <-r.stopCh:
|
||||
r.sendEvent(Event{Type: EventClosed})
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Append to persistent buffer
|
||||
r.buf = append(r.buf, data...)
|
||||
|
||||
// Parse as much as possible, get consumed count
|
||||
consumed := r.parseInput(r.buf)
|
||||
|
||||
// Compact buffer
|
||||
if consumed > 0 {
|
||||
if consumed >= len(r.buf) {
|
||||
r.buf = r.buf[:0]
|
||||
} else {
|
||||
copy(r.buf, r.buf[consumed:])
|
||||
r.buf = r.buf[:len(r.buf)-consumed]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseInput parses raw bytes into events and returns bytes consumed (stop on incomplete sequence)
|
||||
func (r *inputReader) parseInput(data []byte) int {
|
||||
i := 0
|
||||
n := len(data)
|
||||
|
||||
for i < n {
|
||||
select {
|
||||
case <-r.stopCh:
|
||||
return i
|
||||
default:
|
||||
}
|
||||
|
||||
b := data[i]
|
||||
|
||||
// Fast path: printable ASCII
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rune(b)})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Escape sequence
|
||||
if b == 0x1b {
|
||||
// Need at least 2 bytes to determine sequence type
|
||||
if i+1 >= n {
|
||||
return i // Wait for more data
|
||||
}
|
||||
|
||||
consumed, ev := r.parseEscape(data[i:])
|
||||
if consumed == 0 {
|
||||
// Incomplete sequence, wait for more data
|
||||
return i
|
||||
}
|
||||
|
||||
// Only emit if not a swallowed unknown sequence
|
||||
if ev.Key != KeyNone || ev.Type != EventKey {
|
||||
r.sendEvent(ev)
|
||||
}
|
||||
i += consumed
|
||||
continue
|
||||
}
|
||||
|
||||
// Control characters
|
||||
if b < 0x20 {
|
||||
r.sendEvent(r.parseControl(b))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// DEL
|
||||
if b == 0x7f {
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyBackspace})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// UTF-8 multibyte
|
||||
if b >= 0x80 {
|
||||
// Check if full sequence available
|
||||
seqLen := utf8SeqLen(b)
|
||||
if seqLen == 0 {
|
||||
// Invalid start byte, skip
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+seqLen > n {
|
||||
// Incomplete UTF-8, wait for more data
|
||||
return i
|
||||
}
|
||||
|
||||
rn, size := decodeRune(data[i:])
|
||||
r.sendEvent(Event{Type: EventKey, Key: KeyRune, Rune: rn})
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// utf8SeqLen returns expected UTF-8 sequence length from start byte, 0 if invalid
|
||||
func utf8SeqLen(b byte) int {
|
||||
if b < 0x80 {
|
||||
return 1
|
||||
}
|
||||
if b&0xe0 == 0xc0 {
|
||||
return 2
|
||||
}
|
||||
if b&0xf0 == 0xe0 {
|
||||
return 3
|
||||
}
|
||||
if b&0xf8 == 0xf0 {
|
||||
return 4
|
||||
}
|
||||
return 0 // Invalid
|
||||
}
|
||||
|
||||
// parseEscape attempts to parse an escape sequence, returns 0 on incomplete
|
||||
func (r *inputReader) parseEscape(data []byte) (int, Event) {
|
||||
if len(data) < 2 {
|
||||
return 0, Event{} // Incomplete, wait for more
|
||||
}
|
||||
|
||||
// ESC ESC -> Alt+Escape
|
||||
if data[1] == 0x1b {
|
||||
return 2, Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt}
|
||||
}
|
||||
|
||||
if data[1] == '[' {
|
||||
return r.parseCSI(data)
|
||||
}
|
||||
if data[1] == 'O' {
|
||||
return r.parseSS3(data)
|
||||
}
|
||||
|
||||
// Alt+Control character (ESC + 0x00-0x1F)
|
||||
if data[1] < 0x20 {
|
||||
ev := r.parseControl(data[1])
|
||||
ev.Modifiers |= ModAlt
|
||||
return 2, ev
|
||||
}
|
||||
|
||||
// Alt+printable
|
||||
if data[1] >= 0x20 && data[1] < 0x7f {
|
||||
return 2, Event{Type: EventKey, Key: KeyRune, Rune: rune(data[1]), Modifiers: ModAlt}
|
||||
}
|
||||
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
// parseCSI parses CSI sequence without allocation
|
||||
func (r *inputReader) parseCSI(data []byte) (int, Event) {
|
||||
if len(data) < 3 {
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
// SGR mouse: ESC [ < Btn ; X ; Y M/m
|
||||
if data[2] == '<' {
|
||||
return r.parseSGRMouse(data)
|
||||
}
|
||||
|
||||
end := 2
|
||||
maxScan := len(data)
|
||||
if maxScan > 16 {
|
||||
maxScan = 16
|
||||
}
|
||||
|
||||
for end < maxScan {
|
||||
b := data[end]
|
||||
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' {
|
||||
end++
|
||||
break
|
||||
}
|
||||
if b < 0x20 || b > 0x7e {
|
||||
return 0, Event{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
// Check if we found a terminator or ran out of data
|
||||
if end <= 2 || end > maxScan {
|
||||
return 0, Event{} // Incomplete
|
||||
}
|
||||
|
||||
// Check last byte is valid terminator
|
||||
lastByte := data[end-1]
|
||||
if !((lastByte >= 'A' && lastByte <= 'Z') || (lastByte >= 'a' && lastByte <= 'z') || lastByte == '~') {
|
||||
return 0, Event{} // Incomplete, no terminator found
|
||||
}
|
||||
|
||||
if key, mod, ok := lookupCSI(data[2:end]); ok {
|
||||
return end, Event{Type: EventKey, Key: key, Modifiers: mod}
|
||||
}
|
||||
|
||||
// Unknown but valid CSI syntax - consume and return KeyNone
|
||||
return end, Event{Type: EventKey, Key: KeyNone}
|
||||
}
|
||||
|
||||
// parseSS3 parses SS3 sequence without allocation, returns length even for unknown sequences
|
||||
func (r *inputReader) parseSS3(data []byte) (int, Event) {
|
||||
if len(data) < 3 {
|
||||
return 0, Event{}
|
||||
}
|
||||
if key, mod, ok := lookupSS3(data[2:3]); ok {
|
||||
return 3, Event{Type: EventKey, Key: key, Modifiers: mod}
|
||||
}
|
||||
// Unknown SS3 - consume to prevent garbage
|
||||
return 3, Event{Type: EventKey, Key: KeyNone}
|
||||
}
|
||||
|
||||
// parseControl maps control characters to keys
|
||||
func (r *inputReader) parseControl(b byte) Event {
|
||||
switch b {
|
||||
case 0x00: // Ctrl+Space or Ctrl+@
|
||||
return Event{Type: EventKey, Key: KeyCtrlSpace}
|
||||
case 0x01:
|
||||
return Event{Type: EventKey, Key: KeyCtrlA}
|
||||
case 0x02:
|
||||
return Event{Type: EventKey, Key: KeyCtrlB}
|
||||
case 0x03:
|
||||
return Event{Type: EventKey, Key: KeyCtrlC}
|
||||
case 0x04:
|
||||
return Event{Type: EventKey, Key: KeyCtrlD}
|
||||
case 0x05:
|
||||
return Event{Type: EventKey, Key: KeyCtrlE}
|
||||
case 0x06:
|
||||
return Event{Type: EventKey, Key: KeyCtrlF}
|
||||
case 0x07:
|
||||
return Event{Type: EventKey, Key: KeyCtrlG}
|
||||
case 0x08: // Ctrl+H or Backspace
|
||||
return Event{Type: EventKey, Key: KeyBackspace}
|
||||
case 0x09: // Tab
|
||||
return Event{Type: EventKey, Key: KeyTab}
|
||||
case 0x0a, 0x0d: // LF, CR (Enter)
|
||||
return Event{Type: EventKey, Key: KeyEnter}
|
||||
case 0x0b:
|
||||
return Event{Type: EventKey, Key: KeyCtrlK}
|
||||
case 0x0c:
|
||||
return Event{Type: EventKey, Key: KeyCtrlL}
|
||||
case 0x0e:
|
||||
return Event{Type: EventKey, Key: KeyCtrlN}
|
||||
case 0x0f:
|
||||
return Event{Type: EventKey, Key: KeyCtrlO}
|
||||
case 0x10:
|
||||
return Event{Type: EventKey, Key: KeyCtrlP}
|
||||
case 0x11:
|
||||
return Event{Type: EventKey, Key: KeyCtrlQ}
|
||||
case 0x12:
|
||||
return Event{Type: EventKey, Key: KeyCtrlR}
|
||||
case 0x13:
|
||||
return Event{Type: EventKey, Key: KeyCtrlS}
|
||||
case 0x14:
|
||||
return Event{Type: EventKey, Key: KeyCtrlT}
|
||||
case 0x15:
|
||||
return Event{Type: EventKey, Key: KeyCtrlU}
|
||||
case 0x16:
|
||||
return Event{Type: EventKey, Key: KeyCtrlV}
|
||||
case 0x17:
|
||||
return Event{Type: EventKey, Key: KeyCtrlW}
|
||||
case 0x18:
|
||||
return Event{Type: EventKey, Key: KeyCtrlX}
|
||||
case 0x19:
|
||||
return Event{Type: EventKey, Key: KeyCtrlY}
|
||||
case 0x1a:
|
||||
return Event{Type: EventKey, Key: KeyCtrlZ}
|
||||
case 0x1b: // ESC (shouldn't reach here normally)
|
||||
return Event{Type: EventKey, Key: KeyEscape}
|
||||
case 0x1c:
|
||||
return Event{Type: EventKey, Key: KeyCtrlBackslash}
|
||||
case 0x1d:
|
||||
return Event{Type: EventKey, Key: KeyCtrlBracketRight}
|
||||
case 0x1e:
|
||||
return Event{Type: EventKey, Key: KeyCtrlCaret}
|
||||
case 0x1f:
|
||||
return Event{Type: EventKey, Key: KeyCtrlUnderscore}
|
||||
}
|
||||
return Event{Type: EventKey, Key: KeyNone}
|
||||
}
|
||||
|
||||
// parseSGRMouse parses mouse SGR sequences
|
||||
func (r *inputReader) parseSGRMouse(data []byte) (int, Event) {
|
||||
// Format: ESC [ < Btn ; X ; Y M/m
|
||||
// Minimum: ESC [ < 0 ; 1 ; 1 M = 10 bytes
|
||||
if len(data) < 10 {
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
// Find terminator M or m
|
||||
end := 3
|
||||
for end < len(data) && end < 32 {
|
||||
if data[end] == 'M' || data[end] == 'm' {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end >= len(data) || (data[end] != 'M' && data[end] != 'm') {
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
// Parse: Btn;X;Y
|
||||
params := data[3:end]
|
||||
btn, x, y, ok := parseSGRParams(params)
|
||||
if !ok {
|
||||
return 0, Event{}
|
||||
}
|
||||
|
||||
ev := Event{Type: EventMouse, MouseX: x - 1, MouseY: y - 1} // Convert to 0-indexed
|
||||
|
||||
// Decode button and action
|
||||
// Bits 0-1: button (0=left, 1=middle, 2=right, 3=release)
|
||||
// Bit 5 (32): motion
|
||||
// Bit 6 (64): scroll
|
||||
buttonID := btn & 0x03
|
||||
isMotion := btn&32 != 0
|
||||
isScroll := btn&64 != 0
|
||||
|
||||
if isScroll {
|
||||
// Scroll: buttonID 0=up, 1=down
|
||||
if buttonID == 0 {
|
||||
ev.MouseBtn = MouseBtnWheelUp
|
||||
} else {
|
||||
ev.MouseBtn = MouseBtnWheelDown
|
||||
}
|
||||
ev.MouseAction = MouseActionPress // Scroll is instantaneous
|
||||
} else {
|
||||
// Regular button
|
||||
switch buttonID {
|
||||
case 0:
|
||||
ev.MouseBtn = MouseBtnLeft
|
||||
case 1:
|
||||
ev.MouseBtn = MouseBtnMiddle
|
||||
case 2:
|
||||
ev.MouseBtn = MouseBtnRight
|
||||
case 3:
|
||||
ev.MouseBtn = MouseBtnNone // Release with no specific button
|
||||
}
|
||||
|
||||
if data[end] == 'M' {
|
||||
if isMotion {
|
||||
if ev.MouseBtn != MouseBtnNone {
|
||||
ev.MouseAction = MouseActionDrag
|
||||
} else {
|
||||
ev.MouseAction = MouseActionMove
|
||||
}
|
||||
} else {
|
||||
ev.MouseAction = MouseActionPress
|
||||
}
|
||||
} else {
|
||||
ev.MouseAction = MouseActionRelease
|
||||
}
|
||||
}
|
||||
|
||||
// Extract modifiers from button byte
|
||||
if btn&4 != 0 {
|
||||
ev.Modifiers |= ModShift
|
||||
}
|
||||
if btn&8 != 0 {
|
||||
ev.Modifiers |= ModAlt
|
||||
}
|
||||
if btn&16 != 0 {
|
||||
ev.Modifiers |= ModCtrl
|
||||
}
|
||||
|
||||
return end + 1, ev
|
||||
}
|
||||
|
||||
// parseSGRParams extracts btn, x, y from "Btn;X;Y" format
|
||||
func parseSGRParams(data []byte) (btn, x, y int, ok bool) {
|
||||
state := 0 // 0=btn, 1=x, 2=y
|
||||
val := 0
|
||||
|
||||
for _, b := range data {
|
||||
if b == ';' {
|
||||
switch state {
|
||||
case 0:
|
||||
btn = val
|
||||
case 1:
|
||||
x = val
|
||||
}
|
||||
state++
|
||||
val = 0
|
||||
if state > 2 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
} else if b >= '0' && b <= '9' {
|
||||
val = val*10 + int(b-'0')
|
||||
if val > 9999 { // Sanity limit
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
} else {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
}
|
||||
|
||||
if state != 2 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
y = val
|
||||
return btn, x, y, true
|
||||
}
|
||||
|
||||
// sendEvent sends an event to the channel, non-blocking
|
||||
func (r *inputReader) sendEvent(ev Event) {
|
||||
select {
|
||||
case r.eventCh <- ev:
|
||||
default:
|
||||
// Channel full, drop event (shouldn't happen with 64 buffer)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRune decodes the first UTF-8 rune from data
|
||||
func decodeRune(data []byte) (rune, int) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
b := data[0]
|
||||
if b < 0x80 {
|
||||
return rune(b), 1
|
||||
}
|
||||
|
||||
var size int
|
||||
var min rune
|
||||
var r rune
|
||||
|
||||
switch {
|
||||
case b&0xe0 == 0xc0:
|
||||
size = 2
|
||||
min = 0x80
|
||||
r = rune(b & 0x1f)
|
||||
case b&0xf0 == 0xe0:
|
||||
size = 3
|
||||
min = 0x800
|
||||
r = rune(b & 0x0f)
|
||||
case b&0xf8 == 0xf0:
|
||||
size = 4
|
||||
min = 0x10000
|
||||
r = rune(b & 0x07)
|
||||
default:
|
||||
return 0xFFFD, 1 // Invalid, return replacement char
|
||||
}
|
||||
|
||||
if len(data) < size {
|
||||
return 0xFFFD, 1
|
||||
}
|
||||
|
||||
for i := 1; i < size; i++ {
|
||||
if data[i]&0xc0 != 0x80 {
|
||||
return 0xFFFD, 1
|
||||
}
|
||||
r = r<<6 | rune(data[i]&0x3f)
|
||||
}
|
||||
|
||||
if r < min {
|
||||
return 0xFFFD, 1 // Overlong encoding
|
||||
}
|
||||
|
||||
return r, size
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
|
||||
terminal.IronGray)
|
||||
|
||||
term.Flush(cells, w, h)
|
||||
|
||||
ev := term.PollEvent()
|
||||
switch ev.Type {
|
||||
case terminal.EventKey:
|
||||
switch ev.Key {
|
||||
case terminal.KeyUp:
|
||||
list.SelectPrev()
|
||||
case terminal.KeyDown:
|
||||
list.SelectNext()
|
||||
case terminal.KeyEscape:
|
||||
return
|
||||
}
|
||||
case terminal.EventResize:
|
||||
w, h = ev.Width, ev.Height
|
||||
cells = make([]terminal.Cell, w*h)
|
||||
list.SetVisible(h - 2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```go
|
||||
cols := tui.SplitH(root, 0.3, 0.7) // ratio split, normalized
|
||||
rows := tui.SplitV(cols[1], 0.5, 0.5)
|
||||
side, main := tui.SplitHFixed(root, 24) // fixed left width
|
||||
top, rest := tui.SplitVFixed(root, 3) // fixed top height
|
||||
dlg := tui.Center(root, 50, 12) // centered sub-region
|
||||
```
|
||||
|
||||
The last ratio segment absorbs rounding remainder — no gaps.
|
||||
|
||||
## Text and style
|
||||
|
||||
```go
|
||||
r.Text(x, y, "label", fg, bg, terminal.AttrNone)
|
||||
r.TextCenter(y, "title", fg, bg, terminal.AttrBold)
|
||||
r.TextRight(y, "hint", fg, bg, terminal.AttrDim)
|
||||
lines := r.TextBlock(x, y, longText, fg, bg, attr) // word-wrapped, returns line count
|
||||
r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr})
|
||||
```
|
||||
|
||||
String utilities operate on rune counts: `RuneLen`, `Truncate` /
|
||||
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` /
|
||||
`PadCenter`, `WrapText`.
|
||||
|
||||
`Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
|
||||
accept it.
|
||||
|
||||
## Widgets
|
||||
|
||||
Widgets are stateless render functions (mostly `Region` methods). Application
|
||||
state lives in plain structs passed by pointer. Available renderers:
|
||||
|
||||
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double,
|
||||
rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
|
||||
`KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
|
||||
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
|
||||
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` /
|
||||
`ScrollIndicator`, masonry layout.
|
||||
|
||||
Representative patterns below; remaining widgets follow the same
|
||||
opts-struct + state-struct shape — read the source for full options.
|
||||
|
||||
### Scrollable list with scrollbar
|
||||
|
||||
```go
|
||||
items := make([]tui.ListItem, 0, len(files))
|
||||
for _, f := range files {
|
||||
items = append(items, tui.ListItem{
|
||||
Icon: '▸', IconFg: terminal.Amber,
|
||||
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
|
||||
})
|
||||
}
|
||||
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: terminal.DarkSlate})
|
||||
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, terminal.IronGray)
|
||||
```
|
||||
|
||||
### Modal dialog
|
||||
|
||||
```go
|
||||
dlg := tui.Center(root, 50, 12)
|
||||
content := dlg.Modal(tui.ModalOpts{
|
||||
Title: "Settings",
|
||||
Border: tui.LineDouble,
|
||||
BorderFg: terminal.SteelBlue,
|
||||
TitleFg: terminal.White,
|
||||
Bg: terminal.DarkSlate,
|
||||
})
|
||||
content.TextBlock(0, 0, body, fg, terminal.DarkSlate, terminal.AttrNone)
|
||||
```
|
||||
|
||||
`Modal` fills, borders, titles, and returns the content region. `Overlay`
|
||||
adds fullscreen/floating/shadow variants; `ConfirmDialog` adds yes/no buttons
|
||||
with focus state.
|
||||
|
||||
### Progress overlay
|
||||
|
||||
```go
|
||||
prog := tui.NewProgressState(tui.DefaultProgressOpts("Indexing", "Scanning...",
|
||||
tui.ProgressDeterminate))
|
||||
|
||||
// per frame:
|
||||
prog.Tick()
|
||||
prog.SetProgress(done / total)
|
||||
if prog.Visible {
|
||||
root.ProgressOverlay(prog.Opts)
|
||||
}
|
||||
```
|
||||
|
||||
Five progress types (spinner, determinate, indeterminate, pulse, dots), eight
|
||||
spinner styles, eight bar styles, seven frame styles — combinable via opts.
|
||||
|
||||
### Multi-line editor
|
||||
|
||||
```go
|
||||
ed := tui.NewEditorState(initialText)
|
||||
|
||||
// input:
|
||||
if ev.Type == terminal.EventKey {
|
||||
ed.HandleKey(ev.Key, ev.Rune, ev.Modifiers) // full emacs-style bindings built in
|
||||
}
|
||||
|
||||
// render:
|
||||
r.Editor(ed, tui.EditorOpts{LineNumbers: true, Border: tui.LineSingle, Focused: true})
|
||||
text := ed.Value()
|
||||
```
|
||||
|
||||
`TextFieldState` + `TextField` provide the single-line equivalent
|
||||
(placeholder, prefix, password mask, max length).
|
||||
|
||||
## State helpers
|
||||
|
||||
Pure logic, no rendering — usable independently:
|
||||
|
||||
- `ScrollState` — item-index scrolling with selection
|
||||
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
|
||||
- `ViewportScroll` — row-based content scrolling with viewport clipping
|
||||
(`ClipToViewport` maps content rows to visible rows)
|
||||
- `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
|
||||
keyed state, hierarchical → flat visible-node list
|
||||
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
|
||||
- `MasonryState` — multi-column layout calculation over a viewport
|
||||
- Free functions: `AdjustScroll`, `ClampScroll`, `ClampCursor`, `ScrollPercent`,
|
||||
`PageDelta`
|
||||
|
||||
## Notes
|
||||
|
||||
- Width calculations count runes, not terminal columns; East Asian wide
|
||||
characters and combining marks are not width-aware.
|
||||
- Zero-value `terminal.RGB` in style fields generally means "inherit"
|
||||
(widget default or row background) — check specific widget docs.
|
||||
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
|
||||
application-side geometry from the regions used.
|
||||
+159
@@ -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,84 @@
|
||||
package tui
|
||||
|
||||
import "github.com/lixenwraith/terminal"
|
||||
|
||||
// Default spinner frames for Region.Spinner
|
||||
var spinnerFrames = spinnerSets[SpinnerBraille]
|
||||
|
||||
// Text renders text at position, truncates at region edge
|
||||
func (r Region) Text(x, y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
|
||||
if y < 0 || y >= r.H {
|
||||
return
|
||||
}
|
||||
col := 0
|
||||
for _, ch := range s {
|
||||
if x+col >= r.W {
|
||||
break
|
||||
}
|
||||
if x+col >= 0 {
|
||||
r.Cell(x+col, y, ch, fg, bg, attr)
|
||||
}
|
||||
col++
|
||||
}
|
||||
}
|
||||
|
||||
// TextStyled renders text using Style struct
|
||||
func (r Region) TextStyled(x, y int, s string, style Style) {
|
||||
if y < 0 || y >= r.H {
|
||||
return
|
||||
}
|
||||
col := 0
|
||||
for _, ch := range s {
|
||||
if x+col >= r.W {
|
||||
break
|
||||
}
|
||||
if x+col >= 0 {
|
||||
r.Cell(x+col, y, ch, style.Fg, style.Bg, style.Attr)
|
||||
}
|
||||
col++
|
||||
}
|
||||
}
|
||||
|
||||
// TextRight renders text right-aligned on row
|
||||
func (r Region) TextRight(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
|
||||
x := r.W - RuneLen(s)
|
||||
r.Text(x, y, s, fg, bg, attr)
|
||||
}
|
||||
|
||||
// TextCenter renders text centered on row
|
||||
func (r Region) TextCenter(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
|
||||
x := (r.W - RuneLen(s)) / 2
|
||||
r.Text(x, y, s, fg, bg, attr)
|
||||
}
|
||||
|
||||
// TextBlock renders wrapped text within region bounds, returns number of lines rendered
|
||||
func (r Region) TextBlock(x, y int, text string, fg, bg terminal.RGB, attr terminal.Attr) int {
|
||||
if x >= r.W || y >= r.H || text == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
availW := r.W - x
|
||||
if availW < 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := WrapText(text, availW)
|
||||
rendered := 0
|
||||
|
||||
for i, line := range lines {
|
||||
lineY := y + i
|
||||
if lineY >= r.H {
|
||||
break
|
||||
}
|
||||
r.Text(x, lineY, line, fg, bg, attr)
|
||||
rendered++
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// TextBlockStyled renders wrapped text using Style struct, returns number of lines rendered
|
||||
func (r Region) TextBlockStyled(x, y int, text string, style Style) int {
|
||||
return r.TextBlock(x, y, text, style.Fg, style.Bg, style.Attr)
|
||||
}
|
||||
|
||||
@@ -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,73 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/lixenwraith/terminal"
|
||||
)
|
||||
|
||||
// ScrollBar draws vertical scrollbar track with thumb
|
||||
func (r Region) ScrollBar(x int, offset, visible, total int, fg terminal.RGB) {
|
||||
if x < 0 || x >= r.W || r.H < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
trackH := r.H
|
||||
if total <= visible || trackH < 3 {
|
||||
// No scrolling needed or track too small
|
||||
for y := range trackH {
|
||||
r.Cell(x, y, '│', fg, terminal.RGB{}, terminal.AttrDim)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate thumb size and position
|
||||
thumbH := min(max((visible*trackH)/total, 1), trackH)
|
||||
|
||||
maxScroll := total - visible
|
||||
thumbY := 0
|
||||
if maxScroll > 0 {
|
||||
thumbY = (offset * (trackH - thumbH)) / maxScroll
|
||||
}
|
||||
if thumbY < 0 {
|
||||
thumbY = 0
|
||||
}
|
||||
if thumbY+thumbH > trackH {
|
||||
thumbY = trackH - thumbH
|
||||
}
|
||||
|
||||
// Draw track and thumb
|
||||
for y := range trackH {
|
||||
var ch rune
|
||||
if y >= thumbY && y < thumbY+thumbH {
|
||||
ch = '█'
|
||||
} else {
|
||||
ch = '░'
|
||||
}
|
||||
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
|
||||
}
|
||||
}
|
||||
|
||||
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
|
||||
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg terminal.RGB) {
|
||||
if y < 0 || y >= r.H {
|
||||
return
|
||||
}
|
||||
|
||||
var text string
|
||||
if total <= visible || offset <= 0 {
|
||||
text = "Top"
|
||||
} else if offset+visible >= total {
|
||||
text = "Bot"
|
||||
} else {
|
||||
pct := ScrollPercent(offset, visible, total)
|
||||
if pct >= 100 {
|
||||
text = "99%"
|
||||
} else if pct >= 10 {
|
||||
text = string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
|
||||
} else {
|
||||
text = " " + string(rune('0'+pct)) + "%"
|
||||
}
|
||||
}
|
||||
|
||||
r.TextRight(y, text, fg, terminal.RGB{}, terminal.AttrDim)
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// --- Length calculation ---
|
||||
|
||||
// RuneLen returns rune count, used as display width under the package-wide assumption of 1 cell per rune
|
||||
// Wide (CJK), emoji, and combining characters are not handled
|
||||
func RuneLen(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
// --- Truncation ---
|
||||
|
||||
// Truncate truncates string with … suffix if exceeds maxLen
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
// Boundary-safe truncation for UTF-8
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == maxLen-1 {
|
||||
return s[:i] + "…"
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TruncateLeft truncates with … prefix, keeps end of string
|
||||
func TruncateLeft(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 1 {
|
||||
return "…"
|
||||
}
|
||||
return "…" + string(runes[len(runes)-maxLen+1:])
|
||||
}
|
||||
|
||||
// TruncateMiddle keeps start and end, … in middle
|
||||
func TruncateMiddle(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return Truncate(s, maxLen)
|
||||
}
|
||||
|
||||
// Split remaining space between start and end
|
||||
// Favor start slightly: (maxLen-1)/2 for start, rest for end
|
||||
startLen := (maxLen - 1) / 2
|
||||
endLen := maxLen - 1 - startLen
|
||||
|
||||
return string(runes[:startLen]) + "…" + string(runes[len(runes)-endLen:])
|
||||
}
|
||||
|
||||
// --- Padding ---
|
||||
|
||||
// PadRight pads string with spaces to width
|
||||
func PadRight(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
return s
|
||||
}
|
||||
result := make([]rune, width)
|
||||
copy(result, runes)
|
||||
for i := len(runes); i < width; i++ {
|
||||
result[i] = ' '
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// PadLeft left-pads string with spaces to width
|
||||
func PadLeft(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
return s
|
||||
}
|
||||
result := make([]rune, width)
|
||||
padding := width - len(runes)
|
||||
for i := 0; i < padding; i++ {
|
||||
result[i] = ' '
|
||||
}
|
||||
copy(result[padding:], runes)
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// PadCenter centers string within width
|
||||
func PadCenter(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
return s
|
||||
}
|
||||
result := make([]rune, width)
|
||||
leftPad := (width - len(runes)) / 2
|
||||
for i := range result {
|
||||
result[i] = ' '
|
||||
}
|
||||
copy(result[leftPad:], runes)
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// --- Text wrapping ---
|
||||
|
||||
// WrapText wraps text at word boundaries to fit width
|
||||
// Returns slice of lines, each no longer than width
|
||||
func WrapText(s string, width int) []string {
|
||||
if width <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if len(runes) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lineStart := 0
|
||||
lastSpace := -1
|
||||
|
||||
for i := 0; i <= len(runes); i++ {
|
||||
// Check if we need to wrap
|
||||
if i-lineStart >= width || i == len(runes) {
|
||||
if i == len(runes) {
|
||||
// End of string
|
||||
if lineStart < len(runes) {
|
||||
lines = append(lines, string(runes[lineStart:]))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Need to wrap
|
||||
wrapAt := i
|
||||
if lastSpace > lineStart {
|
||||
// Wrap at last space
|
||||
wrapAt = lastSpace
|
||||
}
|
||||
|
||||
lines = append(lines, string(runes[lineStart:wrapAt]))
|
||||
|
||||
// Skip space at wrap point
|
||||
if wrapAt < len(runes) && runes[wrapAt] == ' ' {
|
||||
lineStart = wrapAt + 1
|
||||
} else {
|
||||
lineStart = wrapAt
|
||||
}
|
||||
lastSpace = -1
|
||||
}
|
||||
|
||||
// Track spaces for word wrapping
|
||||
if i < len(runes) && runes[i] == ' ' {
|
||||
lastSpace = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
lines = []string{""}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// --- Repetition ---
|
||||
|
||||
// RepeatRune returns a string of n repeated runes, for string repeat use strings.Repeat
|
||||
func RepeatRune(r rune, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := make([]rune, n)
|
||||
for i := range runes {
|
||||
runes[i] = r
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
@@ -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,52 @@
|
||||
package tui
|
||||
|
||||
// TreeBuilder helps construct flattened visible node list from hierarchical data
|
||||
type TreeBuilder struct {
|
||||
nodes []TreeNode
|
||||
expansion *TreeExpansion
|
||||
}
|
||||
|
||||
// NewTreeBuilder creates a builder with expansion state
|
||||
func NewTreeBuilder(expansion *TreeExpansion) *TreeBuilder {
|
||||
return &TreeBuilder{
|
||||
expansion: expansion,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears accumulated nodes
|
||||
func (b *TreeBuilder) Reset() {
|
||||
b.nodes = b.nodes[:0]
|
||||
}
|
||||
|
||||
// Add adds a node if visible (parent expanded)
|
||||
// parentExpanded should be true for root-level nodes
|
||||
func (b *TreeBuilder) Add(node TreeNode, parentExpanded bool) {
|
||||
if !parentExpanded {
|
||||
return
|
||||
}
|
||||
node.Expanded = b.expansion.IsExpanded(node.Key)
|
||||
b.nodes = append(b.nodes, node)
|
||||
}
|
||||
|
||||
// Nodes returns accumulated visible nodes
|
||||
func (b *TreeBuilder) Nodes() []TreeNode {
|
||||
return b.nodes
|
||||
}
|
||||
|
||||
// MarkLastSiblings sets IsLast flag on nodes that are last at their depth
|
||||
// Call after all nodes added, before rendering
|
||||
func (b *TreeBuilder) MarkLastSiblings() {
|
||||
// Backward scan: seen[d] tracks whether a sibling at depth d was encountered
|
||||
// within the current subtree. Crossing depth d invalidates deeper entries.
|
||||
seen := make([]bool, 0, 8)
|
||||
for i := len(b.nodes) - 1; i >= 0; i-- {
|
||||
d := b.nodes[i].Depth
|
||||
for len(seen) <= d {
|
||||
seen = append(seen, false)
|
||||
}
|
||||
seen = seen[:d+1] // deeper entries belong to a later subtree
|
||||
b.nodes[i].IsLast = !seen[d]
|
||||
seen[d] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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