From ff95f3d54b7bf502cf484e3bce74f65aeed0d08c Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Thu, 23 Jul 2026 00:44:05 +0800 Subject: [PATCH 1/2] Implement pane drag docking --- docs/pane-handle-snap-docking.md | 13 +- scripts/winterm/test-pane-controls.ps1 | 29 +- src/cascadia/TerminalApp/Pane.cpp | 89 +++ src/cascadia/TerminalApp/Pane.h | 18 + src/cascadia/TerminalApp/Tab.cpp | 784 ++++++++++++++++++++- src/cascadia/TerminalApp/Tab.h | 48 +- src/cascadia/TerminalApp/TabManagement.cpp | 18 + 7 files changed, 985 insertions(+), 14 deletions(-) diff --git a/docs/pane-handle-snap-docking.md b/docs/pane-handle-snap-docking.md index bb63c3c83..94c29b016 100644 --- a/docs/pane-handle-snap-docking.md +++ b/docs/pane-handle-snap-docking.md @@ -22,10 +22,15 @@ thread-affine view; its retained `ControlInteractivity`/`ControlCore` owns the buffer, renderer state, and `ITerminalConnection`. Elevated windows and another launched winTerm instance use another process. -The native pane header currently renders and focuses correctly, and its handle -and overflow button share the terminal's unified context flyout. The remaining -runtime pointer/overlay adapter is still readiness-gated. Model or source tests -must not be reported as proof that the live XAML drag interaction passed. +The native pane header renders and focuses correctly, and its handle and +overflow button share the terminal's unified context flyout. Its runtime adapter +captures pointer input only from the grip, feeds the existing +`PaneHandleDragSource` and `DockTargetResolver`, renders the model-produced Snap +zones and proposed-layout preview, and commits same-tab edge drops through the +layout transaction coordinator. Center drop moves the same live pane into a new +tab. Cross-window and outside-window handle drops remain readiness-gated. Model, +source, and compiled tests must not be reported as proof that the live XAML drag +interaction passed. ## Reused v0.5 components diff --git a/scripts/winterm/test-pane-controls.ps1 b/scripts/winterm/test-pane-controls.ps1 index 94a36a5cc..35d98c331 100644 --- a/scripts/winterm/test-pane-controls.ps1 +++ b/scripts/winterm/test-pane-controls.ps1 @@ -123,7 +123,12 @@ try 'SetPaneHeadersVisible', 'ContextFlyout', 'Move ', - 'Open pane menu' + 'Open pane menu', + '_paneGrip.CapturePointer', + 'PaneDragPressed.raise', + 'PaneDragUpdated.raise', + 'PaneDragCompleted.raise', + 'PaneDragCancelled.raise' )) { if (-not $runtimeHeader.Contains($required)) @@ -148,6 +153,28 @@ try } } + $runtimeTab = Get-Content -Raw -LiteralPath (Join-Path $root 'src\cascadia\TerminalApp\Tab.cpp') + foreach ($required in @( + '_ResolvePaneDockTarget', + '_ShowPaneDockingOverlay', + '_DockPane', + 'PaneHandleDragSource', + 'DockTargetResolver', + 'LayoutTransactionCoordinator', + 'PaneSnapLayoutOverlay::Build', + 'PaneMoveToNewTabRequested.raise', + 'SplitDirection::Up', + 'SplitDirection::Down', + 'SplitDirection::Left', + 'SplitDirection::Right' + )) + { + if (-not $runtimeTab.Contains($required)) + { + throw "Runtime Pane Handle docking boundary '$required' is missing." + } + } + $actionHandlers = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[12]) $handlerStart = $actionHandlers.IndexOf('void TerminalPage::_HandleSplitPane') $handlerEnd = $actionHandlers.IndexOf('void TerminalPage::_HandleToggleSplitOrientation', $handlerStart) diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index 40192ea9f..c706d82a9 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -1885,6 +1885,28 @@ void Pane::_AttachLeafVisual() _paneHeader.PointerPressed([this](auto&&, auto&&) { _Focus(); }); _paneHeader.Tapped([this](auto&&, auto&&) { _Focus(); }); _paneHeader.RightTapped([this](auto&&, auto&&) { _Focus(); }); + _paneGrip.PointerPressed([this](auto&&, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) { + _PaneGripPointerPressed(e); + }); + _paneGrip.PointerMoved([this](auto&&, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) { + _PaneGripPointerMoved(e); + }); + _paneGrip.PointerReleased([this](auto&&, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) { + _PaneGripPointerReleased(e); + }); + _paneGrip.PointerCanceled([this](auto&&, auto&&) { + _CancelPaneDrag(true); + }); + _paneGrip.PointerCaptureLost([this](auto&&, auto&&) { + _CancelPaneDrag(true); + }); + _paneGrip.KeyDown([this](auto&&, const winrt::Windows::UI::Xaml::Input::KeyRoutedEventArgs& e) { + if (e.Key() == winrt::Windows::System::VirtualKey::Escape && _paneDragPointerId) + { + _CancelPaneDrag(true); + e.Handled(true); + } + }); _paneGrip.PointerEntered([](auto&&, auto&&) { if (const auto window = Window::Current()) { @@ -1909,6 +1931,73 @@ void Pane::_AttachLeafVisual() _UpdatePaneHeader(); } +void Pane::_PaneGripPointerPressed(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) +{ + const auto point = e.GetCurrentPoint(_paneGrip); + if (_paneDragPointerId || !point.Properties().IsLeftButtonPressed()) + { + return; + } + + _Focus(); + if (!_paneGrip.CapturePointer(e.Pointer())) + { + return; + } + + _paneDragPointerId = e.Pointer().PointerId(); + _paneDragStartPoint = point.Position(); + _paneGrip.Focus(FocusState::Pointer); + PaneDragPressed.raise(shared_from_this(), e); + e.Handled(true); +} + +void Pane::_PaneGripPointerMoved(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) +{ + if (!_paneDragPointerId || e.Pointer().PointerId() != *_paneDragPointerId) + { + return; + } + + PaneDragUpdated.raise(shared_from_this(), e); + e.Handled(true); +} + +void Pane::_PaneGripPointerReleased(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e) +{ + if (!_paneDragPointerId || e.Pointer().PointerId() != *_paneDragPointerId) + { + return; + } + + _paneDragPointerId.reset(); + _paneGrip.ReleasePointerCapture(e.Pointer()); + + PaneDragCompleted.raise(shared_from_this(), e); + if (_content) + { + _content.Focus(FocusState::Programmatic); + } + e.Handled(true); +} + +void Pane::_CancelPaneDrag(const bool restoreFocus) +{ + if (!_paneDragPointerId) + { + return; + } + + _paneDragPointerId.reset(); + _paneGrip.ReleasePointerCaptures(); + + PaneDragCancelled.raise(shared_from_this()); + if (restoreFocus && _content) + { + _content.Focus(FocusState::Programmatic); + } +} + void Pane::_UpdatePaneHeader() { if (!_paneHeader || !_paneTitle || !_paneGrip || !_paneStatus) diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index 754ba6d0c..e9458d673 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -225,6 +225,18 @@ class Pane : public std::enable_shared_from_this til::event>> LostFocus; til::event>> Detached; + using paneDragPointerArgs = winrt::delegate< + std::shared_ptr, + winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs>; + + // Pane dragging is deliberately exposed only by the dedicated header grip. + // Tab owns target resolution and layout changes so a hover never mutates the + // pane tree and a cancelled drag leaves the live session untouched. + til::event PaneDragPressed; + til::event PaneDragUpdated; + til::event PaneDragCompleted; + til::event>> PaneDragCancelled; + private: struct PanePoint; struct PaneNeighborSearch; @@ -273,6 +285,8 @@ class Pane : public std::enable_shared_from_this bool _zoomed{ false }; bool _broadcastEnabled{ false }; bool _paneHeadersVisible{ false }; + std::optional _paneDragPointerId; + winrt::Windows::Foundation::Point _paneDragStartPoint{}; bool _IsLeaf() const noexcept; bool _HasFocusedChild() const noexcept; @@ -283,6 +297,10 @@ class Pane : public std::enable_shared_from_this void _UpdatePaneHeader(); winrt::hstring _PaneHeaderTitle() const; winrt::hstring _PaneHeaderAccessibleTitle() const; + void _PaneGripPointerPressed(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _PaneGripPointerMoved(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _PaneGripPointerReleased(const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _CancelPaneDrag(bool restoreFocus); bool _HasChild(const std::shared_ptr child); winrt::TerminalApp::TerminalPaneContent _getTerminalContent() const; diff --git a/src/cascadia/TerminalApp/Tab.cpp b/src/cascadia/TerminalApp/Tab.cpp index d25ccd773..aec57bb30 100644 --- a/src/cascadia/TerminalApp/Tab.cpp +++ b/src/cascadia/TerminalApp/Tab.cpp @@ -16,8 +16,9 @@ using namespace winrt::Windows::UI::Core; using namespace winrt::Microsoft::Terminal::Control; using namespace winrt::Microsoft::Terminal::TerminalConnection; using namespace winrt::Microsoft::Terminal::Settings::Model; -using namespace winrt::Microsoft::UI::Xaml::Controls; -using namespace winrt::Windows::System; +using namespace winrt::Microsoft::UI::Xaml::Controls; +using namespace winrt::Windows::System; +using namespace winTerm::Docking; namespace winrt { @@ -1413,6 +1414,35 @@ namespace winrt::TerminalApp::implementation } }); + auto paneDragPressedToken = pane->PaneDragPressed( + [weakThis](std::shared_ptr source, const WUX::Input::PointerRoutedEventArgs& e) { + if (auto tab = weakThis.get()) + { + tab->_PaneDragPressed(std::move(source), e); + } + }); + auto paneDragUpdatedToken = pane->PaneDragUpdated( + [weakThis](std::shared_ptr source, const WUX::Input::PointerRoutedEventArgs& e) { + if (auto tab = weakThis.get()) + { + tab->_PaneDragUpdated(std::move(source), e); + } + }); + auto paneDragCompletedToken = pane->PaneDragCompleted( + [weakThis](std::shared_ptr source, const WUX::Input::PointerRoutedEventArgs& e) { + if (auto tab = weakThis.get()) + { + tab->_PaneDragCompleted(std::move(source), e); + } + }); + auto paneDragCancelledToken = pane->PaneDragCancelled( + [weakThis](std::shared_ptr source) { + if (auto tab = weakThis.get()) + { + tab->_PaneDragCancelled(std::move(source)); + } + }); + // Add a Closed event handler to the Pane. If the pane closes out from // underneath us, and it's zoomed, we want to be able to make sure to // update our state accordingly to un-zoom that pane. See GH#7252. @@ -1453,7 +1483,16 @@ namespace winrt::TerminalApp::implementation auto detachedToken = std::make_shared(); // Add a Detached event handler to the Pane to clean up tab state // and other event handlers when a pane is removed from this tab. - *detachedToken = pane->Detached([weakThis, weakPane, gotFocusToken, lostFocusToken, closedToken, detachedToken](std::shared_ptr /*sender*/) { + *detachedToken = pane->Detached([weakThis, + weakPane, + gotFocusToken, + lostFocusToken, + closedToken, + paneDragPressedToken, + paneDragUpdatedToken, + paneDragCompletedToken, + paneDragCancelledToken, + detachedToken](std::shared_ptr /*sender*/) { // Make sure we do this at most once if (auto pane{ weakPane.lock() }) { @@ -1461,10 +1500,17 @@ namespace winrt::TerminalApp::implementation pane->GotFocus(gotFocusToken); pane->LostFocus(lostFocusToken); pane->Closed(closedToken); - - if (auto tab{ weakThis.get() }) - { - tab->_DetachEventHandlersFromContent(pane->Id().value()); + pane->PaneDragPressed(paneDragPressedToken); + pane->PaneDragUpdated(paneDragUpdatedToken); + pane->PaneDragCompleted(paneDragCompletedToken); + pane->PaneDragCancelled(paneDragCancelledToken); + if (auto tab{ weakThis.get() }) + { + if (tab->_paneDragSource.lock() == pane) + { + tab->_PaneDragCancelled(pane); + } + tab->_DetachEventHandlersFromContent(pane->Id().value()); for (auto i = tab->_mruPanes.begin(); i != tab->_mruPanes.end(); ++i) { @@ -1477,6 +1523,730 @@ namespace winrt::TerminalApp::implementation } } }); + } + + void Tab::_PaneDragPressed( + std::shared_ptr source, + const WUX::Input::PointerRoutedEventArgs& e) + { + if (!source || !source->Id() || _paneDragActive || _zoomedPane || GetLeafPaneCount() < 2 || !_rootPane) + { + return; + } + + const auto tabLayout = _BuildPaneDockLayout(_rootPane); + const auto sourceLayout = _BuildPaneDockLayout(source); + if (!tabLayout || !sourceLayout) + { + return; + } + + const auto rootElement = _rootPane->GetRootElement(); + const auto point = e.GetCurrentPoint(rootElement); + PaneHandleDragContext context; + context.processInstanceId = "local-process"; + context.source = _BuildPaneDockSource(source, tabLayout); + context.sourcePaneLayout = sourceLayout; + context.sourceTabLayout = tabLayout; + context.pressedPoint = { point.Position().X, point.Position().Y }; + context.threshold.touch = e.Pointer().PointerDeviceType() == Windows::Devices::Input::PointerDeviceType::Touch; + + _paneHandleDragSource = std::make_unique(_paneDragPayloadRegistry); + if (!_paneHandleDragSource->PointerPressed(winTerm::PaneControls::PanePointerRegion::DragGrip, std::move(context))) + { + _paneHandleDragSource.reset(); + return; + } + + _paneDragActive = true; + _paneDragSource = source; + _paneDragPressedTimestamp = point.Timestamp(); + _paneDockTargetResolver.Clear(); + _HidePaneDockingOverlay(); + if (_activePane != source) + { + _UpdateActivePane(source); + } + } + + void Tab::_PaneDragUpdated( + std::shared_ptr source, + const WUX::Input::PointerRoutedEventArgs& e) + { + if (!_paneDragActive || !source || _paneDragSource.lock() != source || !_rootPane || !_paneHandleDragSource) + { + return; + } + + const auto rootElement = _rootPane->GetRootElement(); + const auto pointerPoint = e.GetCurrentPoint(rootElement); + const auto point = pointerPoint.Position(); + if (_paneHandleDragSource->State() == DockDragState::PointerPressed || + _paneHandleDragSource->State() == DockDragState::DragPending) + { + const auto elapsed = pointerPoint.Timestamp() >= _paneDragPressedTimestamp ? + std::chrono::milliseconds{ (pointerPoint.Timestamp() - _paneDragPressedTimestamp) / 1000 } : + std::chrono::milliseconds::zero(); + _paneHandleDragSource->PointerMoved({ point.X, point.Y }, elapsed); + } + if (_paneHandleDragSource->State() != DockDragState::Dragging && + _paneHandleDragSource->State() != DockDragState::TargetAcquired) + { + return; + } + + const auto hoveredTarget = _ResolvePaneDockTarget(point, source); + if (!hoveredTarget) + { + _paneDockTargetResolver.Clear(); + _HidePaneDockingOverlay(); + return; + } + + const auto tabLayout = _BuildPaneDockLayout(_rootPane); + if (!tabLayout) + { + _HidePaneDockingOverlay(); + return; + } + + const auto sourceModel = _BuildPaneDockSource(source, tabLayout); + const auto hoveredTargetModel = _BuildPaneDockTarget(hoveredTarget); + auto capabilities = _PaneDockCapabilities(); + auto zones = PaneSnapLayoutOverlay::Build( + _PaneDockBounds(hoveredTarget), + sourceModel, + hoveredTargetModel, + capabilities, + true, + std::nullopt, + false); + + const auto splitDirection = [](const DockZone zone) { + switch (zone) + { + case DockZone::Left: return SplitDirection::Left; + case DockZone::Right: return SplitDirection::Right; + case DockZone::Top: return SplitDirection::Up; + case DockZone::Bottom: return SplitDirection::Down; + default: return SplitDirection::Automatic; + } + }; + const auto applySplitAvailability = [&](auto& presentations, const std::shared_ptr& pane) { + const auto bounds = _PaneDockBounds(pane); + for (auto& zone : presentations) + { + const auto direction = splitDirection(zone.zone); + if (direction == SplitDirection::Automatic) + { + continue; + } + const auto canSplit = pane->PreCalculateCanSplit( + pane, + direction, + .5f, + { static_cast(bounds.width), static_cast(bounds.height) }); + if (!canSplit || !*canSplit) + { + zone.enabled = false; + zone.disabledReason = "The target is too small for this split."; + zone.automationName += ". " + zone.disabledReason; + } + } + }; + applySplitAvailability(zones, hoveredTarget); + + std::vector candidates; + candidates.reserve(zones.size()); + for (const auto& zone : zones) + { + DockTargetCandidate candidate; + candidate.key = hoveredTargetModel.nodeId.value_or("pane") + "-" + std::string{ ToString(zone.zone) }; + candidate.target = hoveredTargetModel; + candidate.zone = zone.zone; + candidate.bounds = zone.hitBounds; + candidate.enabled = zone.enabled; + candidate.disabledReason = zone.disabledReason; + candidates.emplace_back(std::move(candidate)); + } + + const auto resolved = _paneDockTargetResolver.Resolve(candidates, { point.X, point.Y }); + if (!resolved || !resolved->target.nodeId) + { + if (_paneHandleDragSource->State() == DockDragState::TargetAcquired) + { + auto invalidCapabilities = capabilities; + invalidCapabilities.canMove = false; + PaneHandleDragPreviewRequest invalidRequest; + invalidRequest.target = hoveredTargetModel; + invalidRequest.zone = DockZone::Center; + invalidRequest.targetLayout = tabLayout; + invalidRequest.capabilities = invalidCapabilities; + invalidRequest.targetBounds = { 0, 0, rootElement.ActualWidth(), rootElement.ActualHeight() }; + _paneHandleDragSource->Preview(invalidRequest); + } + _ShowPaneDockingOverlay(hoveredTarget, zones, nullptr); + _paneDockPlan.reset(); + return; + } + + std::shared_ptr target; + _rootPane->WalkTree([&](const auto& pane) { + if (pane->Id() && std::to_string(*pane->Id()) == *resolved->target.nodeId) + { + target = pane; + return true; + } + return false; + }); + if (!target || target == source) + { + _HidePaneDockingOverlay(); + return; + } + + const auto targetModel = _BuildPaneDockTarget(target); + PaneHandleDragPreviewRequest request; + request.target = targetModel; + request.zone = resolved->zone; + request.targetLayout = tabLayout; + request.capabilities = capabilities; + request.sameProcess = true; + request.targetBounds = { 0, 0, rootElement.ActualWidth(), rootElement.ActualHeight() }; + const auto preview = _paneHandleDragSource->Preview(request); + + zones = PaneSnapLayoutOverlay::Build( + _PaneDockBounds(target), + sourceModel, + targetModel, + capabilities, + true, + preview.Succeeded() ? std::optional{ resolved->zone } : std::nullopt, + false); + applySplitAvailability(zones, target); + _ShowPaneDockingOverlay(target, zones, preview.Succeeded() ? &preview.preview : nullptr); + if (preview.Succeeded()) + { + _paneDockPlan = preview.plan; + } + else + { + _paneDockPlan.reset(); + } + } + + void Tab::_PaneDragCompleted( + std::shared_ptr source, + const WUX::Input::PointerRoutedEventArgs& e) + { + if (!_paneDragActive || !source || _paneDragSource.lock() != source || !_paneHandleDragSource) + { + return; + } + + _PaneDragUpdated(source, e); + const auto target = _paneDockTarget.lock(); + const auto zone = _paneDockZone; + const auto plan = _paneDockPlan; + + if (_paneHandleDragSource->State() == DockDragState::PointerPressed || + _paneHandleDragSource->State() == DockDragState::DragPending) + { + _paneHandleDragSource->PointerReleased(); + } + else if (!target || !zone || !plan || + !_paneHandleDragSource->RequestDrop() || + !_paneHandleDragSource->BeginCommit()) + { + _paneHandleDragSource->Cancel(DragCancellationReason::InvalidDrop); + } + + _paneDragActive = false; + _paneDragSource.reset(); + _paneDockTargetResolver.Clear(); + _HidePaneDockingOverlay(); + + if (_paneHandleDragSource->State() != DockDragState::Committing || !target || !zone || !plan) + { + _paneHandleDragSource->Reset(); + _paneHandleDragSource.reset(); + return; + } + + if (_CommitPaneDockingPlan(source, target, *zone, *plan)) + { + _paneHandleDragSource->Complete(); + } + else + { + _paneHandleDragSource->Fail("The runtime pane layout could not commit the docking plan."); + _paneHandleDragSource->BeginRollback(); + _paneHandleDragSource->CompleteRollback(true); + } + _paneHandleDragSource->Reset(); + _paneHandleDragSource.reset(); + } + + void Tab::_PaneDragCancelled(std::shared_ptr source) + { + if (!_paneDragActive || _paneDragSource.lock() != source) + { + return; + } + + _paneDragActive = false; + _paneDragSource.reset(); + _paneDockTargetResolver.Clear(); + _HidePaneDockingOverlay(); + if (_paneHandleDragSource) + { + _paneHandleDragSource->Cancel(DragCancellationReason::PointerCaptureLost); + _paneHandleDragSource->Reset(); + _paneHandleDragSource.reset(); + } + } + + std::shared_ptr Tab::_ResolvePaneDockTarget( + const Windows::Foundation::Point& point, + const std::shared_ptr& source) const + { + if (!_rootPane) + { + return nullptr; + } + + std::shared_ptr result; + const auto rootElement = _rootPane->GetRootElement(); + _rootPane->WalkTree([&](const auto& pane) { + if (result || pane == source || !pane->_IsLeaf()) + { + return result != nullptr; + } + + try + { + const auto width = pane->_root.ActualWidth(); + const auto height = pane->_root.ActualHeight(); + if (width <= 0.0 || height <= 0.0) + { + return false; + } + + const auto transform = pane->_root.TransformToVisual(rootElement); + const auto origin = transform.TransformPoint({ 0.0f, 0.0f }); + if (point.X < origin.X || point.Y < origin.Y || + point.X > origin.X + width || point.Y > origin.Y + height) + { + return false; + } + + result = pane; + return true; + } + catch (...) + { + // A target can disappear while its terminal is closing. It is + // not a valid drop target, and the source pane remains attached. + return false; + } + }); + return result; + } + + LayoutNodePtr Tab::_BuildPaneDockLayout(const std::shared_ptr& pane) const + { + if (!pane) + { + return nullptr; + } + if (pane->_IsLeaf()) + { + return pane->Id() ? + winTerm::Workspaces::LayoutNodeDescriptor::Pane(std::to_string(*pane->Id())) : + nullptr; + } + + const auto first = _BuildPaneDockLayout(pane->_firstChild); + const auto second = _BuildPaneDockLayout(pane->_secondChild); + if (!first || !second) + { + return nullptr; + } + return winTerm::Workspaces::LayoutNodeDescriptor::Split( + pane->_splitState == SplitState::Vertical ? + winTerm::Workspaces::SplitOrientation::Vertical : + winTerm::Workspaces::SplitOrientation::Horizontal, + pane->_desiredSplitPosition, + first, + second); + } + + DockSource Tab::_BuildPaneDockSource( + const std::shared_ptr& source, + const LayoutNodePtr& tabLayout) const + { + DockSource result; + result.type = DockSourceType::Pane; + result.windowId = "local-window"; + result.tabId = "local-tab"; + result.root = tabLayout; + if (source && source->Id()) + { + const auto id = std::to_string(*source->Id()); + result.paneId = id; + result.paneIds = { id }; + result.activePaneId = id; + result.title = winrt::to_string(source->_PaneHeaderTitle()); + } + return result; + } + + DockTarget Tab::_BuildPaneDockTarget(const std::shared_ptr& target) const + { + DockTarget result; + result.type = DockTargetType::Pane; + result.windowId = "local-window"; + result.tabId = "local-tab"; + if (target && target->Id()) + { + result.nodeId = std::to_string(*target->Id()); + } + return result; + } + + LayoutRect Tab::_PaneDockBounds(const std::shared_ptr& pane) const + { + if (!pane || !_rootPane) + { + return {}; + } + try + { + const auto origin = pane->_root.TransformToVisual(_rootPane->GetRootElement()).TransformPoint({ 0.0f, 0.0f }); + return { origin.X, origin.Y, pane->_root.ActualWidth(), pane->_root.ActualHeight() }; + } + catch (...) + { + return {}; + } + } + + DockingCapabilities Tab::_PaneDockCapabilities() const + { + DockingCapabilities result; + result.canDockCorners = false; + result.canUseEmptySlots = false; + result.sourcePaneCount = 1; + result.currentPaneCount = _rootPane ? static_cast(std::max(0, _rootPane->GetLeafPaneCount() - 1)) : 0; + return result; + } + + void Tab::_ShowPaneDockingOverlay( + const std::shared_ptr& target, + const std::vector& zones, + const DockPreviewModel* preview) + { + std::optional selected; + for (const auto& zone : zones) + { + if (zone.selected) + { + selected = zone.zone; + break; + } + } + if (_paneDockTarget.lock() == target && _paneDockZone == selected && _paneDockingOverlay) + { + return; + } + + _HidePaneDockingOverlay(); + if (!target || !target->_IsLeaf() || !_rootPane || zones.empty()) + { + return; + } + + const auto overlay = WUX::Controls::Grid{}; + overlay.IsHitTestVisible(false); + overlay.Background(WUX::Media::SolidColorBrush{ Windows::UI::ColorHelper::FromArgb(72, 5, 12, 18) }); + WUX::Controls::Canvas::SetZIndex(overlay, 1000); + WUX::Controls::Grid::SetRowSpan(overlay, std::max(1, static_cast(_rootPane->_root.RowDefinitions().Size()))); + WUX::Controls::Grid::SetColumnSpan(overlay, std::max(1, static_cast(_rootPane->_root.ColumnDefinitions().Size()))); + + const auto canvas = WUX::Controls::Canvas{}; + overlay.Children().Append(canvas); + + if (preview && preview->valid) + { + for (const auto& region : preview->regions) + { + const auto panePreview = WUX::Controls::Border{}; + panePreview.Width(region.bounds.width); + panePreview.Height(region.bounds.height); + panePreview.Margin(WUX::ThicknessHelper::FromUniformLength(2.0)); + panePreview.BorderThickness(WUX::ThicknessHelper::FromUniformLength(2.0)); + panePreview.BorderBrush(WUX::Media::SolidColorBrush{ Windows::UI::ColorHelper::FromArgb(200, 115, 220, 205) }); + panePreview.Background(WUX::Media::SolidColorBrush{ + region.role == DockPreviewRegionRole::Source ? + Windows::UI::ColorHelper::FromArgb(110, 18, 126, 108) : + Windows::UI::ColorHelper::FromArgb(68, 38, 58, 72) }); + WUX::Automation::AutomationProperties::SetName(panePreview, winrt::to_hstring(region.automationName)); + WUX::Controls::Canvas::SetLeft(panePreview, region.bounds.x); + WUX::Controls::Canvas::SetTop(panePreview, region.bounds.y); + canvas.Children().Append(panePreview); + } + } + + for (const auto& zone : zones) + { + const auto border = WUX::Controls::Border{}; + border.Width(zone.hitBounds.width); + border.Height(zone.hitBounds.height); + border.CornerRadius(WUX::CornerRadiusHelper::FromUniformRadius(5.0)); + border.BorderThickness(WUX::ThicknessHelper::FromUniformLength(zone.selected ? 2.0 : 1.0)); + border.BorderBrush(WUX::Media::SolidColorBrush{ Windows::UI::ColorHelper::FromArgb(180, 115, 220, 205) }); + border.Background(WUX::Media::SolidColorBrush{ + zone.selected ? + Windows::UI::ColorHelper::FromArgb(210, 18, 126, 108) : + Windows::UI::ColorHelper::FromArgb(188, 38, 58, 72) }); + border.Opacity(zone.enabled ? 1.0 : 0.45); + + const auto panel = WUX::Controls::StackPanel{}; + panel.HorizontalAlignment(WUX::HorizontalAlignment::Center); + panel.VerticalAlignment(WUX::VerticalAlignment::Center); + const auto icon = WUX::Controls::TextBlock{}; + icon.Text(winrt::to_hstring(zone.icon)); + icon.FontSize(20.0); + icon.HorizontalAlignment(WUX::HorizontalAlignment::Center); + icon.Foreground(WUX::Media::SolidColorBrush{ Windows::UI::Colors::White() }); + panel.Children().Append(icon); + if (!zone.label.empty()) + { + const auto label = WUX::Controls::TextBlock{}; + label.Text(winrt::to_hstring(zone.label)); + label.FontSize(10.0); + label.HorizontalAlignment(WUX::HorizontalAlignment::Center); + label.Foreground(WUX::Media::SolidColorBrush{ Windows::UI::Colors::White() }); + panel.Children().Append(label); + } + border.Child(panel); + WUX::Automation::AutomationProperties::SetName(border, winrt::to_hstring(zone.automationName)); + if (!zone.disabledReason.empty()) + { + WUX::Controls::ToolTipService::SetToolTip(border, box_value(winrt::to_hstring(zone.disabledReason))); + } + WUX::Controls::Canvas::SetLeft(border, zone.hitBounds.x); + WUX::Controls::Canvas::SetTop(border, zone.hitBounds.y); + canvas.Children().Append(border); + } + + _rootPane->_root.Children().Append(overlay); + _paneDockTarget = target; + _paneDockingOverlay = overlay; + _paneDockZone = selected; + } + + void Tab::_HidePaneDockingOverlay() + { + if (_rootPane && _paneDockingOverlay) + { + const auto children = _rootPane->_root.Children(); + for (uint32_t i = 0; i < children.Size(); ++i) + { + if (children.GetAt(i) == _paneDockingOverlay) + { + children.RemoveAt(i); + break; + } + } + } + _paneDockingOverlay = nullptr; + _paneDockTarget.reset(); + _paneDockZone.reset(); + _paneDockPlan.reset(); + } + + bool Tab::_CommitPaneDockingPlan( + const std::shared_ptr& source, + const std::shared_ptr& target, + const DockZone zone, + const DockingPlan& plan) + { + if (!source || !source->Id() || !target || !target->Id() || plan.status != DockingStatus::Ready) + { + return false; + } + + if (zone == DockZone::Center) + { + if (_activePane != source) + { + _UpdateActivePane(source); + } + if (auto pane = DetachPane()) + { + PaneMoveToNewTabRequested.raise(std::move(pane)); + return true; + } + return false; + } + + const auto direction = [&]() { + switch (zone) + { + case DockZone::Left: return SplitDirection::Left; + case DockZone::Right: return SplitDirection::Right; + case DockZone::Top: return SplitDirection::Up; + case DockZone::Bottom: return SplitDirection::Down; + default: return SplitDirection::Automatic; + } + }(); + if (direction == SplitDirection::Automatic) + { + return false; + } + + const auto sourceId = std::to_string(*source->Id()); + const SessionOwner owner{ "local-window", "local-tab", sourceId }; + const auto sessionId = "pane-session-" + sourceId; + _paneSessionOwnership.Unregister(sessionId); + if (!_paneSessionOwnership.Register( + SessionIdentity{ sessionId, "live-terminal", "local-process", true, true }, + owner)) + { + return false; + } + + LayoutTransactionRequest request; + request.transactionId = "pane-drag-" + sourceId + "-" + std::to_string(_paneDragPressedTimestamp); + request.plan = plan; + request.snapshot.sourceLayout = _BuildPaneDockLayout(_rootPane); + request.snapshot.targetLayout = request.snapshot.sourceLayout; + request.snapshot.focus.activeWindowId = "local-window"; + request.snapshot.focus.activeTabId = "local-tab"; + request.snapshot.focus.activePaneId = sourceId; + request.sessionIds = { sessionId }; + request.targetOwners = { owner }; + + const auto targetId = *target->Id(); + bool visualCommitted = false; + request.callbacks.sourceAvailable = [this, source] { + return _rootPane && _rootPane->_HasChild(source); + }; + request.callbacks.targetAvailable = [this, targetId] { + return _rootPane && _rootPane->FindPane(targetId) != nullptr; + }; + request.callbacks.prepareTarget = [this, targetId, direction](const auto&) { + const auto currentTarget = _rootPane ? _rootPane->FindPane(targetId) : nullptr; + if (!currentTarget) + { + return false; + } + const auto canSplit = currentTarget->PreCalculateCanSplit( + currentTarget, + direction, + .5f, + { static_cast(currentTarget->_root.ActualWidth()), + static_cast(currentTarget->_root.ActualHeight()) }); + return canSplit && *canSplit; + }; + request.callbacks.commitModel = [](const auto& proposedPlan) { + return proposedPlan.status == DockingStatus::Ready && proposedPlan.proposedTargetLayout != nullptr; + }; + request.callbacks.commitVisualTree = [this, source, target, direction, &visualCommitted](const auto&) { + visualCommitted = _DockPane(source, target, direction); + return visualCommitted; + }; + request.callbacks.rollback = [this, source](const auto&) { + return _rootPane && source->Id() && _rootPane->FindPane(*source->Id()) != nullptr; + }; + request.callbacks.recoverSessions = request.callbacks.rollback; + request.callbacks.markWorkspaceDirty = [] { return true; }; + request.callbacks.restoreFocus = [this] { + if (_activePane) + { + _activePane->_Focus(); + } + }; + + LayoutTransactionCoordinator coordinator{ _paneSessionOwnership }; + const auto result = coordinator.Execute(std::move(request)); + _paneSessionOwnership.Unregister(sessionId); + return result.docking.Succeeded() && visualCommitted; + } + + bool Tab::_DockPane( + const std::shared_ptr& source, + const std::shared_ptr& target, + const SplitDirection direction) + { + if (!source || !target || source == target || + direction == SplitDirection::Automatic || + !_rootPane || !_rootPane->_HasChild(source)) + { + return false; + } + + const auto targetId = target->Id(); + if (!targetId) + { + return false; + } + + const auto canSplit = target->PreCalculateCanSplit( + target, + direction, + .5f, + { static_cast(target->_root.ActualWidth()), + static_cast(target->_root.ActualHeight()) }); + if (!canSplit || !*canSplit) + { + return false; + } + + if (_activePane != source) + { + _UpdateActivePane(source); + } + + auto detached = _rootPane->DetachPane(source); + if (!detached) + { + return false; + } + + _rootPane->SetPaneHeadersVisible(_rootPane->GetLeafPaneCount() >= 2); + _UpdateActivePane(_rootPane->GetActivePane()); + + auto resolvedTarget = _rootPane->FindPane(*targetId); + if (!resolvedTarget) + { + // The target can close between preview and drop. Reattach the source + // rather than allowing a failed commit to lose the live pane. + AttachPane(std::move(detached)); + return false; + } + + const auto originalTarget = resolvedTarget->AttachPane(detached, direction); + const auto movedPane = detached; + originalTarget->Id(*targetId); + _AttachEventHandlersToPane(originalTarget); + + movedPane->WalkTree([&](const auto& pane) { + _AttachEventHandlersToPane(pane); + if (pane->_IsLeaf() && pane->Id()) + { + if (const auto content = pane->GetContent()) + { + _AttachEventHandlersToContent(*pane->Id(), content); + } + } + }); + + _rootPane->SetPaneHeadersVisible(true); + _closePaneMenuItem.Visibility(WUX::Visibility::Visible); + _UpdateActivePane(movedPane->GetActivePane()); + return true; } void Tab::_AppendMoveMenuItems(winrt::Windows::UI::Xaml::Controls::MenuFlyout flyout) diff --git a/src/cascadia/TerminalApp/Tab.h b/src/cascadia/TerminalApp/Tab.h index d75257469..55d5c5b47 100644 --- a/src/cascadia/TerminalApp/Tab.h +++ b/src/cascadia/TerminalApp/Tab.h @@ -4,8 +4,11 @@ #pragma once #include "Pane.h" #include "ColorPickupFlyout.h" -#include "Tab.h" -#include "Tab.g.h" +#include "Tab.h" +#include "Tab.g.h" +#include "../../winterm/Docking/Drag/PaneHandleDragSource.h" +#include "../../winterm/Docking/Targets/DockTargetResolver.h" +#include "../../winterm/Docking/Transactions/LayoutTransaction.h" // fwdecl unittest classes namespace TerminalAppLocalTests @@ -122,6 +125,7 @@ namespace winrt::TerminalApp::implementation til::typed_event ActivePaneChanged; til::event> TabRaiseVisualBell; til::typed_event TaskbarProgressChanged; + til::event>> PaneMoveToNewTabRequested; // The TabViewIndex is the index this Tab object resides in TerminalPage's _tabs vector. WINRT_PROPERTY(uint32_t, TabViewIndex, 0); @@ -200,6 +204,18 @@ namespace winrt::TerminalApp::implementation bool _receivedKeyDown{ false }; bool _iconHidden{ false }; bool _changingActivePane{ false }; + bool _paneDragActive{ false }; + + std::weak_ptr _paneDragSource; + std::weak_ptr _paneDockTarget; + winrt::Windows::UI::Xaml::Controls::Grid _paneDockingOverlay{ nullptr }; + std::unique_ptr _paneHandleDragSource; + winTerm::Docking::DragPayloadRegistry _paneDragPayloadRegistry; + winTerm::Docking::DockTargetResolver _paneDockTargetResolver; + winTerm::Docking::SessionOwnershipRegistry _paneSessionOwnership; + std::optional _paneDockZone; + std::optional _paneDockPlan; + uint64_t _paneDragPressedTimestamp{}; winrt::hstring _runtimeTabText{}; bool _inRename{ false }; @@ -218,6 +234,34 @@ namespace winrt::TerminalApp::implementation void _DetachEventHandlersFromContent(const uint32_t paneId); void _AttachEventHandlersToContent(const uint32_t paneId, const winrt::TerminalApp::IPaneContent& content); void _AttachEventHandlersToPane(std::shared_ptr pane); + void _PaneDragPressed(std::shared_ptr source, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _PaneDragUpdated(std::shared_ptr source, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _PaneDragCompleted(std::shared_ptr source, const winrt::Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _PaneDragCancelled(std::shared_ptr source); + std::shared_ptr _ResolvePaneDockTarget( + const winrt::Windows::Foundation::Point& point, + const std::shared_ptr& source) const; + winTerm::Docking::LayoutNodePtr _BuildPaneDockLayout(const std::shared_ptr& pane) const; + winTerm::Docking::DockSource _BuildPaneDockSource( + const std::shared_ptr& source, + const winTerm::Docking::LayoutNodePtr& tabLayout) const; + winTerm::Docking::DockTarget _BuildPaneDockTarget(const std::shared_ptr& target) const; + winTerm::Docking::LayoutRect _PaneDockBounds(const std::shared_ptr& pane) const; + winTerm::Docking::DockingCapabilities _PaneDockCapabilities() const; + void _ShowPaneDockingOverlay( + const std::shared_ptr& target, + const std::vector& zones, + const winTerm::Docking::DockPreviewModel* preview); + void _HidePaneDockingOverlay(); + bool _CommitPaneDockingPlan( + const std::shared_ptr& source, + const std::shared_ptr& target, + winTerm::Docking::DockZone zone, + const winTerm::Docking::DockingPlan& plan); + bool _DockPane( + const std::shared_ptr& source, + const std::shared_ptr& target, + winrt::Microsoft::Terminal::Settings::Model::SplitDirection direction); void _UpdateActivePane(std::shared_ptr pane); diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index fed7a0ceb..569f8f521 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -136,6 +136,24 @@ namespace winrt::TerminalApp::implementation // for it. The Title change will be propagated upwards through the tab's // PropertyChanged event handler. newTabImpl->ActivePaneChanged({ get_weak(), &TerminalPage::_activePaneChanged }); + + // A center drop from the pane docking overlay moves the existing live + // pane into a standalone tab. The Tab detaches only after a valid drop, + // so pointer cancellation never removes terminal content. + newTabImpl->PaneMoveToNewTabRequested([weakThis{ get_weak() }](std::shared_ptr pane) { + if (const auto page = weakThis.get()) + { + page->_CreateNewTabFromPane(std::move(pane)); + if (auto autoPeer = Automation::Peers::FrameworkElementAutomationPeer::FromElement(*page)) + { + autoPeer.RaiseNotificationEvent( + Automation::Peers::AutomationNotificationKind::ActionCompleted, + Automation::Peers::AutomationNotificationProcessing::ImportantMostRecent, + RS_(L"TerminalPage_PaneMovedAnnouncement_NewTab"), + L"TerminalPagePaneDragToNewTab"); + } + } + }); // The RaiseVisualBell event has been bubbled up to here from the pane, // the next part of the chain is bubbling up to app logic, which will From 17ce3acde41e70dba30f1b0f93b5b73013292056 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Thu, 23 Jul 2026 01:12:05 +0800 Subject: [PATCH 2/2] Link docking model into TerminalApp --- .../TerminalApp/dll/TerminalApp.vcxproj | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/cascadia/TerminalApp/dll/TerminalApp.vcxproj b/src/cascadia/TerminalApp/dll/TerminalApp.vcxproj index 69ccb921f..11cbe51bc 100644 --- a/src/cascadia/TerminalApp/dll/TerminalApp.vcxproj +++ b/src/cascadia/TerminalApp/dll/TerminalApp.vcxproj @@ -70,17 +70,26 @@ - - - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F} - + manually reference the lib. Keep this before the Settings Model static + library so /INCLUDE:DllMain resolves to TerminalAppLib's entry point. --> true true + + + false + + + + {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F} +