/* 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 #56: InputField_HitTestCursor used to map a click // x-coord 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 it // was O(n^2) glyph-metric lookups. Worse, it iterated raw byte boundaries, so // on multibyte UTF-8 input it could return an offset that splits a codepoint // (an invalid cursor position, since cursorPos is a byte offset edited via // value.erase(cursorPos, 1)). // // The hit test now delegates to Font::NearestCursorByte: one left-to-right // cumulative-advance pass (O(n) metrics) over codepoint boundaries, returning // the byte offset of the boundary whose cumulative advance is nearest the // target. This test pins: // - the result matches an independent brute-force "nearest codepoint // boundary" reference (built from per-prefix GetLineWidth) for many click // positions across ASCII and multibyte strings, // - clicks at/left of the text start return 0, clicks far past the end // return value.size() (the end-of-text caret), // - every returned offset lands on a codepoint boundary (never mid-sequence), // - clicking exactly on a glyph's left edge lands on that glyph's boundary. // // Pure CPU + a TrueType file — no Vulkan device or window 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/InputFieldHitTest/font.ttf", "../../examples/HelloUI/font.ttf" }) { if (std::filesystem::exists(cand)) return cand; } return "font.ttf"; } // All codepoint-boundary byte offsets of `s`, including 0 and s.size(). std::vector Boundaries(std::string_view s) { std::vector b{0}; std::size_t i = 0; while (i < s.size()) { std::uint32_t cp = DecodeUtf8(s, i); // advances i to the next boundary if (cp == 0) break; b.push_back(i); } return b; } // Independent reference: the codepoint boundary whose prefix advance is // nearest `target`, ties resolved to the earlier boundary (matching the // implementation's strict `d < bestDist`). Built with per-prefix GetLineWidth // — the slow path the optimisation replaces, used here only as the oracle. std::size_t NearestBoundaryRef(Font& font, std::string_view s, float size, float target) { if (target <= 0.0f) return 0; auto bounds = Boundaries(s); std::size_t best = 0; float bestDist = std::abs(target); // boundary 0 has width 0 for (std::size_t k = 1; k < bounds.size(); ++k) { float w = static_cast(font.GetLineWidth(s.substr(0, bounds[k]), size)); float d = std::abs(target - w); if (d < bestDist) { bestDist = d; best = bounds[k]; } // No early break: the reference scans every boundary regardless, so a // mismatch would also catch any over-eager break in the implementation. } return best; } bool IsBoundary(std::string_view s, std::size_t off) { if (off > s.size()) return false; for (std::size_t b : Boundaries(s)) if (b == off) return true; return false; } 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}, }; // Drive the public hit-test for a click at window-x `clickX`, given a field // rect at `rectX`. paddingX is taken from kColors. std::size_t Hit(const InputField& f, Font& font, float size, float rectX, float clickX) { Rect rect{rectX, 0.0f, 200.0f, 24.0f}; return InputField_HitTestCursor(f, rect, clickX, font, size, kColors); } } // namespace int main() { Font font(FindFont()); constexpr float kSize = 18.0f; const float rectX = 30.0f; const float textStart = rectX + kColors.paddingX; // x where the text begins const std::array samples = { "Hello, world", "1234567890", "iiiiWWWWmmmm", // wildly varying advances "caf\xC3\xA9 na\xC3\xAFve \xE2\x9C\x93", // "café naïve ✓" — 2- and 3-byte cps }; // 1. Across each sample, sweep target px and compare to the oracle. bool sweepOk = true; bool allBoundaries = true; for (std::string_view s : samples) { InputField f; f.type = InputFieldType::Text; f.value = std::string(s); float fullW = static_cast(font.GetLineWidth(s, kSize)); // Step finely enough to cross every glyph edge several times, and run // past the end of the text to exercise the end-of-text caret. for (float t = -10.0f; t <= fullW + 40.0f; t += 1.0f) { std::size_t got = Hit(f, font, kSize, rectX, textStart + t); std::size_t ref = NearestBoundaryRef(font, s, kSize, t); if (got != ref) { sweepOk = false; std::println(" mismatch on \"{}\": target={} got={} ref={}", s, t, got, ref); } if (!IsBoundary(s, got)) allBoundaries = false; } } Check(sweepOk, "hit test matches the brute-force nearest-boundary oracle across a px sweep"); Check(allBoundaries, "every returned offset lands on a UTF-8 codepoint boundary"); // 2. Clicks at or left of the text start return 0. { InputField f; f.value = "Hello"; Check(Hit(f, font, kSize, rectX, textStart) == 0, "click exactly at the text start returns offset 0"); Check(Hit(f, font, kSize, rectX, textStart - 5.0f) == 0, "click left of the text start returns offset 0"); Check(Hit(f, font, kSize, rectX, 0.0f) == 0, "click far to the left of the field returns offset 0"); } // 3. A click far past the end lands on the end-of-text caret (value.size()). { InputField f; f.value = "Hello"; float fullW = static_cast(font.GetLineWidth(f.value, kSize)); Check(Hit(f, font, kSize, rectX, textStart + fullW + 1000.0f) == f.value.size(), "click far past the end returns value.size()"); } // 4. An empty field always resolves to offset 0. { InputField f; f.value = ""; Check(Hit(f, font, kSize, rectX, textStart + 50.0f) == 0, "empty field returns offset 0 regardless of click x"); } // 5. Clicking just past a glyph's right edge selects that glyph's boundary // (cursor sits after the glyph). Walk the boundaries of an ASCII string // and click slightly past each cumulative advance. { std::string_view s = "abcXYZ"; InputField f; f.value = std::string(s); auto bounds = Boundaries(s); bool edgeOk = true; for (std::size_t k = 1; k < bounds.size(); ++k) { float w = static_cast(font.GetLineWidth(s.substr(0, bounds[k]), kSize)); // Click a hair before the glyph's right edge → nearest boundary is // this one (its advance is the closest of all boundaries). std::size_t got = Hit(f, font, kSize, rectX, textStart + w - 0.5f); if (got != bounds[k]) { edgeOk = false; std::println(" edge mismatch at boundary {} (w={}): got {}", bounds[k], w, got); } } Check(edgeOk, "clicking near a glyph's right edge resolves to that glyph's boundary"); } if (failures == 0) std::println("\nAll InputField hit-test checks passed."); else std::println("\n{} InputField hit-test check(s) FAILED.", failures); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }