From da44e060c39090f8e66c43a2c72f59ab29d7c9aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 21:27:51 -0700 Subject: [PATCH] add reusable row cell grapheme extraction RenderStateRowCells.Graphemes previously allocated a fresh codepoint slice for every text cell. That made plaintext renderers pay allocation and conversion costs even when most cells contained a single ASCII codepoint. Add GraphemesInto so callers can reuse their own codepoint scratch buffer, and add AppendGraphemes for renderers that want UTF-8 bytes directly. The existing Graphemes API now delegates through the reusable path while preserving its nil result for empty cells. --- render_state_cell.go | 84 +++++++++++++++++++++++++--- render_state_cell_test.go | 115 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) diff --git a/render_state_cell.go b/render_state_cell.go index 4312319..9493be6 100644 --- a/render_state_cell.go +++ b/render_state_cell.go @@ -10,6 +10,7 @@ import "C" import ( "errors" + "unicode/utf8" "unsafe" ) @@ -164,21 +165,90 @@ func (rc *RenderStateRowCells) Style() (*Style, error) { // current cell. The base codepoint is first, followed by any extra // codepoints. Returns nil if the cell has no text. func (rc *RenderStateRowCells) Graphemes() ([]uint32, error) { + graphemes, err := rc.GraphemesInto(nil) + if err != nil { + return nil, err + } + if len(graphemes) == 0 { + return nil, nil + } + return graphemes, nil +} + +// GraphemesInto appends the full grapheme cluster codepoints for the +// current cell to dst and returns the extended slice. The base codepoint +// is appended first, followed by any extra codepoints. If the cell has no +// text, dst is returned unchanged. +// +// This is the allocation-reusing form of [RenderStateRowCells.Graphemes]. +// Callers that want a per-cell scratch buffer should pass scratch[:0]. +func (rc *RenderStateRowCells) GraphemesInto(dst []uint32) ([]uint32, error) { // Get the number of codepoints. var n C.uint32_t if err := resultError(C.ghostty_render_state_row_cells_get(rc.ptr, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, unsafe.Pointer(&n))); err != nil { - return nil, err + return dst, err } if n == 0 { - return nil, nil + return dst, nil } - // Read codepoints into a buffer. - buf := make([]uint32, uint32(n)) - if err := resultError(C.ghostty_render_state_row_cells_get(rc.ptr, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&buf[0]))); err != nil { - return nil, err + // Read codepoints into the newly appended portion of the caller's buffer. + oldLen := len(dst) + newLen := oldLen + int(n) + if newLen <= cap(dst) { + dst = dst[:newLen] + } else { + grown := make([]uint32, newLen) + copy(grown, dst) + dst = grown + } + if err := resultError(C.ghostty_render_state_row_cells_get(rc.ptr, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&dst[oldLen]))); err != nil { + return dst[:oldLen], err + } + return dst, nil +} + +// AppendGraphemes appends the current cell's grapheme cluster encoded as +// UTF-8 to dst and returns the extended byte slice. If the cell has no text, +// dst is returned unchanged. +// +// This is intended for renderers and text extractors that ultimately need +// bytes or strings and want to avoid allocating a temporary codepoint slice +// and per-cell string. The common case of a short grapheme cluster uses stack +// storage for codepoints; unusually long clusters allocate a temporary slice. +func (rc *RenderStateRowCells) AppendGraphemes(dst []byte) ([]byte, error) { + // Get the number of codepoints so we can avoid a heap allocation for the + // common one-codepoint ASCII case while still preserving full graphemes. + var n C.uint32_t + if err := resultError(C.ghostty_render_state_row_cells_get(rc.ptr, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, unsafe.Pointer(&n))); err != nil { + return dst, err + } + if n == 0 { + return dst, nil + } + + // Most cells are a single codepoint. Keep a small stack buffer so appending + // text for normal terminal output doesn't require allocating []uint32. + var small [8]uint32 + var graphemes []uint32 + if int(n) <= len(small) { + graphemes = small[:int(n)] + } else { + graphemes = make([]uint32, int(n)) + } + + if err := resultError(C.ghostty_render_state_row_cells_get(rc.ptr, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&graphemes[0]))); err != nil { + return dst, err + } + + for _, cp := range graphemes { + if cp < utf8.RuneSelf { + dst = append(dst, byte(cp)) + continue + } + dst = utf8.AppendRune(dst, rune(cp)) } - return buf, nil + return dst, nil } // BgColor returns the resolved background color for the current cell. diff --git a/render_state_cell_test.go b/render_state_cell_test.go index 4fde1b2..f8b9e70 100644 --- a/render_state_cell_test.go +++ b/render_state_cell_test.go @@ -193,6 +193,73 @@ func TestRenderStateRowCellsGraphemes(t *testing.T) { } } +func TestRenderStateRowCellsGraphemesInto(t *testing.T) { + rc := testRenderStateRowCells(t, "ABC") + + // Select column 1 (should be 'B') and append it into caller-owned scratch + // that already has content. This verifies append semantics and lets callers + // reuse the same backing array by passing scratch[:0]. + if err := rc.Select(1); err != nil { + t.Fatal(err) + } + scratch := make([]uint32, 1, 4) + scratch[0] = 'A' + base := &scratch[0] + graphemes, err := rc.GraphemesInto(scratch) + if err != nil { + t.Fatal(err) + } + if len(graphemes) != 2 || graphemes[0] != 'A' || graphemes[1] != 'B' { + t.Fatalf("expected ['A', 'B'], got %v", graphemes) + } + if &graphemes[0] != base { + t.Fatal("expected GraphemesInto to reuse caller-provided capacity") + } + + // Empty cells append nothing and return the same slice unchanged. + if err := rc.Select(3); err != nil { + t.Fatal(err) + } + graphemes, err = rc.GraphemesInto(graphemes) + if err != nil { + t.Fatal(err) + } + if len(graphemes) != 2 || graphemes[0] != 'A' || graphemes[1] != 'B' { + t.Fatalf("expected empty cell to leave graphemes unchanged, got %v", graphemes) + } +} + +func TestRenderStateRowCellsAppendGraphemes(t *testing.T) { + rc := testRenderStateRowCells(t, "Aé") + + var text []byte + for x := uint16(0); x < 2; x++ { + if err := rc.Select(x); err != nil { + t.Fatal(err) + } + var err error + text, err = rc.AppendGraphemes(text) + if err != nil { + t.Fatal(err) + } + } + if string(text) != "Aé" { + t.Fatalf("expected %q, got %q", "Aé", string(text)) + } + + // Empty cells append nothing and preserve the existing byte buffer content. + if err := rc.Select(2); err != nil { + t.Fatal(err) + } + text, err := rc.AppendGraphemes(text) + if err != nil { + t.Fatal(err) + } + if string(text) != "Aé" { + t.Fatalf("expected empty cell to leave text unchanged, got %q", string(text)) + } +} + func TestRenderStateRowCellsStyle(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { @@ -420,3 +487,51 @@ func TestRenderStateRowCellsColors(t *testing.T) { t.Fatalf("expected nil fg for unstyled cell, got %+v", *fg) } } + +func testRenderStateRowCells(t *testing.T, text string) *RenderStateRowCells { + t.Helper() + + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(term.Close) + + term.VTWrite([]byte(text)) + + rs, err := NewRenderState() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rs.Close) + + if err := rs.Update(term); err != nil { + t.Fatal(err) + } + + ri, err := NewRenderStateRowIterator() + if err != nil { + t.Fatal(err) + } + t.Cleanup(ri.Close) + + if err := rs.RowIterator(ri); err != nil { + t.Fatal(err) + } + + if !ri.Next() { + t.Fatal("expected at least one row") + } + + rc, err := NewRenderStateRowCells() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rc.Close) + + if err := ri.Cells(rc); err != nil { + t.Fatal(err) + } + + return rc +} -- 2.51.2