* tui: titles, labels and buttons drawn a column a rune Ten places drew each rune of a title or label at its byte offset, so a string holding runes of more than one byte drew with holes and ran past the width it was measured and truncated to: the Pane title, the Modal title and hint, the Divider label, TabBar titles, Table cells, Form labels, the TextField placeholder, the ConfirmDialog and AlertDialog buttons, and the ProgressOverlay title. Each now draws through Region.Text, which counts columns by rune, within the bound its loop kept; a Form label stays within its label column, as the byte count meant it to. * an unchanged frame writes nothing flush closed every frame with SGR 0, so a frame that changed no cell still sent four bytes, and an app drawing an idle screen kept writing to the terminal. The reset now follows only a frame that wrote cells: the next cell written restates its whole style, and Fini resets on its own. Each widget, given a title such as "● é · ñ", draws it whole where it drew it with holes before; a frame flushed twice writes nothing the second time, where it wrote ESC [ 0 m.
463 lines
12 KiB
Go
463 lines
12 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{}{}
|
|
|
|
// rowEqual reports whether two rows of cells hold the same cells, as one memcmp
|
|
func rowEqual(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))
|
|
}
|
|
|
|
// 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
|
|
// Most rows of a frame are unchanged; one compare settles each of those
|
|
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) {
|
|
continue
|
|
}
|
|
|
|
rowEnd := width
|
|
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
|
|
rowEnd--
|
|
}
|
|
if rowEnd == 0 {
|
|
continue
|
|
}
|
|
|
|
x := 0
|
|
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
|
|
}
|