perf(input-field): O(n) cursor hit test via Font::NearestCursorByte (#56)

InputField_HitTestCursor mapped a click x to a cursor byte offset by
calling Font::GetLineWidth on every prefix value[0..i] for i=1..size.
Each call re-walks from byte 0, so the hit test was O(n^2) glyph-metric
lookups (each an uncached stbtt cmap binary search). It also iterated
raw byte boundaries, so on multibyte UTF-8 input it could return an
offset splitting a codepoint — an invalid position for the byte-based
cursorPos.

Add Font::NearestCursorByte: one left-to-right cumulative-advance pass
(O(n) metrics, scale computed once) over codepoint boundaries, returning
the byte offset of the boundary nearest the target. Cumulative advance is
monotonic, so the scan stops past the closest boundary. The hit test now
delegates to it.

Adds tests/InputFieldHitTest, a pure-CPU regression test (no Vulkan
device/window): a px sweep across ASCII and multibyte strings compared
against an independent brute-force nearest-boundary oracle, plus the
left/right/empty/end-of-text edge cases and a codepoint-boundary invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-16 16:55:03 +00:00
commit d712218f17
6 changed files with 280 additions and 16 deletions

View file

@ -72,6 +72,30 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
return lineWidth;
}
std::size_t Font::NearestCursorByte(std::string_view text, float size, float target) {
if (target <= 0.0f) return 0;
float scale = stbtt_ScaleForPixelHeight(&font, size);
std::size_t best = 0; // byte offset 0 → caret before the first glyph
float bestDist = target; // |target - 0|, and target > 0 here
float cumWidth = 0.0f;
std::size_t i = 0;
while (i < text.size()) {
std::uint32_t cp = DecodeUtf8(text, i); // i advances to the next boundary
if (cp == 0) break;
int advance, lsb;
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
cumWidth += (int)(advance * scale); // match GetLineWidth's per-glyph rounding
float d = std::abs(target - cumWidth);
if (d < bestDist) {
bestDist = d;
best = i; // byte offset just past this codepoint
} else {
break; // monotonic advance ⇒ already past the closest boundary
}
}
return best;
}
float Font::LineHeight(float size) {
float scale = stbtt_ScaleForPixelHeight(&font, size);
return (ascent - descent + lineGap) * scale;

View file

@ -117,23 +117,12 @@ std::size_t Crafter::InputField_HitTestCursor(const InputField& f,
Font& font, float fontSize,
const InputFieldColors& colors)
{
// Single O(n) cumulative-advance pass over the value, keyed at codepoint
// byte boundaries — matching cursorPos, which is a byte offset. The old
// loop re-walked the prefix for every boundary (O(n^2) glyph metric
// lookups) and could even land mid-codepoint on multibyte input.
float target = clickX - (rect.x + colors.paddingX);
if (target <= 0.0f) return 0;
std::size_t best = 0;
float bestDist = std::abs(target);
for (std::size_t i = 1; i <= f.value.size(); ++i) {
std::string_view sub(f.value.data(), i);
float w = static_cast<float>(font.GetLineWidth(sub, fontSize));
float d = std::abs(target - w);
if (d < bestDist) {
bestDist = d;
best = i;
} else {
break;
}
}
return best;
return font.NearestCursorByte(f.value, fontSize, target);
}
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,