Преглед изворни кода

整理 ModernTooltipService

龙腾猫跃 пре 4 месеци
родитељ
комит
7d2d656f23
2 измењених фајлова са 153 додато и 95 уклоњено
  1. 145 95
      Wpf/Controls/ModernTooltipService.cs
  2. 8 0
      Wpf/Main.cs

+ 145 - 95
Wpf/Controls/ModernTooltip.cs → Wpf/Controls/ModernTooltipService.cs

@@ -1,4 +1,6 @@
-using System.Windows;
+// 此文件主体由 AI 生成,应作为独立模块,尽量减少与其他内容的耦合。
+
+using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Controls;
 using System.Windows.Controls.Primitives;
 using System.Windows.Controls.Primitives;
 using System.Windows.Input;
 using System.Windows.Input;
@@ -6,15 +8,19 @@ using System.Windows.Media;
 using System.Windows.Media.Animation;
 using System.Windows.Media.Animation;
 using System.Windows.Media.Effects;
 using System.Windows.Media.Effects;
 using System.Windows.Threading;
 using System.Windows.Threading;
+
 namespace MeloongCore.Wpf;
 namespace MeloongCore.Wpf;
+public static class ModernTooltipService {
 
 
-public static class ModernToolTipService {
-    public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
-        "IsEnabled", typeof(bool), typeof(ModernToolTipService), new PropertyMetadata(true));
+    // 控制是否在控件上启用现代化 Tooltip;默认为 true。
+    public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
+    public static bool GetIsEnabled(DependencyObject element) => (bool) element.GetValue(IsEnabledProperty);
 
 
-    public static readonly DependencyProperty FollowMouseProperty = DependencyProperty.RegisterAttached(
-        "FollowMouse", typeof(bool), typeof(ModernToolTipService), new PropertyMetadata(true));
+    // 控制 Tooltip 是否跟随鼠标移动;默认为 true。
+    public static void SetFollowMouse(DependencyObject element, bool value) => element.SetValue(FollowMouseProperty, value);
+    public static bool GetFollowMouse(DependencyObject element) => (bool) element.GetValue(FollowMouseProperty);
 
 
+    // 样式配置
     private static readonly TimeSpan animationTime = TimeSpan.FromMilliseconds(70);
     private static readonly TimeSpan animationTime = TimeSpan.FromMilliseconds(70);
     private static readonly int shadowRadius = 18;
     private static readonly int shadowRadius = 18;
     private static readonly float shadowOpacity = 0.15f;
     private static readonly float shadowOpacity = 0.15f;
@@ -22,7 +28,43 @@ public static class ModernToolTipService {
     private static readonly Brush backgroundBrush = new SolidColorBrush(Colors.White);
     private static readonly Brush backgroundBrush = new SolidColorBrush(Colors.White);
     private static readonly Brush borderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xD6, 0xD6));
     private static readonly Brush borderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xD6, 0xD6));
     private static readonly Brush foregroundBrush = new SolidColorBrush(Color.FromRgb(0x52, 0x52, 0x52));
     private static readonly Brush foregroundBrush = new SolidColorBrush(Color.FromRgb(0x52, 0x52, 0x52));
