diff --git a/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h b/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h index daca89ae..bf974c09 100644 --- a/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h +++ b/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h @@ -53,6 +53,10 @@ struct UIEditorTabStripInteractionFrame { bool focused = false; }; +// Clears transient pointer-driven state while preserving selection/navigation. +void ClearUIEditorTabStripTransientInteraction( + UIEditorTabStripInteractionState& state); + UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( UIEditorTabStripInteractionState& state, std::string& selectedTabId, diff --git a/new_editor/src/Collections/UIEditorTabStripInteraction.cpp b/new_editor/src/Collections/UIEditorTabStripInteraction.cpp index 837c4160..01c5785a 100644 --- a/new_editor/src/Collections/UIEditorTabStripInteraction.cpp +++ b/new_editor/src/Collections/UIEditorTabStripInteraction.cpp @@ -363,6 +363,14 @@ void CancelTabReorder( } // namespace +void ClearUIEditorTabStripTransientInteraction( + UIEditorTabStripInteractionState& state) { + state.pressedTarget = {}; + state.hasPointerPosition = false; + ClearHoverState(state); + ClearReorderState(state); +} + UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( UIEditorTabStripInteractionState& state, std::string& selectedTabId, diff --git a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp index 5fd152bc..db593cd5 100644 --- a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp +++ b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp @@ -192,6 +192,14 @@ bool IsPointInsideRect( point.y <= rect.y + rect.height; } +void ClearAllTabStripTransientInteractions( + UIEditorDockHostInteractionState& state) { + for (UIEditorDockHostTabStripInteractionEntry& entry : state.tabStripInteractions) { + ClearUIEditorTabStripTransientInteraction(entry.state); + } + SyncDockHostTabStripVisualStates(state); +} + void ClearTabDockDragState(UIEditorDockHostInteractionState& state) { state.activeTabDragNodeId.clear(); state.activeTabDragPanelId.clear(); @@ -510,9 +518,19 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( 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 = {}; const DockHostTabStripEventResult tabStripResult = - ShouldDispatchTabStripEvent(event, state.splitterDragState.active) + !splitterOwnsPointerDown && + ShouldDispatchTabStripEvent(event, state.splitterDragState.active) ? ProcessTabStripEvent(state, layout, event, metrics) : DockHostTabStripEventResult {}; eventResult.requestPointerCapture = tabStripResult.requestPointerCapture; @@ -541,6 +559,7 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( if (state.splitterDragState.active) { EndUISplitterDrag(state.splitterDragState); state.dockHostState.activeSplitterNodeId.clear(); + ClearAllTabStripTransientInteractions(state); eventResult.consumed = true; eventResult.releasePointerCapture = true; } @@ -609,11 +628,11 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( break; } - if (state.dockHostState.hoveredTarget.kind == + if (pointerHitTarget.kind == UIEditorDockHostHitTargetKind::SplitterHandle) { const auto* splitter = FindUIEditorDockHostSplitterLayout( layout, - state.dockHostState.hoveredTarget.nodeId); + pointerHitTarget.nodeId); if (splitter != nullptr && BeginUISplitterDrag( 1u, @@ -626,11 +645,12 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( splitter->metrics, state.pointerPosition, state.splitterDragState)) { + ClearAllTabStripTransientInteractions(state); state.dockHostState.activeSplitterNodeId = splitter->nodeId; state.dockHostState.focused = true; eventResult.consumed = true; eventResult.requestPointerCapture = true; - eventResult.hitTarget = state.dockHostState.hoveredTarget; + eventResult.hitTarget = pointerHitTarget; eventResult.activeSplitterNodeId = splitter->nodeId; } } else { @@ -643,7 +663,10 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( state.dockHostState.hoveredTarget.kind != UIEditorDockHostHitTargetKind::None; eventResult.consumed = state.dockHostState.focused; - eventResult.hitTarget = state.dockHostState.hoveredTarget; + eventResult.hitTarget = + pointerHitTarget.kind != UIEditorDockHostHitTargetKind::None + ? pointerHitTarget + : state.dockHostState.hoveredTarget; } } break; @@ -668,6 +691,7 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( UIEditorWorkspaceLayoutOperationStatus::Changed; } EndUISplitterDrag(state.splitterDragState); + ClearAllTabStripTransientInteractions(state); eventResult.consumed = true; eventResult.releasePointerCapture = true; eventResult.activeSplitterNodeId = state.dockHostState.activeSplitterNodeId; diff --git a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp index fb45d404..b338d691 100644 --- a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp @@ -143,6 +143,18 @@ const UIEditorDockHostTabStackLayout* FindTabStackByNodeId( 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 TEST(UIEditorDockHostInteractionTest, SplitterDragUpdatesWorkspaceSplitRatio) { @@ -216,6 +228,87 @@ TEST(UIEditorDockHostInteractionTest, FocusLostWhileDraggingSplitterRequestsPoin 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) { auto controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());