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]) + } +}