+
+    /// <summary>
+    /// 全局启用现代 Tooltip。启用后会接管所有 <see cref="FrameworkElement.ToolTip"/>。
+    /// </summary>
+    internal static void Init() {
+        if (initialized) return;
+        initialized = true;
+
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseEnterEvent, new MouseEventHandler(OnMouseEnter), true);
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseMoveEvent, new MouseEventHandler(OnMouseMove), true);
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseLeaveEvent, new MouseEventHandler(OnMouseLeave), true);
+
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), FrameworkElement.UnloadedEvent, new RoutedEventHandler(static (sender, _) => {
+            if (sender is FrameworkElement owner && ReferenceEquals(owner, currentOwner)) Close(false);
+        }), true);
+
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), ToolTipService.ToolTipOpeningEvent, new ToolTipEventHandler(static (sender, e) => {
+            if (sender is FrameworkElement owner && GetIsEnabled(owner) && TryGetToolTip(owner, out _, out _)) e.Handled = true;
+        }), true);
+
+        // Loaded 负责常规路径;PreviewMouseDown 兜底处理 Init 晚于控件 Loaded 的情况。
+        EventManager.RegisterClassHandler(typeof(ComboBox), FrameworkElement.LoadedEvent, new RoutedEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
+        EventManager.RegisterClassHandler(typeof(ComboBox), UIElement.PreviewMouseDownEvent, new MouseButtonEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
+    }
+
+    #region 附加属性与运行状态
+
+    // 附加属性
+    public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
+        "IsEnabled", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(true));
+    public static readonly DependencyProperty FollowMouseProperty = DependencyProperty.RegisterAttached(
+        "FollowMouse", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(true));
+    private static readonly DependencyProperty IsComboBoxDropDownHookedProperty = DependencyProperty.RegisterAttached(
+        "IsComboBoxDropDownHooked", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(false));
+
     private static readonly DispatcherTimer openTimer = new();
     private static readonly DispatcherTimer openTimer = new();
+    private static readonly DispatcherTimer leaveWatchTimer = new() { Interval = TimeSpan.FromMilliseconds(50) };
 
 
     private static bool initialized;
     private static bool initialized;
     private static int animationToken;
     private static int animationToken;
