diff --git a/implementations/Crafter.Graphics-InputField.cpp b/implementations/Crafter.Graphics-InputField.cpp index 433fddf..c527cdd 100644 --- a/implementations/Crafter.Graphics-InputField.cpp +++ b/implementations/Crafter.Graphics-InputField.cpp @@ -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(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(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); diff --git a/interfaces/Crafter.Graphics-InputField.cppm b/interfaces/Crafter.Graphics-InputField.cppm index 0812858..b05ab38 100644 --- a/interfaces/Crafter.Graphics-InputField.cppm +++ b/interfaces/Crafter.Graphics-InputField.cppm @@ -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 diff --git a/project.cpp b/project.cpp index f0ec1e9..82fc66b 100644 --- a/project.cpp +++ b/project.cpp @@ -863,6 +863,38 @@ extern "C" Configuration CrafterBuildProject(std::span 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 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 — diff --git a/tests/InputFieldCaretCache/font.ttf b/tests/InputFieldCaretCache/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/InputFieldCaretCache/font.ttf differ diff --git a/tests/InputFieldCaretCache/main.cpp b/tests/InputFieldCaretCache/main.cpp new file mode 100644 index 0000000..a407017 --- /dev/null +++ b/tests/InputFieldCaretCache/main.cpp @@ -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 + +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; +}