From cc5eec589f4375ba342db80dd90ab6f85ffc2844 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Oct 2026 07:21:34 -0400 Subject: [PATCH] render: add RenderStateRowIterator.AppendText for row text Reading the text of a row previously meant walking every cell from Go with RenderStateRowCells.Next and AppendGraphemes. That is at least two cgo calls per cell, so reading an 80x30 screen costs about 4,800 cgo calls. Programs that inspect terminal text on every update, rather than render it, pay that on each read. AppendText reads the whole current row in one cgo call. A small C helper loops over the cells using the existing GRAPHEMES_UTF8 getter, so cell text and encoding match the per-cell API exactly. No libghostty changes are needed. Empty cells between characters become spaces, empty cells at the end of the row are dropped, and the spare cell of a wide character produces nothing. If dst fills up partway through a row, the helper reports where it stopped and how much room it needs, and AppendText grows dst and resumes. On an 80x30 screen this takes 9 to 13us with no allocations, compared to 103 to 133us for the cheapest per-cell loop and 118 to 274us for a per-cell loop that handles wide characters correctly. The benchmark checks that both approaches produce the same text before timing them. --- render_state_row.go | 181 +++++++++++++++++++++++++++++ render_state_row_benchmark_test.go | 179 ++++++++++++++++++++++++++++ render_state_row_test.go | 121 +++++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 render_state_row_benchmark_test.go diff --git a/render_state_row.go b/render_state_row.go index 8ab0158..eed94f6 100644 --- a/render_state_row.go +++ b/render_state_row.go @@ -12,11 +12,139 @@ static inline GhosttyRenderStateRowSelection init_render_state_row_selection() { GhosttyRenderStateRowSelection sel = GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection); return sel; } + +// render_row_text_result describes the outcome of render_row_text_append. It +// is returned by value so Go never has to pass a pointer for C to fill in. +typedef struct { + // result is GHOSTTY_SUCCESS when the rest of the row was written. + // GHOSTTY_OUT_OF_SPACE means dst filled up first, and the caller should + // grow dst and call again starting at next_x. Any other value is an + // error from libghostty. + GhosttyResult result; + + // len is the number of bytes written to dst. + size_t len; + + // next_x is the column to start from on the next call, set only with + // GHOSTTY_OUT_OF_SPACE. Every cell before it has been handled. + uint16_t next_x; + + // needed is the minimum number of extra bytes dst must have for the next + // call to make progress, set only with GHOSTTY_OUT_OF_SPACE. + size_t needed; +} render_row_text_result; + +// render_row_text_append writes the text of the current row to dst as UTF-8, +// starting at column start_x. It loops over the cells in C so that Go makes +// one call per row instead of two per cell. Each cell's text comes from the +// GRAPHEMES_UTF8 getter, so encoding matches the per-cell API exactly. +// +// The text rules are documented on the Go method, AppendText. +// +// Empty cells are held back as a pending run of spaces and written only when +// a cell with text follows them. This is what drops empty cells at the end +// of the row. If dst fills up while spaces are pending, next_x points at the +// first pending space rather than at the cell with text, so the next call +// writes those spaces again along with the text that follows them. +// +// The cells handle is reset to the current row. Its position afterwards is +// unspecified. +static inline render_row_text_result render_row_text_append( + GhosttyRenderStateRowIterator rows, + GhosttyRenderStateRowCells cells, + uint16_t start_x, + uint8_t* dst, + size_t cap +) { + render_row_text_result out = { + .result = GHOSTTY_SUCCESS, + .next_x = start_x, + }; + + // Load the current row into the cells handle. libghostty updates the + // object the handle points to, so the local copy of the handle is enough. + GhosttyResult result = ghostty_render_state_row_get( + rows, + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &cells + ); + if (result != GHOSTTY_SUCCESS) { + out.result = result; + return out; + } + + ghostty_render_state_row_cells_select(cells, start_x); + + // blanks counts the pending empty cells, and blank_x is the column of + // the first one. + size_t blanks = 0; + uint16_t blank_x = start_x; + uint16_t x = start_x; + do { + // Leave room for the pending spaces and ask for the cell's text + // right after them. If even the spaces don't fit, pass a NULL + // buffer. An empty cell still succeeds with len=0, and a cell with + // text fails with GHOSTTY_OUT_OF_SPACE and reports the size it needs. + size_t room = cap - out.len; + GhosttyBuffer buf = { + .ptr = room > blanks ? dst + out.len + blanks : NULL, + .cap = room > blanks ? room - blanks : 0, + .len = 0, + }; + result = ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8, + &buf + ); + if (result == GHOSTTY_OUT_OF_SPACE) { + out.result = result; + out.next_x = blanks > 0 ? blank_x : x; + out.needed = blanks + buf.len; + return out; + } + if (result != GHOSTTY_SUCCESS) { + out.result = result; + return out; + } + + // An empty cell adds to the pending run of spaces, unless it is a + // spacer cell. A spacer cell is either the second half of a wide + // character or the leftover cell where a wide character didn't fit + // at the end of a row, so it produces no text. + if (buf.len == 0) { + GhosttyCell raw; + GhosttyCellWide wide; + ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, + &raw + ); + ghostty_cell_get(raw, GHOSTTY_CELL_DATA_WIDE, &wide); + if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL || + wide == GHOSTTY_CELL_WIDE_SPACER_HEAD) { + continue; + } + + if (blanks == 0) blank_x = x; + blanks++; + continue; + } + + // A cell with text was written after the reserved room, so fill + // that room with the pending spaces. + for (size_t i = 0; i < blanks; i++) dst[out.len + i] = ' '; + out.len += blanks + buf.len; + blanks = 0; + } while (x++, ghostty_render_state_row_cells_next(cells)); + + return out; +} */ import "C" import ( "errors" + "slices" "unsafe" ) @@ -273,6 +401,59 @@ func (ri *RenderStateRowIterator) Cells(rc *RenderStateRowCells) error { )) } +// AppendText appends the text of the current row to dst as UTF-8 and returns +// the extended slice. Any existing content in dst is kept. +// +// The text follows what the row shows on screen: +// +// - Empty cells between characters become spaces. +// - Empty cells at the end of the row are left out. Spaces the program +// actually printed are text and are kept, even at the end of the row. +// - A wide character, such as a CJK character or most emoji, appears once +// even though it covers two cells. +// - A character made of several code points, such as an emoji with a skin +// tone modifier, is kept whole. +// +// No newline is added. When the terminal wraps a long line onto several +// rows, each row is returned separately. To join them, check [Row.Wrap] on +// the value returned by [RenderStateRowIterator.Raw]. +// +// rc is working storage. AppendText loads the current row into it and leaves +// it at an unspecified cell. As with [RenderStateRowIterator.Cells], one rc +// can be reused for every row. +// +// AppendText is much faster than calling [RenderStateRowCells.AppendGraphemes] +// on each cell, because it reads the whole row in one call into libghostty. +// To avoid allocations, reuse one buffer across rows by passing buf[:0]. +func (ri *RenderStateRowIterator) AppendText(dst []byte, rc *RenderStateRowCells) ([]byte, error) { + var x C.uint16_t + for { + oldLen := len(dst) + available := cap(dst) - oldLen + var ptr *C.uint8_t + if available > 0 { + buf := dst[:cap(dst)] + ptr = (*C.uint8_t)(unsafe.Pointer(&buf[oldLen])) + } + + result := C.render_row_text_append(ri.ptr, rc.ptr, x, ptr, C.size_t(available)) + dst = dst[:oldLen+int(result.len)] + switch result.result { + case C.GHOSTTY_SUCCESS: + return dst, nil + + case C.GHOSTTY_OUT_OF_SPACE: + // Grow dst so the next cell fits and continue from where the C + // loop stopped. + x = result.next_x + dst = slices.Grow(dst, int(result.needed)) + + default: + return dst, resultError(result.result) + } + } +} + // CellsRaw returns a borrowed, contiguous view of the packed cell values in // the current row. It avoids one cgo call per cell and is intended for render // paths that decode [Cell.PackedValue] using the layout from [TypeJSON]. diff --git a/render_state_row_benchmark_test.go b/render_state_row_benchmark_test.go new file mode 100644 index 0000000..8bb1936 --- /dev/null +++ b/render_state_row_benchmark_test.go @@ -0,0 +1,179 @@ +package libghostty + +import ( + "strings" + "testing" +) + +// rowTextBenchmarkCase is a line of terminal content. The benchmark fills an +// 80x30 screen with it and reads back the text of every row, which is how a +// program watching a terminal would typically use AppendText. +type rowTextBenchmarkCase struct { + name string + line string +} + +var rowTextBenchmarkCases = []rowTextBenchmarkCase{ + { + name: "Plain", + line: "plain ASCII terminal content with words, numbers 0123456789, and punctuation", + }, + { + name: "Sparse", + line: "short line", + }, + { + name: "Mixed", + line: "\x1b[1;38;5;33mstatus\x1b[0m plain 日本語 é 👩🏽‍💻 \x1b[4munderlined\x1b[0m", + }, +} + +// appendRowTextPerCell produces the same text as AppendText using only the +// per-cell API from Go, which is what callers had to do before AppendText +// existed. It looks up the cell width only for empty cells, since those are +// the only cells where it changes the result. +// +// With checkWide false, it is the cheapest per-cell loop possible, with two +// calls into libghostty per cell (Next and AppendGraphemes). That version +// writes an extra space after each wide character, so it is only useful to +// show the lowest cost a per-cell loop can reach. +func appendRowTextPerCell(dst []byte, ri *RenderStateRowIterator, rc *RenderStateRowCells, checkWide bool) ([]byte, error) { + if err := ri.Cells(rc); err != nil { + return dst, err + } + + blanks := 0 + for rc.Next() { + // Write the pending spaces first. If this cell turns out to be + // empty too, they are removed again below. + mark := len(dst) + for range blanks { + dst = append(dst, ' ') + } + before := len(dst) + + var err error + dst, err = rc.AppendGraphemes(dst) + if err != nil { + return dst, err + } + if len(dst) > before { + blanks = 0 + continue + } + dst = dst[:mark] + + if checkWide { + cell, err := rc.Raw() + if err != nil { + return dst, err + } + wide, err := cell.Wide() + if err != nil { + return dst, err + } + if wide == CellWideSpacerTail || wide == CellWideSpacerHead { + continue + } + } + blanks++ + } + return dst, nil +} + +// BenchmarkRenderStateRowText compares reading the text of a full screen with +// AppendText, which makes one call into libghostty per row, against the +// per-cell loops above, which make two or more calls per cell. +func BenchmarkRenderStateRowText(b *testing.B) { + methods := []struct { + name string + fn func([]byte, *RenderStateRowIterator, *RenderStateRowCells) ([]byte, error) + }{ + { + name: "AppendText", + fn: func(dst []byte, ri *RenderStateRowIterator, rc *RenderStateRowCells) ([]byte, error) { + return ri.AppendText(dst, rc) + }, + }, + { + name: "PerCell", + fn: func(dst []byte, ri *RenderStateRowIterator, rc *RenderStateRowCells) ([]byte, error) { + return appendRowTextPerCell(dst, ri, rc, true) + }, + }, + { + name: "PerCellMinimal", + fn: func(dst []byte, ri *RenderStateRowIterator, rc *RenderStateRowCells) ([]byte, error) { + return appendRowTextPerCell(dst, ri, rc, false) + }, + }, + } + + for _, tc := range rowTextBenchmarkCases { + for _, m := range methods { + b.Run(tc.name+"/"+m.name, func(b *testing.B) { + // Fill an 80x30 screen with the line on every row. + term, err := NewTerminal(WithSize(80, 30), WithMaxScrollbackLines(0)) + if err != nil { + b.Fatal(err) + } + defer term.Close() + term.VTWrite([]byte(strings.Repeat(tc.line+"\r\n", 29) + tc.line)) + + rs, err := NewRenderState() + if err != nil { + b.Fatal(err) + } + defer rs.Close() + if err := rs.Update(term); err != nil { + b.Fatal(err) + } + + ri, err := NewRenderStateRowIterator() + if err != nil { + b.Fatal(err) + } + defer ri.Close() + + rc, err := NewRenderStateRowCells() + if err != nil { + b.Fatal(err) + } + defer rc.Close() + + // Make sure AppendText and the correct per-cell loop produce + // the same text before timing anything. + if err := rs.RowIterator(ri); err != nil { + b.Fatal(err) + } + for ri.Next() { + want, err := ri.AppendText(nil, rc) + if err != nil { + b.Fatal(err) + } + got, err := appendRowTextPerCell(nil, ri, rc, true) + if err != nil { + b.Fatal(err) + } + if string(want) != string(got) { + b.Fatalf("methods disagree: %q vs %q", want, got) + } + } + + buf := make([]byte, 0, 4096) + b.ReportAllocs() + for b.Loop() { + if err := rs.RowIterator(ri); err != nil { + b.Fatal(err) + } + for ri.Next() { + buf, err = m.fn(buf[:0], ri, rc) + if err != nil { + b.Fatal(err) + } + } + } + }) + } + } +} diff --git a/render_state_row_test.go b/render_state_row_test.go index 5967542..62827b0 100644 --- a/render_state_row_test.go +++ b/render_state_row_test.go @@ -413,3 +413,124 @@ func TestRenderStateOverscanAndRowIdentity(t *testing.T) { } read(RenderStateOverscan{}) } + +// rowTexts writes input to a terminal of the given size and returns the +// result of AppendText for every row. Each row is appended to the same dst, +// so a dst with little or no capacity exercises the growth path on every +// row, and a dst with existing content checks that it is kept. +func rowTexts(t *testing.T, cols, rows uint16, input string, dst []byte) []string { + t.Helper() + + term, err := NewTerminal(WithSize(cols, rows)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + term.VTWrite([]byte(input)) + + rs, err := NewRenderState() + if err != nil { + t.Fatal(err) + } + defer rs.Close() + if err := rs.Update(term); err != nil { + t.Fatal(err) + } + + ri, err := NewRenderStateRowIterator() + if err != nil { + t.Fatal(err) + } + defer ri.Close() + if err := rs.RowIterator(ri); err != nil { + t.Fatal(err) + } + + rc, err := NewRenderStateRowCells() + if err != nil { + t.Fatal(err) + } + defer rc.Close() + + var texts []string + for ri.Next() { + text, err := ri.AppendText(dst, rc) + if err != nil { + t.Fatal(err) + } + texts = append(texts, string(text)) + } + return texts +} + +func TestRenderStateRowIteratorAppendText(t *testing.T) { + cases := []struct { + name string + input string + want []string + }{ + { + name: "plain", + input: "hello world\r\nsecond", + want: []string{"hello world", "second", ""}, + }, + { + // Moving the cursor forward skips cells without writing them. + // Those empty cells become spaces. + name: "empty cells between text", + input: "a\x1b[5Gb", + want: []string{"a b", "", ""}, + }, + { + // Spaces the program printed are text, so they are kept at the + // end of the row. Only empty cells are left out. + name: "printed trailing spaces", + input: "a ", + want: []string{"a ", "", ""}, + }, + { + // Each wide character covers two cells but appears once. + name: "wide characters", + input: "日本語x", + want: []string{"日本語x", "", ""}, + }, + { + // Characters made of several code points are kept whole. + name: "multiple code points", + input: "e\u0301 👩🏽‍💻!", + want: []string{"e\u0301 👩🏽‍💻!", "", ""}, + }, + { + // A wide character that doesn't fit in the last column moves to + // the next row. The cell it leaves behind produces no text. + name: "wide character at end of row", + input: "abcdefghijk日", + want: []string{"abcdefghijk", "日", ""}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Small capacities force AppendText to grow dst partway through + // a row, at different points for each capacity. + for _, initial := range []int{0, 1, 3, 256} { + got := rowTexts(t, 12, 3, tc.input, make([]byte, 0, initial)) + if len(got) != len(tc.want) { + t.Fatalf("cap %d: expected %d rows, got %d", initial, len(tc.want), len(got)) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("cap %d row %d: expected %q, got %q", initial, i, tc.want[i], got[i]) + } + } + } + }) + } +} + +func TestRenderStateRowIteratorAppendTextKeepsPrefix(t *testing.T) { + got := rowTexts(t, 12, 1, "abc", []byte("> ")) + if got[0] != "> abc" { + t.Fatalf("expected %q, got %q", "> abc", got[0]) + } +} -- 2.51.2