Expand XCUI layout lab editor widgets

This commit is contained in:
2026-04-05 05:44:07 +08:00
parent 01c54d017f
commit 6dcf881967
12 changed files with 608 additions and 389 deletions

View File

@@ -13,6 +13,10 @@
<Token name="space.cardInset" type="float" value="12" /> <Token name="space.cardInset" type="float" value="12" />
<Token name="radius.card" type="float" value="10" /> <Token name="radius.card" type="float" value="10" />
<Token name="size.listItemHeight" type="float" value="60" /> <Token name="size.listItemHeight" type="float" value="60" />
<Token name="size.treeItemHeight" type="float" value="30" />
<Token name="size.treeIndent" type="float" value="18" />
<Token name="size.fieldRowHeight" type="float" value="32" />
<Token name="size.propertySectionHeight" type="float" value="156" />
<Token name="size.scrollStep" type="float" value="64" /> <Token name="size.scrollStep" type="float" value="64" />
<Token name="font.title" type="float" value="16" /> <Token name="font.title" type="float" value="16" />
<Token name="font.body" type="float" value="13" /> <Token name="font.body" type="float" value="13" />

View File

@@ -14,21 +14,30 @@
tone="accent-alt" tone="accent-alt"
title="Tool Shelf" title="Tool Shelf"
subtitle="Scene, asset, and play-mode actions." /> subtitle="Scene, asset, and play-mode actions." />
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="88"> <ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="132">
<Card <Card
id="assetListHeader" id="assetListHeader"
height="54" height="54"
tone="accent-alt" tone="accent-alt"
title="Project Browser" title="Project Browser"
subtitle="Pinned filters and import shortcuts." /> subtitle="Pinned filters and import shortcuts." />
<Card id="assetMaterials" title="Materials_Master" subtitle="14 assets selected" /> <TreeView id="projectTree" height="156" padding="8" gap="6">
<Card id="assetTerrain" title="Terrain_Cliffs_04" subtitle="Texture2D -4096 x 4096" /> <TreeItem id="treeAssetsRoot" title="Assets" subtitle="Workspace root" />
<Card id="assetLighting" title="Lighting_GlobalRig" subtitle="Prefab -Directional setup" /> <TreeItem id="treeScenes" title="Scenes" subtitle="4 authored scenes" indent="1" />
<Card id="assetCharacter" title="Hero_Character_Controller" subtitle="Prefab -Animation graph bound" /> <TreeItem id="treeMaterials" title="Materials" subtitle="Shared lookdev library" indent="1" />
<Card id="assetUiAtlas" title="UI_RuntimeAtlas" subtitle="SpriteAtlas -83 packed sprites" /> <TreeItem id="treeCharacters" title="Characters" subtitle="Prefab variants" indent="1" />
<Card id="assetAudio" title="Audio_Ambience_Forest" subtitle="AudioBank -loop region authored" /> <TreeItem id="treeUi" title="UI" subtitle="Runtime atlas + themes" indent="1" />
<Card id="assetShader" title="Shader_StylizedTerrain" subtitle="ShaderGraph -6 exposed params" /> </TreeView>
<Card id="assetQuest" title="Quest_DebugFlow" subtitle="ScriptableObject -branching state" /> <ListView id="recentAssetList" height="496" padding="8" gap="6">
<ListItem id="assetMaterials" title="Materials_Master" subtitle="14 assets selected" />
<ListItem id="assetTerrain" title="Terrain_Cliffs_04" subtitle="Texture2D -4096 x 4096" />
<ListItem id="assetLighting" title="Lighting_GlobalRig" subtitle="Prefab -Directional setup" />
<ListItem id="assetCharacter" title="Hero_Character_Controller" subtitle="Prefab -Animation graph bound" />
<ListItem id="assetUiAtlas" title="UI_RuntimeAtlas" subtitle="SpriteAtlas -83 packed sprites" />
<ListItem id="assetAudio" title="Audio_Ambience_Forest" subtitle="AudioBank -loop region authored" />
<ListItem id="assetShader" title="Shader_StylizedTerrain" subtitle="ShaderGraph -6 exposed params" />
<ListItem id="assetQuest" title="Quest_DebugFlow" subtitle="ScriptableObject -branching state" />
</ListView>
</ScrollView> </ScrollView>
</Column> </Column>
<Column id="centerColumn" width="stretch" gap="10"> <Column id="centerColumn" width="stretch" gap="10">
@@ -69,12 +78,33 @@
title="Inspector Summary" title="Inspector Summary"
subtitle="Transform, renderer, and prefab overrides." /> subtitle="Transform, renderer, and prefab overrides." />
<ScrollView id="inspectorSections" height="stretch" padding="10" gap="8" scrollY="52"> <ScrollView id="inspectorSections" height="stretch" padding="10" gap="8" scrollY="52">
<Card id="inspectorTransform" title="Transform" subtitle="Position / rotation / scale" /> <PropertySection id="inspectorTransform" height="156" title="Transform" subtitle="Position / rotation / scale">
<Card id="inspectorMesh" title="Mesh Renderer" subtitle="Materials, shadow flags, probes" /> <FieldRow id="fieldPosition" title="Position" subtitle="0.0, 1.5, 0.0" />
<Card id="inspectorPhysics" title="Physics Body" subtitle="Mass, drag, collision matrix" /> <FieldRow id="fieldRotation" title="Rotation" subtitle="0.0, 42.0, 0.0" />
<Card id="inspectorAnimation" title="Animation Graph" subtitle="Parameters and blend tree state" /> <FieldRow id="fieldScale" title="Scale" subtitle="1.0, 1.0, 1.0" />
<Card id="inspectorAudio" title="Audio Sources" subtitle="Spatial mix and snapshot sends" /> </PropertySection>
<Card id="inspectorMetadata" title="Metadata" subtitle="Tags, labels, import provenance" /> <PropertySection id="inspectorMesh" height="156" title="Mesh Renderer" subtitle="Materials, shadow flags, probes">
<FieldRow id="fieldMaterial" title="Material" subtitle="M_StylizedTerrain" />
<FieldRow id="fieldShadows" title="Cast Shadows" subtitle="On" />
<FieldRow id="fieldProbe" title="Light Probe" subtitle="Blend Probes" />
</PropertySection>
<PropertySection id="inspectorPhysics" height="156" title="Physics Body" subtitle="Mass, drag, collision matrix">
<FieldRow id="fieldMass" title="Mass" subtitle="78.0" />
<FieldRow id="fieldDrag" title="Drag" subtitle="0.35" />
<FieldRow id="fieldLayer" title="Collision Layer" subtitle="Character" />
</PropertySection>
<PropertySection id="inspectorAnimation" height="132" title="Animation Graph" subtitle="Parameters and blend tree state">
<FieldRow id="fieldLocomotion" title="Locomotion" subtitle="RunForward" />
<FieldRow id="fieldUpperBody" title="Upper Body" subtitle="Rifle_Aim" />
</PropertySection>
<PropertySection id="inspectorAudio" height="132" title="Audio Sources" subtitle="Spatial mix and snapshot sends">
<FieldRow id="fieldMixer" title="Mixer Bus" subtitle="Gameplay/Foley" />
<FieldRow id="fieldSpatialBlend" title="Spatial Blend" subtitle="0.85" />
</PropertySection>
<PropertySection id="inspectorMetadata" height="132" title="Metadata" subtitle="Tags, labels, import provenance">
<FieldRow id="fieldTags" title="Tags" subtitle="Gameplay, Hero, Traversal" />
<FieldRow id="fieldImportedBy" title="Imported By" subtitle="Asset pipeline v2" />
</PropertySection>
</ScrollView> </ScrollView>
</Column> </Column>
</Row> </Row>

