Add editor shell interaction contract

This commit is contained in:
2026-04-07 10:16:55 +08:00
parent 558b6438cf
commit ec06340f58
11 changed files with 2312 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_ui_editor_menu_popup.cpp
test_ui_editor_panel_registry.cpp
test_ui_editor_shell_compose.cpp
test_ui_editor_shell_interaction.cpp
test_ui_editor_collection_primitives.cpp
test_ui_editor_dock_host.cpp
test_ui_editor_panel_chrome.cpp

View File

@@ -0,0 +1,545 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorShellInteraction.h>
#include <XCEditor/Core/UIEditorWorkspaceModel.h>
#include <XCEditor/Core/UIEditorWorkspaceSession.h>
#include <XCEngine/Input/InputTypes.h>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceSession;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack;
using XCEngine::UI::Editor::ResolveUIEditorShellInteractionRequest;
using XCEngine::UI::Editor::UIEditorMenuItemKind;
using XCEngine::UI::Editor::UIEditorPanelPresentationKind;
using XCEngine::UI::Editor::UIEditorPanelRegistry;
using XCEngine::UI::Editor::UIEditorResolvedMenuDescriptor;
using XCEngine::UI::Editor::UIEditorResolvedMenuItem;
using XCEngine::UI::Editor::UIEditorResolvedMenuModel;
using XCEngine::UI::Editor::UIEditorShellInteractionFrame;
using XCEngine::UI::Editor::UIEditorShellInteractionMenuButtonRequest;
using XCEngine::UI::Editor::UIEditorShellInteractionModel;
using XCEngine::UI::Editor::UIEditorShellInteractionPopupItemRequest;
using XCEngine::UI::Editor::UIEditorShellInteractionState;
using XCEngine::UI::Editor::UIEditorWorkspaceModel;
using XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel;
using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis;
using XCEngine::UI::Editor::UpdateUIEditorShellInteraction;
using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot;
using XCEngine::UI::Widgets::UIPopupDismissReason;
UIEditorPanelRegistry BuildPanelRegistry() {
UIEditorPanelRegistry registry = {};
registry.panels = {
{ "hierarchy", "Hierarchy", UIEditorPanelPresentationKind::Placeholder, true, true, false },
{ "scene", "Scene", UIEditorPanelPresentationKind::ViewportShell, false, true, false },
{ "inspector", "Inspector", UIEditorPanelPresentationKind::Placeholder, true, true, true }
};
return registry;
}
UIEditorWorkspaceModel BuildWorkspace() {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root",
UIEditorWorkspaceSplitAxis::Horizontal,
0.28f,
BuildUIEditorWorkspacePanel("hierarchy-node", "hierarchy", "Hierarchy", true),
BuildUIEditorWorkspaceSplit(
"main",
UIEditorWorkspaceSplitAxis::Horizontal,
0.72f,
BuildUIEditorWorkspacePanel("scene-node", "scene", "Scene"),
BuildUIEditorWorkspacePanel("inspector-node", "inspector", "Inspector", true)));
workspace.activePanelId = "scene";
return workspace;
}
UIEditorResolvedMenuModel BuildResolvedMenuModel() {
UIEditorResolvedMenuModel model = {};
UIEditorResolvedMenuItem fileWorkspaceTools = {};
fileWorkspaceTools.kind = UIEditorMenuItemKind::Submenu;
fileWorkspaceTools.itemId = "file-workspace-tools";
fileWorkspaceTools.label = "Workspace Tools";
fileWorkspaceTools.enabled = true;
fileWorkspaceTools.children = {
UIEditorResolvedMenuItem{
UIEditorMenuItemKind::Command,
"file-show-inspector",
"Show Inspector",
"workspace.show_inspector",
"Show Inspector",
"Ctrl+I",
true,
false
},
UIEditorResolvedMenuItem{
UIEditorMenuItemKind::Command,
"file-reset-layout",
"Reset Layout",
"workspace.reset_layout",
"Reset Layout",
"Ctrl+R",
true,
false
}
};
UIEditorResolvedMenuDescriptor fileMenu = {};
fileMenu.menuId = "file";
fileMenu.label = "File";
fileMenu.items = {
fileWorkspaceTools,
UIEditorResolvedMenuItem{
UIEditorMenuItemKind::Command,
"file-close",
"Close",
"workspace.close",
"Close",
"Ctrl+W",
true,
false
}
};
UIEditorResolvedMenuDescriptor windowMenu = {};
windowMenu.menuId = "window";
windowMenu.label = "Window";
windowMenu.items = {
UIEditorResolvedMenuItem{
UIEditorMenuItemKind::Command,
"window-focus-inspector",
"Focus Inspector",
"workspace.focus_inspector",
"Focus Inspector",
"Ctrl+P",
true,
false
}
};
model.menus = { fileMenu, windowMenu };
return model;
}
UIEditorShellInteractionModel BuildInteractionModel() {
UIEditorShellInteractionModel model = {};
model.resolvedMenuModel = BuildResolvedMenuModel();
model.statusSegments = {
{ "mode", "Scene", UIEditorStatusBarSlot::Leading, {}, true, true, 78.0f }
};
UIEditorWorkspacePanelPresentationModel presentation = {};
presentation.panelId = "scene";
presentation.kind = UIEditorPanelPresentationKind::ViewportShell;
presentation.viewportShellModel.spec.chrome.title = "Scene";
presentation.viewportShellModel.frame.statusText = "Viewport";
model.workspacePresentations = { presentation };
return model;
}
const UIEditorShellInteractionMenuButtonRequest* FindMenuButton(
const UIEditorShellInteractionFrame& frame,
std::string_view menuId) {
for (const auto& button : frame.request.menuButtons) {
if (button.menuId == menuId) {
return &button;
}
}
return nullptr;
}
const UIEditorShellInteractionPopupItemRequest* FindPopupItem(
const UIEditorShellInteractionFrame& frame,
std::string_view itemId) {
for (const auto& popup : frame.request.popupRequests) {
for (const auto& item : popup.itemRequests) {
if (item.itemId == itemId) {
return &item;
}
}
}
return nullptr;
}
UIPoint RectCenter(const UIRect& rect) {
return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f);
}
UIInputEvent MakePointerMove(const UIPoint& position) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerMove;
event.position = position;
return event;
}
UIInputEvent MakeLeftPointerDown(const UIPoint& position) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerButtonDown;
event.position = position;
event.pointerButton = UIPointerButton::Left;
return event;
}
UIInputEvent MakeKeyDown(KeyCode keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(keyCode);
return event;
}
UIInputEvent MakeFocusLost() {
UIInputEvent event = {};
event.type = UIInputEventType::FocusLost;
return event;
}
} // namespace
TEST(UIEditorShellInteractionTest, ClickMenuBarRootOpensSingleRootPopup) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
const auto request = ResolveUIEditorShellInteractionRequest(
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{},
state);
ASSERT_EQ(request.menuButtons.size(), 2u);
const auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(RectCenter(request.menuButtons.front().rect)) });
EXPECT_TRUE(state.menuSession.HasOpenMenu());
EXPECT_EQ(frame.openRootMenuId, "file");
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
EXPECT_EQ(frame.request.popupRequests.front().menuId, "file");
EXPECT_EQ(state.composeState.menuBarState.openIndex, 0u);
}
TEST(UIEditorShellInteractionTest, HoverOtherRootSwitchesRootPopup) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* windowButton = FindMenuButton(frame, "window");
ASSERT_NE(windowButton, nullptr);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakePointerMove(RectCenter(windowButton->rect)) });
EXPECT_TRUE(state.menuSession.IsMenuOpen("window"));
EXPECT_EQ(frame.openRootMenuId, "window");
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
EXPECT_EQ(frame.request.popupRequests.front().menuId, "window");
}
TEST(UIEditorShellInteractionTest, HoverSubmenuOpensChildPopupAndEscapeCollapsesChain) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
ASSERT_NE(submenuItem, nullptr);
ASSERT_TRUE(submenuItem->hasSubmenu);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakePointerMove(RectCenter(submenuItem->rect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 2u);
EXPECT_EQ(frame.request.popupRequests[1].sourceItemId, "file-workspace-tools");
EXPECT_NE(frame.popupFrames.front().popupState.submenuOpenIndex, UIEditorMenuPopupInvalidIndex);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeKeyDown(KeyCode::Escape) });
EXPECT_EQ(frame.openRootMenuId, "file");
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeKeyDown(KeyCode::Escape) });
EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty());
}
TEST(UIEditorShellInteractionTest, ClickCommandReturnsDispatchHookAndClosesMenu) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* commandItem = FindPopupItem(frame, "file-close");
ASSERT_NE(commandItem, nullptr);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(RectCenter(commandItem->rect)) });
EXPECT_TRUE(frame.result.commandTriggered);
EXPECT_EQ(frame.result.commandId, "workspace.close");
EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty());
}
TEST(UIEditorShellInteractionTest, PointerDownOutsideDismissesWholeMenuChain) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
ASSERT_TRUE(state.menuSession.HasOpenMenu());
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(900.0f, 500.0f)) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty());
}
TEST(UIEditorShellInteractionTest, DisabledSubmenuDoesNotOpenChildPopup) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
auto model = BuildInteractionModel();
model.resolvedMenuModel.menus[0].items[0].enabled = false;
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
ASSERT_NE(submenuItem, nullptr);
ASSERT_TRUE(submenuItem->hasSubmenu);
EXPECT_FALSE(submenuItem->enabled);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakePointerMove(RectCenter(submenuItem->rect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
EXPECT_EQ(frame.request.popupRequests.front().menuId, "file");
EXPECT_TRUE(state.menuSession.HasOpenMenu());
}
TEST(UIEditorShellInteractionTest, FocusLostDismissesWholeMenuChain) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
ASSERT_NE(submenuItem, nullptr);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakePointerMove(RectCenter(submenuItem->rect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 2u);
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeFocusLost() });
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.menuMutation.changed);
EXPECT_EQ(frame.result.menuMutation.dismissReason, UIPopupDismissReason::FocusLoss);
EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty());
}
TEST(UIEditorShellInteractionTest, OpenMenuConsumesWorkspacePointerDownForThatFrame) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
ASSERT_TRUE(state.menuSession.HasOpenMenu());
ASSERT_FALSE(frame.shellFrame.workspaceFrame.viewportFrames.empty());
const UIRect inputRect =
frame.shellFrame.workspaceFrame.viewportFrames.front().viewportShellFrame.slotLayout.inputRect;
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(RectCenter(inputRect)) });
EXPECT_FALSE(frame.shellFrame.workspaceFrame.viewportFrames.front()
.viewportShellFrame.inputFrame.pointerPressedInside);
EXPECT_FALSE(state.menuSession.HasOpenMenu());
}
TEST(UIEditorShellInteractionTest, InvalidResolvedMenuStateClosesInvisibleModalChain) {
const auto registry = BuildPanelRegistry();
const auto workspace = BuildWorkspace();
const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace);
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
ASSERT_TRUE(state.menuSession.HasOpenMenu());
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
auto updatedModel = model;
updatedModel.resolvedMenuModel.menus.erase(updatedModel.resolvedMenuModel.menus.begin());
frame = UpdateUIEditorShellInteraction(
state,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
registry,
workspace,
session,
updatedModel,
{});
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.menuMutation.changed);
EXPECT_EQ(frame.result.menuMutation.dismissReason, UIPopupDismissReason::Programmatic);
EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty());
}