Files
terminal/output.go
T
Lixen df94556101 v0.6.1 flush: memcmp finds a row's first and last changed cells
The whole-row memcmp settled an unchanged row, but on a changed row the cell
loop then read again the prefix memcmp had already compared, and the trailing
trim walked unchanged cells one by one. Chunks of eight cells now find the
first and last changed cell, so no cell is compared twice and only the span
between them is scanned cell by cell. Output is byte-identical.

Measured on frames captured from vif at 60 fps (median of 15, one core):
vanguard 120x40 73.7 -> 66.6 us a frame (-10%), vanguard 240x66
158.8 -> 131.8 (-17%), main with the gun firing 40.0 -> 34.1 (-15%), main
idle 20.3 -> 14.0 (-31%). Dropping the row compare instead costs +15% to
+120% on the same frames.
2026-10-28 22:03:41 -04:00

486 lines
13 KiB
Go

package terminal
import (
"bytes"
"unicode"
"unicode/utf8"
"unsafe"
"github.com/lixenwraith/color"
)
// outputBuffer renders double-buffered terminal output with diffing. It only
// encodes: what an operation renders collects in buf, and termImpl hands it to
// the goroutine that writes it.
type outputBuffer struct {
front []Cell
width int
height int
colorMode ColorMode
buf bytes.Buffer
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
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
}
func newOutputBuffer(colorMode ColorMode) *outputBuffer {
return &outputBuffer{colorMode: colorMode}
}
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 the fields most likely to change first
func cellEqual(a, b Cell) bool {
return a.Rune == b.Rune &&
a.Bg == b.Bg &&
a.Fg == b.Fg &&
a.Attrs == b.Attrs
}
// Cell has no padding, so a row compares as the bytes it occupies; the assertion
// fails to compile should a field change leave padding behind
var _ [0]struct{} = [unsafe.Sizeof(Cell{}) - unsafe.Sizeof(rune(0)) - 2*unsafe.Sizeof(color.RGB{}) - unsafe.Sizeof(Attr(0))]struct{}{}
// cellsEqual reports whether two runs of cells hold the same cells, as one memcmp
func cellsEqual(a, b []Cell) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
n := len(a) * int(unsafe.Sizeof(Cell{}))
return bytes.Equal(unsafe.Slice((*byte)(unsafe.Pointer(&a[0])), n), unsafe.Slice((*byte)(unsafe.Pointer(&b[0])), n))
}
// diffChunk is the cells one memcmp settles while a row's changes are sought: few
// enough that a changed chunk costs little to search again cell by cell
const diffChunk = 8
// firstChange is the first cell where a and b differ, len(a) when none does
func firstChange(a, b []Cell) int {
i := 0
for i+diffChunk <= len(a) && cellsEqual(a[i:i+diffChunk], b[i:i+diffChunk]) {
i += diffChunk
}
for i < len(a) && cellEqual(a[i], b[i]) {
i++
}
return i
}
// lastChange is one past the last cell from on where a and b differ, from when none does
func lastChange(a, b []Cell, from int) int {
j := len(a)
for j-diffChunk >= from && cellsEqual(a[j-diffChunk:j], b[j-diffChunk:j]) {
j -= diffChunk
}
for j > from && cellEqual(a[j-1], b[j-1]) {
j--
}
return j
}
// flush renders the back buffer into buf, 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.buf
start := w.Len()
for y := 0; y < height; y++ {
rowStart := y * width
// Memcmp finds the row's first and last changed cells, so an unchanged row is
// one pass of compares and a changed one is scanned cell by cell between them
row, front := cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]
x := firstChange(row, front)
if x == width {
continue
}
rowEnd := lastChange(row, front, x+1)
for x < rowEnd {
idx := rowStart + x
if cellEqual(cells[idx], o.front[idx]) {
x++
continue
}
segStart := x
segEnd := x + 1
// A gap of up to 3 unchanged cells is rewritten, about the bytes of a cursor move
for segEnd < rowEnd {
gapStart := segEnd
for gapStart < rowEnd && cellEqual(cells[rowStart+gapStart], o.front[rowStart+gapStart]) {
gapStart++
}
gapSize := gapStart - segEnd
if gapSize == 0 {
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++
}
continue
}
if gapSize > 3 {
break
}
// Only a gap in the segment's style: an SGR inside it costs more than the move
gapCompatible := true
refCell := cells[rowStart+segEnd-1]
for k := 0; k < gapSize; k++ {
gCell := cells[rowStart+segEnd+k]
if gCell.Fg != refCell.Fg || gCell.Bg != refCell.Bg || gCell.Attrs != refCell.Attrs {
gapCompatible = false
break
}
}
if !gapCompatible {
break
}
if gapStart >= rowEnd {
break
}
segEnd = gapStart
for segEnd < rowEnd && !cellEqual(cells[rowStart+segEnd], o.front[rowStart+segEnd]) {
segEnd++
}
}
o.moveCursorTo(w, segStart, y)
for sx := segStart; sx < segEnd; sx++ {
cidx := rowStart + sx
c := cells[cidx]
o.writeStyleCoalesced(w, c.Fg, c.Bg, c.Attrs)
if r := c.Rune; r >= ' ' && r < 0x7f {
w.WriteByte(byte(r))
} else {
w.WriteRune(oneColumn(r))
}
o.front[cidx] = c
o.cursorX++
}
x = segEnd
}
}
// A frame that changed no cell writes nothing, the reset included
if w.Len() > start {
w.Write(csiSGR0)
}
o.lastValid = false
}
// oneColumn is r as a cell draws it: one glyph in one column, where the
// terminal draws ambiguous-width runes (box drawing, U+FFFD) narrow
func oneColumn(r rune) rune {
if uint32(r) < 0x10000 && plain[r>>6]&(1<<(r&63)) != 0 {
return r
}
if !utf8.ValidRune(r) {
return ' '
}
for _, k := range drawnAs {
if unicode.In(r, k.runes...) {
return k.as
}
}
return r
}
// drawnAs is the glyph a cell draws for each kind of rune that would not take
// one column: a space for one a terminal acts on, hides or reorders text by
// (a control, a format character, a line separator), U+FFFD for one it draws
// in none or two or joins to a neighbour (a mark, a wide or emoji rune)
var drawnAs = [...]struct {
as rune
runes []*unicode.RangeTable
}{
{' ', []*unicode.RangeTable{unicode.Cc, unicode.Cf, unicode.Zl, unicode.Zp}},
{utf8.RuneError, []*unicode.RangeTable{wideOrJoining, unicode.Mn, unicode.Me, unicode.Mc}},
}
// plain has a bit set for each BMP rune a cell draws as itself, so most text
// skips drawnAs's searches
var plain = func() (p [0x10000 >> 6]uint64) {
for i := range p {
if i < 0xD800>>6 || i >= 0xE000>>6 {
p[i] = ^uint64(0)
}
}
for _, k := range drawnAs {
for _, t := range k.runes {
for _, r := range t.R16 {
for c := int(r.Lo); c <= int(r.Hi); c += int(r.Stride) {
p[c>>6] &^= 1 << (c & 63)
}
}
}
}
return p
}()
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
// fullwidth and emoji presentation as of Unicode 17, and the letters a
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
// Extend ones); a range spans the unassigned code points between its runes
var wideOrJoining = &unicode.RangeTable{
R16: []unicode.Range16{
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
},
R32: []unicode.Range32{
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
},
}
func cursorForwardCost(n int) int {
if n == 1 {
return 3 // \x1b[C
}
return 3 + digitCount(n) // \x1b[nC
}
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 caps at 4: no terminal is 10000 cells across
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 *bytes.Buffer, 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 *bytes.Buffer, fg, bg color.RGB, attr Attr) {
if o.colorMode == ColorModeNone {
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
}
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 *bytes.Buffer, 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)))
}
}
func (o *outputBuffer) forceFullRedraw() {
for i := range o.front {
o.front[i] = Cell{Rune: 0}
}
o.lastValid = false
o.cursorValid = false
}
// clear renders 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.buf
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
for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
}
}
func (o *outputBuffer) invalidateCursor() {
o.cursorValid = false
}