new input system

This commit is contained in:
Jorijn van der Graaf 2026-05-12 00:24:48 +02:00
commit ac2eb7fb0a
31 changed files with 3292 additions and 781 deletions

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,189 @@
// End-to-end demo of GPU asset decompression via VK_EXT_memory_decompression.
//
// Walks the full compressed-asset pipeline:
// 1. Build a procedural cube + checkerboard texture in memory.
// 2. SaveCompressed → on-disk .cmesh / .ctex (GDeflate streams).
// 3. LoadCompressed* → CompressedMeshAsset / CompressedTextureAsset.
// 4. CPU verification: blob → DecompressCPU → byte-equal to source.
// 5. GPU: Mesh::Build(compressedMesh, cmd) and ImageVulkan::Update(compressedTex, cmd, layout).
// Picks the GPU path on NVIDIA (extension supported) or the CPU fallback elsewhere.
//
// No rendering — the goal is to exercise the asset pipeline and prove the
// new APIs round-trip. Validation layers are enabled by Crafter.Graphics in
// debug builds, so any VUID violation surfaces during FinishInit().
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Asset;
import Crafter.Math;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
// Procedural unit cube centered at origin, scaled so it's visible if the
// example is ever wired into a renderer. 8 unique positions, 36 indices.
MeshAsset<void> MakeCubeMesh() {
MeshAsset<void> mesh;
mesh.vertexes = {
{-50.f, -50.f, -50.f}, { 50.f, -50.f, -50.f},
{ 50.f, 50.f, -50.f}, {-50.f, 50.f, -50.f},
{-50.f, -50.f, 50.f}, { 50.f, -50.f, 50.f},
{ 50.f, 50.f, 50.f}, {-50.f, 50.f, 50.f},
};
mesh.indexes = {
// -Z
0, 1, 2, 0, 2, 3,
// +Z
4, 6, 5, 4, 7, 6,
// -X
0, 3, 7, 0, 7, 4,
// +X
1, 5, 6, 1, 6, 2,
// -Y
0, 4, 5, 0, 5, 1,
// +Y
3, 2, 6, 3, 6, 7,
};
return mesh;
}
struct RGBA8 { std::uint8_t r, g, b, a; };
// 256×256 checkerboard with smooth radial fade — compressible enough to make
// the GDeflate ratio interesting, structured enough that the demo is
// meaningful.
TextureAsset<RGBA8> MakeCheckerboard() {
TextureAsset<RGBA8> tex;
tex.sizeX = 256;
tex.sizeY = 256;
tex.opaque = OpaqueType::FullyOpaque;
tex.pixels.resize(tex.sizeX * tex.sizeY);
for (std::uint32_t y = 0; y < tex.sizeY; ++y) {
for (std::uint32_t x = 0; x < tex.sizeX; ++x) {
bool checker = ((x / 32) ^ (y / 32)) & 1;
float dx = float(x) - 128.0f;
float dy = float(y) - 128.0f;
float d = std::sqrt(dx * dx + dy * dy) / 180.0f;
float fade = std::clamp(1.0f - d, 0.0f, 1.0f);
std::uint8_t base = checker ? 220 : 40;
std::uint8_t lit = static_cast<std::uint8_t>(base * fade + 16);
tex.pixels[y * tex.sizeX + x] = RGBA8{lit, lit, lit, 255};
}
}
return tex;
}
double Ratio(std::size_t a, std::size_t b) {
return b == 0 ? 0.0 : 100.0 * double(a) / double(b);
}
} // namespace
int main() {
const fs::path meshPath = "cube.cmesh";
const fs::path texPath = "checker.ctex";
// ── 1. Build procedural assets ─────────────────────────────────────
MeshAsset<void> srcMesh = MakeCubeMesh();
TextureAsset<RGBA8> srcTex = MakeCheckerboard();
const std::size_t srcMeshBytes =
srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0])
+ srcMesh.indexes.size() * sizeof(srcMesh.indexes[0]);
const std::size_t srcTexBytes =
srcTex.pixels.size() * sizeof(RGBA8);
std::println("Procedural cube: {} vertices, {} indices ({} bytes)",
srcMesh.vertexes.size(), srcMesh.indexes.size(), srcMeshBytes);
std::println("Procedural checker: {}x{} RGBA8 ({} bytes)",
srcTex.sizeX, srcTex.sizeY, srcTexBytes);
// ── 2. SaveCompressed ──────────────────────────────────────────────
srcMesh.SaveCompressed(meshPath);
srcTex.SaveCompressed(texPath);
const std::size_t meshFileSize = fs::file_size(meshPath);
const std::size_t texFileSize = fs::file_size(texPath);
std::println("Saved {}: {} bytes ({:.1f}% of raw)",
meshPath.string(), meshFileSize, Ratio(meshFileSize, srcMeshBytes));
std::println("Saved {}: {} bytes ({:.1f}% of raw)",
texPath.string(), texFileSize, Ratio(texFileSize, srcTexBytes));
// ── 3. LoadCompressed ──────────────────────────────────────────────
CompressedMeshAsset loadedMesh = LoadCompressedMesh(meshPath);
CompressedTextureAsset loadedTex = LoadCompressedTexture(texPath);
if (loadedMesh.vertexCount != srcMesh.vertexes.size()
|| loadedMesh.indexCount != srcMesh.indexes.size()) {
std::println(std::cerr,"[FAIL] mesh header mismatch after LoadCompressedMesh");
return 1;
}
if (loadedTex.sizeX != srcTex.sizeX || loadedTex.sizeY != srcTex.sizeY) {
std::println(std::cerr,"[FAIL] texture header mismatch after LoadCompressedTexture");
return 1;
}
std::println("Loaded headers OK.");
// ── 4. CPU roundtrip verification ──────────────────────────────────
{
std::vector<Vector<float, 3, 3>> v(loadedMesh.vertexCount);
std::vector<std::uint32_t> i(loadedMesh.indexCount);
std::array<std::span<std::byte>, 3> outputs = {
std::as_writable_bytes(std::span(v)),
std::as_writable_bytes(std::span(i)),
std::span<std::byte>{},
};
Compression::DecompressCPU(loadedMesh.blob,
std::span(outputs).first(loadedMesh.blob.regions.size()));
if (v != srcMesh.vertexes || i != srcMesh.indexes) {
std::println(std::cerr,"[FAIL] CPU mesh decompress != source");
return 1;
}
}
{
std::vector<RGBA8> p(loadedTex.sizeX * loadedTex.sizeY);
std::array<std::span<std::byte>, 1> outputs = {
std::as_writable_bytes(std::span(p)),
};
Compression::DecompressCPU(loadedTex.blob, outputs);
if (std::memcmp(p.data(), srcTex.pixels.data(), srcTexBytes) != 0) {
std::println(std::cerr,"[FAIL] CPU texture decompress != source");
return 1;
}
}
std::println("CPU roundtrip OK (mesh + texture decode byte-equal).");
// ── 5. GPU path via Mesh::Build / ImageVulkan::Update ──────────────
Device::Initialize();
Window window(800, 600, "Decompression");
std::println("VK_EXT_memory_decompression: {}",
Device::memoryDecompressionSupported ? "AVAILABLE → GPU path" : "absent → CPU fallback");
VkCommandBuffer cmd = window.StartInit();
DescriptorHeapVulkan heap;
heap.Initialize(/*images*/ 1, /*buffers*/ 1, /*samplers*/ 0);
window.descriptorHeap = &heap;
// Mesh cubeMesh;
// cubeMesh.Build(loadedMesh, cmd);
ImageVulkan<RGBA8> checkerImage;
checkerImage.Create(
loadedTex.sizeX, loadedTex.sizeY, /*mipLevels*/ 1, cmd,
VK_FORMAT_R8G8B8A8_UNORM,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
checkerImage.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
window.FinishInit();
std::println("GPU init submit + wait completed without validation errors.");
//std::println("BLAS device address: 0x{:x}", cubeMesh.blasAddr);
// Cleanup happens via Window/Device dtors when they go out of scope.
return 0;
}

