Merge remote-tracking branch 'origin/master' into claude/issue-50
# Conflicts: # project.cpp
This commit is contained in:
commit
f2f5a770c0
7 changed files with 311 additions and 21 deletions
|
|
@ -72,6 +72,30 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
|
|||
return lineWidth;
|
||||
}
|
||||
|
||||
std::size_t Font::NearestCursorByte(std::string_view text, float size, float target) {
|
||||
if (target <= 0.0f) return 0;
|
||||
float scale = stbtt_ScaleForPixelHeight(&font, size);
|
||||
std::size_t best = 0; // byte offset 0 → caret before the first glyph
|
||||
float bestDist = target; // |target - 0|, and target > 0 here
|
||||
float cumWidth = 0.0f;
|
||||
std::size_t i = 0;
|
||||
while (i < text.size()) {
|
||||
std::uint32_t cp = DecodeUtf8(text, i); // i advances to the next boundary
|
||||
if (cp == 0) break;
|
||||
int advance, lsb;
|
||||
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
|
||||
cumWidth += (int)(advance * scale); // match GetLineWidth's per-glyph rounding
|
||||
float d = std::abs(target - cumWidth);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = i; // byte offset just past this codepoint
|
||||
} else {
|
||||
break; // monotonic advance ⇒ already past the closest boundary
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
float Font::LineHeight(float size) {
|
||||
float scale = stbtt_ScaleForPixelHeight(&font, size);
|
||||
return (ascent - descent + lineGap) * scale;
|
||||
|
|
|
|||
|
|
@ -117,23 +117,12 @@ std::size_t Crafter::InputField_HitTestCursor(const InputField& f,
|
|||
Font& font, float fontSize,
|
||||
const InputFieldColors& colors)
|
||||
{
|
||||
// Single O(n) cumulative-advance pass over the value, keyed at codepoint
|
||||
// byte boundaries — matching cursorPos, which is a byte offset. The old
|
||||
// loop re-walked the prefix for every boundary (O(n^2) glyph metric
|
||||
// lookups) and could even land mid-codepoint on multibyte input.
|
||||
float target = clickX - (rect.x + colors.paddingX);
|
||||
if (target <= 0.0f) return 0;
|
||||
|
||||
std::size_t best = 0;
|
||||
float bestDist = std::abs(target);
|
||||
for (std::size_t i = 1; i <= f.value.size(); ++i) {
|
||||
std::string_view sub(f.value.data(), i);
|
||||
float w = static_cast<float>(font.GetLineWidth(sub, fontSize));
|
||||
float d = std::abs(target - w);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return font.NearestCursorByte(f.value, fontSize, target);
|
||||
}
|
||||
|
||||
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,
|
||||
|
|
|
|||
|
|
@ -182,15 +182,41 @@ void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader
|
|||
const void* push, std::uint32_t pushBytes,
|
||||
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
|
||||
if (!firstDispatchThisFrame_) {
|
||||
VkMemoryBarrier mb {
|
||||
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
|
||||
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
|
||||
// Every UI pass read-modify-writes the same swapchain storage image
|
||||
// (later passes overdraw earlier ones), so each dispatch must observe
|
||||
// the previous dispatch's writes. The only resource the hazard touches
|
||||
// is that one image, so scope the dependency to its subresource with a
|
||||
// VkImageMemoryBarrier rather than a queue-wide VkMemoryBarrier: same
|
||||
// correctness, but only this image's shader caches are flushed —
|
||||
// unrelated buffers/images are no longer invalidated. The image stays
|
||||
// in VK_IMAGE_LAYOUT_GENERAL (bound as a storage image), so this is a
|
||||
// pure memory dependency, no layout transition.
|
||||
//
|
||||
// The execution dependency is still COMPUTE->COMPUTE over the whole
|
||||
// queue, so the passes do NOT overlap — this is a narrower cache flush
|
||||
// only. Eliminating the barriers entirely requires fusing the passes
|
||||
// into one dispatch (tracked in #47); do not attempt that here.
|
||||
VkImageMemoryBarrier ib {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
|
||||
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
|
||||
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
|
||||
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
|
||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.image = window_->images[window_->currentBuffer],
|
||||
.subresourceRange = {
|
||||
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
.baseMipLevel = 0,
|
||||
.levelCount = 1,
|
||||
.baseArrayLayer = 0,
|
||||
.layerCount = 1,
|
||||
},
|
||||
};
|
||||
vkCmdPipelineBarrier(cmd,
|
||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||
0, 1, &mb, 0, nullptr, 0, nullptr);
|
||||
0, 0, nullptr, 0, nullptr, 1, &ib);
|
||||
}
|
||||
firstDispatchThisFrame_ = false;
|
||||
shader.Dispatch(cmd, push, pushBytes, gx, gy, gz);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ namespace Crafter {
|
|||
stbtt_fontinfo font;
|
||||
Font(const std::filesystem::path& font);
|
||||
std::uint32_t GetLineWidth(const std::string_view text, float size);
|
||||
// Map a target advance (px from the line start) to the nearest UTF-8
|
||||
// codepoint boundary, returning its BYTE offset into `text`. A single
|
||||
// left-to-right pass accumulates per-glyph advances (O(n) metric
|
||||
// lookups), instead of re-walking the prefix for every boundary as a
|
||||
// naive caller would. Cumulative advance is monotonic (advances are
|
||||
// non-negative), so the scan stops at the first boundary past the
|
||||
// closest one. Returned offsets land on codepoint boundaries, which are
|
||||
// exactly the valid positions for a byte-based text cursor.
|
||||
std::size_t NearestCursorByte(std::string_view text, float size, float target);
|
||||
float LineHeight(float size);
|
||||
float AscentPx(float size);
|
||||
float ScaleForSize(float size);
|
||||
|
|
|
|||
30
project.cpp
30
project.cpp
|
|
@ -441,6 +441,36 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
clipImpls.emplace_back("tests/UIClipFlag/main");
|
||||
clc.GetInterfacesAndImplementations(ifaces, clipImpls);
|
||||
cfg.tests.push_back(std::move(clipTest));
|
||||
|
||||
// Issue #56: InputField_HitTestCursor mapped a click x to a cursor byte
|
||||
// offset by re-walking the prefix for every boundary (O(n^2) glyph
|
||||
// metric lookups) over raw byte boundaries. It now delegates to
|
||||
// Font::NearestCursorByte — one cumulative-advance pass over codepoint
|
||||
// boundaries. The mapping is pure CPU over a TrueType file, so this test
|
||||
// drives it directly: no Vulkan device or window. The font is copied
|
||||
// next to the binary; the test also probes the project-root path.
|
||||
Test hitTest;
|
||||
Configuration& hc = hitTest.config;
|
||||
hc.path = cfg.path;
|
||||
hc.name = "InputFieldHitTest";
|
||||
hc.outputName = "InputFieldHitTest";
|
||||
hc.type = ConfigurationType::Executable;
|
||||
hc.target = cfg.target;
|
||||
hc.march = cfg.march;
|
||||
hc.mtune = cfg.mtune;
|
||||
hc.debug = cfg.debug;
|
||||
hc.sysroot = cfg.sysroot;
|
||||
hc.dependencies = cfg.dependencies;
|
||||
hc.externalDependencies = cfg.externalDependencies;
|
||||
hc.compileFlags = cfg.compileFlags;
|
||||
hc.linkFlags = cfg.linkFlags;
|
||||
hc.defines = cfg.defines;
|
||||
hc.cFiles = cfg.cFiles;
|
||||
hc.files = { fs::path("tests/InputFieldHitTest/font.ttf") };
|
||||
std::vector<fs::path> hitImpls(impls.begin(), impls.end());
|
||||
hitImpls.emplace_back("tests/InputFieldHitTest/main");
|
||||
hc.GetInterfacesAndImplementations(ifaces, hitImpls);
|
||||
cfg.tests.push_back(std::move(hitTest));
|
||||
}
|
||||
|
||||
return cfg;
|
||||
|
|
|
|||
BIN
tests/InputFieldHitTest/font.ttf
Normal file
BIN
tests/InputFieldHitTest/font.ttf
Normal file
Binary file not shown.
212
tests/InputFieldHitTest/main.cpp
Normal file
212
tests/InputFieldHitTest/main.cpp
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue