Add XCUI expansion state and coverage tests

This commit is contained in:
2026-04-05 07:29:27 +08:00
parent 646e5855ce
commit 511e94fd30
18 changed files with 1213 additions and 53 deletions

View File

@@ -14,7 +14,7 @@
tone="accent-alt"
title="Tool Shelf"
subtitle="Scene, asset, and play-mode actions." />
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="132">
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="40">
<Card
id="assetListHeader"
height="54"
@@ -29,9 +29,9 @@
<TreeItem id="treeUi" title="UI" subtitle="Runtime atlas + themes" indent="1" />
</TreeView>
<ListView id="recentAssetList" height="496" padding="8" gap="6">
<ListItem id="assetLighting" title="Lighting_GlobalRig" subtitle="Prefab -Directional setup" />
<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" />
@@ -77,7 +77,7 @@
height="88"
title="Inspector Summary"
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="0">
<PropertySection id="inspectorTransform" height="156" title="Transform" subtitle="Position / rotation / scale">
<FieldRow id="fieldPosition" title="Position" subtitle="0.0, 1.5, 0.0" />
<FieldRow id="fieldRotation" title="Rotation" subtitle="0.0, 42.0, 0.0" />

View File

@@ -33,13 +33,14 @@ Old `editor` replacement is explicitly out of scope for this phase.
- Shared text-input controller/state now also lives under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so character insertion, backspace/delete, submit, and multiline key handling no longer need to be reimplemented per host.
- Shared editor collection primitive classification and metric helpers now also live under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets`, covering the current `ScrollView` / `TreeView` / `ListView` / `PropertySection` / `FieldRow` prototype taxonomy.
- Shared single-selection state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UISelectionModel`, so collection-style widget selection no longer has to stay private to `LayoutLab`.
- Shared expansion state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UIExpansionModel`, so collapsible tree/property-style widget state no longer has to stay private to `LayoutLab`.
- Core regression coverage now includes `UIContext`, layout, style, runtime screen player/system, and real document-host tests through `core_ui_tests`.
Current gap:
- Minimal schema self-definition support is landed, including consistency checks for enum/document-only schema metadata, but schema-driven validation for `.xcui` / `.xctheme` instances is still not implemented.
- Shared widget/runtime instantiation is still thin and mostly editor-side.
- Common widget primitives are still incomplete: shared text-input presentation/composition on top of the new text controller, real tree/list/property widget state on top of the new editor-primitive helpers, and native image/source-rect level APIs.
- Common widget primitives are still incomplete: shared text-input presentation/composition on top of the new text controller, multi-selection/keyboard-navigation/virtualized collection state on top of the new editor-primitive helpers, and native image/source-rect level APIs.
### 2. Runtime/Game Layer
@@ -68,6 +69,7 @@ Current gap:
- `LayoutLab` now also covers editor-facing widget prototypes: `TreeView`, `TreeItem`, `ListView`, `ListItem`, `PropertySection`, and `FieldRow`.
- `LayoutLab` now consumes the shared `UIEditorCollectionPrimitives` helper layer for collection-widget tag classification, clipping flags, and default metric resolution instead of keeping that taxonomy private to the sandbox runtime.
- `LayoutLab` now also consumes the shared `UISelectionModel` for click-selection persistence across collection-style widgets, and the diagnostics panel now exposes both hovered and selected element ids.
- `LayoutLab` now also consumes the shared `UIExpansionModel` for tree expansion and property-section collapse, with reserved property headers, disclosure glyphs, and persisted click-toggle behavior in the sandbox runtime.
- Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths.
- The editor bridge layer now has smoke coverage for swapchain after-UI rendering hooks and SRV-backed ImGui texture descriptor registration.
- `Application` no longer owns the ImGui backend directly; window presentation now routes through `IWindowUICompositor` with an `ImGuiWindowUICompositor` implementation, which currently delegates to `IEditorHostCompositor` / `ImGuiHostCompositor`.
@@ -80,18 +82,19 @@ Current gap:
- The shell is still ImGui-hosted.
- Legacy hosted preview still depends on an active ImGui window context for inline presentation.
- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, multi-selection/tree expansion state, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules.
- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, multi-selection/keyboard-navigation state, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules.
## Validated This Phase
- `new_editor_xcui_demo_runtime_tests`: `7/7`
- `new_editor_xcui_layout_lab_runtime_tests`: `7/7`
- `new_editor_xcui_layout_lab_runtime_tests`: `9/9`
- `new_editor_xcui_rhi_command_compiler_tests`: `6/6`
- `new_editor_xcui_rhi_render_backend_tests`: `5/5`
- `new_editor_xcui_hosted_preview_presenter_tests`: `12/12`
- `new_editor_imgui_window_ui_compositor_tests`: `5/5`
- `XCNewEditor` Debug target builds successfully
- `core_ui_tests`: `30/30`
- `scene_tests`: `65/65`
- `core_ui_tests`: `38 total` (`36` passed, `2` skipped because `KeyCode::Delete` currently aliases `Backspace`)
- `scene_tests`: `68/68`
- `core_ui_style_tests`: `5/5`
- `ui_resource_tests`: `11/11`
- `editor_tests` targeted bridge smoke: `3/3`
@@ -104,6 +107,7 @@ Current gap:
- Common-core `UITextInputController` extraction now owns per-field text state, character insertion, enter-submit, and multiline keyboard editing behavior with dedicated `core_ui_tests` coverage.
- Common-core `UIEditorCollectionPrimitives` extraction now owns the editor collection tag taxonomy and default metric resolution used by current `LayoutLab` widget prototypes, with dedicated `core_ui_tests` coverage.
- Common-core `UISelectionModel` extraction now owns reusable single-selection state for collection-style widgets, with dedicated `core_ui_tests` coverage.
- Common-core `UIExpansionModel` extraction now owns reusable expansion/collapse state for tree/property-style widgets, with dedicated `core_ui_tests` coverage.
- Demo runtime text editing was extended with:
- click-to-place caret
- `Delete` support
@@ -113,6 +117,7 @@ Current gap:
- 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.
- LayoutLab click-selection now persists through the shared `UISelectionModel`, including selected-state diagnostics and reusable visual selection feedback on cards, collection rows, and field rows.
- LayoutLab tree expansion and property-section collapse now persist through the shared `UIExpansionModel`, including reserved property headers, disclosure glyphs, and runtime coverage for collapsed/expanded visibility.
- Schema document support extended with:
- retained `UISchemaDefinition` data on `UIDocumentModel`
- artifact schema version bump for UI documents
@@ -156,6 +161,12 @@ Current gap:
- `IEditorHostCompositor`
- `ImGuiHostCompositor`
- `Application` frame/present flow routed through the compositor instead of direct `m_imguiBackend` ownership
- The window-level XCUI compositor seam now also has a dedicated regression target around `ImGuiWindowUICompositor`, covering initialization, render-frame ordering, Win32 message forwarding, texture registration forwarding, and shutdown safety.
- `SceneRuntime` layered XCUI routing now has dedicated regression coverage for:
- top-interactive layer input ownership
- blocking/modal layer suppression of lower layers
- hidden top-layer pass-through back to visible underlying layers
- Shared `UITextInputController` coverage now includes more caret-boundary / modifier branches; the remaining `Delete` distinction stays blocked on `KeyCode::Delete` and `KeyCode::Backspace` still sharing the same enum value.
- Window compositor texture registration now also flows back into `Application` as XCUI-owned `UITextureRegistration` / `UITextureHandle` data instead of exposing raw `ImTextureID` there.
- Hosted preview contracts were tightened again:
- generic preview surface metadata stays on XCUI-owned value types
@@ -174,7 +185,7 @@ Current gap:
- `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.
- 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.
- Editor widget coverage is still prototype-driven inside `LayoutLab`; it has not yet been promoted into a full reusable shared widget/runtime layer with command routing, virtualization, and property-edit transactions.
## Next Phase

View File

@@ -521,8 +521,10 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIExpansionModel.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UISelectionModel.h
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIEditorCollectionPrimitives.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIExpansionModel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UISelectionModel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h

View File

