v0.1.0 initial commit
This commit is contained in:
+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()
|
||||
}
|
||||
Reference in New Issue
Block a user