Files
terminal/terminal.go
T

605 lines
14 KiB
Go

package terminal
import (
"io"
"os"
"sync"
"sync/atomic"
"time"
"github.com/lixenwraith/color"
"golang.org/x/term"
)
// Attr represents text attributes and how to read a cell's colors (bitmask)
type Attr uint16
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
AttrFgDefault Attr = 1 << 8 // Fg is the terminal's own (SGR 39); Fg is ignored
AttrBgDefault Attr = 1 << 9 // Bg is the terminal's own (SGR 49); Bg is ignored
)
// AttrStyle masks only the style bits (excludes color mode flags)
const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBlink | AttrReverse
// AttrFgColor and AttrBgColor mask the bits that say how to read Fg and Bg
const (
AttrFgColor Attr = AttrFg256 | AttrFgDefault
AttrBgColor Attr = AttrBg256 | AttrBgDefault
)
// Cell represents a single terminal cell
type Cell struct {
Rune rune
Fg color.RGB
Bg color.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 hands a frame to the terminal; cells are row-major: cells[y*width + x].
// It never waits on the terminal: while the last write is still out, the frame
// is held and drawn once that write returns, a newer one replacing it
Flush(cells []Cell, width, height int)
// Blocked reports how long the terminal has held the write in flight, zero
// while output drains. A terminal that stops reading (a hidden tab, a client
// that went away, flow control) shows here instead of as a caller that waits
Blocked() time.Duration
// Clear fills screen with specified background color
Clear(bg color.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
// SetPasteMode turns bracketed paste on or off: on, a paste arrives as
// one EventPaste rather than as keys, so its newlines don't press Enter
SetPasteMode(on bool) 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
paste bool
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
// stops reading holds that goroutine rather than whoever draws. queued is what
// was rendered and not yet taken; a frame Flush is handed while a write is out
// waits in held, newest first, until it returns. Both are guarded by mu.
queued []byte
writing []byte // owned by writeLoop
held []Cell
heldW int
heldH int
holding bool
writeSince atomic.Int64 // unix nanoseconds the write out began; zero when none is
wake chan struct{}
quit chan struct{}
writerDone chan struct{}
}
// finiDrainTimeout bounds how long Fini waits for the terminal to take what is
// queued, the restoring sequences last: a terminal that stopped reading would
// otherwise hold the process at exit
const finiDrainTimeout = 2 * time.Second
// New creates a new Terminal instance. Without a mode it uses
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
// overrides both, as a --color=always or never would
func New(colorMode ...ColorMode) Terminal {
b := newBackend()
c := DetectColorMode()
if len(colorMode) > 0 {
c = colorMode[0]
} else if os.Getenv("NO_COLOR") != "" {
c = ColorModeNone
}
return newTerm(b, c)
}
func newTerm(b Backend, c ColorMode) *termImpl {
return &termImpl{
backend: b,
output: newOutputBuffer(c),
syntheticCh: make(chan Event, 16),
resizeCh: make(chan ResizeEvent, 1),
wake: make(chan struct{}, 1),
quit: make(chan struct{}),
writerDone: make(chan struct{}),
}
}
// 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:
}
}
})
go t.writeLoop()
// Enter alternate screen, hide cursor
out := &t.output.buf
out.Write(csiAltScreenEnter)
out.Write(csiCursorHide)
// DISABLE AUTO-WRAP
// Prevents terminal scroll/wrap on bottom-right corner write
out.Write(csiAutoWrapOff)
// Invisible cursor
t.cursorVisible.Store(false)
// Build the LUT at startup, not on the first frame under t.mu
if t.output.colorMode == ColorMode256 {
WarmPalette256()
}
// Clear screen
t.output.clear(color.RGB{}, AttrBgDefault)
t.sendLocked()
// Start input reader
t.input.start()
t.initialized = true
return nil
}
// Fini restores terminal state
func (t *termImpl) Fini() {
t.mu.Lock()
if !t.initialized || t.finalized {
t.mu.Unlock()
return
}
t.finalized = true
t.holding = false // a frame not yet drawn is not drawn over the restored screen
w := &t.output.buf
// Disable mouse before other cleanup
if t.mouseMode != MouseModeNone {
w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
t.sendLocked()
}
if t.paste {
w.Write(csiPasteOff)
}
// Stop handlers
if t.input != nil {
t.input.stop()
}
// Show cursor
w.Write(csiCursorShow)
// Exit alternate screen
w.Write(csiAltScreenExit)
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
w.Write(csiAutoWrapOn)
// Reset attributes
w.Write(csiSGR0)
t.sendLocked()
t.mu.Unlock()
// The restoring sequences leave before raw mode does, unless the terminal has
// stopped reading: then the termios is restored without them
close(t.quit)
select {
case <-t.writerDone:
case <-time.After(finiDrainTimeout):
}
// Backend cleanup
t.backend.Fini()
}
// 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 renders cells and queues them for the terminal, or holds them while a write
// is out. 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
}
if t.writeSince.Load() != 0 {
t.held = append(t.held[:0], cells[:min(len(cells), width*height)]...)
t.heldW, t.heldH, t.holding = width, height, true
return
}
// A frame is a whole screen, so one rendered now supersedes any held
t.holding = false
t.renderLocked(cells, width, height)
}
// renderLocked diffs one frame into the queue. Caller MUST hold mu.
func (t *termImpl) renderLocked(cells []Cell, width, height int) {
// 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)
t.sendLocked()
}
// releaseLocked renders a held frame ahead of a control sequence, so the terminal
// receives the two in the order they were asked for. Caller MUST hold mu.
func (t *termImpl) releaseLocked() {
if t.holding {
t.holding = false
t.renderLocked(t.held, t.heldW, t.heldH)
}
}
// Blocked reports how long the write in flight has waited on the terminal
func (t *termImpl) Blocked() time.Duration {
if since := t.writeSince.Load(); since != 0 {
return time.Since(time.Unix(0, since))
}
return 0
}
// Clear fills screen with background color
func (t *termImpl) Clear(bg color.RGB) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized {
return
}
t.releaseLocked()
t.output.clear(bg, 0)
t.sendLocked()
}
// 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
}
t.releaseLocked()
w := &t.output.buf
if visible {
w.Write(csiCursorShow)
} else {
w.Write(csiCursorHide)
}
t.sendLocked()
}
// 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
}
t.releaseLocked()
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
}
// Through the queue, to keep the stream's order
writeCursorPos(&t.output.buf, x, y)
t.sendLocked()
}
// 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.releaseLocked()
t.output.clear(color.RGB{}, AttrBgDefault)
t.output.forceFullRedraw()
t.sendLocked()
}
// 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
t.releaseLocked()
w := &t.output.buf
// Disable modes no longer needed (reverse order of enable): VTE 0.54+,
// WezTerm and Windows Terminal keep each one set and report by the highest
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)
}
// xterm, xterm.js, kitty, tmux and Konsole keep one mode, which setting
// another replaces and resetting any turns off, and alacritty's and urxvt's
// set clears the others: on any change the highest mode wanted is written
// last, even when already on
var top MouseMode
switch {
case mode == oldMode:
case mode&MouseModeMotion != 0:
top = MouseModeMotion
case mode&MouseModeDrag != 0:
top = MouseModeDrag
case mode&MouseModeClick != 0:
top = MouseModeClick
}
// Enable new modes, and the highest again (click is base, drag extends,
// motion extends further)
if mode&MouseModeClick != 0 && (oldMode&MouseModeClick == 0 || top == MouseModeClick) {
w.Write(csiMouseClickOn)
}
if mode&MouseModeDrag != 0 && (oldMode&MouseModeDrag == 0 || top == MouseModeDrag) {
w.Write(csiMouseDragOn)
}
if mode&MouseModeMotion != 0 && (oldMode&MouseModeMotion == 0 || top == MouseModeMotion) {
w.Write(csiMouseMotionOn)
}
t.sendLocked()
return nil
}
// SetPasteMode turns bracketed paste on or off
func (t *termImpl) SetPasteMode(on bool) error {
t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized || t.paste == on {
return nil
}
t.paste = on
t.input.pasteMode.Store(on)
t.releaseLocked()
w := &t.output.buf
if on {
w.Write(csiPasteOn)
} else {
w.Write(csiPasteOff)
}
t.sendLocked()
return nil
}
// EmergencyReset attempts to restore terminal to sane state
// Call this from panic recovery if Fini() cannot be called normally. Given a
// redirected stdout, it writes to the controlling terminal instead.
func EmergencyReset(w io.Writer) {
if f, ok := w.(*os.File); ok && !term.IsTerminal(int(f.Fd())) {
if tty := controllingTTY(); tty != nil {
defer tty.Close()
w = tty
}
}
// Disable mouse tracking
w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff)
w.Write(csiPasteOff)
// 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()
}