mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-26 09:35:52 +00:00
699 lines
15 KiB
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: ' '}
|
|
}
|
|
}
|