View file

@ -11,15 +11,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
Configuration cfg;
cfg.path = "./";
cfg.name = "OptionsSpike";
cfg.outputName = "OptionsSpike";
cfg.name = "Decompression";
cfg.outputName = "Decompression";
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("font.ttf");
return cfg;
}

View file

@ -0,0 +1,241 @@
// =====================================================================
// InputSystem — guided tour of Crafter::Input
// =====================================================================
//
// Run this with a window focused. The console narrates each event as
// it fires; the on-screen window only exists so the OS routes keyboard
// and mouse input to us. Plug a gamepad in (Xbox / DualShock /
// Switch Pro) and it will be picked up live.
//
// What this demo demonstrates, in order:
//
// 1. Three runtime layers stacked end-to-end:
// - Crafter::Key(CrafterKeys::X) compile-time abstract → raw
// platform KeyCode
// - Window::onRawKeyDown raw keyboard channel for UI /
// text input (NOT used for game
// input — actions handle that)
// - Crafter::Input::Map named actions → bindings →
// events
// - Crafter::Gamepad hot-plug, button + axis state,
// rumble
//
// 2. All four action types: Button, Axis1D, Vector2, with multiple
// bindings per action (any-of semantics — press Space OR press
// gamepad-A; both trigger Jump).
//
// 3. Composite bindings: WASDBind (4 keys → Vector2),
// GamepadStickBind (analog stick → Vector2), MouseDeltaBind
// (mouse movement → Vector2).
//
// 4. Runtime rebinding: press R to enter rebind mode; the next key /
// mouse button / gamepad button you press becomes the new Jump
// binding. The captured input does NOT fire Jump that frame.
//
// 5. Serialization: bindings are round-tripped to text strings at
// startup. Use these as the on-disk format for your settings file.
//
// 6. Hot-plug: connect / disconnect a gamepad while running. The
// Map's polling-based evaluation seamlessly switches over —
// bindings to a vanished gamepad become inert until reconnect.
//
// Controls:
// Space / Gamepad A → Jump
// WASD / Left stick → Move
// Mouse-Left / Right Trigger → Fire
// Mouse delta / Right stick → Look
// Mouse wheel → Zoom (Axis1D)
// R → Start rebinding "Jump" — press
// any key/button next to assign it
// Esc → Quit
//
// Output is intentionally chatty so you can SEE the system working.
// In a real game, replace the print statements with your gameplay
// code — that's it.
// =====================================================================
import Crafter.Graphics;
import Crafter.Event; // EventListener<>: Crafter.Graphics imports
import Crafter.Math; // Vector<float,2>: it transitively, but
// C++20 module-reachability rules require
// us to name them explicitly to instantiate
// the templates in this TU.
import std;
using namespace Crafter;
using namespace Crafter::Input;
int main() {
Device::Initialize();
Window window(640, 360, "Crafter Input — give the window focus");
// =================================================================
// Build the action map.
//
// AddAction returns a reference; we keep it so we can subscribe.
// The map owns the actions (vector<unique_ptr>); references stay
// valid for the map's lifetime.
// =================================================================
Map map;
Action& jump = map.AddAction("Jump", ActionType::Button);
Action& move = map.AddAction("Move", ActionType::Vector2);
Action& fire = map.AddAction("Fire", ActionType::Button);
Action& look = map.AddAction("Look", ActionType::Vector2);
Action& zoom = map.AddAction("Zoom", ActionType::Axis1D);
Action& reset = map.AddAction("Reset", ActionType::Button);
Action& quit = map.AddAction("Quit", ActionType::Button);
// =================================================================
// Default bindings.
//
// Key(CrafterKeys::X) is `consteval` — these literals fold to
// per-platform scancodes at compile time, no runtime translation.
// Multiple bindings per action are OR'd: pressing EITHER triggers
// the action.
//
// gamepadId == 0 is a sentinel here meaning "first connected
// gamepad". For multi-player you'd save the stable id from a
// specific Device and use that.
// =================================================================
jump.bindings = {
KeyBind{ Key(CrafterKeys::Space) },
GamepadButtonBind{ 0, Gamepad::Button::South },
};
move.bindings = {
WASDBind{
Key(CrafterKeys::W), Key(CrafterKeys::S),
Key(CrafterKeys::A), Key(CrafterKeys::D),
},
GamepadStickBind{ 0, Gamepad::Stick::Left },
};
fire.bindings = {
MouseButtonBind{ 0 }, // left click
GamepadAxisBind{ 0, Gamepad::Axis::RightTrigger, false }, // analog R2
};
look.bindings = {
MouseDeltaBind{ 0.01f }, // scale down
GamepadStickBind{ 0, Gamepad::Stick::Right },
};
zoom.bindings = {
MouseScrollBind{},
};
reset.bindings = { KeyBind{ Key(CrafterKeys::R) } };
quit.bindings = { KeyBind{ Key(CrafterKeys::Escape) } };
// =================================================================
// Serialization round-trip — call sites can save these strings to
// disk and load them back into Action::bindings later.
//
// IMPORTANT: KeyCode values are platform-specific (Win32 scancodes
// vs. Wayland kernel keycodes). A saved file from one platform
// does NOT load on the other. Store your config under platform-
// specific paths.
// =================================================================
std::println("─── Default bindings (serialized form) ───");
for (auto& a : map.actions) {
for (auto& b : a->bindings) {
std::string s = BindingToString(b);
// Parse it back to prove the format roundtrips.
auto parsed = BindingFromString(s);
std::println(" {:<6} {} (roundtrip OK: {})",
a->name, s, parsed.has_value());
}
}
std::println("");
// =================================================================
// Subscribe to action events.
//
// EventListeners use the same RAII pattern as the rest of the
// library — declare them in scope, they unsubscribe when they go
// out of scope. Capturing references to the Actions is safe
// because the Map (and therefore its actions) outlive these
// listeners (both are in main()'s scope).
// =================================================================
EventListener<void> jumpPerf(&jump.onPerformed,
[] { std::println("[Jump] performed"); });
EventListener<void> jumpCanc(&jump.onCanceled,
[] { std::println("[Jump] canceled"); });
EventListener<Vector<float,2>> moveChanged(&move.onVector2Changed,
[](Vector<float,2> v) {
std::println("[Move] ({:+.2f}, {:+.2f})", v.x, v.y);
});
EventListener<void> firePerf(&fire.onPerformed,
[] { std::println("[Fire] performed"); });
EventListener<void> fireCanc(&fire.onCanceled,
[] { std::println("[Fire] canceled"); });
EventListener<Vector<float,2>> lookChanged(&look.onVector2Changed,
[](Vector<float,2> v) {
// Mouse delta fires every frame the mouse moves; throttle
// by checking magnitude so the console doesn't drown.
if (std::abs(v.x) + std::abs(v.y) > 0.01f) {
std::println("[Look] ({:+.3f}, {:+.3f})", v.x, v.y);
}
});
EventListener<float> zoomChanged(&zoom.onValueChanged,
[](float v) { std::println("[Zoom] delta {:+.0f}", v); });
EventListener<void> quitPerf(&quit.onPerformed,
[&] {
std::println("[Quit] shutting down");
window.open = false;
});
// =================================================================
// Rebinding flow.
//
// Pressing R fires Reset → we call StartRebind for the Jump
// action. While rebinding, the next qualifying input does NOT
// fire Jump (or any other action that uses the same input). It
// routes to the onCaptured callback exactly once.
//
// CaptureMask::Any accepts keyboard | mouse | gamepad. Narrow it
// (Keyboard, Gamepad, etc.) to restrict what counts as a valid
// binding for a given action.
// =================================================================
EventListener<void> resetPerf(&reset.onPerformed, [&] {
std::println("[Rebind] Press any key / button to bind \"Jump\" "
"(Esc to cancel)…");
map.StartRebind(jump, CaptureMask::Any, [&](Binding captured) {
jump.bindings.clear();
jump.bindings.push_back(captured);
std::println("[Rebind] Jump → {}", BindingToString(captured));
});
});
// =================================================================
// Gamepad lifecycle observers.
//
// Gamepad::Tick() is called automatically inside Window::StartSync
// every frame, so these events fire on the main thread alongside
// every other event. Hot-plug a controller while running and you
// should see the connect line below.
// =================================================================
EventListener<Gamepad::Device*> gpAdded(&Gamepad::onConnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] connected: id={} name=\"{}\"",
d->id, d->name);
});
EventListener<Gamepad::Device*> gpRemoved(&Gamepad::onDisconnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] disconnected: id={}", d->id);
});
// =================================================================
// Connect the map to the window. Map polls window state + gamepad
// state every Tick; we trigger Tick from onBeforeUpdate so it
// runs after the OS event pump (and Gamepad::Tick) for that frame.
// =================================================================
map.Attach(window);
EventListener<void> tick(&window.onBeforeUpdate, [&] { map.Tick(); });
std::println("─── Ready. Focus the window and try the controls. ───");
window.Render();
window.StartSync();
std::println("Done.");
return 0;
}

