From 585575a7389f02136ecba62272d98476630de910 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 06:36:50 +0800 Subject: [PATCH] Add XCUI editor collection primitives and stack rollback --- docs/plan/XCUI_Phase_Status_2026-04-05.md | 8 +- .../UI/Widgets/UIEditorCollectionPrimitives.h | 41 +++++++ .../UI/Runtime/UIScreenStackController.cpp | 20 ++- .../Widgets/UIEditorCollectionPrimitives.cpp | 114 ++++++++++++++++++ tests/Core/UI/CMakeLists.txt | 1 + .../test_ui_editor_collection_primitives.cpp | 83 +++++++++++++ tests/Core/UI/test_ui_runtime.cpp | 31 +++++ 7 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 engine/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h create mode 100644 engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp create mode 100644 tests/Core/UI/test_ui_editor_collection_primitives.cpp diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index 40a1e6d8..221ac33e 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -31,13 +31,14 @@ Old `editor` replacement is explicitly out of scope for this phase. - Shared engine-side `UIDocumentScreenHost` now compiles `.xcui` / `.xctheme` screen documents into a runtime-facing document host path instead of leaving all document ownership in `new_editor`. - Shared text-editing primitives now live under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so UTF-8 caret movement, line splitting, and multiline navigation are no longer trapped inside `XCUI Demo`. - 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. - 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, tree/list virtualization, property-grid composition, 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, real tree/list/property widget state on top of the new editor-primitive helpers, and native image/source-rect level APIs. ### 2. Runtime/Game Layer @@ -45,6 +46,7 @@ Current gap: - The demo runtime has moved past single-line input: multiline `TextArea` behavior is now covered in the sandbox testbed. - Engine-side runtime ownership is no longer zero: `UIScreenPlayer`, `UIDocumentScreenHost`, and `UISystem` now define a shared runtime contract for loading a screen document, ticking it with input, and collecting `UI::UIDrawData`. - `UISystem` now supports layered screen composition semantics: stacked screen players, top-interactive input routing, and modal layers that block lower screens. +- `UIScreenStackController::ReplaceTop` now preserves the previous top screen if the replacement screen fails to load, so runtime menu flows do not silently drop their active layer on bad assets. - Runtime screen emission now also carries concrete button text in the shared document host path instead of silently dropping button labels. Current gap: @@ -82,7 +84,7 @@ Current gap: - `new_editor_xcui_rhi_render_backend_tests`: `5/5` - `new_editor_xcui_hosted_preview_presenter_tests`: `12/12` - `XCNewEditor` Debug target builds successfully -- `core_ui_tests`: `26/26` +- `core_ui_tests`: `28/28` - `core_ui_style_tests`: `5/5` - `ui_resource_tests`: `11/11` - `editor_tests` targeted bridge smoke: `3/3` @@ -93,6 +95,7 @@ Current gap: - Demo runtime multiline `TextArea` path in the sandbox and test coverage for caret movement / multiline input. - Common-core `UITextEditing` extraction now owns UTF-8 offset stepping, codepoint counting, line splitting, and vertical caret motion with dedicated `core_ui_tests` coverage. - 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. - Demo runtime text editing was extended with: - click-to-place caret - `Delete` support @@ -117,6 +120,7 @@ Current gap: - `UISystem` - layered screen composition and modal blocking semantics - Runtime/game integration scaffolding now includes reusable `HUD/menu/modal` stack helpers on top of `UISystem`. +- `UIScreenStackController` replacement now rolls back safely on failure instead of popping the active top layer first. - Runtime document-host draw emission now preserves button labels for shared screen rendering. - RHI image path improvements: - clipped image UV adjustment diff --git a/engine/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h b/engine/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h new file mode 100644 index 00000000..c804a111 --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +enum class UIEditorCollectionPrimitiveKind : std::uint8_t { + None = 0, + ScrollView, + TreeView, + TreeItem, + ListView, + ListItem, + PropertySection, + FieldRow +}; + +UIEditorCollectionPrimitiveKind ClassifyUIEditorCollectionPrimitive(std::string_view tagName); +bool IsUIEditorCollectionPrimitiveContainer(UIEditorCollectionPrimitiveKind kind); +bool UsesUIEditorCollectionPrimitiveColumnLayout(UIEditorCollectionPrimitiveKind kind); +bool IsUIEditorCollectionPrimitiveHoverable(UIEditorCollectionPrimitiveKind kind); +bool DoesUIEditorCollectionPrimitiveClipChildren(UIEditorCollectionPrimitiveKind kind); +float ResolveUIEditorCollectionPrimitivePadding( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme); +float ResolveUIEditorCollectionPrimitiveDefaultHeight( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme); +float ResolveUIEditorCollectionPrimitiveIndent( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme, + float indentLevel); + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Runtime/UIScreenStackController.cpp b/engine/src/UI/Runtime/UIScreenStackController.cpp index abae789b..c487b266 100644 --- a/engine/src/UI/Runtime/UIScreenStackController.cpp +++ b/engine/src/UI/Runtime/UIScreenStackController.cpp @@ -82,11 +82,27 @@ bool UIScreenStackController::ReplaceTop( return PushScreen(asset, options) != 0; } - if (!Pop()) { + if (m_system == nullptr) { return false; } - return PushScreen(asset, options) != 0; + const UIScreenStackEntry previousTop = m_entries.back(); + const UIScreenLayerId replacementLayerId = m_system->PushScreen(asset, options); + if (replacementLayerId == 0) { + return false; + } + + if (!m_system->RemoveLayer(previousTop.layerId)) { + m_system->RemoveLayer(replacementLayerId); + return false; + } + + UIScreenStackEntry replacementEntry = {}; + replacementEntry.layerId = replacementLayerId; + replacementEntry.asset = asset; + replacementEntry.options = options; + m_entries.back() = std::move(replacementEntry); + return true; } bool UIScreenStackController::Pop() { diff --git a/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp b/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp new file mode 100644 index 00000000..9ef604fe --- /dev/null +++ b/engine/src/UI/Widgets/UIEditorCollectionPrimitives.cpp @@ -0,0 +1,114 @@ +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +namespace { + +float ResolveFloatToken( + const Style::UITheme& theme, + const char* tokenName, + float fallbackValue) { + const Style::UITokenResolveResult result = + theme.ResolveToken(tokenName, Style::UIStyleValueType::Float); + if (result.status != Style::UITokenResolveStatus::Resolved) { + return fallbackValue; + } + + const float* value = result.value.TryGetFloat(); + return value != nullptr ? *value : fallbackValue; +} + +} // namespace + +UIEditorCollectionPrimitiveKind ClassifyUIEditorCollectionPrimitive(std::string_view tagName) { + if (tagName == "ScrollView") { + return UIEditorCollectionPrimitiveKind::ScrollView; + } + if (tagName == "TreeView") { + return UIEditorCollectionPrimitiveKind::TreeView; + } + if (tagName == "TreeItem") { + return UIEditorCollectionPrimitiveKind::TreeItem; + } + if (tagName == "ListView") { + return UIEditorCollectionPrimitiveKind::ListView; + } + if (tagName == "ListItem") { + return UIEditorCollectionPrimitiveKind::ListItem; + } + if (tagName == "PropertySection") { + return UIEditorCollectionPrimitiveKind::PropertySection; + } + if (tagName == "FieldRow") { + return UIEditorCollectionPrimitiveKind::FieldRow; + } + + return UIEditorCollectionPrimitiveKind::None; +} + +bool IsUIEditorCollectionPrimitiveContainer(UIEditorCollectionPrimitiveKind kind) { + return kind == UIEditorCollectionPrimitiveKind::ScrollView || + kind == UIEditorCollectionPrimitiveKind::TreeView || + kind == UIEditorCollectionPrimitiveKind::ListView || + kind == UIEditorCollectionPrimitiveKind::PropertySection; +} + +bool UsesUIEditorCollectionPrimitiveColumnLayout(UIEditorCollectionPrimitiveKind kind) { + return kind == UIEditorCollectionPrimitiveKind::TreeView || + kind == UIEditorCollectionPrimitiveKind::ListView || + kind == UIEditorCollectionPrimitiveKind::PropertySection; +} + +bool IsUIEditorCollectionPrimitiveHoverable(UIEditorCollectionPrimitiveKind kind) { + return kind == UIEditorCollectionPrimitiveKind::TreeItem || + kind == UIEditorCollectionPrimitiveKind::ListItem || + kind == UIEditorCollectionPrimitiveKind::FieldRow; +} + +bool DoesUIEditorCollectionPrimitiveClipChildren(UIEditorCollectionPrimitiveKind kind) { + return kind == UIEditorCollectionPrimitiveKind::ScrollView || + kind == UIEditorCollectionPrimitiveKind::TreeView || + kind == UIEditorCollectionPrimitiveKind::ListView; +} + +float ResolveUIEditorCollectionPrimitivePadding( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme) { + return kind == UIEditorCollectionPrimitiveKind::TreeView || + kind == UIEditorCollectionPrimitiveKind::ListView || + kind == UIEditorCollectionPrimitiveKind::PropertySection + ? ResolveFloatToken(theme, "space.cardInset", 12.0f) + : 0.0f; +} + +float ResolveUIEditorCollectionPrimitiveDefaultHeight( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme) { + switch (kind) { + case UIEditorCollectionPrimitiveKind::TreeItem: + return ResolveFloatToken(theme, "size.treeItemHeight", 28.0f); + case UIEditorCollectionPrimitiveKind::ListItem: + return ResolveFloatToken(theme, "size.listItemHeight", 60.0f); + case UIEditorCollectionPrimitiveKind::FieldRow: + return ResolveFloatToken(theme, "size.fieldRowHeight", 32.0f); + case UIEditorCollectionPrimitiveKind::PropertySection: + return ResolveFloatToken(theme, "size.propertySectionHeight", 148.0f); + default: + return 0.0f; + } +} + +float ResolveUIEditorCollectionPrimitiveIndent( + UIEditorCollectionPrimitiveKind kind, + const Style::UITheme& theme, + float indentLevel) { + return kind == UIEditorCollectionPrimitiveKind::TreeItem + ? indentLevel * ResolveFloatToken(theme, "size.treeIndent", 18.0f) + : 0.0f; +} + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index 9d52a339..463b5388 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -4,6 +4,7 @@ set(UI_TEST_SOURCES test_ui_core.cpp + test_ui_editor_collection_primitives.cpp test_layout_engine.cpp test_ui_runtime.cpp test_ui_text_editing.cpp diff --git a/tests/Core/UI/test_ui_editor_collection_primitives.cpp b/tests/Core/UI/test_ui_editor_collection_primitives.cpp new file mode 100644 index 00000000..4f9fd160 --- /dev/null +++ b/tests/Core/UI/test_ui_editor_collection_primitives.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include +#include + +namespace { + +namespace Style = XCEngine::UI::Style; +namespace UIWidgets = XCEngine::UI::Widgets; + +Style::UITheme BuildEditorPrimitiveTheme() { + Style::UIThemeDefinition definition = {}; + definition.SetToken("space.cardInset", Style::UIStyleValue(14.0f)); + definition.SetToken("size.treeItemHeight", Style::UIStyleValue(30.0f)); + definition.SetToken("size.listItemHeight", Style::UIStyleValue(64.0f)); + definition.SetToken("size.fieldRowHeight", Style::UIStyleValue(36.0f)); + definition.SetToken("size.propertySectionHeight", Style::UIStyleValue(156.0f)); + definition.SetToken("size.treeIndent", Style::UIStyleValue(20.0f)); + return Style::BuildTheme(definition); +} + +TEST(UIEditorCollectionPrimitivesTest, ClassifyAndFlagsMatchEditorCollectionTags) { + using Kind = UIWidgets::UIEditorCollectionPrimitiveKind; + + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("ScrollView"), Kind::ScrollView); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("TreeView"), Kind::TreeView); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("TreeItem"), Kind::TreeItem); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("ListView"), Kind::ListView); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("ListItem"), Kind::ListItem); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("PropertySection"), Kind::PropertySection); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("FieldRow"), Kind::FieldRow); + EXPECT_EQ(UIWidgets::ClassifyUIEditorCollectionPrimitive("Column"), Kind::None); + + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveContainer(Kind::ScrollView)); + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveContainer(Kind::TreeView)); + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveContainer(Kind::ListView)); + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveContainer(Kind::PropertySection)); + EXPECT_FALSE(UIWidgets::IsUIEditorCollectionPrimitiveContainer(Kind::TreeItem)); + + EXPECT_TRUE(UIWidgets::UsesUIEditorCollectionPrimitiveColumnLayout(Kind::TreeView)); + EXPECT_TRUE(UIWidgets::UsesUIEditorCollectionPrimitiveColumnLayout(Kind::ListView)); + EXPECT_TRUE(UIWidgets::UsesUIEditorCollectionPrimitiveColumnLayout(Kind::PropertySection)); + EXPECT_FALSE(UIWidgets::UsesUIEditorCollectionPrimitiveColumnLayout(Kind::ScrollView)); + + EXPECT_TRUE(UIWidgets::DoesUIEditorCollectionPrimitiveClipChildren(Kind::ScrollView)); + EXPECT_TRUE(UIWidgets::DoesUIEditorCollectionPrimitiveClipChildren(Kind::TreeView)); + EXPECT_TRUE(UIWidgets::DoesUIEditorCollectionPrimitiveClipChildren(Kind::ListView)); + EXPECT_FALSE(UIWidgets::DoesUIEditorCollectionPrimitiveClipChildren(Kind::PropertySection)); + + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::TreeItem)); + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::ListItem)); + EXPECT_TRUE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::FieldRow)); + EXPECT_FALSE(UIWidgets::IsUIEditorCollectionPrimitiveHoverable(Kind::TreeView)); +} + +TEST(UIEditorCollectionPrimitivesTest, ResolveMetricsUseThemeTokensAndFallbacks) { + using Kind = UIWidgets::UIEditorCollectionPrimitiveKind; + + const Style::UITheme themed = BuildEditorPrimitiveTheme(); + const Style::UITheme fallback = Style::UITheme(); + + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitivePadding(Kind::TreeView, themed), 14.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitivePadding(Kind::ListView, themed), 14.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitivePadding(Kind::PropertySection, themed), 14.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitivePadding(Kind::ScrollView, themed), 0.0f); + + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::TreeItem, themed), 30.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::ListItem, themed), 64.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::FieldRow, themed), 36.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::PropertySection, themed), 156.0f); + + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::TreeItem, fallback), 28.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::ListItem, fallback), 60.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::FieldRow, fallback), 32.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveDefaultHeight(Kind::PropertySection, fallback), 148.0f); + + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveIndent(Kind::TreeItem, themed, 2.0f), 40.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveIndent(Kind::TreeItem, fallback, 2.0f), 36.0f); + EXPECT_FLOAT_EQ(UIWidgets::ResolveUIEditorCollectionPrimitiveIndent(Kind::ListItem, themed, 2.0f), 0.0f); +} + +} // namespace diff --git a/tests/Core/UI/test_ui_runtime.cpp b/tests/Core/UI/test_ui_runtime.cpp index 5ff0507d..8f74bd29 100644 --- a/tests/Core/UI/test_ui_runtime.cpp +++ b/tests/Core/UI/test_ui_runtime.cpp @@ -205,3 +205,34 @@ TEST(UIRuntimeTest, ScreenStackControllerReplaceTopSwapsMenuContent) { EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Settings Menu")); EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Pause Menu")); } + +TEST(UIRuntimeTest, ScreenStackControllerReplaceTopKeepsPreviousScreenWhenReplacementFails) { + TempFileScope pauseView("xcui_runtime_pause", ".xcui", BuildViewMarkup("Pause Menu")); + + UIDocumentScreenHost host = {}; + UISystem system(host); + UIScreenStackController stack(system); + + const auto pauseLayer = stack.PushMenu(BuildScreenAsset(pauseView.Path(), "runtime.pause"), "pause"); + ASSERT_NE(pauseLayer, 0u); + + XCEngine::UI::Runtime::UIScreenLayerOptions replacementOptions = {}; + replacementOptions.debugName = "broken"; + replacementOptions.acceptsInput = true; + replacementOptions.blocksLayersBelow = true; + + UIScreenAsset invalidAsset = {}; + invalidAsset.screenId = "runtime.invalid"; + invalidAsset.documentPath = (fs::temp_directory_path() / "xcui_missing_runtime_screen.xcui").string(); + + EXPECT_FALSE(stack.ReplaceTop(invalidAsset, replacementOptions)); + ASSERT_EQ(stack.GetEntryCount(), 1u); + ASSERT_NE(stack.GetTop(), nullptr); + EXPECT_EQ(stack.GetTop()->layerId, pauseLayer); + EXPECT_EQ(stack.GetTop()->asset.screenId, "runtime.pause"); + EXPECT_EQ(system.GetLayerCount(), 1u); + + const auto& frame = system.Update(BuildInputState(6u)); + EXPECT_EQ(frame.presentedLayerCount, 1u); + EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Pause Menu")); +}