Add XCUI runtime screen layer and demo textarea
This commit is contained in:
@@ -52,6 +52,19 @@
|
||||
value="" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Card id="notesCard" style="MetricCard">
|
||||
<Column gap="6">
|
||||
<Text text="Session notes" style="MetricLabel" />
|
||||
<TextArea
|
||||
id="sessionNotes"
|
||||
style="CommandArea"
|
||||
width="stretch"
|
||||
min-width="240"
|
||||
rows="4"
|
||||
placeholder="Write multiline notes, prompts, or todos for the current screen..."
|
||||
value="" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Button id="toggleAccent" action="demo.toggleAccent" style="AccentButton">
|
||||
<Text text="Toggle Accent" style="ButtonLabel" />
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
Current execution stays inside the XCUI module and `new_editor`.
|
||||
Old `editor` replacement is explicitly out of scope for this phase.
|
||||
|
||||
## Latest Checkpoint
|
||||
|
||||
- Phase 1 sandbox batch committed and pushed as `67a28bd` (`Add XCUI new editor sandbox phase 1`).
|
||||
- Current work has moved into Phase 2: filling the three-layer XCUI structure instead of replacing the old editor.
|
||||
|
||||
## Three-Layer Status
|
||||
|
||||
### 1. Common Core
|
||||
@@ -12,21 +17,26 @@ Old `editor` replacement is explicitly out of scope for this phase.
|
||||
- `UI::DrawData`, input event types, focus routing, style/theme resolution are in active use.
|
||||
- `UIDocumentCompiler` was restored to a stable buildable baseline after a broken parallel schema attempt corrupted the file.
|
||||
- Build-system hardening for MSVC/PDB output paths has started in root CMake, `engine/CMakeLists.txt`, `new_editor/CMakeLists.txt`, and `tests/NewEditor/CMakeLists.txt`.
|
||||
- Shared engine-side XCUI runtime scaffolding is now present under `engine/include/XCEngine/UI/Runtime` and `engine/src/UI/Runtime`.
|
||||
- Core regression coverage now includes `UIContext`, layout, style, and runtime screen-player tests through `core_ui_tests`.
|
||||
|
||||
Current gap:
|
||||
|
||||
- Schema/validation is not yet landed in a stable form.
|
||||
- Shared widget/runtime instantiation is still thin and mostly editor-side.
|
||||
- Common widget primitives are still incomplete: multiline text editing, tree/list virtualization, property-grid composition, and native image/source-rect level APIs.
|
||||
|
||||
### 2. Runtime/Game Layer
|
||||
|
||||
- Runtime-side XCUI is still shallow.
|
||||
- The main concrete progress here is that the retained-mode demo runtime now supports a real `TextField` input path with UTF-8 text entry and backspace handling.
|
||||
- This proves that the runtime-facing layer is no longer limited to static cards/buttons.
|
||||
- Engine-side runtime ownership is no longer zero: `UIScreenPlayer` and `UISystem` now define a minimal shared runtime contract for loading a screen document, ticking it with input, and collecting `UI::UIDrawData`.
|
||||
|
||||
Current gap:
|
||||
|
||||
- No real game-facing screen host, menu stack, HUD stack, or shared runtime widget library yet.
|
||||
- The new runtime layer still needs a real XCUI document host implementation instead of the current host-facing contract only.
|
||||
|
||||
### 3. Editor Layer
|
||||
|
||||
@@ -49,6 +59,7 @@ Current gap:
|
||||
- `new_editor_xcui_rhi_command_compiler_tests`: `6/6`
|
||||
- `new_editor_xcui_rhi_render_backend_tests`: `5/5`
|
||||
- `XCNewEditor` Debug target builds successfully
|
||||
- `core_ui_tests`: `20/20`
|
||||
|
||||
## Landed This Phase
|
||||
|
||||
@@ -72,7 +83,8 @@ Current gap:
|
||||
|
||||
## Next Phase
|
||||
|
||||
1. Re-open common-layer schema/validation on a clean branch and land the smallest stable version.
|
||||
2. Add next editor-facing widgets: `TextArea`, list/tree, property-style sections.
|
||||
3. Move more diagnostics and shell affordances into XCUI-owned editor-layer surfaces instead of only ImGui HUDs.
|
||||
4. Continue phased validation, commit, push, and plan refresh after each stable batch.
|
||||
1. Re-open common-layer schema/validation on a clean baseline and land the smallest stable version.
|
||||
2. Expand runtime/game-layer ownership from `UIScreenPlayer`/`UISystem` into a real XCUI document host plus menu/HUD stack patterns.
|
||||
3. Add next editor-facing widgets: `TextArea`, list/tree, property-style sections.
|
||||
4. Move more diagnostics and shell affordances into XCUI-owned editor-layer surfaces instead of only ImGui HUDs.
|
||||
5. Continue phased validation, commit, push, and plan refresh after each stable batch.
|
||||
|
||||
@@ -516,6 +516,13 @@ add_library(XCEngine STATIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputRouter.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIShortcutRegistry.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputDispatcher.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenPlayer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UISystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Runtime/UIScreenDocumentHost.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Runtime/UIScreenPlayer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Runtime/UISystem.cpp
|
||||
|
||||
# Input
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h
|
||||
|
||||
29
engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h
Normal file
29
engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenTypes.h>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
class IUIScreenDocumentHost {
|
||||
public:
|
||||
virtual ~IUIScreenDocumentHost() = default;
|
||||
|
||||
virtual UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) = 0;
|
||||
virtual UIScreenFrameResult BuildFrame(
|
||||
const UIScreenDocument& document,
|
||||
const UIScreenFrameInput& input) = 0;
|
||||
};
|
||||
|
||||
class UIDocumentScreenHost final : public IUIScreenDocumentHost {
|
||||
public:
|
||||
UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override;
|
||||
UIScreenFrameResult BuildFrame(
|
||||
const UIScreenDocument& document,
|
||||
const UIScreenFrameInput& input) override;
|
||||
};
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
39
engine/include/XCEngine/UI/Runtime/UIScreenPlayer.h
Normal file
39
engine/include/XCEngine/UI/Runtime/UIScreenPlayer.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
class UIScreenPlayer {
|
||||
public:
|
||||
explicit UIScreenPlayer(IUIScreenDocumentHost& documentHost);
|
||||
|
||||
bool Load(const UIScreenAsset& asset);
|
||||
void Unload();
|
||||
|
||||
bool IsLoaded() const;
|
||||
const UIScreenAsset* GetAsset() const;
|
||||
const UIScreenDocument* GetDocument() const;
|
||||
const UIScreenFrameResult& GetLastFrame() const;
|
||||
const std::string& GetLastError() const;
|
||||
std::uint64_t GetPresentedFrameCount() const;
|
||||
|
||||
const UIScreenFrameResult& Update(const UIScreenFrameInput& input);
|
||||
|
||||
private:
|
||||
IUIScreenDocumentHost* m_documentHost = nullptr;
|
||||
UIScreenAsset m_asset = {};
|
||||
UIScreenDocument m_document = {};
|
||||
UIScreenFrameResult m_lastFrame = {};
|
||||
std::string m_lastError = {};
|
||||
std::uint64_t m_presentedFrameCount = 0;
|
||||
};
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
100
engine/include/XCEngine/UI/Runtime/UIScreenTypes.h
Normal file
100
engine/include/XCEngine/UI/Runtime/UIScreenTypes.h
Normal file
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/Resources/UI/UIDocumentTypes.h>
|
||||
#include <XCEngine/UI/DrawData.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
using UIScreenLayerId = std::uint64_t;
|
||||
|
||||
struct UIScreenAsset {
|
||||
std::string screenId = {};
|
||||
std::string documentPath = {};
|
||||
std::string themePath = {};
|
||||
|
||||
bool IsValid() const {
|
||||
return !documentPath.empty();
|
||||
}
|
||||
};
|
||||
|
||||
struct UIScreenDocument {
|
||||
std::string sourcePath = {};
|
||||
std::string displayName = {};
|
||||
std::vector<std::string> dependencies = {};
|
||||
Resources::UIDocumentModel viewDocument = {};
|
||||
Resources::UIDocumentModel themeDocument = {};
|
||||
bool hasThemeDocument = false;
|
||||
|
||||
bool IsValid() const {
|
||||
return !sourcePath.empty();
|
||||
}
|
||||
|
||||
const Resources::UIDocumentModel* GetThemeDocument() const {
|
||||
return hasThemeDocument ? &themeDocument : nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
struct UIScreenLoadResult {
|
||||
bool succeeded = false;
|
||||
std::string errorMessage = {};
|
||||
UIScreenDocument document = {};
|
||||
};
|
||||
|
||||
struct UIScreenFrameInput {
|
||||
UIRect viewportRect = {};
|
||||
std::vector<UIInputEvent> events = {};
|
||||
double deltaTimeSeconds = 0.0;
|
||||
std::uint64_t frameIndex = 0;
|
||||
bool focused = false;
|
||||
};
|
||||
|
||||
struct UIScreenFrameStats {
|
||||
bool documentLoaded = false;
|
||||
std::size_t drawListCount = 0;
|
||||
std::size_t commandCount = 0;
|
||||
std::size_t inputEventCount = 0;
|
||||
std::size_t nodeCount = 0;
|
||||
std::size_t filledRectCommandCount = 0;
|
||||
std::size_t textCommandCount = 0;
|
||||
std::uint64_t presentedFrameIndex = 0;
|
||||
};
|
||||
|
||||
struct UIScreenFrameResult {
|
||||
UIDrawData drawData = {};
|
||||
UIScreenFrameStats stats = {};
|
||||
std::string errorMessage = {};
|
||||
};
|
||||
|
||||
struct UIScreenLayerOptions {
|
||||
std::string debugName = {};
|
||||
bool visible = true;
|
||||
bool acceptsInput = true;
|
||||
bool blocksLayersBelow = false;
|
||||
};
|
||||
|
||||
struct UISystemPresentedLayer {
|
||||
UIScreenLayerId layerId = 0;
|
||||
UIScreenAsset asset = {};
|
||||
UIScreenLayerOptions options = {};
|
||||
UIScreenFrameStats stats = {};
|
||||
};
|
||||
|
||||
struct UISystemFrameResult {
|
||||
UIDrawData drawData = {};
|
||||
std::vector<UISystemPresentedLayer> layers = {};
|
||||
std::size_t presentedLayerCount = 0;
|
||||
std::size_t skippedLayerCount = 0;
|
||||
std::uint64_t frameIndex = 0;
|
||||
std::string errorMessage = {};
|
||||
};
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
50
engine/include/XCEngine/UI/Runtime/UISystem.h
Normal file
50
engine/include/XCEngine/UI/Runtime/UISystem.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
class UISystem {
|
||||
public:
|
||||
explicit UISystem(IUIScreenDocumentHost& documentHost);
|
||||
|
||||
UIScreenPlayer& CreatePlayer(const UIScreenLayerOptions& options = UIScreenLayerOptions());
|
||||
UIScreenLayerId PushScreen(
|
||||
const UIScreenAsset& asset,
|
||||
const UIScreenLayerOptions& options = UIScreenLayerOptions());
|
||||
bool RemoveLayer(UIScreenLayerId layerId);
|
||||
bool SetLayerVisibility(UIScreenLayerId layerId, bool visible);
|
||||
bool SetLayerOptions(UIScreenLayerId layerId, const UIScreenLayerOptions& options);
|
||||
const UIScreenLayerOptions* FindLayerOptions(UIScreenLayerId layerId) const;
|
||||
UIScreenLayerId GetLayerId(std::size_t index) const;
|
||||
void DestroyAllPlayers();
|
||||
|
||||
std::size_t GetPlayerCount() const;
|
||||
std::size_t GetLayerCount() const;
|
||||
|
||||
const UISystemFrameResult& Update(const UIScreenFrameInput& input);
|
||||
void Tick(const UIScreenFrameInput& input);
|
||||
const UISystemFrameResult& GetLastFrame() const;
|
||||
|
||||
const std::vector<std::unique_ptr<UIScreenPlayer>>& GetPlayers() const;
|
||||
|
||||
private:
|
||||
std::size_t FindLayerIndex(UIScreenLayerId layerId) const;
|
||||
|
||||
IUIScreenDocumentHost* m_documentHost = nullptr;
|
||||
std::vector<std::unique_ptr<UIScreenPlayer>> m_players = {};
|
||||
std::vector<UIScreenLayerId> m_layerIds = {};
|
||||
std::vector<UIScreenLayerOptions> m_layerOptions = {};
|
||||
UISystemFrameResult m_lastFrame = {};
|
||||
UIScreenLayerId m_nextLayerId = 1;
|
||||
};
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
531
engine/src/UI/Runtime/UIScreenDocumentHost.cpp
Normal file
531
engine/src/UI/Runtime/UIScreenDocumentHost.cpp
Normal file
@@ -0,0 +1,531 @@
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
|
||||
#include <XCEngine/Core/Containers/String.h>
|
||||
#include <XCEngine/Core/Math/Color.h>
|
||||
#include <XCEngine/Resources/UI/UIDocumentCompiler.h>
|
||||
#include <XCEngine/UI/Layout/LayoutEngine.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::Math::Color;
|
||||
using XCEngine::Resources::CompileUIDocument;
|
||||
using XCEngine::Resources::GetUIDocumentDefaultRootTag;
|
||||
using XCEngine::Resources::UIDocumentAttribute;
|
||||
using XCEngine::Resources::UIDocumentCompileRequest;
|
||||
using XCEngine::Resources::UIDocumentCompileResult;
|
||||
using XCEngine::Resources::UIDocumentKind;
|
||||
using XCEngine::Resources::UIDocumentNode;
|
||||
namespace Layout = XCEngine::UI::Layout;
|
||||
|
||||
constexpr float kDefaultFontSize = 16.0f;
|
||||
constexpr float kSmallFontSize = 13.0f;
|
||||
constexpr float kApproximateGlyphWidth = 0.56f;
|
||||
|
||||
struct RuntimeLayoutNode {
|
||||
const UIDocumentNode* source = nullptr;
|
||||
std::vector<RuntimeLayoutNode> children = {};
|
||||
UISize desiredSize = {};
|
||||
UIRect rect = {};
|
||||
};
|
||||
|
||||
UIColor ToUIColor(const Color& color) {
|
||||
return UIColor(color.r, color.g, color.b, color.a);
|
||||
}
|
||||
|
||||
std::string ToStdString(const Containers::String& value) {
|
||||
return std::string(value.CStr());
|
||||
}
|
||||
|
||||
bool IsUtf8ContinuationByte(unsigned char value) {
|
||||
return (value & 0xC0u) == 0x80u;
|
||||
}
|
||||
|
||||
std::size_t CountUtf8Codepoints(const std::string& text) {
|
||||
std::size_t count = 0u;
|
||||
for (unsigned char ch : text) {
|
||||
if (!IsUtf8ContinuationByte(ch)) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
float MeasureTextWidth(const std::string& text, float fontSize) {
|
||||
return fontSize * kApproximateGlyphWidth * static_cast<float>(CountUtf8Codepoints(text));
|
||||
}
|
||||
|
||||
float MeasureTextHeight(float fontSize) {
|
||||
return fontSize + 6.0f;
|
||||
}
|
||||
|
||||
const UIDocumentAttribute* FindAttribute(const UIDocumentNode& node, const char* name) {
|
||||
for (const UIDocumentAttribute& attribute : node.attributes) {
|
||||
if (attribute.name == name) {
|
||||
return &attribute;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string GetAttribute(
|
||||
const UIDocumentNode& node,
|
||||
const char* name,
|
||||
const std::string& fallback = {}) {
|
||||
const UIDocumentAttribute* attribute = FindAttribute(node, name);
|
||||
return attribute != nullptr ? ToStdString(attribute->value) : fallback;
|
||||
}
|
||||
|
||||
bool TryParseFloat(const std::string& text, float& outValue) {
|
||||
if (text.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char* end = nullptr;
|
||||
const float parsed = std::strtof(text.c_str(), &end);
|
||||
if (end == text.c_str()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
while (*end != '\0') {
|
||||
if (!std::isspace(static_cast<unsigned char>(*end))) {
|
||||
return false;
|
||||
}
|
||||
++end;
|
||||
}
|
||||
|
||||
outValue = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
float ParseFloatAttribute(
|
||||
const UIDocumentNode& node,
|
||||
const char* name,
|
||||
float fallback) {
|
||||
float value = fallback;
|
||||
return TryParseFloat(GetAttribute(node, name), value) ? value : fallback;
|
||||
}
|
||||
|
||||
Layout::UILayoutLength ParseLengthAttribute(
|
||||
const UIDocumentNode& node,
|
||||
const char* name) {
|
||||
const std::string value = GetAttribute(node, name);
|
||||
if (value == "stretch" || value == "fill") {
|
||||
return Layout::UILayoutLength::Stretch();
|
||||
}
|
||||
|
||||
float pixels = 0.0f;
|
||||
return TryParseFloat(value, pixels)
|
||||
? Layout::UILayoutLength::Pixels(pixels)
|
||||
: Layout::UILayoutLength::Auto();
|
||||
}
|
||||
|
||||
Layout::UILayoutThickness ParsePadding(
|
||||
const UIDocumentNode& node,
|
||||
float fallback) {
|
||||
return Layout::UILayoutThickness::Uniform(ParseFloatAttribute(node, "padding", fallback));
|
||||
}
|
||||
|
||||
std::string ResolveNodeText(const UIDocumentNode& node) {
|
||||
const std::string text = GetAttribute(node, "text");
|
||||
if (!text.empty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const std::string title = GetAttribute(node, "title");
|
||||
if (!title.empty()) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return ToStdString(node.tagName);
|
||||
}
|
||||
|
||||
bool IsHorizontalTag(const std::string& tagName) {
|
||||
return tagName == "Row";
|
||||
}
|
||||
|
||||
bool IsContainerTag(const UIDocumentNode& node) {
|
||||
if (node.children.Size() > 0u) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::string tagName = ToStdString(node.tagName);
|
||||
return tagName == "View" ||
|
||||
tagName == "Column" ||
|
||||
tagName == "Row" ||
|
||||
tagName == "Card" ||
|
||||
tagName == "Button";
|
||||
}
|
||||
|
||||
Color ResolveBackgroundColor(const UIDocumentNode& node) {
|
||||
const std::string tone = GetAttribute(node, "tone");
|
||||
const std::string tagName = ToStdString(node.tagName);
|
||||
|
||||
if (tagName == "View") {
|
||||
return Color(0.08f, 0.09f, 0.11f, 1.0f);
|
||||
}
|
||||
if (tone == "accent") {
|
||||
return Color(0.19f, 0.31f, 0.52f, 1.0f);
|
||||
}
|
||||
if (tone == "accent-alt") {
|
||||
return Color(0.24f, 0.26f, 0.39f, 1.0f);
|
||||
}
|
||||
if (tagName == "Button") {
|
||||
return Color(0.20f, 0.23f, 0.29f, 1.0f);
|
||||
}
|
||||
|
||||
return Color(0.13f, 0.15f, 0.18f, 1.0f);
|
||||
}
|
||||
|
||||
Color ResolveBorderColor(const UIDocumentNode& node) {
|
||||
const std::string tone = GetAttribute(node, "tone");
|
||||
if (tone == "accent") {
|
||||
return Color(0.31f, 0.56f, 0.90f, 1.0f);
|
||||
}
|
||||
if (tone == "accent-alt") {
|
||||
return Color(0.47f, 0.51f, 0.76f, 1.0f);
|
||||
}
|
||||
|
||||
return Color(0.25f, 0.28f, 0.33f, 1.0f);
|
||||
}
|
||||
|
||||
RuntimeLayoutNode BuildLayoutTree(const UIDocumentNode& source) {
|
||||
RuntimeLayoutNode node = {};
|
||||
node.source = &source;
|
||||
node.children.reserve(source.children.Size());
|
||||
for (const UIDocumentNode& child : source.children) {
|
||||
node.children.push_back(BuildLayoutTree(child));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
UISize MeasureNode(RuntimeLayoutNode& node) {
|
||||
const UIDocumentNode& source = *node.source;
|
||||
const std::string tagName = ToStdString(source.tagName);
|
||||
|
||||
if (tagName == "Text") {
|
||||
const std::string text = ResolveNodeText(source);
|
||||
node.desiredSize = UISize(
|
||||
MeasureTextWidth(text, kDefaultFontSize),
|
||||
MeasureTextHeight(kDefaultFontSize));
|
||||
return node.desiredSize;
|
||||
}
|
||||
|
||||
if (!IsContainerTag(source)) {
|
||||
node.desiredSize = UISize(
|
||||
(std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f),
|
||||
44.0f);
|
||||
return node.desiredSize;
|
||||
}
|
||||
|
||||
Layout::UIStackLayoutOptions options = {};
|
||||
options.axis = IsHorizontalTag(tagName)
|
||||
? Layout::UILayoutAxis::Horizontal
|
||||
: Layout::UILayoutAxis::Vertical;
|
||||
options.spacing = ParseFloatAttribute(
|
||||
source,
|
||||
"gap",
|
||||
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
|
||||
options.padding = ParsePadding(
|
||||
source,
|
||||
tagName == "View" ? 16.0f : 12.0f);
|
||||
|
||||
std::vector<Layout::UILayoutItem> items = {};
|
||||
items.reserve(node.children.size());
|
||||
for (RuntimeLayoutNode& child : node.children) {
|
||||
Layout::UILayoutItem item = {};
|
||||
item.desiredContentSize = MeasureNode(child);
|
||||
item.width = ParseLengthAttribute(*child.source, "width");
|
||||
item.height = ParseLengthAttribute(*child.source, "height");
|
||||
items.push_back(item);
|
||||
}
|
||||
|
||||
const auto measured = Layout::MeasureStackLayout(options, items);
|
||||
node.desiredSize = measured.desiredSize;
|
||||
|
||||
const std::string title = GetAttribute(source, "title");
|
||||
const std::string subtitle = GetAttribute(source, "subtitle");
|
||||
float headerHeight = 0.0f;
|
||||
if (!title.empty()) {
|
||||
headerHeight += MeasureTextHeight(kDefaultFontSize);
|
||||
}
|
||||
if (!subtitle.empty()) {
|
||||
if (headerHeight > 0.0f) {
|
||||
headerHeight += 4.0f;
|
||||
}
|
||||
headerHeight += MeasureTextHeight(kSmallFontSize);
|
||||
}
|
||||
|
||||
node.desiredSize.width = (std::max)(
|
||||
node.desiredSize.width,
|
||||
MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
|
||||
node.desiredSize.height += headerHeight;
|
||||
|
||||
float explicitWidth = 0.0f;
|
||||
if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) {
|
||||
node.desiredSize.width = explicitWidth;
|
||||
}
|
||||
|
||||
float explicitHeight = 0.0f;
|
||||
if (TryParseFloat(GetAttribute(source, "height"), explicitHeight)) {
|
||||
node.desiredSize.height = explicitHeight;
|
||||
}
|
||||
|
||||
return node.desiredSize;
|
||||
}
|
||||
|
||||
void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) {
|
||||
node.rect = rect;
|
||||
|
||||
const UIDocumentNode& source = *node.source;
|
||||
if (!IsContainerTag(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string tagName = ToStdString(source.tagName);
|
||||
Layout::UIStackLayoutOptions options = {};
|
||||
options.axis = IsHorizontalTag(tagName)
|
||||
? Layout::UILayoutAxis::Horizontal
|
||||
: Layout::UILayoutAxis::Vertical;
|
||||
options.spacing = ParseFloatAttribute(
|
||||
source,
|
||||
"gap",
|
||||
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
|
||||
options.padding = ParsePadding(
|
||||
source,
|
||||
tagName == "View" ? 16.0f : 12.0f);
|
||||
|
||||
float headerHeight = 0.0f;
|
||||
if (!GetAttribute(source, "title").empty()) {
|
||||
headerHeight += MeasureTextHeight(kDefaultFontSize);
|
||||
}
|
||||
if (!GetAttribute(source, "subtitle").empty()) {
|
||||
if (headerHeight > 0.0f) {
|
||||
headerHeight += 4.0f;
|
||||
}
|
||||
headerHeight += MeasureTextHeight(kSmallFontSize);
|
||||
}
|
||||
|
||||
UIRect contentRect = rect;
|
||||
contentRect.y += headerHeight;
|
||||
contentRect.height = (std::max)(0.0f, rect.height - headerHeight);
|
||||
|
||||
std::vector<Layout::UILayoutItem> items = {};
|
||||
items.reserve(node.children.size());
|
||||
for (RuntimeLayoutNode& child : node.children) {
|
||||
Layout::UILayoutItem item = {};
|
||||
item.desiredContentSize = child.desiredSize;
|
||||
item.width = ParseLengthAttribute(*child.source, "width");
|
||||
item.height = ParseLengthAttribute(*child.source, "height");
|
||||
items.push_back(item);
|
||||
}
|
||||
|
||||
const auto arranged = Layout::ArrangeStackLayout(options, items, contentRect);
|
||||
for (std::size_t index = 0; index < node.children.size(); ++index) {
|
||||
ArrangeNode(node.children[index], arranged.children[index].arrangedRect);
|
||||
}
|
||||
}
|
||||
|
||||
void EmitNode(
|
||||
const RuntimeLayoutNode& node,
|
||||
UIDrawList& drawList,
|
||||
UIScreenFrameStats& stats) {
|
||||
const UIDocumentNode& source = *node.source;
|
||||
const std::string tagName = ToStdString(source.tagName);
|
||||
|
||||
++stats.nodeCount;
|
||||
|
||||
if (tagName == "View" || tagName == "Card" || tagName == "Button") {
|
||||
drawList.AddFilledRect(node.rect, ToUIColor(ResolveBackgroundColor(source)), 10.0f);
|
||||
++stats.filledRectCommandCount;
|
||||
|
||||
if (tagName != "View") {
|
||||
drawList.AddRectOutline(node.rect, ToUIColor(ResolveBorderColor(source)), 1.0f, 10.0f);
|
||||
}
|
||||
}
|
||||
|
||||
const std::string title = GetAttribute(source, "title");
|
||||
const std::string subtitle = GetAttribute(source, "subtitle");
|
||||
float textY = node.rect.y + 12.0f;
|
||||
if (!title.empty()) {
|
||||
drawList.AddText(
|
||||
UIPoint(node.rect.x + 12.0f, textY),
|
||||
title,
|
||||
ToUIColor(Color(0.94f, 0.95f, 0.97f, 1.0f)),
|
||||
kDefaultFontSize);
|
||||
++stats.textCommandCount;
|
||||
textY += MeasureTextHeight(kDefaultFontSize) + 2.0f;
|
||||
}
|
||||
|
||||
if (!subtitle.empty()) {
|
||||
drawList.AddText(
|
||||
UIPoint(node.rect.x + 12.0f, textY),
|
||||
subtitle,
|
||||
ToUIColor(Color(0.67f, 0.70f, 0.76f, 1.0f)),
|
||||
kSmallFontSize);
|
||||
++stats.textCommandCount;
|
||||
}
|
||||
|
||||
if (tagName == "Text") {
|
||||
drawList.AddText(
|
||||
UIPoint(node.rect.x, node.rect.y),
|
||||
ResolveNodeText(source),
|
||||
ToUIColor(Color(0.92f, 0.94f, 0.97f, 1.0f)),
|
||||
kDefaultFontSize);
|
||||
++stats.textCommandCount;
|
||||
}
|
||||
|
||||
for (const RuntimeLayoutNode& child : node.children) {
|
||||
EmitNode(child, drawList, stats);
|
||||
}
|
||||
}
|
||||
|
||||
std::string ResolveDisplayName(
|
||||
const UIScreenAsset& asset,
|
||||
const UIDocumentCompileResult& viewResult) {
|
||||
if (!asset.screenId.empty()) {
|
||||
return asset.screenId;
|
||||
}
|
||||
|
||||
if (!viewResult.document.displayName.Empty()) {
|
||||
return ToStdString(viewResult.document.displayName);
|
||||
}
|
||||
|
||||
if (!viewResult.document.sourcePath.Empty()) {
|
||||
return std::filesystem::path(viewResult.document.sourcePath.CStr()).stem().string();
|
||||
}
|
||||
|
||||
return "UIScreen";
|
||||
}
|
||||
|
||||
void AppendDependencies(
|
||||
const Containers::Array<Containers::String>& dependencies,
|
||||
std::unordered_set<std::string>& seenDependencies,
|
||||
std::vector<std::string>& outDependencies) {
|
||||
for (const Containers::String& dependency : dependencies) {
|
||||
const std::string value = ToStdString(dependency);
|
||||
if (value.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenDependencies.insert(value).second) {
|
||||
outDependencies.push_back(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UIScreenLoadResult UIDocumentScreenHost::LoadScreen(const UIScreenAsset& asset) {
|
||||
UIScreenLoadResult result = {};
|
||||
if (!asset.IsValid()) {
|
||||
result.errorMessage = "UIScreenAsset is invalid.";
|
||||
return result;
|
||||
}
|
||||
|
||||
UIDocumentCompileRequest viewRequest = {};
|
||||
viewRequest.kind = UIDocumentKind::View;
|
||||
viewRequest.path = Containers::String(asset.documentPath.c_str());
|
||||
viewRequest.expectedRootTag = GetUIDocumentDefaultRootTag(viewRequest.kind);
|
||||
|
||||
UIDocumentCompileResult viewResult = {};
|
||||
if (!CompileUIDocument(viewRequest, viewResult) ||
|
||||
!viewResult.succeeded ||
|
||||
!viewResult.document.valid) {
|
||||
result.errorMessage = viewResult.errorMessage.Empty()
|
||||
? "Failed to compile UI screen view document."
|
||||
: ToStdString(viewResult.errorMessage);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.succeeded = true;
|
||||
result.document.sourcePath = asset.documentPath;
|
||||
result.document.displayName = ResolveDisplayName(asset, viewResult);
|
||||
result.document.viewDocument = viewResult.document;
|
||||
|
||||
std::unordered_set<std::string> seenDependencies = {};
|
||||
AppendDependencies(
|
||||
viewResult.document.dependencies,
|
||||
seenDependencies,
|
||||
result.document.dependencies);
|
||||
|
||||
if (!asset.themePath.empty()) {
|
||||
UIDocumentCompileRequest themeRequest = {};
|
||||
themeRequest.kind = UIDocumentKind::Theme;
|
||||
themeRequest.path = Containers::String(asset.themePath.c_str());
|
||||
themeRequest.expectedRootTag = GetUIDocumentDefaultRootTag(themeRequest.kind);
|
||||
|
||||
UIDocumentCompileResult themeResult = {};
|
||||
if (!CompileUIDocument(themeRequest, themeResult) ||
|
||||
!themeResult.succeeded ||
|
||||
!themeResult.document.valid) {
|
||||
result = {};
|
||||
result.errorMessage = themeResult.errorMessage.Empty()
|
||||
? "Failed to compile UI screen theme document."
|
||||
: ToStdString(themeResult.errorMessage);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.document.themeDocument = themeResult.document;
|
||||
result.document.hasThemeDocument = true;
|
||||
if (seenDependencies.insert(asset.themePath).second) {
|
||||
result.document.dependencies.push_back(asset.themePath);
|
||||
}
|
||||
AppendDependencies(
|
||||
themeResult.document.dependencies,
|
||||
seenDependencies,
|
||||
result.document.dependencies);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
|
||||
const UIScreenDocument& document,
|
||||
const UIScreenFrameInput& input) {
|
||||
UIScreenFrameResult result = {};
|
||||
if (!document.viewDocument.valid || document.viewDocument.rootNode.tagName.Empty()) {
|
||||
result.errorMessage = "UIScreenDocument does not contain a compiled view document.";
|
||||
return result;
|
||||
}
|
||||
|
||||
RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode);
|
||||
MeasureNode(root);
|
||||
|
||||
UIRect viewportRect = input.viewportRect;
|
||||
if (viewportRect.width <= 0.0f) {
|
||||
viewportRect.width = (std::max)(640.0f, root.desiredSize.width);
|
||||
}
|
||||
if (viewportRect.height <= 0.0f) {
|
||||
viewportRect.height = (std::max)(360.0f, root.desiredSize.height);
|
||||
}
|
||||
|
||||
ArrangeNode(root, viewportRect);
|
||||
|
||||
UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName);
|
||||
EmitNode(root, drawList, result.stats);
|
||||
|
||||
result.stats.documentLoaded = true;
|
||||
result.stats.drawListCount = result.drawData.GetDrawListCount();
|
||||
result.stats.commandCount = result.drawData.GetTotalCommandCount();
|
||||
result.stats.inputEventCount = input.events.size();
|
||||
result.stats.presentedFrameIndex = input.frameIndex;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
106
engine/src/UI/Runtime/UIScreenPlayer.cpp
Normal file
106
engine/src/UI/Runtime/UIScreenPlayer.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
namespace {
|
||||
|
||||
UIScreenFrameResult MakeNotLoadedFrame(const UIScreenFrameInput& input) {
|
||||
UIScreenFrameResult frame = {};
|
||||
frame.errorMessage = "UIScreenPlayer has no loaded screen document.";
|
||||
frame.stats.documentLoaded = false;
|
||||
frame.stats.inputEventCount = input.events.size();
|
||||
frame.stats.presentedFrameIndex = input.frameIndex;
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UIScreenPlayer::UIScreenPlayer(IUIScreenDocumentHost& documentHost)
|
||||
: m_documentHost(&documentHost) {
|
||||
}
|
||||
|
||||
bool UIScreenPlayer::Load(const UIScreenAsset& asset) {
|
||||
if (m_documentHost == nullptr) {
|
||||
m_lastError = "UIScreenPlayer has no document host.";
|
||||
m_document = {};
|
||||
m_asset = {};
|
||||
m_lastFrame = {};
|
||||
m_presentedFrameCount = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
const UIScreenLoadResult loadResult = m_documentHost->LoadScreen(asset);
|
||||
if (!loadResult.succeeded || !loadResult.document.IsValid()) {
|
||||
m_asset = {};
|
||||
m_document = {};
|
||||
m_lastFrame = {};
|
||||
m_presentedFrameCount = 0;
|
||||
m_lastError = loadResult.errorMessage.empty()
|
||||
? "UIScreenPlayer failed to load screen document."
|
||||
: loadResult.errorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
m_asset = asset;
|
||||
m_document = loadResult.document;
|
||||
m_lastFrame = {};
|
||||
m_lastError.clear();
|
||||
m_presentedFrameCount = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void UIScreenPlayer::Unload() {
|
||||
m_asset = {};
|
||||
m_document = {};
|
||||
m_lastFrame = {};
|
||||
m_lastError.clear();
|
||||
m_presentedFrameCount = 0;
|
||||
}
|
||||
|
||||
bool UIScreenPlayer::IsLoaded() const {
|
||||
return m_document.IsValid();
|
||||
}
|
||||
|
||||
const UIScreenAsset* UIScreenPlayer::GetAsset() const {
|
||||
return m_asset.IsValid() ? &m_asset : nullptr;
|
||||
}
|
||||
|
||||
const UIScreenDocument* UIScreenPlayer::GetDocument() const {
|
||||
return m_document.IsValid() ? &m_document : nullptr;
|
||||
}
|
||||
|
||||
const UIScreenFrameResult& UIScreenPlayer::GetLastFrame() const {
|
||||
return m_lastFrame;
|
||||
}
|
||||
|
||||
const std::string& UIScreenPlayer::GetLastError() const {
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
std::uint64_t UIScreenPlayer::GetPresentedFrameCount() const {
|
||||
return m_presentedFrameCount;
|
||||
}
|
||||
|
||||
const UIScreenFrameResult& UIScreenPlayer::Update(const UIScreenFrameInput& input) {
|
||||
if (!IsLoaded() || m_documentHost == nullptr) {
|
||||
m_lastFrame = MakeNotLoadedFrame(input);
|
||||
m_lastError = m_lastFrame.errorMessage;
|
||||
return m_lastFrame;
|
||||
}
|
||||
|
||||
m_lastFrame = m_documentHost->BuildFrame(m_document, input);
|
||||
m_lastFrame.stats.documentLoaded = true;
|
||||
m_lastFrame.stats.drawListCount = m_lastFrame.drawData.GetDrawListCount();
|
||||
m_lastFrame.stats.commandCount = m_lastFrame.drawData.GetTotalCommandCount();
|
||||
m_lastFrame.stats.inputEventCount = input.events.size();
|
||||
m_lastFrame.stats.presentedFrameIndex = input.frameIndex;
|
||||
m_lastError = m_lastFrame.errorMessage;
|
||||
++m_presentedFrameCount;
|
||||
return m_lastFrame;
|
||||
}
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
184
engine/src/UI/Runtime/UISystem.cpp
Normal file
184
engine/src/UI/Runtime/UISystem.cpp
Normal file
@@ -0,0 +1,184 @@
|
||||
#include <XCEngine/UI/Runtime/UISystem.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Runtime {
|
||||
|
||||
UISystem::UISystem(IUIScreenDocumentHost& documentHost)
|
||||
: m_documentHost(&documentHost) {
|
||||
}
|
||||
|
||||
UIScreenPlayer& UISystem::CreatePlayer(const UIScreenLayerOptions& options) {
|
||||
m_players.push_back(std::make_unique<UIScreenPlayer>(*m_documentHost));
|
||||
m_layerIds.push_back(m_nextLayerId++);
|
||||
m_layerOptions.push_back(options);
|
||||
return *m_players.back();
|
||||
}
|
||||
|
||||
UIScreenLayerId UISystem::PushScreen(
|
||||
const UIScreenAsset& asset,
|
||||
const UIScreenLayerOptions& options) {
|
||||
UIScreenPlayer& player = CreatePlayer(options);
|
||||
if (!player.Load(asset)) {
|
||||
m_players.pop_back();
|
||||
m_layerIds.pop_back();
|
||||
m_layerOptions.pop_back();
|
||||
return 0;
|
||||
}
|
||||
return m_layerIds.empty() ? 0 : m_layerIds.back();
|
||||
}
|
||||
|
||||
bool UISystem::RemoveLayer(UIScreenLayerId layerId) {
|
||||
const std::size_t index = FindLayerIndex(layerId);
|
||||
if (index >= m_players.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_players.erase(m_players.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
m_layerIds.erase(m_layerIds.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
m_layerOptions.erase(m_layerOptions.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UISystem::SetLayerVisibility(UIScreenLayerId layerId, bool visible) {
|
||||
const std::size_t index = FindLayerIndex(layerId);
|
||||
if (index >= m_layerOptions.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_layerOptions[index].visible = visible;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UISystem::SetLayerOptions(
|
||||
UIScreenLayerId layerId,
|
||||
const UIScreenLayerOptions& options) {
|
||||
const std::size_t index = FindLayerIndex(layerId);
|
||||
if (index >= m_layerOptions.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_layerOptions[index] = options;
|
||||
return true;
|
||||
}
|
||||
|
||||
const UIScreenLayerOptions* UISystem::FindLayerOptions(UIScreenLayerId layerId) const {
|
||||
const std::size_t index = FindLayerIndex(layerId);
|
||||
return index < m_layerOptions.size()
|
||||
? &m_layerOptions[index]
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
UIScreenLayerId UISystem::GetLayerId(std::size_t index) const {
|
||||
return index < m_layerIds.size() ? m_layerIds[index] : 0;
|
||||
}
|
||||
|
||||
void UISystem::DestroyAllPlayers() {
|
||||
m_players.clear();
|
||||
m_layerIds.clear();
|
||||
m_layerOptions.clear();
|
||||
m_lastFrame = {};
|
||||
}
|
||||
|
||||
std::size_t UISystem::GetPlayerCount() const {
|
||||
return m_players.size();
|
||||
}
|
||||
|
||||
std::size_t UISystem::GetLayerCount() const {
|
||||
return m_layerIds.size();
|
||||
}
|
||||
|
||||
const UISystemFrameResult& UISystem::Update(const UIScreenFrameInput& input) {
|
||||
m_lastFrame = {};
|
||||
m_lastFrame.frameIndex = input.frameIndex;
|
||||
|
||||
std::vector<std::size_t> presentedIndices;
|
||||
presentedIndices.reserve(m_players.size());
|
||||
for (std::size_t index = m_players.size(); index > 0; --index) {
|
||||
const std::size_t layerIndex = index - 1;
|
||||
if (!m_layerOptions[layerIndex].visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
presentedIndices.push_back(layerIndex);
|
||||
if (m_layerOptions[layerIndex].blocksLayersBelow) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::reverse(presentedIndices.begin(), presentedIndices.end());
|
||||
|
||||
std::size_t interactiveLayerIndex = m_players.size();
|
||||
for (std::size_t index = presentedIndices.size(); index > 0; --index) {
|
||||
const std::size_t layerIndex = presentedIndices[index - 1];
|
||||
if (m_layerOptions[layerIndex].acceptsInput) {
|
||||
interactiveLayerIndex = layerIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::size_t layerIndex : presentedIndices) {
|
||||
UIScreenFrameInput layerInput = input;
|
||||
if (layerIndex != interactiveLayerIndex) {
|
||||
layerInput.events.clear();
|
||||
}
|
||||
|
||||
const UIScreenFrameResult& frame = m_players[layerIndex]->Update(layerInput);
|
||||
for (const UIDrawList& drawList : frame.drawData.GetDrawLists()) {
|
||||
m_lastFrame.drawData.AddDrawList(drawList);
|
||||
}
|
||||
|
||||
UISystemPresentedLayer presentedLayer = {};
|
||||
presentedLayer.layerId = m_layerIds[layerIndex];
|
||||
if (const UIScreenAsset* asset = m_players[layerIndex]->GetAsset();
|
||||
asset != nullptr) {
|
||||
presentedLayer.asset = *asset;
|
||||
}
|
||||
presentedLayer.options = m_layerOptions[layerIndex];
|
||||
presentedLayer.stats = frame.stats;
|
||||
m_lastFrame.layers.push_back(std::move(presentedLayer));
|
||||
|
||||
if (m_lastFrame.errorMessage.empty() && !frame.errorMessage.empty()) {
|
||||
m_lastFrame.errorMessage = frame.errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
m_lastFrame.presentedLayerCount = m_lastFrame.layers.size();
|
||||
m_lastFrame.skippedLayerCount =
|
||||
m_players.size() > m_lastFrame.presentedLayerCount
|
||||
? m_players.size() - m_lastFrame.presentedLayerCount
|
||||
: 0;
|
||||
return m_lastFrame;
|
||||
}
|
||||
|
||||
void UISystem::Tick(const UIScreenFrameInput& input) {
|
||||
for (const std::unique_ptr<UIScreenPlayer>& player : m_players) {
|
||||
if (player) {
|
||||
player->Update(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const UISystemFrameResult& UISystem::GetLastFrame() const {
|
||||
return m_lastFrame;
|
||||
}
|
||||
|
||||
const std::vector<std::unique_ptr<UIScreenPlayer>>& UISystem::GetPlayers() const {
|
||||
return m_players;
|
||||
}
|
||||
|
||||
std::size_t UISystem::FindLayerIndex(UIScreenLayerId layerId) const {
|
||||
for (std::size_t index = 0; index < m_layerIds.size(); ++index) {
|
||||
if (m_layerIds[index] == layerId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return m_layerIds.size();
|
||||
}
|
||||
|
||||
} // namespace Runtime
|
||||
} // namespace UI
|
||||
} // namespace XCEngine
|
||||
@@ -2,10 +2,14 @@
|
||||
<Token name="color.surface" type="color" value="#0D1218" />
|
||||
<Token name="color.surface.elevated" type="color" value="#141C26" />
|
||||
<Token name="color.surface.card" type="color" value="#1D2733" />
|
||||
<Token name="color.surface.input" type="color" value="#101923" />
|
||||
<Token name="color.text.primary" type="color" value="#F7F9FC" />
|
||||
<Token name="color.text.secondary" type="color" value="#BCC8DB" />
|
||||
<Token name="color.text.placeholder" type="color" value="#7B8A9B" />
|
||||
<Token name="color.accent" type="color" value="#5EE3FF" />
|
||||
<Token name="color.accent.alt" type="color" value="#FFB65F" />
|
||||
<Token name="color.outline" type="color" value="#3A4A5A" />
|
||||
<Token name="color.surface.track" type="color" value="#243241" />
|
||||
<Token name="space.compact" type="float" value="8" />
|
||||
<Token name="space.regular" type="float" value="14" />
|
||||
<Token name="space.loose" type="float" value="20" />
|
||||
@@ -13,6 +17,7 @@
|
||||
<Token name="padding.card" type="thickness" value="12" />
|
||||
<Token name="radius.card" type="corner-radius" value="10" />
|
||||
<Token name="radius.button" type="corner-radius" value="6" />
|
||||
<Token name="radius.pill" type="corner-radius" value="18" />
|
||||
<Token name="font.body" type="float" value="14" />
|
||||
<Token name="font.title" type="float" value="18" />
|
||||
<Token name="line.default" type="float" value="1" />
|
||||
|
||||
@@ -16,6 +16,29 @@
|
||||
<Text text="Hover + shortcuts" style="MetricValue" />
|
||||
</Card>
|
||||
</Row>
|
||||
<Card id="commandCard" style="MetricCard">
|
||||
<Column gap="6">
|
||||
<Text text="Agent command" style="MetricLabel" />
|
||||
<TextField
|
||||
id="agentPrompt"
|
||||
width="stretch"
|
||||
min-width="240"
|
||||
placeholder="Type a command or note for XCUI..."
|
||||
value="" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Card id="notesCard" style="MetricCard">
|
||||
<Column gap="6">
|
||||
<Text text="Session notes" style="MetricLabel" />
|
||||
<TextArea
|
||||
id="sessionNotes"
|
||||
width="stretch"
|
||||
min-width="240"
|
||||
rows="4"
|
||||
placeholder="Write multiline notes, prompts, or todos for the current screen..."
|
||||
value="" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Button id="toggleAccent" style="AccentButton">
|
||||
<Text text="Toggle Accent" style="ButtonLabel" />
|
||||
</Button>
|
||||
|
||||
@@ -456,6 +456,114 @@ bool IsTextFieldNode(const DemoNode& node) {
|
||||
return node.tagName == "TextField";
|
||||
}
|
||||
|
||||
bool IsTextAreaNode(const DemoNode& node) {
|
||||
return node.tagName == "TextArea";
|
||||
}
|
||||
|
||||
bool IsTextInputNode(const DemoNode& node) {
|
||||
return IsTextFieldNode(node) || IsTextAreaNode(node);
|
||||
}
|
||||
|
||||
std::vector<std::string> SplitLines(const std::string& text) {
|
||||
std::vector<std::string> lines = {};
|
||||
std::size_t lineStart = 0u;
|
||||
for (std::size_t index = 0u; index < text.size(); ++index) {
|
||||
if (text[index] != '\n') {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push_back(text.substr(lineStart, index - lineStart));
|
||||
lineStart = index + 1u;
|
||||
}
|
||||
|
||||
lines.push_back(text.substr(lineStart));
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::size_t CountUtf8CodepointsInRange(
|
||||
const std::string& text,
|
||||
std::size_t beginOffset,
|
||||
std::size_t endOffset) {
|
||||
beginOffset = (std::min)(beginOffset, text.size());
|
||||
endOffset = (std::min)(endOffset, text.size());
|
||||
if (endOffset <= beginOffset) {
|
||||
return 0u;
|
||||
}
|
||||
|
||||
std::size_t count = 0u;
|
||||
for (std::size_t index = beginOffset; index < endOffset; ++index) {
|
||||
if (!IsUtf8ContinuationByte(static_cast<unsigned char>(text[index]))) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
std::size_t AdvanceUtf8Codepoints(
|
||||
const std::string& text,
|
||||
std::size_t offset,
|
||||
std::size_t codepointCount) {
|
||||
offset = (std::min)(offset, text.size());
|
||||
for (std::size_t index = 0u; index < codepointCount && offset < text.size(); ++index) {
|
||||
offset = AdvanceUtf8Offset(text, offset);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
std::size_t FindLineStartOffset(const std::string& text, std::size_t caret) {
|
||||
caret = (std::min)(caret, text.size());
|
||||
while (caret > 0u && text[caret - 1u] != '\n') {
|
||||
--caret;
|
||||
}
|
||||
return caret;
|
||||
}
|
||||
|
||||
std::size_t FindLineEndOffset(const std::string& text, std::size_t caret) {
|
||||
caret = (std::min)(caret, text.size());
|
||||
while (caret < text.size() && text[caret] != '\n') {
|
||||
++caret;
|
||||
}
|
||||
return caret;
|
||||
}
|
||||
|
||||
std::size_t MoveCaretVertically(
|
||||
const std::string& text,
|
||||
std::size_t caret,
|
||||
int direction) {
|
||||
caret = (std::min)(caret, text.size());
|
||||
const std::size_t currentLineStart = FindLineStartOffset(text, caret);
|
||||
const std::size_t currentLineEnd = FindLineEndOffset(text, caret);
|
||||
const std::size_t column = CountUtf8CodepointsInRange(text, currentLineStart, caret);
|
||||
|
||||
if (direction < 0) {
|
||||
if (currentLineStart == 0u) {
|
||||
return caret;
|
||||
}
|
||||
|
||||
const std::size_t previousLineEnd = currentLineStart - 1u;
|
||||
const std::size_t previousLineStart = FindLineStartOffset(text, previousLineEnd);
|
||||
const std::size_t previousLineLength =
|
||||
CountUtf8CodepointsInRange(text, previousLineStart, previousLineEnd);
|
||||
return AdvanceUtf8Codepoints(
|
||||
text,
|
||||
previousLineStart,
|
||||
(std::min)(column, previousLineLength));
|
||||
}
|
||||
|
||||
if (currentLineEnd >= text.size()) {
|
||||
return caret;
|
||||
}
|
||||
|
||||
const std::size_t nextLineStart = currentLineEnd + 1u;
|
||||
const std::size_t nextLineEnd = FindLineEndOffset(text, nextLineStart);
|
||||
const std::size_t nextLineLength =
|
||||
CountUtf8CodepointsInRange(text, nextLineStart, nextLineEnd);
|
||||
return AdvanceUtf8Codepoints(
|
||||
text,
|
||||
nextLineStart,
|
||||
(std::min)(column, nextLineLength));
|
||||
}
|
||||
|
||||
Style::UIStyleValue ParseThemeTokenValue(
|
||||
const std::string& typeName,
|
||||
const std::string& rawValue,
|
||||
@@ -604,6 +712,15 @@ void ConfigureDemoStyleSheet(Style::UIStyleSheet& styleSheet) {
|
||||
styleSheet.GetOrCreateTypeStyle("TextField").SetProperty(
|
||||
Style::UIStylePropertyId::CornerRadius,
|
||||
Style::UIStyleValue::Token("radius.button"));
|
||||
styleSheet.GetOrCreateTypeStyle("TextArea").SetProperty(
|
||||
Style::UIStylePropertyId::BackgroundColor,
|
||||
Style::UIStyleValue::Token("color.surface.input"));
|
||||
styleSheet.GetOrCreateTypeStyle("TextArea").SetProperty(
|
||||
Style::UIStylePropertyId::Padding,
|
||||
Style::UIStyleValue(ParseStyleThickness("12,10")));
|
||||
styleSheet.GetOrCreateTypeStyle("TextArea").SetProperty(
|
||||
Style::UIStylePropertyId::CornerRadius,
|
||||
Style::UIStyleValue::Token("radius.button"));
|
||||
|
||||
styleSheet.GetOrCreateNamedStyle("HeroCard").SetProperty(
|
||||
Style::UIStylePropertyId::BackgroundColor,
|
||||
@@ -915,17 +1032,17 @@ float ResolveProgressValue(const RuntimeBuildContext& state, const DemoNode& nod
|
||||
return TryParseFloat(valueText, parsedValue) ? Clamp01(parsedValue) : 0.0f;
|
||||
}
|
||||
|
||||
std::string ResolveTextFieldStateKey(const DemoNode& node) {
|
||||
std::string ResolveTextInputStateKey(const DemoNode& node) {
|
||||
const std::string stateKey = GetNodeAttribute(node, "state-key");
|
||||
return !stateKey.empty() ? stateKey : node.elementKey;
|
||||
}
|
||||
|
||||
void EnsureTextFieldStateInitialized(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
if (!IsTextFieldNode(node)) {
|
||||
void EnsureTextInputStateInitialized(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
if (!IsTextInputNode(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string stateKey = ResolveTextFieldStateKey(node);
|
||||
const std::string stateKey = ResolveTextInputStateKey(node);
|
||||
auto valueIt = state.textFieldValues.find(stateKey);
|
||||
if (valueIt == state.textFieldValues.end()) {
|
||||
valueIt = state.textFieldValues
|
||||
@@ -941,14 +1058,14 @@ void EnsureTextFieldStateInitialized(RuntimeBuildContext& state, const DemoNode&
|
||||
caretIt->second = (std::min)(caretIt->second, valueIt->second.size());
|
||||
}
|
||||
|
||||
std::string ResolveTextFieldValue(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
EnsureTextFieldStateInitialized(state, node);
|
||||
return state.textFieldValues[ResolveTextFieldStateKey(node)];
|
||||
std::string ResolveTextInputValue(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
EnsureTextInputStateInitialized(state, node);
|
||||
return state.textFieldValues[ResolveTextInputStateKey(node)];
|
||||
}
|
||||
|
||||
std::size_t ResolveTextFieldCaret(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
EnsureTextFieldStateInitialized(state, node);
|
||||
const std::string stateKey = ResolveTextFieldStateKey(node);
|
||||
std::size_t ResolveTextInputCaret(RuntimeBuildContext& state, const DemoNode& node) {
|
||||
EnsureTextInputStateInitialized(state, node);
|
||||
const std::string stateKey = ResolveTextInputStateKey(node);
|
||||
std::size_t& caret = state.textFieldCarets[stateKey];
|
||||
caret = (std::min)(caret, state.textFieldValues[stateKey].size());
|
||||
return caret;
|
||||
@@ -959,7 +1076,7 @@ DemoNode* TryGetNodeByElementId(RuntimeBuildContext& state, UIElementId elementI
|
||||
return it != state.nodeIndexById.end() ? &state.nodes[it->second] : nullptr;
|
||||
}
|
||||
|
||||
bool HandleTextFieldCharacterInput(
|
||||
bool HandleTextInputCharacterInput(
|
||||
RuntimeBuildContext& state,
|
||||
UIElementId elementId,
|
||||
std::uint32_t character) {
|
||||
@@ -968,12 +1085,12 @@ bool HandleTextFieldCharacterInput(
|
||||
}
|
||||
|
||||
DemoNode* node = TryGetNodeByElementId(state, elementId);
|
||||
if (node == nullptr || !IsTextFieldNode(*node)) {
|
||||
if (node == nullptr || !IsTextInputNode(*node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureTextFieldStateInitialized(state, *node);
|
||||
const std::string stateKey = ResolveTextFieldStateKey(*node);
|
||||
EnsureTextInputStateInitialized(state, *node);
|
||||
const std::string stateKey = ResolveTextInputStateKey(*node);
|
||||
std::string& value = state.textFieldValues[stateKey];
|
||||
std::size_t& caret = state.textFieldCarets[stateKey];
|
||||
caret = (std::min)(caret, value.size());
|
||||
@@ -990,17 +1107,17 @@ bool HandleTextFieldCharacterInput(
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HandleTextFieldKeyDown(
|
||||
bool HandleTextInputKeyDown(
|
||||
RuntimeBuildContext& state,
|
||||
UIElementId elementId,
|
||||
std::int32_t keyCode) {
|
||||
DemoNode* node = TryGetNodeByElementId(state, elementId);
|
||||
if (node == nullptr || !IsTextFieldNode(*node)) {
|
||||
if (node == nullptr || !IsTextInputNode(*node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureTextFieldStateInitialized(state, *node);
|
||||
const std::string stateKey = ResolveTextFieldStateKey(*node);
|
||||
EnsureTextInputStateInitialized(state, *node);
|
||||
const std::string stateKey = ResolveTextInputStateKey(*node);
|
||||
std::string& value = state.textFieldValues[stateKey];
|
||||
std::size_t& caret = state.textFieldCarets[stateKey];
|
||||
caret = (std::min)(caret, value.size());
|
||||
@@ -1026,17 +1143,38 @@ bool HandleTextFieldKeyDown(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyCode == static_cast<std::int32_t>(KeyCode::Up) && IsTextAreaNode(*node)) {
|
||||
caret = MoveCaretVertically(value, caret, -1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyCode == static_cast<std::int32_t>(KeyCode::Down) && IsTextAreaNode(*node)) {
|
||||
caret = MoveCaretVertically(value, caret, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyCode == static_cast<std::int32_t>(KeyCode::Home)) {
|
||||
caret = 0u;
|
||||
caret = IsTextAreaNode(*node)
|
||||
? FindLineStartOffset(value, caret)
|
||||
: 0u;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyCode == static_cast<std::int32_t>(KeyCode::End)) {
|
||||
caret = value.size();
|
||||
caret = IsTextAreaNode(*node)
|
||||
? FindLineEndOffset(value, caret)
|
||||
: value.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyCode == static_cast<std::int32_t>(KeyCode::Enter)) {
|
||||
if (IsTextAreaNode(*node)) {
|
||||
value.insert(caret, "\n");
|
||||
++caret;
|
||||
state.lastCommandId = "demo.text.edit." + stateKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
state.lastCommandId = "demo.text.submit." + stateKey;
|
||||
return true;
|
||||
}
|
||||
@@ -1103,7 +1241,8 @@ void BuildDemoNodesRecursive(
|
||||
node.interactive =
|
||||
node.tagName == "Button" ||
|
||||
node.tagName == "Toggle" ||
|
||||
node.tagName == "TextField";
|
||||
node.tagName == "TextField" ||
|
||||
node.tagName == "TextArea";
|
||||
|
||||
const std::size_t nodeIndex = state.nodes.size();
|
||||
state.nodes.push_back(node);
|
||||
@@ -1119,8 +1258,8 @@ void BuildDemoNodesRecursive(
|
||||
state.nodes[nodeIndex].interactive = true;
|
||||
}
|
||||
|
||||
if (IsTextFieldNode(state.nodes[nodeIndex])) {
|
||||
EnsureTextFieldStateInitialized(state, state.nodes[nodeIndex]);
|
||||
if (IsTextInputNode(state.nodes[nodeIndex])) {
|
||||
EnsureTextInputStateInitialized(state, state.nodes[nodeIndex]);
|
||||
}
|
||||
|
||||
for (std::size_t childIndex = 0; childIndex < sourceNode.children.Size(); ++childIndex) {
|
||||
@@ -1215,7 +1354,7 @@ UISize MeasureNode(RuntimeBuildContext& state, std::size_t index) {
|
||||
return node.desiredSize;
|
||||
}
|
||||
|
||||
if (IsTextFieldNode(node)) {
|
||||
if (IsTextInputNode(node)) {
|
||||
const float fontSize = GetFloatProperty(
|
||||
node,
|
||||
state.activeTheme,
|
||||
@@ -1224,18 +1363,38 @@ UISize MeasureNode(RuntimeBuildContext& state, std::size_t index) {
|
||||
14.0f);
|
||||
const Layout::UILayoutThickness padding =
|
||||
ResolvePadding(node, state.activeTheme, state.styleSheet);
|
||||
const std::string value = ResolveTextFieldValue(state, node);
|
||||
const std::string value = ResolveTextInputValue(state, node);
|
||||
const std::string placeholder = GetNodeAttribute(node, "placeholder");
|
||||
float minWidth = 220.0f;
|
||||
TryParseFloat(GetNodeAttribute(node, "min-width"), minWidth);
|
||||
const std::string& probeText = value.empty() ? placeholder : value;
|
||||
const std::string probeText = value.empty() ? placeholder : value;
|
||||
const std::vector<std::string> lines = SplitLines(probeText);
|
||||
float widestLine = 0.0f;
|
||||
for (const std::string& line : lines) {
|
||||
widestLine = (std::max)(
|
||||
widestLine,
|
||||
MeasureTextWidth(line.empty() ? std::string(" ") : line, fontSize));
|
||||
}
|
||||
|
||||
const float lineHeight = MeasureTextHeight(fontSize);
|
||||
std::size_t lineCount = lines.empty() ? 1u : lines.size();
|
||||
if (IsTextAreaNode(node)) {
|
||||
float requestedRows = 4.0f;
|
||||
TryParseFloat(GetNodeAttribute(node, "rows"), requestedRows);
|
||||
lineCount = (std::max)(
|
||||
lineCount,
|
||||
static_cast<std::size_t>((std::max)(1.0f, requestedRows)));
|
||||
}
|
||||
|
||||
node.desiredSize = UISize(
|
||||
(std::max)(
|
||||
minWidth,
|
||||
MeasureTextWidth(probeText.empty() ? std::string(" ") : probeText, fontSize) +
|
||||
widestLine +
|
||||
padding.Horizontal() +
|
||||
18.0f),
|
||||
MeasureTextHeight(fontSize) + padding.Vertical() + 8.0f);
|
||||
lineHeight * static_cast<float>(lineCount) +
|
||||
padding.Vertical() +
|
||||
(IsTextAreaNode(node) ? 10.0f : 8.0f));
|
||||
return node.desiredSize;
|
||||
}
|
||||
|
||||
@@ -1491,7 +1650,7 @@ void DrawTextFieldNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawL
|
||||
state.styleSheet,
|
||||
Style::UIStylePropertyId::FontSize,
|
||||
14.0f);
|
||||
const std::string value = ResolveTextFieldValue(state, node);
|
||||
const std::string value = ResolveTextInputValue(state, node);
|
||||
const std::string placeholder = GetNodeAttribute(node, "placeholder");
|
||||
const bool showingPlaceholder = value.empty() && !placeholder.empty();
|
||||
const std::string& displayText = showingPlaceholder ? placeholder : value;
|
||||
@@ -1518,7 +1677,7 @@ void DrawTextFieldNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawL
|
||||
return;
|
||||
}
|
||||
|
||||
const std::size_t caret = ResolveTextFieldCaret(state, node);
|
||||
const std::size_t caret = ResolveTextInputCaret(state, node);
|
||||
const float caretX =
|
||||
contentRect.x +
|
||||
2.0f +
|
||||
@@ -1535,6 +1694,75 @@ void DrawTextFieldNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawL
|
||||
drawList.AddFilledRect(caretRect, ToUIColor(caretColor), 1.0f);
|
||||
}
|
||||
|
||||
void DrawTextAreaNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawList& drawList) {
|
||||
const Layout::UILayoutThickness padding =
|
||||
ResolvePadding(node, state.activeTheme, state.styleSheet);
|
||||
const UIRect contentRect = InsetRect(node.rect, padding);
|
||||
const float fontSize = GetFloatProperty(
|
||||
node,
|
||||
state.activeTheme,
|
||||
state.styleSheet,
|
||||
Style::UIStylePropertyId::FontSize,
|
||||
14.0f);
|
||||
const float lineHeight = MeasureTextHeight(fontSize);
|
||||
const std::string value = ResolveTextInputValue(state, node);
|
||||
const std::string placeholder = GetNodeAttribute(node, "placeholder");
|
||||
const bool showingPlaceholder = value.empty() && !placeholder.empty();
|
||||
const std::vector<std::string> lines = SplitLines(showingPlaceholder ? placeholder : value);
|
||||
const Color textColor = showingPlaceholder
|
||||
? ResolveColorToken(state.activeTheme, "color.text.placeholder", Color(0.49f, 0.56f, 0.64f, 1.0f))
|
||||
: GetColorProperty(
|
||||
node,
|
||||
state.activeTheme,
|
||||
state.styleSheet,
|
||||
Style::UIStylePropertyId::ForegroundColor,
|
||||
Color(0.92f, 0.94f, 0.97f, 1.0f));
|
||||
|
||||
for (std::size_t lineIndex = 0u; lineIndex < lines.size(); ++lineIndex) {
|
||||
drawList.AddText(
|
||||
UIPoint(
|
||||
contentRect.x + 2.0f,
|
||||
contentRect.y + 2.0f + static_cast<float>(lineIndex) * lineHeight),
|
||||
lines[lineIndex].empty() ? std::string(" ") : lines[lineIndex],
|
||||
ToUIColor(textColor),
|
||||
fontSize);
|
||||
}
|
||||
|
||||
const bool focused =
|
||||
state.frameResult.stats.focusedElementId == node.elementKey &&
|
||||
!node.elementKey.empty();
|
||||
if (!focused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::size_t caret = ResolveTextInputCaret(state, node);
|
||||
const std::size_t lineStart = FindLineStartOffset(value, caret);
|
||||
std::size_t lineIndex = 0u;
|
||||
for (std::size_t scan = 0u; scan < lineStart && scan < value.size(); ++scan) {
|
||||
if (value[scan] == '\n') {
|
||||
++lineIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const float caretX =
|
||||
contentRect.x +
|
||||
2.0f +
|
||||
MeasureGlyphRunWidth(value.substr(lineStart, caret - lineStart), fontSize);
|
||||
const float caretY = contentRect.y + 3.0f + static_cast<float>(lineIndex) * lineHeight;
|
||||
const Color caretColor = ResolveColorToken(
|
||||
state.activeTheme,
|
||||
"color.accent",
|
||||
Color(0.37f, 0.89f, 1.0f, 1.0f));
|
||||
drawList.AddFilledRect(
|
||||
UIRect(
|
||||
caretX,
|
||||
caretY,
|
||||
2.0f,
|
||||
(std::max)(12.0f, lineHeight - 4.0f)),
|
||||
ToUIColor(caretColor),
|
||||
1.0f);
|
||||
}
|
||||
|
||||
void DrawToggleNode(RuntimeBuildContext& state, const DemoNode& node, UIDrawList& drawList) {
|
||||
const Layout::UILayoutThickness padding =
|
||||
ResolvePadding(node, state.activeTheme, state.styleSheet);
|
||||
@@ -1624,7 +1852,7 @@ void DrawNode(RuntimeBuildContext& state, std::size_t index, UIDrawList& drawLis
|
||||
state.styleSheet,
|
||||
Style::UIStylePropertyId::BorderColor,
|
||||
Color::Clear());
|
||||
if (focused && IsTextFieldNode(node)) {
|
||||
if (focused && IsTextInputNode(node)) {
|
||||
borderColor = ResolveColorToken(
|
||||
state.activeTheme,
|
||||
"color.accent",
|
||||
@@ -1659,6 +1887,8 @@ void DrawNode(RuntimeBuildContext& state, std::size_t index, UIDrawList& drawLis
|
||||
node.resolvedText,
|
||||
ToUIColor(foreground),
|
||||
fontSize);
|
||||
} else if (IsTextAreaNode(node)) {
|
||||
DrawTextAreaNode(state, node, drawList);
|
||||
} else if (IsTextFieldNode(node)) {
|
||||
DrawTextFieldNode(state, node, drawList);
|
||||
} else if (IsProgressBarNode(node)) {
|
||||
@@ -1884,13 +2114,13 @@ const XCUIDemoFrameResult& XCUIDemoRuntime::Update(const XCUIDemoInputState& inp
|
||||
!event.modifiers.control &&
|
||||
!event.modifiers.alt &&
|
||||
!event.modifiers.super) {
|
||||
if (HandleTextFieldCharacterInput(state, focusedElementId, event.character)) {
|
||||
if (HandleTextInputCharacterInput(state, focusedElementId, event.character)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type == UIInputEventType::KeyDown) {
|
||||
if (HandleTextFieldKeyDown(state, focusedElementId, event.keyCode)) {
|
||||
if (HandleTextInputKeyDown(state, focusedElementId, event.keyCode)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
set(UI_TEST_SOURCES
|
||||
test_ui_core.cpp
|
||||
test_layout_engine.cpp
|
||||
test_ui_runtime.cpp
|
||||
)
|
||||
|
||||
add_executable(core_ui_tests ${UI_TEST_SOURCES})
|
||||
|
||||
130
tests/Core/UI/test_layout_engine.cpp
Normal file
130
tests/Core/UI/test_layout_engine.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/UI/Layout/LayoutEngine.h>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::UI::UIRect;
|
||||
using XCEngine::UI::UISize;
|
||||
using XCEngine::UI::Layout::ArrangeOverlayLayout;
|
||||
using XCEngine::UI::Layout::ArrangeStackLayout;
|
||||
using XCEngine::UI::Layout::MeasureOverlayLayout;
|
||||
using XCEngine::UI::Layout::MeasureStackLayout;
|
||||
using XCEngine::UI::Layout::UILayoutAlignment;
|
||||
using XCEngine::UI::Layout::UILayoutAxis;
|
||||
using XCEngine::UI::Layout::UILayoutConstraints;
|
||||
using XCEngine::UI::Layout::UILayoutItem;
|
||||
using XCEngine::UI::Layout::UILayoutLength;
|
||||
using XCEngine::UI::Layout::UILayoutThickness;
|
||||
using XCEngine::UI::Layout::UIOverlayLayoutOptions;
|
||||
using XCEngine::UI::Layout::UIStackLayoutOptions;
|
||||
|
||||
void ExpectRect(
|
||||
const UIRect& rect,
|
||||
float x,
|
||||
float y,
|
||||
float width,
|
||||
float height) {
|
||||
EXPECT_FLOAT_EQ(rect.x, x);
|
||||
EXPECT_FLOAT_EQ(rect.y, y);
|
||||
EXPECT_FLOAT_EQ(rect.width, width);
|
||||
EXPECT_FLOAT_EQ(rect.height, height);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(UI_Layout, MeasureHorizontalStackAccumulatesSpacingPaddingAndCrossExtent) {
|
||||
UIStackLayoutOptions options = {};
|
||||
options.axis = UILayoutAxis::Horizontal;
|
||||
options.spacing = 5.0f;
|
||||
options.padding = UILayoutThickness::Symmetric(10.0f, 6.0f);
|
||||
|
||||
std::vector<UILayoutItem> items(2);
|
||||
items[0].desiredContentSize = UISize(40.0f, 20.0f);
|
||||
items[1].desiredContentSize = UISize(60.0f, 30.0f);
|
||||
|
||||
const auto result = MeasureStackLayout(options, items);
|
||||
|
||||
EXPECT_FLOAT_EQ(result.desiredSize.width, 125.0f);
|
||||
EXPECT_FLOAT_EQ(result.desiredSize.height, 42.0f);
|
||||
}
|
||||
|
||||
TEST(UI_Layout, ArrangeHorizontalStackDistributesRemainingSpaceToStretchChildren) {
|
||||
UIStackLayoutOptions options = {};
|
||||
options.axis = UILayoutAxis::Horizontal;
|
||||
options.spacing = 5.0f;
|
||||
options.padding = UILayoutThickness::Uniform(10.0f);
|
||||
|
||||
std::vector<UILayoutItem> items(3);
|
||||
items[0].width = UILayoutLength::Pixels(100.0f);
|
||||
items[0].desiredContentSize = UISize(10.0f, 20.0f);
|
||||
|
||||
items[1].width = UILayoutLength::Stretch(1.0f);
|
||||
items[1].desiredContentSize = UISize(30.0f, 20.0f);
|
||||
|
||||
items[2].width = UILayoutLength::Pixels(50.0f);
|
||||
items[2].desiredContentSize = UISize(10.0f, 20.0f);
|
||||
|
||||
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 300.0f, 80.0f));
|
||||
|
||||
ExpectRect(result.children[0].arrangedRect, 10.0f, 10.0f, 100.0f, 20.0f);
|
||||
ExpectRect(result.children[1].arrangedRect, 115.0f, 10.0f, 120.0f, 20.0f);
|
||||
ExpectRect(result.children[2].arrangedRect, 240.0f, 10.0f, 50.0f, 20.0f);
|
||||
}
|
||||
|
||||
TEST(UI_Layout, ArrangeVerticalStackSupportsCrossAxisStretch) {
|
||||
UIStackLayoutOptions options = {};
|
||||
options.axis = UILayoutAxis::Vertical;
|
||||
options.spacing = 4.0f;
|
||||
options.padding = UILayoutThickness::Symmetric(8.0f, 6.0f);
|
||||
|
||||
std::vector<UILayoutItem> items(2);
|
||||
items[0].desiredContentSize = UISize(40.0f, 10.0f);
|
||||
items[0].horizontalAlignment = UILayoutAlignment::Stretch;
|
||||
items[1].desiredContentSize = UISize(60.0f, 20.0f);
|
||||
|
||||
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 200.0f, 100.0f));
|
||||
|
||||
ExpectRect(result.children[0].arrangedRect, 8.0f, 6.0f, 184.0f, 10.0f);
|
||||
ExpectRect(result.children[1].arrangedRect, 8.0f, 20.0f, 60.0f, 20.0f);
|
||||
}
|
||||
|
||||
TEST(UI_Layout, ArrangeOverlaySupportsCenterAndStretch) {
|
||||
UIOverlayLayoutOptions options = {};
|
||||
options.padding = UILayoutThickness::Uniform(10.0f);
|
||||
|
||||
std::vector<UILayoutItem> items(2);
|
||||
items[0].desiredContentSize = UISize(40.0f, 20.0f);
|
||||
items[0].horizontalAlignment = UILayoutAlignment::Center;
|
||||
items[0].verticalAlignment = UILayoutAlignment::Center;
|
||||
|
||||
items[1].desiredContentSize = UISize(10.0f, 10.0f);
|
||||
items[1].width = UILayoutLength::Stretch();
|
||||
items[1].height = UILayoutLength::Stretch();
|
||||
items[1].margin = UILayoutThickness::Uniform(5.0f);
|
||||
|
||||
const auto result = ArrangeOverlayLayout(options, items, UIRect(0.0f, 0.0f, 100.0f, 60.0f));
|
||||
|
||||
ExpectRect(result.children[0].arrangedRect, 30.0f, 20.0f, 40.0f, 20.0f);
|
||||
ExpectRect(result.children[1].arrangedRect, 15.0f, 15.0f, 70.0f, 30.0f);
|
||||
}
|
||||
|
||||
TEST(UI_Layout, MeasureOverlayRespectsItemMinMaxAndAvailableConstraints) {
|
||||
UIOverlayLayoutOptions options = {};
|
||||
|
||||
std::vector<UILayoutItem> items(1);
|
||||
items[0].width = UILayoutLength::Pixels(500.0f);
|
||||
items[0].desiredContentSize = UISize(10.0f, 10.0f);
|
||||
items[0].minSize = UISize(0.0f, 50.0f);
|
||||
items[0].maxSize = UISize(200.0f, 120.0f);
|
||||
|
||||
const auto result = MeasureOverlayLayout(
|
||||
options,
|
||||
items,
|
||||
UILayoutConstraints::Bounded(150.0f, 100.0f));
|
||||
|
||||
EXPECT_FLOAT_EQ(result.children[0].measuredSize.width, 150.0f);
|
||||
EXPECT_FLOAT_EQ(result.children[0].measuredSize.height, 50.0f);
|
||||
EXPECT_FLOAT_EQ(result.desiredSize.width, 150.0f);
|
||||
EXPECT_FLOAT_EQ(result.desiredSize.height, 50.0f);
|
||||
}
|
||||
166
tests/Core/UI/test_style_system.cpp
Normal file
166
tests/Core/UI/test_style_system.cpp
Normal file
@@ -0,0 +1,166 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/UI/Style/StyleResolver.h>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::Math::Color;
|
||||
using XCEngine::UI::Style::BuildBuiltinTheme;
|
||||
using XCEngine::UI::Style::BuildTheme;
|
||||
using XCEngine::UI::Style::UICornerRadius;
|
||||
using XCEngine::UI::Style::UIBuiltinThemeKind;
|
||||
using XCEngine::UI::Style::UIResolvedStyle;
|
||||
using XCEngine::UI::Style::UIStyleLayer;
|
||||
using XCEngine::UI::Style::UIStylePropertyId;
|
||||
using XCEngine::UI::Style::UIStyleResolveContext;
|
||||
using XCEngine::UI::Style::UIStyleSet;
|
||||
using XCEngine::UI::Style::UIStyleSheet;
|
||||
using XCEngine::UI::Style::UIStyleValue;
|
||||
using XCEngine::UI::Style::UIStyleValueType;
|
||||
using XCEngine::UI::Style::UITheme;
|
||||
using XCEngine::UI::Style::UIThemeDefinition;
|
||||
using XCEngine::UI::Style::UITokenResolveStatus;
|
||||
|
||||
void ExpectColorEq(const Color& actual, const Color& expected) {
|
||||
EXPECT_FLOAT_EQ(actual.r, expected.r);
|
||||
EXPECT_FLOAT_EQ(actual.g, expected.g);
|
||||
EXPECT_FLOAT_EQ(actual.b, expected.b);
|
||||
EXPECT_FLOAT_EQ(actual.a, expected.a);
|
||||
}
|
||||
|
||||
TEST(UI_StyleSystem, ThemeDefinitionBuildsThemeAndResolvesAliases) {
|
||||
UIThemeDefinition definition = {};
|
||||
definition.name = "CustomTheme";
|
||||
definition.SetToken("space.base", UIStyleValue(8.0f));
|
||||
definition.SetToken("gap.control", UIStyleValue::Token("space.base"));
|
||||
definition.SetToken("radius.card", UIStyleValue(UICornerRadius::Uniform(12.0f)));
|
||||
|
||||
const UITheme theme = BuildTheme(definition);
|
||||
|
||||
EXPECT_EQ(theme.GetName(), "CustomTheme");
|
||||
|
||||
const auto gapToken = theme.ResolveToken("gap.control", UIStyleValueType::Float);
|
||||
ASSERT_EQ(gapToken.status, UITokenResolveStatus::Resolved);
|
||||
ASSERT_NE(gapToken.value.TryGetFloat(), nullptr);
|
||||
EXPECT_FLOAT_EQ(*gapToken.value.TryGetFloat(), 8.0f);
|
||||
|
||||
const auto radiusToken = theme.ResolveToken("radius.card", UIStyleValueType::CornerRadius);
|
||||
ASSERT_EQ(radiusToken.status, UITokenResolveStatus::Resolved);
|
||||
ASSERT_NE(radiusToken.value.TryGetCornerRadius(), nullptr);
|
||||
EXPECT_TRUE(radiusToken.value.TryGetCornerRadius()->IsUniform());
|
||||
EXPECT_FLOAT_EQ(radiusToken.value.TryGetCornerRadius()->topLeft, 12.0f);
|
||||
}
|
||||
|
||||
TEST(UI_StyleSystem, ThemeResolvesParentTokensAndBuiltinVariantsDiffer) {
|
||||
UIThemeDefinition baseDefinition = {};
|
||||
baseDefinition.name = "Base";
|
||||
baseDefinition.SetToken("color.surface", UIStyleValue(Color(0.20f, 0.21f, 0.22f, 1.0f)));
|
||||
const UITheme baseTheme = BuildTheme(baseDefinition);
|
||||
|
||||
UIThemeDefinition childDefinition = {};
|
||||
childDefinition.name = "Child";
|
||||
childDefinition.SetToken("font.body", UIStyleValue(15.0f));
|
||||
const UITheme childTheme = BuildTheme(childDefinition, &baseTheme);
|
||||
|
||||
const auto parentToken = childTheme.ResolveToken("color.surface", UIStyleValueType::Color);
|
||||
ASSERT_EQ(parentToken.status, UITokenResolveStatus::Resolved);
|
||||
ASSERT_NE(parentToken.value.TryGetColor(), nullptr);
|
||||
ExpectColorEq(*parentToken.value.TryGetColor(), Color(0.20f, 0.21f, 0.22f, 1.0f));
|
||||
|
||||
const UITheme darkTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralDark);
|
||||
const UITheme lightTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralLight);
|
||||
const auto darkSurface = darkTheme.ResolveToken("color.surface", UIStyleValueType::Color);
|
||||
const auto lightSurface = lightTheme.ResolveToken("color.surface", UIStyleValueType::Color);
|
||||
ASSERT_EQ(darkSurface.status, UITokenResolveStatus::Resolved);
|
||||
ASSERT_EQ(lightSurface.status, UITokenResolveStatus::Resolved);
|
||||
ASSERT_NE(darkSurface.value.TryGetColor(), nullptr);
|
||||
ASSERT_NE(lightSurface.value.TryGetColor(), nullptr);
|
||||
EXPECT_NE(darkSurface.value.TryGetColor()->r, lightSurface.value.TryGetColor()->r);
|
||||
}
|
||||
|
||||
TEST(UI_StyleSystem, ThemeReportsMissingCyclesAndTypeMismatches) {
|
||||
UIThemeDefinition definition = {};
|
||||
definition.SetToken("cycle.a", UIStyleValue::Token("cycle.b"));
|
||||
definition.SetToken("cycle.b", UIStyleValue::Token("cycle.a"));
|
||||
definition.SetToken("color.surface", UIStyleValue(Color(0.1f, 0.2f, 0.3f, 1.0f)));
|
||||
const UITheme theme = BuildTheme(definition);
|
||||
|
||||
EXPECT_EQ(
|
||||
theme.ResolveToken("missing.token", UIStyleValueType::Float).status,
|
||||
UITokenResolveStatus::MissingToken);
|
||||
EXPECT_EQ(
|
||||
theme.ResolveToken("cycle.a", UIStyleValueType::Float).status,
|
||||
UITokenResolveStatus::CycleDetected);
|
||||
EXPECT_EQ(
|
||||
theme.ResolveToken("color.surface", UIStyleValueType::Float).status,
|
||||
UITokenResolveStatus::TypeMismatch);
|
||||
}
|
||||
|
||||
TEST(UI_StyleSystem, StyleResolutionPrefersLocalThenNamedThenTypeThenDefault) {
|
||||
UIStyleSheet styleSheet = {};
|
||||
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::FontSize, UIStyleValue(12.0f));
|
||||
styleSheet.GetOrCreateTypeStyle("Button").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(14.0f));
|
||||
styleSheet.GetOrCreateNamedStyle("Primary").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(16.0f));
|
||||
|
||||
UIStyleSet localStyle = {};
|
||||
localStyle.SetProperty(UIStylePropertyId::FontSize, UIStyleValue(18.0f));
|
||||
|
||||
UIStyleResolveContext context = {};
|
||||
context.styleSheet = &styleSheet;
|
||||
context.selector.typeName = "Button";
|
||||
context.selector.styleName = "Primary";
|
||||
context.localStyle = &localStyle;
|
||||
|
||||
auto resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
|
||||
ASSERT_TRUE(resolution.resolved);
|
||||
EXPECT_EQ(resolution.layer, UIStyleLayer::Local);
|
||||
ASSERT_NE(resolution.value.TryGetFloat(), nullptr);
|
||||
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 18.0f);
|
||||
|
||||
localStyle.RemoveProperty(UIStylePropertyId::FontSize);
|
||||
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
|
||||
ASSERT_TRUE(resolution.resolved);
|
||||
EXPECT_EQ(resolution.layer, UIStyleLayer::Named);
|
||||
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 16.0f);
|
||||
|
||||
styleSheet.GetOrCreateNamedStyle("Primary").RemoveProperty(UIStylePropertyId::FontSize);
|
||||
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
|
||||
ASSERT_TRUE(resolution.resolved);
|
||||
EXPECT_EQ(resolution.layer, UIStyleLayer::Type);
|
||||
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 14.0f);
|
||||
}
|
||||
|
||||
TEST(UI_StyleSystem, StyleResolutionFallsBackWhenHigherPriorityTokenCannotResolve) {
|
||||
UIThemeDefinition themeDefinition = {};
|
||||
themeDefinition.SetToken("color.accent", UIStyleValue(Color(0.90f, 0.20f, 0.10f, 1.0f)));
|
||||
const UITheme theme = BuildTheme(themeDefinition);
|
||||
|
||||
UIStyleSheet styleSheet = {};
|
||||
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::BorderWidth, UIStyleValue(1.0f));
|
||||
styleSheet.GetOrCreateTypeStyle("Button")
|
||||
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("color.accent"));
|
||||
styleSheet.GetOrCreateNamedStyle("Danger")
|
||||
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("missing.token"));
|
||||
|
||||
UIStyleResolveContext context = {};
|
||||
context.theme = &theme;
|
||||
context.styleSheet = &styleSheet;
|
||||
context.selector.typeName = "Button";
|
||||
context.selector.styleName = "Danger";
|
||||
|
||||
const auto backgroundResolution =
|
||||
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::BackgroundColor, context);
|
||||
ASSERT_TRUE(backgroundResolution.resolved);
|
||||
EXPECT_EQ(backgroundResolution.layer, UIStyleLayer::Type);
|
||||
ASSERT_NE(backgroundResolution.value.TryGetColor(), nullptr);
|
||||
ExpectColorEq(*backgroundResolution.value.TryGetColor(), Color(0.90f, 0.20f, 0.10f, 1.0f));
|
||||
|
||||
const UIResolvedStyle resolvedStyle = XCEngine::UI::Style::ResolveStyle(context);
|
||||
const auto* borderWidthResolution = resolvedStyle.FindProperty(UIStylePropertyId::BorderWidth);
|
||||
ASSERT_NE(borderWidthResolution, nullptr);
|
||||
EXPECT_EQ(borderWidthResolution->layer, UIStyleLayer::Default);
|
||||
ASSERT_NE(borderWidthResolution->value.TryGetFloat(), nullptr);
|
||||
EXPECT_FLOAT_EQ(*borderWidthResolution->value.TryGetFloat(), 1.0f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
326
tests/Core/UI/test_ui_runtime.cpp
Normal file
326
tests/Core/UI/test_ui_runtime.cpp
Normal file
@@ -0,0 +1,326 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
#include <XCEngine/UI/Runtime/UISystem.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
using XCEngine::UI::UIColor;
|
||||
using XCEngine::UI::UIDrawList;
|
||||
using XCEngine::UI::UIInputEvent;
|
||||
using XCEngine::UI::UIInputEventType;
|
||||
using XCEngine::UI::UIPoint;
|
||||
using XCEngine::UI::UIRect;
|
||||
using XCEngine::UI::Runtime::IUIScreenDocumentHost;
|
||||
using XCEngine::UI::Runtime::UIScreenAsset;
|
||||
using XCEngine::UI::Runtime::UIScreenDocument;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameResult;
|
||||
using XCEngine::UI::Runtime::UIScreenLayerId;
|
||||
using XCEngine::UI::Runtime::UIScreenLayerOptions;
|
||||
using XCEngine::UI::Runtime::UIScreenLoadResult;
|
||||
using XCEngine::UI::Runtime::UIScreenPlayer;
|
||||
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
||||
using XCEngine::UI::Runtime::UISystemFrameResult;
|
||||
using XCEngine::UI::Runtime::UISystem;
|
||||
|
||||
class FakeScreenDocumentHost final : public IUIScreenDocumentHost {
|
||||
public:
|
||||
struct BuildCall {
|
||||
std::string displayName = {};
|
||||
std::size_t inputEventCount = 0;
|
||||
std::uint64_t frameIndex = 0;
|
||||
};
|
||||
|
||||
UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override {
|
||||
++loadCount;
|
||||
lastLoadedAsset = asset;
|
||||
|
||||
UIScreenLoadResult result = {};
|
||||
if (!asset.IsValid()) {
|
||||
result.errorMessage = "Invalid screen asset.";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.succeeded = true;
|
||||
result.document.sourcePath = asset.documentPath;
|
||||
result.document.displayName = asset.screenId.empty() ? asset.documentPath : asset.screenId;
|
||||
result.document.viewDocument.valid = true;
|
||||
result.document.dependencies.push_back(asset.themePath);
|
||||
return result;
|
||||
}
|
||||
|
||||
UIScreenFrameResult BuildFrame(
|
||||
const UIScreenDocument& document,
|
||||
const UIScreenFrameInput& input) override {
|
||||
++buildCount;
|
||||
lastBuiltDocument = document;
|
||||
lastFrameInput = input;
|
||||
|
||||
UIScreenFrameResult result = {};
|
||||
UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName);
|
||||
drawList.AddFilledRect(input.viewportRect, UIColor(0.2f, 0.3f, 0.4f, 1.0f), 4.0f);
|
||||
drawList.AddText(
|
||||
UIPoint(input.viewportRect.x + 8.0f, input.viewportRect.y + 8.0f),
|
||||
document.displayName,
|
||||
UIColor(1.0f, 1.0f, 1.0f, 1.0f),
|
||||
16.0f);
|
||||
buildCalls.push_back(BuildCall{
|
||||
document.displayName,
|
||||
input.events.size(),
|
||||
input.frameIndex
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
std::size_t loadCount = 0;
|
||||
std::size_t buildCount = 0;
|
||||
UIScreenAsset lastLoadedAsset = {};
|
||||
UIScreenDocument lastBuiltDocument = {};
|
||||
UIScreenFrameInput lastFrameInput = {};
|
||||
std::vector<BuildCall> buildCalls = {};
|
||||
};
|
||||
|
||||
UIScreenAsset MakeAsset() {
|
||||
UIScreenAsset asset = {};
|
||||
asset.screenId = "MainMenu";
|
||||
asset.documentPath = "Assets/UI/MainMenu.xcui";
|
||||
asset.themePath = "Assets/UI/MainMenu.xctheme";
|
||||
return asset;
|
||||
}
|
||||
|
||||
UIScreenFrameInput MakeFrameInput(std::uint64_t frameIndex = 7) {
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = UIRect(10.0f, 20.0f, 320.0f, 180.0f);
|
||||
input.deltaTimeSeconds = 1.0 / 60.0;
|
||||
input.frameIndex = frameIndex;
|
||||
input.focused = true;
|
||||
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::PointerMove;
|
||||
event.position = UIPoint(42.0f, 64.0f);
|
||||
input.events.push_back(event);
|
||||
return input;
|
||||
}
|
||||
|
||||
void WriteTextFile(const fs::path& path, const char* contents) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||
ASSERT_TRUE(output.is_open());
|
||||
output << contents;
|
||||
ASSERT_TRUE(static_cast<bool>(output));
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, ScreenPlayerLoadsAssetAndDocumentMetadata) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(MakeAsset()));
|
||||
ASSERT_TRUE(player.IsLoaded());
|
||||
ASSERT_NE(player.GetAsset(), nullptr);
|
||||
ASSERT_NE(player.GetDocument(), nullptr);
|
||||
EXPECT_EQ(player.GetAsset()->documentPath, "Assets/UI/MainMenu.xcui");
|
||||
EXPECT_EQ(player.GetDocument()->displayName, "MainMenu");
|
||||
EXPECT_EQ(host.loadCount, 1u);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, ScreenPlayerUpdateBuildsFrameAndTracksStats) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
ASSERT_TRUE(player.Load(MakeAsset()));
|
||||
|
||||
const UIScreenFrameResult& result = player.Update(MakeFrameInput(12));
|
||||
|
||||
EXPECT_TRUE(result.errorMessage.empty());
|
||||
EXPECT_TRUE(result.stats.documentLoaded);
|
||||
EXPECT_EQ(result.stats.drawListCount, 1u);
|
||||
EXPECT_EQ(result.stats.commandCount, 2u);
|
||||
EXPECT_EQ(result.stats.inputEventCount, 1u);
|
||||
EXPECT_EQ(result.stats.presentedFrameIndex, 12u);
|
||||
EXPECT_EQ(player.GetPresentedFrameCount(), 1u);
|
||||
EXPECT_EQ(host.buildCount, 1u);
|
||||
EXPECT_EQ(host.lastBuiltDocument.displayName, "MainMenu");
|
||||
EXPECT_EQ(host.lastFrameInput.viewportRect.width, 320.0f);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, ScreenPlayerWithoutLoadedDocumentReturnsNotLoadedFrame) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
const UIScreenFrameResult& result = player.Update(MakeFrameInput());
|
||||
|
||||
EXPECT_FALSE(result.stats.documentLoaded);
|
||||
EXPECT_TRUE(result.drawData.Empty());
|
||||
EXPECT_FALSE(result.errorMessage.empty());
|
||||
EXPECT_EQ(player.GetPresentedFrameCount(), 0u);
|
||||
EXPECT_EQ(host.buildCount, 0u);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, UISystemTicksAllCreatedPlayers) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UISystem system(host);
|
||||
UIScreenPlayer& playerA = system.CreatePlayer();
|
||||
UIScreenPlayer& playerB = system.CreatePlayer();
|
||||
|
||||
ASSERT_TRUE(playerA.Load(MakeAsset()));
|
||||
|
||||
UIScreenAsset hudAsset = MakeAsset();
|
||||
hudAsset.screenId = "HUD";
|
||||
hudAsset.documentPath = "Assets/UI/Hud.xcui";
|
||||
ASSERT_TRUE(playerB.Load(hudAsset));
|
||||
|
||||
system.Tick(MakeFrameInput(21));
|
||||
|
||||
EXPECT_EQ(system.GetPlayerCount(), 2u);
|
||||
EXPECT_EQ(host.loadCount, 2u);
|
||||
EXPECT_EQ(host.buildCount, 2u);
|
||||
EXPECT_EQ(playerA.GetLastFrame().stats.presentedFrameIndex, 21u);
|
||||
EXPECT_EQ(playerB.GetLastFrame().stats.presentedFrameIndex, 21u);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, UISystemUpdateComposesLayersAndRoutesInputToTopInteractiveLayer) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UISystem system(host);
|
||||
|
||||
UIScreenPlayer& gameplay = system.CreatePlayer();
|
||||
ASSERT_TRUE(gameplay.Load(MakeAsset()));
|
||||
|
||||
UIScreenAsset hudAsset = MakeAsset();
|
||||
hudAsset.screenId = "HUD";
|
||||
hudAsset.documentPath = "Assets/UI/Hud.xcui";
|
||||
UIScreenLayerOptions hudOptions = {};
|
||||
hudOptions.debugName = "HUD";
|
||||
hudOptions.acceptsInput = false;
|
||||
UIScreenPlayer& hud = system.CreatePlayer(hudOptions);
|
||||
ASSERT_TRUE(hud.Load(hudAsset));
|
||||
|
||||
const UISystemFrameResult& frame = system.Update(MakeFrameInput(33));
|
||||
|
||||
ASSERT_EQ(frame.presentedLayerCount, 2u);
|
||||
EXPECT_EQ(frame.skippedLayerCount, 0u);
|
||||
ASSERT_EQ(frame.layers.size(), 2u);
|
||||
EXPECT_EQ(frame.layers[0].asset.screenId, "MainMenu");
|
||||
EXPECT_EQ(frame.layers[1].asset.screenId, "HUD");
|
||||
EXPECT_EQ(frame.layers[0].stats.inputEventCount, 1u);
|
||||
EXPECT_EQ(frame.layers[1].stats.inputEventCount, 0u);
|
||||
|
||||
ASSERT_EQ(frame.drawData.GetDrawListCount(), 2u);
|
||||
EXPECT_EQ(frame.drawData.GetDrawLists()[0].GetDebugName(), "MainMenu");
|
||||
EXPECT_EQ(frame.drawData.GetDrawLists()[1].GetDebugName(), "HUD");
|
||||
|
||||
ASSERT_EQ(host.buildCalls.size(), 2u);
|
||||
EXPECT_EQ(host.buildCalls[0].displayName, "MainMenu");
|
||||
EXPECT_EQ(host.buildCalls[0].inputEventCount, 1u);
|
||||
EXPECT_EQ(host.buildCalls[1].displayName, "HUD");
|
||||
EXPECT_EQ(host.buildCalls[1].inputEventCount, 0u);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, UISystemModalLayerBlocksLowerLayersAndKeepsOnlyTopFrameVisible) {
|
||||
FakeScreenDocumentHost host = {};
|
||||
UISystem system(host);
|
||||
|
||||
UIScreenAsset gameplayAsset = MakeAsset();
|
||||
gameplayAsset.screenId = "GameplayHUD";
|
||||
gameplayAsset.documentPath = "Assets/UI/GameplayHud.xcui";
|
||||
const UIScreenLayerId gameplayLayer = system.PushScreen(gameplayAsset);
|
||||
ASSERT_NE(gameplayLayer, 0u);
|
||||
|
||||
UIScreenAsset pauseAsset = MakeAsset();
|
||||
pauseAsset.screenId = "PauseMenu";
|
||||
pauseAsset.documentPath = "Assets/UI/PauseMenu.xcui";
|
||||
UIScreenLayerOptions pauseOptions = {};
|
||||
pauseOptions.debugName = "PauseMenu";
|
||||
pauseOptions.blocksLayersBelow = true;
|
||||
const UIScreenLayerId pauseLayer = system.PushScreen(pauseAsset, pauseOptions);
|
||||
ASSERT_NE(pauseLayer, 0u);
|
||||
|
||||
const UISystemFrameResult& frame = system.Update(MakeFrameInput(48));
|
||||
|
||||
EXPECT_EQ(system.GetLayerCount(), 2u);
|
||||
EXPECT_EQ(frame.presentedLayerCount, 1u);
|
||||
EXPECT_EQ(frame.skippedLayerCount, 1u);
|
||||
ASSERT_EQ(frame.layers.size(), 1u);
|
||||
EXPECT_EQ(frame.layers[0].layerId, pauseLayer);
|
||||
EXPECT_EQ(frame.layers[0].asset.screenId, "PauseMenu");
|
||||
EXPECT_TRUE(frame.layers[0].options.blocksLayersBelow);
|
||||
ASSERT_EQ(frame.drawData.GetDrawListCount(), 1u);
|
||||
EXPECT_EQ(frame.drawData.GetDrawLists()[0].GetDebugName(), "PauseMenu");
|
||||
|
||||
ASSERT_EQ(host.buildCalls.size(), 1u);
|
||||
EXPECT_EQ(host.buildCalls[0].displayName, "PauseMenu");
|
||||
EXPECT_EQ(host.buildCalls[0].inputEventCount, 1u);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, UIDocumentScreenHostLoadsRealCompiledDocuments) {
|
||||
const fs::path root = fs::temp_directory_path() / "xcui_runtime_host_load_test";
|
||||
fs::remove_all(root);
|
||||
|
||||
WriteTextFile(
|
||||
root / "RuntimeScreen.xcui",
|
||||
"<View name=\"RuntimeScreen\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Text text=\"Runtime HUD\" />\n"
|
||||
" </Column>\n"
|
||||
"</View>\n");
|
||||
WriteTextFile(root / "RuntimeTheme.xctheme", "<Theme name=\"RuntimeTheme\" />\n");
|
||||
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenAsset asset = {};
|
||||
asset.screenId = "RuntimeHUD";
|
||||
asset.documentPath = (root / "RuntimeScreen.xcui").string();
|
||||
asset.themePath = (root / "RuntimeTheme.xctheme").string();
|
||||
|
||||
const UIScreenLoadResult loadResult = host.LoadScreen(asset);
|
||||
ASSERT_TRUE(loadResult.succeeded);
|
||||
EXPECT_EQ(loadResult.document.displayName, "RuntimeHUD");
|
||||
EXPECT_TRUE(loadResult.document.viewDocument.valid);
|
||||
EXPECT_TRUE(loadResult.document.hasThemeDocument);
|
||||
EXPECT_FALSE(loadResult.document.dependencies.empty());
|
||||
|
||||
fs::remove_all(root);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeTest, UIDocumentScreenHostBuildsConcreteRuntimeFrame) {
|
||||
const fs::path root = fs::temp_directory_path() / "xcui_runtime_host_frame_test";
|
||||
fs::remove_all(root);
|
||||
|
||||
WriteTextFile(
|
||||
root / "RuntimeScreen.xcui",
|
||||
"<View name=\"RuntimeScreen\" padding=\"18\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Text text=\"Runtime HUD\" />\n"
|
||||
" <Card title=\"Quest Tracker\" subtitle=\"2 active objectives\">\n"
|
||||
" <Text text=\"Collect 3 relic shards\" />\n"
|
||||
" </Card>\n"
|
||||
" <Row gap=\"12\">\n"
|
||||
" <Button title=\"Resume\" width=\"stretch\" />\n"
|
||||
" <Button title=\"Settings\" width=\"stretch\" />\n"
|
||||
" </Row>\n"
|
||||
" </Column>\n"
|
||||
"</View>\n");
|
||||
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
UIScreenAsset asset = {};
|
||||
asset.documentPath = (root / "RuntimeScreen.xcui").string();
|
||||
ASSERT_TRUE(player.Load(asset));
|
||||
|
||||
const UIScreenFrameResult& frame = player.Update(MakeFrameInput(33));
|
||||
EXPECT_TRUE(frame.errorMessage.empty());
|
||||
EXPECT_TRUE(frame.stats.documentLoaded);
|
||||
EXPECT_GT(frame.stats.commandCount, 0u);
|
||||
EXPECT_GT(frame.stats.nodeCount, 0u);
|
||||
EXPECT_GT(frame.stats.filledRectCommandCount, 0u);
|
||||
EXPECT_GT(frame.stats.textCommandCount, 0u);
|
||||
EXPECT_EQ(frame.stats.presentedFrameIndex, 33u);
|
||||
|
||||
fs::remove_all(root);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -334,3 +334,55 @@ TEST(NewEditorXCUIDemoRuntimeTest, TextFieldAcceptsUtf8CharactersAndBackspace) {
|
||||
EXPECT_NE(FindTextCommand(backspacedFrame.drawData, "AI"), nullptr);
|
||||
EXPECT_EQ(backspacedFrame.stats.focusedElementId, "agentPrompt");
|
||||
}
|
||||
|
||||
TEST(NewEditorXCUIDemoRuntimeTest, TextAreaAcceptsMultilineInputAndCaretMovement) {
|
||||
XCEngine::Editor::XCUIBackend::XCUIDemoRuntime runtime;
|
||||
ASSERT_TRUE(runtime.ReloadDocuments());
|
||||
|
||||
const auto& baselineFrame = runtime.Update(BuildInputState());
|
||||
ASSERT_TRUE(baselineFrame.stats.documentsReady);
|
||||
|
||||
XCEngine::UI::UIRect notesRect = {};
|
||||
ASSERT_TRUE(runtime.TryGetElementRect("sessionNotes", notesRect));
|
||||
|
||||
const XCEngine::UI::UIPoint notesCenter(
|
||||
notesRect.x + notesRect.width * 0.5f,
|
||||
notesRect.y + notesRect.height * 0.5f);
|
||||
|
||||
XCEngine::Editor::XCUIBackend::XCUIDemoInputState pressedInput = BuildInputState();
|
||||
pressedInput.pointerPosition = notesCenter;
|
||||
pressedInput.pointerPressed = true;
|
||||
pressedInput.pointerDown = true;
|
||||
runtime.Update(pressedInput);
|
||||
|
||||
XCEngine::Editor::XCUIBackend::XCUIDemoInputState releasedInput = BuildInputState();
|
||||
releasedInput.pointerPosition = notesCenter;
|
||||
releasedInput.pointerReleased = true;
|
||||
const auto& focusedFrame = runtime.Update(releasedInput);
|
||||
ASSERT_TRUE(focusedFrame.stats.documentsReady);
|
||||
EXPECT_EQ(focusedFrame.stats.focusedElementId, "sessionNotes");
|
||||
|
||||
XCEngine::Editor::XCUIBackend::XCUIDemoInputState textInput = BuildInputState();
|
||||
textInput.events.push_back(MakeCharacterEvent('O'));
|
||||
textInput.events.push_back(MakeCharacterEvent('K'));
|
||||
textInput.events.push_back(MakeKeyDownEvent(XCEngine::Input::KeyCode::Enter));
|
||||
textInput.events.push_back(MakeCharacterEvent('X'));
|
||||
const auto& typedFrame = runtime.Update(textInput);
|
||||
|
||||
ASSERT_TRUE(typedFrame.stats.documentsReady);
|
||||
EXPECT_EQ(typedFrame.stats.focusedElementId, "sessionNotes");
|
||||
EXPECT_EQ(typedFrame.stats.lastCommandId, "demo.text.edit.sessionNotes");
|
||||
EXPECT_NE(FindTextCommand(typedFrame.drawData, "OK"), nullptr);
|
||||
EXPECT_NE(FindTextCommand(typedFrame.drawData, "X"), nullptr);
|
||||
|
||||
XCEngine::Editor::XCUIBackend::XCUIDemoInputState caretInput = BuildInputState();
|
||||
caretInput.events.push_back(MakeKeyDownEvent(XCEngine::Input::KeyCode::Up));
|
||||
caretInput.events.push_back(MakeKeyDownEvent(XCEngine::Input::KeyCode::End));
|
||||
caretInput.events.push_back(MakeCharacterEvent('!'));
|
||||
const auto& editedFrame = runtime.Update(caretInput);
|
||||
|
||||
ASSERT_TRUE(editedFrame.stats.documentsReady);
|
||||
EXPECT_NE(FindTextCommand(editedFrame.drawData, "OK!"), nullptr);
|
||||
EXPECT_NE(FindTextCommand(editedFrame.drawData, "X"), nullptr);
|
||||
EXPECT_EQ(editedFrame.stats.focusedElementId, "sessionNotes");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user