diff --git a/README.md b/README.md index 4935eae..0d5f8cd 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ behaves as `New`'s Terminal does, the writer goroutine included; `New` is The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and passes it to `Flush`. The output buffer diffs against the previously flushed frame: -- Rows are scanned with early termination (trailing unchanged cells skipped). +- A row's first and last changed cells are found by memcmp, eight cells a compare: + an unchanged row costs those compares alone, and only the cells between are + scanned one by one. - Cursor moves are emitted only when the write position is non-contiguous. - SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences are suppressed. diff --git a/output.go b/output.go index 1c91935..d059ec2 100644 --- a/output.go +++ b/output.go @@ -76,8 +76,8 @@ func cellEqual(a, b Cell) bool { // 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 { +// 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 } @@ -88,6 +88,34 @@ func rowEqual(a, b []Cell) bool { 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 { @@ -104,20 +132,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { 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]) { + // 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) - 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 diff --git a/terminal_test.go b/terminal_test.go index be4edfb..1cb5f79 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -3,6 +3,7 @@ package terminal import ( "bytes" "cmp" + "math/rand/v2" "slices" "strings" "sync" @@ -133,6 +134,25 @@ func TestUnchangedFrameWritesNothing(t *testing.T) { } } +// Every cell a frame changes is written, wherever in its row it lies and however +// wide the row: after a flush the screen the terminal holds is the frame +func TestEveryChangedCellIsWritten(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 2)) + for _, width := range []int{1, 7, 8, 9, 17, 120} { + o := newOutputBuffer(ColorModeTrueColor) + frame := make([]Cell, width*4) + for range 200 { + for range rng.IntN(len(frame)/2 + 1) { + frame[rng.IntN(len(frame))] = Cell{Rune: 'a' + rng.Int32N(4), Fg: color.RGB{G: uint8(rng.IntN(2))}} + } + o.flush(frame, width, 4) + if !slices.Equal(o.front, frame) { + t.Fatalf("width %d: a changed cell went unwritten", width) + } + } + } +} + // A paste arrives as one event, whatever reads split it into: an escape // inside it is text even when a poll times out on it, and a paste the // terminal never ends ends when input pauses, whether reads time out or