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.
486 lines
13 KiB
Go
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
|
|
}
|