From e2f7f6c3c87db61556d5052ad25094570bb1249e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 21:55:04 -0700 Subject: [PATCH] render: add reusable cell style snapshot Add RenderCellStyle plus StyleInto and ResolvedStyleInto for row-cell rendering. The new API lets callers reuse one destination value while reading resolved foreground, background, has-styling, and common text style flags. Previously renderers had to call Style, FgColor, BgColor, and HasStyling separately. That allocated Go wrapper values and crossed cgo multiple times per styled cell. The new binding-side helper batches the existing libghostty queries into one cgo transition and treats absent foreground or background colors as nil fields instead of errors. --- render_state_cell.go | 167 ++++++++++++++++++++++++++++++++++++++ render_state_cell_test.go | 102 +++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/render_state_cell.go b/render_state_cell.go index 9493be6..dc2ade6 100644 --- a/render_state_cell.go +++ b/render_state_cell.go @@ -5,6 +5,83 @@ package libghostty /* #include + +// render_cell_style_snapshot is a small binding-side adapter over the current +// libghostty render-cell APIs. The upstream API exposes each style/color field +// separately, which is flexible but expensive for renderers because each Go +// getter is a cgo transition and the allocation-returning Go getters materialize +// *Style / *ColorRGB values. This helper keeps the same libghostty behavior but +// batches the fields into one cgo call and a caller-owned result struct. +typedef struct { + bool has_styling; + bool has_foreground; + GhosttyColorRgb foreground; + bool has_background; + GhosttyColorRgb background; + bool bold; + bool faint; + bool italic; + bool underline; + bool strikethrough; + bool inverse; +} render_cell_style_snapshot; + +static inline GhosttyResult render_cell_style_snapshot_get( + GhosttyRenderStateRowCells cells, + render_cell_style_snapshot* out +) { + if (out == NULL) return GHOSTTY_INVALID_VALUE; + + *out = (render_cell_style_snapshot){0}; + + GhosttyResult result = ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING, + &out->has_styling + ); + if (result != GHOSTTY_SUCCESS) return result; + + if (out->has_styling) { + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + result = ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, + &style + ); + if (result != GHOSTTY_SUCCESS) return result; + + out->bold = style.bold; + out->faint = style.faint; + out->italic = style.italic; + out->underline = style.underline != GHOSTTY_SGR_UNDERLINE_NONE; + out->strikethrough = style.strikethrough; + out->inverse = style.inverse; + } + + result = ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, + &out->foreground + ); + if (result == GHOSTTY_SUCCESS) { + out->has_foreground = true; + } else if (result != GHOSTTY_INVALID_VALUE) { + return result; + } + + result = ghostty_render_state_row_cells_get( + cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, + &out->background + ); + if (result == GHOSTTY_SUCCESS) { + out->has_background = true; + } else if (result != GHOSTTY_INVALID_VALUE) { + return result; + } + + return GHOSTTY_SUCCESS; +} */ import "C" @@ -55,6 +132,50 @@ const ( RenderStateRowCellsDataHasStyling RenderStateRowCellsData = C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING ) +// RenderCellStyle is a reusable, resolved style snapshot for the current +// render-state cell. It corresponds to the data returned by +// ghostty_render_state_row_cells_get for style, foreground color, background +// color, and has-styling, flattened into one Go value for hot render paths. +// +// Foreground and Background point at storage owned by this struct when set and +// are nil when the cell has no explicit/resolved color. Because those pointers +// target internal fields, callers should not copy a RenderCellStyle value after +// calling [RenderStateRowCells.ResolvedStyleInto]; pass the same value by +// pointer and reuse it instead. +type RenderCellStyle struct { + // Foreground is the resolved foreground color, or nil when the cell has no + // explicit foreground and the renderer should use its default foreground. + Foreground *ColorRGB + + // Background is the resolved background color, or nil when the cell has no + // explicit background and the renderer should use its default background. + Background *ColorRGB + + // HasStyling reports whether the cell has any explicit non-default styling. + HasStyling bool + + // Bold reports whether bold text is set. + Bold bool + + // Faint reports whether faint/dim text is set. + Faint bool + + // Italic reports whether italic text is set. + Italic bool + + // Underline reports whether any underline style is set. + Underline bool + + // Strikethrough reports whether strikethrough text is set. + Strikethrough bool + + // Inverse reports whether inverse video is set. + Inverse bool + + foreground ColorRGB + background ColorRGB +} + // RenderStateRowCells iterates over cells in a render-state row. // Create one with NewRenderStateRowCells, populate it via // [RenderStateRowIterator.Cells], then advance with @@ -161,6 +282,52 @@ func (rc *RenderStateRowCells) Style() (*Style, error) { return &Style{c: cs}, nil } +// StyleInto fills dst with the reusable resolved style snapshot for the current +// cell. It is an alias for [RenderStateRowCells.ResolvedStyleInto]. +func (rc *RenderStateRowCells) StyleInto(dst *RenderCellStyle) error { + return rc.ResolvedStyleInto(dst) +} + +// ResolvedStyleInto fills dst with the current cell's resolved colors and text +// style flags. This is the allocation-reusing form of querying Style, FgColor, +// BgColor, and HasStyling separately: it performs one cgo transition, stores +// colors in dst-owned memory, and sets Foreground/Background to nil when the +// corresponding color is absent. +func (rc *RenderStateRowCells) ResolvedStyleInto(dst *RenderCellStyle) error { + if dst == nil { + return errors.New("libghostty: nil RenderCellStyle") + } + + var snap C.render_cell_style_snapshot + if err := resultError(C.render_cell_style_snapshot_get(rc.ptr, &snap)); err != nil { + return err + } + + dst.HasStyling = bool(snap.has_styling) + dst.Bold = bool(snap.bold) + dst.Faint = bool(snap.faint) + dst.Italic = bool(snap.italic) + dst.Underline = bool(snap.underline) + dst.Strikethrough = bool(snap.strikethrough) + dst.Inverse = bool(snap.inverse) + + if bool(snap.has_foreground) { + dst.foreground = ColorRGB{R: uint8(snap.foreground.r), G: uint8(snap.foreground.g), B: uint8(snap.foreground.b)} + dst.Foreground = &dst.foreground + } else { + dst.Foreground = nil + } + + if bool(snap.has_background) { + dst.background = ColorRGB{R: uint8(snap.background.r), G: uint8(snap.background.g), B: uint8(snap.background.b)} + dst.Background = &dst.background + } else { + dst.Background = nil + } + + return nil +} + // Graphemes returns the full grapheme cluster codepoints for the // current cell. The base codepoint is first, followed by any extra // codepoints. Returns nil if the cell has no text. diff --git a/render_state_cell_test.go b/render_state_cell_test.go index f8b9e70..96b9b70 100644 --- a/render_state_cell_test.go +++ b/render_state_cell_test.go @@ -336,6 +336,108 @@ func TestRenderStateRowCellsStyle(t *testing.T) { } } +func TestRenderStateRowCellsResolvedStyleInto(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // Write one heavily styled cell and one default-styled cell. The styled + // cell exercises the resolved RGB color path as well as the style flags Rex + // needs to produce ANSI output without allocating per-cell Style/Color values. + term.VTWrite([]byte("\x1b[1;2;3;4;7;9;38;2;255;0;0;48;2;0;0;255mX\x1b[0mY")) + + 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) + } + + if !ri.Next() { + t.Fatal("expected at least one row") + } + + rc, err := NewRenderStateRowCells() + if err != nil { + t.Fatal(err) + } + defer rc.Close() + + if err := ri.Cells(rc); err != nil { + t.Fatal(err) + } + + if !rc.Next() { + t.Fatal("expected at least one cell") + } + + var style RenderCellStyle + if err := rc.ResolvedStyleInto(&style); err != nil { + t.Fatal(err) + } + if !style.HasStyling { + t.Fatal("expected first cell to have styling") + } + if style.Foreground == nil || *style.Foreground != (ColorRGB{R: 255, G: 0, B: 0}) { + t.Fatalf("expected red foreground, got %#v", style.Foreground) + } + if style.Background == nil || *style.Background != (ColorRGB{R: 0, G: 0, B: 255}) { + t.Fatalf("expected blue background, got %#v", style.Background) + } + if !style.Bold || !style.Faint || !style.Italic || !style.Underline || !style.Strikethrough || !style.Inverse { + t.Fatalf("expected all style flags to be set, got %+v", style) + } + fgPtr := style.Foreground + bgPtr := style.Background + + if !rc.Next() { + t.Fatal("expected second cell") + } + if err := rc.ResolvedStyleInto(&style); err != nil { + t.Fatal(err) + } + if style.HasStyling { + t.Fatal("expected second cell to have default styling") + } + if style.Foreground != nil { + t.Fatalf("expected nil foreground for default-styled cell, got %+v", *style.Foreground) + } + if style.Background != nil { + t.Fatalf("expected nil background for default-styled cell, got %+v", *style.Background) + } + if style.Bold || style.Faint || style.Italic || style.Underline || style.Strikethrough || style.Inverse { + t.Fatalf("expected all style flags to be cleared, got %+v", style) + } + + if err := rc.Select(0); err != nil { + t.Fatal(err) + } + if err := rc.StyleInto(&style); err != nil { + t.Fatal(err) + } + if style.Foreground != fgPtr { + t.Fatal("expected foreground pointer storage to be reused") + } + if style.Background != bgPtr { + t.Fatal("expected background pointer storage to be reused") + } +} + func TestRenderStateRowCellsSelected(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { -- 2.51.2