InputField_HitTestCursor mapped a click x 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 the hit test was O(n^2) glyph-metric lookups (each an uncached stbtt cmap binary search). It also iterated raw byte boundaries, so on multibyte UTF-8 input it could return an offset splitting a codepoint — an invalid position for the byte-based cursorPos. Add Font::NearestCursorByte: one left-to-right cumulative-advance pass (O(n) metrics, scale computed once) over codepoint boundaries, returning the byte offset of the boundary nearest the target. Cumulative advance is monotonic, so the scan stops past the closest boundary. The hit test now delegates to it. Adds tests/InputFieldHitTest, a pure-CPU regression test (no Vulkan device/window): a px sweep across ASCII and multibyte strings compared against an independent brute-force nearest-boundary oracle, plus the left/right/empty/end-of-text edge cases and a codepoint-boundary invariant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
8.5 KiB
C++
212 lines
8.5 KiB
C++
/*
|
|
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 <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/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<std::size_t> Boundaries(std::string_view s) {
|
|
std::vector<std::size_t> 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<float>(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<std::string_view, 4> 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<float>(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<float>(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<float>(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;
|
|
}
|