//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 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 quads{}; std::uint32_t count = 0; UIBuffer buf; buf.quads = quads.data(); buf.quadCount = &count; buf.quadCap = static_cast(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::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(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 samples = { "Hello, world", "1234567890", "iiiiWWWWmmmm", "caf\xC3\xA9 na\xC3\xAFve \xE2\x9C\x93", // "café naïve ✓" — multibyte }; const std::array 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(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; }