View File

@@ -10,6 +10,7 @@ Old `editor` replacement is explicitly out of scope for this phase.
- Phase 1 sandbox batch committed and pushed as `67a28bd` (`Add XCUI new editor sandbox phase 1`). - Phase 1 sandbox batch committed and pushed as `67a28bd` (`Add XCUI new editor sandbox phase 1`).
- Phase 2 common/runtime batch committed and pushed as `ade5be3` (`Add XCUI runtime screen layer and demo textarea`). - Phase 2 common/runtime batch committed and pushed as `ade5be3` (`Add XCUI runtime screen layer and demo textarea`).
- Current work has moved into Phase 3: stabilize schema/validation and continue filling the remaining common/runtime/editor gaps instead of replacing the old editor. - Current work has moved into Phase 3: stabilize schema/validation and continue filling the remaining common/runtime/editor gaps instead of replacing the old editor.
- The current stable editor-layer batch is centered on `LayoutLab` as the widget proving ground for tree/list/property-section style controls.
## Three-Layer Status ## Three-Layer Status
@@ -47,22 +48,23 @@ Current gap:
- `XCUI Demo` remains the long-lived effect and behavior testbed. - `XCUI Demo` remains the long-lived effect and behavior testbed.
- `XCUI Demo` now covers both single-line and multiline text authoring behavior. - `XCUI Demo` now covers both single-line and multiline text authoring behavior.
- `LayoutLab` now includes a `ScrollView` prototype and a more editor-like three-column authored layout. - `LayoutLab` now includes a `ScrollView` prototype and a more editor-like three-column authored layout.
- `LayoutLab` now also covers editor-facing widget prototypes: `TreeView`, `TreeItem`, `ListView`, `ListItem`, `PropertySection`, and `FieldRow`.
- Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths. - Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths.
- `XCNewEditor` builds successfully to `build/new_editor/bin/Debug/XCNewEditor.exe`. - `XCNewEditor` builds successfully to `build/new_editor/bin/Debug/XCNewEditor.exe`.
Current gap: Current gap:
- The shell is still ImGui-hosted. - The shell is still ImGui-hosted.
- Editor-specialized widgets are still incomplete: tree, list virtualization, property grid, toolbar/menu, text area, icon atlas widgets. - Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, selection models, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules.
## Validated This Phase ## Validated This Phase
- `new_editor_xcui_demo_runtime_tests`: `7/7` - `new_editor_xcui_demo_runtime_tests`: `7/7`
- `new_editor_xcui_layout_lab_runtime_tests`: `5/5` - `new_editor_xcui_layout_lab_runtime_tests`: `6/6`
- `new_editor_xcui_rhi_command_compiler_tests`: `6/6` - `new_editor_xcui_rhi_command_compiler_tests`: `6/6`
- `new_editor_xcui_rhi_render_backend_tests`: `5/5` - `new_editor_xcui_rhi_render_backend_tests`: `5/5`
- `XCNewEditor` Debug target builds successfully - `XCNewEditor` Debug target builds successfully
- `core_ui_tests`: `19/19` - `core_ui_tests`: `14/14`
- `core_ui_style_tests`: `5/5` - `core_ui_style_tests`: `5/5`
## Landed This Phase ## Landed This Phase
@@ -71,6 +73,7 @@ Current gap:
- Demo runtime multiline `TextArea` path in the sandbox and test coverage for caret movement / multiline input. - Demo runtime multiline `TextArea` path in the sandbox and test coverage for caret movement / multiline input.
- Demo authored resources updated to exercise the input field. - Demo authored resources updated to exercise the input field.
- LayoutLab `ScrollView` prototype with clipping and hover rejection outside clipped content. - LayoutLab `ScrollView` prototype with clipping and hover rejection outside clipped content.
- LayoutLab editor-widget prototypes for tree/list/property-style sections with dedicated runtime coverage.
- Engine runtime layer added: - Engine runtime layer added:
- `UIScreenPlayer` - `UIScreenPlayer`
- `UIDocumentScreenHost` - `UIDocumentScreenHost`
@@ -84,6 +87,7 @@ Current gap:
- `new_editor` panel/shell diagnostics improvements for hosted preview state. - `new_editor` panel/shell diagnostics improvements for hosted preview state.
- XCUI asset document loading changed to prefer direct source compilation before `ResourceManager` fallback for the sandbox path, fixing the LayoutLab crash. - XCUI asset document loading changed to prefer direct source compilation before `ResourceManager` fallback for the sandbox path, fixing the LayoutLab crash.
- `UIDocumentCompiler.cpp` repaired enough to restore full local builds after the duplicated schema-helper regression. - `UIDocumentCompiler.cpp` repaired enough to restore full local builds after the duplicated schema-helper regression.
- MSVC debug build hardening was tightened again so large parallel `engine` rebuilds stop tripping over compile-PDB contention.
## Phase Risks Still Open ## Phase Risks Still Open
@@ -91,11 +95,12 @@ Current gap:
- `ScrollView` is still authored/static; no wheel-driven scrolling or virtualization yet. - `ScrollView` is still authored/static; no wheel-driven scrolling or virtualization yet.
- `Image` widgets still do not have source-rect/atlas-subregion level API in the high-level draw command model. - `Image` widgets still do not have source-rect/atlas-subregion level API in the high-level draw command model.
- Editor shell still depends on ImGui as host chrome. - Editor shell still depends on ImGui as host chrome.
- Editor widget coverage is still prototype-driven inside `LayoutLab`; it has not yet been promoted into a reusable shared widget/runtime layer.
## Next Phase ## Next Phase
1. Cleanly stabilize schema/validation in `UIDocumentCompiler.cpp` and add targeted schema regression tests. 1. Cleanly stabilize schema/validation in `UIDocumentCompiler.cpp` and add targeted schema regression tests.
2. Expand runtime/game-layer ownership from the current document host + layered `UISystem` into reusable menu/HUD stack patterns and engine runtime integration. 2. Expand runtime/game-layer ownership from the current document host + layered `UISystem` into reusable menu/HUD stack patterns and engine runtime integration.
3. Add next editor-facing widgets: tree/list, property-style sections, toolbar/menu, and more native shell-owned chrome. 3. Promote the current editor-facing widget prototypes out of authored `LayoutLab` content and into reusable XCUI widget/runtime modules, then continue with toolbar/menu and more native shell-owned chrome.
4. Move more diagnostics and shell affordances into XCUI-owned editor-layer surfaces instead of only ImGui HUDs. 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. 5. Continue phased validation, commit, push, and plan refresh after each stable batch.

View File

@@ -388,6 +388,15 @@ void EmitNode(
++stats.textCommandCount; ++stats.textCommandCount;
} }
if (tagName == "Button" && title.empty() && subtitle.empty()) {
drawList.AddText(
UIPoint(node.rect.x + 12.0f, node.rect.y + 12.0f),
ResolveNodeText(source),
ToUIColor(Color(0.95f, 0.97f, 1.0f, 1.0f)),
kDefaultFontSize);
++stats.textCommandCount;
}
for (const RuntimeLayoutNode& child : node.children) { for (const RuntimeLayoutNode& child : node.children) {
EmitNode(child, drawList, stats); EmitNode(child, drawList, stats);
} }

