Crafter.Graphics/tests/InputFieldCaretCache/main.cpp

232 lines
9.5 KiB
C++

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// 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;
}