Files
terminal/terminal.go
T
2026-07-12 18:41:05 -04:00

458 lines
9.4 KiB
Go

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()
}