diff --git a/examples/OptionsSpike/font.ttf b/examples/OptionsSpike/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/examples/OptionsSpike/font.ttf differ diff --git a/examples/OptionsSpike/main.cpp b/examples/OptionsSpike/main.cpp new file mode 100644 index 0000000..b28462a --- /dev/null +++ b/examples/OptionsSpike/main.cpp @@ -0,0 +1,309 @@ +// Spike for the OptionsMenu port (3DForts → Crafter.Graphics imperative UI). +// Validates: Rect::SubRect carving at real-menu density, InputField shape and +// caret blink, click-routing-to-focused-field, tab switching with per-tab +// builders. Throwaway example, but kept around as a living reference. + +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import Crafter.Event; +import std; +using namespace Crafter; + +namespace { + +enum class Tab : std::uint8_t { Graphics, Input, Audio }; +constexpr std::array kTabLabels = { + "Graphics", "Input", "Audio" +}; + +struct Layout { + Rect canvas; + Rect titleBar; + Rect footer; + Rect main; + Rect tabStrip; + Rect content; + std::array tabRects; + Rect btnExit; + Rect btnSave; +}; + +Layout ComputeLayout(const Window& window) { + Layout L; + L.canvas = Rect::FromWindow(window); + L.titleBar = L.canvas.SubRect(60, Rect::Anchor::Top); + L.footer = L.canvas.SubRect(60, Rect::Anchor::Bottom); + L.main = L.canvas.Inset(60, 0, 60, 0); + L.tabStrip = L.main.SubRect(220, Rect::Anchor::Left).Inset(20); + L.content = L.main.Inset(0, 20, 0, 240); + + Rect strip = L.tabStrip; + for (int i = 0; i < 3; ++i) { + L.tabRects[i] = strip.SubRect(48, Rect::Anchor::Top).Inset(0, 0, 8, 0); + strip = strip.Inset(56, 0, 0, 0); + } + + Rect footerInset = L.footer.Inset(20, 60, 20, 60); + L.btnExit = footerInset.SubRect(160, Rect::Anchor::Left); + L.btnSave = footerInset.SubRect(160, Rect::Anchor::Right); + return L; +} + +} + +int main() { + Device::Initialize(); + Window window(1280, 720, "OptionsSpike"); + + VkCommandBuffer init = window.StartInit(); + + DescriptorHeapVulkan heap; + heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4); + window.descriptorHeap = &heap; + + Font font("font.ttf"); + + FontAtlas atlas; + atlas.Initialize(init); + + UIRenderer ui; + ui.fontAtlas = &atlas; + ui.Initialize(window, heap, init); + window.passes.push_back(&ui); + + VulkanBuffer quadsBuf; + VulkanBuffer glyphsBuf; + quadsBuf.Create( + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256); + glyphsBuf.Create( + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096); + + auto quadsSlot = ui.RegisterBuffer(quadsBuf); + auto glyphsSlot = ui.RegisterBuffer(glyphsBuf); + + // Application state. + Tab activeTab = Tab::Graphics; + std::array tabHover{}; + bool exitHover = false, saveHover = false; + + // Graphics-tab fields. In the real port these are rebuilt per-tab; here we + // just keep one set live and only show them on the Graphics tab to confirm + // the routing pattern. Width/height come from a fake "tempOptions" state. + InputField widthField { .value = "1920", .type = InputFieldType::UInt }; + InputField heightField { .value = "1080", .type = InputFieldType::UInt }; + std::array fields{ &widthField, &heightField }; + std::array fieldRects{}; + std::array fieldHover{}; + std::ptrdiff_t focusedField = -1; + + auto BlurAll = [&]() { + for (auto* f : fields) f->focused = false; + focusedField = -1; + }; + + auto SetFocus = [&](std::ptrdiff_t idx) { + BlurAll(); + if (idx >= 0 && idx < std::ssize(fields)) { + fields[idx]->focused = true; + focusedField = idx; + } + }; + + auto LayoutGraphicsTabFields = [&](const Layout& L) { + Rect rows = L.content; + Rect row0 = rows.SubRect(48, Rect::Anchor::Top); + rows = rows.Inset(56, 0, 0, 0); + Rect row1 = rows.SubRect(48, Rect::Anchor::Top); + + // Each row: label on left half, field on right half (right half inset). + fieldRects[0] = row0.SubRect(row0.w * 0.5f, Rect::Anchor::Right).Inset(4); + fieldRects[1] = row1.SubRect(row1.w * 0.5f, Rect::Anchor::Right).Inset(4); + }; + + Layout L = ComputeLayout(window); + LayoutGraphicsTabFields(L); + + // ─── input listeners ─────────────────────────────────────────────── + EventListener moveSub(&window.onMouseMove, [&]() { + float mx = window.currentMousePos.x; + float my = window.currentMousePos.y; + for (int i = 0; i < 3; ++i) tabHover[i] = L.tabRects[i].Contains(mx, my); + exitHover = L.btnExit.Contains(mx, my); + saveHover = L.btnSave.Contains(mx, my); + if (activeTab == Tab::Graphics) { + for (int i = 0; i < (int)fields.size(); ++i) + fieldHover[i] = fieldRects[i].Contains(mx, my); + } else { + for (auto& h : fieldHover) h = false; + } + }); + + EventListener clickSub(&window.onMouseLeftClick, [&]() { + for (int i = 0; i < 3; ++i) { + if (tabHover[i]) { + activeTab = static_cast(i); + BlurAll(); + return; + } + } + if (exitHover) { window.open = false; return; } + if (saveHover) { + // In the real port: OptionsIO::Save + Apply. Here we just print. + std::println("[spike] save: width={} height={}", + widthField.value, heightField.value); + return; + } + if (activeTab == Tab::Graphics) { + for (int i = 0; i < (int)fields.size(); ++i) { + if (fieldHover[i]) { + SetFocus(i); + fields[i]->cursorPos = InputField_HitTestCursor( + *fields[i], fieldRects[i], + window.currentMousePos.x, + font, 18.0f, InputFieldColors{ + .bg = {0.18f, 0.18f, 0.22f, 1.0f}, + .bgFocused = {0.22f, 0.22f, 0.30f, 1.0f}, + .border = {0.10f, 0.10f, 0.14f, 1.0f}, + .borderFocused = {0.40f, 0.65f, 1.00f, 1.0f}, + .text = {1.00f, 1.00f, 1.00f, 1.0f}, + .caret = {1.00f, 1.00f, 1.00f, 1.0f}, + }); + return; + } + } + } + BlurAll(); + }); + + EventListener textSub(&window.onTextInput, [&](std::string_view t) { + if (focusedField >= 0) InputField_OnText(*fields[focusedField], t); + }); + EventListener keySub(&window.onAnyKeyDown, [&](CrafterKeys k) { + if (focusedField >= 0) InputField_OnKey(*fields[focusedField], k); + }); + + // ─── palettes ────────────────────────────────────────────────────── + ButtonColors tabPalette{ + .bg = {0.20f, 0.22f, 0.28f, 1.0f}, + .bgHover = {0.30f, 0.55f, 0.95f, 1.0f}, + .bgPressed = {0.18f, 0.40f, 0.78f, 1.0f}, + .text = {1, 1, 1, 1}, + .cornerRadius = 6.0f, + }; + ButtonColors tabPaletteSelected = tabPalette; + tabPaletteSelected.bg = {0.30f, 0.55f, 0.95f, 1.0f}; + + ButtonColors footerPalette{ + .bg = {0.20f, 0.22f, 0.28f, 1.0f}, + .bgHover = {0.30f, 0.55f, 0.95f, 1.0f}, + .bgPressed = {0.18f, 0.40f, 0.78f, 1.0f}, + .text = {1, 1, 1, 1}, + .cornerRadius = 8.0f, + }; + + InputFieldColors fieldPalette{ + .bg = {0.18f, 0.18f, 0.22f, 1.0f}, + .bgFocused = {0.22f, 0.22f, 0.30f, 1.0f}, + .border = {0.10f, 0.10f, 0.14f, 1.0f}, + .borderFocused = {0.40f, 0.65f, 1.00f, 1.0f}, + .text = {1, 1, 1, 1}, + .caret = {1, 1, 1, 1}, + }; + + auto startTime = std::chrono::steady_clock::now(); + + EventListener buildSub(&ui.onBuild, [&](UIBuildArgs a) { + VkCommandBuffer cmd = a.cmd; + + L = ComputeLayout(window); + LayoutGraphicsTabFields(L); + + std::uint32_t qc = 0, gc = 0; + UIBuffer buf{ + .quads = quadsBuf.value, + .quadCount = &qc, + .quadCap = 256, + .glyphs = glyphsBuf.value, + .glyphCount = &gc, + .glyphCap = 4096, + .atlas = &atlas, + .renderer = &ui, + }; + + // Background. + if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{ + L.canvas.x, L.canvas.y, L.canvas.w, L.canvas.h, + 0.10f, 0.10f, 0.13f, 1.0f, + 0, 0, 0, 0, 0, 0, 0, 0, + }; + // Title bar. + if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{ + L.titleBar.x, L.titleBar.y, L.titleBar.w, L.titleBar.h, + 0.14f, 0.14f, 0.18f, 1.0f, + 0, 0, 0, 0, 0, 0, 0, 0, + }; + DrawText(buf, "OPTIONS", + L.titleBar.x + L.titleBar.w * 0.5f, + L.titleBar.y + L.titleBar.h * 0.5f + 18.0f * 0.32f, + font, 24.0f, {1, 1, 1, 1}, TextAlign::Center); + + // Tabs. + for (int i = 0; i < 3; ++i) { + const auto& palette = (activeTab == static_cast(i)) + ? tabPaletteSelected : tabPalette; + DrawButton(buf, L.tabRects[i], kTabLabels[i], + tabHover[i], false, font, 18.0f, palette); + } + + // Active tab content. + if (activeTab == Tab::Graphics) { + Rect rows = L.content; + Rect row0 = rows.SubRect(48, Rect::Anchor::Top); + rows = rows.Inset(56, 0, 0, 0); + Rect row1 = rows.SubRect(48, Rect::Anchor::Top); + + DrawText(buf, "Resolution width", + row0.x + 8.0f, + row0.y + row0.h * 0.5f + 18.0f * 0.32f, + font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left); + DrawText(buf, "Resolution height", + row1.x + 8.0f, + row1.y + row1.h * 0.5f + 18.0f * 0.32f, + font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left); + + auto now = std::chrono::steady_clock::now(); + auto ms = std::chrono::duration_cast(now - startTime).count(); + bool caretVisible = ((ms / 500) & 1) == 0; + + DrawInputField(buf, widthField, fieldRects[0], font, 18.0f, fieldPalette, caretVisible); + DrawInputField(buf, heightField, fieldRects[1], font, 18.0f, fieldPalette, caretVisible); + } else { + DrawText(buf, "(tab content not populated in spike)", + L.content.x + L.content.w * 0.5f, + L.content.y + L.content.h * 0.5f, + font, 18.0f, {0.6f, 0.6f, 0.6f, 1.0f}, TextAlign::Center); + } + + // Footer. + DrawButton(buf, L.btnExit, "Exit", exitHover, false, font, 18.0f, footerPalette); + DrawButton(buf, L.btnSave, "Save changes", saveHover, false, font, 18.0f, footerPalette); + + // Dispatch. + if (qc > 0) { + quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + ui.DispatchQuads(cmd, quadsSlot, qc); + } + if (gc > 0) { + glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + ui.DispatchText(cmd, glyphsSlot, gc); + } + }); + + window.FinishInit(); + window.Render(); + window.StartUpdate(); + window.StartSync(); +} diff --git a/examples/OptionsSpike/project.cpp b/examples/OptionsSpike/project.cpp new file mode 100644 index 0000000..77f5be6 --- /dev/null +++ b/examples/OptionsSpike/project.cpp @@ -0,0 +1,25 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* graphics = LocalProject({ + .projectFile = "../../project.cpp", + .args = std::vector(args.begin(), args.end()), + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "OptionsSpike"; + cfg.outputName = "OptionsSpike"; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { graphics }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.files.push_back("font.ttf"); + return cfg; +} diff --git a/implementations/Crafter.Graphics-InputField.cpp b/implementations/Crafter.Graphics-InputField.cpp new file mode 100644 index 0000000..3ea79c9 --- /dev/null +++ b/implementations/Crafter.Graphics-InputField.cpp @@ -0,0 +1,180 @@ +/* +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 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, CrafterKeys key) { + switch (key) { + case CrafterKeys::Backspace: + if (f.cursorPos > 0 && !f.value.empty()) { + f.value.erase(f.cursorPos - 1, 1); + --f.cursorPos; + } + break; + case CrafterKeys::Delete: + if (f.cursorPos < f.value.size()) { + f.value.erase(f.cursorPos, 1); + } + break; + case CrafterKeys::Left: + if (f.cursorPos > 0) --f.cursorPos; + break; + case CrafterKeys::Right: + if (f.cursorPos < f.value.size()) ++f.cursorPos; + break; + case CrafterKeys::Home: + f.cursorPos = 0; + break; + case CrafterKeys::End: + f.cursorPos = f.value.size(); + break; + default: break; + } +} + +std::size_t Crafter::InputField_HitTestCursor(const InputField& f, + Rect rect, + float clickX, + Font& font, float fontSize, + const InputFieldColors& colors) +{ + 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(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, + 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(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, + }; + } +} diff --git a/implementations/Crafter.Graphics-UIComponents.cpp b/implementations/Crafter.Graphics-UIComponents.cpp index 109e5a6..f4d240d 100644 --- a/implementations/Crafter.Graphics-UIComponents.cpp +++ b/implementations/Crafter.Graphics-UIComponents.cpp @@ -184,3 +184,50 @@ void Crafter::DrawProgressBar(UIBuffer& buf, Rect r, float t01, }); } } + +// ─── DrawText ─────────────────────────────────────────────────────────── + +float Crafter::DrawText(UIBuffer& buf, std::string_view text, + float x, float baselineY, + Font& font, float fontSize, + std::array color, + TextAlign align) +{ + if (text.empty() || buf.atlas == nullptr || buf.renderer == nullptr) return 0.0f; + if (buf.glyphs == nullptr || buf.glyphCount == nullptr) return 0.0f; + + std::uint32_t before = *buf.glyphCount; + std::uint32_t cap = (buf.glyphCap > before) ? (buf.glyphCap - before) : 0; + if (cap == 0) return 0.0f; + + GlyphItem* writePos = buf.glyphs + before; + float advance = 0.0f; + std::uint32_t n = buf.renderer->ShapeText( + font, fontSize, x, baselineY, + text, color, writePos, cap, &advance + ); + *buf.glyphCount = before + n; + + if (align != TextAlign::Left && n > 0) { + float shift = (align == TextAlign::Center) ? -advance * 0.5f : -advance; + for (std::uint32_t i = 0; i < n; ++i) writePos[i].x += shift; + } + return advance; +} + +// ─── DrawImage ────────────────────────────────────────────────────────── + +void Crafter::DrawImage(UIBuffer& buf, Rect r, + std::uint32_t textureSlot, std::uint32_t samplerSlot, + std::array tint, + std::array uv) +{ + if (buf.images == nullptr || buf.imageCount == nullptr) return; + if (*buf.imageCount >= buf.imageCap) return; + buf.images[(*buf.imageCount)++] = ImageItem{ + r.x, r.y, r.w, r.h, + uv[0], uv[1], uv[2], uv[3], + tint[0], tint[1], tint[2], tint[3], + textureSlot, samplerSlot, 0, 0, + }; +} diff --git a/interfaces/Crafter.Graphics-InputField.cppm b/interfaces/Crafter.Graphics-InputField.cppm new file mode 100644 index 0000000..c51ba85 --- /dev/null +++ b/interfaces/Crafter.Graphics-InputField.cppm @@ -0,0 +1,103 @@ +/* +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; +export module Crafter.Graphics:InputField; +import std; +import :Types; +import :Font; +import :UI; +import :UIComponents; + +// Tier 3: single-line text input. Same contract as the other UI components — +// state is a user-owned POD, the draw function is stateless presentation, and +// input feeding is done via free functions called from the host's own event +// listeners. Validation rules cover the common numeric cases (UInt/Int/Float) +// and accept "in-progress" intermediate states (lone '-', '.', "1e") so the +// user can type naturally; final-form parsing is on the caller via TryParse. + +export namespace Crafter { + enum class InputFieldType : std::uint8_t { + Text, // any UTF-8 + UInt, // digits only + Int, // optional leading '-', then digits + Float, // signed float with optional exponent + }; + + struct InputFieldColors { + std::array bg; + std::array bgFocused; + std::array border; + std::array borderFocused; + std::array text; + std::array caret; + float cornerRadius = 6.0f; + float borderThickness = 2.0f; + float paddingX = 8.0f; // text inset from the rect's left edge + }; + + struct InputField { + std::string value; + InputFieldType type = InputFieldType::Text; + bool focused = false; + std::size_t cursorPos = 0; // byte offset into `value` + }; + + // Returns true if `s` is a valid candidate for the given type, including + // "in-progress" states (lone '-', empty, "1e", "1."). + bool InputField_IsValidCandidate(InputFieldType, std::string_view s); + + // Try to parse the field's current value into T. Returns nullopt for empty + // or in-progress strings. Defined for arithmetic types via std::from_chars. + template + std::optional InputField_TryParse(const InputField& f) { + if (f.value.empty()) return std::nullopt; + T out{}; + const char* begin = f.value.data(); + const char* end = begin + f.value.size(); + auto [ptr, ec] = std::from_chars(begin, end, out); + if (ec != std::errc{} || ptr != end) return std::nullopt; + return out; + } + + // Insert UTF-8 text at the cursor. The full would-be result is validated + // against the field's type before committing — invalid candidates are + // dropped silently. Cursor advances by the inserted byte count. + void InputField_OnText(InputField&, std::string_view utf8); + + // Edit-control keys: Backspace, Delete, Left, Right, Home, End. Anything + // else is ignored. Safe to feed every key the host receives. + void InputField_OnKey(InputField&, CrafterKeys); + + // Map a click x-coord (in window pixels) to a cursor position. `rect` is + // the field's current draw rect; `colors.paddingX` is consulted for the + // text-start offset. + std::size_t InputField_HitTestCursor(const InputField&, + Rect rect, + float clickX, + Font&, float fontSize, + const InputFieldColors&); + + // Draw the field. `caretVisible` is provided by the caller so that blink + // policy stays in user code (typical: `(steady_clock::now() / 500ms) & 1`). + // The caret is only drawn if the field is focused AND caretVisible is true. + void DrawInputField(UIBuffer& buf, const InputField& f, Rect rect, + Font& font, float fontSize, + const InputFieldColors& colors, + bool caretVisible); +} diff --git a/interfaces/Crafter.Graphics-UIComponents.cppm b/interfaces/Crafter.Graphics-UIComponents.cppm index 67b9f02..33ea55e 100644 --- a/interfaces/Crafter.Graphics-UIComponents.cppm +++ b/interfaces/Crafter.Graphics-UIComponents.cppm @@ -38,8 +38,10 @@ import :FontAtlas; // function body into your code and modify it. There is no override hook. export namespace Crafter { - // Aggregate for the two item buffers + the optional text-shaping deps. - // Build one per frame in onBuild and pass it to component calls. + // Aggregate for the standard item buffers + the optional text-shaping + // deps. Build one per frame in onBuild and pass it to component calls. + // Any pointer left null causes the corresponding component to be a no-op + // (so a frame that doesn't draw images can leave `images` unset). struct UIBuffer { QuadItem* quads = nullptr; std::uint32_t* quadCount = nullptr; @@ -49,6 +51,10 @@ export namespace Crafter { std::uint32_t* glyphCount = nullptr; std::uint32_t glyphCap = 0; + ImageItem* images = nullptr; + std::uint32_t* imageCount = nullptr; + std::uint32_t imageCap = 0; + FontAtlas* atlas = nullptr; // for text-emitting components UIRenderer* renderer = nullptr; // for ShapeText }; @@ -92,6 +98,9 @@ export namespace Crafter { float cornerRadius = 0; }; + // Where the X coordinate sits relative to the emitted glyph run. + enum class TextAlign : std::uint8_t { Left, Center, Right }; + // ─── component functions ─────────────────────────────────────────── // Background quad (color depends on state) + centered label glyphs. @@ -112,4 +121,22 @@ export namespace Crafter { // Background quad + a filled quad clipped to t01 of the inner width. void DrawProgressBar(UIBuffer& buf, Rect r, float t01, const ProgressColors& c); + + // Single-line text emit. `(x, baselineY)` is the start position; horizontal + // alignment shifts the run after shaping. Returns the advance width that + // was written. No line-wrap, no kerning — same shaping rules as + // UIRenderer::ShapeText (which this calls). + float DrawText(UIBuffer& buf, std::string_view text, + float x, float baselineY, + Font& font, float fontSize, + std::array color, + TextAlign align = TextAlign::Left); + + // Sampled image quad. The texture and sampler must already have heap + // slots (UIRenderer::RegisterImage / RegisterSampler). `uv` is + // {u0, v0, u1, v1} into the source texture; defaults to the full image. + void DrawImage(UIBuffer& buf, Rect r, + std::uint32_t textureSlot, std::uint32_t samplerSlot, + std::array tint = {1, 1, 1, 1}, + std::array uv = {0, 0, 1, 1}); } diff --git a/interfaces/Crafter.Graphics.cppm b/interfaces/Crafter.Graphics.cppm index 1cd67a4..a0b5c68 100644 --- a/interfaces/Crafter.Graphics.cppm +++ b/interfaces/Crafter.Graphics.cppm @@ -41,4 +41,5 @@ export import :RTPass; export import :FontAtlas; export import :ComputeShader; export import :UI; -export import :UIComponents; \ No newline at end of file +export import :UIComponents; +export import :InputField; \ No newline at end of file diff --git a/project.cpp b/project.cpp index 28f4987..e6a2177 100644 --- a/project.cpp +++ b/project.cpp @@ -63,7 +63,7 @@ extern "C" Configuration CrafterBuildProject(std::span a if (opts.Has("--timing")) cfg.defines.push_back({"CRAFTER_TIMING", ""}); - std::array ifaces = { + std::array ifaces = { "interfaces/Crafter.Graphics", "interfaces/Crafter.Graphics-Animation", "interfaces/Crafter.Graphics-ComputeShader", @@ -73,6 +73,7 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Graphics-FontAtlas", "interfaces/Crafter.Graphics-ForwardDeclarations", "interfaces/Crafter.Graphics-ImageVulkan", + "interfaces/Crafter.Graphics-InputField", "interfaces/Crafter.Graphics-Mesh", "interfaces/Crafter.Graphics-PipelineRTVulkan", "interfaces/Crafter.Graphics-RenderingElement3D", @@ -88,11 +89,12 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Graphics-VulkanTransition", "interfaces/Crafter.Graphics-Window", }; - std::array impls = { + std::array impls = { "implementations/Crafter.Graphics-ComputeShader", "implementations/Crafter.Graphics-Device", "implementations/Crafter.Graphics-Font", "implementations/Crafter.Graphics-FontAtlas", + "implementations/Crafter.Graphics-InputField", "implementations/Crafter.Graphics-Mesh", "implementations/Crafter.Graphics-RenderingElement3D", "implementations/Crafter.Graphics-UI",