v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
)
|
||||
|
||||
// outputBuffer manages double-buffered terminal output with diffing
|
||||
type outputBuffer struct {
|
||||
front []Cell
|
||||
width int
|
||||
height int
|
||||
colorMode ColorMode
|
||||
writer *bufio.Writer
|
||||
|
||||
cursorX int
|
||||
cursorY int
|
||||
cursorValid bool
|
||||
|
||||
// Style state for coalescing
|
||||
lastFg RGB
|
||||
lastBg RGB
|
||||
lastAttr Attr
|
||||
lastValid bool
|
||||
}
|
||||
|
||||
// writerAdapter adapts Backend to io.Writer for bufio
|
||||
type writerAdapter struct {
|
||||
b Backend
|
||||
}
|
||||
|
||||
func (wa writerAdapter) Write(p []byte) (int, error) {
|
||||
err := wa.b.Write(p)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// newOutputBuffer creates a new output buffer
|
||||
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
||||
// Use 128KB buffer for minimal calls to backend
|
||||
adapter := writerAdapter{b: backend}
|
||||
return &outputBuffer{
|
||||
writer: bufio.NewWriterSize(adapter, 131072),
|
||||
colorMode: colorMode,
|
||||
}
|
||||
}
|
||||
|
||||
// resize updates buffer dimensions
|
||||
func (o *outputBuffer) resize(width, height int) {
|
||||
size := width * height
|
||||
if cap(o.front) < size {
|
||||
o.front = make([]Cell, size)
|
||||
} else {
|
||||
o.front = o.front[:size]
|
||||
}
|
||||
o.width = width
|
||||
o.height = height
|
||||
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: 0}
|
||||
}
|
||||
o.lastValid = false
|
||||
o.cursorValid = false
|
||||
}
|
||||
|
||||
// cellEqual compares two cells for equality (standalone for inlining)
|
||||
func cellEqual(a, b Cell) bool {
|
||||
// A cell is only equal if every visual component matches, checking most likely changed fields first (Rune/Bg)
|
||||
return a.Rune == b.Rune &&
|
||||
a.Bg == b.Bg &&
|
||||
a.Fg == b.Fg &&
|
||||
a.Attrs == b.Attrs
|
||||
}
|
||||
|
||||
// flush writes the back buffer to terminal, diffing against front buffer
|
||||
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
if width != o.width || height != o.height {
|
||||
o.resize(width, height)
|
||||
}
|
||||
|
||||
expectedSize := width * height
|
||||
if len(cells) < expectedSize {
|
||||
return
|
||||
}
|
||||
|
||||
w := o.writer
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
rowStart := y * width
|
||||
|
||||
// Early termination: find last dirty cell in row (scan backward)
|
||||
rowEnd := width
|
||||
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
||||
rowEnd--
|
||||
}
|
||||
if rowEnd == 0 {
|
||||
continue // Entire row unchanged
|
||||
}
|
||||
|
||||
x := 0
|
||||
for x < rowEnd {
|
||||
idx := rowStart + x
|
||||
|
||||
if cellEqual(cells[idx], o.front[idx]) {
|
||||
x++
|
||||
continue
|
||||
}
|
||||
|
||||
// Found dirty cell - check for small gaps ahead to potentially merge segments
|
||||
segStart := x
|
||||
segEnd := x + 1
|
||||
|
||||
// Extend segment through small gaps (≤3 unchanged cells)
|
||||
for segEnd < rowEnd {
|
||||
// Find gap size
|
||||
gapStart := segEnd
|
||||
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
|
||||
gapStart++
|
||||
}
|
||||
gapSize := gapStart - segEnd
|
||||
|
||||
if gapSize == 0 {
|
||||
// No gap, extend to next unchanged
|
||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||
segEnd++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if gapSize > 3 {
|
||||
break // Gap too large, end segment here
|
||||
}
|
||||
|
||||
// Gap logic check: only bridge the gap if the gap cells have the same attributes as the current segment, otherwise, emit SGR codes inside the gap, making it more expensive than a cursor move
|
||||
gapCompatible := true
|
||||
refCell := cells[rowStart+segEnd-1] // The last dirty cell of the current segment
|
||||
|
||||
for k := 0; k < gapSize; k++ {
|
||||
gCell := cells[rowStart+segEnd+k]
|
||||
// Strict equality on style/color to ensure no SGR emission
|
||||
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
|
||||
gapCompatible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !gapCompatible {
|
||||
break // Gap has different style, cheaper to jump
|
||||
}
|
||||
|
||||
// Check if there's more dirty content after gap
|
||||
if gapStart >= rowEnd {
|
||||
break // Gap extends to row end
|
||||
}
|
||||
|
||||
// Small gap with content after - include gap in segment
|
||||
segEnd = gapStart
|
||||
// Continue to find more dirty cells
|
||||
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
|
||||
segEnd++
|
||||
}
|
||||
}
|
||||
|
||||
// Positions cursor to segment start
|
||||
o.moveCursorTo(w, segStart, y)
|
||||
|
||||
// Write segment [segStart, segEnd)
|
||||
for sx := segStart; sx < segEnd; sx++ {
|
||||
cidx := rowStart + sx
|
||||
c := cells[cidx]
|
||||
|
||||
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
|
||||
|
||||
r := c.Rune
|
||||
if r == 0 {
|
||||
r = ' '
|
||||
}
|
||||
if r < 0x80 {
|
||||
w.WriteByte(byte(r))
|
||||
} else {
|
||||
w.WriteRune(r)
|
||||
}
|
||||
|
||||
o.front[cidx] = c
|
||||
o.cursorX++
|
||||
}
|
||||
|
||||
x = segEnd
|
||||
}
|
||||
}
|
||||
|
||||
w.Write(csiSGR0)
|
||||
o.lastValid = false
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// cursorForwardCost returns byte cost of cursor forward sequence
|
||||
func cursorForwardCost(n int) int {
|
||||
if n == 1 {
|
||||
return 3 // \x1b[C
|
||||
}
|
||||
return 3 + digitCount(n) // \x1b[nC
|
||||
}
|
||||
|
||||
// cursorAbsoluteCost returns byte cost of absolute cursor position
|
||||
func cursorAbsoluteCost(x, y int) int {
|
||||
// \x1b[row;colH = 2 + digits(row) + 1 + digits(col) + 1
|
||||
return 4 + digitCount(y+1) + digitCount(x+1)
|
||||
}
|
||||
|
||||
// digitCount returns number of decimal digits in n
|
||||
func digitCount(n int) int {
|
||||
if n < 10 {
|
||||
return 1
|
||||
}
|
||||
if n < 100 {
|
||||
return 2
|
||||
}
|
||||
if n < 1000 {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// moveCursorTo positions cursor using most efficient method
|
||||
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
||||
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
||||
return
|
||||
}
|
||||
|
||||
moved := false
|
||||
if o.cursorValid && o.cursorY == y && x > o.cursorX {
|
||||
gap := x - o.cursorX
|
||||
fwdCost := cursorForwardCost(gap)
|
||||
absCost := cursorAbsoluteCost(x, y)
|
||||
|
||||
if fwdCost < absCost {
|
||||
writeCursorForward(w, gap)
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
|
||||
if !moved {
|
||||
writeCursorPos(w, x, y)
|
||||
}
|
||||
|
||||
o.cursorX = x
|
||||
o.cursorY = y
|
||||
o.cursorValid = true
|
||||
}
|
||||
|
||||
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
||||
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg RGB, attr Attr) {
|
||||
// Check what changed
|
||||
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFg256) != (o.lastAttr&AttrFg256)
|
||||
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBg256) != (o.lastAttr&AttrBg256)
|
||||
styleAttr := attr & AttrStyle
|
||||
lastStyleAttr := o.lastAttr & AttrStyle
|
||||
attrChanged := !o.lastValid || styleAttr != lastStyleAttr
|
||||
|
||||
if !fgChanged && !bgChanged && !attrChanged {
|
||||
return
|
||||
}
|
||||
|
||||
// If attributes changed, must reset first
|
||||
if attrChanged {
|
||||
w.Write(csi) // \x1b[
|
||||
first := true
|
||||
|
||||
// Reset
|
||||
w.WriteByte('0')
|
||||
first = false
|
||||
|
||||
// Style attributes
|
||||
if styleAttr&AttrBold != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('1')
|
||||
first = false
|
||||
}
|
||||
if styleAttr&AttrDim != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('2')
|
||||
first = false
|
||||
}
|
||||
if styleAttr&AttrItalic != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('3')
|
||||
first = false
|
||||
}
|
||||
if styleAttr&AttrUnderline != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('4')
|
||||
first = false
|
||||
}
|
||||
if styleAttr&AttrBlink != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('5')
|
||||
first = false
|
||||
}
|
||||
if styleAttr&AttrReverse != 0 {
|
||||
if !first {
|
||||
w.WriteByte(';')
|
||||
}
|
||||
w.WriteByte('7')
|
||||
first = false
|
||||
}
|
||||
|
||||
o.writeFgInline(w, fg, attr)
|
||||
o.writeBgInline(w, bg, attr)
|
||||
w.WriteByte('m')
|
||||
} else {
|
||||
// Only colors changed, emit minimal sequence
|
||||
if fgChanged && bgChanged {
|
||||
w.Write(csi)
|
||||
o.writeFgInline(w, fg, attr)
|
||||
o.writeBgInline(w, bg, attr)
|
||||
w.WriteByte('m')
|
||||
} else if fgChanged {
|
||||
o.writeFgFull(w, fg, attr)
|
||||
} else if bgChanged {
|
||||
o.writeBgFull(w, bg, attr)
|
||||
}
|
||||
}
|
||||
|
||||
o.lastFg = fg
|
||||
o.lastBg = bg
|
||||
o.lastAttr = attr
|
||||
o.lastValid = true
|
||||
}
|
||||
|
||||
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
|
||||
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg RGB, attr Attr) {
|
||||
w.WriteByte(';')
|
||||
if attr&AttrFg256 != 0 {
|
||||
// 256-color: 38;5;N
|
||||
w.Write([]byte("38;5;"))
|
||||
writeInt(w, int(fg.R))
|
||||
} else if o.colorMode == ColorModeTrueColor {
|
||||
// True color: 38;2;R;G;B
|
||||
w.Write([]byte("38;2;"))
|
||||
writeInt(w, int(fg.R))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(fg.G))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(fg.B))
|
||||
} else {
|
||||
// Fallback 256: 38;5;N
|
||||
w.Write([]byte("38;5;"))
|
||||
writeInt(w, int(RGBTo256(fg)))
|
||||
}
|
||||
}
|
||||
|
||||
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
|
||||
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg RGB, attr Attr) {
|
||||
w.WriteByte(';')
|
||||
if attr&AttrBg256 != 0 {
|
||||
// 256-color: 48;5;N
|
||||
w.Write([]byte("48;5;"))
|
||||
writeInt(w, int(bg.R))
|
||||
} else if o.colorMode == ColorModeTrueColor {
|
||||
// True color: 48;2;R;G;B
|
||||
w.Write([]byte("48;2;"))
|
||||
writeInt(w, int(bg.R))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(bg.G))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(bg.B))
|
||||
} else {
|
||||
// Fallback 256: 48;5;N
|
||||
w.Write([]byte("48;5;"))
|
||||
writeInt(w, int(RGBTo256(bg)))
|
||||
}
|
||||
}
|
||||
|
||||
// writeFgFull writes complete fg color sequence
|
||||
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg RGB, attr Attr) {
|
||||
if attr&AttrFg256 != 0 {
|
||||
w.Write(csiFg256)
|
||||
writeInt(w, int(fg.R))
|
||||
w.WriteByte('m')
|
||||
} else if o.colorMode == ColorModeTrueColor {
|
||||
w.Write(csiFgRGB)
|
||||
writeInt(w, int(fg.R))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(fg.G))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(fg.B))
|
||||
w.WriteByte('m')
|
||||
} else {
|
||||
w.Write(csiFg256)
|
||||
writeInt(w, int(RGBTo256(fg)))
|
||||
w.WriteByte('m')
|
||||
}
|
||||
}
|
||||
|
||||
// writeBgFull writes complete bg color sequence
|
||||
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg RGB, attr Attr) {
|
||||
if attr&AttrBg256 != 0 {
|
||||
w.Write(csiBg256)
|
||||
writeInt(w, int(bg.R))
|
||||
w.WriteByte('m')
|
||||
} else if o.colorMode == ColorModeTrueColor {
|
||||
w.Write(csiBgRGB)
|
||||
writeInt(w, int(bg.R))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(bg.G))
|
||||
w.WriteByte(';')
|
||||
writeInt(w, int(bg.B))
|
||||
w.WriteByte('m')
|
||||
} else {
|
||||
w.Write(csiBg256)
|
||||
writeInt(w, int(RGBTo256(bg)))
|
||||
w.WriteByte('m')
|
||||
}
|
||||
}
|
||||
|
||||
// forceFullRedraw clears front buffer to force complete redraw
|
||||
func (o *outputBuffer) forceFullRedraw() {
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: 0}
|
||||
}
|
||||
o.lastValid = false
|
||||
o.cursorValid = false
|
||||
}
|
||||
|
||||
// clear writes a clear screen with specified background
|
||||
func (o *outputBuffer) clear(bg RGB) {
|
||||
w := o.writer
|
||||
w.Write(csiSGR0)
|
||||
o.writeBgFull(w, bg, 0)
|
||||
w.Write(csiClear)
|
||||
|
||||
o.lastValid = false
|
||||
o.cursorValid = false
|
||||
w.Flush()
|
||||
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: ' ', Bg: bg}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateCursor marks cursor position as unknown
|
||||
func (o *outputBuffer) invalidateCursor() {
|
||||
o.cursorValid = false
|
||||
}
|
||||
Reference in New Issue
Block a user