From 511e94fd30b213a5a355db3b7bcb68cea0b5aaf7 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 07:29:27 +0800 Subject: [PATCH] Add XCUI expansion state and coverage tests --- Assets/XCUI/NewEditor/LayoutLab/View.xcui | 6 +- docs/plan/XCUI_Phase_Status_2026-04-05.md | 23 +- engine/CMakeLists.txt | 2 + .../XCEngine/UI/Widgets/UIExpansionModel.h | 30 ++ .../Widgets/UIEditorCollectionPrimitives.cpp | 1 + engine/src/UI/Widgets/UIExpansionModel.cpp | 57 +++ .../resources/xcui_layout_lab_view.xcui | 6 +- .../src/XCUIBackend/XCUILayoutLabRuntime.cpp | 327 +++++++++++++++--- .../src/XCUIBackend/XCUILayoutLabRuntime.h | 2 + new_editor/src/panels/XCUILayoutLabPanel.cpp | 6 + tests/Core/UI/CMakeLists.txt | 1 + .../test_ui_editor_collection_primitives.cpp | 1 + tests/Core/UI/test_ui_expansion_model.cpp | 45 +++ .../Core/UI/test_ui_text_input_controller.cpp | 172 +++++++++ tests/NewEditor/CMakeLists.txt | 37 ++ .../test_imgui_window_ui_compositor.cpp | 283 +++++++++++++++ .../test_xcui_layout_lab_runtime.cpp | 113 ++++++ tests/Scene/test_scene_runtime.cpp | 154 +++++++++ 18 files changed, 1213 insertions(+), 53 deletions(-) create mode 100644 engine/include/XCEngine/UI/Widgets/UIExpansionModel.h create mode 100644 engine/src/UI/Widgets/UIExpansionModel.cpp create mode 100644 tests/Core/UI/test_ui_expansion_model.cpp create mode 100644 tests/NewEditor/test_imgui_window_ui_compositor.cpp diff --git a/Assets/XCUI/NewEditor/LayoutLab/View.xcui b/Assets/XCUI/NewEditor/LayoutLab/View.xcui index 471b2e24..afd79014 100644 --- a/Assets/XCUI/NewEditor/LayoutLab/View.xcui +++ b/Assets/XCUI/NewEditor/LayoutLab/View.xcui @@ -14,7 +14,7 @@ tone="accent-alt" title="Tool Shelf" subtitle="Scene, asset, and play-mode actions." /> - + + - @@ -77,7 +77,7 @@ height="88" title="Inspector Summary" subtitle="Transform, renderer, and prefab overrides." /> - + diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index df21625e..1af40f86 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -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 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index ab68e7b7..d20d97ba 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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 diff --git a/engine/include/XCEngine/UI/Widgets/UIExpansionModel.h b/engine/include/XCEngine/UI/Widgets/UIExpansionModel.h new file mode 100644 index 00000000..090cfc75 --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UIExpansionModel.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +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 m_expandedIds = {}; +}; + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp b/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp index 9ef604fe..c7f511a9 100644 --- a/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp +++ b/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp @@ -64,6 +64,7 @@ bool UsesUIEditorCollectionPrimitiveColumnLayout(UIEditorCollectionPrimitiveKind bool IsUIEditorCollectionPrimitiveHoverable(UIEditorCollectionPrimitiveKind kind) { return kind == UIEditorCollectionPrimitiveKind::TreeItem || kind == UIEditorCollectionPrimitiveKind::ListItem || + kind == UIEditorCollectionPrimitiveKind::PropertySection || kind == UIEditorCollectionPrimitiveKind::FieldRow; } diff --git a/engine/src/UI/Widgets/UIExpansionModel.cpp b/engine/src/UI/Widgets/UIExpansionModel.cpp new file mode 100644 index 00000000..0a7f8697 --- /dev/null +++ b/engine/src/UI/Widgets/UIExpansionModel.cpp @@ -0,0 +1,57 @@ +#include + +#include + +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 diff --git a/new_editor/resources/xcui_layout_lab_view.xcui b/new_editor/resources/xcui_layout_lab_view.xcui index ea81d5d9..5fa50909 100644 --- a/new_editor/resources/xcui_layout_lab_view.xcui +++ b/new_editor/resources/xcui_layout_lab_view.xcui @@ -14,7 +14,7 @@ tone="accent-alt" title="Tool Shelf" subtitle="Scene, asset, and play-mode actions." /> - + + - @@ -74,7 +74,7 @@ height="88" title="Inspector Summary" subtitle="Transform, renderer, and prefab overrides." /> - + diff --git a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp index 03e75331..4cac5971 100644 --- a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp +++ b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -78,6 +79,7 @@ struct RuntimeBuildContext { std::vector nodes = {}; std::unordered_map nodeIndexById = {}; std::unordered_map 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(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 CollectVisibleChildren( + const RuntimeBuildContext& state, + std::size_t nodeIndex) { + std::vector 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 visibleChildren = CollectVisibleChildren(state, nodeIndex); + if (visibleChildren.empty()) { return; } float fixedHeight = 0.0f; std::size_t stretchCount = 0; - std::vector 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 resolvedHeights(visibleChildren.size(), 0.0f); + std::vector 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((std::max)(1u, node.children.size()) - 1u); + const float totalGap = + gap * static_cast((std::max)(1u, visibleChildren.size()) - 1u); const float remainingHeight = (std::max)(0.0f, contentRect.height - fixedHeight - totalGap); const float stretchHeight = stretchCount > 0u ? remainingHeight / static_cast(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 visibleChildren = CollectVisibleChildren(state, nodeIndex); + if (visibleChildren.empty()) { return; } float fixedWidth = 0.0f; std::size_t stretchCount = 0; - std::vector 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 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((std::max)(1u, node.children.size()) - 1u); + const float totalGap = + gap * static_cast((std::max)(1u, visibleChildren.size()) - 1u); const float remainingWidth = (std::max)(0.0f, contentRect.width - fixedWidth - totalGap); const float stretchWidth = stretchCount > 0u ? remainingWidth / static_cast(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; } diff --git a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h index 94ba97fb..c5284d66 100644 --- a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h +++ b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h @@ -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 = {}; }; diff --git a/new_editor/src/panels/XCUILayoutLabPanel.cpp b/new_editor/src/panels/XCUILayoutLabPanel.cpp index 3fb4b5f5..1e2c9a8b 100644 --- a/new_editor/src/panels/XCUILayoutLabPanel.cpp +++ b/new_editor/src/panels/XCUILayoutLabPanel.cpp @@ -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, diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index 04a41d2e..c90a6599 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -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 diff --git a/tests/Core/UI/test_ui_editor_collection_primitives.cpp b/tests/Core/UI/test_ui_editor_collection_primitives.cpp index 4f9fd160..757d4cba 100644 --- a/tests/Core/UI/test_ui_editor_collection_primitives.cpp +++ b/tests/Core/UI/test_ui_editor_collection_primitives.cpp @@ -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)); } diff --git a/tests/Core/UI/test_ui_expansion_model.cpp b/tests/Core/UI/test_ui_expansion_model.cpp new file mode 100644 index 00000000..2128a4db --- /dev/null +++ b/tests/Core/UI/test_ui_expansion_model.cpp @@ -0,0 +1,45 @@ +#include + +#include + +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 diff --git a/tests/Core/UI/test_ui_text_input_controller.cpp b/tests/Core/UI/test_ui_text_input_controller.cpp index 9b0e541d..607772de 100644 --- a/tests/Core/UI/test_ui_text_input_controller.cpp +++ b/tests/Core/UI/test_ui_text_input_controller.cpp @@ -41,6 +41,50 @@ TEST(UITextInputControllerTest, BackspaceAndArrowKeysUseUtf8Boundaries) { EXPECT_EQ(state.caret, 1u); } +TEST(UITextInputControllerTest, DeleteUsesUtf8BoundariesAndLeavesCaretAtDeletePoint) { + if (static_cast(KeyCode::Delete) == + static_cast(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(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(KeyCode::Delete) == + static_cast(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(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(KeyCode::Home), + {}, + {}); + EXPECT_TRUE(singleHome.handled); + EXPECT_EQ(singleLine.caret, 0u); + + const auto singleEnd = UIText::HandleKeyDown( + singleLine, + static_cast(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(KeyCode::Home), + {}, + options); + EXPECT_TRUE(multilineHome.handled); + EXPECT_EQ(multiline.caret, 5u); + + multiline.caret = 7u; + const auto multilineEnd = UIText::HandleKeyDown( + multiline, + static_cast(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(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(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(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(KeyCode::Tab), + superModifier, + options); + EXPECT_FALSE(superResult.handled); + EXPECT_FALSE(superResult.valueChanged); + EXPECT_EQ(superState.value, "root\nnode"); + EXPECT_EQ(superState.caret, 5u); +} + } // namespace diff --git a/tests/NewEditor/CMakeLists.txt b/tests/NewEditor/CMakeLists.txt index e82397ab..82b18552 100644 --- a/tests/NewEditor/CMakeLists.txt +++ b/tests/NewEditor/CMakeLists.txt @@ -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 diff --git a/tests/NewEditor/test_imgui_window_ui_compositor.cpp b/tests/NewEditor/test_imgui_window_ui_compositor.cpp new file mode 100644 index 00000000..2124ef8f --- /dev/null +++ b/tests/NewEditor/test_imgui_window_ui_compositor.cpp @@ -0,0 +1,283 @@ +#include + +#include "XCUIBackend/IEditorHostCompositor.h" +#include "XCUIBackend/ImGuiWindowUICompositor.h" +#include "XCUIBackend/UITextureRegistration.h" + +#include +#include +#include +#include +#include +#include + +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 lastClearColor = { 0.0f, 0.0f, 0.0f, 0.0f }; + UITextureRegistration nextRegistration = {}; + UITextureRegistration freedRegistration = {}; + std::vector 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(beforeUiRender); + afterUiRenderProvided = static_cast(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(static_cast(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* 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* 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{ "begin", "ui", "present" })); +} + +TEST(ImGuiWindowUICompositorTest, HandleWindowMessageAndTextureRegistrationForwardToHost) { + auto host = std::make_unique(); + 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(WM_SIZE)); + EXPECT_EQ(hostPtr->lastWParam, static_cast(7u)); + EXPECT_EQ(hostPtr->lastLParam, static_cast(19u)); + + UITextureRegistration registration = {}; + auto* fakeDevice = reinterpret_cast<::XCEngine::RHI::RHIDevice*>(static_cast(0x41u)); + auto* fakeTexture = reinterpret_cast<::XCEngine::RHI::RHITexture*>(static_cast(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* hostPtr = host.get(); + + ImGuiWindowUICompositor compositor(std::move(host)); + D3D12WindowRenderer renderer = {}; + ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), renderer, {})); + + bool firstUiRendered = false; + compositor.RenderFrame( + std::array{ 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{ 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{}); + 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{ 0.0f, 0.0f, 0.0f, 1.0f }.data(), + [&]() { uiRendered = true; }, + {}, + {}); + EXPECT_FALSE(uiRendered); + + compositor.Shutdown(); +} + +} // namespace diff --git a/tests/NewEditor/test_xcui_layout_lab_runtime.cpp b/tests/NewEditor/test_xcui_layout_lab_runtime.cpp index 1aa3efdf..ceca3a65 100644 --- a/tests/NewEditor/test_xcui_layout_lab_runtime.cpp +++ b/tests/NewEditor/test_xcui_layout_lab_runtime.cpp @@ -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); +} diff --git a/tests/Scene/test_scene_runtime.cpp b/tests/Scene/test_scene_runtime.cpp index c7ebc657..6d051609 100644 --- a/tests/Scene/test_scene_runtime.cpp +++ b/tests/Scene/test_scene_runtime.cpp @@ -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* 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