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 e52900913..4c22d6dfa 100644
--- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
+++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
@@ -408,16 +408,16 @@ public event TypedEventHandler HelpClicked
///
/// Gets or sets the that should be executed when the Maximize button is clicked."/>
///
- public Action? MaximizeActionOverride { get; set; }
+ public Action? MaximizeActionOverride { get; set; }
///
/// Gets or sets what should be executed when the Minimize button is clicked.
///
- public Action? MinimizeActionOverride { get; set; }
+ public Action? MinimizeActionOverride { get; set; }
private readonly TitleBarButton?[] _buttons = new TitleBarButton[4];
private readonly TextBlock _titleBlock;
- private System.Windows.Window _currentWindow = null!;
+ private Window _currentWindow = null!;
/*private System.Windows.Controls.Grid _mainGrid = null!;*/
private System.Windows.Controls.ContentPresenter _icon = null!;
@@ -468,7 +468,7 @@ protected virtual void OnLoaded(object sender, RoutedEventArgs e)
}
_currentWindow =
- System.Windows.Window.GetWindow(this) ?? throw new InvalidOperationException("Window is null");
+ Window.GetWindow(this) ?? throw new InvalidOperationException("Window is null");
if (_currentWindow.WindowState == WindowState.Maximized)
{
SetCurrentValue(IsMaximizedProperty, true);
@@ -674,54 +674,160 @@ or PInvoke.WM_NCLBUTTONUP
return IntPtr.Zero;
}
- foreach (TitleBarButton? button in _buttons)
+ 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)
{
- // Check if button is null to avoid potential NullReferenceException if OnApplyTemplate hasn't been called yet, e.g. when TitleBar has Visibility == Collapsed.
- if (button is null || !button.ReactToHwndHook(message, lParam, out IntPtr returnIntPtr))
+ if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement)
{
- continue;
+ 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));
}
- // Fix for when sometimes, button hover backgrounds aren't cleared correctly, causing multiple buttons to appear as if hovered.
- foreach (TitleBarButton? anotherButton in _buttons)
+ TitleBarButton? rightmostButton = null;
+ double rightmostRightEdge = double.MinValue;
+
+ foreach (TitleBarButton button in _buttons)
{
- if (anotherButton is null || anotherButton == button)
+ if (button is null)
{
continue;
}
- if (anotherButton.IsHovered && button.IsHovered)
+ try
{
- anotherButton.RemoveHover();
+ if (PresentationSource.FromVisual(button) is not null)
+ {
+ double buttonRightEdge = button.PointToScreen(new Point(button.RenderSize.Width, 0)).X;
+
+ if (buttonRightEdge > rightmostRightEdge)
+ {
+ rightmostRightEdge = buttonRightEdge;
+ rightmostButton = button;
+ }
+ }
+ }
+ catch
+ {
+ // Ignore visual transform errors and keep searching.
+ }
+
+ if (TitleBarButton.IsMouseOverNonClient(button, lParam))
+ {
+ isMouseOverButtons = true;
}
}
- handled = true;
- return returnIntPtr;
- }
+ htResult = GetWindowBorderHitTestResult(hwnd, lParam);
- bool isMouseOverHeaderContent = false;
- IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE;
+ // Resize zones always take priority over buttons, matching native Windows behavior.
+ // The resize strip occupies the outermost few pixels of each edge; GetWindowBorderHitTestResult
+ // operates in physical pixels throughout, so the zone is correctly positioned at any DPI.
+ if (htResult != (IntPtr)PInvoke.HTNOWHERE)
+ {
+ RemoveButtonHovers();
+ handled = true;
+ return htResult;
+ }
- if (message == PInvoke.WM_NCHITTEST)
- {
- if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement)
+ if (rightmostButton is not null
+ && PInvoke.GetCursorPos(out System.Drawing.Point cursorPoint))
{
- UIElement? headerLeftUIElement = Header as UIElement;
- UIElement? headerCenterUIElement = CenterContent as UIElement;
- UIElement? headerRightUiElement = TrailingContent as UIElement;
+ Point cursorPosition = new(cursorPoint.X, cursorPoint.Y);
- isMouseOverHeaderContent =
- (
- headerLeftUIElement is not null
- && headerLeftUIElement != _titleBlock
- && headerLeftUIElement.IsMouseOverElement(lParam)
+ try
+ {
+ Point rightmostTopLeft = rightmostButton.PointToScreen(new Point(0, 0));
+ double rightEdge = rightmostButton.PointToScreen(new Point(rightmostButton.RenderSize.Width, 0)).X;
+ double leftEdge = rightEdge - 1;
+ double bottomEdge = rightmostButton.PointToScreen(new Point(0, rightmostButton.RenderSize.Height)).Y;
+
+ if (
+ cursorPosition.X >= leftEdge
+ && cursorPosition.X <= rightEdge
+ && cursorPosition.Y >= rightmostTopLeft.Y
+ && cursorPosition.Y <= bottomEdge
)
- || (headerCenterUIElement?.IsMouseOverElement(lParam) ?? false)
- || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false);
+ {
+ RemoveButtonHovers();
+ 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))
+ {
+ continue;
+ }
+
+ // Fix for when sometimes, button hover backgrounds aren't cleared correctly, causing multiple buttons to appear as if hovered.
+ foreach (TitleBarButton anotherButton in _buttons)
+ {
+ if (anotherButton == button)
+ {
+ continue;
+ }
+
+ if (anotherButton.IsHovered && button.IsHovered)
+ {
+ anotherButton.RemoveHover();
+ }
+ }
+
+ handled = true;
+ return returnIntPtr;
}
var e = new HwndProcEventArgs(hwnd, msg, wParam, lParam, isMouseOverHeaderContent);
@@ -735,14 +841,14 @@ headerLeftUIElement is not null
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:
@@ -750,6 +856,14 @@ headerLeftUIElement is not null
}
}
+ private void RemoveButtonHovers()
+ {
+ foreach (TitleBarButton button in _buttons)
+ {
+ button?.RemoveHover();
+ }
+ }
+
///
/// Show 'SystemMenu' on mouse right button up.
///
diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs
index 56bfca17c..ee3828788 100644
--- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs
+++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs
@@ -10,8 +10,94 @@
// ReSharper disable once CheckNamespace
namespace Wpf.Ui.Controls;
-public class TitleBarButton : Wpf.Ui.Controls.Button
+public class TitleBarButton : 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;
+ }
+
+ Point 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 (!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:
@@ -232,34 +318,4 @@ protected void OnButtonTypeChanged(DependencyPropertyChangedEventArgs e)
),
};
}
-
- // TODO: Incorrectly calculates mouse position for high DPI displays.
- // PresentationSource presentationSource = null;
- // protected bool IsMouseOverElement(nint lParam)
- // {
- // System.Drawing.Point winPoint;
- // bool gotCursorPos = User32.GetCursorPos(out winPoint);
-
- // if (!gotCursorPos)
- // {
- // int fallbackX = unchecked((short)((long)lParam & 0xFFFF));
- // int fallbackY = unchecked((short)(((long)lParam >> 16) & 0xFFFF));
- // winPoint = new System.Drawing.Point(fallbackX, fallbackY);
- // }
-
- // var screenPoint = new System.Windows.Point(winPoint.X, winPoint.Y);
-
- // presentationSource ??= PresentationSource.FromVisual(this);
-
- // if (presentationSource?.CompositionTarget != null)
- // {
- // screenPoint = presentationSource.CompositionTarget.TransformFromDevice.Transform(screenPoint);
- // }
-
- // var localPoint = this.PointFromScreen(screenPoint);
-
- // var hitTestRect = new System.Windows.Rect(0, 0, this.ActualWidth, this.ActualHeight);
-
- // return hitTestRect.Contains(localPoint);
- //}
}
diff --git a/src/Wpf.Ui/NativeMethods.txt b/src/Wpf.Ui/NativeMethods.txt
index fe5b8a161..ed0384d83 100644
--- a/src/Wpf.Ui/NativeMethods.txt
+++ b/src/Wpf.Ui/NativeMethods.txt
@@ -2,6 +2,7 @@ S_OK
DwmIsCompositionEnabled
DwmExtendFrameIntoClientArea
SetWindowThemeAttribute
+GetCursorPos
GetDpiForWindow
GetForegroundWindow
IsWindowVisible
@@ -32,4 +33,4 @@ HTMINBUTTON
HTNOWHERE
HTRIGHT*
HTSYSMENU
-HTTOP*
\ No newline at end of file
+HTTOP*