View File

@@ -1,11 +1,43 @@
#include <XCEngine/UI/Runtime/UISystem.h> #include <XCEngine/UI/Runtime/UISystem.h>
#include <algorithm>
namespace XCEngine { namespace XCEngine {
namespace UI { namespace UI {
namespace Runtime { namespace Runtime {
namespace {
std::size_t FindTopInputLayerIndex(
const std::vector<UIScreenLayerOptions>& layerOptions,
std::size_t lowestPresentedIndex) {
if (layerOptions.empty() || lowestPresentedIndex >= layerOptions.size()) {
return static_cast<std::size_t>(-1);
}
for (std::size_t index = layerOptions.size(); index-- > lowestPresentedIndex;) {
if (layerOptions[index].visible && layerOptions[index].acceptsInput) {
return index;
}
}
return static_cast<std::size_t>(-1);
}
std::size_t FindLowestPresentedLayerIndex(const std::vector<UIScreenLayerOptions>& layerOptions) {
if (layerOptions.empty()) {
return 0u;
}
for (std::size_t index = layerOptions.size(); index-- > 0u;) {
if (layerOptions[index].visible && layerOptions[index].blocksLayersBelow) {
return index;
}
}
return 0u;
}
} // namespace
UISystem::UISystem(IUIScreenDocumentHost& documentHost) UISystem::UISystem(IUIScreenDocumentHost& documentHost)
: m_documentHost(&documentHost) { : m_documentHost(&documentHost) {
} }
@@ -27,7 +59,8 @@ UIScreenLayerId UISystem::PushScreen(
m_layerOptions.pop_back(); m_layerOptions.pop_back();
return 0; return 0;
} }
return m_layerIds.empty() ? 0 : m_layerIds.back();
return m_layerIds.back();
} }
bool UISystem::RemoveLayer(UIScreenLayerId layerId) { bool UISystem::RemoveLayer(UIScreenLayerId layerId) {
@@ -52,9 +85,7 @@ bool UISystem::SetLayerVisibility(UIScreenLayerId layerId, bool visible) {
return true; return true;
} }
bool UISystem::SetLayerOptions( bool UISystem::SetLayerOptions(UIScreenLayerId layerId, const UIScreenLayerOptions& options) {
UIScreenLayerId layerId,
const UIScreenLayerOptions& options) {
const std::size_t index = FindLayerIndex(layerId); const std::size_t index = FindLayerIndex(layerId);
if (index >= m_layerOptions.size()) { if (index >= m_layerOptions.size()) {
return false; return false;
@@ -66,9 +97,7 @@ bool UISystem::SetLayerOptions(
const UIScreenLayerOptions* UISystem::FindLayerOptions(UIScreenLayerId layerId) const { const UIScreenLayerOptions* UISystem::FindLayerOptions(UIScreenLayerId layerId) const {
const std::size_t index = FindLayerIndex(layerId); const std::size_t index = FindLayerIndex(layerId);
return index < m_layerOptions.size() return index < m_layerOptions.size() ? &m_layerOptions[index] : nullptr;
? &m_layerOptions[index]
: nullptr;
} }
UIScreenLayerId UISystem::GetLayerId(std::size_t index) const { UIScreenLayerId UISystem::GetLayerId(std::size_t index) const {
@@ -94,71 +123,54 @@ const UISystemFrameResult& UISystem::Update(const UIScreenFrameInput& input) {
m_lastFrame = {}; m_lastFrame = {};
m_lastFrame.frameIndex = input.frameIndex; m_lastFrame.frameIndex = input.frameIndex;
std::vector<std::size_t> presentedIndices; if (m_players.empty()) {
presentedIndices.reserve(m_players.size()); return m_lastFrame;
for (std::size_t index = m_players.size(); index > 0; --index) { }
const std::size_t layerIndex = index - 1;
if (!m_layerOptions[layerIndex].visible) { const std::size_t lowestPresentedIndex = FindLowestPresentedLayerIndex(m_layerOptions);
const std::size_t inputLayerIndex = FindTopInputLayerIndex(m_layerOptions, lowestPresentedIndex);
for (std::size_t index = 0; index < lowestPresentedIndex && index < m_players.size(); ++index) {
++m_lastFrame.skippedLayerCount;
}
for (std::size_t index = lowestPresentedIndex; index < m_players.size(); ++index) {
if (!m_layerOptions[index].visible) {
++m_lastFrame.skippedLayerCount;
continue; 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; UIScreenFrameInput layerInput = input;
if (layerIndex != interactiveLayerIndex) { if (index != inputLayerIndex) {
layerInput.events.clear(); layerInput.events.clear();
layerInput.focused = false;
} }
const UIScreenFrameResult& frame = m_players[layerIndex]->Update(layerInput); const UIScreenFrameResult& layerFrame = m_players[index]->Update(layerInput);
for (const UIDrawList& drawList : frame.drawData.GetDrawLists()) { for (const UIDrawList& drawList : layerFrame.drawData.GetDrawLists()) {
m_lastFrame.drawData.AddDrawList(drawList); m_lastFrame.drawData.AddDrawList(drawList);
} }
UISystemPresentedLayer presentedLayer = {}; UISystemPresentedLayer presentedLayer = {};
presentedLayer.layerId = m_layerIds[layerIndex]; presentedLayer.layerId = m_layerIds[index];
if (const UIScreenAsset* asset = m_players[layerIndex]->GetAsset(); if (const UIScreenAsset* asset = m_players[index]->GetAsset(); asset != nullptr) {
asset != nullptr) {
presentedLayer.asset = *asset; presentedLayer.asset = *asset;
} }
presentedLayer.options = m_layerOptions[layerIndex]; presentedLayer.options = m_layerOptions[index];
presentedLayer.stats = frame.stats; presentedLayer.stats = layerFrame.stats;
m_lastFrame.layers.push_back(std::move(presentedLayer)); m_lastFrame.layers.push_back(std::move(presentedLayer));
++m_lastFrame.presentedLayerCount;
if (m_lastFrame.errorMessage.empty() && !frame.errorMessage.empty()) { if (m_lastFrame.errorMessage.empty() && !layerFrame.errorMessage.empty()) {
m_lastFrame.errorMessage = frame.errorMessage; m_lastFrame.errorMessage = layerFrame.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; return m_lastFrame;
} }
void UISystem::Tick(const UIScreenFrameInput& input) { void UISystem::Tick(const UIScreenFrameInput& input) {
for (const std::unique_ptr<UIScreenPlayer>& player : m_players) { Update(input);
if (player) {
player->Update(input);
}
}
} }
const UISystemFrameResult& UISystem::GetLastFrame() const { const UISystemFrameResult& UISystem::GetLastFrame() const {
@@ -176,7 +188,7 @@ std::size_t UISystem::FindLayerIndex(UIScreenLayerId layerId) const {
} }
} }
return m_layerIds.size(); return static_cast<std::size_t>(-1);
} }
} // namespace Runtime } // namespace Runtime

View File

@@ -11,6 +11,11 @@
<Token name="space.stack" type="float" value="12" /> <Token name="space.stack" type="float" value="12" />
<Token name="space.cardInset" type="float" value="12" /> <Token name="space.cardInset" type="float" value="12" />
<Token name="radius.card" type="float" value="10" /> <Token name="radius.card" type="float" value="10" />
<Token name="size.listItemHeight" type="float" value="60" />
<Token name="size.treeItemHeight" type="float" value="30" />
<Token name="size.treeIndent" type="float" value="18" />
<Token name="size.fieldRowHeight" type="float" value="32" />
<Token name="size.propertySectionHeight" type="float" value="156" />
<Token name="font.title" type="float" value="16" /> <Token name="font.title" type="float" value="16" />
<Token name="font.body" type="float" value="13" /> <Token name="font.body" type="float" value="13" />
</Theme> </Theme>

View File

@@ -7,37 +7,89 @@
title="XCUI Layout Lab" title="XCUI Layout Lab"
subtitle="Resource-driven row / column / overlay stress." /> subtitle="Resource-driven row / column / overlay stress." />
<Row id="mainRow" height="stretch" gap="14"> <Row id="mainRow" height="stretch" gap="14">
<Column id="leftColumn" width="0.28" gap="12"> <Column id="leftRail" width="272" gap="10">
<Card id="leftTop" height="stretch" title="Left Column" subtitle="Stack item 1" /> <Card
<Card id="leftBottom" height="stretch" title="Left Column" subtitle="Stack item 2" /> id="toolShelf"
height="62"
tone="accent-alt"
title="Tool Shelf"
subtitle="Scene, asset, and play-mode actions." />
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="132">
<Card
id="assetListHeader"
height="54"
tone="accent-alt"
title="Project Browser"
subtitle="Pinned filters and import shortcuts." />
<TreeView id="projectTree" height="156" padding="8" gap="6">
<TreeItem id="treeAssetsRoot" title="Assets" subtitle="Workspace root" />
<TreeItem id="treeScenes" title="Scenes" subtitle="4 authored scenes" indent="1" />
<TreeItem id="treeMaterials" title="Materials" subtitle="Shared lookdev library" indent="1" />
<TreeItem id="treeCharacters" title="Characters" subtitle="Prefab variants" indent="1" />
<TreeItem id="treeUi" title="UI" subtitle="Runtime atlas + themes" indent="1" />
</TreeView>
<ListView id="recentAssetList" height="360" padding="8" gap="6">
<ListItem id="assetMaterials" title="Materials_Master" subtitle="14 assets selected" />
<ListItem id="assetTerrain" title="Terrain_Cliffs_04" subtitle="Texture2D -4096 x 4096" />
<ListItem id="assetLighting" title="Lighting_GlobalRig" subtitle="Prefab -Directional setup" />
<ListItem id="assetCharacter" title="Hero_Character_Controller" subtitle="Prefab -Animation graph bound" />
<ListItem id="assetUiAtlas" title="UI_RuntimeAtlas" subtitle="SpriteAtlas -83 packed sprites" />
</ListView>
</ScrollView>
</Column> </Column>
<Overlay id="centerOverlay" width="0.42"> <Column id="centerColumn" width="stretch" gap="10">
<Card <Card
id="overlayBase" id="viewportToolbar"
title="Center Overlay" height="62"
subtitle="Base layer filling the entire region." /> title="Viewport Toolbar"
subtitle="Gizmos, snap presets, camera bookmarks." />
<Overlay id="viewportOverlay" height="stretch">
<Card
id="viewportBase"
title="Scene Viewport"
subtitle="Primary preview surface with composition overlays." />
<Card
id="viewportBadge"
x="18"
y="18"
width="224"
height="68"
tone="accent-alt"
title="Selection Overlay"
subtitle="Bounds, pivots, nav markers." />
<Card
id="viewportInspectorBubble"
x="0.58"
y="0.54"
width="0.32"
height="88"
tone="accent-alt"
title="Context Bubble"
subtitle="Inline quick edit affordance." />
</Overlay>
</Column>
<Column id="inspectorColumn" width="320" gap="10">
<Card <Card
id="overlayNorth" id="inspectorSummary"
x="18" height="88"
y="18" title="Inspector Summary"
width="0.42" subtitle="Transform, renderer, and prefab overrides." />
height="72" <ScrollView id="inspectorSections" height="stretch" padding="10" gap="8" scrollY="52">
tone="accent-alt" <PropertySection id="inspectorTransform" height="156" title="Transform" subtitle="Position / rotation / scale">
title="Overlay A" <FieldRow id="fieldPosition" title="Position" subtitle="0.0, 1.5, 0.0" />
subtitle="Floating note" /> <FieldRow id="fieldRotation" title="Rotation" subtitle="0.0, 42.0, 0.0" />
<Card <FieldRow id="fieldScale" title="Scale" subtitle="1.0, 1.0, 1.0" />
id="overlayCenter" </PropertySection>
x="0.28" <PropertySection id="inspectorMesh" height="156" title="Mesh Renderer" subtitle="Materials, shadow flags, probes">
y="0.45" <FieldRow id="fieldMaterial" title="Material" subtitle="M_StylizedTerrain" />
width="0.44" <FieldRow id="fieldShadows" title="Cast Shadows" subtitle="On" />
height="86" <FieldRow id="fieldProbe" title="Light Probe" subtitle="Blend Probes" />
tone="accent-alt" </PropertySection>
title="Overlay B" <PropertySection id="inspectorMetadata" height="132" title="Metadata" subtitle="Tags, labels, import provenance">
subtitle="Centered overlay layer" /> <FieldRow id="fieldTags" title="Tags" subtitle="Gameplay, Hero, Traversal" />
</Overlay> <FieldRow id="fieldImportedBy" title="Imported By" subtitle="Asset pipeline v2" />
<Column id="rightColumn" width="stretch" gap="12"> </PropertySection>
<Card id="rightTop" height="stretch" title="Right Column" subtitle="Another stacked column" /> </ScrollView>
<Card id="rightBottom" height="stretch" title="Right Column" subtitle="Pairs with the overlay stage" />
</Column> </Column>
</Row> </Row>
<Overlay id="footerOverlay" height="146"> <Overlay id="footerOverlay" height="146">

View File

@@ -60,6 +60,7 @@ struct LayoutNode {
std::string gapAttr = {}; std::string gapAttr = {};
std::string paddingAttr = {}; std::string paddingAttr = {};
std::string scrollYAttr = {}; std::string scrollYAttr = {};
std::string indentAttr = {};
std::size_t parentIndex = kInvalidIndex; std::size_t parentIndex = kInvalidIndex;
std::vector<std::size_t> children = {}; std::vector<std::size_t> children = {};
UIRect rect = {}; UIRect rect = {};
@@ -287,6 +288,30 @@ bool IsScrollViewTag(const std::string& tagName) {
return tagName == "ScrollView"; return tagName == "ScrollView";
} }
bool IsTreeViewTag(const std::string& tagName) {
return tagName == "TreeView";
}
bool IsTreeItemTag(const std::string& tagName) {
return tagName == "TreeItem";
}
bool IsListViewTag(const std::string& tagName) {
return tagName == "ListView";
}
bool IsListItemTag(const std::string& tagName) {
return tagName == "ListItem";
}
bool IsPropertySectionTag(const std::string& tagName) {
return tagName == "PropertySection";
}
bool IsFieldRowTag(const std::string& tagName) {
return tagName == "FieldRow";
}
float ResolveScalar( float ResolveScalar(
const std::string& text, const std::string& text,
float referenceValue, float referenceValue,
@@ -417,6 +442,7 @@ void BuildNodesRecursive(
layoutNode.gapAttr = GetAttributeValue(node, "gap"); layoutNode.gapAttr = GetAttributeValue(node, "gap");
layoutNode.paddingAttr = GetAttributeValue(node, "padding"); layoutNode.paddingAttr = GetAttributeValue(node, "padding");
layoutNode.scrollYAttr = GetAttributeValue(node, "scrollY"); layoutNode.scrollYAttr = GetAttributeValue(node, "scrollY");
layoutNode.indentAttr = GetAttributeValue(node, "indent");
layoutNode.parentIndex = parentIndex; layoutNode.parentIndex = parentIndex;
layoutNode.depth = depth; layoutNode.depth = depth;
@@ -458,6 +484,12 @@ float ResolvePadding(const LayoutNode& node, const Style::UITheme& theme) {
ResolveFloatToken(theme, "space.outer", 18.0f)); ResolveFloatToken(theme, "space.outer", 18.0f));
} }
if (IsTreeViewTag(node.tagName) ||
IsListViewTag(node.tagName) ||
IsPropertySectionTag(node.tagName)) {
return ResolveFloatToken(theme, "space.cardInset", 12.0f);
}
return node.tagName == "View" return node.tagName == "View"
? ResolveFloatToken(theme, "space.outer", 18.0f) ? ResolveFloatToken(theme, "space.outer", 18.0f)
: 0.0f; : 0.0f;
@@ -480,10 +512,44 @@ float ResolveListItemHeight(const Style::UITheme& theme) {
return ResolveFloatToken(theme, "size.listItemHeight", 60.0f); return ResolveFloatToken(theme, "size.listItemHeight", 60.0f);
} }
float ResolveTreeIndent(const LayoutNode& node, const Style::UITheme& theme) {
float indentLevel = 0.0f;
if (!node.indentAttr.empty()) {
TryParseFloat(node.indentAttr, indentLevel);
}
return (std::max)(0.0f, indentLevel) *
ResolveFloatToken(theme, "size.treeIndent", 18.0f);
}
float ResolveFieldRowHeight(const Style::UITheme& theme) {
return ResolveFloatToken(theme, "size.fieldRowHeight", 32.0f);
}
UIRect GetContentRect(const LayoutNode& node, const Style::UITheme& theme) { UIRect GetContentRect(const LayoutNode& node, const Style::UITheme& theme) {
return InsetRect(node.rect, ResolvePadding(node, theme)); return InsetRect(node.rect, ResolvePadding(node, theme));
} }
float ResolveDefaultHeight(const LayoutNode& node, const Style::UITheme& theme) {
if (IsTreeItemTag(node.tagName)) {
return ResolveFloatToken(theme, "size.treeItemHeight", 28.0f);
}
if (IsListItemTag(node.tagName)) {
return ResolveListItemHeight(theme);
}
if (IsFieldRowTag(node.tagName)) {
return ResolveFieldRowHeight(theme);
}
if (IsPropertySectionTag(node.tagName)) {
return ResolveFloatToken(theme, "size.propertySectionHeight", 148.0f);
}
return 0.0f;
}
void LayoutNodeTree(RuntimeBuildContext& state, std::size_t nodeIndex); void LayoutNodeTree(RuntimeBuildContext& state, std::size_t nodeIndex);
void LayoutColumnChildren(RuntimeBuildContext& state, std::size_t nodeIndex) { void LayoutColumnChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
@@ -505,7 +571,10 @@ void LayoutColumnChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
continue; continue;
} }
resolvedHeights[childOffset] = ResolveScalar(child.heightAttr, contentRect.height, 0.0f); resolvedHeights[childOffset] = ResolveScalar(
child.heightAttr,
contentRect.height,
ResolveDefaultHeight(child, state.theme));
fixedHeight += resolvedHeights[childOffset]; fixedHeight += resolvedHeights[childOffset];
} }
@@ -608,10 +677,15 @@ void LayoutScrollViewChildren(RuntimeBuildContext& state, std::size_t nodeIndex)
float cursorY = contentRect.y - scrollOffset; float cursorY = contentRect.y - scrollOffset;
for (std::size_t childIndex : node.children) { for (std::size_t childIndex : node.children) {
LayoutNode& child = state.nodes[childIndex]; LayoutNode& child = state.nodes[childIndex];
const float childHeight = const float defaultHeight = ResolveDefaultHeight(child, state.theme);
!IsStretch(child.heightAttr) const float childHeight = child.heightAttr.empty()
? ResolveScalar(child.heightAttr, contentRect.height, defaultItemHeight) ? (defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: defaultItemHeight; : (!IsStretch(child.heightAttr)
? ResolveScalar(
child.heightAttr,
contentRect.height,
defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: defaultItemHeight);
const float childWidth = const float childWidth =
!IsStretch(child.widthAttr) !IsStretch(child.widthAttr)
? (std::min)( ? (std::min)(
@@ -628,7 +702,11 @@ void LayoutNodeTree(RuntimeBuildContext& state, std::size_t nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex]; const LayoutNode& node = state.nodes[nodeIndex];
state.rectsById[node.id] = node.rect; state.rectsById[node.id] = node.rect;
if (node.tagName == "View" || node.tagName == "Column") { if (node.tagName == "View" ||
node.tagName == "Column" ||
IsTreeViewTag(node.tagName) ||
IsListViewTag(node.tagName) ||
IsPropertySectionTag(node.tagName)) {
LayoutColumnChildren(state, nodeIndex); LayoutColumnChildren(state, nodeIndex);
} else if (node.tagName == "Row") { } else if (node.tagName == "Row") {
LayoutRowChildren(state, nodeIndex); LayoutRowChildren(state, nodeIndex);
@@ -652,7 +730,9 @@ void DrawNode(
"color.panel", "color.panel",
Color(0.07f, 0.10f, 0.14f, 1.0f)); Color(0.07f, 0.10f, 0.14f, 1.0f));
drawList.AddFilledRect(node.rect, ToUIColor(panelColor), 0.0f); drawList.AddFilledRect(node.rect, ToUIColor(panelColor), 0.0f);
} else if (IsScrollViewTag(node.tagName)) { } else if (IsScrollViewTag(node.tagName) ||
IsTreeViewTag(node.tagName) ||
IsListViewTag(node.tagName)) {
const Color surfaceColor = ResolveColorToken( const Color surfaceColor = ResolveColorToken(
state.theme, state.theme,
"color.scroll.surface", "color.scroll.surface",
@@ -664,6 +744,44 @@ void DrawNode(
const float rounding = ResolveFloatToken(state.theme, "radius.card", 10.0f); const float rounding = ResolveFloatToken(state.theme, "radius.card", 10.0f);
drawList.AddFilledRect(node.rect, ToUIColor(surfaceColor), rounding); drawList.AddFilledRect(node.rect, ToUIColor(surfaceColor), rounding);
drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding); drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding);
} else if (IsPropertySectionTag(node.tagName)) {
const Color sectionColor = ResolveColorToken(
state.theme,
"color.card",
Color(0.12f, 0.17f, 0.23f, 1.0f));
const Color borderColor = ResolveColorToken(
state.theme,
"color.border",
Color(0.24f, 0.34f, 0.43f, 1.0f));
const Color textColor = ResolveColorToken(
state.theme,
"color.text",
Color(0.95f, 0.97f, 1.0f, 1.0f));
const Color mutedColor = ResolveColorToken(
state.theme,
"color.text.muted",
Color(0.72f, 0.79f, 0.86f, 1.0f));
const float rounding = ResolveFloatToken(state.theme, "radius.card", 10.0f);
const float inset = ResolveFloatToken(state.theme, "space.cardInset", 12.0f);
const float titleFont = ResolveFloatToken(state.theme, "font.title", 16.0f);
const float bodyFont = ResolveFloatToken(state.theme, "font.body", 13.0f);
drawList.AddFilledRect(node.rect, ToUIColor(sectionColor), rounding);
drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding);
if (!node.title.empty()) {
drawList.AddText(
UIPoint(node.rect.x + inset, node.rect.y + inset),
node.title,
ToUIColor(textColor),
titleFont);
}
if (!node.subtitle.empty()) {
drawList.AddText(
UIPoint(node.rect.x + inset, node.rect.y + inset + titleFont + 6.0f),
node.subtitle,
ToUIColor(mutedColor),
bodyFont);
}
} else if (node.tagName == "Card") { } else if (node.tagName == "Card") {
const Color cardColor = ResolveColorToken( const Color cardColor = ResolveColorToken(
state.theme, state.theme,
@@ -713,9 +831,82 @@ void DrawNode(
2.0f, 2.0f,
rounding); rounding);
} }
} else if (IsTreeItemTag(node.tagName) || IsListItemTag(node.tagName)) {
const bool hovered = node.id == hoveredId;
const Color rowColor = hovered
? ResolveColorToken(state.theme, "color.card.alt", Color(0.20f, 0.27f, 0.34f, 1.0f))
: ResolveColorToken(state.theme, "color.card", Color(0.12f, 0.17f, 0.23f, 1.0f));
const Color borderColor = ResolveColorToken(
state.theme,
"color.border",
Color(0.24f, 0.34f, 0.43f, 1.0f));
const Color textColor = ResolveColorToken(
state.theme,
"color.text",
Color(0.95f, 0.97f, 1.0f, 1.0f));
const Color mutedColor = ResolveColorToken(
state.theme,
"color.text.muted",
Color(0.72f, 0.79f, 0.86f, 1.0f));
const float inset = ResolveFloatToken(state.theme, "space.cardInset", 12.0f);
const float titleFont = ResolveFloatToken(state.theme, "font.body", 13.0f);
const float bodyFont = ResolveFloatToken(state.theme, "font.body", 13.0f);
const float rounding = ResolveFloatToken(state.theme, "radius.card", 10.0f);
const float indent = IsTreeItemTag(node.tagName) ? ResolveTreeIndent(node, state.theme) : 0.0f;
drawList.AddFilledRect(node.rect, ToUIColor(rowColor), rounding);
drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding);
if (IsTreeItemTag(node.tagName)) {
drawList.AddFilledRect(
UIRect(node.rect.x + inset + indent - 8.0f, node.rect.y + 11.0f, 4.0f, 4.0f),
ToUIColor(textColor),
2.0f);
}
drawList.AddText(
UIPoint(node.rect.x + inset + indent, node.rect.y + 8.0f),
node.title,
ToUIColor(textColor),
titleFont);
if (!node.subtitle.empty()) {
drawList.AddText(
UIPoint(node.rect.x + inset + indent, node.rect.y + 8.0f + titleFont + 4.0f),
node.subtitle,
ToUIColor(mutedColor),
bodyFont);
}
} else if (IsFieldRowTag(node.tagName)) {
const bool hovered = node.id == hoveredId;
const Color textColor = ResolveColorToken(
state.theme,
"color.text",
Color(0.95f, 0.97f, 1.0f, 1.0f));
const Color mutedColor = ResolveColorToken(
state.theme,
"color.text.muted",
Color(0.72f, 0.79f, 0.86f, 1.0f));
const Color lineColor = hovered
? ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f))
: ResolveColorToken(state.theme, "color.border", Color(0.24f, 0.34f, 0.43f, 1.0f));
const float inset = ResolveFloatToken(state.theme, "space.cardInset", 12.0f);
const float fontSize = ResolveFloatToken(state.theme, "font.body", 13.0f);
drawList.AddRectOutline(node.rect, ToUIColor(lineColor), 1.0f, 6.0f);
drawList.AddText(
UIPoint(node.rect.x + inset, node.rect.y + 8.0f),
node.title,
ToUIColor(textColor),
fontSize);
drawList.AddText(
UIPoint(node.rect.x + node.rect.width * 0.54f, node.rect.y + 8.0f),
node.subtitle,
ToUIColor(mutedColor),
fontSize);
} }
const bool clipsChildren = IsScrollViewTag(node.tagName); const bool clipsChildren =
IsScrollViewTag(node.tagName) ||
IsTreeViewTag(node.tagName) ||
IsListViewTag(node.tagName);
if (clipsChildren) { if (clipsChildren) {
drawList.PushClipRect(GetContentRect(node, state.theme)); drawList.PushClipRect(GetContentRect(node, state.theme));
} }
@@ -734,7 +925,9 @@ bool IsPointInsideNodeClipping(
std::size_t currentIndex = nodeIndex; std::size_t currentIndex = nodeIndex;
while (currentIndex != kInvalidIndex) { while (currentIndex != kInvalidIndex) {
const LayoutNode& currentNode = state.nodes[currentIndex]; const LayoutNode& currentNode = state.nodes[currentIndex];
if (IsScrollViewTag(currentNode.tagName) && if ((IsScrollViewTag(currentNode.tagName) ||
IsTreeViewTag(currentNode.tagName) ||
IsListViewTag(currentNode.tagName)) &&
!ContainsPoint(GetContentRect(currentNode, state.theme), point)) { !ContainsPoint(GetContentRect(currentNode, state.theme), point)) {
return false; return false;
} }
@@ -751,7 +944,12 @@ std::size_t HitTest(
int bestDepth = -1; int bestDepth = -1;
for (std::size_t index = 0; index < state.nodes.size(); ++index) { for (std::size_t index = 0; index < state.nodes.size(); ++index) {
const LayoutNode& node = state.nodes[index]; const LayoutNode& node = state.nodes[index];
if (node.tagName != "Card" || const bool hoverable =
node.tagName == "Card" ||
IsTreeItemTag(node.tagName) ||
IsListItemTag(node.tagName) ||
IsFieldRowTag(node.tagName);
if (!hoverable ||
!ContainsPoint(node.rect, point) || !ContainsPoint(node.rect, point) ||
!IsPointInsideNodeClipping(state, index, point)) { !IsPointInsideNodeClipping(state, index, point)) {
continue; continue;
@@ -890,6 +1088,18 @@ const XCUILayoutLabFrameResult& XCUILayoutLabRuntime::Update(const XCUILayoutLab
++state.frameResult.stats.overlayCount; ++state.frameResult.stats.overlayCount;
} else if (IsScrollViewTag(node.tagName)) { } else if (IsScrollViewTag(node.tagName)) {
++state.frameResult.stats.scrollViewCount; ++state.frameResult.stats.scrollViewCount;
} else if (IsTreeViewTag(node.tagName)) {
++state.frameResult.stats.treeViewCount;
} else if (IsTreeItemTag(node.tagName)) {
++state.frameResult.stats.treeItemCount;
} else if (IsListViewTag(node.tagName)) {
++state.frameResult.stats.listViewCount;
} else if (IsListItemTag(node.tagName)) {
++state.frameResult.stats.listItemCount;
} else if (IsPropertySectionTag(node.tagName)) {
++state.frameResult.stats.propertySectionCount;
} else if (IsFieldRowTag(node.tagName)) {
++state.frameResult.stats.fieldRowCount;
} }
} }

View File

@@ -40,6 +40,12 @@ struct XCUILayoutLabFrameStats {
std::size_t columnCount = 0; std::size_t columnCount = 0;
std::size_t overlayCount = 0; std::size_t overlayCount = 0;
std::size_t scrollViewCount = 0; std::size_t scrollViewCount = 0;
std::size_t treeViewCount = 0;
std::size_t treeItemCount = 0;
std::size_t listViewCount = 0;
std::size_t listItemCount = 0;
std::size_t propertySectionCount = 0;
std::size_t fieldRowCount = 0;
std::string hoveredElementId = {}; std::string hoveredElementId = {};
}; };

View File

@@ -1,5 +1,7 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <XCEngine/Core/Asset/IResource.h>
#include <XCEngine/Resources/UI/UIDocuments.h>
#include <XCEngine/UI/Core/UIContext.h> #include <XCEngine/UI/Core/UIContext.h>
namespace { namespace {
@@ -15,6 +17,10 @@ using XCEngine::UI::UIElementChangeKind;
using XCEngine::UI::UIElementId; using XCEngine::UI::UIElementId;
using XCEngine::UI::UIElementNode; using XCEngine::UI::UIElementNode;
using XCEngine::UI::UIElementTree; using XCEngine::UI::UIElementTree;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UISchema;
using XCEngine::Resources::UIView;
class TestViewModel : public RevisionedViewModelBase { class TestViewModel : public RevisionedViewModelBase {
public: public:
@@ -194,4 +200,41 @@ TEST(UICoreTest, RebuildFailsWhenElementScopesRemainOpen) {
EXPECT_EQ(context.GetElementTree().GetNodeCount(), 0u); EXPECT_EQ(context.GetElementTree().GetNodeCount(), 0u);
} }
TEST(UICoreTest, UIDocumentResourcesAcceptMovedDocumentModels) {
UIDocumentModel viewDocument = {};
viewDocument.kind = UIDocumentKind::View;
viewDocument.sourcePath = "Assets/UI/Test.xcui";
viewDocument.displayName = "TestView";
viewDocument.rootNode.tagName = "View";
viewDocument.valid = true;
UIView view = {};
XCEngine::Resources::IResource::ConstructParams params = {};
params.name = "TestView";
params.path = viewDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
view.Initialize(params);
view.SetDocumentModel(std::move(viewDocument));
EXPECT_EQ(view.GetRootNode().tagName, "View");
EXPECT_EQ(view.GetSourcePath(), "Assets/UI/Test.xcui");
UIDocumentModel schemaDocument = {};
schemaDocument.kind = UIDocumentKind::Schema;
schemaDocument.sourcePath = "Assets/UI/Test.xcschema";
schemaDocument.displayName = "TestSchema";
schemaDocument.rootNode.tagName = "Schema";
schemaDocument.schemaDefinition.name = "TestSchema";
schemaDocument.schemaDefinition.valid = true;
schemaDocument.valid = true;
UISchema schema = {};
params.name = "TestSchema";
params.path = schemaDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
schema.Initialize(params);
schema.SetDocumentModel(std::move(schemaDocument));
EXPECT_TRUE(schema.GetSchemaDefinition().valid);
EXPECT_EQ(schema.GetSchemaDefinition().name, "TestSchema");
}
} // namespace } // namespace

View File

@@ -4,323 +4,140 @@
#include <XCEngine/UI/Runtime/UIScreenPlayer.h> #include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <XCEngine/UI/Runtime/UISystem.h> #include <XCEngine/UI/Runtime/UISystem.h>
#include <chrono>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <string>
namespace { 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::UIScreenAsset;
using XCEngine::UI::Runtime::UIScreenDocument;
using XCEngine::UI::Runtime::UIScreenFrameInput; 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::UIScreenPlayer;
using XCEngine::UI::Runtime::UIDocumentScreenHost; using XCEngine::UI::Runtime::UIDocumentScreenHost;
using XCEngine::UI::Runtime::UISystemFrameResult;
using XCEngine::UI::Runtime::UISystem; using XCEngine::UI::Runtime::UISystem;
class FakeScreenDocumentHost final : public IUIScreenDocumentHost { namespace fs = std::filesystem;
class TempFileScope {
public: public:
struct BuildCall { TempFileScope(std::string stem, std::string extension, std::string contents) {
std::string displayName = {}; const auto uniqueId = std::to_string(
std::size_t inputEventCount = 0; std::chrono::steady_clock::now().time_since_epoch().count());
std::uint64_t frameIndex = 0; m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
}; std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
output << contents;
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( ~TempFileScope() {
const UIScreenDocument& document, std::error_code ec;
const UIScreenFrameInput& input) override { fs::remove(m_path, ec);
++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; const fs::path& Path() const {
std::size_t buildCount = 0; return m_path;
UIScreenAsset lastLoadedAsset = {}; }
UIScreenDocument lastBuiltDocument = {};
UIScreenFrameInput lastFrameInput = {}; private:
std::vector<BuildCall> buildCalls = {}; fs::path m_path = {};
}; };
UIScreenAsset MakeAsset() { std::string BuildViewMarkup(const char* heroTitle, const char* overlayText = nullptr) {
UIScreenAsset asset = {}; std::string markup =
asset.screenId = "MainMenu"; "<View name=\"Runtime Screen\">\n"
asset.documentPath = "Assets/UI/MainMenu.xcui"; " <Column id=\"root\" padding=\"18\" gap=\"10\">\n"
asset.themePath = "Assets/UI/MainMenu.xctheme"; " <Card id=\"hero\" title=\"" + std::string(heroTitle) + "\" subtitle=\"Shared XCUI runtime layer\" />\n"
return asset; " <Text id=\"status\" text=\"Ready for play\" />\n"
" <Row id=\"actions\" gap=\"12\">\n"
" <Button id=\"start\" text=\"Start\" />\n"
" <Button id=\"options\" text=\"Options\" />\n"
" </Row>\n";
if (overlayText != nullptr) {
markup += " <Card id=\"overlay\" title=\"" + std::string(overlayText) + "\" tone=\"accent\" />\n";
}
markup +=
" </Column>\n"
"</View>\n";
return markup;
} }
UIScreenFrameInput MakeFrameInput(std::uint64_t frameIndex = 7) { UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
UIScreenAsset screen = {};
screen.screenId = screenId;
screen.documentPath = viewPath.string();
return screen;
}
bool DrawDataContainsText(
const XCEngine::UI::UIDrawData& drawData,
const std::string& text) {
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
if (command.type == XCEngine::UI::UIDrawCommandType::Text &&
command.text == text) {
return true;
}
}
}
return false;
}
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
UIScreenFrameInput input = {}; UIScreenFrameInput input = {};
input.viewportRect = UIRect(10.0f, 20.0f, 320.0f, 180.0f); input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 800.0f, 480.0f);
input.deltaTimeSeconds = 1.0 / 60.0;
input.frameIndex = frameIndex; input.frameIndex = frameIndex;
input.focused = true; input.focused = true;
UIInputEvent event = {};
event.type = UIInputEventType::PointerMove;
event.position = UIPoint(42.0f, 64.0f);
input.events.push_back(event);
return input; return input;
} }
void WriteTextFile(const fs::path& path, const char* contents) { } // namespace
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) { TEST(UIRuntimeTest, ScreenPlayerBuildsDrawDataFromDocumentTree) {
FakeScreenDocumentHost host = {}; TempFileScope viewFile("xcui_runtime_screen", ".xcui", BuildViewMarkup("Runtime HUD"));
UIDocumentScreenHost host = {};
UIScreenPlayer player(host); UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(MakeAsset())); ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.main_menu")));
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) { const auto& frame = player.Update(BuildInputState());
FakeScreenDocumentHost host = {}; EXPECT_TRUE(frame.stats.documentLoaded);
UIScreenPlayer player(host); EXPECT_EQ(frame.stats.nodeCount, 7u);
ASSERT_TRUE(player.Load(MakeAsset())); EXPECT_EQ(frame.stats.drawListCount, frame.drawData.GetDrawListCount());
EXPECT_EQ(frame.stats.commandCount, frame.drawData.GetTotalCommandCount());
const UIScreenFrameResult& result = player.Update(MakeFrameInput(12)); EXPECT_GE(frame.stats.textCommandCount, 5u);
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Runtime HUD"));
EXPECT_TRUE(result.errorMessage.empty()); EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Ready for play"));
EXPECT_TRUE(result.stats.documentLoaded); EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Start"));
EXPECT_EQ(result.stats.drawListCount, 1u); EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Options"));
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(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) { TEST(UIRuntimeTest, UISystemForwardsActiveScreenToPlayer) {
FakeScreenDocumentHost host = {}; TempFileScope baseView("xcui_runtime_base", ".xcui", BuildViewMarkup("Base Screen"));
UIScreenPlayer player(host); TempFileScope overlayView("xcui_runtime_overlay", ".xcui", BuildViewMarkup("Overlay Screen", "Modal Dialog"));
const UIScreenFrameResult& result = player.Update(MakeFrameInput()); UIDocumentScreenHost host = {};
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); UISystem system(host);
UIScreenPlayer& gameplay = system.CreatePlayer(); const auto baseLayer = system.PushScreen(
ASSERT_TRUE(gameplay.Load(MakeAsset())); BuildScreenAsset(baseView.Path(), "runtime.base"));
ASSERT_NE(baseLayer, 0u);
UIScreenAsset hudAsset = MakeAsset(); XCEngine::UI::Runtime::UIScreenLayerOptions overlayOptions = {};
hudAsset.screenId = "HUD"; overlayOptions.debugName = "overlay";
hudAsset.documentPath = "Assets/UI/Hud.xcui"; overlayOptions.blocksLayersBelow = true;
UIScreenLayerOptions hudOptions = {}; const auto overlayLayer = system.PushScreen(
hudOptions.debugName = "HUD"; BuildScreenAsset(overlayView.Path(), "runtime.overlay"),
hudOptions.acceptsInput = false; overlayOptions);
UIScreenPlayer& hud = system.CreatePlayer(hudOptions); ASSERT_NE(overlayLayer, 0u);
ASSERT_TRUE(hud.Load(hudAsset));
const UISystemFrameResult& frame = system.Update(MakeFrameInput(33)); const auto& frame = system.Update(BuildInputState(3u));
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.presentedLayerCount, 1u);
EXPECT_EQ(frame.skippedLayerCount, 1u); EXPECT_EQ(frame.skippedLayerCount, 1u);
ASSERT_EQ(frame.layers.size(), 1u); EXPECT_EQ(frame.layers.size(), 1u);
EXPECT_EQ(frame.layers[0].layerId, pauseLayer); EXPECT_EQ(frame.layers.front().layerId, overlayLayer);
EXPECT_EQ(frame.layers[0].asset.screenId, "PauseMenu"); EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Overlay Screen"));
EXPECT_TRUE(frame.layers[0].options.blocksLayersBelow); EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Modal Dialog"));
ASSERT_EQ(frame.drawData.GetDrawListCount(), 1u); EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Base Screen"));
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

View File

@@ -79,6 +79,12 @@ TEST(NewEditorXCUILayoutLabRuntimeTest, UpdateBuildsLayoutSmokeFrame) {
EXPECT_GE(frame.stats.columnCount, 1u); EXPECT_GE(frame.stats.columnCount, 1u);
EXPECT_GE(frame.stats.overlayCount, 1u); EXPECT_GE(frame.stats.overlayCount, 1u);
EXPECT_GE(frame.stats.scrollViewCount, 2u); EXPECT_GE(frame.stats.scrollViewCount, 2u);
EXPECT_GE(frame.stats.treeViewCount, 1u);
EXPECT_GE(frame.stats.treeItemCount, 3u);
EXPECT_GE(frame.stats.listViewCount, 1u);
EXPECT_GE(frame.stats.listItemCount, 3u);
EXPECT_GE(frame.stats.propertySectionCount, 2u);
EXPECT_GE(frame.stats.fieldRowCount, 4u);
XCEngine::UI::UIRect heroRect = {}; XCEngine::UI::UIRect heroRect = {};
EXPECT_TRUE(runtime.TryGetElementRect("heroCard", heroRect)); EXPECT_TRUE(runtime.TryGetElementRect("heroCard", heroRect));
@@ -143,6 +149,26 @@ TEST(NewEditorXCUILayoutLabRuntimeTest, HoverProbeResolvesTrackedElementRect) {
EXPECT_GT(hoveredRect.height, 0.0f); EXPECT_GT(hoveredRect.height, 0.0f);
} }
TEST(NewEditorXCUILayoutLabRuntimeTest, EditorPrototypeWidgetsExposeRectsAndLabels) {
XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime;
ASSERT_TRUE(runtime.ReloadDocuments());
const auto& frame = runtime.Update(BuildInputState());
ASSERT_TRUE(frame.stats.documentsReady);
XCEngine::UI::UIRect projectTreeRect = {};
XCEngine::UI::UIRect fieldPositionRect = {};
ASSERT_TRUE(runtime.TryGetElementRect("projectTree", projectTreeRect));
ASSERT_TRUE(runtime.TryGetElementRect("fieldPosition", fieldPositionRect));
EXPECT_GT(projectTreeRect.height, 0.0f);
EXPECT_GT(fieldPositionRect.width, 0.0f);
EXPECT_NE(FindTextCommand(frame.drawData, "Assets"), nullptr);
EXPECT_NE(FindTextCommand(frame.drawData, "Lighting_GlobalRig"), nullptr);
EXPECT_NE(FindTextCommand(frame.drawData, "Position"), nullptr);
EXPECT_NE(FindTextCommand(frame.drawData, "0.0, 1.5, 0.0"), nullptr);
}
TEST(NewEditorXCUILayoutLabRuntimeTest, ScrollViewOffsetsContentAndAddsNestedClips) { TEST(NewEditorXCUILayoutLabRuntimeTest, ScrollViewOffsetsContentAndAddsNestedClips) {
XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime; XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime;
ASSERT_TRUE(runtime.ReloadDocuments()); ASSERT_TRUE(runtime.ReloadDocuments());