feat(xcui): add tab strip and workspace compose foundations

This commit is contained in:
2026-04-06 04:27:54 +08:00
parent 3540dbc94d
commit b14a4fb7bb
27 changed files with 2075 additions and 41 deletions

View File

@@ -1,6 +1,10 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(editor_ui_layout_integration_tests
DEPENDS
editor_ui_layout_splitter_resize_validation
editor_ui_layout_tab_strip_selection_validation
editor_ui_layout_workspace_compose_validation
)

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_layout_tab_strip_selection_validation WIN32
main.cpp
${EDITOR_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES}
)
target_include_directories(editor_ui_layout_tab_strip_selection_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_layout_tab_strip_selection_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_layout_tab_strip_selection_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_layout_tab_strip_selection_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_layout_tab_strip_selection_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_layout_tab_strip_selection_validation PROPERTIES
OUTPUT_NAME "XCUIEditorLayoutTabStripSelectionValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,46 @@
<View
name="EditorTabStripSelectionValidation"
theme="../../shared/themes/editor_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能TabStrip 选择切换"
subtitle="只验证 tab 头部点击、键盘导航,以及只渲染 selected tab 内容"
tone="accent"
height="156">
<Column gap="6">
<Text text="1. 点击 Scene / Console / Inspector 任一 tab下方内容区应立即切换旧内容不应继续显示。" />
<Text text="2. 先点击一个 tab 让它获得 focus再按 Left / Right / Home / Endselected tab 应变化。" />
<Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigatedFocused 应落在当前 tab。" />
<Text text="4. 这个场景只检查 TabStrip 基础能力,不检查 editor 业务面板。" />
</Column>
</Card>
<TabStrip
id="editor-workspace-tabs"
tabHeaderHeight="34"
tabMinWidth="96"
height="fill">
<Tab id="tab-scene" label="Scene" selected="true">
<Card title="Scene Tab Content" subtitle="selected = Scene" height="fill">
<Column gap="8">
<Text text="这里应该只显示 Scene 的内容占位。" />
</Column>
</Card>
</Tab>
<Tab id="tab-console" label="Console">
<Card title="Console Tab Content" subtitle="selected = Console" height="fill">
<Column gap="8">
<Text text="切换到 Console 后Scene 内容应消失。" />
</Column>
</Card>
</Tab>
<Tab id="tab-inspector" label="Inspector">
<Card title="Inspector Tab Content" subtitle="selected = Inspector" height="fill">
<Column gap="8">
<Text text="按 Home / End 时,也应只保留当前 selected 内容。" />
</Column>
</Card>
</Tab>
</TabStrip>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.tab_strip_selection");
}

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_layout_workspace_compose_validation WIN32
main.cpp
${EDITOR_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES}
)
target_include_directories(editor_ui_layout_workspace_compose_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_layout_workspace_compose_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_layout_workspace_compose_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_layout_workspace_compose_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_layout_workspace_compose_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_layout_workspace_compose_validation PROPERTIES
OUTPUT_NAME "XCUIEditorLayoutWorkspaceComposeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,94 @@
<View
name="EditorWorkspaceComposeValidation"
theme="../../shared/themes/editor_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Workspace compose"
subtitle="只检查 editor 工作区的 split + tab + placeholder 组合,不检查任何业务面板"
tone="accent"
height="156">
<Column gap="6">
<Text text="1. 先看布局:左、中、右、下四个区域应边界清晰,没有重叠、穿透或错位。" />
<Text text="2. 拖拽 workspace-left-right 和 workspace-top-bottom各区域尺寸应实时变化并被最小尺寸 clamp 住。" />
<Text text="3. 点击中间的 Document A / B / C只应显示当前 selected tab 的 placeholder 内容。" />
<Text text="4. 这个场景只验证工作区组合基础,不代表 Hierarchy / Inspector / Console 已开始实现。" />
</Column>
</Card>
<Splitter
id="workspace-top-bottom"
axis="vertical"
splitRatio="0.76"
splitterSize="10"
splitterHitSize="18"
primaryMin="320"
secondaryMin="120"
height="fill">
<Splitter
id="workspace-left-right"
axis="horizontal"
splitRatio="0.24"
splitterSize="10"
splitterHitSize="18"
primaryMin="160"
secondaryMin="420"
height="fill">
<Card id="workspace-left-slot" title="Navigation Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是左侧 placeholder slot只检查 pane compose。" />
</Column>
</Card>
<Splitter
id="workspace-center-right"
axis="horizontal"
splitRatio="0.70"
splitterSize="10"
splitterHitSize="18"
primaryMin="260"
secondaryMin="180"
height="fill">
<TabStrip
id="workspace-document-tabs"
tabHeaderHeight="34"
tabMinWidth="112"
height="fill">
<Tab id="tab-document-a" label="Document A" selected="true">
<Card title="Primary Document Slot" subtitle="selected = Document A" height="fill">
<Column gap="8">
<Text text="这里应只显示 Document A 的 placeholder 内容。" />
</Column>
</Card>
</Tab>
<Tab id="tab-document-b" label="Document B">
<Card title="Secondary Document Slot" subtitle="selected = Document B" height="fill">
<Column gap="8">
<Text text="切换到 Document B 后A 的内容应消失。" />
</Column>
</Card>
</Tab>
<Tab id="tab-document-c" label="Document C">
<Card title="Tertiary Document Slot" subtitle="selected = Document C" height="fill">
<Column gap="8">
<Text text="这里只是第三个 placeholder不代表真实面板业务。" />
</Column>
</Card>
</Tab>
</TabStrip>
<Card id="workspace-right-slot" title="Details Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是右侧 placeholder slot只检查嵌套 split 稳定性。" />
</Column>
</Card>
</Splitter>
</Splitter>
<Card id="workspace-bottom-slot" title="Output Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是底部 placeholder slot用来检查上下 split compose。" />
</Column>
</Card>
</Splitter>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.workspace_compose");
}

