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>
164 lines
6 KiB
C++
164 lines
6 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
|
|
*/
|
|
module;
|
|
module Crafter.Graphics:InputField_impl;
|
|
import :InputField;
|
|
import :UI;
|
|
import :UIComponents;
|
|
import :Font;
|
|
import :Types;
|
|
import :Keys;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
|
|
bool Crafter::InputField_IsValidCandidate(InputFieldType type, std::string_view s) {
|
|
switch (type) {
|
|
case InputFieldType::Text:
|
|
return true;
|
|
|
|
case InputFieldType::UInt: {
|
|
for (char c : s) if (c < '0' || c > '9') return false;
|
|
return true;
|
|
}
|
|
|
|
case InputFieldType::Int: {
|
|
if (s.empty()) return true;
|
|
std::size_t i = 0;
|
|
if (s[0] == '-') {
|
|
if (s.size() == 1) return true; // lone '-' allowed mid-edit
|
|
i = 1;
|
|
}
|
|
for (; i < s.size(); ++i)
|
|
if (s[i] < '0' || s[i] > '9') return false;
|
|
return true;
|
|
}
|
|
|
|
case InputFieldType::Float: {
|
|
if (s.empty()) return true;
|
|
std::size_t i = 0;
|
|
if (s[0] == '-') {
|
|
if (s.size() == 1) return true;
|
|
i = 1;
|
|
}
|
|
bool sawDigit = false, sawDot = false, sawExp = false;
|
|
for (; i < s.size(); ++i) {
|
|
char c = s[i];
|
|
if (c >= '0' && c <= '9') {
|
|
if (!sawExp) sawDigit = true;
|
|
} else if (c == '.') {
|
|
if (sawDot || sawExp) return false;
|
|
sawDot = true;
|
|
} else if (c == 'e' || c == 'E') {
|
|
if (sawExp || !sawDigit) return false;
|
|
sawExp = true;
|
|
if (i + 1 < s.size() && (s[i + 1] == '+' || s[i + 1] == '-')) ++i;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void Crafter::InputField_OnText(InputField& f, std::string_view utf8) {
|
|
if (utf8.empty()) return;
|
|
std::string candidate = f.value;
|
|
candidate.insert(f.cursorPos, utf8);
|
|
if (!InputField_IsValidCandidate(f.type, candidate)) return;
|
|
f.value = std::move(candidate);
|
|
f.cursorPos += utf8.size();
|
|
}
|
|
|
|
void Crafter::InputField_OnKey(InputField& f, KeyCode code) {
|
|
// `Key(...)` is consteval — every comparison below folds to an immediate
|
|
// integer at compile time, so this is just a sequence of int-eq tests.
|
|
if (code == Key(CrafterKeys::Backspace)) {
|
|
if (f.cursorPos > 0 && !f.value.empty()) {
|
|
f.value.erase(f.cursorPos - 1, 1);
|
|
--f.cursorPos;
|
|
}
|
|
} else if (code == Key(CrafterKeys::Delete)) {
|
|
if (f.cursorPos < f.value.size()) {
|
|
f.value.erase(f.cursorPos, 1);
|
|
}
|
|
} else if (code == Key(CrafterKeys::Left)) {
|
|
if (f.cursorPos > 0) --f.cursorPos;
|
|
} else if (code == Key(CrafterKeys::Right)) {
|
|
if (f.cursorPos < f.value.size()) ++f.cursorPos;
|
|
} else if (code == Key(CrafterKeys::Home)) {
|
|
f.cursorPos = 0;
|
|
} else if (code == Key(CrafterKeys::End)) {
|
|
f.cursorPos = f.value.size();
|
|
}
|
|
}
|
|
|
|
std::size_t Crafter::InputField_HitTestCursor(const InputField& f,
|
|
Rect rect,
|
|
float clickX,
|
|
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);
|
|
return font.NearestCursorByte(f.value, fontSize, target);
|
|
}
|
|
|
|
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,
|
|
Font& font, float fontSize,
|
|
const InputFieldColors& c,
|
|
bool caretVisible)
|
|
{
|
|
if (buf.quads == nullptr || buf.quadCount == nullptr) return;
|
|
|
|
const auto& bg = f.focused ? c.bgFocused : c.bg;
|
|
const auto& border = f.focused ? c.borderFocused : c.border;
|
|
|
|
if (*buf.quadCount < buf.quadCap) {
|
|
buf.quads[(*buf.quadCount)++] = QuadItem{
|
|
rect.x, rect.y, rect.w, rect.h,
|
|
bg[0], bg[1], bg[2], bg[3],
|
|
c.cornerRadius, c.cornerRadius, c.cornerRadius, c.cornerRadius,
|
|
c.borderThickness, border[0], border[1], border[2],
|
|
};
|
|
}
|
|
|
|
float baseline = rect.y + rect.h * 0.5f + fontSize * 0.32f;
|
|
float textX = rect.x + c.paddingX;
|
|
DrawText(buf, f.value, textX, baseline, font, fontSize, c.text, TextAlign::Left);
|
|
|
|
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<float>(font.GetLineWidth(sub, fontSize)));
|
|
float caretH = fontSize * 1.1f;
|
|
float caretY = rect.y + (rect.h - caretH) * 0.5f;
|
|
float caretW = std::max(1.0f, fontSize / 16.0f);
|
|
buf.quads[(*buf.quadCount)++] = QuadItem{
|
|
caretX, caretY, caretW, caretH,
|
|
c.caret[0], c.caret[1], c.caret[2], c.caret[3],
|
|
0, 0, 0, 0,
|
|
0, 0, 0, 0,
|
|
};
|
|
}
|
|
}
|