fix(editor_ui): resolve splitter and tab drag gesture conflict

This commit is contained in:
2026-04-11 20:33:53 +08:00
parent 0a015b52ca
commit 3e55f8c204
4 changed files with 134 additions and 5 deletions

View File

@@ -53,6 +53,10 @@ struct UIEditorTabStripInteractionFrame {
bool focused = false; bool focused = false;
}; };
// Clears transient pointer-driven state while preserving selection/navigation.
void ClearUIEditorTabStripTransientInteraction(
UIEditorTabStripInteractionState& state);
UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction(
UIEditorTabStripInteractionState& state, UIEditorTabStripInteractionState& state,
std::string& selectedTabId, std::string& selectedTabId,

View File

@@ -363,6 +363,14 @@ void CancelTabReorder(
} // namespace } // namespace
void ClearUIEditorTabStripTransientInteraction(
UIEditorTabStripInteractionState& state) {
state.pressedTarget = {};
state.hasPointerPosition = false;
ClearHoverState(state);
ClearReorderState(state);
}
UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction(
UIEditorTabStripInteractionState& state, UIEditorTabStripInteractionState& state,
std::string& selectedTabId, std::string& selectedTabId,

View File

@@ -192,6 +192,14 @@ bool IsPointInsideRect(
point.y <= rect.y + rect.height; point.y <= rect.y + rect.height;
} }
void ClearAllTabStripTransientInteractions(
UIEditorDockHostInteractionState& state) {
for (UIEditorDockHostTabStripInteractionEntry& entry : state.tabStripInteractions) {
ClearUIEditorTabStripTransientInteraction(entry.state);
}
SyncDockHostTabStripVisualStates(state);
}
void ClearTabDockDragState(UIEditorDockHostInteractionState& state) { void ClearTabDockDragState(UIEditorDockHostInteractionState& state) {
state.activeTabDragNodeId.clear(); state.activeTabDragNodeId.clear();
state.activeTabDragPanelId.clear(); state.activeTabDragPanelId.clear();
@@ -510,9 +518,19 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
state.hasPointerPosition = false; state.hasPointerPosition = false;
} }
const UIEditorDockHostHitTarget pointerHitTarget =
state.hasPointerPosition
? HitTestUIEditorDockHost(layout, state.pointerPosition)
: UIEditorDockHostHitTarget {};
const bool splitterOwnsPointerDown =
event.type == UIInputEventType::PointerButtonDown &&
event.pointerButton == ::XCEngine::UI::UIPointerButton::Left &&
pointerHitTarget.kind == UIEditorDockHostHitTargetKind::SplitterHandle;
UIEditorDockHostInteractionResult eventResult = {}; UIEditorDockHostInteractionResult eventResult = {};
const DockHostTabStripEventResult tabStripResult = const DockHostTabStripEventResult tabStripResult =
ShouldDispatchTabStripEvent(event, state.splitterDragState.active) !splitterOwnsPointerDown &&
ShouldDispatchTabStripEvent(event, state.splitterDragState.active)
? ProcessTabStripEvent(state, layout, event, metrics) ? ProcessTabStripEvent(state, layout, event, metrics)
: DockHostTabStripEventResult {}; : DockHostTabStripEventResult {};
eventResult.requestPointerCapture = tabStripResult.requestPointerCapture; eventResult.requestPointerCapture = tabStripResult.requestPointerCapture;
@@ -541,6 +559,7 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
if (state.splitterDragState.active) { if (state.splitterDragState.active) {
EndUISplitterDrag(state.splitterDragState); EndUISplitterDrag(state.splitterDragState);
state.dockHostState.activeSplitterNodeId.clear(); state.dockHostState.activeSplitterNodeId.clear();
ClearAllTabStripTransientInteractions(state);
eventResult.consumed = true; eventResult.consumed = true;
eventResult.releasePointerCapture = true; eventResult.releasePointerCapture = true;
} }
@@ -609,11 +628,11 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
break; break;
} }
if (state.dockHostState.hoveredTarget.kind == if (pointerHitTarget.kind ==
UIEditorDockHostHitTargetKind::SplitterHandle) { UIEditorDockHostHitTargetKind::SplitterHandle) {
const auto* splitter = FindUIEditorDockHostSplitterLayout( const auto* splitter = FindUIEditorDockHostSplitterLayout(
layout, layout,
state.dockHostState.hoveredTarget.nodeId); pointerHitTarget.nodeId);
if (splitter != nullptr && if (splitter != nullptr &&
BeginUISplitterDrag( BeginUISplitterDrag(
1u, 1u,
@@ -626,11 +645,12 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
splitter->metrics, splitter->metrics,
state.pointerPosition, state.pointerPosition,
state.splitterDragState)) { state.splitterDragState)) {
ClearAllTabStripTransientInteractions(state);
state.dockHostState.activeSplitterNodeId = splitter->nodeId; state.dockHostState.activeSplitterNodeId = splitter->nodeId;
state.dockHostState.focused = true; state.dockHostState.focused = true;
eventResult.consumed = true; eventResult.consumed = true;
eventResult.requestPointerCapture = true; eventResult.requestPointerCapture = true;
eventResult.hitTarget = state.dockHostState.hoveredTarget; eventResult.hitTarget = pointerHitTarget;
eventResult.activeSplitterNodeId = splitter->nodeId; eventResult.activeSplitterNodeId = splitter->nodeId;
} }
} else { } else {
@@ -643,7 +663,10 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
state.dockHostState.hoveredTarget.kind != state.dockHostState.hoveredTarget.kind !=
UIEditorDockHostHitTargetKind::None; UIEditorDockHostHitTargetKind::None;
eventResult.consumed = state.dockHostState.focused; eventResult.consumed = state.dockHostState.focused;
eventResult.hitTarget = state.dockHostState.hoveredTarget; eventResult.hitTarget =
pointerHitTarget.kind != UIEditorDockHostHitTargetKind::None
? pointerHitTarget
: state.dockHostState.hoveredTarget;
} }
} }
break; break;
@@ -668,6 +691,7 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
UIEditorWorkspaceLayoutOperationStatus::Changed; UIEditorWorkspaceLayoutOperationStatus::Changed;
} }
EndUISplitterDrag(state.splitterDragState); EndUISplitterDrag(state.splitterDragState);
ClearAllTabStripTransientInteractions(state);
eventResult.consumed = true; eventResult.consumed = true;
eventResult.releasePointerCapture = true; eventResult.releasePointerCapture = true;
eventResult.activeSplitterNodeId = state.dockHostState.activeSplitterNodeId; eventResult.activeSplitterNodeId = state.dockHostState.activeSplitterNodeId;

