From 346f1cdf9772f8d648f7002bea383f1618957c30 Mon Sep 17 00:00:00 2001 From: Koichi Kobayashi Date: Sat, 17 Jan 2026 11:46:11 +0900 Subject: [PATCH 1/5] Fix flickering issue when hovering over the top-right close button --- .../TitleBar/TitleBar.WindowResize.cs | 9 ++ src/Wpf.Ui/Controls/TitleBar/TitleBar.cs | 151 +++++++++++++++--- .../Controls/TitleBar/TitleBarButton.cs | 92 ++++++++++- src/Wpf.Ui/Interop/PInvoke.cs | 16 +- 4 files changed, 242 insertions(+), 26 deletions(-) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs index bb6b19a49..6294bb934 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs @@ -73,6 +73,15 @@ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam) hit |= 0b1000u; // bottom #pragma warning restore + if (hit == 0b0110u) + { + const int cornerWidth = 1; + if (x < windowRect.right - cornerWidth) + { + hit = 0b0100u; + } + } + return hit switch { 0b0101u => (IntPtr)PInvoke.HTTOPLEFT, // top + left (0b0100 | 0b0001) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs index 22e4dee0d..64758890b 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -674,6 +674,134 @@ or PInvoke.WM_NCLBUTTONUP return IntPtr.Zero; } + bool isMouseOverHeaderContent = false; + bool isMouseOverButtons = false; + IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; + + // For WM_NCHITTEST, perform resize detection first, and skip button hit testing if top-left or top-right corner resize detection succeeds + if (message == PInvoke.WM_NCHITTEST) + { + if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) + { + UIElement? headerLeftUIElement = Header as UIElement; + UIElement? headerCenterUIElement = CenterContent as UIElement; + UIElement? headerRightUiElement = TrailingContent as UIElement; + + isMouseOverHeaderContent = + (headerLeftUIElement is not null + && headerLeftUIElement != _titleBlock + && TitleBarButton.IsMouseOverNonClient(headerLeftUIElement, lParam)) || (headerCenterUIElement is not null + && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerRightUiElement is not null + && TitleBarButton.IsMouseOverNonClient(headerRightUiElement, lParam)); + } + + TitleBarButton? rightmostButton = null; + double rightmostRightEdge = double.MinValue; + + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + try + { + if (PresentationSource.FromVisual(button) is not null) + { + Point buttonTopLeft = button.PointToScreen(new Point(0, 0)); + double buttonRightEdge = buttonTopLeft.X + button.RenderSize.Width; + + if (buttonRightEdge > rightmostRightEdge) + { + rightmostRightEdge = buttonRightEdge; + rightmostButton = button; + } + } + } + catch + { + // Ignore visual transform errors and keep searching. + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + } + } + + htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + // If resize hit test succeeds, let Windows handle it + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + handled = true; + return htResult; + } + + if (rightmostButton is not null + && Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT cursorPoint)) + { + Point cursorPosition = new(cursorPoint.X, cursorPoint.Y); + + try + { + Point rightmostTopLeft = rightmostButton.PointToScreen(new Point(0, 0)); + double rightEdge = rightmostTopLeft.X + rightmostButton.RenderSize.Width; + double leftEdge = rightEdge - 1; + double bottomEdge = rightmostTopLeft.Y + rightmostButton.RenderSize.Height; + + if ( + cursorPosition.X >= leftEdge + && cursorPosition.X <= rightEdge + && cursorPosition.Y >= rightmostTopLeft.Y + && cursorPosition.Y <= bottomEdge + ) + { + handled = true; + return (IntPtr)PInvoke.HTRIGHT; + } + } + catch + { + // Ignore transform errors and fall back to default hit testing. + } + } + + if (isMouseOverButtons) + { + htResult = (IntPtr)PInvoke.HTNOWHERE; + } + } + else if (message == PInvoke.WM_NCLBUTTONDOWN) + { + // For WM_NCLBUTTONDOWN, also skip button hit testing if within top-left or top-right corner resize area + // This ensures resize handling works correctly + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + break; + } + } + + htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + // If within resize area, skip button hit testing + // and let Windows handle the default resize processing + handled = false; + return IntPtr.Zero; + } + } + foreach (TitleBarButton button in _buttons) { if (!button.ReactToHwndHook(message, lParam, out IntPtr returnIntPtr)) @@ -699,25 +827,6 @@ or PInvoke.WM_NCLBUTTONUP return returnIntPtr; } - bool isMouseOverHeaderContent = false; - IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; - - if (message == PInvoke.WM_NCHITTEST) - { - if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) - { - UIElement? headerLeftUIElement = Header as UIElement; - UIElement? headerCenterUIElement = CenterContent as UIElement; - UIElement? headerRightUiElement = TrailingContent as UIElement; - - isMouseOverHeaderContent = (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock && headerLeftUIElement.IsMouseOverElement(lParam)) - || (headerCenterUIElement?.IsMouseOverElement(lParam) ?? false) - || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false); - } - - htResult = GetWindowBorderHitTestResult(hwnd, lParam); - } - var e = new HwndProcEventArgs(hwnd, msg, wParam, lParam, isMouseOverHeaderContent); WndProcInvoked?.Invoke(this, e); @@ -729,14 +838,14 @@ or PInvoke.WM_NCLBUTTONUP switch (message) { - case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && _icon.IsMouseOverElement(lParam): + case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && TitleBarButton.IsMouseOverNonClient(_icon, lParam): // Ideally, clicking on the icon should open the system menu, but when the system menu is opened manually, double-clicking on the icon does not close the window handled = true; return (IntPtr)PInvoke.HTSYSMENU; case PInvoke.WM_NCHITTEST when htResult != (IntPtr)PInvoke.HTNOWHERE: handled = true; return htResult; - case PInvoke.WM_NCHITTEST when this.IsMouseOverElement(lParam) && !isMouseOverHeaderContent: + case PInvoke.WM_NCHITTEST when TitleBarButton.IsMouseOverNonClient(this, lParam) && !isMouseOverHeaderContent: handled = true; return (IntPtr)PInvoke.HTCAPTION; default: diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs index 56bfca17c..f48c159ac 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs @@ -12,6 +12,92 @@ namespace Wpf.Ui.Controls; public class TitleBarButton : Wpf.Ui.Controls.Button { + // We intentionally keep this logic local to TitleBar components to avoid changing + // global hit-testing behavior for other controls. + internal static bool IsMouseOverNonClient(UIElement element, IntPtr lParam, double tolerance = 1.0) + { + // This will be invoked very often and must be as simple as possible. + if (lParam == IntPtr.Zero) + { + return false; + } + + try + { + // Ensure the visual is connected to a presentation source (needed for PointFromScreen). + if (PresentationSource.FromVisual(element) == null) + { + return false; + } + + var mousePosition = TryGetCursorPos(out Point cursorPosition) + ? cursorPosition + : GetLParamPoint(lParam); + + // Add a small tolerance to reduce hover flicker at pixel boundaries (rounding/DPI edge cases). + var hitRect = new Rect( + -tolerance, + -tolerance, + element.RenderSize.Width + (2 * tolerance), + element.RenderSize.Height + (2 * tolerance) + ); + + if (!hitRect.Contains(element.PointFromScreen(mousePosition)) || !element.IsHitTestVisible) + { + return false; + } + + // If element is Panel, check if children at mousePosition is with IsHitTestVisible false. + if (element is System.Windows.Controls.Panel panel) + { + foreach (UIElement child in panel.Children) + { + var childHitRect = new Rect( + -tolerance, + -tolerance, + child.RenderSize.Width + (2 * tolerance), + child.RenderSize.Height + (2 * tolerance) + ); + + if (childHitRect.Contains(child.PointFromScreen(mousePosition))) + { + return child.IsHitTestVisible; + } + } + + return false; + } + + return true; + } + catch + { + return false; + } + } + + private static Point GetLParamPoint(IntPtr lParam) + { + long lp = lParam.ToInt64(); + int x = (short)(lp & 0xFFFF); + int y = (short)(lp >> 16); + + return new Point(x, y); + } + + private static bool TryGetCursorPos(out Point mousePosition) + { + mousePosition = default; + + if (!Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT point)) + { + return false; + } + + mousePosition = new Point(point.X, point.Y); + return true; + } + /// Identifies the dependency property. public static readonly DependencyProperty ButtonTypeProperty = DependencyProperty.Register( nameof(ButtonType), @@ -179,7 +265,7 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) switch (msg) { case PInvoke.WM_NCHITTEST: - if (this.IsMouseOverElement(lParam)) + if (IsMouseOverNonClient(this, lParam)) { /*Debug.WriteLine($"Hitting {ButtonType} | return code {_returnValue}");*/ Hover(); @@ -192,10 +278,10 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) case PInvoke.WM_NCMOUSELEAVE: // Mouse leaves the window RemoveHover(); return false; - case PInvoke.WM_NCLBUTTONDOWN when this.IsMouseOverElement(lParam): // Left button clicked down + case PInvoke.WM_NCLBUTTONDOWN when IsMouseOverNonClient(this, lParam): // Left button clicked down _isClickedDown = true; return true; - case PInvoke.WM_NCLBUTTONUP when _isClickedDown && this.IsMouseOverElement(lParam): // Left button clicked up + case PInvoke.WM_NCLBUTTONUP when _isClickedDown && IsMouseOverNonClient(this, lParam): // Left button clicked up InvokeClick(); return true; default: diff --git a/src/Wpf.Ui/Interop/PInvoke.cs b/src/Wpf.Ui/Interop/PInvoke.cs index 1d4c052c5..3723fabf7 100644 --- a/src/Wpf.Ui/Interop/PInvoke.cs +++ b/src/Wpf.Ui/Interop/PInvoke.cs @@ -4,7 +4,6 @@ // All Rights Reserved. using System.Runtime.InteropServices; -using Windows.Win32.Foundation; using Windows.Win32.UI.WindowsAndMessaging; namespace Windows.Win32; @@ -15,5 +14,18 @@ internal static partial class PInvoke DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "SetWindowLongPtrW", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32) ] - internal static extern nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); + internal static extern nint SetWindowLongPtr(Windows.Win32.Foundation.HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); + + [ + DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "GetCursorPos", SetLastError = true), + DefaultDllImportSearchPaths(DllImportSearchPath.System32) + ] + internal static extern bool GetCursorPos(out POINT lpPoint); +} + +[StructLayout(LayoutKind.Sequential)] +internal struct POINT +{ + public int X; + public int Y; } From a2b21fa3e8f7adc48145f94987cb029988092dfb Mon Sep 17 00:00:00 2001 From: Koichi Kobayashi Date: Sat, 17 Jan 2026 11:46:11 +0900 Subject: [PATCH 2/5] Fix flickering issue when hovering over the top-right close button --- .../TitleBar/TitleBar.WindowResize.cs | 9 ++ src/Wpf.Ui/Controls/TitleBar/TitleBar.cs | 151 +++++++++++++++--- .../Controls/TitleBar/TitleBarButton.cs | 92 ++++++++++- src/Wpf.Ui/Interop/PInvoke.cs | 16 +- 4 files changed, 242 insertions(+), 26 deletions(-) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs index bb6b19a49..6294bb934 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs @@ -73,6 +73,15 @@ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam) hit |= 0b1000u; // bottom #pragma warning restore + if (hit == 0b0110u) + { + const int cornerWidth = 1; + if (x < windowRect.right - cornerWidth) + { + hit = 0b0100u; + } + } + return hit switch { 0b0101u => (IntPtr)PInvoke.HTTOPLEFT, // top + left (0b0100 | 0b0001) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs index 22e4dee0d..64758890b 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -674,6 +674,134 @@ or PInvoke.WM_NCLBUTTONUP return IntPtr.Zero; } + bool isMouseOverHeaderContent = false; + bool isMouseOverButtons = false; + IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; + + // For WM_NCHITTEST, perform resize detection first, and skip button hit testing if top-left or top-right corner resize detection succeeds + if (message == PInvoke.WM_NCHITTEST) + { + if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) + { + UIElement? headerLeftUIElement = Header as UIElement; + UIElement? headerCenterUIElement = CenterContent as UIElement; + UIElement? headerRightUiElement = TrailingContent as UIElement; + + isMouseOverHeaderContent = + (headerLeftUIElement is not null + && headerLeftUIElement != _titleBlock + && TitleBarButton.IsMouseOverNonClient(headerLeftUIElement, lParam)) || (headerCenterUIElement is not null + && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerRightUiElement is not null + && TitleBarButton.IsMouseOverNonClient(headerRightUiElement, lParam)); + } + + TitleBarButton? rightmostButton = null; + double rightmostRightEdge = double.MinValue; + + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + try + { + if (PresentationSource.FromVisual(button) is not null) + { + Point buttonTopLeft = button.PointToScreen(new Point(0, 0)); + double buttonRightEdge = buttonTopLeft.X + button.RenderSize.Width; + + if (buttonRightEdge > rightmostRightEdge) + { + rightmostRightEdge = buttonRightEdge; + rightmostButton = button; + } + } + } + catch + { + // Ignore visual transform errors and keep searching. + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + } + } + + htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + // If resize hit test succeeds, let Windows handle it + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + handled = true; + return htResult; + } + + if (rightmostButton is not null + && Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT cursorPoint)) + { + Point cursorPosition = new(cursorPoint.X, cursorPoint.Y); + + try + { + Point rightmostTopLeft = rightmostButton.PointToScreen(new Point(0, 0)); + double rightEdge = rightmostTopLeft.X + rightmostButton.RenderSize.Width; + double leftEdge = rightEdge - 1; + double bottomEdge = rightmostTopLeft.Y + rightmostButton.RenderSize.Height; + + if ( + cursorPosition.X >= leftEdge + && cursorPosition.X <= rightEdge + && cursorPosition.Y >= rightmostTopLeft.Y + && cursorPosition.Y <= bottomEdge + ) + { + handled = true; + return (IntPtr)PInvoke.HTRIGHT; + } + } + catch + { + // Ignore transform errors and fall back to default hit testing. + } + } + + if (isMouseOverButtons) + { + htResult = (IntPtr)PInvoke.HTNOWHERE; + } + } + else if (message == PInvoke.WM_NCLBUTTONDOWN) + { + // For WM_NCLBUTTONDOWN, also skip button hit testing if within top-left or top-right corner resize area + // This ensures resize handling works correctly + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + break; + } + } + + htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + // If within resize area, skip button hit testing + // and let Windows handle the default resize processing + handled = false; + return IntPtr.Zero; + } + } + foreach (TitleBarButton button in _buttons) { if (!button.ReactToHwndHook(message, lParam, out IntPtr returnIntPtr)) @@ -699,25 +827,6 @@ or PInvoke.WM_NCLBUTTONUP return returnIntPtr; } - bool isMouseOverHeaderContent = false; - IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; - - if (message == PInvoke.WM_NCHITTEST) - { - if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) - { - UIElement? headerLeftUIElement = Header as UIElement; - UIElement? headerCenterUIElement = CenterContent as UIElement; - UIElement? headerRightUiElement = TrailingContent as UIElement; - - isMouseOverHeaderContent = (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock && headerLeftUIElement.IsMouseOverElement(lParam)) - || (headerCenterUIElement?.IsMouseOverElement(lParam) ?? false) - || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false); - } - - htResult = GetWindowBorderHitTestResult(hwnd, lParam); - } - var e = new HwndProcEventArgs(hwnd, msg, wParam, lParam, isMouseOverHeaderContent); WndProcInvoked?.Invoke(this, e); @@ -729,14 +838,14 @@ or PInvoke.WM_NCLBUTTONUP switch (message) { - case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && _icon.IsMouseOverElement(lParam): + case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && TitleBarButton.IsMouseOverNonClient(_icon, lParam): // Ideally, clicking on the icon should open the system menu, but when the system menu is opened manually, double-clicking on the icon does not close the window handled = true; return (IntPtr)PInvoke.HTSYSMENU; case PInvoke.WM_NCHITTEST when htResult != (IntPtr)PInvoke.HTNOWHERE: handled = true; return htResult; - case PInvoke.WM_NCHITTEST when this.IsMouseOverElement(lParam) && !isMouseOverHeaderContent: + case PInvoke.WM_NCHITTEST when TitleBarButton.IsMouseOverNonClient(this, lParam) && !isMouseOverHeaderContent: handled = true; return (IntPtr)PInvoke.HTCAPTION; default: diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs index 56bfca17c..f48c159ac 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs @@ -12,6 +12,92 @@ namespace Wpf.Ui.Controls; public class TitleBarButton : Wpf.Ui.Controls.Button { + // We intentionally keep this logic local to TitleBar components to avoid changing + // global hit-testing behavior for other controls. + internal static bool IsMouseOverNonClient(UIElement element, IntPtr lParam, double tolerance = 1.0) + { + // This will be invoked very often and must be as simple as possible. + if (lParam == IntPtr.Zero) + { + return false; + } + + try + { + // Ensure the visual is connected to a presentation source (needed for PointFromScreen). + if (PresentationSource.FromVisual(element) == null) + { + return false; + } + + var mousePosition = TryGetCursorPos(out Point cursorPosition) + ? cursorPosition + : GetLParamPoint(lParam); + + // Add a small tolerance to reduce hover flicker at pixel boundaries (rounding/DPI edge cases). + var hitRect = new Rect( + -tolerance, + -tolerance, + element.RenderSize.Width + (2 * tolerance), + element.RenderSize.Height + (2 * tolerance) + ); + + if (!hitRect.Contains(element.PointFromScreen(mousePosition)) || !element.IsHitTestVisible) + { + return false; + } + + // If element is Panel, check if children at mousePosition is with IsHitTestVisible false. + if (element is System.Windows.Controls.Panel panel) + { + foreach (UIElement child in panel.Children) + { + var childHitRect = new Rect( + -tolerance, + -tolerance, + child.RenderSize.Width + (2 * tolerance), + child.RenderSize.Height + (2 * tolerance) + ); + + if (childHitRect.Contains(child.PointFromScreen(mousePosition))) + { + return child.IsHitTestVisible; + } + } + + return false; + } + + return true; + } + catch + { + return false; + } + } + + private static Point GetLParamPoint(IntPtr lParam) + { + long lp = lParam.ToInt64(); + int x = (short)(lp & 0xFFFF); + int y = (short)(lp >> 16); + + return new Point(x, y); + } + + private static bool TryGetCursorPos(out Point mousePosition) + { + mousePosition = default; + + if (!Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT point)) + { + return false; + } + + mousePosition = new Point(point.X, point.Y); + return true; + } + /// Identifies the dependency property. public static readonly DependencyProperty ButtonTypeProperty = DependencyProperty.Register( nameof(ButtonType), @@ -179,7 +265,7 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) switch (msg) { case PInvoke.WM_NCHITTEST: - if (this.IsMouseOverElement(lParam)) + if (IsMouseOverNonClient(this, lParam)) { /*Debug.WriteLine($"Hitting {ButtonType} | return code {_returnValue}");*/ Hover(); @@ -192,10 +278,10 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) case PInvoke.WM_NCMOUSELEAVE: // Mouse leaves the window RemoveHover(); return false; - case PInvoke.WM_NCLBUTTONDOWN when this.IsMouseOverElement(lParam): // Left button clicked down + case PInvoke.WM_NCLBUTTONDOWN when IsMouseOverNonClient(this, lParam): // Left button clicked down _isClickedDown = true; return true; - case PInvoke.WM_NCLBUTTONUP when _isClickedDown && this.IsMouseOverElement(lParam): // Left button clicked up + case PInvoke.WM_NCLBUTTONUP when _isClickedDown && IsMouseOverNonClient(this, lParam): // Left button clicked up InvokeClick(); return true; default: diff --git a/src/Wpf.Ui/Interop/PInvoke.cs b/src/Wpf.Ui/Interop/PInvoke.cs index 1d4c052c5..3723fabf7 100644 --- a/src/Wpf.Ui/Interop/PInvoke.cs +++ b/src/Wpf.Ui/Interop/PInvoke.cs @@ -4,7 +4,6 @@ // All Rights Reserved. using System.Runtime.InteropServices; -using Windows.Win32.Foundation; using Windows.Win32.UI.WindowsAndMessaging; namespace Windows.Win32; @@ -15,5 +14,18 @@ internal static partial class PInvoke DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "SetWindowLongPtrW", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32) ] - internal static extern nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); + internal static extern nint SetWindowLongPtr(Windows.Win32.Foundation.HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); + + [ + DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "GetCursorPos", SetLastError = true), + DefaultDllImportSearchPaths(DllImportSearchPath.System32) + ] + internal static extern bool GetCursorPos(out POINT lpPoint); +} + +[StructLayout(LayoutKind.Sequential)] +internal struct POINT +{ + public int X; + public int Y; } From 9cd3569d5583b2291f25d72346b7580a6523724f Mon Sep 17 00:00:00 2001 From: Koichi Kobayashi Date: Tue, 17 Feb 2026 17:42:32 +0900 Subject: [PATCH 3/5] refactor(interop): use CsWin32-generated GetCursorPos and remove manual POINT interop Replaced manual GetCursorPos/POINT declarations with CsWin32-generated APIs by updating NativeMethods and title bar call sites. --- src/Wpf.Ui/Controls/TitleBar/TitleBar.cs | 2 +- src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs | 2 +- src/Wpf.Ui/Interop/PInvoke.cs | 13 ------------- src/Wpf.Ui/NativeMethods.txt | 1 + 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs index 64758890b..e8f7a6ecc 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -740,7 +740,7 @@ or PInvoke.WM_NCLBUTTONUP } if (rightmostButton is not null - && Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT cursorPoint)) + && Windows.Win32.PInvoke.GetCursorPos(out System.Drawing.Point cursorPoint)) { Point cursorPosition = new(cursorPoint.X, cursorPoint.Y); diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs index f48c159ac..5cf41c48c 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs @@ -89,7 +89,7 @@ private static bool TryGetCursorPos(out Point mousePosition) { mousePosition = default; - if (!Windows.Win32.PInvoke.GetCursorPos(out Windows.Win32.POINT point)) + if (!Windows.Win32.PInvoke.GetCursorPos(out System.Drawing.Point point)) { return false; } diff --git a/src/Wpf.Ui/Interop/PInvoke.cs b/src/Wpf.Ui/Interop/PInvoke.cs index 3723fabf7..b0a21376b 100644 --- a/src/Wpf.Ui/Interop/PInvoke.cs +++ b/src/Wpf.Ui/Interop/PInvoke.cs @@ -15,17 +15,4 @@ internal static partial class PInvoke DefaultDllImportSearchPaths(DllImportSearchPath.System32) ] internal static extern nint SetWindowLongPtr(Windows.Win32.Foundation.HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); - - [ - DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "GetCursorPos", SetLastError = true), - DefaultDllImportSearchPaths(DllImportSearchPath.System32) - ] - internal static extern bool GetCursorPos(out POINT lpPoint); -} - -[StructLayout(LayoutKind.Sequential)] -internal struct POINT -{ - public int X; - public int Y; } diff --git a/src/Wpf.Ui/NativeMethods.txt b/src/Wpf.Ui/NativeMethods.txt index fe5b8a161..15aff33b4 100644 --- a/src/Wpf.Ui/NativeMethods.txt +++ b/src/Wpf.Ui/NativeMethods.txt @@ -3,6 +3,7 @@ DwmIsCompositionEnabled DwmExtendFrameIntoClientArea SetWindowThemeAttribute GetDpiForWindow +GetCursorPos GetForegroundWindow IsWindowVisible SetWindowLong From a2424fcaaf1993129070ad502f1061b05f8198e9 Mon Sep 17 00:00:00 2001 From: Koichi Kobayashi Date: Tue, 17 Feb 2026 17:52:17 +0900 Subject: [PATCH 4/5] refactor(titlebar): replace resize hit-test magic numbers with named flags Introduce BorderHitEdges flag enum for border hit-testing and replace binary literals with named combinations to improve readability and maintainability. --- .../TitleBar/TitleBar.WindowResize.cs | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs index 6294bb934..a47a2eda3 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs @@ -35,6 +35,24 @@ namespace Wpf.Ui.Controls; /// public partial class TitleBar { + /// + /// Bit flags that represent which window border edges the cursor is currently over. + /// + [Flags] + private enum BorderHitEdges : uint + { + /// No border edge is hit. + None = 0, + /// The left border edge is hit. + Left = 1 << 0, + /// The right border edge is hit. + Right = 1 << 1, + /// The top border edge is hit. + Top = 1 << 2, + /// The bottom border edge is hit. + Bottom = 1 << 3, + } + private int _borderX; private int _borderY; @@ -60,38 +78,36 @@ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam) int x = (short)(lp & 0xFFFF); int y = (short)((lp >> 16) & 0xFFFF); - uint hit = 0u; + BorderHitEdges hit = BorderHitEdges.None; -#pragma warning disable if (x < windowRect.left + _borderX) - hit |= 0b0001u; // left + hit |= BorderHitEdges.Left; if (x >= windowRect.right - _borderX) - hit |= 0b0010u; // right + hit |= BorderHitEdges.Right; if (y < windowRect.top + _borderY) - hit |= 0b0100u; // top + hit |= BorderHitEdges.Top; if (y >= windowRect.bottom - _borderY) - hit |= 0b1000u; // bottom -#pragma warning restore + hit |= BorderHitEdges.Bottom; - if (hit == 0b0110u) + if (hit == (BorderHitEdges.Top | BorderHitEdges.Right)) { const int cornerWidth = 1; if (x < windowRect.right - cornerWidth) { - hit = 0b0100u; + hit = BorderHitEdges.Top; } } return hit switch { - 0b0101u => (IntPtr)PInvoke.HTTOPLEFT, // top + left (0b0100 | 0b0001) - 0b0110u => (IntPtr)PInvoke.HTTOPRIGHT, // top + right (0b0100 | 0b0010) - 0b1001u => (IntPtr)PInvoke.HTBOTTOMLEFT, // bottom + left (0b1000 | 0b0001) - 0b1010u => (IntPtr)PInvoke.HTBOTTOMRIGHT, // bottom + right (0b1000 | 0b0010) - 0b0100u => (IntPtr)PInvoke.HTTOP, // top - 0b0001u => (IntPtr)PInvoke.HTLEFT, // left - 0b1000u => (IntPtr)PInvoke.HTBOTTOM, // bottom - 0b0010u => (IntPtr)PInvoke.HTRIGHT, // right + BorderHitEdges.Top | BorderHitEdges.Left => (IntPtr)PInvoke.HTTOPLEFT, + BorderHitEdges.Top | BorderHitEdges.Right => (IntPtr)PInvoke.HTTOPRIGHT, + BorderHitEdges.Bottom | BorderHitEdges.Left => (IntPtr)PInvoke.HTBOTTOMLEFT, + BorderHitEdges.Bottom | BorderHitEdges.Right => (IntPtr)PInvoke.HTBOTTOMRIGHT, + BorderHitEdges.Top => (IntPtr)PInvoke.HTTOP, + BorderHitEdges.Left => (IntPtr)PInvoke.HTLEFT, + BorderHitEdges.Bottom => (IntPtr)PInvoke.HTBOTTOM, + BorderHitEdges.Right => (IntPtr)PInvoke.HTRIGHT, // no match = HTNOWHERE (stop processing) _ => (IntPtr)PInvoke.HTNOWHERE, From c7eaae4dc3cd36049f15f622003bef272c8bd7f6 Mon Sep 17 00:00:00 2001 From: Koichi Kobayashi Date: Tue, 17 Feb 2026 17:57:13 +0900 Subject: [PATCH 5/5] refactor(titlebar): rename headerRightUiElement to headerTrailingUiElement Align variable naming with TrailingContent terminology for consistency in title bar hit-testing logic. --- src/Wpf.Ui/Controls/TitleBar/TitleBar.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs index e8f7a6ecc..acc0f7776 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -685,14 +685,14 @@ or PInvoke.WM_NCLBUTTONUP { UIElement? headerLeftUIElement = Header as UIElement; UIElement? headerCenterUIElement = CenterContent as UIElement; - UIElement? headerRightUiElement = TrailingContent as UIElement; + UIElement? headerTrailingUiElement = TrailingContent as UIElement; isMouseOverHeaderContent = (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock && TitleBarButton.IsMouseOverNonClient(headerLeftUIElement, lParam)) || (headerCenterUIElement is not null - && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerRightUiElement is not null - && TitleBarButton.IsMouseOverNonClient(headerRightUiElement, lParam)); + && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerTrailingUiElement is not null + && TitleBarButton.IsMouseOverNonClient(headerTrailingUiElement, lParam)); } TitleBarButton? rightmostButton = null;