@@ -34,44 +76,47 @@ public static class ModernToolTipService {
     private static ToolTip? borrowedToolTip;
     private static ToolTip? borrowedToolTip;
     private static object? borrowedContent;
     private static object? borrowedContent;
 
 
-    static ModernToolTipService() {
+    static ModernTooltipService() {
         openTimer.Tick += (_, _) => {
         openTimer.Tick += (_, _) => {
             openTimer.Stop();
             openTimer.Stop();
             if (currentOwner is not null) Show(currentOwner, lastMousePoint);
             if (currentOwner is not null) Show(currentOwner, lastMousePoint);
         };
         };
-    }
 
 
-    /// <summary>
-    /// 全局启用现代 Tooltip。启用后会接管所有 <see cref="FrameworkElement.ToolTip"/>。
-    /// </summary>
-    public static void Initialize() {
-        if (initialized) return;
-        initialized = true;
-
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseEnterEvent, new MouseEventHandler(OnMouseEnter), true);
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseMoveEvent, new MouseEventHandler(OnMouseMove), true);
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseLeaveEvent, new MouseEventHandler(OnMouseLeave), true);
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), FrameworkElement.UnloadedEvent, new RoutedEventHandler(OnUnloaded), true);
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), ToolTipService.ToolTipOpeningEvent, new ToolTipEventHandler(OnNativeToolTipOpening), true);
-    }
+        leaveWatchTimer.Tick += (_, _) => {
+            if (currentOwner is null) {
+                leaveWatchTimer.Stop();
+                return;
+            }
 
 
-    public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
+            if (TryGetCursorPoint(currentOwner, out var point)) {
+                lastMousePoint = point;
+                if (GetFollowMouse(currentOwner) && popup is { IsOpen: true }) UpdatePosition(currentOwner, point);
+                if (currentOwner.IsMouseOver) leaveWatchTimer.Stop();
+                return;
+            }
 
 
-    public static bool GetIsEnabled(DependencyObject element) => (bool) element.GetValue(IsEnabledProperty);
+            Close(true);
+        };
+    }
 
 
-    public static void SetFollowMouse(DependencyObject element, bool value) => element.SetValue(FollowMouseProperty, value);
+    #endregion
 
 
-    public static bool GetFollowMouse(DependencyObject element) => (bool) element.GetValue(FollowMouseProperty);
+    #region 全局鼠标事件
 
 
     private static void OnMouseEnter(object sender, MouseEventArgs e) {
     private static void OnMouseEnter(object sender, MouseEventArgs e) {
-        if (sender is not FrameworkElement owner || !GetIsEnabled(owner) || GetToolTipContent(owner) is null) return;
-        if (ReferenceEquals(currentOwner, owner) && popup is { IsOpen: true }) return;
+        if (sender is not FrameworkElement owner || !GetIsEnabled(owner) || !TryGetToolTip(owner, out _, out _)) return;
+        if (ReferenceEquals(currentOwner, owner) && popup is { IsOpen: true }) {
+            leaveWatchTimer.Stop();
+            return;
+        }
 
 
+        if (owner is ComboBox comboBox) HookComboBoxDropDown(comboBox);
         if (!ReferenceEquals(currentOwner, owner)) HideImmediately();
         if (!ReferenceEquals(currentOwner, owner)) HideImmediately();
 
 
         currentOwner = owner;
         currentOwner = owner;
         lastMousePoint = e.GetPosition(owner);
         lastMousePoint = e.GetPosition(owner);
         openTimer.Stop();
         openTimer.Stop();
+        leaveWatchTimer.Stop();
 
 
         var delay = Math.Max(0, ToolTipService.GetInitialShowDelay(owner));
         var delay = Math.Max(0, ToolTipService.GetInitialShowDelay(owner));
         if (delay == 0) {
         if (delay == 0) {
@@ -90,31 +135,50 @@ public static class ModernToolTipService {
     }
     }
 
 
     private static void OnMouseLeave(object sender, MouseEventArgs e) {
     private static void OnMouseLeave(object sender, MouseEventArgs e) {
-        if (sender is FrameworkElement owner && ReferenceEquals(owner, currentOwner)) Close(true);
-    }
+        if (sender is not FrameworkElement owner || !ReferenceEquals(owner, currentOwner)) return;
 
 
-    private static void OnUnloaded(object sender, RoutedEventArgs e) {
-        if (sender is FrameworkElement owner && ReferenceEquals(owner, currentOwner)) Close(false);
+        // TextBox 点击时,内部元素可能短暂捕获鼠标并触发一次假的 MouseLeave。
+        // 若确认鼠标还在控件内,则短暂轮询;避免 WPF 不再补发真正 MouseLeave 时 Tooltip 残留。
+        owner.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => {
+            if (!ReferenceEquals(owner, currentOwner)) return;
+
+            if (TryGetCursorPoint(owner, out _)) {
+                leaveWatchTimer.Start();
+                return;
+            }
+
+            Close(true);
+        }));
     }
     }
 
 
-    private static void OnNativeToolTipOpening(object sender, ToolTipEventArgs e) {
-        if (sender is FrameworkElement owner && GetIsEnabled(owner) && GetToolTipContent(owner) is not null) e.Handled = true;
+    // 确认鼠标位置
+    private static bool TryGetCursorPoint(FrameworkElement owner, out Point point) {
+        point = default;
+        if (!owner.IsLoaded || !owner.IsVisible || !GetCursorPos(out var cursor)) return false;
+
+        point = owner.PointFromScreen(new Point(cursor.X, cursor.Y));
+        return point.X >= 0 && point.Y >= 0 && point.X <= owner.ActualWidth && point.Y <= owner.ActualHeight;
     }
     }
+    [DllImport("user32.dll")] private static extern bool GetCursorPos(out CursorPoint point);
+    [StructLayout(LayoutKind.Sequential)]
+    private struct CursorPoint {
+        public int X;
+        public int Y;
+    }
+
+    #endregion
+
+    #region Tooltip 生命周期
 
 
-    private static object? GetToolTipContent(FrameworkElement owner) {
+    private static bool TryGetToolTip(FrameworkElement owner, out object? content, out ToolTip? sourceToolTip) {
         var raw = owner.ToolTip;
         var raw = owner.ToolTip;
-        var content = raw is ToolTip toolTip ? toolTip.Content : raw;
-        return content is string text && text.Length == 0 ? null : content;
+        sourceToolTip = raw as ToolTip;
+        content = sourceToolTip?.Content ?? raw;
+        return content is not null && (content is not string text || text.Length > 0);
     }
     }
 
 
     private static void Show(FrameworkElement owner, Point point) {
     private static void Show(FrameworkElement owner, Point point) {
-        if (!ReferenceEquals(owner, currentOwner)) return;
-
-        var raw = owner.ToolTip;
-        var sourceToolTip = raw as ToolTip;
-        var content = sourceToolTip?.Content ?? raw;
-        if (content is null) return;
-        if (content is string text && text.Length == 0) return;
+        if (!ReferenceEquals(owner, currentOwner) || !TryGetToolTip(owner, out var content, out var sourceToolTip)) return;
 
 
         EnsurePopup();
         EnsurePopup();
 
 
@@ -126,6 +190,8 @@ public static class ModernToolTipService {
         popupCard.Child = null;
         popupCard.Child = null;
         RestoreBorrowedContent();
         RestoreBorrowedContent();
 
 
+        // 如果原 ToolTip.Content 是 Visual/FrameworkElement,必须先从原 ToolTip 借出,
+        // 否则同一个元素会同时拥有两个逻辑父级,导致 WPF 抛异常。
         if (sourceToolTip is not null && content is DependencyObject contentObject && ReferenceEquals(LogicalTreeHelper.GetParent(contentObject), sourceToolTip)) {
         if (sourceToolTip is not null && content is DependencyObject contentObject && ReferenceEquals(LogicalTreeHelper.GetParent(contentObject), sourceToolTip)) {
             borrowedToolTip = sourceToolTip;
             borrowedToolTip = sourceToolTip;
             borrowedContent = content;
             borrowedContent = content;
@@ -133,34 +199,23 @@ public static class ModernToolTipService {
         }
         }
 
 
         popupCard.Child = content is string stringContent && sourceToolTip?.ContentTemplate is null && sourceToolTip?.ContentTemplateSelector is null
         popupCard.Child = content is string stringContent && sourceToolTip?.ContentTemplate is null && sourceToolTip?.ContentTemplateSelector is null
-            ? new TextBlock {
-                Text = stringContent,
-                TextWrapping = TextWrapping.Wrap,
-                Foreground = foregroundBrush,
-                Margin = contentMargin,
-                FontSize = 12.5,
-                LineHeight = 17,
-                MaxWidth = 676
-            }
-            : new ContentPresenter {
-                Content = content,
-                ContentTemplate = sourceToolTip?.ContentTemplate,
-                ContentTemplateSelector = sourceToolTip?.ContentTemplateSelector,
-                ContentStringFormat = sourceToolTip?.ContentStringFormat,
-                Margin = contentMargin,
-                MaxWidth = 676
-            };
+            ? new TextBlock { 
+                Text = stringContent, TextWrapping = TextWrapping.Wrap, Foreground = foregroundBrush, Margin = contentMargin, 
+                FontSize = 12.5, LineHeight = 17, MaxWidth = 676 }
+            : new ContentPresenter { 
+                Content = content, ContentTemplate = sourceToolTip?.ContentTemplate, ContentTemplateSelector = sourceToolTip?.ContentTemplateSelector,
+                ContentStringFormat = sourceToolTip?.ContentStringFormat, Margin = contentMargin, MaxWidth = 676 };
 
 
         animationToken++;
         animationToken++;
         popupCard.Opacity = 0;
         popupCard.Opacity = 0;
-        popupScale!.ScaleX = 0.97;
-        popupScale.ScaleY = 0.97;
+        popupScale!.ScaleX = 0.97; popupScale.ScaleY = 0.97;
         popup.IsOpen = true;
         popup.IsOpen = true;
         Animate(1, 1, null);
         Animate(1, 1, null);
     }
     }
 
 
     private static void Close(bool animated) {
     private static void Close(bool animated) {
         openTimer.Stop();
         openTimer.Stop();
+        leaveWatchTimer.Stop();
         currentOwner = null;
         currentOwner = null;
 
 
         if (popup is null || popupCard is null) return;
         if (popup is null || popupCard is null) return;
@@ -177,6 +232,8 @@ public static class ModernToolTipService {
     }
     }
 
 
     private static void HideImmediately() {
     private static void HideImmediately() {
+        openTimer.Stop();
+        leaveWatchTimer.Stop();
         animationToken++;
         animationToken++;
 
 
         if (popup is not null) popup.IsOpen = false;
         if (popup is not null) popup.IsOpen = false;
@@ -201,49 +258,32 @@ public static class ModernToolTipService {
         borrowedContent = null;
         borrowedContent = null;
     }
     }
 
 
+    #endregion
+
+    #region Popup 创建、定位与动画
+
     private static void EnsurePopup() {
     private static void EnsurePopup() {
         if (popup is not null) return;
         if (popup is not null) return;
 
 
         popupScale = new ScaleTransform(0.97, 0.97);
         popupScale = new ScaleTransform(0.97, 0.97);
-        popupCard = new Border {
-            Background = backgroundBrush,
-            BorderBrush = borderBrush,
-            BorderThickness = new Thickness(1),
-            CornerRadius = new CornerRadius(8),
-            MaxWidth = 700,
-            SnapsToDevicePixels = true,
-            UseLayoutRounding = true,
-            RenderTransform = popupScale,
-            RenderTransformOrigin = new Point(0, 0),
-            Effect = new DropShadowEffect {
-                Opacity = shadowOpacity,
-                BlurRadius = shadowRadius,
-                ShadowDepth = 0,
-                Color = Colors.Black
-            }
-        };
+        popupCard = new Border { 
+            Background = backgroundBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), 
+            MaxWidth = 700, SnapsToDevicePixels = true, UseLayoutRounding = true, 
+            RenderTransform = popupScale, RenderTransformOrigin = new Point(0, 0), 
+            Effect = new DropShadowEffect { Opacity = shadowOpacity, BlurRadius = shadowRadius, ShadowDepth = 0, Color = Colors.Black } };
 
 
-        var root = new Grid {
-            Margin = new Thickness(shadowRadius + 1),
-            SnapsToDevicePixels = true,
-            UseLayoutRounding = true
-        };
+        var root = new Grid { Margin = new Thickness(shadowRadius + 1), SnapsToDevicePixels = true, UseLayoutRounding = true };
         root.Children.Add(popupCard);
         root.Children.Add(popupCard);
 
 
-        popup = new Popup {
-            AllowsTransparency = true,
-            IsHitTestVisible = false,
-            StaysOpen = true,
-            PopupAnimation = PopupAnimation.None,
-            Placement = PlacementMode.Relative,
-            Child = root
-        };
+        popup = new Popup { 
+            AllowsTransparency = true, IsHitTestVisible = false, StaysOpen = true, PopupAnimation = PopupAnimation.None, 
+            Placement = PlacementMode.Relative, Child = root };
     }
     }
 
 
     private static void UpdatePosition(FrameworkElement owner, Point point) {
     private static void UpdatePosition(FrameworkElement owner, Point point) {
         popup!.PlacementTarget = owner;
         popup!.PlacementTarget = owner;
-        popup.HorizontalOffset = Math.Round(point.X + 25);
-        popup.VerticalOffset = Math.Round(point.Y + 20);
+        popup.HorizontalOffset = Math.Round(point.X + 15);
+        popup.VerticalOffset = Math.Round(point.Y + 25);
     }
     }
 
 
     private static void Animate(double opacity, double scale, EventHandler? completed) {
     private static void Animate(double opacity, double scale, EventHandler? completed) {
@@ -260,4 +300,14 @@ public static class ModernToolTipService {
         popupScale!.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
         popupScale!.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
         popupScale.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
         popupScale.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
     }
     }
-}
+
+    #endregion
+
+    // WPF 原生 Tooltip 会在 ComboBox 下拉弹出时关闭;这里保持同样行为。
+    private static void HookComboBoxDropDown(ComboBox? comboBox) {
+        if (comboBox is null || (bool) comboBox.GetValue(IsComboBoxDropDownHookedProperty)) return;
+        comboBox.SetValue(IsComboBoxDropDownHookedProperty, true);
+        comboBox.DropDownOpened += static (_, _) => { if (currentOwner is not null) Close(true); };
+    }
+
+}

+ 8 - 0
Wpf/Main.cs

@@ -0,0 +1,8 @@
+namespace MeloongCore.Wpf;
+public static class Main {
+
+    public static void Init() {
+        ModernTooltipService.Init();
+    }
+
+}