View File

@@ -143,6 +143,18 @@ const UIEditorDockHostTabStackLayout* FindTabStackByNodeId(
return nullptr; return nullptr;
} }
const XCEngine::UI::Editor::Widgets::UIEditorDockHostSplitterLayout* FindSplitterByNodeId(
const UIEditorDockHostLayout& layout,
std::string_view nodeId) {
for (const auto& splitter : layout.splitters) {
if (splitter.nodeId == nodeId) {
return &splitter;
}
}
return nullptr;
}
} // namespace } // namespace
TEST(UIEditorDockHostInteractionTest, SplitterDragUpdatesWorkspaceSplitRatio) { TEST(UIEditorDockHostInteractionTest, SplitterDragUpdatesWorkspaceSplitRatio) {
@@ -216,6 +228,87 @@ TEST(UIEditorDockHostInteractionTest, FocusLostWhileDraggingSplitterRequestsPoin
EXPECT_TRUE(state.dockHostState.activeSplitterNodeId.empty()); EXPECT_TRUE(state.dockHostState.activeSplitterNodeId.empty());
} }
TEST(UIEditorDockHostInteractionTest, SplitterGestureDoesNotLeaveGhostTabDragWhenHitZoneOverlapsTabHeader) {
auto controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
UIEditorDockHostInteractionState state = {};
auto frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{});
const auto* consoleStack = FindTabStackByNodeId(frame.layout, "console-node");
const auto* rightSplitter = FindSplitterByNodeId(frame.layout, "right-split");
ASSERT_NE(consoleStack, nullptr);
ASSERT_NE(rightSplitter, nullptr);
const UIRect overlapRect(
(std::max)(consoleStack->tabStripLayout.headerRect.x, rightSplitter->handleHitRect.x),
(std::max)(consoleStack->tabStripLayout.headerRect.y, rightSplitter->handleHitRect.y),
(std::min)(
consoleStack->tabStripLayout.headerRect.x + consoleStack->tabStripLayout.headerRect.width,
rightSplitter->handleHitRect.x + rightSplitter->handleHitRect.width) -
(std::max)(consoleStack->tabStripLayout.headerRect.x, rightSplitter->handleHitRect.x),
(std::min)(
consoleStack->tabStripLayout.headerRect.y + consoleStack->tabStripLayout.headerRect.height,
rightSplitter->handleHitRect.y + rightSplitter->handleHitRect.height) -
(std::max)(consoleStack->tabStripLayout.headerRect.y, rightSplitter->handleHitRect.y));
ASSERT_GT(overlapRect.width, 0.0f);
ASSERT_GT(overlapRect.height, 0.0f);
const UIPoint overlapPoint = RectCenter(overlapRect);
const UIPoint splitterDragPoint(overlapPoint.x, overlapPoint.y + 24.0f);
const UIPoint probeMovePoint(
consoleStack->tabStripLayout.headerRect.x + 8.0f,
consoleStack->tabStripLayout.headerRect.y + consoleStack->tabStripLayout.headerRect.height * 0.5f);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerMove(overlapPoint.x, overlapPoint.y) });
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerDown(overlapPoint.x, overlapPoint.y) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.requestPointerCapture);
EXPECT_EQ(frame.result.activeSplitterNodeId, "right-split");
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerMove(splitterDragPoint.x, splitterDragPoint.y) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.layoutChanged);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerUp(splitterDragPoint.x, splitterDragPoint.y) });
EXPECT_TRUE(frame.result.releasePointerCapture);
EXPECT_TRUE(state.dockHostState.activeSplitterNodeId.empty());
EXPECT_TRUE(state.activeTabDragNodeId.empty());
EXPECT_TRUE(state.activeTabDragPanelId.empty());
EXPECT_FALSE(state.dockHostState.dropPreview.visible);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerMove(probeMovePoint.x, probeMovePoint.y) });
EXPECT_FALSE(frame.result.requestPointerCapture);
EXPECT_FALSE(frame.result.releasePointerCapture);
EXPECT_FALSE(frame.result.layoutChanged);
EXPECT_TRUE(state.activeTabDragNodeId.empty());
EXPECT_TRUE(state.activeTabDragPanelId.empty());
EXPECT_FALSE(state.dockHostState.dropPreview.visible);
}
TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) { TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) {
auto controller = auto controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());