Skip to content

perf(textarea): Make typing hot paths allocation-free - #1033

Open
reneleonhardt wants to merge 7 commits into
charmbracelet:mainfrom
reneleonhardt:perf/textarea-hot-paths
Open

perf(textarea): Make typing hot paths allocation-free#1033
reneleonhardt wants to merge 7 commits into
charmbracelet:mainfrom
reneleonhardt:perf/textarea-hot-paths

Conversation

@reneleonhardt

Copy link
Copy Markdown

Summary

The textarea's per-keystroke and per-render hot paths were doing more work than the content deserved: soft-wrapping rebuilt strings on every word boundary, every cached-wrap lookup re-hashed the whole line, and the length/value/placeholder checks re-measured or re-materialized the entire buffer. This branch makes those paths allocation-free while keeping output identical (plus two internal behavior fixes: a zero-value model's Length() reported -1, and CharLimit is now enforced in cells).

Changes

  • Wrap + line-lookup caching rebuilt allocation-free. Word wrapping now accumulates rows in reusable buffers with zero-copy width views instead of allocating a string per word, and the wrap cache is keyed by an allocation-free content hash instead of SHA-256 over the whole line. The wrapping algorithm is unchanged — parity and cursor tests pin it.
  • Per-key length, value and placeholder checks made exact and cheap. The CharLimit length walk and the placeholder emptiness test now short-circuit for the common cases (printable ASCII, empty buffer) without materializing or measuring more than needed, and the value rebuild pre-grows its buffer so multi-line content is written once.
  • Zero-value models no longer report a negative length. An uninitialized model's Length() returned -1 (len(m.value) - 1 on nil); it now returns 0, pinned by a test.
  • CharLimit is now enforced in cells. Paste truncation previously cut by rune count while Length() counts cells, letting wide (CJK) content overshoot the limit by up to ~2x; truncation now walks cell width. ASCII behavior is unchanged.

Validation

Parity/cursor tests (wrap vs the line grid, every cursor position) plus a corpus/fuzz covering ASCII, CJK, ambiguous-width, trailing-space, and empty inputs; tests for the new short-circuits. Full ./textarea/... suite green, -race clean, gofmt/vet clean.

Performance (Apple M4 Max, committed benches perf_bench_test.go + wrap_test.go)

Benchmark main tip time bytes allocs
wrap, average prompt 18.5us / 2.3KB / 52 14.7us / 680B / 20 -21% -70% 52 -> 20
wrap, ~2k line 569us / 64KB / 1,375 452us / 16.6KB / 451 -20% -74% 1,375 -> 451
line.Hash, 20k line 89.4us / 62KB / 7 30.8us / 122B / 1 -66% -99.8% 7 -> 1
Length, ~2KB ASCII 34.0us / 2.6KB / 1 0.84us / 519B / 0 -97.5% -80% 1 -> 0
Value, 20-line buffer 6.1us / 5.9KB / 26 6.0us / 3.5KB / 21 ~0% -40% 26 -> 21

At the 20k-char paste limit: wrap() 5.75ms -> 4.54ms (-21%), 647KB -> 163KB (-75%), 13,713 -> 4,450 allocs (-68%).

Breaking changes

None (wrap/hash/value outputs are identical; the ASCII short-circuits preserve exact widths; the zero-model Length() guard changes only an uninitialized model's -1 to 0).

Notes

Commits are only split for reviewability, Squash and merge allows a cleaner history.

Developed with carefully directed, manually reviewed AI assistance.

Word wrapping rebuilt a fresh string on every word boundary, and every
cached-wrap lookup re-hashed the whole line with SHA-256. Wrap now
accumulates rows in reusable buffers with zero-copy width views and
materializes the result once at the end, keeping the algorithm
byte-identical (parity + cursor tests pin it); the cache key is an
allocation-free FNV-1a hash of runes+width.
…le buffer

The View placeholder path materialized the entire buffer string just to
test whether it was empty. The check is now an exact O(1) equivalent
(Value() is empty only when there is at most one line with no runes; the
inter-line newlines count), so an all-empty multiline buffer is still
non-empty — test-pinned.
The CharLimit walk re-measured every line through the grapheme machinery
even for pure ASCII. Printable ASCII runes are width 1, so the count is
exact by rune count for 0x20-0x7E; control (width 0) and wide runes fall
back to the exact path.
…g the buffer

Value() grew its builder from zero, so a multi-line buffer was reallocated
and copied several times over per rebuild. Pre-growing to the total rune
count plus newlines writes the buffer once.
Length() computed len(m.value) - 1 with no nil guard, so an uninitialized
model reported -1. Return 0 instead; the test pins Value()/Length()
early-return behavior for a zero-value model.
Add benchmarks for word wrap (average prompt, 2k, and 20k lines), the
line hash, the ASCII length check, and the multi-line value rebuild, so
the hot paths' timing and allocation behavior can be verified and
compared over time.
Length() counts cells, but paste truncation cut by rune count, so wide
(CJK) content could overshoot CharLimit by up to ~2x. Truncate by cell
width now; ASCII behavior is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant