vibetunnel/linux/pkg/terminal/buffer.go

699 lines
15 KiB
Go

package terminal
import (
"encoding/binary"
"sync"
"unicode/utf8"
)
// BufferCell represents a single cell in the terminal buffer
type BufferCell struct {
Char rune
Fg uint32 // Foreground color (RGB + flags)
Bg uint32 // Background color (RGB + flags)
Flags uint8 // Bold, Italic, Underline, etc.
}
// BufferSnapshot represents the current state of the terminal buffer
type BufferSnapshot struct {
Cols int
Rows int
ViewportY int
CursorX int
CursorY int
Cells [][]BufferCell
}
// TerminalBuffer manages a virtual terminal buffer similar to xterm.js
type TerminalBuffer struct {
mu sync.RWMutex
cols int
rows int
buffer [][]BufferCell
cursorX int
cursorY int
viewportY int // For scrollback
parser *AnsiParser
// Style state
currentFg uint32
currentBg uint32
currentFlags uint8
}
// NewTerminalBuffer creates a new terminal buffer
func NewTerminalBuffer(cols, rows int) *TerminalBuffer {
tb := &TerminalBuffer{
cols: cols,
rows: rows,
buffer: make([][]BufferCell, rows),
parser: NewAnsiParser(),
}
// Initialize buffer with empty cells
for i := 0; i < rows; i++ {
tb.buffer[i] = make([]BufferCell, cols)
for j := 0; j < cols; j++ {
tb.buffer[i][j] = BufferCell{Char: ' '}
}
}
// Set up parser callbacks
tb.parser.OnPrint = tb.handlePrint
tb.parser.OnExecute = tb.handleExecute
tb.parser.OnCsi = tb.handleCsi
tb.parser.OnOsc = tb.handleOsc
tb.parser.OnEscape = tb.handleEscape
return tb
}
// Write processes terminal output and updates the buffer
func (tb *TerminalBuffer) Write(data []byte) (int, error) {
tb.mu.Lock()
defer tb.mu.Unlock()
// Parse the data through ANSI parser
tb.parser.Parse(data)
return len(data), nil
}
// GetSnapshot returns the current buffer state
func (tb *TerminalBuffer) GetSnapshot() *BufferSnapshot {
tb.mu.RLock()
defer tb.mu.RUnlock()
// Deep copy the buffer
cells := make([][]BufferCell, tb.rows)
for i := 0; i < tb.rows; i++ {
cells[i] = make([]BufferCell, tb.cols)
copy(cells[i], tb.buffer[i])
}
return &BufferSnapshot{
Cols: tb.cols,
Rows: tb.rows,
ViewportY: tb.viewportY,
CursorX: tb.cursorX,
CursorY: tb.cursorY,
Cells: cells,
}
}
// Resize adjusts the buffer size
func (tb *TerminalBuffer) Resize(cols, rows int) {
tb.mu.Lock()
defer tb.mu.Unlock()
if cols == tb.cols && rows == tb.rows {
return
}
// Create new buffer
newBuffer := make([][]BufferCell, rows)
for i := 0; i < rows; i++ {
newBuffer[i] = make([]BufferCell, cols)
for j := 0; j < cols; j++ {
newBuffer[i][j] = BufferCell{Char: ' '}
}
}
// Copy existing content
minRows := rows
if tb.rows < minRows {
minRows = tb.rows
}
minCols := cols
if tb.cols < minCols {
minCols = tb.cols
}
for i := 0; i < minRows; i++ {
for j := 0; j < minCols; j++ {
newBuffer[i][j] = tb.buffer[i][j]
}
}
tb.buffer = newBuffer
tb.cols = cols
tb.rows = rows
// Adjust cursor position
if tb.cursorX >= cols {
tb.cursorX = cols - 1
}
if tb.cursorY >= rows {
tb.cursorY = rows - 1
}
}
// SerializeToBinary converts the buffer snapshot to the binary format expected by the web client
func (snapshot *BufferSnapshot) SerializeToBinary() []byte {
// Pre-calculate actual data size for efficiency
dataSize := 28 // Header size (2 magic + 1 version + 1 flags + 4*6 for dimensions/cursor/reserved)
// First pass: calculate exact size needed
for row := 0; row < snapshot.Rows; row++ {
rowCells := snapshot.Cells[row]
if isEmptyRow(rowCells) {
// Empty row marker: 2 bytes
dataSize += 2
} else {
// Row header: 3 bytes (marker + length)
dataSize += 3
// Trim trailing blank cells
trimmedCells := trimRowCells(rowCells)
for _, cell := range trimmedCells {
dataSize += calculateCellSize(cell)
}
}
}
buffer := make([]byte, dataSize)
offset := 0
// Write header (32 bytes)
binary.LittleEndian.PutUint16(buffer[offset:], 0x5654) // Magic "VT"
offset += 2
buffer[offset] = 0x01 // Version 1
offset++
buffer[offset] = 0x00 // Flags
offset++
binary.LittleEndian.PutUint32(buffer[offset:], uint32(snapshot.Cols))
offset += 4
binary.LittleEndian.PutUint32(buffer[offset:], uint32(snapshot.Rows))
offset += 4
binary.LittleEndian.PutUint32(buffer[offset:], uint32(snapshot.ViewportY))
offset += 4
binary.LittleEndian.PutUint32(buffer[offset:], uint32(snapshot.CursorX))
offset += 4
binary.LittleEndian.PutUint32(buffer[offset:], uint32(snapshot.CursorY))
offset += 4
binary.LittleEndian.PutUint32(buffer[offset:], 0) // Reserved
offset += 4
// Write cells with optimized format
for row := 0; row < snapshot.Rows; row++ {
rowCells := snapshot.Cells[row]
if isEmptyRow(rowCells) {
// Empty row marker
buffer[offset] = 0xfe // Empty row marker
offset++
buffer[offset] = 1 // Count of empty rows (for now just 1)
offset++
} else {
// Row with content
buffer[offset] = 0xfd // Row marker
offset++
trimmedCells := trimRowCells(rowCells)
binary.LittleEndian.PutUint16(buffer[offset:], uint16(len(trimmedCells)))
offset += 2
// Write each cell
for _, cell := range trimmedCells {
offset = encodeCell(buffer, offset, cell)
}
}
}
// Return exact size buffer
return buffer[:offset]
}
// Helper functions for binary serialization
// isEmptyRow checks if a row contains only empty cells
func isEmptyRow(cells []BufferCell) bool {
if len(cells) == 0 {
return true
}
if len(cells) == 1 && cells[0].Char == ' ' && cells[0].Fg == 0 && cells[0].Bg == 0 && cells[0].Flags == 0 {
return true
}
for _, cell := range cells {
if cell.Char != ' ' || cell.Fg != 0 || cell.Bg != 0 || cell.Flags != 0 {
return false
}
}
return true
}
// trimRowCells removes trailing blank cells from a row
func trimRowCells(cells []BufferCell) []BufferCell {
lastNonBlank := len(cells) - 1
for lastNonBlank >= 0 {
cell := cells[lastNonBlank]
if cell.Char != ' ' || cell.Fg != 0 || cell.Bg != 0 || cell.Flags != 0 {
break
}
lastNonBlank--
}
// Keep at least one cell
if lastNonBlank < 0 {
return cells[:1]
}
return cells[:lastNonBlank+1]
}
// calculateCellSize calculates the size needed to encode a cell
func calculateCellSize(cell BufferCell) int {
isSpace := cell.Char == ' '
hasAttrs := cell.Flags != 0
hasFg := cell.Fg != 0
hasBg := cell.Bg != 0
isAscii := cell.Char <= 127
if isSpace && !hasAttrs && !hasFg && !hasBg {
return 1 // Just a space marker
}
size := 1 // Type byte
if isAscii {
size++ // ASCII character
} else {
charBytes := utf8.RuneLen(cell.Char)
size += 1 + charBytes // Length byte + UTF-8 bytes
}
// Attributes/colors byte
if hasAttrs || hasFg || hasBg {
size++ // Flags byte for attributes
if hasFg {
if cell.Fg > 255 {
size += 3 // RGB
} else {
size++ // Palette
}
}
if hasBg {
if cell.Bg > 255 {
size += 3 // RGB
} else {
size++ // Palette
}
}
}
return size
}
// encodeCell encodes a single cell into the buffer
func encodeCell(buffer []byte, offset int, cell BufferCell) int {
isSpace := cell.Char == ' '
hasAttrs := cell.Flags != 0
hasFg := cell.Fg != 0
hasBg := cell.Bg != 0
isAscii := cell.Char <= 127
// Type byte format:
// Bit 7: Has extended data (attrs/colors)
// Bit 6: Is Unicode (vs ASCII)
// Bit 5: Has foreground color
// Bit 4: Has background color
// Bit 3: Is RGB foreground (vs palette)
// Bit 2: Is RGB background (vs palette)
// Bits 1-0: Character type (00=space, 01=ASCII, 10=Unicode)
if isSpace && !hasAttrs && !hasFg && !hasBg {
// Simple space - 1 byte
buffer[offset] = 0x00 // Type: space, no extended data
return offset + 1
}
var typeByte byte = 0
if hasAttrs || hasFg || hasBg {
typeByte |= 0x80 // Has extended data
}
if !isAscii {
typeByte |= 0x40 // Is Unicode
typeByte |= 0x02 // Character type: Unicode
} else if !isSpace {
typeByte |= 0x01 // Character type: ASCII
}
if hasFg {
typeByte |= 0x20 // Has foreground
if cell.Fg > 255 {
typeByte |= 0x08 // Is RGB
}
}
if hasBg {
typeByte |= 0x10 // Has background
if cell.Bg > 255 {
typeByte |= 0x04 // Is RGB
}
}
buffer[offset] = typeByte
offset++
// Write character
if !isAscii {
charBytes := make([]byte, 4)
n := utf8.EncodeRune(charBytes, cell.Char)
buffer[offset] = byte(n)
offset++
copy(buffer[offset:], charBytes[:n])
offset += n
} else if !isSpace {
buffer[offset] = byte(cell.Char)
offset++
}
// Write extended data if present
if typeByte&0x80 != 0 {
// Attributes byte (convert our flags to Node.js format)
var attrs byte = 0
if cell.Flags&0x01 != 0 { // Bold
attrs |= 0x01
}
if cell.Flags&0x02 != 0 { // Italic
attrs |= 0x02
}
if cell.Flags&0x04 != 0 { // Underline
attrs |= 0x04
}
if cell.Flags&0x08 != 0 { // Inverse/Dim - map inverse to dim in Node.js
attrs |= 0x08
}
// Note: Node.js has additional attributes we don't support yet
if hasAttrs || hasFg || hasBg {
buffer[offset] = attrs
offset++
}
// Foreground color
if hasFg {
if cell.Fg > 255 {
// RGB
buffer[offset] = byte((cell.Fg >> 16) & 0xff)
offset++
buffer[offset] = byte((cell.Fg >> 8) & 0xff)
offset++
buffer[offset] = byte(cell.Fg & 0xff)
offset++
} else {
// Palette
buffer[offset] = byte(cell.Fg)
offset++
}
}
// Background color
if hasBg {
if cell.Bg > 255 {
// RGB
buffer[offset] = byte((cell.Bg >> 16) & 0xff)
offset++
buffer[offset] = byte((cell.Bg >> 8) & 0xff)
offset++
buffer[offset] = byte(cell.Bg & 0xff)
offset++
} else {
// Palette
buffer[offset] = byte(cell.Bg)
offset++
}
}
}
return offset
}
// handlePrint handles printable characters
func (tb *TerminalBuffer) handlePrint(r rune) {
// Place character at cursor position
if tb.cursorY < tb.rows && tb.cursorX < tb.cols {
tb.buffer[tb.cursorY][tb.cursorX] = BufferCell{
Char: r,
Fg: tb.currentFg,
Bg: tb.currentBg,
Flags: tb.currentFlags,
}
}
// Advance cursor
tb.cursorX++
if tb.cursorX >= tb.cols {
tb.cursorX = 0
tb.cursorY++
if tb.cursorY >= tb.rows {
// Scroll
tb.scrollUp()
tb.cursorY = tb.rows - 1
}
}
}
// handleExecute handles control characters
func (tb *TerminalBuffer) handleExecute(b byte) {
switch b {
case '\r': // Carriage return
tb.cursorX = 0
case '\n': // Line feed
tb.cursorY++
if tb.cursorY >= tb.rows {
tb.scrollUp()
tb.cursorY = tb.rows - 1
}
case '\b': // Backspace
if tb.cursorX > 0 {
tb.cursorX--
}
case '\t': // Tab
// Move to next tab stop (every 8 columns)
tb.cursorX = ((tb.cursorX / 8) + 1) * 8
if tb.cursorX >= tb.cols {
tb.cursorX = tb.cols - 1
}
}
}
// handleCsi handles CSI sequences
func (tb *TerminalBuffer) handleCsi(params []int, intermediate []byte, final byte) {
switch final {
case 'A': // Cursor up
n := 1
if len(params) > 0 && params[0] > 0 {
n = params[0]
}
tb.cursorY -= n
if tb.cursorY < 0 {
tb.cursorY = 0
}
case 'B': // Cursor down
n := 1
if len(params) > 0 && params[0] > 0 {
n = params[0]
}
tb.cursorY += n
if tb.cursorY >= tb.rows {
tb.cursorY = tb.rows - 1
}
case 'C': // Cursor forward
n := 1
if len(params) > 0 && params[0] > 0 {
n = params[0]
}
tb.cursorX += n
if tb.cursorX >= tb.cols {
tb.cursorX = tb.cols - 1
}
case 'D': // Cursor back
n := 1
if len(params) > 0 && params[0] > 0 {
n = params[0]
}
tb.cursorX -= n
if tb.cursorX < 0 {
tb.cursorX = 0
}
case 'H', 'f': // Cursor position
row := 1
col := 1
if len(params) > 0 {
row = params[0]
}
if len(params) > 1 {
col = params[1]
}
// Convert from 1-based to 0-based
tb.cursorY = row - 1
tb.cursorX = col - 1
// Clamp to bounds
if tb.cursorY < 0 {
tb.cursorY = 0
}
if tb.cursorY >= tb.rows {
tb.cursorY = tb.rows - 1
}
if tb.cursorX < 0 {
tb.cursorX = 0
}
if tb.cursorX >= tb.cols {
tb.cursorX = tb.cols - 1
}
case 'J': // Erase display
mode := 0
if len(params) > 0 {
mode = params[0]
}
switch mode {
case 0: // Clear from cursor to end
tb.clearFromCursor()
case 1: // Clear from start to cursor
tb.clearToCursor()
case 2, 3: // Clear entire screen
tb.clearScreen()
}
case 'K': // Erase line
mode := 0
if len(params) > 0 {
mode = params[0]
}
switch mode {
case 0: // Clear from cursor to end of line
tb.clearLineFromCursor()
case 1: // Clear from start of line to cursor
tb.clearLineToCursor()
case 2: // Clear entire line
tb.clearLine()
}
case 'm': // SGR - Set Graphics Rendition
tb.handleSGR(params)
}
}
// handleSGR processes SGR (Select Graphic Rendition) parameters
func (tb *TerminalBuffer) handleSGR(params []int) {
if len(params) == 0 {
params = []int{0} // Default to reset
}
for i := 0; i < len(params); i++ {
switch params[i] {
case 0: // Reset
tb.currentFg = 0
tb.currentBg = 0
tb.currentFlags = 0
case 1: // Bold
tb.currentFlags |= 0x01
case 3: // Italic
tb.currentFlags |= 0x02
case 4: // Underline
tb.currentFlags |= 0x04
case 7: // Inverse
tb.currentFlags |= 0x08
case 30, 31, 32, 33, 34, 35, 36, 37: // Foreground colors
tb.currentFg = uint32(params[i] - 30)
case 40, 41, 42, 43, 44, 45, 46, 47: // Background colors
tb.currentBg = uint32(params[i] - 40)
case 38: // Extended foreground color
if i+2 < len(params) && params[i+1] == 5 {
// 256 color mode
tb.currentFg = uint32(params[i+2])
i += 2
}
case 48: // Extended background color
if i+2 < len(params) && params[i+1] == 5 {
// 256 color mode
tb.currentBg = uint32(params[i+2])
i += 2
}
}
}
}
// handleOsc handles OSC sequences
func (tb *TerminalBuffer) handleOsc(params [][]byte) {
// Handle window title changes, etc.
// For now, we ignore these
}
// handleEscape handles ESC sequences
func (tb *TerminalBuffer) handleEscape(intermediate []byte, final byte) {
// Handle various escape sequences
// For now, we handle the basics
}
// Helper methods for clearing
func (tb *TerminalBuffer) clearScreen() {
for y := 0; y < tb.rows; y++ {
for x := 0; x < tb.cols; x++ {
tb.buffer[y][x] = BufferCell{Char: ' '}
}
}
}
func (tb *TerminalBuffer) clearFromCursor() {
// Clear from cursor to end of line
for x := tb.cursorX; x < tb.cols; x++ {
tb.buffer[tb.cursorY][x] = BufferCell{Char: ' '}
}
// Clear all lines below
for y := tb.cursorY + 1; y < tb.rows; y++ {
for x := 0; x < tb.cols; x++ {
tb.buffer[y][x] = BufferCell{Char: ' '}
}
}
}
func (tb *TerminalBuffer) clearToCursor() {
// Clear from start to cursor
for x := 0; x <= tb.cursorX && x < tb.cols; x++ {
tb.buffer[tb.cursorY][x] = BufferCell{Char: ' '}
}
// Clear all lines above
for y := 0; y < tb.cursorY; y++ {
for x := 0; x < tb.cols; x++ {
tb.buffer[y][x] = BufferCell{Char: ' '}
}
}
}
func (tb *TerminalBuffer) clearLine() {
for x := 0; x < tb.cols; x++ {
tb.buffer[tb.cursorY][x] = BufferCell{Char: ' '}
}
}
func (tb *TerminalBuffer) clearLineFromCursor() {
for x := tb.cursorX; x < tb.cols; x++ {
tb.buffer[tb.cursorY][x] = BufferCell{Char: ' '}
}
}
func (tb *TerminalBuffer) clearLineToCursor() {
for x := 0; x <= tb.cursorX && x < tb.cols; x++ {
tb.buffer[tb.cursorY][x] = BufferCell{Char: ' '}
}
}
func (tb *TerminalBuffer) scrollUp() {
// Shift all lines up
for y := 0; y < tb.rows-1; y++ {
tb.buffer[y] = tb.buffer[y+1]
}
// Clear last line
tb.buffer[tb.rows-1] = make([]BufferCell, tb.cols)
for x := 0; x < tb.cols; x++ {
tb.buffer[tb.rows-1][x] = BufferCell{Char: ' '}
}
}