Harden shell interaction modal recovery

This commit is contained in:
2026-04-07 12:00:44 +08:00
parent d3377708d2
commit 3def94d0e0
2 changed files with 213 additions and 1 deletions

View File

@@ -137,7 +137,10 @@ const std::vector<UIEditorResolvedMenuItem>* ResolvePopupItems(
const UIEditorResolvedMenuItem* item = const UIEditorResolvedMenuItem* item =
FindResolvedMenuItemRecursive(menu->items, popupState.itemId); FindResolvedMenuItemRecursive(menu->items, popupState.itemId);
if (item == nullptr || item->kind != UIEditorMenuItemKind::Submenu) { if (item == nullptr ||
item->kind != UIEditorMenuItemKind::Submenu ||
!item->enabled ||
item->children.empty()) {
return nullptr; return nullptr;
} }

View File

@@ -18,6 +18,7 @@ using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack;
using XCEngine::UI::Editor::FindUIEditorWorkspacePanelPresentationState;
using XCEngine::UI::Editor::ResolveUIEditorShellInteractionRequest; using XCEngine::UI::Editor::ResolveUIEditorShellInteractionRequest;
using XCEngine::UI::Editor::UIEditorCommandDispatchStatus; using XCEngine::UI::Editor::UIEditorCommandDispatchStatus;
using XCEngine::UI::Editor::UIEditorCommandDispatcher; using XCEngine::UI::Editor::UIEditorCommandDispatcher;
@@ -479,6 +480,69 @@ TEST(UIEditorShellInteractionTest, DisabledSubmenuDoesNotOpenChildPopup) {
EXPECT_TRUE(state.menuSession.HasOpenMenu()); EXPECT_TRUE(state.menuSession.HasOpenMenu());
} }
TEST(UIEditorShellInteractionTest, HoverDisabledSubmenuSiblingClosesOnlyOpenChildChain) {
auto controller = BuildController();
auto model = BuildInteractionModel();
UIEditorResolvedMenuItem disabledSibling = {};
disabledSibling.kind = UIEditorMenuItemKind::Submenu;
disabledSibling.itemId = "file-disabled-tools";
disabledSibling.label = "Disabled Tools";
disabledSibling.enabled = false;
disabledSibling.children = {
UIEditorResolvedMenuItem{
UIEditorMenuItemKind::Command,
"file-disabled-child",
"Disabled Child",
"workspace.close",
"Disabled Child",
{},
true,
false
}
};
model.resolvedMenuModel.menus[0].items.push_back(disabledSibling);
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
const auto* disabledSiblingItem = FindPopupItem(frame, "file-disabled-tools");
ASSERT_NE(submenuItem, nullptr);
ASSERT_NE(disabledSiblingItem, nullptr);
const UIRect submenuRect = submenuItem->rect;
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakePointerMove(RectCenter(submenuRect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 2u);
const std::string childPopupId = frame.request.popupRequests[1].popupId;
disabledSiblingItem = FindPopupItem(frame, "file-disabled-tools");
ASSERT_NE(disabledSiblingItem, nullptr);
const UIRect disabledSiblingRect = disabledSiblingItem->rect;
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakePointerMove(RectCenter(disabledSiblingRect)) });
EXPECT_TRUE(frame.result.menuMutation.changed);
ASSERT_EQ(frame.result.menuMutation.closedPopupIds.size(), 1u);
EXPECT_EQ(frame.result.menuMutation.closedPopupIds.front(), childPopupId);
EXPECT_EQ(frame.openRootMenuId, "file");
ASSERT_EQ(frame.request.popupRequests.size(), 1u);
EXPECT_EQ(frame.request.popupRequests.front().menuId, "file");
EXPECT_TRUE(state.menuSession.HasOpenMenu());
}
TEST(UIEditorShellInteractionTest, FocusLostDismissesWholeMenuChain) { TEST(UIEditorShellInteractionTest, FocusLostDismissesWholeMenuChain) {
auto controller = BuildController(); auto controller = BuildController();
const auto model = BuildInteractionModel(); const auto model = BuildInteractionModel();
@@ -515,6 +579,77 @@ TEST(UIEditorShellInteractionTest, FocusLostDismissesWholeMenuChain) {
EXPECT_TRUE(frame.request.popupRequests.empty()); EXPECT_TRUE(frame.request.popupRequests.empty());
} }
TEST(UIEditorShellInteractionTest, FocusLostWhileMenuOpenReleasesExistingViewportCapture) {
auto controller = BuildController();
const auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{});
const auto* fileButton = FindMenuButton(frame, "file");
ASSERT_NE(fileButton, nullptr);
const UIRect fileButtonRect = fileButton->rect;
ASSERT_FALSE(frame.shellFrame.workspaceFrame.viewportFrames.empty());
const UIRect inputRect =
frame.shellFrame.workspaceFrame.viewportFrames.front().viewportShellFrame.slotLayout.inputRect;
const UIPoint center = RectCenter(inputRect);
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{
MakePointerMove(center),
MakeLeftPointerDown(center)
});
ASSERT_TRUE(frame.result.requestPointerCapture);
const auto* sceneStateBeforeMenu = FindUIEditorWorkspacePanelPresentationState(
state.workspaceInteractionState.composeState,
"scene");
ASSERT_NE(sceneStateBeforeMenu, nullptr);
ASSERT_TRUE(sceneStateBeforeMenu->viewportShellState.inputBridgeState.captured);
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakeLeftPointerDown(RectCenter(fileButtonRect)) });
ASSERT_TRUE(state.menuSession.HasOpenMenu());
const auto* sceneStateWithMenu = FindUIEditorWorkspacePanelPresentationState(
state.workspaceInteractionState.composeState,
"scene");
ASSERT_NE(sceneStateWithMenu, nullptr);
ASSERT_TRUE(sceneStateWithMenu->viewportShellState.inputBridgeState.captured);
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakeFocusLost() });
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.menuMutation.changed);
EXPECT_EQ(frame.result.menuMutation.dismissReason, UIPopupDismissReason::FocusLoss);
EXPECT_TRUE(frame.result.releasePointerCapture);
EXPECT_TRUE(frame.result.workspaceResult.releasePointerCapture);
EXPECT_EQ(frame.result.viewportPanelId, "scene");
EXPECT_TRUE(frame.result.viewportInputFrame.focusLost);
EXPECT_TRUE(frame.result.viewportInputFrame.captureEnded);
EXPECT_FALSE(state.menuSession.HasOpenMenu());
const auto* sceneStateAfterFocusLoss = FindUIEditorWorkspacePanelPresentationState(
state.workspaceInteractionState.composeState,
"scene");
ASSERT_NE(sceneStateAfterFocusLoss, nullptr);
EXPECT_FALSE(sceneStateAfterFocusLoss->viewportShellState.inputBridgeState.captured);
}
TEST(UIEditorShellInteractionTest, OpenMenuConsumesWorkspacePointerDownForThatFrame) { TEST(UIEditorShellInteractionTest, OpenMenuConsumesWorkspacePointerDownForThatFrame) {
auto controller = BuildController(); auto controller = BuildController();
const auto model = BuildInteractionModel(); const auto model = BuildInteractionModel();
@@ -648,3 +783,77 @@ TEST(UIEditorShellInteractionTest, InvalidResolvedMenuStateClosesInvisibleModalC
EXPECT_FALSE(state.menuSession.HasOpenMenu()); EXPECT_FALSE(state.menuSession.HasOpenMenu());
EXPECT_TRUE(frame.request.popupRequests.empty()); EXPECT_TRUE(frame.request.popupRequests.empty());
} }
TEST(UIEditorShellInteractionTest, OpenedSubmenuClosesWhenSourceItemBecomesDisabled) {
auto controller = BuildController();
auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
ASSERT_NE(submenuItem, nullptr);
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakePointerMove(RectCenter(submenuItem->rect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 2u);
model.resolvedMenuModel.menus[0].items[0].enabled = false;
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{});
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());
}
TEST(UIEditorShellInteractionTest, OpenedSubmenuClosesWhenSourceItemLosesChildren) {
auto controller = BuildController();
auto model = BuildInteractionModel();
UIEditorShellInteractionState state = {};
auto frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) });
const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools");
ASSERT_NE(submenuItem, nullptr);
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{ MakePointerMove(RectCenter(submenuItem->rect)) });
ASSERT_EQ(frame.request.popupRequests.size(), 2u);
model.resolvedMenuModel.menus[0].items[0].children.clear();
frame = UpdateUIEditorShellInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 1280.0f, 720.0f),
model,
{});
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());
}