Skip to content
Closed
55 changes: 40 additions & 15 deletions src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ namespace Wpf.Ui.Controls;
/// </remarks>
public partial class TitleBar
{
/// <summary>
/// Bit flags that represent which window border edges the cursor is currently over.
/// </summary>
[Flags]
private enum BorderHitEdges : uint
{
/// <summary>No border edge is hit.</summary>
None = 0,
/// <summary>The left border edge is hit.</summary>
Left = 1 << 0,
/// <summary>The right border edge is hit.</summary>
Right = 1 << 1,
/// <summary>The top border edge is hit.</summary>
Top = 1 << 2,
/// <summary>The bottom border edge is hit.</summary>
Bottom = 1 << 3,
}

private int _borderX;
private int _borderY;

Expand All @@ -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,
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? 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))
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 System.Drawing.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
3 changes: 1 addition & 2 deletions src/Wpf.Ui/Interop/PInvoke.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@
// 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);
}
1 change: 1 addition & 0 deletions src/Wpf.Ui/NativeMethods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ DwmIsCompositionEnabled
DwmExtendFrameIntoClientArea
SetWindowThemeAttribute
GetDpiForWindow
GetCursorPos
GetForegroundWindow
IsWindowVisible
SetWindowLong
Expand Down
Loading