@@ -0,0 +1,30 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <unordered_set>
namespace XCEngine {
namespace UI {
namespace Widgets {
class UIExpansionModel {
public:
bool HasExpandedItems() const;
std::size_t GetExpandedCount() const;
bool IsExpanded(std::string_view id) const;
bool SetExpanded(std::string itemId, bool expanded);
bool Expand(std::string itemId);
bool Collapse(std::string itemId);
bool ToggleExpanded(std::string itemId);
bool Clear();
private:
std::unordered_set<std::string> m_expandedIds = {};
};
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -64,6 +64,7 @@ bool UsesUIEditorCollectionPrimitiveColumnLayout(UIEditorCollectionPrimitiveKind
bool IsUIEditorCollectionPrimitiveHoverable(UIEditorCollectionPrimitiveKind kind) {
return kind == UIEditorCollectionPrimitiveKind::TreeItem ||
kind == UIEditorCollectionPrimitiveKind::ListItem ||
kind == UIEditorCollectionPrimitiveKind::PropertySection ||
kind == UIEditorCollectionPrimitiveKind::FieldRow;
}

View File

@@ -0,0 +1,57 @@
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <utility>
namespace XCEngine {
namespace UI {
namespace Widgets {
bool UIExpansionModel::HasExpandedItems() const {
return !m_expandedIds.empty();
}
std::size_t UIExpansionModel::GetExpandedCount() const {
return m_expandedIds.size();
}
bool UIExpansionModel::IsExpanded(std::string_view id) const {
return m_expandedIds.contains(std::string(id));
}
bool UIExpansionModel::SetExpanded(std::string itemId, bool expanded) {
return expanded
? Expand(std::move(itemId))
: Collapse(std::move(itemId));
}
bool UIExpansionModel::Expand(std::string itemId) {
return m_expandedIds.insert(std::move(itemId)).second;
}
bool UIExpansionModel::Collapse(std::string itemId) {
return m_expandedIds.erase(itemId) > 0u;
}
bool UIExpansionModel::ToggleExpanded(std::string itemId) {
const auto it = m_expandedIds.find(itemId);
if (it != m_expandedIds.end()) {
m_expandedIds.erase(it);
return true;
}
m_expandedIds.insert(std::move(itemId));
return true;
}
bool UIExpansionModel::Clear() {
if (m_expandedIds.empty()) {
return false;
}
m_expandedIds.clear();
return true;
}
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -14,7 +14,7 @@
tone="accent-alt"
title="Tool Shelf"
subtitle="Scene, asset, and play-mode actions." />
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="132">
<ScrollView id="assetList" height="stretch" padding="10" gap="8" scrollY="40">
<Card
id="assetListHeader"
height="54"
@@ -29,9 +29,9 @@
<TreeItem id="treeUi" title="UI" subtitle="Runtime atlas + themes" indent="1" />
</TreeView>
<ListView id="recentAssetList" height="360" padding="8" gap="6">
<ListItem id="assetLighting" title="Lighting_GlobalRig" subtitle="Prefab -Directional setup" />
<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>
@@ -74,7 +74,7 @@
height="88"
title="Inspector Summary"
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="0">
<PropertySection id="inspectorTransform" height="156" title="Transform" subtitle="Position / rotation / scale">
<FieldRow id="fieldPosition" title="Position" subtitle="0.0, 1.5, 0.0" />
<FieldRow id="fieldRotation" title="Rotation" subtitle="0.0, 42.0, 0.0" />

View File

@@ -9,6 +9,7 @@
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Types.h>
#include <XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <algorithm>
@@ -78,6 +79,7 @@ struct RuntimeBuildContext {
std::vector<LayoutNode> nodes = {};
std::unordered_map<std::string, std::size_t> nodeIndexById = {};
std::unordered_map<std::string, UIRect> rectsById = {};
UIWidgets::UIExpansionModel expansionModel = {};
UIWidgets::UISelectionModel selectionModel = {};
bool documentsReady = false;
std::string statusMessage = {};
@@ -491,16 +493,20 @@ float ResolveListItemHeight(const Style::UITheme& theme) {
theme);
}
float ResolveTreeIndent(const LayoutNode& node, const Style::UITheme& theme) {
float ResolveTreeIndentLevel(const LayoutNode& node) {
float indentLevel = 0.0f;
if (!node.indentAttr.empty()) {
TryParseFloat(node.indentAttr, indentLevel);
}
return (std::max)(0.0f, indentLevel);
}
float ResolveTreeIndent(const LayoutNode& node, const Style::UITheme& theme) {
return UIWidgets::ResolveUIEditorCollectionPrimitiveIndent(
UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem,
theme,
(std::max)(0.0f, indentLevel));
ResolveTreeIndentLevel(node));
}
float ResolveFieldRowHeight(const Style::UITheme& theme) {
@@ -519,23 +525,195 @@ float ResolveDefaultHeight(const LayoutNode& node, const Style::UITheme& theme)
theme);
}
float ResolvePropertySectionHeaderHeight(
const RuntimeBuildContext& state,
const LayoutNode& node) {
const float titleFont = ResolveFloatToken(state.theme, "font.title", 16.0f);
const float bodyFont = ResolveFloatToken(state.theme, "font.body", 13.0f);
return titleFont + 8.0f + (node.subtitle.empty() ? 0.0f : bodyFont + 8.0f);
}
bool HasTreeItemChildren(const RuntimeBuildContext& state, std::size_t nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex];
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName) !=
UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem ||
node.parentIndex == kInvalidIndex) {
return false;
}
const LayoutNode& parent = state.nodes[node.parentIndex];
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(parent.tagName) !=
UIWidgets::UIEditorCollectionPrimitiveKind::TreeView) {
return false;
}
const float indentLevel = ResolveTreeIndentLevel(node);
const auto siblingIt = std::find(parent.children.begin(), parent.children.end(), nodeIndex);
if (siblingIt == parent.children.end()) {
return false;
}
for (auto it = siblingIt + 1; it != parent.children.end(); ++it) {
const LayoutNode& sibling = state.nodes[*it];
const float siblingIndentLevel = ResolveTreeIndentLevel(sibling);
if (siblingIndentLevel <= indentLevel) {
break;
}
return true;
}
return false;
}
bool IsNodeCollapsible(const RuntimeBuildContext& state, std::size_t nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex];
const UIWidgets::UIEditorCollectionPrimitiveKind primitiveKind =
UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName);
if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection) {
return !node.children.empty();
}
return HasTreeItemChildren(state, nodeIndex);
}
bool IsNodeExpanded(const RuntimeBuildContext& state, std::size_t nodeIndex) {
return !IsNodeCollapsible(state, nodeIndex) ||
state.expansionModel.IsExpanded(state.nodes[nodeIndex].id);
}
float ResolvePropertySectionCollapsedHeight(
const RuntimeBuildContext& state,
const LayoutNode& node) {
const float inset = ResolveFloatToken(state.theme, "space.cardInset", 12.0f);
return inset * 2.0f + ResolvePropertySectionHeaderHeight(state, node);
}
bool IsNodeVisible(const RuntimeBuildContext& state, std::size_t nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex];
const UIWidgets::UIEditorCollectionPrimitiveKind primitiveKind =
UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName);
std::size_t ancestorIndex = node.parentIndex;
while (ancestorIndex != kInvalidIndex) {
const LayoutNode& ancestor = state.nodes[ancestorIndex];
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(ancestor.tagName) ==
UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection &&
!IsNodeExpanded(state, ancestorIndex)) {
return false;
}
ancestorIndex = ancestor.parentIndex;
}
if (primitiveKind != UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem ||
node.parentIndex == kInvalidIndex) {
return true;
}
const LayoutNode& parent = state.nodes[node.parentIndex];
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(parent.tagName) !=
UIWidgets::UIEditorCollectionPrimitiveKind::TreeView) {
return true;
}
float requiredAncestorIndent = ResolveTreeIndentLevel(node);
if (requiredAncestorIndent <= 0.0f) {
return true;
}
const auto siblingIt = std::find(parent.children.begin(), parent.children.end(), nodeIndex);
if (siblingIt == parent.children.end()) {
return true;
}
const std::size_t siblingOffset =
static_cast<std::size_t>(siblingIt - parent.children.begin());
for (std::size_t offset = siblingOffset; offset > 0u && requiredAncestorIndent > 0.0f; --offset) {
const std::size_t siblingIndex = parent.children[offset - 1u];
const LayoutNode& sibling = state.nodes[siblingIndex];
const float siblingIndent = ResolveTreeIndentLevel(sibling);
if (siblingIndent < requiredAncestorIndent) {
if (!IsNodeExpanded(state, siblingIndex)) {
return false;
}
requiredAncestorIndent = siblingIndent;
}
}
return true;
}
std::vector<std::size_t> CollectVisibleChildren(
const RuntimeBuildContext& state,
std::size_t nodeIndex) {
std::vector<std::size_t> visibleChildren = {};
const LayoutNode& node = state.nodes[nodeIndex];
visibleChildren.reserve(node.children.size());
for (const std::size_t childIndex : node.children) {
if (IsNodeVisible(state, childIndex)) {
visibleChildren.push_back(childIndex);
}
}
return visibleChildren;
}
void SeedDefaultExpansionState(RuntimeBuildContext& state) {
state.expansionModel.Clear();
for (std::size_t nodeIndex = 0; nodeIndex < state.nodes.size(); ++nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex];
const UIWidgets::UIEditorCollectionPrimitiveKind primitiveKind =
UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName);
if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection ||
HasTreeItemChildren(state, nodeIndex)) {
state.expansionModel.Expand(node.id);
}
}
}
void LayoutNodeTree(RuntimeBuildContext& state, std::size_t nodeIndex);
void LayoutColumnChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
LayoutNode& node = state.nodes[nodeIndex];
const float padding = ResolvePadding(node, state.theme);
const float gap = ResolveGap(node, state.theme);
const UIRect contentRect = InsetRect(node.rect, padding);
if (node.children.empty()) {
UIRect contentRect = InsetRect(node.rect, padding);
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName) ==
UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection) {
const float headerHeight = ResolvePropertySectionHeaderHeight(state, node);
contentRect.y += headerHeight;
contentRect.height = (std::max)(0.0f, contentRect.height - headerHeight);
}
const std::vector<std::size_t> visibleChildren = CollectVisibleChildren(state, nodeIndex);
if (visibleChildren.empty()) {
return;
}
float fixedHeight = 0.0f;
std::size_t stretchCount = 0;
std::vector<float> resolvedHeights(node.children.size(), 0.0f);
for (std::size_t childOffset = 0; childOffset < node.children.size(); ++childOffset) {
const LayoutNode& child = state.nodes[node.children[childOffset]];
std::vector<float> resolvedHeights(visibleChildren.size(), 0.0f);
std::vector<bool> childUsesStretchHeight(visibleChildren.size(), false);
for (std::size_t childOffset = 0; childOffset < visibleChildren.size(); ++childOffset) {
const LayoutNode& child = state.nodes[visibleChildren[childOffset]];
const float defaultHeight = ResolveDefaultHeight(child, state.theme);
if (UIWidgets::ClassifyUIEditorCollectionPrimitive(child.tagName) ==
UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection &&
!IsNodeExpanded(state, visibleChildren[childOffset])) {
resolvedHeights[childOffset] = ResolvePropertySectionCollapsedHeight(state, child);
fixedHeight += resolvedHeights[childOffset];
continue;
}
if (child.heightAttr.empty() && defaultHeight > 0.0f) {
resolvedHeights[childOffset] = defaultHeight;
fixedHeight += resolvedHeights[childOffset];
continue;
}
if (IsStretch(child.heightAttr)) {
childUsesStretchHeight[childOffset] = true;
++stretchCount;
continue;
}
@@ -543,27 +721,28 @@ void LayoutColumnChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
resolvedHeights[childOffset] = ResolveScalar(
child.heightAttr,
contentRect.height,
ResolveDefaultHeight(child, state.theme));
defaultHeight);
fixedHeight += resolvedHeights[childOffset];
}
const float totalGap = gap * static_cast<float>((std::max<std::size_t>)(1u, node.children.size()) - 1u);
const float totalGap =
gap * static_cast<float>((std::max<std::size_t>)(1u, visibleChildren.size()) - 1u);
const float remainingHeight = (std::max)(0.0f, contentRect.height - fixedHeight - totalGap);
const float stretchHeight =
stretchCount > 0u ? remainingHeight / static_cast<float>(stretchCount) : 0.0f;
float cursorY = contentRect.y;
for (std::size_t childOffset = 0; childOffset < node.children.size(); ++childOffset) {
LayoutNode& child = state.nodes[node.children[childOffset]];
for (std::size_t childOffset = 0; childOffset < visibleChildren.size(); ++childOffset) {
LayoutNode& child = state.nodes[visibleChildren[childOffset]];
const float childHeight =
!IsStretch(child.heightAttr) ? resolvedHeights[childOffset] : stretchHeight;
childUsesStretchHeight[childOffset] ? stretchHeight : resolvedHeights[childOffset];
const float childWidth =
!IsStretch(child.widthAttr)
? ResolveScalar(child.widthAttr, contentRect.width, contentRect.width)
: contentRect.width;
child.rect = UIRect(contentRect.x, cursorY, childWidth, childHeight);
cursorY += childHeight + gap;
LayoutNodeTree(state, node.children[childOffset]);
LayoutNodeTree(state, visibleChildren[childOffset]);
}
}
@@ -572,15 +751,16 @@ void LayoutRowChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
const float padding = ResolvePadding(node, state.theme);
const float gap = ResolveGap(node, state.theme);
const UIRect contentRect = InsetRect(node.rect, padding);
if (node.children.empty()) {
const std::vector<std::size_t> visibleChildren = CollectVisibleChildren(state, nodeIndex);
if (visibleChildren.empty()) {
return;
}
float fixedWidth = 0.0f;
std::size_t stretchCount = 0;
std::vector<float> resolvedWidths(node.children.size(), 0.0f);
for (std::size_t childOffset = 0; childOffset < node.children.size(); ++childOffset) {
const LayoutNode& child = state.nodes[node.children[childOffset]];
std::vector<float> resolvedWidths(visibleChildren.size(), 0.0f);
for (std::size_t childOffset = 0; childOffset < visibleChildren.size(); ++childOffset) {
const LayoutNode& child = state.nodes[visibleChildren[childOffset]];
if (IsStretch(child.widthAttr)) {
++stretchCount;
continue;
@@ -590,14 +770,15 @@ void LayoutRowChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
fixedWidth += resolvedWidths[childOffset];
}
const float totalGap = gap * static_cast<float>((std::max<std::size_t>)(1u, node.children.size()) - 1u);
const float totalGap =
gap * static_cast<float>((std::max<std::size_t>)(1u, visibleChildren.size()) - 1u);
const float remainingWidth = (std::max)(0.0f, contentRect.width - fixedWidth - totalGap);
const float stretchWidth =
stretchCount > 0u ? remainingWidth / static_cast<float>(stretchCount) : 0.0f;
float cursorX = contentRect.x;
for (std::size_t childOffset = 0; childOffset < node.children.size(); ++childOffset) {
LayoutNode& child = state.nodes[node.children[childOffset]];
for (std::size_t childOffset = 0; childOffset < visibleChildren.size(); ++childOffset) {
LayoutNode& child = state.nodes[visibleChildren[childOffset]];
const float childWidth =
!IsStretch(child.widthAttr) ? resolvedWidths[childOffset] : stretchWidth;
const float childHeight =
@@ -606,7 +787,7 @@ void LayoutRowChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
: contentRect.height;
child.rect = UIRect(cursorX, contentRect.y, childWidth, childHeight);
cursorX += childWidth + gap;
LayoutNodeTree(state, node.children[childOffset]);
LayoutNodeTree(state, visibleChildren[childOffset]);
}
}
@@ -614,7 +795,7 @@ void LayoutOverlayChildren(RuntimeBuildContext& state, std::size_t nodeIndex) {
LayoutNode& node = state.nodes[nodeIndex];
const UIRect contentRect = GetContentRect(node, state.theme);
for (std::size_t childIndex : node.children) {
for (const std::size_t childIndex : CollectVisibleChildren(state, nodeIndex)) {
LayoutNode& child = state.nodes[childIndex];
const float offsetX = ResolveScalar(child.xAttr, contentRect.width, 0.0f);
const float offsetY = ResolveScalar(child.yAttr, contentRect.height, 0.0f);
@@ -644,17 +825,22 @@ void LayoutScrollViewChildren(RuntimeBuildContext& state, std::size_t nodeIndex)
const UIRect contentRect = GetContentRect(node, state.theme);
float cursorY = contentRect.y - scrollOffset;
for (std::size_t childIndex : node.children) {
for (const std::size_t childIndex : CollectVisibleChildren(state, nodeIndex)) {
LayoutNode& child = state.nodes[childIndex];
const float defaultHeight = ResolveDefaultHeight(child, state.theme);
const float childHeight = child.heightAttr.empty()
? (defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: (!IsStretch(child.heightAttr)
? ResolveScalar(
child.heightAttr,
contentRect.height,
defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: defaultItemHeight);
const float childHeight =
UIWidgets::ClassifyUIEditorCollectionPrimitive(child.tagName) ==
UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection &&
!IsNodeExpanded(state, childIndex)
? ResolvePropertySectionCollapsedHeight(state, child)
: (child.heightAttr.empty()
? (defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: (!IsStretch(child.heightAttr)
? ResolveScalar(
child.heightAttr,
contentRect.height,
defaultHeight > 0.0f ? defaultHeight : defaultItemHeight)
: defaultItemHeight));
const float childWidth =
!IsStretch(child.widthAttr)
? (std::min)(
@@ -686,6 +872,23 @@ void LayoutNodeTree(RuntimeBuildContext& state, std::size_t nodeIndex) {
}
}
void DrawDisclosureGlyph(
UIDrawList& drawList,
const UIRect& rect,
const UIColor& color,
bool expanded) {
drawList.AddFilledRect(
UIRect(rect.x, rect.y + rect.height * 0.5f - 1.0f, rect.width, 2.0f),
color,
1.0f);
if (!expanded) {
drawList.AddFilledRect(
UIRect(rect.x + rect.width * 0.5f - 1.0f, rect.y, 2.0f, rect.height),
color,
1.0f);
}
}
void DrawNode(
RuntimeBuildContext& state,
std::size_t nodeIndex,
@@ -716,14 +919,15 @@ void DrawNode(
drawList.AddFilledRect(node.rect, ToUIColor(surfaceColor), rounding);
drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding);
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection) {
const bool selected = state.selectionModel.IsSelected(node.id);
const bool expanded = IsNodeExpanded(state, nodeIndex);
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 borderColor = selected
? 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 Color textColor = ResolveColorToken(
state.theme,
"color.text",
@@ -753,6 +957,17 @@ void DrawNode(
ToUIColor(mutedColor),
bodyFont);
}
if (IsNodeCollapsible(state, nodeIndex)) {
DrawDisclosureGlyph(
drawList,
UIRect(
node.rect.x + node.rect.width - inset - 10.0f,
node.rect.y + inset + 3.0f,
10.0f,
10.0f),
ToUIColor(textColor),
expanded);
}
} else if (node.tagName == "Card") {
const bool selected = state.selectionModel.IsSelected(node.id);
const Color cardColor = ResolveColorToken(
@@ -813,6 +1028,8 @@ void DrawNode(
primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::ListItem) {
const bool hovered = node.id == hoveredId;
const bool selected = state.selectionModel.IsSelected(node.id);
const bool hasTreeChildren = primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem &&
HasTreeItemChildren(state, nodeIndex);
const Color rowColor = selected
? ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f))
: (hovered
@@ -840,10 +1057,18 @@ void DrawNode(
drawList.AddFilledRect(node.rect, ToUIColor(rowColor), rounding);
drawList.AddRectOutline(node.rect, ToUIColor(borderColor), 1.0f, rounding);
if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem) {
drawList.AddFilledRect(
UIRect(node.rect.x + inset + indent - 8.0f, node.rect.y + 11.0f, 4.0f, 4.0f),
ToUIColor(textColor),
2.0f);
if (hasTreeChildren) {
DrawDisclosureGlyph(
drawList,
UIRect(node.rect.x + inset + indent - 10.0f, node.rect.y + 10.0f, 8.0f, 8.0f),
ToUIColor(textColor),
IsNodeExpanded(state, nodeIndex));
} else {
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),
@@ -891,7 +1116,10 @@ void DrawNode(
if (clipsChildren) {
drawList.PushClipRect(GetContentRect(node, state.theme));
}
for (std::size_t childIndex : node.children) {
for (const std::size_t childIndex : node.children) {
if (!IsNodeVisible(state, childIndex)) {
continue;
}
DrawNode(state, childIndex, drawList, hoveredId);
}
if (clipsChildren) {
@@ -928,6 +1156,7 @@ std::size_t HitTest(
UIWidgets::IsUIEditorCollectionPrimitiveHoverable(
UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName));
if (!hoverable ||
!IsNodeVisible(state, index) ||
!ContainsPoint(node.rect, point) ||
!IsPointInsideNodeClipping(state, index, point)) {
continue;
@@ -967,6 +1196,7 @@ bool XCUILayoutLabRuntime::ReloadDocuments() {
state.nodes.clear();
state.nodeIndexById.clear();
state.rectsById.clear();
state.expansionModel.Clear();
state.selectionModel.ClearSelection();
state.documentSource.SetPathSet(XCUIAssetDocumentSource::MakeLayoutLabPathSet());
@@ -1001,6 +1231,7 @@ bool XCUILayoutLabRuntime::ReloadDocuments() {
std::string(),
0u,
0);
SeedDefaultExpansionState(state);
state.documentsReady = !state.nodes.empty();
state.statusMessage = state.documentsReady
? (loadState.usedLegacyFallback
@@ -1050,6 +1281,9 @@ const XCUILayoutLabFrameResult& XCUILayoutLabRuntime::Update(const XCUILayoutLab
if (input.pointerPressed) {
if (hoveredIndex != kInvalidIndex) {
if (IsNodeCollapsible(state, hoveredIndex)) {
state.expansionModel.ToggleExpanded(state.nodes[hoveredIndex].id);
}
state.selectionModel.SetSelection(state.nodes[hoveredIndex].id);
} else {
state.selectionModel.ClearSelection();
@@ -1069,7 +1303,12 @@ const XCUILayoutLabFrameResult& XCUILayoutLabRuntime::Update(const XCUILayoutLab
state.frameResult.stats.drawListCount = state.frameResult.drawData.GetDrawListCount();
state.frameResult.stats.commandCount = state.frameResult.drawData.GetTotalCommandCount();
AnalyzeNativeOverlayCompatibility(state.frameResult.drawData, state.frameResult.stats);
for (const LayoutNode& node : state.nodes) {
for (std::size_t nodeIndex = 0; nodeIndex < state.nodes.size(); ++nodeIndex) {
const LayoutNode& node = state.nodes[nodeIndex];
if (!IsNodeVisible(state, nodeIndex)) {
continue;
}
const UIWidgets::UIEditorCollectionPrimitiveKind primitiveKind =
UIWidgets::ClassifyUIEditorCollectionPrimitive(node.tagName);
if (node.tagName == "Row") {
@@ -1084,12 +1323,18 @@ const XCUILayoutLabFrameResult& XCUILayoutLabRuntime::Update(const XCUILayoutLab
++state.frameResult.stats.treeViewCount;
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem) {
++state.frameResult.stats.treeItemCount;
if (HasTreeItemChildren(state, nodeIndex) && IsNodeExpanded(state, nodeIndex)) {
++state.frameResult.stats.expandedTreeItemCount;
}
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::ListView) {
++state.frameResult.stats.listViewCount;
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::ListItem) {
++state.frameResult.stats.listItemCount;
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::PropertySection) {
++state.frameResult.stats.propertySectionCount;
if (IsNodeExpanded(state, nodeIndex)) {
++state.frameResult.stats.expandedPropertySectionCount;
}
} else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::FieldRow) {
++state.frameResult.stats.fieldRowCount;
}

View File

@@ -47,6 +47,8 @@ struct XCUILayoutLabFrameStats {
std::size_t listItemCount = 0;
std::size_t propertySectionCount = 0;
std::size_t fieldRowCount = 0;
std::size_t expandedTreeItemCount = 0;
std::size_t expandedPropertySectionCount = 0;
std::string hoveredElementId = {};
std::string selectedElementId = {};
};

View File

@@ -317,6 +317,12 @@ void XCUILayoutLabPanel::Render() {
stats.columnCount,
stats.overlayCount,
stats.scrollViewCount);
ImGui::Text(
"Tree items: %zu (%zu expanded) | Property sections: %zu (%zu expanded)",
stats.treeItemCount,
stats.expandedTreeItemCount,
stats.propertySectionCount,
stats.expandedPropertySectionCount);
ImGui::Text(
"Draw lists: %zu | Draw commands: %zu",
stats.drawListCount,

View File

@@ -5,6 +5,7 @@
set(UI_TEST_SOURCES
test_ui_core.cpp
test_ui_editor_collection_primitives.cpp
test_ui_expansion_model.cpp
test_layout_engine.cpp
test_ui_selection_model.cpp
test_ui_runtime.cpp

View File

@@ -50,6 +50,7 @@ TEST(UIEditorCollectionPrimitivesTest, ClassifyAndFlagsMatchEditorCollectionTags
EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::TreeItem));
EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::ListItem));
EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::PropertySection));
EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::FieldRow));
EXPECT_FALSE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::TreeView));
}

View File

@@ -0,0 +1,45 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
namespace {
using XCEngine::UI::Widgets::UIExpansionModel;
TEST(UIExpansionModelTest, ExpandCollapseAndClearTrackExpandedItems) {
UIExpansionModel expansion = {};
EXPECT_FALSE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_TRUE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_TRUE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_FALSE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.Clear());
}
TEST(UIExpansionModelTest, SetAndToggleExpandedReplaceStateForMatchingItem) {
UIExpansionModel expansion = {};
EXPECT_TRUE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.IsExpanded("inspectorTransform"));
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorTransform"));
EXPECT_FALSE(expansion.IsExpanded("inspectorTransform"));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.IsExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.SetExpanded("inspectorMesh", false));
EXPECT_FALSE(expansion.IsExpanded("inspectorMesh"));
}
} // namespace

View File

@@ -41,6 +41,50 @@ TEST(UITextInputControllerTest, BackspaceAndArrowKeysUseUtf8Boundaries) {
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteUsesUtf8BoundariesAndLeavesCaretAtDeletePoint) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "B";
state.caret = 1u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_TRUE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteClampsOversizedCaretAndDoesNotMutateAtDocumentEnd) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = "AB";
state.caret = 99u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, SingleLineEnterRequestsSubmitWithoutMutatingValue) {
UIText::UITextInputState state = {};
state.value = "prompt";
@@ -82,6 +126,64 @@ TEST(UITextInputControllerTest, MultilineEnterAndVerticalMovementStayInControlle
EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n");
}
TEST(UITextInputControllerTest, HomeAndEndRespectSingleLineAndMultilineBounds) {
UIText::UITextInputState singleLine = {};
singleLine.value = "prompt";
singleLine.caret = 2u;
const auto singleHome = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::Home),
{},
{});
EXPECT_TRUE(singleHome.handled);
EXPECT_EQ(singleLine.caret, 0u);
const auto singleEnd = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::End),
{},
{});
EXPECT_TRUE(singleEnd.handled);
EXPECT_EQ(singleLine.caret, singleLine.value.size());
UIText::UITextInputState multiline = {};
multiline.value = "root\nleaf\nend";
multiline.caret = 7u;
const UIText::UITextInputOptions options = { true, 4u };
const auto multilineHome = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::Home),
{},
options);
EXPECT_TRUE(multilineHome.handled);
EXPECT_EQ(multiline.caret, 5u);
multiline.caret = 7u;
const auto multilineEnd = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::End),
{},
options);
EXPECT_TRUE(multilineEnd.handled);
EXPECT_EQ(multiline.caret, 9u);
}
TEST(UITextInputControllerTest, ClampCaretAndInsertCharacterRecoverFromOversizedCaret) {
UIText::UITextInputState state = {};
state.value = "go";
state.caret = 42u;
UIText::ClampCaret(state);
EXPECT_EQ(state.caret, state.value.size());
state.caret = 42u;
EXPECT_TRUE(UIText::InsertCharacter(state, '!'));
EXPECT_EQ(state.value, "go!");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, MultilineTabAndShiftTabIndentAndOutdentCurrentLine) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
@@ -112,4 +214,74 @@ TEST(UITextInputControllerTest, MultilineTabAndShiftTabIndentAndOutdentCurrentLi
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, ShiftTabWithoutLeadingSpacesIsHandledWithoutMutatingText) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = true;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
modifiers,
{ true, 4u });
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "root\nnode");
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, MultilineTabIgnoresSystemModifiers) {
const auto buildState = []() {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
return state;
};
const UIText::UITextInputOptions options = { true, 4u };
XCEngine::UI::UIInputModifiers control = {};
control.control = true;
auto controlState = buildState();
const auto controlResult = UIText::HandleKeyDown(
controlState,
static_cast<std::int32_t>(KeyCode::Tab),
control,
options);
EXPECT_FALSE(controlResult.handled);
EXPECT_FALSE(controlResult.valueChanged);
EXPECT_EQ(controlState.value, "root\nnode");
EXPECT_EQ(controlState.caret, 5u);
XCEngine::UI::UIInputModifiers alt = {};
alt.alt = true;
auto altState = buildState();
const auto altResult = UIText::HandleKeyDown(
altState,
static_cast<std::int32_t>(KeyCode::Tab),
alt,
options);
EXPECT_FALSE(altResult.handled);
EXPECT_FALSE(altResult.valueChanged);
EXPECT_EQ(altState.value, "root\nnode");
EXPECT_EQ(altState.caret, 5u);
XCEngine::UI::UIInputModifiers superModifier = {};
superModifier.super = true;
auto superState = buildState();
const auto superResult = UIText::HandleKeyDown(
superState,
static_cast<std::int32_t>(KeyCode::Tab),
superModifier,
options);
EXPECT_FALSE(superResult.handled);
EXPECT_FALSE(superResult.valueChanged);
EXPECT_EQ(superState.value, "root\nnode");
EXPECT_EQ(superState.caret, 5u);
}
} // namespace

View File

@@ -100,6 +100,12 @@ set(NEW_EDITOR_STANDALONE_TEXT_ATLAS_PROVIDER_SOURCE
set(NEW_EDITOR_HOSTED_PREVIEW_PRESENTER_HEADER
${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/XCUIHostedPreviewPresenter.h
)
set(NEW_EDITOR_WINDOW_UI_COMPOSITOR_HEADER
${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/IWindowUICompositor.h
)
set(NEW_EDITOR_IMGUI_WINDOW_UI_COMPOSITOR_HEADER
${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/ImGuiWindowUICompositor.h
)
set(NEW_EDITOR_NATIVE_BACKDROP_RENDERER_HEADER
${CMAKE_SOURCE_DIR}/new_editor/src/Rendering/MainWindowNativeBackdropRenderer.h
)
@@ -265,6 +271,37 @@ else()
message(STATUS "Skipping new_editor_xcui_hosted_preview_presenter_tests because presenter header or ImGui sources are missing.")
endif()
if(EXISTS "${NEW_EDITOR_WINDOW_UI_COMPOSITOR_HEADER}" AND
EXISTS "${NEW_EDITOR_IMGUI_WINDOW_UI_COMPOSITOR_HEADER}" AND
EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_imgui_window_ui_compositor.cpp")
add_executable(new_editor_imgui_window_ui_compositor_tests
test_imgui_window_ui_compositor.cpp
)
xcengine_configure_new_editor_test_target(new_editor_imgui_window_ui_compositor_tests)
target_link_libraries(new_editor_imgui_window_ui_compositor_tests
PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
user32
comdlg32
)
target_include_directories(new_editor_imgui_window_ui_compositor_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/new_editor/src
${CMAKE_SOURCE_DIR}/editor/src
${CMAKE_BINARY_DIR}/_deps/imgui-src
${CMAKE_BINARY_DIR}/_deps/imgui-src/backends
)
xcengine_discover_new_editor_gtests(new_editor_imgui_window_ui_compositor_tests)
else()
message(STATUS "Skipping new_editor_imgui_window_ui_compositor_tests because compositor headers or the test source are missing.")
endif()
if(EXISTS "${NEW_EDITOR_NATIVE_BACKDROP_RENDERER_HEADER}")
add_executable(new_editor_native_backdrop_renderer_api_tests
test_main_window_native_backdrop_renderer_api.cpp

View File

@@ -0,0 +1,283 @@
#include <gtest/gtest.h>
#include "XCUIBackend/IEditorHostCompositor.h"
#include "XCUIBackend/ImGuiWindowUICompositor.h"
#include "XCUIBackend/UITextureRegistration.h"
#include <array>
#include <cstdint>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace {
using XCEngine::Editor::Platform::D3D12WindowRenderer;
using XCEngine::Editor::XCUIBackend::IEditorHostCompositor;
using XCEngine::Editor::XCUIBackend::ImGuiWindowUICompositor;
using XCEngine::Editor::XCUIBackend::UITextureRegistration;
class RecordingHostCompositor final : public IEditorHostCompositor {
public:
bool initializeResult = true;
bool handleWindowMessageResult = false;
bool createTextureDescriptorResult = false;
bool invokeConfigureFonts = false;
int initializeCount = 0;
int shutdownCount = 0;
int beginFrameCount = 0;
int endFrameCount = 0;
int handleWindowMessageCount = 0;
int createTextureDescriptorCount = 0;
int freeTextureDescriptorCount = 0;
HWND lastHwnd = nullptr;
UINT lastMessage = 0u;
WPARAM lastWParam = 0u;
LPARAM lastLParam = 0u;
D3D12WindowRenderer* initializedRenderer = nullptr;
D3D12WindowRenderer* presentedRenderer = nullptr;
::XCEngine::RHI::RHIDevice* lastDevice = nullptr;
::XCEngine::RHI::RHITexture* lastTexture = nullptr;
bool beforeUiRenderProvided = false;
bool afterUiRenderProvided = false;
std::array<float, 4> lastClearColor = { 0.0f, 0.0f, 0.0f, 0.0f };
UITextureRegistration nextRegistration = {};
UITextureRegistration freedRegistration = {};
std::vector<std::string> callOrder = {};
bool Initialize(
HWND hwnd,
D3D12WindowRenderer& windowRenderer,
const ConfigureFontsCallback& configureFonts) override {
++initializeCount;
lastHwnd = hwnd;
initializedRenderer = &windowRenderer;
callOrder.push_back("initialize");
if (invokeConfigureFonts && configureFonts) {
configureFonts();
}
return initializeResult;
}
void Shutdown() override {
++shutdownCount;
callOrder.push_back("shutdown");
}
bool HandleWindowMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) override {
++handleWindowMessageCount;
lastHwnd = hwnd;
lastMessage = message;
lastWParam = wParam;
lastLParam = lParam;
callOrder.push_back("message");
return handleWindowMessageResult;
}
void BeginFrame() override {
++beginFrameCount;
callOrder.push_back("begin");
}
void EndFrameAndPresent(
D3D12WindowRenderer& windowRenderer,
const float clearColor[4],
const RenderCallback& beforeUiRender,
const RenderCallback& afterUiRender) override {
++endFrameCount;
presentedRenderer = &windowRenderer;
beforeUiRenderProvided = static_cast<bool>(beforeUiRender);
afterUiRenderProvided = static_cast<bool>(afterUiRender);
for (std::size_t index = 0; index < lastClearColor.size(); ++index) {
lastClearColor[index] = clearColor[index];
}
callOrder.push_back("present");
}
bool CreateTextureDescriptor(
::XCEngine::RHI::RHIDevice* device,
::XCEngine::RHI::RHITexture* texture,
UITextureRegistration& outRegistration) override {
++createTextureDescriptorCount;
lastDevice = device;
lastTexture = texture;
if (createTextureDescriptorResult) {
outRegistration = nextRegistration;
}
return createTextureDescriptorResult;
}
void FreeTextureDescriptor(const UITextureRegistration& registration) override {
++freeTextureDescriptorCount;
freedRegistration = registration;
}
};
HWND MakeFakeHwnd() {
return reinterpret_cast<HWND>(static_cast<std::uintptr_t>(0x1234u));
}
UITextureRegistration MakeRegistration() {
UITextureRegistration registration = {};
registration.cpuHandle.ptr = 11u;
registration.gpuHandle.ptr = 29u;
registration.texture.nativeHandle = 43u;
registration.texture.width = 256u;
registration.texture.height = 128u;
registration.texture.kind = ::XCEngine::UI::UITextureHandleKind::ShaderResourceView;
return registration;
}
TEST(ImGuiWindowUICompositorTest, InitializeForwardsToHostAndConfigureFontsCallback) {
auto host = std::make_unique<RecordingHostCompositor>();
RecordingHostCompositor* hostPtr = host.get();
hostPtr->invokeConfigureFonts = true;
ImGuiWindowUICompositor compositor(std::move(host));
D3D12WindowRenderer renderer = {};
bool configureFontsCalled = false;
EXPECT_TRUE(compositor.Initialize(
MakeFakeHwnd(),
renderer,
[&configureFontsCalled]() { configureFontsCalled = true; }));
EXPECT_TRUE(configureFontsCalled);
ASSERT_NE(hostPtr, nullptr);
EXPECT_EQ(hostPtr->initializeCount, 1);
EXPECT_EQ(hostPtr->lastHwnd, MakeFakeHwnd());
EXPECT_EQ(hostPtr->initializedRenderer, &renderer);
}
TEST(ImGuiWindowUICompositorTest, RenderFrameCallsHostBeginUiAndPresentInOrder) {
auto host = std::make_unique<RecordingHostCompositor>();
RecordingHostCompositor* hostPtr = host.get();
ImGuiWindowUICompositor compositor(std::move(host));
D3D12WindowRenderer renderer = {};
ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), renderer, {}));
hostPtr->callOrder.clear();
bool uiRendered = false;
constexpr float clearColor[4] = { 0.1f, 0.2f, 0.3f, 0.4f };
compositor.RenderFrame(
clearColor,
[&]() {
uiRendered = true;
hostPtr->callOrder.push_back("ui");
},
[](const ::XCEngine::Rendering::RenderContext&, const ::XCEngine::Rendering::RenderSurface&) {},
[](const ::XCEngine::Rendering::RenderContext&, const ::XCEngine::Rendering::RenderSurface&) {});
EXPECT_TRUE(uiRendered);
EXPECT_EQ(hostPtr->beginFrameCount, 1);
EXPECT_EQ(hostPtr->endFrameCount, 1);
EXPECT_EQ(hostPtr->presentedRenderer, &renderer);
EXPECT_TRUE(hostPtr->beforeUiRenderProvided);
EXPECT_TRUE(hostPtr->afterUiRenderProvided);
EXPECT_EQ(hostPtr->lastClearColor[0], clearColor[0]);
EXPECT_EQ(hostPtr->lastClearColor[1], clearColor[1]);
EXPECT_EQ(hostPtr->lastClearColor[2], clearColor[2]);
EXPECT_EQ(hostPtr->lastClearColor[3], clearColor[3]);
EXPECT_EQ(
hostPtr->callOrder,
(std::vector<std::string>{ "begin", "ui", "present" }));
}
TEST(ImGuiWindowUICompositorTest, HandleWindowMessageAndTextureRegistrationForwardToHost) {
auto host = std::make_unique<RecordingHostCompositor>();
RecordingHostCompositor* hostPtr = host.get();
hostPtr->handleWindowMessageResult = true;
hostPtr->createTextureDescriptorResult = true;
hostPtr->nextRegistration = MakeRegistration();
ImGuiWindowUICompositor compositor(std::move(host));
EXPECT_TRUE(compositor.HandleWindowMessage(MakeFakeHwnd(), WM_SIZE, 7u, 19u));
EXPECT_EQ(hostPtr->handleWindowMessageCount, 1);
EXPECT_EQ(hostPtr->lastMessage, static_cast<UINT>(WM_SIZE));
EXPECT_EQ(hostPtr->lastWParam, static_cast<WPARAM>(7u));
EXPECT_EQ(hostPtr->lastLParam, static_cast<LPARAM>(19u));
UITextureRegistration registration = {};
auto* fakeDevice = reinterpret_cast<::XCEngine::RHI::RHIDevice*>(static_cast<std::uintptr_t>(0x41u));
auto* fakeTexture = reinterpret_cast<::XCEngine::RHI::RHITexture*>(static_cast<std::uintptr_t>(0x59u));
EXPECT_TRUE(compositor.CreateTextureDescriptor(fakeDevice, fakeTexture, registration));
EXPECT_EQ(hostPtr->createTextureDescriptorCount, 1);
EXPECT_EQ(hostPtr->lastDevice, fakeDevice);
EXPECT_EQ(hostPtr->lastTexture, fakeTexture);
EXPECT_EQ(registration.cpuHandle.ptr, hostPtr->nextRegistration.cpuHandle.ptr);
EXPECT_EQ(registration.gpuHandle.ptr, hostPtr->nextRegistration.gpuHandle.ptr);
EXPECT_EQ(registration.texture.nativeHandle, hostPtr->nextRegistration.texture.nativeHandle);
compositor.FreeTextureDescriptor(registration);
EXPECT_EQ(hostPtr->freeTextureDescriptorCount, 1);
EXPECT_EQ(hostPtr->freedRegistration.cpuHandle.ptr, registration.cpuHandle.ptr);
EXPECT_EQ(hostPtr->freedRegistration.gpuHandle.ptr, registration.gpuHandle.ptr);
EXPECT_EQ(hostPtr->freedRegistration.texture.nativeHandle, registration.texture.nativeHandle);
}
TEST(ImGuiWindowUICompositorTest, ShutdownClearsRendererBindingAndPreventsFurtherRender) {
auto host = std::make_unique<RecordingHostCompositor>();
RecordingHostCompositor* hostPtr = host.get();
ImGuiWindowUICompositor compositor(std::move(host));
D3D12WindowRenderer renderer = {};
ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), renderer, {}));
bool firstUiRendered = false;
compositor.RenderFrame(
std::array<float, 4>{ 0.0f, 0.0f, 0.0f, 1.0f }.data(),
[&]() { firstUiRendered = true; },
{},
{});
EXPECT_TRUE(firstUiRendered);
EXPECT_EQ(hostPtr->beginFrameCount, 1);
EXPECT_EQ(hostPtr->endFrameCount, 1);
compositor.Shutdown();
EXPECT_EQ(hostPtr->shutdownCount, 1);
bool secondUiRendered = false;
compositor.RenderFrame(
std::array<float, 4>{ 1.0f, 0.0f, 0.0f, 1.0f }.data(),
[&]() { secondUiRendered = true; },
{},
{});
EXPECT_FALSE(secondUiRendered);
EXPECT_EQ(hostPtr->beginFrameCount, 1);
EXPECT_EQ(hostPtr->endFrameCount, 1);
}
TEST(ImGuiWindowUICompositorTest, NullHostCompositorReturnsSafeDefaults) {
ImGuiWindowUICompositor compositor(std::unique_ptr<IEditorHostCompositor>{});
D3D12WindowRenderer renderer = {};
bool configureFontsCalled = false;
EXPECT_FALSE(compositor.Initialize(
MakeFakeHwnd(),
renderer,
[&configureFontsCalled]() { configureFontsCalled = true; }));
EXPECT_FALSE(configureFontsCalled);
EXPECT_FALSE(compositor.HandleWindowMessage(MakeFakeHwnd(), WM_CLOSE, 0u, 0u));
UITextureRegistration registration = {};
EXPECT_FALSE(compositor.CreateTextureDescriptor(nullptr, nullptr, registration));
bool uiRendered = false;
compositor.RenderFrame(
std::array<float, 4>{ 0.0f, 0.0f, 0.0f, 1.0f }.data(),
[&]() { uiRendered = true; },
{},
{});
EXPECT_FALSE(uiRendered);
compositor.Shutdown();
}
} // namespace

