diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 177daf2570b1f2738ba596b43390df99c8550d7f..3b717d2644e3225239942aa7b6c3745f42d77745 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -63,6 +63,32 @@ type Chat struct { // follow is a flag to indicate whether the view should auto-scroll to // bottom on new messages. follow bool + + // drawCache memoizes the decoded form of the last list.Render output so + // repeat frames with byte-identical content skip the per-cell ANSI + // reparse that uv.StyledString.Draw performs every call. See F9 + // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one + // entry; invalidated implicitly by string inequality on the next Draw. + drawCache *chatDrawCache +} + +// chatDrawCache holds the pre-decoded form of the last list.Render output. +// The cache is keyed by the rendered string and the screen's width method +// (graphemes vs wcwidth pick different decoders inside ultraviolet's +// printString, so a cached buffer is only valid for the method it was +// decoded with). The cached buffer is independent of the draw area, so +// resize / scroll changes that produce the same string still hit. We cannot +// use uv.StyledString.Lines because it bottoms out at the first iteration +// against a zero-bounds rectangle (see ultraviolet styled.go line 45 — the +// shared printString loop's `y >= bounds.Max.Y` exit applies to the +// line-building branch too). A Buffer of the rendered text's natural +// dimensions is the cheapest correct shape: StyledString.Draw runs once on +// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no +// ANSI re-parse on hit. +type chatDrawCache struct { + rendered string + method ansi.Method + buf uv.ScreenBuffer } // NewChat creates a new instance of [Chat] that handles chat interactions and @@ -89,8 +115,93 @@ func (m *Chat) Height() int { } // Draw renders the chat UI component to the screen and the given area. +// +// The list's rendered output is cached in decoded form (see chatDrawCache) so +// that frames with byte-identical content skip the ANSI reparse that +// uv.StyledString.Draw performs on every call. The cache is keyed by the +// rendered string and the screen's width method; area / scroll changes do not +// invalidate it. func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { - uv.NewStyledString(m.list.Render()).Draw(scr, area) + rendered := m.list.Render() + method, ok := scr.WidthMethod().(ansi.Method) + if !ok { + // Width method isn't an ansi.Method (unlikely in practice — both + // TerminalScreen and ScreenBuffer store ansi.Method). Fall back + // to the uncached path so behavior matches upstream exactly. + uv.NewStyledString(rendered).Draw(scr, area) + return + } + if m.drawCache == nil || + m.drawCache.rendered != rendered || + m.drawCache.method != method { + m.drawCache = newChatDrawCache(rendered, method) + } + drawCachedBuffer(scr, area, m.drawCache.buf) +} + +// newChatDrawCache builds a chatDrawCache for the given rendered string by +// running uv.StyledString.Draw into a fresh buffer sized to the text's +// natural bounds under the active width method. This is the only place +// ANSI decoding happens for cached frames — subsequent draws reuse buf +// via drawCachedBuffer. +// +// We can't use uv.StyledString.Bounds() here: it is hard-coded to +// ansi.GraphemeWidth, while StyledString.Draw lays cells using the +// destination buffer's WidthMethod (which we capture in `method`). For +// strings where graphemes and wcwidth disagree (emoji ZWJ sequences, +// some CJK, certain combining marks) the two answers diverge, leaving +// the cached buffer either too small (trailing cells dropped on hit) or +// too large (dead cells past the live content). Computing dimensions +// with `method.StringWidth` per line matches what printString tallies +// cell-by-cell, since both decode ANSI sequences and use the same width +// method. +func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache { + w, h := renderedBounds(rendered, method) + if w <= 0 { + w = 1 + } + if h <= 0 { + h = 1 + } + buf := uv.NewScreenBuffer(w, h) + buf.Method = method + uv.NewStyledString(rendered).Draw(buf, buf.Bounds()) + return &chatDrawCache{ + rendered: rendered, + method: method, + buf: buf, + } +} + +// renderedBounds returns the (width, height) cell extent of rendered +// when laid out by method. Width is the widest line's StringWidth (which +// strips ANSI sequences and tallies cells via method, exactly like +// printString); height is the line count. Both match what +// uv.StyledString.Draw will write into a buffer whose WidthMethod is +// method, so the cache buffer is always sized to fit the live content. +func renderedBounds(rendered string, method ansi.Method) (w, h int) { + for line := range strings.SplitSeq(rendered, "\n") { + w = max(w, method.StringWidth(line)) + h++ + } + return w, h +} + +// drawCachedBuffer blits a previously-decoded buffer into scr at area, +// mirroring uv.StyledString.Draw's screen-mode behavior for the default +// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line +// 51-56; the buf.Draw call replaces the per-cell ANSI decode that +// printString does on every uncached frame with a pure cell copy. +func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) { + // Clear the area first to match StyledString.Draw — leftover cells + // from a previous frame outside the new content must be zeroed, + // because Buffer.Draw skips empty cells (it doesn't clear). + for y := area.Min.Y; y < area.Max.Y; y++ { + for x := area.Min.X; x < area.Max.X; x++ { + scr.SetCell(x, y, nil) + } + } + buf.Draw(scr, area) } // SetSize sets the size of the chat view port. diff --git a/internal/ui/model/chat_draw_cache_test.go b/internal/ui/model/chat_draw_cache_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ac422364716e3a1f35baa4e252d1137c38bdf350 --- /dev/null +++ b/internal/ui/model/chat_draw_cache_test.go @@ -0,0 +1,334 @@ +package model + +import ( + "strconv" + "testing" + + "github.com/charmbracelet/crush/internal/ui/chat" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" +) + +// drawTestArea is a fixed area used by the cache tests; it must be at least +// as large as the test items so we can compare every cell. +func drawTestArea(width, height int) uv.Rectangle { + return uv.Rect(0, 0, width, height) +} + +// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests +// can assert byte-equivalence with a fresh uv.NewStyledString render. +func renderToBuffer(t *testing.T, c *Chat, w, h int) string { + t.Helper() + scr := uv.NewScreenBuffer(w, h) + c.Draw(scr, drawTestArea(w, h)) + return scr.Render() +} + +// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws +// against the same list output reuse the same chatDrawCache rather than +// allocating a fresh one. Both the wrapper pointer and the embedded +// RenderBuffer pointer must be stable across the second Draw — that's what +// proves no re-decoding happened. +func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) { + t.Parallel() + + u := newTestUI() + u.chat.SetMessages( + testMessageItem{id: "a", text: "alpha"}, + testMessageItem{id: "b", text: "beta"}, + ) + u.updateLayoutAndSize() + + w, h := 80, 20 + _ = renderToBuffer(t, u.chat, w, h) + require.NotNil(t, u.chat.drawCache, "first draw should populate cache") + firstCache := u.chat.drawCache + firstBuf := u.chat.drawCache.buf.RenderBuffer + + _ = renderToBuffer(t, u.chat, w, h) + require.Same(t, firstCache, u.chat.drawCache, + "identical rendered string must reuse the same cache pointer") + require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer, + "identical rendered string must reuse the same RenderBuffer pointer") +} + +// TestChatDrawCache_MissOnDifferentRender asserts that when the list output +// changes, the cache is rebuilt and the new draw output matches a fresh +// uv.NewStyledString render byte-for-byte. +func TestChatDrawCache_MissOnDifferentRender(t *testing.T) { + t.Parallel() + + u := newTestUI() + u.chat.SetMessages( + testMessageItem{id: "a", text: "alpha"}, + ) + u.updateLayoutAndSize() + + w, h := 80, 20 + _ = renderToBuffer(t, u.chat, w, h) + require.NotNil(t, u.chat.drawCache) + firstCache := u.chat.drawCache + + // Replace the items so the rendered string differs. + u.chat.SetMessages( + testMessageItem{id: "c", text: "gamma delta"}, + ) + u.updateLayoutAndSize() + + got := renderToBuffer(t, u.chat, w, h) + require.NotSame(t, firstCache, u.chat.drawCache, + "changed rendered string must replace the cache entry") + + // Output must match a fresh uv.NewStyledString render of the current + // list output for the same area. This is the byte-equivalence guard + // that protects against blit drift from StyledString.Draw. + want := freshStyledRender(u.chat.list.Render(), w, h) + require.Equal(t, want, got) +} + +// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached +// decoded lines do not depend on the screen / area: the same string drawn +// into a smaller area must hit the cache, and the output must match a fresh +// uv.NewStyledString render against the new area. +func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) { + t.Parallel() + + u := newTestUI() + u.chat.SetMessages( + testMessageItem{id: "a", text: "alpha"}, + testMessageItem{id: "b", text: "beta"}, + ) + u.updateLayoutAndSize() + + w, h := 80, 20 + _ = renderToBuffer(t, u.chat, w, h) + require.NotNil(t, u.chat.drawCache) + firstCache := u.chat.drawCache + firstBuf := u.chat.drawCache.buf.RenderBuffer + + // Draw the same content into a smaller buffer. The list output is + // width-sensitive in production (list.Render depends on width), so we + // hold the list width fixed via updateLayoutAndSize and only shrink + // the destination area. The cache key is the rendered string + width + // method, both unchanged here. + smallScr := uv.NewScreenBuffer(40, 10) + u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10)) + + require.Same(t, firstCache, u.chat.drawCache, + "smaller area must still hit the cache") + require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer, + "cached RenderBuffer must be reused across area changes") + + want := freshStyledRender(u.chat.list.Render(), 40, 10) + require.Equal(t, want, smallScr.Render()) +} + +// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one +// entry. This is structural — any future change that introduces an LRU or +// ring would have to update this test. +func TestChatDrawCache_BoundedSize(t *testing.T) { + t.Parallel() + + u := newTestUI() + w, h := 80, 20 + + // Cycle through several distinct list outputs and confirm that + // drawCache is still a single *chatDrawCache pointing at the most + // recent rendered string each time. + for i := range 5 { + u.chat.SetMessages( + testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)}, + ) + u.updateLayoutAndSize() + _ = renderToBuffer(t, u.chat, w, h) + require.NotNil(t, u.chat.drawCache) + require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered, + "cache.rendered must always match the most recent list output") + } + + // Sanity: the type of drawCache is a single pointer, not a slice/map. + // This is enforced at compile time but checking it explicitly here + // keeps the bounded-size invariant visible to future readers. + require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache) +} + +// freshStyledRender renders the same string through uv.NewStyledString into +// a fresh ScreenBuffer of the given size. It's the reference implementation +// used by the cache tests to detect blit drift. +func freshStyledRender(s string, w, h int) string { + scr := uv.NewScreenBuffer(w, h) + uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h)) + return scr.Render() +} + +// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds +// when the destination screen's width method changes between frames. +// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other +// modern unicode, so the decoded buffer is only valid for the method it +// was decoded with. Reusing across methods would corrupt cell widths. +func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) { + t.Parallel() + + u := newTestUI() + // Use content where the two methods disagree so any accidental + // reuse would surface as a different rendered cell layout. The + // woman-technologist ZWJ sequence is one cell under GraphemeWidth + // but two under WcWidth (the components combine). + u.chat.SetMessages( + testMessageItem{id: "a", text: "hi 👩\u200d💻 there"}, + ) + u.updateLayoutAndSize() + + w, h := 80, 5 + + // First draw under GraphemeWidth. + scrA := uv.ScreenBuffer{ + RenderBuffer: uv.NewRenderBuffer(w, h), + Method: ansi.GraphemeWidth, + } + u.chat.Draw(scrA, uv.Rect(0, 0, w, h)) + require.NotNil(t, u.chat.drawCache) + require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method) + firstCache := u.chat.drawCache + + // Same string, swap the method. The cache key includes method, so + // this must rebuild even though `rendered` is byte-identical. + scrB := uv.ScreenBuffer{ + RenderBuffer: uv.NewRenderBuffer(w, h), + Method: ansi.WcWidth, + } + u.chat.Draw(scrB, uv.Rect(0, 0, w, h)) + require.NotSame(t, firstCache, u.chat.drawCache, + "width method change must invalidate the cache") + require.Equal(t, ansi.WcWidth, u.chat.drawCache.method) +} + +// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non +// ansi.Method) WidthMethod implementation. Chat.Draw is expected to +// detect the type-assertion miss and fall through to the uncached path. +type fixedMethodScreen struct { + uv.ScreenBuffer + method uv.WidthMethod +} + +func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method } + +// customWidth is a WidthMethod whose concrete type is intentionally NOT +// ansi.Method, so the type assertion in Chat.Draw fails. The actual +// width math just delegates to ansi.GraphemeWidth so the rendered +// output is well-defined and comparable. +type customWidth struct{} + +func (customWidth) StringWidth(s string) int { + return ansi.GraphemeWidth.StringWidth(s) +} + +// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen +// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw +// skips the cache (it has no comparable key for an arbitrary interface) +// and falls through to a direct uv.NewStyledString.Draw, producing the +// same output as the upstream uncached path. +func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) { + t.Parallel() + + u := newTestUI() + u.chat.SetMessages( + testMessageItem{id: "a", text: "alpha"}, + testMessageItem{id: "b", text: "beta"}, + ) + u.updateLayoutAndSize() + + w, h := 40, 10 + scr := fixedMethodScreen{ + ScreenBuffer: uv.ScreenBuffer{ + RenderBuffer: uv.NewRenderBuffer(w, h), + Method: ansi.GraphemeWidth, + }, + method: customWidth{}, + } + // Sanity: the wrapper actually returns a non-ansi.Method type so + // the fallback path is exercised end-to-end. + _, isAnsiMethod := scr.WidthMethod().(ansi.Method) + require.False(t, isAnsiMethod, + "test setup must hand Draw a non-ansi.Method WidthMethod") + + u.chat.Draw(scr, uv.Rect(0, 0, w, h)) + + // The fallback path uses uv.NewStyledString(rendered).Draw — same + // thing freshStyledRender does — so output must match the + // reference render byte-for-byte. + want := freshStyledRender(u.chat.list.Render(), w, h) + require.Equal(t, want, scr.Render()) + + // And no cache was populated (or if it was, it wasn't used for + // this draw). Either way: drawing through the fallback must never + // leave a stale cache that future draws would reuse incorrectly. + require.Nil(t, u.chat.drawCache, + "fallback path must not populate the cache") +} + +// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant +// that renderedBounds (used to size the cache buffer) returns the same +// width as the cell tally StyledString.Draw will write into a buffer +// using the same WidthMethod. We compare the computed width to a +// reference render: lay the string into an oversized buffer, then count +// non-empty cells per row. A divergent-width sample (woman-technologist +// ZWJ sequence) is the canonical case that would expose a Bounds() +// vs printString mismatch — under GraphemeWidth the sequence is one +// cell, under WcWidth it's two — so the same string must produce two +// different widths under the two methods, both matching the tally. +func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) { + t.Parallel() + + const sample = "x 👩\u200d💻 y" + + for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} { + w, h := renderedBounds(sample, m) + // Tally what printString would actually write. + // Use an oversized buffer so nothing gets clipped. + scr := uv.ScreenBuffer{ + RenderBuffer: uv.NewRenderBuffer(64, 4), + Method: m, + } + uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4)) + + // Tally width: rightmost non-empty cell + its width, per row. + gotW := 0 + gotH := 0 + for y := 0; y < 4; y++ { + rowW := 0 + rowHasContent := false + for x := 0; x < 64; x++ { + cell := scr.CellAt(x, y) + if cell == nil || cell.IsZero() || + cell.Content == " " && cell.Width == 1 && + cell.Style == (uv.Style{}) { + continue + } + rowHasContent = true + if x+cell.Width > rowW { + rowW = x + cell.Width + } + } + if rowHasContent { + gotH = y + 1 + if rowW > gotW { + gotW = rowW + } + } + } + + require.Equal(t, gotW, w, + "renderedBounds width must match printString cell tally for method %v", m) + // Height is just '\n' count + 1 — no special-casing needed + // here, but pin it so future regressions on multi-line + // inputs don't sneak by. + require.Equal(t, gotH, h, + "renderedBounds height must match printString row tally for method %v", m) + } +} + +// Compile-time guard: testMessageItem is reused from layout_test.go and +// satisfies chat.MessageItem there. We rely on the same shape here. +var _ chat.MessageItem = testMessageItem{}