Skip to content
9 changes: 9 additions & 0 deletions src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam)
hit |= 0b1000u; // bottom
#pragma warning restore

if (hit == 0b0110u)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of magic numbers here. Does CsWin32 generate a flags enum, perhaps? Or can we at least use constants?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thank you. I removed the magic bitmask values and introduced a named [Flags] enum (BorderHitEdges), then updated the hit-test logic to use named combinations for better readability and maintainability.

{
const int cornerWidth = 1;
if (x < windowRect.right - cornerWidth)
{
hit = 0b0100u;
}
}

return hit switch
{
0b0101u => (IntPtr)PInvoke.HTTOPLEFT, // top + left (0b0100 | 0b0001)
Expand Down
151 changes: 130 additions & 21 deletions src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Right" should now be "Trailing"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, thanks. I renamed headerRightUiElement to headerTrailingUiElement to align terminology with TrailingContent.


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))
Expand All @@ -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);

Expand All @@ -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:
Expand Down
92 changes: 89 additions & 3 deletions src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>Identifies the <see cref="ButtonType"/> dependency property.</summary>
public static readonly DependencyProperty ButtonTypeProperty = DependencyProperty.Register(
nameof(ButtonType),
Expand Down Expand Up @@ -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();
Expand All @@ -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:
Expand Down
16 changes: 14 additions & 2 deletions src/Wpf.Ui/Interop/PInvoke.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@
// All Rights Reserved.

using System.Runtime.InteropServices;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;

namespace Windows.Win32;

internal static partial class PInvoke

Check warning on line 11 in src/Wpf.Ui/Interop/PInvoke.cs

View workflow job for this annotation

GitHub Actions / build

Check warning on line 11 in src/Wpf.Ui/Interop/PInvoke.cs

View workflow job for this annotation

GitHub Actions / build

Move pinvokes to native methods class (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1060)
{
[
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the one generated from cswin32?
https://github.com/microsoft/CsWin32

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I replaced the manual interop with the CsWin32-generated GetCursorPos API by adding it to NativeMethods.txt, removed the custom GetCursorPos/POINT declarations from Interop/PInvoke.cs, and updated call sites accordingly.

}

[StructLayout(LayoutKind.Sequential)]
internal struct POINT
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the one generated from cswin32?
https://github.com/microsoft/CsWin32

{
public int X;
public int Y;
}
Loading