Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts: # interfaces/Crafter.Graphics-UI.cppm # project.cpp
This commit is contained in:
commit
e8f1c7cff9
15 changed files with 732 additions and 39 deletions
|
|
@ -256,12 +256,17 @@ struct UIDispatchHeader {
|
||||||
@group(1) @binding(0) var outTex : texture_storage_2d<rgba8unorm, write>;
|
@group(1) @binding(0) var outTex : texture_storage_2d<rgba8unorm, write>;
|
||||||
@group(1) @binding(1) var prevTex : texture_2d<f32>;
|
@group(1) @binding(1) var prevTex : texture_2d<f32>;
|
||||||
|
|
||||||
|
// Renderer-reserved high flag bit (mirrors ui-shared.glsl::UI_FLAG_CLIP).
|
||||||
|
const UI_FLAG_CLIP : u32 = 0x80000000u;
|
||||||
|
|
||||||
fn uiResolvePixel(coord: vec2<u32>) -> bool {
|
fn uiResolvePixel(coord: vec2<u32>) -> bool {
|
||||||
if (coord.x >= hdr.surfaceW || coord.y >= hdr.surfaceH) { return false; }
|
if (coord.x >= hdr.surfaceW || coord.y >= hdr.surfaceH) { return false; }
|
||||||
let fx = f32(coord.x); let fy = f32(coord.y);
|
if ((hdr.flags & UI_FLAG_CLIP) != 0u) {
|
||||||
if (fx < hdr.clipX || fy < hdr.clipY) { return false; }
|
let fx = f32(coord.x); let fy = f32(coord.y);
|
||||||
if (fx >= hdr.clipX + hdr.clipW) { return false; }
|
if (fx < hdr.clipX || fy < hdr.clipY) { return false; }
|
||||||
if (fy >= hdr.clipY + hdr.clipH) { return false; }
|
if (fx >= hdr.clipX + hdr.clipW) { return false; }
|
||||||
|
if (fy >= hdr.clipY + hdr.clipH) { return false; }
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@ Font::Font(const std::filesystem::path& fontFilePath) {
|
||||||
this->ascent = ascent;
|
this->ascent = ascent;
|
||||||
this->descent = descent;
|
this->descent = descent;
|
||||||
this->lineGap = lineGap;
|
this->lineGap = lineGap;
|
||||||
|
|
||||||
|
asciiAdvance_.fill(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t Font::AdvanceUnits(std::uint32_t cp) {
|
||||||
|
if (cp < asciiAdvance_.size()) {
|
||||||
|
if (asciiAdvance_[cp] < 0) {
|
||||||
|
int advance = 0, lsb = 0;
|
||||||
|
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
|
||||||
|
asciiAdvance_[cp] = advance;
|
||||||
|
}
|
||||||
|
return asciiAdvance_[cp];
|
||||||
|
}
|
||||||
|
if (auto it = advanceUnits_.find(cp); it != advanceUnits_.end()) return it->second;
|
||||||
|
int advance = 0, lsb = 0;
|
||||||
|
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
|
||||||
|
return advanceUnits_.emplace(cp, advance).first->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
|
std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
|
||||||
|
|
@ -65,13 +82,33 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
|
||||||
while (i < text.size()) {
|
while (i < text.size()) {
|
||||||
std::uint32_t cp = DecodeUtf8(text, i);
|
std::uint32_t cp = DecodeUtf8(text, i);
|
||||||
if (cp == 0) break;
|
if (cp == 0) break;
|
||||||
int advance, lsb;
|
lineWidth += (int)(AdvanceUnits(cp) * scale);
|
||||||
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
|
|
||||||
lineWidth += (int)(advance * scale);
|
|
||||||
}
|
}
|
||||||
return lineWidth;
|
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;
|
||||||
|
cumWidth += (int)(AdvanceUnits(cp) * 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 Font::LineHeight(float size) {
|
||||||
float scale = stbtt_ScaleForPixelHeight(&font, size);
|
float scale = stbtt_ScaleForPixelHeight(&font, size);
|
||||||
return (ascent - descent + lineGap) * scale;
|
return (ascent - descent + lineGap) * scale;
|
||||||
|
|
|
||||||
|
|
@ -117,23 +117,12 @@ std::size_t Crafter::InputField_HitTestCursor(const InputField& f,
|
||||||
Font& font, float fontSize,
|
Font& font, float fontSize,
|
||||||
const InputFieldColors& colors)
|
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);
|
float target = clickX - (rect.x + colors.paddingX);
|
||||||
if (target <= 0.0f) return 0;
|
return font.NearestCursorByte(f.value, fontSize, target);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,
|
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,27 @@ UIDispatchHeader UIRenderer::FillHeader(std::uint32_t itemBufferSlot,
|
||||||
#else
|
#else
|
||||||
h.frameIdx = 0;
|
h.frameIdx = 0;
|
||||||
#endif
|
#endif
|
||||||
h.flags = flags;
|
h.flags = ClipFlags(clipRectPx, window_->width, window_->height, flags);
|
||||||
h._pad = 0;
|
h._pad = 0;
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::uint32_t UIRenderer::ClipFlags(std::array<float,4> clipRectPx,
|
||||||
|
std::uint32_t surfaceWidth,
|
||||||
|
std::uint32_t surfaceHeight,
|
||||||
|
std::uint32_t flags) noexcept {
|
||||||
|
// The reserved kUIFlagClip bit signals "clip rect is narrower than the
|
||||||
|
// surface". When the rect already covers the whole surface (the common
|
||||||
|
// {0,0,1e9,1e9} default), the four per-pixel clip compares in
|
||||||
|
// uiResolveScreenPixel can never reject an in-surface pixel, so we leave
|
||||||
|
// the bit clear and the shader skips them — pixel-identical output.
|
||||||
|
const bool clipCoversSurface =
|
||||||
|
clipRectPx[0] <= 0.0f && clipRectPx[1] <= 0.0f
|
||||||
|
&& clipRectPx[0] + clipRectPx[2] >= static_cast<float>(surfaceWidth)
|
||||||
|
&& clipRectPx[1] + clipRectPx[3] >= static_cast<float>(surfaceHeight);
|
||||||
|
return clipCoversSurface ? (flags & ~kUIFlagClip) : (flags | kUIFlagClip);
|
||||||
|
}
|
||||||
|
|
||||||
std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
||||||
float x, float baselineY,
|
float x, float baselineY,
|
||||||
std::string_view utf8,
|
std::string_view utf8,
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,15 @@ void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd,
|
||||||
pc.outImage = outImageSlot_;
|
pc.outImage = outImageSlot_;
|
||||||
pc.fontTexture = fontAtlasImageSlot_;
|
pc.fontTexture = fontAtlasImageSlot_;
|
||||||
pc.fontSampler = fontAtlasSamplerSlot_;
|
pc.fontSampler = fontAtlasSamplerSlot_;
|
||||||
pc.flags = 0;
|
// Per-category clip-active bits, reusing the same surface-coverage test the
|
||||||
|
// standard path uses (ClipFlags). A category whose clip already covers the
|
||||||
|
// surface clears its bit so the kernel skips that category's per-pixel clip
|
||||||
|
// compares (the common full-surface case) — pixel-identical either way.
|
||||||
|
auto clipBit = [&](const FusedBatch& b, std::uint32_t bit) -> std::uint32_t {
|
||||||
|
return (ClipFlags(b.clipRectPx, window_->width, window_->height, 0) & kUIFlagClip) ? bit : 0u;
|
||||||
|
};
|
||||||
|
pc.flags = clipBit(quads, 0x1u) | clipBit(circles, 0x2u)
|
||||||
|
| clipBit(images, 0x4u) | clipBit(text, 0x8u);
|
||||||
pc.surfaceWidth = window_->width;
|
pc.surfaceWidth = window_->width;
|
||||||
pc.surfaceHeight = window_->height;
|
pc.surfaceHeight = window_->height;
|
||||||
pc.frameIdx = window_->currentBuffer;
|
pc.frameIdx = window_->currentBuffer;
|
||||||
|
|
@ -239,15 +247,41 @@ void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader
|
||||||
const void* push, std::uint32_t pushBytes,
|
const void* push, std::uint32_t pushBytes,
|
||||||
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
|
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
|
||||||
if (!firstDispatchThisFrame_) {
|
if (!firstDispatchThisFrame_) {
|
||||||
VkMemoryBarrier mb {
|
// Every UI pass read-modify-writes the same swapchain storage image
|
||||||
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
|
// (later passes overdraw earlier ones), so each dispatch must observe
|
||||||
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
|
// the previous dispatch's writes. The only resource the hazard touches
|
||||||
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
|
// 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,
|
vkCmdPipelineBarrier(cmd,
|
||||||
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
|
||||||
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;
|
firstDispatchThisFrame_ = false;
|
||||||
shader.Dispatch(cmd, push, pushBytes, gx, gy, gz);
|
shader.Dispatch(cmd, push, pushBytes, gx, gy, gz);
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,29 @@ namespace Crafter {
|
||||||
stbtt_fontinfo font;
|
stbtt_fontinfo font;
|
||||||
Font(const std::filesystem::path& font);
|
Font(const std::filesystem::path& font);
|
||||||
std::uint32_t GetLineWidth(const std::string_view text, float size);
|
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 LineHeight(float size);
|
||||||
float AscentPx(float size);
|
float AscentPx(float size);
|
||||||
float ScaleForSize(float size);
|
float ScaleForSize(float size);
|
||||||
|
|
||||||
|
// Horizontal advance for `cp` in unscaled font units, cached per Font.
|
||||||
|
// Stored in font units (not pixels) so it can be rescaled for any
|
||||||
|
// `size`; Glyph::advance in the FontAtlas is baked at kBaseSize and is
|
||||||
|
// wrong at other sizes, so it cannot be reused here. ASCII lands in a
|
||||||
|
// flat array (the common path for caret hit-testing, which rescans the
|
||||||
|
// whole field per character), everything else in the map.
|
||||||
|
std::int32_t AdvanceUnits(std::uint32_t cp);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::array<std::int32_t, 128> asciiAdvance_; // -1 = uncached
|
||||||
|
std::unordered_map<std::uint32_t, std::int32_t> advanceUnits_;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,12 @@ export namespace Crafter {
|
||||||
};
|
};
|
||||||
static_assert(sizeof(UIDispatchHeader) == 48);
|
static_assert(sizeof(UIDispatchHeader) == 48);
|
||||||
|
|
||||||
|
// Reserved `flags` bit (mirrors shaders/ui-shared.glsl::UI_FLAG_CLIP).
|
||||||
|
// FillHeader sets it when the clip rect is narrower than the surface so
|
||||||
|
// the standard shaders can skip the per-pixel clip compares otherwise.
|
||||||
|
// The remaining low bits stay free for user-defined feature flags.
|
||||||
|
inline constexpr std::uint32_t kUIFlagClip = 0x80000000u;
|
||||||
|
|
||||||
// ─── fused-dispatch push-constant header (issue #47) ────────────────
|
// ─── fused-dispatch push-constant header (issue #47) ────────────────
|
||||||
// Mirrors the PC block in shaders/ui-fused.comp.glsl byte-for-byte: every
|
// Mirrors the PC block in shaders/ui-fused.comp.glsl byte-for-byte: every
|
||||||
// member is vec4-aligned, no padding holes, exactly 128 bytes (the
|
// member is vec4-aligned, no padding holes, exactly 128 bytes (the
|
||||||
|
|
@ -71,7 +77,7 @@ export namespace Crafter {
|
||||||
std::uint32_t outImage; // swapchain image heap slot
|
std::uint32_t outImage; // swapchain image heap slot
|
||||||
std::uint32_t fontTexture; // font-atlas image slot (text phase)
|
std::uint32_t fontTexture; // font-atlas image slot (text phase)
|
||||||
std::uint32_t fontSampler; // font-atlas sampler slot (text phase)
|
std::uint32_t fontSampler; // font-atlas sampler slot (text phase)
|
||||||
std::uint32_t flags; // reserved — keep zeroed
|
std::uint32_t flags; // per-category clip-active bits (0x1 quads .. 0x8 text)
|
||||||
std::uint32_t surfaceWidth;
|
std::uint32_t surfaceWidth;
|
||||||
std::uint32_t surfaceHeight;
|
std::uint32_t surfaceHeight;
|
||||||
std::uint32_t frameIdx;
|
std::uint32_t frameIdx;
|
||||||
|
|
@ -200,6 +206,18 @@ export namespace Crafter {
|
||||||
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f},
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f},
|
||||||
std::uint32_t flags = 0) const noexcept;
|
std::uint32_t flags = 0) const noexcept;
|
||||||
|
|
||||||
|
// Builds the header `flags` word: the caller's user flags with the
|
||||||
|
// reserved kUIFlagClip bit set iff `clipRectPx` does not already cover
|
||||||
|
// the whole surface. When the clip rect spans the surface, the four
|
||||||
|
// per-pixel clip compares in uiResolveScreenPixel can never reject an
|
||||||
|
// in-surface pixel, so the bit stays clear and the shaders skip them.
|
||||||
|
// Static + dimension-parameterised so the decision is unit-testable
|
||||||
|
// without a live Window; FillHeader feeds it the current window size.
|
||||||
|
static std::uint32_t ClipFlags(std::array<float,4> clipRectPx,
|
||||||
|
std::uint32_t surfaceWidth,
|
||||||
|
std::uint32_t surfaceHeight,
|
||||||
|
std::uint32_t flags) noexcept;
|
||||||
|
|
||||||
void DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
void DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
||||||
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
||||||
void DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
void DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
||||||
|
|
|
||||||
87
project.cpp
87
project.cpp
|
|
@ -443,6 +443,93 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
fc.GetInterfacesAndImplementations(ifaces, fusedImpls);
|
fc.GetInterfacesAndImplementations(ifaces, fusedImpls);
|
||||||
fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" };
|
fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" };
|
||||||
cfg.tests.push_back(std::move(fusedTest));
|
cfg.tests.push_back(std::move(fusedTest));
|
||||||
|
|
||||||
|
// Issue #57: Font::GetLineWidth memoises per-codepoint advances in
|
||||||
|
// font units (Font::AdvanceUnits) and rescales per call, instead of
|
||||||
|
// calling stbtt_GetCodepointHMetrics for every glyph on every caret
|
||||||
|
// query. Pure CPU — Font only touches stb_truetype — so this drives
|
||||||
|
// the public API directly with no Vulkan device. The font file is
|
||||||
|
// copied next to the binary; the test also probes the project root.
|
||||||
|
Test advTest;
|
||||||
|
Configuration& vc = advTest.config;
|
||||||
|
vc.path = cfg.path;
|
||||||
|
vc.name = "FontAdvanceCache";
|
||||||
|
vc.outputName = "FontAdvanceCache";
|
||||||
|
vc.type = ConfigurationType::Executable;
|
||||||
|
vc.target = cfg.target;
|
||||||
|
vc.march = cfg.march;
|
||||||
|
vc.mtune = cfg.mtune;
|
||||||
|
vc.debug = cfg.debug;
|
||||||
|
vc.sysroot = cfg.sysroot;
|
||||||
|
vc.dependencies = cfg.dependencies;
|
||||||
|
vc.externalDependencies = cfg.externalDependencies;
|
||||||
|
vc.compileFlags = cfg.compileFlags;
|
||||||
|
vc.linkFlags = cfg.linkFlags;
|
||||||
|
vc.defines = cfg.defines;
|
||||||
|
vc.cFiles = cfg.cFiles;
|
||||||
|
vc.files = { fs::path("tests/FontAdvanceCache/font.ttf") };
|
||||||
|
std::vector<fs::path> advImpls(impls.begin(), impls.end());
|
||||||
|
advImpls.emplace_back("tests/FontAdvanceCache/main");
|
||||||
|
vc.GetInterfacesAndImplementations(ifaces, advImpls);
|
||||||
|
cfg.tests.push_back(std::move(advTest));
|
||||||
|
|
||||||
|
// Issue #50: uiResolveScreenPixel now gates its per-pixel clip-rect
|
||||||
|
// compares on the reserved kUIFlagClip bit, which FillHeader sets only
|
||||||
|
// when the clip rect is narrower than the surface. The decision lives
|
||||||
|
// in UIRenderer::ClipFlags — pure CPU logic over the clip rect and the
|
||||||
|
// surface size — so this test drives it directly with synthetic
|
||||||
|
// dimensions, no Window or GPU device needed at runtime.
|
||||||
|
Test clipTest;
|
||||||
|
Configuration& clc = clipTest.config;
|
||||||
|
clc.path = cfg.path;
|
||||||
|
clc.name = "UIClipFlag";
|
||||||
|
clc.outputName = "UIClipFlag";
|
||||||
|
clc.type = ConfigurationType::Executable;
|
||||||
|
clc.target = cfg.target;
|
||||||
|
clc.march = cfg.march;
|
||||||
|
clc.mtune = cfg.mtune;
|
||||||
|
clc.debug = cfg.debug;
|
||||||
|
clc.sysroot = cfg.sysroot;
|
||||||
|
clc.dependencies = cfg.dependencies;
|
||||||
|
clc.externalDependencies = cfg.externalDependencies;
|
||||||
|
clc.compileFlags = cfg.compileFlags;
|
||||||
|
clc.linkFlags = cfg.linkFlags;
|
||||||
|
clc.defines = cfg.defines;
|
||||||
|
clc.cFiles = cfg.cFiles;
|
||||||
|
std::vector<fs::path> clipImpls(impls.begin(), impls.end());
|
||||||
|
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;
|
return cfg;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||||||
const float ON_EDGE = 128.0 / 255.0;
|
const float ON_EDGE = 128.0 / 255.0;
|
||||||
const float DIST_SCALE = 32.0;
|
const float DIST_SCALE = 32.0;
|
||||||
|
|
||||||
|
// Per-category clip-active bits packed into pc.misc.w (the fused analogue of
|
||||||
|
// ui-shared.glsl's UI_FLAG_CLIP). A bit is set only when that category's clip
|
||||||
|
// rect is narrower than the surface; when clear, the category skips its four
|
||||||
|
// per-pixel clip compares entirely (the common full-surface case). The branch
|
||||||
|
// is push-constant-uniform, so it never diverges.
|
||||||
|
const uint UI_FUSED_CLIP_QUADS = 0x1u;
|
||||||
|
const uint UI_FUSED_CLIP_CIRCLES = 0x2u;
|
||||||
|
const uint UI_FUSED_CLIP_IMAGES = 0x4u;
|
||||||
|
const uint UI_FUSED_CLIP_TEXT = 0x8u;
|
||||||
|
|
||||||
// Generic per-chunk cooperative-cull scratch, REUSED across the four phases
|
// Generic per-chunk cooperative-cull scratch, REUSED across the four phases
|
||||||
// (they composite sequentially with a barrier between, so the storage is free
|
// (they composite sequentially with a barrier between, so the storage is free
|
||||||
// once a phase's last read completes). Keeping one shared set instead of four
|
// once a phase's last read completes). Keeping one shared set instead of four
|
||||||
|
|
@ -95,7 +105,8 @@ void main() {
|
||||||
{
|
{
|
||||||
uint heap = pc.itemBuffers.x;
|
uint heap = pc.itemBuffers.x;
|
||||||
uint count = pc.itemCounts.x;
|
uint count = pc.itemCounts.x;
|
||||||
bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipQuads);
|
bool inClip = inSurface &&
|
||||||
|
((pc.misc.w & UI_FUSED_CLIP_QUADS) == 0u || uiPixelInClipRect(pxu, pc.clipQuads));
|
||||||
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
||||||
uint idx = base + lid;
|
uint idx = base + lid;
|
||||||
bool keep = false;
|
bool keep = false;
|
||||||
|
|
@ -151,7 +162,8 @@ void main() {
|
||||||
{
|
{
|
||||||
uint heap = pc.itemBuffers.y;
|
uint heap = pc.itemBuffers.y;
|
||||||
uint count = pc.itemCounts.y;
|
uint count = pc.itemCounts.y;
|
||||||
bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipCircles);
|
bool inClip = inSurface &&
|
||||||
|
((pc.misc.w & UI_FUSED_CLIP_CIRCLES) == 0u || uiPixelInClipRect(pxu, pc.clipCircles));
|
||||||
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
||||||
uint idx = base + lid;
|
uint idx = base + lid;
|
||||||
bool keep = false;
|
bool keep = false;
|
||||||
|
|
@ -210,7 +222,8 @@ void main() {
|
||||||
{
|
{
|
||||||
uint heap = pc.itemBuffers.z;
|
uint heap = pc.itemBuffers.z;
|
||||||
uint count = pc.itemCounts.z;
|
uint count = pc.itemCounts.z;
|
||||||
bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipImages);
|
bool inClip = inSurface &&
|
||||||
|
((pc.misc.w & UI_FUSED_CLIP_IMAGES) == 0u || uiPixelInClipRect(pxu, pc.clipImages));
|
||||||
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
||||||
uint idx = base + lid;
|
uint idx = base + lid;
|
||||||
bool keep = false;
|
bool keep = false;
|
||||||
|
|
@ -260,7 +273,8 @@ void main() {
|
||||||
{
|
{
|
||||||
uint heap = pc.itemBuffers.w;
|
uint heap = pc.itemBuffers.w;
|
||||||
uint count = pc.itemCounts.w;
|
uint count = pc.itemCounts.w;
|
||||||
bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipText);
|
bool inClip = inSurface &&
|
||||||
|
((pc.misc.w & UI_FUSED_CLIP_TEXT) == 0u || uiPixelInClipRect(pxu, pc.clipText));
|
||||||
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
for (uint base = 0u; base < count; base += UI_CHUNK) {
|
||||||
uint idx = base + lid;
|
uint idx = base + lid;
|
||||||
bool keep = false;
|
bool keep = false;
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,19 @@ struct UIDispatchHeader {
|
||||||
vec4 clipRectPx; // (xy, wh) — every standard shader honors this
|
vec4 clipRectPx; // (xy, wh) — every standard shader honors this
|
||||||
uint itemCount;
|
uint itemCount;
|
||||||
uint frameIdx;
|
uint frameIdx;
|
||||||
uint flags; // user-defined feature bits
|
uint flags; // feature bits — high bit reserved by the renderer
|
||||||
uint _pad; // reserved — keep zeroed
|
uint _pad; // reserved — keep zeroed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── header flag bits ───────────────────────────────────────────────────
|
||||||
|
// `flags` is otherwise free for user-defined feature bits, but the renderer
|
||||||
|
// reserves the high bit: UI_FLAG_CLIP is set by UIRenderer::FillHeader when
|
||||||
|
// the clip rect does not already cover the whole surface. uiResolveScreenPixel
|
||||||
|
// gates its four clipRectPx compares on this bit so the overwhelmingly common
|
||||||
|
// full-surface case (clipRect == {0,0,1e9,1e9}) skips them entirely. The
|
||||||
|
// branch is push-constant-uniform across the dispatch, so it never diverges.
|
||||||
|
const uint UI_FLAG_CLIP = 0x80000000u;
|
||||||
|
|
||||||
// ─── standard item structs ──────────────────────────────────────────────
|
// ─── standard item structs ──────────────────────────────────────────────
|
||||||
// These match the C++ Crafter::QuadItem / CircleItem / ImageItem / GlyphItem
|
// These match the C++ Crafter::QuadItem / CircleItem / ImageItem / GlyphItem
|
||||||
// byte-for-byte under std430.
|
// byte-for-byte under std430.
|
||||||
|
|
@ -121,9 +130,11 @@ QuadItem LoadQuadItem(uint heap, uint i) {
|
||||||
bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) {
|
bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) {
|
||||||
uvec2 px = gl_GlobalInvocationID.xy;
|
uvec2 px = gl_GlobalInvocationID.xy;
|
||||||
if (px.x >= hdr.surfaceSize.x || px.y >= hdr.surfaceSize.y) return false;
|
if (px.x >= hdr.surfaceSize.x || px.y >= hdr.surfaceSize.y) return false;
|
||||||
if (float(px.x) < hdr.clipRectPx.x || float(px.y) < hdr.clipRectPx.y) return false;
|
if ((hdr.flags & UI_FLAG_CLIP) != 0u) {
|
||||||
if (float(px.x) >= hdr.clipRectPx.x + hdr.clipRectPx.z) return false;
|
if (float(px.x) < hdr.clipRectPx.x || float(px.y) < hdr.clipRectPx.y) return false;
|
||||||
if (float(px.y) >= hdr.clipRectPx.y + hdr.clipRectPx.w) return false;
|
if (float(px.x) >= hdr.clipRectPx.x + hdr.clipRectPx.z) return false;
|
||||||
|
if (float(px.y) >= hdr.clipRectPx.y + hdr.clipRectPx.w) return false;
|
||||||
|
}
|
||||||
screenPx = ivec2(px);
|
screenPx = ivec2(px);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
tests/FontAdvanceCache/font.ttf
Normal file
BIN
tests/FontAdvanceCache/font.ttf
Normal file
Binary file not shown.
144
tests/FontAdvanceCache/main.cpp
Normal file
144
tests/FontAdvanceCache/main.cpp
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Issue #57: Font::GetLineWidth used to call stbtt_GetCodepointHMetrics for
|
||||||
|
// every glyph on every call. Caret hit-testing (InputField_HitTestCursor)
|
||||||
|
// rescans the whole field once per character, so that is O(n²) HMetrics calls.
|
||||||
|
// Advances are now memoised per Font in *font units* (Font::AdvanceUnits):
|
||||||
|
// ASCII in a flat array, the rest in a map, rescaled per call.
|
||||||
|
//
|
||||||
|
// The cache must be in unscaled units, NOT pixels — the FontAtlas's
|
||||||
|
// Glyph::advance is baked at kBaseSize and is wrong at any other size, so it
|
||||||
|
// cannot be reused here. What is asserted:
|
||||||
|
//
|
||||||
|
// - AdvanceUnits is size-independent: the value for a codepoint is identical
|
||||||
|
// no matter which size was queried first (proves it stores units, not px).
|
||||||
|
// - GetLineWidth equals the documented pipeline: sum of per-glyph
|
||||||
|
// (int)(units * scaleForSize), with the truncation applied PER GLYPH.
|
||||||
|
// - Repeated calls (cache hits) are byte-identical to the first (miss).
|
||||||
|
// - Width scales with size — a 2× size is wider, ruling out a baked-at-one-
|
||||||
|
// size advance that would make every size render the same width.
|
||||||
|
// - The empty string is zero.
|
||||||
|
// - Codepoints above ASCII (the map path) behave like the array path.
|
||||||
|
//
|
||||||
|
// Pure CPU: Font only touches stb_truetype, so no Vulkan device is needed.
|
||||||
|
|
||||||
|
#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/FontAdvanceCache/font.ttf",
|
||||||
|
"../../examples/HelloUI/font.ttf" }) {
|
||||||
|
if (std::filesystem::exists(cand)) return cand;
|
||||||
|
}
|
||||||
|
return "font.ttf";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact contract GetLineWidth must honour: truncate each glyph's scaled
|
||||||
|
// advance to int independently, then accumulate. Rescaling the cached unit
|
||||||
|
// advance per call is what keeps it correct at sizes other than kBaseSize.
|
||||||
|
std::uint32_t ExpectedWidth(Font& font, std::string_view text, float size) {
|
||||||
|
float scale = font.ScaleForSize(size);
|
||||||
|
std::uint32_t w = 0;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < text.size()) {
|
||||||
|
std::uint32_t cp = DecodeUtf8(text, i);
|
||||||
|
if (cp == 0) break;
|
||||||
|
w += static_cast<int>(font.AdvanceUnits(cp) * scale);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
Font font(FindFont());
|
||||||
|
|
||||||
|
const std::string_view text = "Hello, World!";
|
||||||
|
|
||||||
|
// ── 1. AdvanceUnits is size-independent (stored in font units). ─────────
|
||||||
|
// Prime the cache via a large size, capture the unit advance, then prime
|
||||||
|
// again via a tiny size. The unit value must not move — if the cache stored
|
||||||
|
// pixels it would differ wildly between the two priming sizes.
|
||||||
|
font.GetLineWidth(text, 96.0f);
|
||||||
|
std::int32_t aBig = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
|
||||||
|
font.GetLineWidth(text, 6.0f);
|
||||||
|
std::int32_t aSmall = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
|
||||||
|
Check(aBig == aSmall && aBig > 0,
|
||||||
|
"AdvanceUnits('A') is a stable, positive font-unit value across sizes");
|
||||||
|
|
||||||
|
// ── 2. GetLineWidth matches the per-glyph-truncated, rescaled pipeline. ─
|
||||||
|
bool pipelineOk = true;
|
||||||
|
for (float size : {6.0f, 11.0f, 18.0f, 32.0f, 64.0f, 100.0f}) {
|
||||||
|
std::uint32_t got = font.GetLineWidth(text, size);
|
||||||
|
std::uint32_t exp = ExpectedWidth(font, text, size);
|
||||||
|
if (got != exp) {
|
||||||
|
pipelineOk = false;
|
||||||
|
std::println(" size {}: got {} expected {}", size, got, exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Check(pipelineOk, "GetLineWidth == sum of per-glyph (int)(units * scale) at every size");
|
||||||
|
|
||||||
|
// ── 3. Cache hits are byte-identical to the first (miss) call. ──────────
|
||||||
|
std::uint32_t first = font.GetLineWidth(text, 18.0f);
|
||||||
|
bool stable = true;
|
||||||
|
for (int k = 0; k < 8; ++k) stable = stable && (font.GetLineWidth(text, 18.0f) == first);
|
||||||
|
Check(stable && first > 0, "repeated GetLineWidth calls are identical (cache hit == miss)");
|
||||||
|
|
||||||
|
// ── 4. Width scales with size — not baked at a single size. ─────────────
|
||||||
|
std::uint32_t w18 = font.GetLineWidth(text, 18.0f);
|
||||||
|
std::uint32_t w36 = font.GetLineWidth(text, 36.0f);
|
||||||
|
Check(w36 > w18 && w36 > w18 + (w18 / 2),
|
||||||
|
"doubling the size roughly doubles the width (advance is rescaled, not baked)");
|
||||||
|
|
||||||
|
// ── 5. Empty string is zero. ────────────────────────────────────────────
|
||||||
|
Check(font.GetLineWidth("", 18.0f) == 0, "empty string has zero width");
|
||||||
|
|
||||||
|
// ── 6. Non-ASCII codepoints (the map path) behave like the array path. ──
|
||||||
|
// U+00E9 'é' (UTF-8 0xC3 0xA9) is > 127 so it lands in advanceUnits_, not
|
||||||
|
// the flat ASCII array. Its advance must be stable and a prefix narrower
|
||||||
|
// than the whole.
|
||||||
|
const std::string_view accented = "café"; // 'é' is two UTF-8 bytes
|
||||||
|
std::uint32_t accWidth = font.GetLineWidth(accented, 24.0f);
|
||||||
|
std::uint32_t accExp = ExpectedWidth(font, accented, 24.0f);
|
||||||
|
Check(accWidth == accExp && accWidth > font.GetLineWidth("caf", 24.0f),
|
||||||
|
"non-ASCII codepoint goes through the map path and widens the line");
|
||||||
|
std::uint32_t eFirst = static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9));
|
||||||
|
Check(static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9)) == eFirst,
|
||||||
|
"AdvanceUnits for a mapped codepoint is stable across calls");
|
||||||
|
|
||||||
|
if (failures == 0) std::println("\nAll font advance-cache checks passed.");
|
||||||
|
else std::println("\n{} font advance-cache check(s) FAILED.", failures);
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
105
tests/UIClipFlag/main.cpp
Normal file
105
tests/UIClipFlag/main.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/*
|
||||||
|
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 #50: uiResolveScreenPixel used to run four float
|
||||||
|
// compares against hdr.clipRectPx for every pixel of every dispatch, even
|
||||||
|
// though the clip rect is the full-surface default {0,0,1e9,1e9} in the
|
||||||
|
// overwhelmingly common case. The compares are now gated on the reserved
|
||||||
|
// kUIFlagClip bit, which UIRenderer::FillHeader sets (via ClipFlags) only when
|
||||||
|
// the clip rect is actually narrower than the surface.
|
||||||
|
//
|
||||||
|
// ClipFlags is the pure CPU decision behind that gate: given a clip rect, the
|
||||||
|
// surface size, and the caller's user flags, it returns the header flag word
|
||||||
|
// with kUIFlagClip set iff the rect does not cover the whole surface. This
|
||||||
|
// test drives it directly with synthetic dimensions — no Window or device.
|
||||||
|
//
|
||||||
|
// The contract it must hold for the shader gate to be correct:
|
||||||
|
// * full-surface (and over-covering) clips must NOT set the bit, so the
|
||||||
|
// shader skips the compares — and the skipped compares would never have
|
||||||
|
// rejected an in-surface pixel anyway, so output is pixel-identical;
|
||||||
|
// * any clip narrower than the surface on ANY edge MUST set the bit;
|
||||||
|
// * the caller's user flag bits are preserved either way; only the reserved
|
||||||
|
// high bit is touched.
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::array<float,4> kFullDefault = {0.0f, 0.0f, 1e9f, 1e9f};
|
||||||
|
|
||||||
|
bool ClipBitSet(std::array<float,4> rect, std::uint32_t w, std::uint32_t h,
|
||||||
|
std::uint32_t userFlags = 0) {
|
||||||
|
return (UIRenderer::ClipFlags(rect, w, h, userFlags) & kUIFlagClip) != 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
constexpr std::uint32_t W = 1920, H = 1080;
|
||||||
|
|
||||||
|
// --- full-surface clips leave the bit clear (the fast path) -----------
|
||||||
|
Check(!ClipBitSet(kFullDefault, W, H),
|
||||||
|
"the {0,0,1e9,1e9} default does not set the clip bit");
|
||||||
|
Check(!ClipBitSet({0.0f, 0.0f, float(W), float(H)}, W, H),
|
||||||
|
"a clip exactly matching the surface does not set the clip bit");
|
||||||
|
Check(!ClipBitSet({-5.0f, -5.0f, float(W) + 10.0f, float(H) + 10.0f}, W, H),
|
||||||
|
"a clip that over-covers the surface does not set the clip bit");
|
||||||
|
|
||||||
|
// --- any narrower edge sets the bit -----------------------------------
|
||||||
|
Check(ClipBitSet({0.0f, 0.0f, float(W) - 1.0f, float(H)}, W, H),
|
||||||
|
"a clip one pixel short on the right edge sets the clip bit");
|
||||||
|
Check(ClipBitSet({0.0f, 0.0f, float(W), float(H) - 1.0f}, W, H),
|
||||||
|
"a clip one pixel short on the bottom edge sets the clip bit");
|
||||||
|
Check(ClipBitSet({1.0f, 0.0f, float(W), float(H)}, W, H),
|
||||||
|
"a clip inset on the left edge sets the clip bit");
|
||||||
|
Check(ClipBitSet({0.0f, 1.0f, float(W), float(H)}, W, H),
|
||||||
|
"a clip inset on the top edge sets the clip bit");
|
||||||
|
Check(ClipBitSet({100.0f, 100.0f, 200.0f, 200.0f}, W, H),
|
||||||
|
"a small interior clip rect sets the clip bit");
|
||||||
|
|
||||||
|
// --- user flag bits survive the decision ------------------------------
|
||||||
|
constexpr std::uint32_t kUser = 0x0000000Fu; // four low user bits
|
||||||
|
Check(UIRenderer::ClipFlags(kFullDefault, W, H, kUser) == kUser,
|
||||||
|
"full-surface clip preserves user flags and clears the reserved bit");
|
||||||
|
Check(UIRenderer::ClipFlags({0.0f, 0.0f, 200.0f, 200.0f}, W, H, kUser)
|
||||||
|
== (kUser | kUIFlagClip),
|
||||||
|
"narrow clip preserves user flags and sets the reserved bit");
|
||||||
|
// A caller that (wrongly) pre-set the reserved bit must not flip the
|
||||||
|
// decision for a full-surface clip — ClipFlags owns that bit.
|
||||||
|
Check(UIRenderer::ClipFlags(kFullDefault, W, H, kUIFlagClip | kUser) == kUser,
|
||||||
|
"full-surface clip clears a stale reserved bit the caller passed in");
|
||||||
|
|
||||||
|
if (failures != 0) {
|
||||||
|
std::println(std::cerr, "{} case(s) failed", failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::println(std::cout, "all UI clip-flag cases passed");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue