diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs
index bb6b19a49..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,29 +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 == (BorderHitEdges.Top | BorderHitEdges.Right))
+ {
+ const int cornerWidth = 1;
+ if (x < windowRect.right - cornerWidth)
+ {
+ 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,
diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
index 22e4dee0d..acc0f7776 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? headerTrailingUiElement = TrailingContent as UIElement;
+
+ isMouseOverHeaderContent =
+ (headerLeftUIElement is not null
+ && headerLeftUIElement != _titleBlock
+ && TitleBarButton.IsMouseOverNonClient(headerLeftUIElement, lParam)) || (headerCenterUIElement is not null
+ && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerTrailingUiElement is not null
+ && TitleBarButton.IsMouseOverNonClient(headerTrailingUiElement, 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 System.Drawing.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..5cf41c48c 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 System.Drawing.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..b0a21376b 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,5 @@ 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);
}
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