View file

@ -0,0 +1,23 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = std::vector<std::string>(args.begin(), args.end()),
});
Configuration cfg;
cfg.path = "./";
cfg.name = "InputSystem";
cfg.outputName = "InputSystem";
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

Binary file not shown.

View file

@ -1,309 +0,0 @@
// 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<std::string_view, 3> kTabLabels = {
"Graphics", "Input", "Audio"
};
struct Layout {
Rect canvas;
Rect titleBar;
Rect footer;
Rect main;
Rect tabStrip;
Rect content;
std::array<Rect, 3> 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<QuadItem, true> quadsBuf;
VulkanBuffer<GlyphItem, true> 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<bool, 3> 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<InputField*, 2> fields{ &widthField, &heightField };
std::array<Rect, 2> fieldRects{};
std::array<bool, 2> 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<void> 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<void> clickSub(&window.onMouseLeftClick, [&]() {
for (int i = 0; i < 3; ++i) {
if (tabHover[i]) {
activeTab = static_cast<Tab>(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<const std::string_view> textSub(&window.onTextInput, [&](std::string_view t) {
if (focusedField >= 0) InputField_OnText(*fields[focusedField], t);
});
EventListener<CrafterKeys> 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<UIBuildArgs> 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<Tab>(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<std::chrono::milliseconds>(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();
}

View file

@ -43,6 +43,27 @@ library does not track widgets or focus.
Drop a TTF in this directory as `font.ttf` before running (the example
loads it via `Font("font.ttf")`).
### [InputSystem](InputSystem/)
Guided tour of `Crafter::Input`: name actions ("Jump", "Move", "Fire",
"Look", "Zoom"), bind them to keys / mouse / gamepad (with composite
bindings for WASD-as-Vector2 and analog sticks), and consume them as
events. Demonstrates:
- The compile-time `Key(CrafterKeys::Space)` helper that folds to a
per-platform raw scancode — bindings stay cross-platform-readable
in source while runtime data stores raw codes only.
- All four action types (Button, Axis1D, Vector2) with multiple
bindings per action (any-of semantics).
- `Map::StartRebind` — press R, then press any input to remap "Jump"
at runtime. Captured input is filtered out for that frame.
- `BindingToString` / `BindingFromString` round-trip — print the
default bindings as the on-disk format.
- Gamepad hot-plug events: plug a controller in mid-run and the
bindings start firing immediately.
Console-driven (no UI rendering needed); focus the window and watch
stdout.
### [CustomShader](CustomShader/)
Tier 1 demo: a user-authored compute shader (`inverse-circle.comp.glsl`)
running alongside the shipped `drawQuads`. The custom shader inverts RGB