From b0e75dee9d9f464b43b07056442de6d3368245ee Mon Sep 17 00:00:00 2001 From: Z-a-r-a-k-i Date: Sun, 30 Nov 2025 12:47:06 +0000 Subject: [PATCH] Add smooth tab reorder animation during drag --- src/cascadia/TerminalApp/TabManagement.cpp | 6 + .../TerminalApp/TabReorderAnimator.cpp | 335 ++++++++++++++++++ src/cascadia/TerminalApp/TabReorderAnimator.h | 42 +++ .../TerminalApp/TerminalAppLib.vcxproj | 2 + src/cascadia/TerminalApp/TerminalPage.cpp | 43 ++- src/cascadia/TerminalApp/TerminalPage.h | 4 + 6 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 src/cascadia/TerminalApp/TabReorderAnimator.cpp create mode 100644 src/cascadia/TerminalApp/TabReorderAnimator.h diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index ed57887cdd9..6fec6150a0c 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -1096,6 +1096,12 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_TabDragCompleted(const IInspectable& /*sender*/, const IInspectable& /*eventArgs*/) { + // Complete smooth reorder animation + if (_tabReorderAnimator) + { + _tabReorderAnimator->OnDragCompleted(); + } + auto& from{ _rearrangeFrom }; auto& to{ _rearrangeTo }; diff --git a/src/cascadia/TerminalApp/TabReorderAnimator.cpp b/src/cascadia/TerminalApp/TabReorderAnimator.cpp new file mode 100644 index 00000000000..a250f7a124e --- /dev/null +++ b/src/cascadia/TerminalApp/TabReorderAnimator.cpp @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "TabReorderAnimator.h" + +using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::UI::Xaml; +using namespace winrt::Windows::UI::Xaml::Media; +using namespace winrt::Windows::UI::Xaml::Media::Animation; + +namespace MUX = winrt::Microsoft::UI::Xaml::Controls; + +static constexpr int AnimationDurationMs = 200; +static constexpr double DefaultTabWidthFallback = 200.0; + +namespace winrt::TerminalApp::implementation +{ + TabReorderAnimator::TabReorderAnimator(const MUX::TabView& tabView, bool animationsEnabled) : + _tabView{ tabView }, + _animationsEnabled{ animationsEnabled } + { + } + + void TabReorderAnimator::SetAnimationsEnabled(bool enabled) + { + _animationsEnabled = enabled; + } + + void TabReorderAnimator::OnDragStarting(uint32_t draggedTabIndex) + { + _isDragging = true; + _draggedTabIndex = static_cast(draggedTabIndex); + _currentGapIndex = _draggedTabIndex; + + _EnsureTransformsSetup(); + _DisableBuiltInTransitions(); + } + + void TabReorderAnimator::OnDragOver(const DragEventArgs& e) + { + if (!_isDragging && _draggedTabIndex < 0) + { + // Cross-window drag initialization + _isDragging = true; + _draggedTabIndex = -1; + _currentGapIndex = -1; + _EnsureTransformsSetup(); + _DisableBuiltInTransitions(); + } + + const auto pos = e.GetPosition(_tabView); + const auto newGapIndex = _CalculateGapIndex(pos.X); + + if (newGapIndex != _currentGapIndex) + { + _AnimateTabsToMakeGap(newGapIndex); + } + } + + void TabReorderAnimator::OnDragCompleted() + { + // Snap transforms back immediately (no animation) so we don't conflict + // with TabView's built-in reorder animation + _ResetAllTransforms(false); + _RestoreBuiltInTransitions(); + + _isDragging = false; + _draggedTabIndex = -1; + _currentGapIndex = -1; + _transforms.clear(); + } + + void TabReorderAnimator::OnDragLeave() + { + _ResetAllTransforms(true); + _RestoreBuiltInTransitions(); + + _isDragging = false; + _draggedTabIndex = -1; + _currentGapIndex = -1; + _transforms.clear(); + } + + void TabReorderAnimator::_EnsureTransformsSetup() + { + _StopAllAnimations(); + _transforms.clear(); + + const auto tabCount = _tabView.TabItems().Size(); + + for (uint32_t i = 0; i < tabCount; i++) + { + if (const auto item = _tabView.ContainerFromIndex(i).try_as()) + { + auto transform = item.RenderTransform().try_as(); + if (!transform) + { + transform = TranslateTransform{}; + item.RenderTransform(transform); + } + transform.X(0.0); + _transforms.push_back(transform); + } + else + { + _transforms.push_back(nullptr); + } + } + } + + int TabReorderAnimator::_CalculateGapIndex(double pointerX) const + { + const auto tabCount = static_cast(_tabView.TabItems().Size()); + + for (int i = 0; i < tabCount; i++) + { + if (i == _draggedTabIndex) + { + continue; + } + + if (const auto item = _tabView.ContainerFromIndex(i).try_as()) + { + const auto itemTransform = item.TransformToVisual(_tabView); + const auto itemPos = itemTransform.TransformPoint({ 0, 0 }); + const auto tabMidpoint = itemPos.X + (item.ActualWidth() / 2); + + if (pointerX < tabMidpoint) + { + return i; + } + } + } + + return tabCount; + } + + double TabReorderAnimator::_GetTabWidth() const + { + const auto tabCount = _tabView.TabItems().Size(); + + for (uint32_t i = 0; i < tabCount; i++) + { + if (static_cast(i) != _draggedTabIndex) + { + if (const auto item = _tabView.ContainerFromIndex(i).try_as()) + { + return item.ActualWidth(); + } + } + } + + if (_draggedTabIndex >= 0 && _draggedTabIndex < static_cast(tabCount)) + { + if (const auto item = _tabView.ContainerFromIndex(_draggedTabIndex).try_as()) + { + return item.ActualWidth(); + } + } + + return DefaultTabWidthFallback; + } + + void TabReorderAnimator::_AnimateTabsToMakeGap(int gapIndex) + { + _currentGapIndex = gapIndex; + const auto tabWidth = _GetTabWidth(); + const auto tabCount = static_cast(_transforms.size()); + + _StopAllAnimations(); + + for (int i = 0; i < tabCount; i++) + { + if (i == _draggedTabIndex) + { + continue; + } + + auto& transform = _transforms[i]; + if (!transform) + { + continue; + } + + double targetOffset = 0.0; + + // Only animate for same-window drags. Cross-window drags don't shift tabs + // because there's no source gap to fill, and shifting right would push + // tabs off-screen. + if (_draggedTabIndex >= 0) + { + if (_draggedTabIndex < gapIndex) + { + if (i > _draggedTabIndex && i < gapIndex) + { + targetOffset = -tabWidth; + } + } + else if (_draggedTabIndex > gapIndex) + { + if (i >= gapIndex && i < _draggedTabIndex) + { + targetOffset = tabWidth; + } + } + } + + _AnimateTransformTo(transform, targetOffset); + } + } + + void TabReorderAnimator::_AnimateTransformTo(const TranslateTransform& transform, double targetX) + { + if (!transform) + { + return; + } + + if (!_animationsEnabled) + { + transform.X(targetX); + return; + } + + if (std::abs(transform.X() - targetX) < 0.5) + { + transform.X(targetX); + return; + } + + const auto duration = DurationHelper::FromTimeSpan( + TimeSpan{ std::chrono::milliseconds(AnimationDurationMs) }); + + DoubleAnimation animation; + animation.Duration(duration); + animation.To(targetX); + animation.EasingFunction(QuadraticEase{}); + animation.EnableDependentAnimation(true); + + Storyboard sb; + sb.Duration(duration); + sb.Children().Append(animation); + sb.SetTarget(animation, transform); + sb.SetTargetProperty(animation, L"X"); + + _activeAnimations.push_back(sb); + sb.Begin(); + } + + void TabReorderAnimator::_StopAllAnimations() + { + for (auto& anim : _activeAnimations) + { + if (anim) + { + anim.Stop(); + } + } + _activeAnimations.clear(); + } + + void TabReorderAnimator::_ResetAllTransforms(bool animated) + { + _StopAllAnimations(); + + for (auto& transform : _transforms) + { + if (transform) + { + if (animated && _animationsEnabled) + { + _AnimateTransformTo(transform, 0.0); + } + else + { + transform.X(0.0); + } + } + } + } + + void TabReorderAnimator::_DisableBuiltInTransitions() + { + try + { + const auto childCount = VisualTreeHelper::GetChildrenCount(_tabView); + for (int32_t i = 0; i < childCount; i++) + { + if (const auto listView = VisualTreeHelper::GetChild(_tabView, i).try_as()) + { + if (!_transitionsSaved) + { + _savedTransitions = listView.ItemContainerTransitions(); + _transitionsSaved = true; + } + listView.ItemContainerTransitions(nullptr); + break; + } + } + } + catch (...) + { + // Do nothing on failure - visual tree structure may vary + } + } + + void TabReorderAnimator::_RestoreBuiltInTransitions() + { + if (!_transitionsSaved) + { + return; + } + + try + { + const auto childCount = VisualTreeHelper::GetChildrenCount(_tabView); + for (int32_t i = 0; i < childCount; i++) + { + if (const auto listView = VisualTreeHelper::GetChild(_tabView, i).try_as()) + { + listView.ItemContainerTransitions(_savedTransitions); + break; + } + } + } + catch (...) + { + // Do nothing on failure - visual tree structure may vary + } + + _savedTransitions = nullptr; + _transitionsSaved = false; + } +} diff --git a/src/cascadia/TerminalApp/TabReorderAnimator.h b/src/cascadia/TerminalApp/TabReorderAnimator.h new file mode 100644 index 00000000000..b3e82ad3e4c --- /dev/null +++ b/src/cascadia/TerminalApp/TabReorderAnimator.h @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +namespace winrt::TerminalApp::implementation +{ + class TabReorderAnimator + { + public: + TabReorderAnimator(const Microsoft::UI::Xaml::Controls::TabView& tabView, bool animationsEnabled); + + void OnDragStarting(uint32_t draggedTabIndex); + void OnDragOver(const Windows::UI::Xaml::DragEventArgs& e); + void OnDragCompleted(); + void OnDragLeave(); + + void SetAnimationsEnabled(bool enabled); + + private: + void _AnimateTabsToMakeGap(int gapIndex); + void _ResetAllTransforms(bool animated); + int _CalculateGapIndex(double pointerX) const; + void _EnsureTransformsSetup(); + double _GetTabWidth() const; + void _AnimateTransformTo(const Windows::UI::Xaml::Media::TranslateTransform& transform, double targetX); + void _StopAllAnimations(); + void _DisableBuiltInTransitions(); + void _RestoreBuiltInTransitions(); + + Microsoft::UI::Xaml::Controls::TabView _tabView{ nullptr }; + int _draggedTabIndex{ -1 }; + int _currentGapIndex{ -1 }; + std::vector _transforms; + std::vector _activeAnimations; + bool _animationsEnabled{ true }; + bool _isDragging{ false }; + + Windows::UI::Xaml::Media::Animation::TransitionCollection _savedTransitions{ nullptr }; + bool _transitionsSaved{ false }; + }; +} diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index 322b30b576a..1e646ea505e 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -174,6 +174,7 @@ TerminalPaneContent.idl + SuggestionsControl.xaml @@ -287,6 +288,7 @@ + SuggestionsControl.xaml diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index cc85d5a5618..8f37926f686 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -417,6 +417,11 @@ namespace winrt::TerminalApp::implementation _tabView.TabStripDragOver({ this, &TerminalPage::_onTabStripDragOver }); _tabView.TabStripDrop({ this, &TerminalPage::_onTabStripDrop }); _tabView.TabDroppedOutside({ this, &TerminalPage::_onTabDroppedOutside }); + _tabView.DragLeave({ this, &TerminalPage::_onTabStripDragLeave }); + + _tabReorderAnimator = std::make_unique( + _tabView, + !_settings.GlobalSettings().DisableAnimations()); _CreateNewTabFlyout(); @@ -3822,7 +3827,12 @@ namespace winrt::TerminalApp::implementation // Settings AllowDependentAnimations will affect whether animations are // enabled application-wide, so we don't need to check it each time we // want to create an animation. - WUX::Media::Animation::Timeline::AllowDependentAnimations(!_settings.GlobalSettings().DisableAnimations()); + const auto animationsEnabled = !_settings.GlobalSettings().DisableAnimations(); + WUX::Media::Animation::Timeline::AllowDependentAnimations(animationsEnabled); + if (_tabReorderAnimator) + { + _tabReorderAnimator->SetAnimationsEnabled(animationsEnabled); + } _tabRow.ShowElevationShield(IsRunningElevated() && _settings.GlobalSettings().ShowAdminShield()); @@ -5473,6 +5483,16 @@ namespace winrt::TerminalApp::implementation // We're going to be asked for this. _stashed.draggedTab = tabImpl; + // Start smooth reorder animation for same-window drags + if (_tabReorderAnimator) + { + uint32_t draggedIndex; + if (_tabs.IndexOf(tabBase, draggedIndex)) + { + _tabReorderAnimator->OnDragStarting(draggedIndex); + } + } + // Stash the offset from where we started the drag to the // tab's origin. We'll use that offset in the future to help // position the dropped window. @@ -5516,12 +5536,29 @@ namespace winrt::TerminalApp::implementation (winrt::unbox_value_or(props.TryLookup(L"pid"), 0u) == GetCurrentProcessId())) { e.AcceptedOperation(DataPackageOperation::Move); + + // Animate tabs to show where the dragged tab will be inserted + if (_tabReorderAnimator) + { + _tabReorderAnimator->OnDragOver(e); + } } // You may think to yourself, this is a great place to increase the // width of the TabView artificially, to make room for the new tab item. - // However, we'll never get a message that the tab left the tab view - // (without being dropped). So there's no good way to resize back down. + // However, TabView doesn't have a TabStripDragLeave event, so there's + // no good way to resize back down. Instead, we use TabReorderAnimator + // to shift existing tabs via transforms, reset via the generic DragLeave. + } + + void TerminalPage::_onTabStripDragLeave(const winrt::Windows::Foundation::IInspectable& /*sender*/, + const winrt::Windows::UI::Xaml::DragEventArgs& /*e*/) + { + // When the drag leaves the tab strip, reset the gap animation + if (_tabReorderAnimator) + { + _tabReorderAnimator->OnDragLeave(); + } } // Method Description: diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 10b58e175dc..9f8ba4ee924 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -13,6 +13,7 @@ #include "RequestMoveContentArgs.g.h" #include "LaunchPositionRequest.g.h" #include "Toast.h" +#include "TabReorderAnimator.h" #include "WindowsPackageManagerFactory.h" @@ -291,6 +292,8 @@ namespace winrt::TerminalApp::implementation winrt::Windows::Foundation::Point dragOffset{ 0, 0 }; } _stashed; + std::unique_ptr _tabReorderAnimator; + safe_void_coroutine _NewTerminalByDrop(const Windows::Foundation::IInspectable&, winrt::Windows::UI::Xaml::DragEventArgs e); __declspec(noinline) CommandPalette _loadCommandPaletteSlowPath(); @@ -551,6 +554,7 @@ namespace winrt::TerminalApp::implementation void _onTabStripDragOver(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::UI::Xaml::DragEventArgs& e); void _onTabStripDrop(winrt::Windows::Foundation::IInspectable sender, winrt::Windows::UI::Xaml::DragEventArgs e); void _onTabDroppedOutside(winrt::Windows::Foundation::IInspectable sender, winrt::Microsoft::UI::Xaml::Controls::TabViewTabDroppedOutsideEventArgs e); + void _onTabStripDragLeave(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::UI::Xaml::DragEventArgs& e); void _DetachPaneFromWindow(std::shared_ptr pane); void _DetachTabFromWindow(const winrt::com_ptr& tabImpl);