View File

@@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 4> scenarios = { {
const std::array<EditorValidationScenario, 6>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 6> scenarios = { {
{
"editor.input.keyboard_focus",
UIValidationDomain::Editor,
@@ -61,6 +61,24 @@ const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
},
{
"editor.layout.tab_strip_selection",
UIValidationDomain::Editor,
"layout",
"Editor Layout | TabStrip Selection",
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/captures")
},
{
"editor.layout.workspace_compose",
UIValidationDomain::Editor,
"layout",
"Editor Layout | Workspace Compose",
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/captures")
}
} };
return scenarios;

View File

@@ -2,6 +2,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_input_modifier_tracker.cpp
test_editor_validation_registry.cpp
test_structured_editor_shell.cpp
test_ui_editor_workspace_model.cpp
# Migration bridge: editor-facing XCUI primitive tests still reuse the
# legacy source location until they are relocated under tests/UI/Editor/unit.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp

View File

@@ -17,19 +17,27 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti
const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
const auto* tabStripScenario = FindEditorValidationScenario("editor.layout.tab_strip_selection");
const auto* workspaceScenario = FindEditorValidationScenario("editor.layout.workspace_compose");
ASSERT_NE(pointerScenario, nullptr);
ASSERT_NE(keyboardScenario, nullptr);
ASSERT_NE(shortcutScenario, nullptr);
ASSERT_NE(splitterScenario, nullptr);
ASSERT_NE(tabStripScenario, nullptr);
ASSERT_NE(workspaceScenario, nullptr);
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(pointerScenario->categoryId, "input");
EXPECT_EQ(keyboardScenario->categoryId, "input");
EXPECT_EQ(shortcutScenario->categoryId, "input");
EXPECT_EQ(splitterScenario->categoryId, "layout");
EXPECT_EQ(tabStripScenario->categoryId, "layout");
EXPECT_EQ(workspaceScenario->categoryId, "layout");
EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
@@ -38,6 +46,10 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath));
}
TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {

View File

@@ -0,0 +1,143 @@
#include <gtest/gtest.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <algorithm>
#include <string>
#include <vector>
namespace {
using XCEngine::NewEditor::BuildUIEditorWorkspacePanel;
using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit;
using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack;
using XCEngine::NewEditor::CollectUIEditorWorkspaceVisiblePanels;
using XCEngine::NewEditor::ContainsUIEditorWorkspacePanel;
using XCEngine::NewEditor::FindUIEditorWorkspaceActivePanel;
using XCEngine::NewEditor::TryActivateUIEditorWorkspacePanel;
using XCEngine::NewEditor::UIEditorWorkspaceModel;
using XCEngine::NewEditor::UIEditorWorkspaceNodeKind;
using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis;
using XCEngine::NewEditor::UIEditorWorkspaceValidationCode;
using XCEngine::NewEditor::ValidateUIEditorWorkspace;
std::vector<std::string> CollectVisiblePanelIds(const UIEditorWorkspaceModel& workspace) {
const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace);
std::vector<std::string> ids = {};
ids.reserve(panels.size());
for (const auto& panel : panels) {
ids.push_back(panel.panelId);
}
return ids;
}
} // namespace
TEST(UIEditorWorkspaceModelTest, ValidationRejectsSplitWithoutExactlyTwoChildren) {
UIEditorWorkspaceModel workspace = {};
workspace.root.kind = UIEditorWorkspaceNodeKind::Split;
workspace.root.nodeId = "root-split";
workspace.root.splitAxis = UIEditorWorkspaceSplitAxis::Horizontal;
workspace.root.splitRatio = 0.5f;
workspace.root.children.push_back(
BuildUIEditorWorkspacePanel("panel-a-node", "panel-a", "Panel A", true));
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::InvalidSplitChildCount);
}
TEST(UIEditorWorkspaceModelTest, ValidationRejectsTabStackWithNestedSplitChild) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceTabStack(
"root-tabs",
{
BuildUIEditorWorkspacePanel("panel-a-node", "panel-a", "Panel A", true),
BuildUIEditorWorkspaceSplit(
"nested-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.5f,
BuildUIEditorWorkspacePanel("panel-b-node", "panel-b", "Panel B", true),
BuildUIEditorWorkspacePanel("panel-c-node", "panel-c", "Panel C", true))
},
0u);
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::NonPanelTabChild);
}
TEST(UIEditorWorkspaceModelTest, VisiblePanelsOnlyIncludeSelectedTabsAcrossSplitTree) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.68f,
BuildUIEditorWorkspacePanel("left-panel-node", "left-panel", "Left Panel", true),
BuildUIEditorWorkspaceSplit(
"right-split",
UIEditorWorkspaceSplitAxis::Vertical,
0.74f,
BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
},
1u),
BuildUIEditorWorkspacePanel("bottom-panel-node", "bottom-panel", "Bottom Panel", true)));
workspace.activePanelId = "doc-b";
const auto validation = ValidateUIEditorWorkspace(workspace);
ASSERT_TRUE(validation.IsValid()) << validation.message;
const auto visibleIds = CollectVisiblePanelIds(workspace);
ASSERT_EQ(visibleIds.size(), 3u);
EXPECT_EQ(visibleIds[0], "left-panel");
EXPECT_EQ(visibleIds[1], "doc-b");
EXPECT_EQ(visibleIds[2], "bottom-panel");
const auto* activePanel = FindUIEditorWorkspaceActivePanel(workspace);
ASSERT_NE(activePanel, nullptr);
EXPECT_EQ(activePanel->panelId, "doc-b");
}
TEST(UIEditorWorkspaceModelTest, ActivatingHiddenPanelSelectsContainingTabAndUpdatesActivePanel) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.62f,
BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true),
BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true)
},
0u),
BuildUIEditorWorkspacePanel("details-node", "details", "Details", true));
ASSERT_TRUE(ContainsUIEditorWorkspacePanel(workspace, "doc-b"));
ASSERT_TRUE(TryActivateUIEditorWorkspacePanel(workspace, "doc-b"));
EXPECT_EQ(workspace.activePanelId, "doc-b");
ASSERT_EQ(workspace.root.children.front().selectedTabIndex, 1u);
const auto visibleIds = CollectVisiblePanelIds(workspace);
ASSERT_EQ(visibleIds.size(), 2u);
EXPECT_EQ(visibleIds[0], "doc-b");
EXPECT_EQ(visibleIds[1], "details");
}
TEST(UIEditorWorkspaceModelTest, ValidationRejectsActivePanelHiddenByCurrentTabSelection) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
},
0u);
workspace.activePanelId = "doc-b";
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::InvalidActivePanelId);
}