Add XCUI editor collection primitives and stack rollback

This commit is contained in:
2026-04-05 06:36:50 +08:00
parent 9525053624
commit 585575a738
7 changed files with 294 additions and 4 deletions

View File

@@ -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

View File

@@ -0,0 +1,41 @@
#pragma once
#include <XCEngine/UI/Style/Theme.h>
#include <cstdint>
#include <string_view>
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

View File

@@ -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() {

View File

@@ -0,0 +1,114 @@
#include <XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h>
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

View File

@@ -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

View File

@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Style/StyleTypes.h>
#include <XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h>
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

View File

@@ -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"));
}