perf(ui): memoize the input-field caret prefix width (#128) #146
5 changed files with 309 additions and 1 deletions
perf(ui): memoize the input-field caret prefix width (#128)
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>
commit
a45e793da4
|
|
@ -150,7 +150,21 @@ void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,
|
|||
|
||||
if (f.focused && caretVisible && *buf.quadCount < buf.quadCap) {
|
||||
std::string_view sub(f.value.data(), std::min(f.cursorPos, f.value.size()));
|
||||
float caretX = textX + (sub.empty() ? 0.0f : static_cast<float>(font.GetLineWidth(sub, fontSize)));
|
||||
// Memoise the prefix width (issue #128): only the blink changes between
|
||||
// frames, so re-walking the prefix every frame is wasted work. A byte
|
||||
// compare of the cached prefix (plus fontSize) is far cheaper than the
|
||||
// per-glyph UTF-8 decode + advance accumulation GetLineWidth does.
|
||||
float width;
|
||||
if (f.caretCacheFontSize_ == fontSize && f.caretCachePrefix_ == sub) {
|
||||
width = f.caretCacheWidth_;
|
||||
} else {
|
||||
width = sub.empty() ? 0.0f
|
||||
: static_cast<float>(font.GetLineWidth(sub, fontSize));
|
||||
f.caretCachePrefix_.assign(sub);
|
||||
f.caretCacheFontSize_ = fontSize;
|
||||
f.caretCacheWidth_ = width;
|
||||
}
|
||||
float caretX = textX + width;
|
||||
float caretH = fontSize * 1.1f;
|
||||
float caretY = rect.y + (rect.h - caretH) * 0.5f;
|
||||
float caretW = std::max(1.0f, fontSize / 16.0f);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@ export namespace Crafter {
|
|||
InputFieldType type = InputFieldType::Text;
|
||||
bool focused = false;
|
||||
std::size_t cursorPos = 0; // byte offset into `value`
|
||||
|
||||
// Caret-x memo (issue #128). For a focused field DrawInputField measures
|
||||
// the cursor prefix via Font::GetLineWidth every frame, but only the
|
||||
// blink (caretVisible) changes frame-to-frame — value/cursorPos/fontSize
|
||||
// are stable across the vast majority of frames. Cache the measured
|
||||
// prefix WIDTH (the GetLineWidth result, in px) keyed on the inputs that
|
||||
// determine it: the prefix bytes and fontSize. The final caretX adds the
|
||||
// layout-dependent textX each frame, so it is intentionally NOT cached —
|
||||
// a field that moves on screen with an unchanged value would otherwise
|
||||
// get a stale caret. `mutable` so the `const InputField&` draw fn can
|
||||
// refresh it; these fields are internal and need no user initialisation.
|
||||
mutable std::string caretCachePrefix_;
|
||||
mutable float caretCacheFontSize_ = -1.0f; // <0 = unset
|
||||
mutable float caretCacheWidth_ = 0.0f;
|
||||
};
|
||||
|
||||
// Returns true if `s` is a valid candidate for the given type, including
|
||||
|
|
|
|||
32
project.cpp
32
project.cpp
|
|
@ -863,6 +863,38 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
hc.GetInterfacesAndImplementations(ifaces, hitImpls);
|
||||
cfg.tests.push_back(std::move(hitTest));
|
||||
|
||||
// Issue #128: DrawInputField re-measured the cursor prefix via
|
||||
// Font::GetLineWidth every frame of a focused field even though only the
|
||||
// blink changes frame-to-frame. The prefix WIDTH is now memoised on the
|
||||
// InputField keyed on (prefix bytes, fontSize); the absolute caretX is
|
||||
// deliberately not cached so a relocated field can't get a stale caret.
|
||||
// The memo is transparent presentation logic over a TrueType file —
|
||||
// DrawText no-ops with a null atlas/renderer — so this test drives
|
||||
// DrawInputField directly with no Vulkan device or window. The font is
|
||||
// copied next to the binary; the test also probes the project root.
|
||||
Test caretTest;
|
||||
Configuration& crc = caretTest.config;
|
||||
crc.path = cfg.path;
|
||||
crc.name = "InputFieldCaretCache";
|
||||
crc.outputName = "InputFieldCaretCache";
|
||||
crc.type = ConfigurationType::Executable;
|
||||
crc.target = cfg.target;
|
||||
crc.march = cfg.march;
|
||||
crc.mtune = cfg.mtune;
|
||||
crc.debug = cfg.debug;
|
||||
crc.sysroot = cfg.sysroot;
|
||||
crc.dependencies = cfg.dependencies;
|
||||
crc.externalDependencies = cfg.externalDependencies;
|
||||
crc.compileFlags = cfg.compileFlags;
|
||||
crc.linkFlags = cfg.linkFlags;
|
||||
crc.defines = cfg.defines;
|
||||
crc.cFiles = cfg.cFiles;
|
||||
crc.files = { fs::path("tests/InputFieldCaretCache/font.ttf") };
|
||||
std::vector<fs::path> caretImpls(impls.begin(), impls.end());
|
||||
caretImpls.emplace_back("tests/InputFieldCaretCache/main");
|
||||
crc.GetInterfacesAndImplementations(ifaces, caretImpls);
|
||||
cfg.tests.push_back(std::move(caretTest));
|
||||
|
||||
// Issue #69: the engine feeds one shared Device::pipelineCache to every
|
||||
// vkCreate*Pipelines call and persists it across runs, discarding an
|
||||
// on-disk blob whose header doesn't match the current GPU. That gate —
|
||||
|
|
|
|||
BIN
tests/InputFieldCaretCache/font.ttf
Normal file
BIN
tests/InputFieldCaretCache/font.ttf
Normal file
Binary file not shown.
248
tests/InputFieldCaretCache/main.cpp
Normal file
248
tests/InputFieldCaretCache/main.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
Crafter®.Graphics
|
||||
Copyright (C) 2026 Catcrafts®
|
||||
catcrafts.net
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License version 3.0 as published by the Free Software Foundation;
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
// Regression test for issue #128: DrawInputField re-walked the cursor prefix
|
||||
// via Font::GetLineWidth on every frame of a focused field, even though only
|
||||
// the blink (caretVisible) changes frame-to-frame. The fix memoises the prefix
|
||||
// WIDTH on the InputField, keyed on (prefix bytes, fontSize) — a cheap byte
|
||||
// compare guards the expensive per-glyph advance accumulation.
|
||||
//
|
||||
// The cache must be transparent: the emitted caret quad has to sit at exactly
|
||||
// the same x as the uncached `textX + GetLineWidth(prefix)` oracle, in every
|
||||
// state. This test pins:
|
||||
// - the caret x matches the oracle across many (value, cursorPos, fontSize)
|
||||
// states, on both the first draw (cache miss) and an immediate redraw
|
||||
// (cache hit) — a hit must be byte-identical to a miss,
|
||||
// - the cache invalidates correctly when value, cursorPos, or fontSize change
|
||||
// (no stale caret after an edit),
|
||||
// - the blink (caretVisible toggling) never moves the caret,
|
||||
// - moving the field on screen (rect.x) with an unchanged value still moves
|
||||
// the caret by the same delta — the WIDTH is cached, not the absolute
|
||||
// caretX, so a relocated field can't get a stale caret,
|
||||
// - the internal cache fields hold the measured prefix/fontSize/width after a
|
||||
// draw (white-box: the memo is actually populated).
|
||||
//
|
||||
// Pure CPU + a TrueType file — DrawText no-ops with a null atlas/renderer, so
|
||||
// only the background + caret quads are emitted and no Vulkan device or window
|
||||
// is needed. The font is copied next to the binary; the test also probes the
|
||||
// project-root path.
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
import Crafter.Graphics;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
std::filesystem::path FindFont() {
|
||||
for (const char* cand : {
|
||||
"font.ttf",
|
||||
"tests/InputFieldCaretCache/font.ttf",
|
||||
"../../examples/HelloUI/font.ttf" }) {
|
||||
if (std::filesystem::exists(cand)) return cand;
|
||||
}
|
||||
return "font.ttf";
|
||||
}
|
||||
|
||||
const InputFieldColors kColors{
|
||||
{0, 0, 0, 1}, {0, 0, 0, 1}, {0, 0, 0, 1}, {0, 0, 0, 1},
|
||||
{1, 1, 1, 1}, {1, 1, 1, 1},
|
||||
};
|
||||
|
||||
constexpr float kEps = 1e-3f;
|
||||
|
||||
// Draw the field into a fresh quad buffer and return the caret quad's x, or
|
||||
// NaN if no caret was emitted (not focused / not visible). The caret is the
|
||||
// quad emitted after the background quad. atlas/renderer left null so DrawText
|
||||
// is a no-op — only the bg + caret quads land.
|
||||
float CaretX(const InputField& f, Rect rect, Font& font, float fontSize,
|
||||
bool caretVisible) {
|
||||
std::array<QuadItem, 8> quads{};
|
||||
std::uint32_t count = 0;
|
||||
UIBuffer buf;
|
||||
buf.quads = quads.data();
|
||||
buf.quadCount = &count;
|
||||
buf.quadCap = static_cast<std::uint32_t>(quads.size());
|
||||
|
||||
DrawInputField(buf, f, rect, font, fontSize, kColors, caretVisible);
|
||||
|
||||
// bg quad always emitted (count>=1); caret quad, if any, is the last one.
|
||||
if (count < 2) return std::numeric_limits<float>::quiet_NaN();
|
||||
return quads[count - 1].x;
|
||||
}
|
||||
|
||||
// Uncached oracle: where the caret should sit, computed straight from
|
||||
// GetLineWidth on the prefix — the exact arithmetic DrawInputField uses, with
|
||||
// no memo in the path.
|
||||
float OracleCaretX(const InputField& f, Rect rect, Font& font, float fontSize) {
|
||||
float textX = rect.x + kColors.paddingX;
|
||||
std::string_view sub(f.value.data(), std::min(f.cursorPos, f.value.size()));
|
||||
float w = sub.empty() ? 0.0f
|
||||
: static_cast<float>(font.GetLineWidth(sub, fontSize));
|
||||
return textX + w;
|
||||
}
|
||||
|
||||
bool Near(float a, float b) { return std::abs(a - b) <= kEps; }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
Font font(FindFont());
|
||||
|
||||
const float rectX = 30.0f;
|
||||
const std::array<std::string_view, 4> samples = {
|
||||
"Hello, world",
|
||||
"1234567890",
|
||||
"iiiiWWWWmmmm",
|
||||
"caf\xC3\xA9 na\xC3\xAFve \xE2\x9C\x93", // "café naïve ✓" — multibyte
|
||||
};
|
||||
const std::array<float, 3> sizes = {12.0f, 18.0f, 27.5f};
|
||||
|
||||
// 1. Across every (value, cursorPos, fontSize), the cached caret matches the
|
||||
// uncached oracle — and a redraw (guaranteed cache hit) matches the first
|
||||
// draw exactly.
|
||||
bool sweepOk = true, hitMatchesMiss = true;
|
||||
for (std::string_view s : samples) {
|
||||
for (float size : sizes) {
|
||||
// Walk every codepoint boundary as a cursor position.
|
||||
for (std::size_t pos = 0; pos <= s.size(); ++pos) {
|
||||
InputField f;
|
||||
f.value = std::string(s);
|
||||
f.focused = true;
|
||||
f.cursorPos = pos;
|
||||
Rect rect{rectX, 0.0f, 200.0f, 24.0f};
|
||||
|
||||
float miss = CaretX(f, rect, font, size, true); // populates cache
|
||||
float hit = CaretX(f, rect, font, size, true); // reads cache
|
||||
float oracle = OracleCaretX(f, rect, font, size);
|
||||
|
||||
if (!Near(miss, oracle)) {
|
||||
sweepOk = false;
|
||||
std::println(" caret mismatch \"{}\" pos={} size={}: got={} oracle={}",
|
||||
s, pos, size, miss, oracle);
|
||||
}
|
||||
if (!Near(miss, hit)) hitMatchesMiss = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(sweepOk, "cached caret x matches the uncached GetLineWidth oracle across states");
|
||||
Check(hitMatchesMiss, "a cache hit (redraw) is byte-identical to the cache miss");
|
||||
|
||||
// 2. The cache invalidates on each keyed input. Reuse ONE field across the
|
||||
// mutations so a stale memo would survive into the next draw if the key
|
||||
// check were wrong.
|
||||
{
|
||||
InputField f;
|
||||
f.value = "abcdef";
|
||||
f.focused = true;
|
||||
Rect rect{rectX, 0.0f, 200.0f, 24.0f};
|
||||
constexpr float size = 18.0f;
|
||||
|
||||
f.cursorPos = 3;
|
||||
(void) CaretX(f, rect, font, size, true); // prime cache at (abcdef,3,18)
|
||||
|
||||
// 2a. cursorPos change.
|
||||
f.cursorPos = 6;
|
||||
Check(Near(CaretX(f, rect, font, size, true), OracleCaretX(f, rect, font, size)),
|
||||
"caret updates when cursorPos changes (cache key includes cursorPos)");
|
||||
|
||||
// 2b. value change with the SAME cursorPos and a prefix of the same
|
||||
// length — only the bytes differ, so a length-only key would miss
|
||||
// this. Widen a glyph to force a different width.
|
||||
f.value = "abcWWW";
|
||||
f.cursorPos = 6;
|
||||
Check(Near(CaretX(f, rect, font, size, true), OracleCaretX(f, rect, font, size)),
|
||||
"caret updates when value bytes change (cache key compares bytes, not length)");
|
||||
|
||||
// 2c. fontSize change with value/cursor fixed.
|
||||
float small = CaretX(f, rect, font, 12.0f, true);
|
||||
float big = CaretX(f, rect, font, 27.5f, true);
|
||||
Check(Near(small, OracleCaretX(f, rect, font, 12.0f)) &&
|
||||
Near(big, OracleCaretX(f, rect, font, 27.5f)) &&
|
||||
!Near(small, big),
|
||||
"caret updates when fontSize changes (cache key includes fontSize)");
|
||||
}
|
||||
|
||||
// 3. The blink must never move the caret: drawing with caretVisible=true,
|
||||
// then false (no caret), then true again yields the same x.
|
||||
{
|
||||
InputField f;
|
||||
f.value = "blink test";
|
||||
f.focused = true;
|
||||
f.cursorPos = 5;
|
||||
Rect rect{rectX, 0.0f, 200.0f, 24.0f};
|
||||
constexpr float size = 18.0f;
|
||||
|
||||
float on1 = CaretX(f, rect, font, size, true);
|
||||
float off = CaretX(f, rect, font, size, false); // caret suppressed
|
||||
float on2 = CaretX(f, rect, font, size, true);
|
||||
Check(std::isnan(off) && Near(on1, on2) && Near(on1, OracleCaretX(f, rect, font, size)),
|
||||
"blink (caretVisible toggle) does not move or stale the caret");
|
||||
}
|
||||
|
||||
// 4. Moving the field on screen with an UNCHANGED value moves the caret by
|
||||
// exactly the rect delta. This is the guard for caching the prefix WIDTH
|
||||
// rather than the absolute caretX — a memoised caretX would go stale here.
|
||||
{
|
||||
InputField f;
|
||||
f.value = "relocate";
|
||||
f.focused = true;
|
||||
f.cursorPos = 8;
|
||||
constexpr float size = 18.0f;
|
||||
|
||||
Rect a{rectX, 0.0f, 200.0f, 24.0f};
|
||||
Rect b{rectX + 137.0f, 0.0f, 200.0f, 24.0f};
|
||||
float xa = CaretX(f, a, font, size, true); // primes cache
|
||||
float xb = CaretX(f, b, font, size, true); // same value → cache hit on width
|
||||
Check(Near(xb - xa, 137.0f) && Near(xb, OracleCaretX(f, b, font, size)),
|
||||
"moving the field shifts the caret by the rect delta (width cached, not caretX)");
|
||||
}
|
||||
|
||||
// 5. White-box: after a draw the memo holds the measured prefix, fontSize
|
||||
// and width — confirming the fast path is actually wired, not dead code.
|
||||
{
|
||||
InputField f;
|
||||
f.value = "memo check";
|
||||
f.focused = true;
|
||||
f.cursorPos = 4;
|
||||
Rect rect{rectX, 0.0f, 200.0f, 24.0f};
|
||||
constexpr float size = 18.0f;
|
||||
(void) CaretX(f, rect, font, size, true);
|
||||
|
||||
std::string_view sub(f.value.data(), f.cursorPos);
|
||||
float w = static_cast<float>(font.GetLineWidth(sub, size));
|
||||
Check(f.caretCachePrefix_ == sub &&
|
||||
f.caretCacheFontSize_ == size &&
|
||||
Near(f.caretCacheWidth_, w),
|
||||
"draw populates the caret memo with the measured prefix/fontSize/width");
|
||||
}
|
||||
|
||||
if (failures == 0) std::println("\nAll InputField caret-cache checks passed.");
|
||||
else std::println("\n{} InputField caret-cache check(s) FAILED.", failures);
|
||||
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue