perf(ui): memoize the input-field caret prefix width (#128) #146

Merged
catbot merged 1 commit from claude/issue-128 into master 2026-06-18 15:30:33 +02:00
Member

Problem

For a focused, caret-visible field, DrawInputField walked the whole cursor prefix via Font::GetLineWidth every frame, even though only caretVisible (the blink) changes frame-to-frame. value, cursorPos and fontSize are stable across the vast majority of frames, so the per-glyph UTF-8 decode + advance accumulation was repeated for no reason. (implementations/Crafter.Graphics-InputField.cpp:151-153)

Fix

Memoize the measured prefix width on the InputField, keyed on (prefix bytes, fontSize). A cheap byte compare of the cached prefix guards the expensive GetLineWidth call; on a hit the stored width is reused. The cache members are mutable since DrawInputField takes const InputField&.

The absolute caretX is deliberately not cached — it adds the layout-dependent textX (rect.x + paddingX) each frame, so caching caretX would hand a relocated field a stale caret. Caching only the width keeps the result correct regardless of where the field is drawn.

Tests

Adds tests/InputFieldCaretCache (registered in project.cpp, pure CPU — DrawText no-ops with a null atlas/renderer so only the bg + caret quads are emitted, no Vulkan device needed). It pins:

  • the cached caret x matches the uncached GetLineWidth oracle across many (value, cursorPos, fontSize) states;
  • a redraw (guaranteed cache hit) is byte-identical to the cache miss;
  • the cache invalidates on value (byte-level, not just length), cursorPos, and fontSize changes;
  • the blink (caretVisible toggle) never moves the caret;
  • moving the field on screen shifts the caret by exactly the rect delta (proving the width, not caretX, is cached);
  • white-box: the memo is actually populated after a draw.

Full suite green: 24 passed.

Resolves #128

🤖 Generated with Claude Code

## Problem For a focused, caret-visible field, `DrawInputField` walked the whole cursor prefix via `Font::GetLineWidth` **every frame**, even though only `caretVisible` (the blink) changes frame-to-frame. `value`, `cursorPos` and `fontSize` are stable across the vast majority of frames, so the per-glyph UTF-8 decode + advance accumulation was repeated for no reason. (`implementations/Crafter.Graphics-InputField.cpp:151-153`) ## Fix Memoize the measured prefix **width** on the `InputField`, keyed on `(prefix bytes, fontSize)`. A cheap byte compare of the cached prefix guards the expensive `GetLineWidth` call; on a hit the stored width is reused. The cache members are `mutable` since `DrawInputField` takes `const InputField&`. The absolute `caretX` is deliberately **not** cached — it adds the layout-dependent `textX` (`rect.x + paddingX`) each frame, so caching `caretX` would hand a relocated field a stale caret. Caching only the width keeps the result correct regardless of where the field is drawn. ## Tests Adds `tests/InputFieldCaretCache` (registered in `project.cpp`, pure CPU — `DrawText` no-ops with a null atlas/renderer so only the bg + caret quads are emitted, no Vulkan device needed). It pins: - the cached caret x matches the uncached `GetLineWidth` oracle across many `(value, cursorPos, fontSize)` states; - a redraw (guaranteed cache hit) is byte-identical to the cache miss; - the cache invalidates on `value` (byte-level, not just length), `cursorPos`, and `fontSize` changes; - the blink (`caretVisible` toggle) never moves the caret; - moving the field on screen shifts the caret by exactly the rect delta (proving the width, not `caretX`, is cached); - white-box: the memo is actually populated after a draw. Full suite green: `24 passed`. Resolves #128 🤖 Generated with [Claude Code](https://claude.com/claude-code)
DrawInputField re-measured the cursor prefix via Font::GetLineWidth on
every frame of a focused field, even though only the blink (caretVisible)
changes frame-to-frame — value, cursorPos and fontSize are stable across
the vast majority of frames.

Cache the measured prefix WIDTH on the InputField, keyed on (prefix bytes,
fontSize). A cheap byte compare of the cached prefix guards the expensive
per-glyph UTF-8 decode + advance accumulation. The cache is mutable so the
const-ref draw fn can refresh it.

The absolute caretX is deliberately NOT cached: it adds the layout-
dependent textX (rect.x + paddingX) each frame, so caching it would give a
relocated field a stale caret. Caching the width keeps that correct.

Adds tests/InputFieldCaretCache pinning: the cached caret matches the
uncached GetLineWidth oracle across states, a redraw (cache hit) is
identical to the miss, the cache invalidates on value/cursorPos/fontSize
changes, the blink never moves the caret, and a relocated field shifts the
caret by exactly the rect delta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot merged commit 9cf972016c into master 2026-06-18 15:30:33 +02:00
catbot deleted branch claude/issue-128 2026-06-18 15:30:33 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!146
No description provided.