View File

@@ -250,3 +250,116 @@ TEST(NewEditorXCUILayoutLabRuntimeTest, ClickSelectionPersistsOnSharedCollection
ASSERT_TRUE(persistedFrame.stats.documentsReady);
EXPECT_EQ(persistedFrame.stats.selectedElementId, selectedElementId);
}
TEST(NewEditorXCUILayoutLabRuntimeTest, ClickingTreeRootTogglesIndentedChildrenVisibility) {
XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime;
ASSERT_TRUE(runtime.ReloadDocuments());
const auto& baseline = runtime.Update(BuildInputState());
ASSERT_TRUE(baseline.stats.documentsReady);
EXPECT_EQ(baseline.stats.expandedTreeItemCount, 1u);
XCEngine::UI::UIRect treeRootRect = {};
XCEngine::UI::UIRect treeChildRect = {};
ASSERT_TRUE(runtime.TryGetElementRect("treeAssetsRoot", treeRootRect));
ASSERT_TRUE(runtime.TryGetElementRect("treeScenes", treeChildRect));
ASSERT_GT(treeRootRect.width, 0.0f);
ASSERT_GT(treeRootRect.height, 0.0f);
const XCEngine::UI::UIPoint rootClickPoint(
treeRootRect.x + 18.0f,
treeRootRect.y + treeRootRect.height * 0.5f);
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState hoverInput = BuildInputState();
hoverInput.pointerPosition = rootClickPoint;
const auto& hoveredFrame = runtime.Update(hoverInput);
ASSERT_TRUE(hoveredFrame.stats.documentsReady);
ASSERT_EQ(hoveredFrame.stats.hoveredElementId, "treeAssetsRoot")
<< "treeRootRect=("
<< treeRootRect.x << ", "
<< treeRootRect.y << ", "
<< treeRootRect.width << ", "
<< treeRootRect.height << ")";
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState collapseInput = BuildInputState();
collapseInput.pointerPosition = rootClickPoint;
collapseInput.pointerPressed = true;
const auto& collapsedFrame = runtime.Update(collapseInput);
ASSERT_TRUE(collapsedFrame.stats.documentsReady);
EXPECT_EQ(collapsedFrame.stats.selectedElementId, "treeAssetsRoot");
const auto& collapsedPersistedFrame = runtime.Update(BuildInputState());
ASSERT_TRUE(collapsedPersistedFrame.stats.documentsReady);
EXPECT_EQ(collapsedPersistedFrame.stats.selectedElementId, "treeAssetsRoot");
EXPECT_EQ(collapsedPersistedFrame.stats.expandedTreeItemCount, 0u);
EXPECT_FALSE(runtime.TryGetElementRect("treeScenes", treeChildRect));
ASSERT_TRUE(runtime.TryGetElementRect("treeAssetsRoot", treeRootRect));
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState expandInput = BuildInputState();
expandInput.pointerPosition = XCEngine::UI::UIPoint(
treeRootRect.x + 18.0f,
treeRootRect.y + treeRootRect.height * 0.5f);
expandInput.pointerPressed = true;
const auto& expandedClickFrame = runtime.Update(expandInput);
ASSERT_TRUE(expandedClickFrame.stats.documentsReady);
const auto& expandedFrame = runtime.Update(BuildInputState());
ASSERT_TRUE(expandedFrame.stats.documentsReady);
EXPECT_EQ(expandedFrame.stats.expandedTreeItemCount, 1u);
EXPECT_TRUE(runtime.TryGetElementRect("treeScenes", treeChildRect));
EXPECT_GT(treeChildRect.height, 0.0f);
}
TEST(NewEditorXCUILayoutLabRuntimeTest, ClickingPropertySectionHeaderTogglesFieldVisibility) {
XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime;
ASSERT_TRUE(runtime.ReloadDocuments());
const auto& baseline = runtime.Update(BuildInputState());
ASSERT_TRUE(baseline.stats.documentsReady);
XCEngine::UI::UIRect sectionRect = {};
XCEngine::UI::UIRect fieldRect = {};
ASSERT_TRUE(runtime.TryGetElementRect("inspectorTransform", sectionRect));
ASSERT_TRUE(runtime.TryGetElementRect("fieldPosition", fieldRect));
const float expandedHeight = sectionRect.height;
const XCEngine::UI::UIPoint sectionHeaderPoint(
sectionRect.x + 18.0f,
sectionRect.y + 10.0f);
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState hoverInput = BuildInputState();
hoverInput.pointerPosition = sectionHeaderPoint;
const auto& hoveredFrame = runtime.Update(hoverInput);
ASSERT_TRUE(hoveredFrame.stats.documentsReady);
ASSERT_EQ(hoveredFrame.stats.hoveredElementId, "inspectorTransform")
<< "sectionRect=("
<< sectionRect.x << ", "
<< sectionRect.y << ", "
<< sectionRect.width << ", "
<< sectionRect.height << "), expandedPropertySectionCount="
<< baseline.stats.expandedPropertySectionCount;
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState collapseInput = BuildInputState();
collapseInput.pointerPosition = sectionHeaderPoint;
collapseInput.pointerPressed = true;
const auto& collapsedFrame = runtime.Update(collapseInput);
ASSERT_TRUE(collapsedFrame.stats.documentsReady);
const auto& collapsedPersistedFrame = runtime.Update(BuildInputState());
ASSERT_TRUE(collapsedPersistedFrame.stats.documentsReady);
EXPECT_EQ(collapsedPersistedFrame.stats.selectedElementId, "inspectorTransform");
ASSERT_TRUE(runtime.TryGetElementRect("inspectorTransform", sectionRect));
EXPECT_LT(sectionRect.height, expandedHeight);
EXPECT_FALSE(runtime.TryGetElementRect("fieldPosition", fieldRect));
XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState expandInput = BuildInputState();
expandInput.pointerPosition = XCEngine::UI::UIPoint(
sectionRect.x + 18.0f,
sectionRect.y + 10.0f);
expandInput.pointerPressed = true;
const auto& expandedClickFrame = runtime.Update(expandInput);
ASSERT_TRUE(expandedClickFrame.stats.documentsReady);
const auto& expandedFrame = runtime.Update(BuildInputState());
ASSERT_TRUE(expandedFrame.stats.documentsReady);
EXPECT_TRUE(runtime.TryGetElementRect("fieldPosition", fieldRect));
EXPECT_GT(fieldRect.height, 0.0f);
}

