Gap-closing work for the declarative UI module vs UE/Unity/Godot: - Layout: real measure pass (measureTree) + pure flexArrange with margin/padding/min-max/percent/anchor/absolute placement; window size, anchors and percentage sizing; script layout props. - Widgets: Image/ImageButton (engine textures + nine-patch + tint/uv), Combo, text wrapping + CJK font candidates/ranges, InputText 1024B, virtualized ScrollList/virtualList. - Events: script onClick/onChange callbacks with structured payloads (keeps the poll queues for compatibility). - Tooling: UI JSON save/load (saveTreeJson/loadTreeJson), host pos tween animation, gamepad nav, per-frame UI stats. - Viewport: embedded offscreen render target widget with input routing; RenderSystem3D::renderToCanvas preview pass + Graphics::renderScene3DToCanvas; editor.newHeightmapMesh/updateHeightmapMesh for heightmap terrain; examples/terrain-editor demonstrating orbit camera + raise/lower brush. - Tests: 32 UI tests including real-window Vulkan smoke for the viewport.
705 lines
29 KiB
C++
705 lines
29 KiB
C++
#include "ui/UISystem.h"
|
|
|
|
#include "ui/Layout.h"
|
|
#include "ui/Theme.h"
|
|
#include "ui/UIBackend.h"
|
|
|
|
#include "common/Module.h"
|
|
#include "graphics/Graphics.h"
|
|
|
|
#include <imgui.h>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <map>
|
|
#include <vector>
|
|
|
|
namespace eve::ui {
|
|
namespace {
|
|
|
|
std::vector<UIEvent> g_pending;
|
|
std::vector<UIClick> g_clicks;
|
|
std::vector<UIChange> g_changes;
|
|
UIBackend *g_backend = nullptr;
|
|
UIStats g_stats;
|
|
std::map<std::string, ViewportState> g_viewports;
|
|
|
|
std::string viewportKey(UIHost *host, const UINode &n) {
|
|
const std::string hostName =
|
|
host && !host->meta()->name.empty() ? host->meta()->name : "?";
|
|
return hostName + "/" + (n.id.empty() ? "viewport" : n.id);
|
|
}
|
|
|
|
void pushPending(UIHost *host, const UINode &n, const char *kind, uint32_t handlerIndex,
|
|
bool toggleValue = false, float floatValue = 0.f, std::string textValue = {}) {
|
|
UIEvent ev;
|
|
ev.host = host;
|
|
ev.hostName = host ? host->meta()->name : "";
|
|
ev.nodeId = n.id;
|
|
ev.kind = kind;
|
|
ev.handlerIndex = handlerIndex;
|
|
ev.toggleValue = toggleValue;
|
|
ev.floatValue = floatValue;
|
|
ev.textValue = std::move(textValue);
|
|
g_pending.push_back(std::move(ev));
|
|
}
|
|
|
|
void walkNode(UIHost *host, UIHost::Tree *tree, int index);
|
|
|
|
void walkSiblings(UIHost *host, UIHost::Tree *tree, int index) {
|
|
while (index >= 0 && index < int(tree->nodes.size())) {
|
|
const int next = tree->nodes[size_t(index)].nextSibling;
|
|
walkNode(host, tree, index);
|
|
index = next;
|
|
}
|
|
}
|
|
|
|
void emitSpacer(float main, float cross, bool row) {
|
|
if (main < 0.f) main = 0.f;
|
|
if (cross < 0.f) cross = 0.f;
|
|
if (row) {
|
|
if (main <= 0.f && cross <= 0.f) {
|
|
ImGui::Dummy(ImVec2(0.f, 0.f));
|
|
} else {
|
|
ImGui::Dummy(ImVec2(main, cross > 0.f ? cross : 1.f));
|
|
}
|
|
} else {
|
|
if (main <= 0.f && cross <= 0.f) {
|
|
ImGui::Dummy(ImVec2(0.f, 0.f));
|
|
} else {
|
|
ImGui::Dummy(ImVec2(cross > 0.f ? cross : 1.f, main));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Draw a texture with optional nine-patch borders (ImGui 1.83 has no AddImageNineSlice). */
|
|
void drawNinePatch(void *handle, const ImVec2 &pos, const ImVec2 &size, const ImVec2 &uv0,
|
|
const ImVec2 &uv1, float bL, float bT, float bR, float bB, int texW, int texH,
|
|
ImU32 tint) {
|
|
ImDrawList *dl = ImGui::GetWindowDrawList();
|
|
const auto draw = [&](float x0, float y0, float x1, float y1, float u0, float v0, float u1,
|
|
float v1) {
|
|
dl->AddImage(static_cast<ImTextureID>(handle), ImVec2(pos.x + x0, pos.y + y0),
|
|
ImVec2(pos.x + x1, pos.y + y1), ImVec2(u0, v0), ImVec2(u1, v1), tint);
|
|
};
|
|
if (bL <= 0.f && bT <= 0.f && bR <= 0.f && bB <= 0.f) {
|
|
draw(0.f, 0.f, size.x, size.y, uv0.x, uv0.y, uv1.x, uv1.y);
|
|
return;
|
|
}
|
|
if (texW <= 0 || texH <= 0) {
|
|
draw(0.f, 0.f, size.x, size.y, uv0.x, uv0.y, uv1.x, uv1.y);
|
|
return;
|
|
}
|
|
const float uPerPx = (uv1.x - uv0.x) / float(texW);
|
|
const float vPerPx = (uv1.y - uv0.y) / float(texH);
|
|
const float uL = uv0.x + std::min(bL, size.x * 0.5f) * uPerPx;
|
|
const float uR = uv1.x - std::min(bR, size.x * 0.5f) * uPerPx;
|
|
const float vT = uv0.y + std::min(bT, size.y * 0.5f) * vPerPx;
|
|
const float vB = uv1.y - std::min(bB, size.y * 0.5f) * vPerPx;
|
|
const float bl = std::min(bL, size.x * 0.5f);
|
|
const float br = std::min(bR, size.x * 0.5f);
|
|
const float bt = std::min(bT, size.y * 0.5f);
|
|
const float bb = std::min(bB, size.y * 0.5f);
|
|
// Corners
|
|
draw(0.f, 0.f, bl, bt, uv0.x, uv0.y, uL, vT);
|
|
draw(size.x - br, 0.f, size.x, bt, uR, uv0.y, uv1.x, vT);
|
|
draw(0.f, size.y - bb, bl, size.y, uv0.x, vB, uL, uv1.y);
|
|
draw(size.x - br, size.y - bb, size.x, size.y, uR, vB, uv1.x, uv1.y);
|
|
// Edges
|
|
draw(bl, 0.f, size.x - br, bt, uL, uv0.y, uR, vT);
|
|
draw(bl, size.y - bb, size.x - br, size.y, uL, vB, uR, uv1.y);
|
|
draw(0.f, bt, bl, size.y - bb, uv0.x, vT, uL, vB);
|
|
draw(size.x - br, bt, size.x, size.y - bb, uR, vT, uv1.x, vB);
|
|
// Center
|
|
draw(bl, bt, size.x - br, size.y - bb, uL, vT, uR, vB);
|
|
}
|
|
|
|
void walkFlex(UIHost *host, UIHost::Tree *tree, UINode &flex) {
|
|
const bool row = flex.flexDirection == FlexDirection::Row;
|
|
const ImGuiStyle &style = ImGui::GetStyle();
|
|
const float gap = flex.gap >= 0.f ? flex.gap : (row ? style.ItemSpacing.x : style.ItemSpacing.y);
|
|
|
|
// Own content box: an explicit size (set by the parent's arrange) wins;
|
|
// otherwise use the available region (root flex inside a window/child).
|
|
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
|
const float flexW = flex.sizeX > 0.f ? flex.sizeX : avail.x;
|
|
const float flexH = flex.sizeY > 0.f ? flex.sizeY : avail.y;
|
|
const float padMain = row ? flex.paddingL + flex.paddingR : flex.paddingT + flex.paddingB;
|
|
const float padCross = row ? flex.paddingT + flex.paddingB : flex.paddingL + flex.paddingR;
|
|
const float availMain = (row ? flexW : flexH) - padMain;
|
|
const float availCross = (row ? flexH : flexW) - padCross;
|
|
|
|
std::vector<int> kids;
|
|
std::vector<int> absKids;
|
|
std::vector<FlexItemSpec> specs;
|
|
for (int c = flex.firstChild; c >= 0; c = tree->nodes[size_t(c)].nextSibling) {
|
|
if (c >= int(tree->nodes.size())) break;
|
|
UINode &child = tree->nodes[size_t(c)];
|
|
if (!child.visible) continue;
|
|
if (child.absolute) {
|
|
absKids.push_back(c);
|
|
continue;
|
|
}
|
|
kids.push_back(c);
|
|
FlexItemSpec s;
|
|
s.basisMain = row ? child.measuredW : child.measuredH;
|
|
s.basisCross = row ? child.measuredH : child.measuredW;
|
|
s.marginBefore = row ? child.marginL : child.marginT;
|
|
s.marginAfter = row ? child.marginR : child.marginB;
|
|
s.marginCrossBefore = row ? child.marginT : child.marginL;
|
|
s.marginCrossAfter = row ? child.marginB : child.marginR;
|
|
s.flexGrow = child.flexGrow;
|
|
s.isSpacer = child.type == NodeType::Spacer;
|
|
s.minMain = row ? child.minSizeX : child.minSizeY;
|
|
s.maxMain = row ? child.maxSizeX : child.maxSizeY;
|
|
s.minCross = row ? child.minSizeY : child.minSizeX;
|
|
s.maxCross = row ? child.maxSizeY : child.maxSizeX;
|
|
s.percentMain = row ? child.percentW : child.percentH;
|
|
s.percentCross = row ? child.percentH : child.percentW;
|
|
s.explicitMain = row ? child.sizeX : child.sizeY;
|
|
s.explicitCross = row ? child.sizeY : child.sizeX;
|
|
specs.push_back(s);
|
|
}
|
|
|
|
const FlexResult res =
|
|
flexArrange(row, gap, std::max(0.f, availMain), std::max(0.f, availCross),
|
|
flex.alignItems, flex.justifyContent, specs);
|
|
const ImVec2 cursor = ImGui::GetCursorScreenPos();
|
|
const ImVec2 origin = ImVec2(cursor.x + flex.paddingL, cursor.y + flex.paddingT);
|
|
|
|
for (size_t i = 0; i < kids.size(); ++i) {
|
|
UINode &child = tree->nodes[size_t(kids[i])];
|
|
const FlexRect &r = res.items[i];
|
|
ImGui::SetCursorScreenPos(ImVec2(origin.x + r.x, origin.y + r.y));
|
|
const float oldX = child.sizeX;
|
|
const float oldY = child.sizeY;
|
|
bool pushedWidth = false;
|
|
if (r.w > 0.f) child.sizeX = r.w;
|
|
if (r.h > 0.f) child.sizeY = r.h;
|
|
if (row || r.w > 0.f) {
|
|
ImGui::PushItemWidth(r.w > 0.f ? r.w : 1.f);
|
|
pushedWidth = true;
|
|
}
|
|
walkNode(host, tree, kids[i]);
|
|
child.sizeX = oldX;
|
|
child.sizeY = oldY;
|
|
if (pushedWidth) ImGui::PopItemWidth();
|
|
}
|
|
|
|
// Absolutely placed children are excluded from flow; draw on top of it.
|
|
for (int c : absKids) {
|
|
UINode &child = tree->nodes[size_t(c)];
|
|
float w = child.percentW > 0.f ? child.percentW * flexW : child.measuredW;
|
|
float h = child.percentH > 0.f ? child.percentH * flexH : child.measuredH;
|
|
if (child.sizeX > 0.f) w = child.sizeX;
|
|
if (child.sizeY > 0.f) h = child.sizeY;
|
|
if (child.minSizeX > 0.f) w = std::max(w, child.minSizeX);
|
|
if (child.minSizeY > 0.f) h = std::max(h, child.minSizeY);
|
|
if (child.maxSizeX > 0.f) w = std::min(w, child.maxSizeX);
|
|
if (child.maxSizeY > 0.f) h = std::min(h, child.maxSizeY);
|
|
const float x = child.anchorX * flexW + child.posX - child.anchorX * w;
|
|
const float y = child.anchorY * flexH + child.posY - child.anchorY * h;
|
|
ImGui::SetCursorScreenPos(ImVec2(origin.x + x, origin.y + y));
|
|
const float oldX = child.sizeX;
|
|
const float oldY = child.sizeY;
|
|
child.sizeX = w;
|
|
child.sizeY = h;
|
|
if (w > 0.f) ImGui::PushItemWidth(w);
|
|
walkNode(host, tree, c);
|
|
child.sizeX = oldX;
|
|
child.sizeY = oldY;
|
|
if (w > 0.f) ImGui::PopItemWidth();
|
|
}
|
|
}
|
|
|
|
void walkNode(UIHost *host, UIHost::Tree *tree, int index) {
|
|
if (index < 0 || index >= int(tree->nodes.size())) return;
|
|
UINode &n = tree->nodes[size_t(index)];
|
|
if (!n.visible) return;
|
|
|
|
switch (n.type) {
|
|
case NodeType::Window: {
|
|
std::string title = n.text.empty() ? "Window" : n.text;
|
|
if (host && !host->meta()->name.empty()) title = host->meta()->name + "/" + title;
|
|
const bool modal = host && host->meta()->modal;
|
|
ImGuiWindowFlags flags = 0;
|
|
float winW = 0.f;
|
|
float winH = 0.f;
|
|
if (host && host->meta()->percentW > 0.f)
|
|
winW = host->meta()->percentW * ImGui::GetIO().DisplaySize.x;
|
|
else if (host && host->meta()->hasSize)
|
|
winW = host->meta()->sizeX;
|
|
if (host && host->meta()->percentH > 0.f)
|
|
winH = host->meta()->percentH * ImGui::GetIO().DisplaySize.y;
|
|
else if (host && host->meta()->hasSize)
|
|
winH = host->meta()->sizeY;
|
|
if (host && host->meta()->hasPos) {
|
|
const ImVec2 display = ImGui::GetIO().DisplaySize;
|
|
const float px = host->meta()->pivotX;
|
|
const float py = host->meta()->pivotY;
|
|
const float x = host->meta()->anchorX * display.x + host->meta()->posX - px * winW;
|
|
const float y = host->meta()->anchorY * display.y + host->meta()->posY - py * winH;
|
|
ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always,
|
|
ImVec2(host->meta()->pivotX, host->meta()->pivotY));
|
|
flags |= ImGuiWindowFlags_NoMove;
|
|
if (winW <= 0.f && winH <= 0.f) flags |= ImGuiWindowFlags_AlwaysAutoResize;
|
|
}
|
|
if (winW > 0.f || winH > 0.f) {
|
|
ImGui::SetNextWindowSize(
|
|
ImVec2(winW > 0.f ? winW : -1.f, winH > 0.f ? winH : -1.f), ImGuiCond_Always);
|
|
} else if (n.measuredW > 0.f || n.measuredH > 0.f) {
|
|
// Real measured size → stable auto-resize instead of ImGui's guess.
|
|
ImGui::SetNextWindowContentSize(ImVec2(n.measuredW, n.measuredH));
|
|
}
|
|
if (host && host->meta()->overlay) {
|
|
flags |= ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoCollapse |
|
|
ImGuiWindowFlags_AlwaysAutoResize;
|
|
ImGui::SetNextWindowBgAlpha(0.4f);
|
|
}
|
|
if (modal) {
|
|
ImGui::OpenPopup(title.c_str());
|
|
if (ImGui::BeginPopupModal(title.c_str(), nullptr,
|
|
ImGuiWindowFlags_AlwaysAutoResize | flags)) {
|
|
if (n.firstChild >= 0) walkSiblings(host, tree, n.firstChild);
|
|
ImGui::EndPopup();
|
|
}
|
|
} else {
|
|
ImGui::Begin(title.c_str(), nullptr, flags);
|
|
if (n.firstChild >= 0) walkSiblings(host, tree, n.firstChild);
|
|
ImGui::End();
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::Text:
|
|
if (n.wrapWidth > 0.f) {
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + n.wrapWidth);
|
|
ImGui::TextUnformatted(n.text.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
} else {
|
|
ImGui::TextUnformatted(n.text.c_str());
|
|
}
|
|
break;
|
|
case NodeType::Button: {
|
|
const char *label = n.text.empty() ? "Button" : n.text.c_str();
|
|
const bool sized = n.sizeX > 0.f || n.sizeY > 0.f;
|
|
const bool clicked =
|
|
sized ? ImGui::Button(label, ImVec2(n.sizeX > 0.f ? n.sizeX : 0.f,
|
|
n.sizeY > 0.f ? n.sizeY : 0.f))
|
|
: ImGui::Button(label);
|
|
if (clicked) pushPending(host, n, "click", n.handlerClick);
|
|
break;
|
|
}
|
|
case NodeType::SameLine:
|
|
ImGui::SameLine();
|
|
break;
|
|
case NodeType::Group:
|
|
ImGui::BeginGroup();
|
|
if (n.firstChild >= 0) walkSiblings(host, tree, n.firstChild);
|
|
ImGui::EndGroup();
|
|
break;
|
|
case NodeType::Separator:
|
|
ImGui::Separator();
|
|
break;
|
|
case NodeType::Checkbox: {
|
|
bool checked = n.checked;
|
|
if (ImGui::Checkbox(n.text.empty() ? "Check" : n.text.c_str(), &checked)) {
|
|
n.checked = checked;
|
|
pushPending(host, n, "toggle", n.handlerToggle, checked);
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::Slider: {
|
|
float v = n.value;
|
|
const char *label = n.text.empty() ? "Slider" : n.text.c_str();
|
|
if (ImGui::SliderFloat(label, &v, n.minValue, n.maxValue)) {
|
|
n.value = v;
|
|
pushPending(host, n, "value", n.handlerValue, false, v);
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::Progress: {
|
|
const char *overlay = n.text.empty() ? nullptr : n.text.c_str();
|
|
ImGui::ProgressBar(n.value, ImVec2(-1.f, 0.f), overlay);
|
|
break;
|
|
}
|
|
case NodeType::Image: {
|
|
const float w = n.sizeX > 0.f ? n.sizeX : 32.f;
|
|
const float h = n.sizeY > 0.f ? n.sizeY : 32.f;
|
|
const ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
const ImVec2 size(w, h);
|
|
const ImU32 tint =
|
|
ImGui::ColorConvertFloat4ToU32(ImVec4(n.tintR, n.tintG, n.tintB, n.tintA));
|
|
if (n.textureId != 0 && g_backend) {
|
|
void *handle = g_backend->textureHandle(n.textureId);
|
|
int tw = 0, th = 0;
|
|
g_backend->textureSize(n.textureId, &tw, &th);
|
|
drawNinePatch(handle, pos, size, ImVec2(n.uv0x, n.uv0y), ImVec2(n.uv1x, n.uv1y),
|
|
n.borderL, n.borderT, n.borderR, n.borderB, tw, th, tint);
|
|
} else {
|
|
ImGui::GetWindowDrawList()->AddRectFilled(pos, ImVec2(pos.x + w, pos.y + h), tint,
|
|
n.cornerRadius);
|
|
}
|
|
ImGui::Dummy(size);
|
|
break;
|
|
}
|
|
case NodeType::ImageButton: {
|
|
const float w = n.sizeX > 0.f ? n.sizeX : 32.f;
|
|
const float h = n.sizeY > 0.f ? n.sizeY : 32.f;
|
|
const ImVec2 size(w, h);
|
|
const std::string sid = n.id.empty() ? "imgbtn" : n.id;
|
|
ImGui::PushID(sid.c_str());
|
|
bool clicked = false;
|
|
if (n.textureId != 0 && g_backend) {
|
|
void *handle = g_backend->textureHandle(n.textureId);
|
|
if (handle) {
|
|
clicked = ImGui::ImageButton(static_cast<ImTextureID>(handle), size,
|
|
ImVec2(n.uv0x, n.uv0y), ImVec2(n.uv1x, n.uv1y), -1,
|
|
ImVec4(0.f, 0.f, 0.f, 0.f),
|
|
ImVec4(n.tintR, n.tintG, n.tintB, n.tintA));
|
|
}
|
|
}
|
|
if (!clicked && n.textureId == 0) {
|
|
clicked = ImGui::InvisibleButton(sid.c_str(), size);
|
|
const ImVec2 pmin = ImGui::GetItemRectMin();
|
|
const ImVec2 pmax = ImGui::GetItemRectMax();
|
|
ImU32 col =
|
|
ImGui::ColorConvertFloat4ToU32(ImVec4(n.tintR, n.tintG, n.tintB, n.tintA));
|
|
if (ImGui::IsItemHovered()) col = ImGui::GetColorU32(ImGuiCol_ButtonHovered);
|
|
ImGui::GetWindowDrawList()->AddRectFilled(pmin, pmax, col, n.cornerRadius);
|
|
}
|
|
ImGui::PopID();
|
|
if (clicked) pushPending(host, n, "click", n.handlerClick);
|
|
break;
|
|
}
|
|
case NodeType::Combo: {
|
|
// Options are newline-separated in valueText; selected index in value.
|
|
std::vector<std::string> storage;
|
|
size_t start = 0;
|
|
while (start <= n.valueText.size()) {
|
|
const size_t end = n.valueText.find('\n', start);
|
|
storage.push_back(n.valueText.substr(
|
|
start, end == std::string::npos ? std::string::npos : end - start));
|
|
if (end == std::string::npos) break;
|
|
start = end + 1;
|
|
}
|
|
std::vector<const char *> items;
|
|
items.reserve(storage.size());
|
|
for (const auto &s : storage) items.push_back(s.c_str());
|
|
int idx = int(n.value);
|
|
const char *label = n.text.empty() ? "Combo" : n.text.c_str();
|
|
if (ImGui::Combo(label, &idx, items.data(), int(items.size()))) {
|
|
n.value = float(idx);
|
|
pushPending(host, n, "value", n.handlerValue, false, float(idx));
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::InputText: {
|
|
char buf[1024];
|
|
std::memset(buf, 0, sizeof(buf));
|
|
if (!n.valueText.empty()) {
|
|
std::strncpy(buf, n.valueText.c_str(), sizeof(buf) - 1);
|
|
}
|
|
const char *label = n.text.empty() ? "Input" : n.text.c_str();
|
|
if (ImGui::InputText(label, buf, sizeof(buf))) {
|
|
n.valueText = buf;
|
|
pushPending(host, n, "text", n.handlerText, false, 0.f, n.valueText);
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::CollapsingHeader: {
|
|
ImGuiTreeNodeFlags flags = n.open ? ImGuiTreeNodeFlags_DefaultOpen : 0;
|
|
const char *label = n.text.empty() ? "Section" : n.text.c_str();
|
|
if (ImGui::CollapsingHeader(label, flags)) {
|
|
if (n.firstChild >= 0) walkSiblings(host, tree, n.firstChild);
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::Child: {
|
|
ImVec2 size(n.sizeX, n.sizeY);
|
|
const char *cid = n.id.empty() ? "child" : n.id.c_str();
|
|
if (ImGui::BeginChild(cid, size, true)) {
|
|
if (n.firstChild >= 0) walkSiblings(host, tree, n.firstChild);
|
|
}
|
|
ImGui::EndChild();
|
|
break;
|
|
}
|
|
case NodeType::ScrollList: {
|
|
// Virtualized list: only the rows intersecting the viewport are drawn.
|
|
std::vector<int> kids;
|
|
for (int c = n.firstChild; c >= 0; c = tree->nodes[size_t(c)].nextSibling) {
|
|
if (c >= int(tree->nodes.size())) break;
|
|
if (tree->nodes[size_t(c)].visible) kids.push_back(c);
|
|
}
|
|
const int total = int(kids.size());
|
|
const char *cid = n.id.empty() ? "scrolllist" : n.id.c_str();
|
|
const ImVec2 size(n.sizeX > 0.f ? n.sizeX : 0.f,
|
|
n.sizeY > 0.f ? n.sizeY : 120.f);
|
|
ImGui::BeginChild(cid, size, true);
|
|
const float itemH = n.itemHeight > 0.f ? n.itemHeight : ImGui::GetFrameHeight();
|
|
const float contentW = ImGui::GetContentRegionAvail().x;
|
|
const float scrollY = ImGui::GetScrollY();
|
|
const float viewH = ImGui::GetWindowHeight();
|
|
if (total > 0 && itemH > 0.f) {
|
|
int first = int(scrollY / itemH);
|
|
if (first < 0) first = 0;
|
|
if (first > total) first = total;
|
|
int last = int(std::ceil((scrollY + viewH) / itemH));
|
|
if (last < first) last = first;
|
|
if (last > total) last = total;
|
|
for (int i = first; i < last; ++i) {
|
|
UINode &child = tree->nodes[size_t(kids[size_t(i)])];
|
|
ImGui::SetCursorPos(ImVec2(0.f, float(i) * itemH));
|
|
const float oldX = child.sizeX;
|
|
const float oldY = child.sizeY;
|
|
if (contentW > 0.f) child.sizeX = contentW;
|
|
if (contentW > 0.f) ImGui::PushItemWidth(contentW);
|
|
walkNode(host, tree, kids[size_t(i)]);
|
|
child.sizeX = oldX;
|
|
child.sizeY = oldY;
|
|
if (contentW > 0.f) ImGui::PopItemWidth();
|
|
// Force the row to occupy exactly itemH so rows stay uniform.
|
|
ImGui::SetCursorPosY(float(i) * itemH + itemH);
|
|
}
|
|
}
|
|
// Establish the full scrollable extent (scrollbar reflects total rows).
|
|
ImGui::SetCursorPosY(float(total) * itemH);
|
|
ImGui::EndChild();
|
|
break;
|
|
}
|
|
case NodeType::Viewport: {
|
|
const float w = n.sizeX > 0.f ? n.sizeX : ImGui::GetContentRegionAvail().x;
|
|
const float h = n.sizeY > 0.f ? n.sizeY : ImGui::GetContentRegionAvail().y;
|
|
const ImVec2 size(std::max(1.f, w), std::max(1.f, h));
|
|
const std::string key = viewportKey(host, n);
|
|
ViewportState *vs = UISystem::ensureViewport(key, int(size.x), int(size.y));
|
|
|
|
const ImVec2 rectMin = ImGui::GetCursorScreenPos();
|
|
const std::string label = "##vp" + (n.id.empty() ? std::string("viewport") : n.id);
|
|
ImGui::InvisibleButton(label.c_str(), size);
|
|
const bool hovered = ImGui::IsItemHovered();
|
|
const bool active = ImGui::IsItemActive();
|
|
const ImVec2 mouse = ImGui::GetMousePos();
|
|
if (vs) {
|
|
vs->hovered = hovered;
|
|
vs->active = active;
|
|
vs->mouseX = mouse.x - rectMin.x;
|
|
vs->mouseY = mouse.y - rectMin.y;
|
|
if (active) {
|
|
vs->dragDX = ImGui::GetIO().MouseDelta.x;
|
|
vs->dragDY = ImGui::GetIO().MouseDelta.y;
|
|
} else {
|
|
vs->dragDX = 0.f;
|
|
vs->dragDY = 0.f;
|
|
}
|
|
vs->wheel = hovered ? ImGui::GetIO().MouseWheel : 0.f;
|
|
}
|
|
|
|
if (vs && vs->textureId && g_backend) {
|
|
void *handle = g_backend->textureHandle(vs->textureId);
|
|
if (handle) {
|
|
ImGui::SetCursorScreenPos(rectMin);
|
|
ImGui::Image(static_cast<ImTextureID>(handle), size);
|
|
} else {
|
|
ImGui::GetWindowDrawList()->AddRectFilled(
|
|
rectMin, ImVec2(rectMin.x + size.x, rectMin.y + size.y),
|
|
IM_COL32(10, 12, 16, 255));
|
|
}
|
|
} else {
|
|
ImGui::GetWindowDrawList()->AddRectFilled(
|
|
rectMin, ImVec2(rectMin.x + size.x, rectMin.y + size.y), IM_COL32(10, 12, 16, 255));
|
|
}
|
|
break;
|
|
}
|
|
case NodeType::Flex:
|
|
walkFlex(host, tree, n);
|
|
break;
|
|
case NodeType::Spacer:
|
|
// Outside Flex: honor explicit size, otherwise a tiny dummy.
|
|
emitSpacer(n.sizeX > 0.f ? n.sizeX : 0.f, n.sizeY > 0.f ? n.sizeY : 0.f, true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void walk(UIHost *host, UIHost::Tree *tree, int index) { walkSiblings(host, tree, index); }
|
|
|
|
} // namespace
|
|
|
|
std::vector<UIEvent> &UISystem::pendingEvents() { return g_pending; }
|
|
std::vector<UIClick> &UISystem::clickQueue() { return g_clicks; }
|
|
std::vector<UIChange> &UISystem::changeQueue() { return g_changes; }
|
|
|
|
void UISystem::setBackend(UIBackend *backend) { g_backend = backend; }
|
|
UIBackend *UISystem::backend() { return g_backend; }
|
|
const UIStats &UISystem::stats() { return g_stats; }
|
|
|
|
ViewportState *UISystem::ensureViewport(const std::string &key, int w, int h) {
|
|
if (key.empty() || w <= 0 || h <= 0) return nullptr;
|
|
ViewportState &vs = g_viewports[key];
|
|
vs.key = key;
|
|
auto *gfx = eve::ModuleManager::getInstance<eve::graphics::Graphics>("Graphics");
|
|
if (!gfx) return &vs;
|
|
if (!vs.canvas || vs.width != w || vs.height != h) {
|
|
if (vs.textureId && g_backend) g_backend->unregisterTexture(vs.textureId);
|
|
vs.textureId = 0;
|
|
vs.canvas = gfx->newCanvas(w, h);
|
|
vs.width = w;
|
|
vs.height = h;
|
|
if (vs.canvas && g_backend)
|
|
vs.textureId = g_backend->registerTexture(vs.canvas->getTexture());
|
|
}
|
|
return &vs;
|
|
}
|
|
|
|
ViewportState *UISystem::viewportState(const std::string &hostName, const std::string &nodeId) {
|
|
auto it = g_viewports.find(hostName + "/" + nodeId);
|
|
return it == g_viewports.end() ? nullptr : &it->second;
|
|
}
|
|
|
|
UIHost *UISystem::findHost(const std::string &name) {
|
|
if (name.empty()) return nullptr;
|
|
if (ecs::current()->getManager<UIHost>() == nullptr) return nullptr;
|
|
auto view = ecs::View<UIHost, UIHost::Meta, UIHost::Tree>();
|
|
for (auto it = view.begin(); it != view.end(); ++it) {
|
|
auto [meta, tree] = *it;
|
|
(void)tree;
|
|
if (meta->name == name) return meta->entity;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
UIHost *UISystem::findHostByOwner(uint32_t ownerId) {
|
|
if (ownerId == 0) return nullptr;
|
|
if (ecs::current()->getManager<UIHost>() == nullptr) return nullptr;
|
|
auto view = ecs::View<UIHost, UIHost::Meta, UIHost::Tree>();
|
|
for (auto it = view.begin(); it != view.end(); ++it) {
|
|
auto [meta, tree] = *it;
|
|
(void)tree;
|
|
if (meta->ownerId == ownerId) return meta->entity;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void UISystem::render() {
|
|
if (ecs::current()->getManager<UIHost>() == nullptr) return;
|
|
|
|
applyThemeToImGui(globalTheme());
|
|
|
|
struct Item {
|
|
UIHost *host;
|
|
UIHost::Meta *meta;
|
|
UIHost::Tree *tree;
|
|
};
|
|
std::vector<Item> items;
|
|
|
|
auto view = ecs::View<UIHost, UIHost::Meta, UIHost::Tree>();
|
|
for (auto it = view.begin(); it != view.end(); ++it) {
|
|
auto [meta, tree] = *it;
|
|
if (!meta->visible) continue;
|
|
UIHost *host = meta->entity;
|
|
if (!host) continue;
|
|
items.push_back(Item{host, meta, tree});
|
|
}
|
|
|
|
std::stable_sort(items.begin(), items.end(),
|
|
[](const Item &a, const Item &b) { return a.meta->layer < b.meta->layer; });
|
|
|
|
g_stats.hostCount = int(items.size());
|
|
g_stats.nodeCount = 0;
|
|
g_stats.measureMs = 0.0;
|
|
g_stats.walkMs = 0.0;
|
|
for (auto &item : items) {
|
|
item.tree->dirty = false;
|
|
// Real measure pass: nested containers, text metrics, margins/min/max.
|
|
const auto m0 = std::chrono::steady_clock::now();
|
|
measureTree(*item.tree);
|
|
const auto m1 = std::chrono::steady_clock::now();
|
|
g_stats.nodeCount += int(item.tree->nodes.size());
|
|
if (item.tree->root >= 0) walk(item.host, item.tree, item.tree->root);
|
|
const auto m2 = std::chrono::steady_clock::now();
|
|
g_stats.measureMs +=
|
|
std::chrono::duration<double, std::milli>(m1 - m0).count();
|
|
g_stats.walkMs += std::chrono::duration<double, std::milli>(m2 - m1).count();
|
|
}
|
|
}
|
|
|
|
void UISystem::dispatchEvents() {
|
|
auto events = std::move(g_pending);
|
|
g_pending.clear();
|
|
for (auto &ev : events) {
|
|
if (!ev.host) continue;
|
|
|
|
if (ev.kind == "click" && !ev.nodeId.empty()) {
|
|
UIClick c;
|
|
c.hostName = ev.hostName.empty() ? ev.host->meta()->name : ev.hostName;
|
|
c.nodeId = ev.nodeId;
|
|
g_clicks.push_back(std::move(c));
|
|
}
|
|
|
|
if ((ev.kind == "toggle" || ev.kind == "value" || ev.kind == "text") && !ev.nodeId.empty()) {
|
|
UIChange ch;
|
|
ch.hostName = ev.hostName.empty() ? ev.host->meta()->name : ev.hostName;
|
|
ch.nodeId = ev.nodeId;
|
|
ch.kind = ev.kind;
|
|
g_changes.push_back(std::move(ch));
|
|
}
|
|
|
|
if (ev.kind == "toggle" && ev.handlerIndex != 0) {
|
|
auto t = ev.host->tree();
|
|
size_t idx = size_t(ev.handlerIndex - 1);
|
|
if (idx < t->toggleHandlers.size() && t->toggleHandlers[idx])
|
|
t->toggleHandlers[idx](ev.toggleValue);
|
|
continue;
|
|
}
|
|
if (ev.kind == "value" && ev.handlerIndex != 0) {
|
|
auto t = ev.host->tree();
|
|
size_t idx = size_t(ev.handlerIndex - 1);
|
|
if (idx < t->valueHandlers.size() && t->valueHandlers[idx])
|
|
t->valueHandlers[idx](ev.floatValue);
|
|
continue;
|
|
}
|
|
if (ev.kind == "text" && ev.handlerIndex != 0) {
|
|
auto t = ev.host->tree();
|
|
size_t idx = size_t(ev.handlerIndex - 1);
|
|
if (idx < t->textHandlers.size() && t->textHandlers[idx])
|
|
t->textHandlers[idx](ev.textValue);
|
|
continue;
|
|
}
|
|
if (ev.handlerIndex == 0) continue;
|
|
auto t = ev.host->tree();
|
|
size_t idx = size_t(ev.handlerIndex - 1);
|
|
if (idx >= t->clickHandlers.size()) continue;
|
|
if (t->clickHandlers[idx]) t->clickHandlers[idx]();
|
|
}
|
|
}
|
|
|
|
std::string UISystem::consumeClick() {
|
|
if (g_clicks.empty()) return {};
|
|
UIClick c = std::move(g_clicks.front());
|
|
g_clicks.erase(g_clicks.begin());
|
|
if (c.hostName.empty()) return c.nodeId;
|
|
return c.hostName + "/" + c.nodeId;
|
|
}
|
|
|
|
std::string UISystem::consumeClickFor(const std::string &hostName) {
|
|
for (auto it = g_clicks.begin(); it != g_clicks.end(); ++it) {
|
|
if (it->hostName != hostName) continue;
|
|
std::string id = std::move(it->nodeId);
|
|
g_clicks.erase(it);
|
|
return id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
std::string UISystem::consumeChange() {
|
|
if (g_changes.empty()) return {};
|
|
UIChange c = std::move(g_changes.front());
|
|
g_changes.erase(g_changes.begin());
|
|
if (c.hostName.empty()) return c.nodeId;
|
|
return c.hostName + "/" + c.nodeId;
|
|
}
|
|
|
|
} // namespace eve::ui
|