Files
terminal/output.go
T

383 lines
9.1 KiB
Go

package terminal
import (
"bufio"
"unicode"
"unicode/utf8"
"github.com/lixenwraith/color"
)
// 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 color.RGB
lastBg color.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)
// A cell holds one glyph, never a control the terminal would act on
r := c.Rune
if unicode.IsControl(r) || !utf8.ValidRune(r) {
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 color.RGB, attr Attr) {
if o.colorMode == ColorModeNone {
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
}
// Check what changed
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFgColor) != (o.lastAttr&AttrFgColor)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBgColor) != (o.lastAttr&AttrBgColor)
styleAttr := attr & AttrStyle
attrChanged := !o.lastValid || styleAttr != o.lastAttr&AttrStyle
// A text console's bright colors (90-97) set bold, which only a reset
// clears: 16 colors restate the whole style from one
if o.colorMode == ColorMode16 && (fgChanged || bgChanged) {
attrChanged = true
}
if !fgChanged && !bgChanged && !attrChanged {
return
}
w.Write(csi)
// If attributes changed, must reset first, then restate both colors
if attrChanged {
w.WriteByte('0')
for _, a := range attrCodes {
if styleAttr&a.bit != 0 {
w.WriteByte(';')
w.WriteByte(a.code)
}
}
fgChanged, bgChanged = true, true
}
if fgChanged {
o.writeColor(w, fg, attr&AttrFg256 != 0, attr&AttrFgDefault != 0, 30, attrChanged)
}
if bgChanged {
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, attrChanged || fgChanged)
}
w.WriteByte('m')
o.lastFg = fg
o.lastBg = bg
o.lastAttr = attr
o.lastValid = true
}
// attrCodes pairs each style bit with its SGR parameter, in emission order
var attrCodes = [...]struct {
bit Attr
code byte
}{{AttrBold, '1'}, {AttrDim, '2'}, {AttrItalic, '3'}, {AttrUnderline, '4'}, {AttrBlink, '5'}, {AttrReverse, '7'}}
// writeColor writes one color's SGR parameters, after a ';' when sep, for
// base 30 (foreground) or 40 (background): the terminal's own, a palette
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) {
if o.colorMode == ColorModeNone {
return
}
if sep {
w.WriteByte(';')
}
switch {
case deflt:
writeInt(w, base+9)
case o.colorMode == ColorMode16:
i := color.RGBTo16(c)
if palette {
i = paletteTo16(c.R)
}
if i >= 8 {
base += 60 - 8 // bright: 90-97, 100-107
}
writeInt(w, base+int(i))
case palette:
writeInt(w, base+8)
w.Write(sgrPalette)
writeInt(w, int(c.R))
case o.colorMode == ColorModeTrueColor:
writeInt(w, base+8)
w.Write(sgrRGB)
writeInt(w, int(c.R))
w.WriteByte(';')
writeInt(w, int(c.G))
w.WriteByte(';')
writeInt(w, int(c.B))
default:
writeInt(w, base+8)
w.Write(sgrPalette)
writeInt(w, int(RGBTo256(c)))
}
}
// 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, attr's
// AttrBgDefault or AttrBg256 saying how to read it
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
w := o.writer
w.Write(csiSGR0)
w.Write(csi)
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
w.WriteByte('m')
w.Write(csiClear)
o.lastValid = false
o.cursorValid = false
w.Flush()
for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
}
}
// invalidateCursor marks cursor position as unknown
func (o *outputBuffer) invalidateCursor() {
o.cursorValid = false
}