View File

@@ -131,6 +131,18 @@ bool DrawDataContainsText(
return false;
}
const XCEngine::UI::Runtime::UISystemPresentedLayer* FindPresentedLayerById(
const XCEngine::UI::Runtime::UISystemFrameResult& frame,
XCEngine::UI::Runtime::UIScreenLayerId layerId) {
for (const XCEngine::UI::Runtime::UISystemPresentedLayer& layer : frame.layers) {
if (layer.layerId == layerId) {
return &layer;
}
}
return nullptr;
}
class RecordingScriptRuntime : public IScriptRuntime {
public:
explicit RecordingScriptRuntime(std::vector<std::string>* events)
@@ -430,4 +442,146 @@ TEST_F(SceneRuntimeTest, StopClearsUiRuntimeState) {
EXPECT_TRUE(runtime.GetLastUIFrame().layers.empty());
}
TEST_F(SceneRuntimeTest, LayeredSceneUiRoutesInputOnlyToTopInteractivePresentedLayer) {
Scene* runtimeScene = CreateScene("RuntimeScene");
runtime.Start(runtimeScene);
runtime.SetUIViewportRect(XCEngine::UI::UIRect(0.0f, 0.0f, 1280.0f, 720.0f));
runtime.SetUIFocused(true);
TempFileScope gameplayView("xcui_scene_runtime_gameplay", ".xcui", BuildViewMarkup("Gameplay Layer"));
TempFileScope overlayView("xcui_scene_runtime_overlay", ".xcui", BuildViewMarkup("Overlay Layer"));
XCEngine::UI::Runtime::UIScreenLayerOptions gameplayOptions = {};
gameplayOptions.debugName = "gameplay";
gameplayOptions.acceptsInput = true;
gameplayOptions.blocksLayersBelow = false;
XCEngine::UI::Runtime::UIScreenLayerOptions overlayOptions = {};
overlayOptions.debugName = "overlay";
overlayOptions.acceptsInput = true;
overlayOptions.blocksLayersBelow = false;
const auto gameplayLayerId = runtime.GetUIScreenStackController().PushScreen(
BuildScreenAsset(gameplayView.Path(), "runtime.gameplay"),
gameplayOptions);
const auto overlayLayerId = runtime.GetUIScreenStackController().PushScreen(
BuildScreenAsset(overlayView.Path(), "runtime.overlay"),
overlayOptions);
ASSERT_NE(gameplayLayerId, 0u);
ASSERT_NE(overlayLayerId, 0u);
XCEngine::UI::UIInputEvent textEvent = {};
textEvent.type = XCEngine::UI::UIInputEventType::Character;
textEvent.character = 'I';
runtime.QueueUIInputEvent(textEvent);
runtime.Update(0.016f);
const auto& frame = runtime.GetLastUIFrame();
ASSERT_EQ(frame.presentedLayerCount, 2u);
ASSERT_EQ(frame.skippedLayerCount, 0u);
ASSERT_EQ(frame.layers.size(), 2u);
const auto* gameplayLayer = FindPresentedLayerById(frame, gameplayLayerId);
const auto* overlayLayer = FindPresentedLayerById(frame, overlayLayerId);
ASSERT_NE(gameplayLayer, nullptr);
ASSERT_NE(overlayLayer, nullptr);
EXPECT_EQ(gameplayLayer->stats.inputEventCount, 0u);
EXPECT_EQ(overlayLayer->stats.inputEventCount, 1u);
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Gameplay Layer"));
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Overlay Layer"));
}
TEST_F(SceneRuntimeTest, BlockingLayerSkipsLowerLayersAndOwnsQueuedInput) {
Scene* runtimeScene = CreateScene("RuntimeScene");
runtime.Start(runtimeScene);
runtime.SetUIViewportRect(XCEngine::UI::UIRect(0.0f, 0.0f, 1280.0f, 720.0f));
runtime.SetUIFocused(true);
TempFileScope gameplayView("xcui_scene_runtime_blocked_gameplay", ".xcui", BuildViewMarkup("Blocked Gameplay"));
TempFileScope modalView("xcui_scene_runtime_modal", ".xcui", BuildViewMarkup("Pause Modal"));
XCEngine::UI::Runtime::UIScreenLayerOptions gameplayOptions = {};
gameplayOptions.debugName = "gameplay";
gameplayOptions.acceptsInput = true;
gameplayOptions.blocksLayersBelow = false;
const auto gameplayLayerId = runtime.GetUIScreenStackController().PushScreen(
BuildScreenAsset(gameplayView.Path(), "runtime.blocked.gameplay"),
gameplayOptions);
const auto modalLayerId = runtime.GetUIScreenStackController().PushModal(
BuildScreenAsset(modalView.Path(), "runtime.pause.modal"),
"pause-modal");
ASSERT_NE(gameplayLayerId, 0u);
ASSERT_NE(modalLayerId, 0u);
XCEngine::UI::UIInputEvent textEvent = {};
textEvent.type = XCEngine::UI::UIInputEventType::Character;
textEvent.character = 'P';
runtime.QueueUIInputEvent(textEvent);
runtime.Update(0.016f);
const auto& frame = runtime.GetLastUIFrame();
ASSERT_EQ(frame.presentedLayerCount, 1u);
ASSERT_EQ(frame.skippedLayerCount, 1u);
ASSERT_EQ(frame.layers.size(), 1u);
const auto* modalLayer = FindPresentedLayerById(frame, modalLayerId);
ASSERT_NE(modalLayer, nullptr);
EXPECT_EQ(modalLayer->stats.inputEventCount, 1u);
EXPECT_EQ(FindPresentedLayerById(frame, gameplayLayerId), nullptr);
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Pause Modal"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Blocked Gameplay"));
}
TEST_F(SceneRuntimeTest, HiddenTopLayerDoesNotStealInputFromVisibleUnderlyingLayer) {
Scene* runtimeScene = CreateScene("RuntimeScene");
runtime.Start(runtimeScene);
runtime.SetUIViewportRect(XCEngine::UI::UIRect(0.0f, 0.0f, 1280.0f, 720.0f));
runtime.SetUIFocused(true);
TempFileScope gameplayView("xcui_scene_runtime_visible_gameplay", ".xcui", BuildViewMarkup("Visible Gameplay"));
TempFileScope hiddenOverlayView("xcui_scene_runtime_hidden_overlay", ".xcui", BuildViewMarkup("Hidden Overlay"));
XCEngine::UI::Runtime::UIScreenLayerOptions gameplayOptions = {};
gameplayOptions.debugName = "gameplay";
gameplayOptions.acceptsInput = true;
gameplayOptions.blocksLayersBelow = false;
XCEngine::UI::Runtime::UIScreenLayerOptions hiddenOverlayOptions = {};
hiddenOverlayOptions.debugName = "hidden-overlay";
hiddenOverlayOptions.visible = false;
hiddenOverlayOptions.acceptsInput = true;
hiddenOverlayOptions.blocksLayersBelow = false;
const auto gameplayLayerId = runtime.GetUIScreenStackController().PushScreen(
BuildScreenAsset(gameplayView.Path(), "runtime.visible.gameplay"),
gameplayOptions);
const auto hiddenOverlayLayerId = runtime.GetUIScreenStackController().PushScreen(
BuildScreenAsset(hiddenOverlayView.Path(), "runtime.hidden.overlay"),
hiddenOverlayOptions);
ASSERT_NE(gameplayLayerId, 0u);
ASSERT_NE(hiddenOverlayLayerId, 0u);
XCEngine::UI::UIInputEvent textEvent = {};
textEvent.type = XCEngine::UI::UIInputEventType::Character;
textEvent.character = 'W';
runtime.QueueUIInputEvent(textEvent);
runtime.Update(0.016f);
const auto& frame = runtime.GetLastUIFrame();
ASSERT_EQ(frame.presentedLayerCount, 1u);
ASSERT_EQ(frame.skippedLayerCount, 1u);
ASSERT_EQ(frame.layers.size(), 1u);
const auto* gameplayLayer = FindPresentedLayerById(frame, gameplayLayerId);
ASSERT_NE(gameplayLayer, nullptr);
EXPECT_EQ(gameplayLayer->stats.inputEventCount, 1u);
EXPECT_EQ(FindPresentedLayerById(frame, hiddenOverlayLayerId), nullptr);
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Visible Gameplay"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Hidden Overlay"));
}
} // namespace