diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs
new file mode 100644
index 000000000..193507ade
--- /dev/null
+++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs
@@ -0,0 +1,285 @@
+// This Source Code Form is subject to the terms of the MIT License.
+// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT.
+// Copyright (C) Leszek Pomianowski and WPF UI Contributors.
+// All Rights Reserved.
+
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Shell;
+using Wpf.Ui.Interop;
+using Wpf.Ui.Interop.WinDef;
+
+// ReSharper disable once CheckNamespace
+namespace Wpf.Ui.Controls;
+
+///
+/// Provides optional window-resize related hit-testing support for the control.
+///
+///
+/// This partial class implements logic to return appropriate WM_NCHITTEST results (for example
+/// HTLEFT, HTBOTTOMRIGHT, etc.) when the mouse is positioned over the window edges
+/// or corners. This enables intuitive resizing behavior when the user drags the window borders.
+///
+/// Key points:
+/// - The implementation prefers the
+/// value (expressed in device-independent units) when available and translates it into physical pixels;
+/// - If WindowChrome or DPI information is not available, the code falls back to system metrics via
+/// ;
+/// - Because WM_NCHITTEST is raised frequently, computed border pixel sizes are cached to
+/// reduce overhead; the cache is invalidated when DPI or relevant system parameters change;
+/// - This component only augments the control's non-client hit-testing to
+/// improve resize behavior and does not alter the window style or system behavior itself.
+///
+/// Splitting this functionality into a partial class keeps the resize-related responsibilities
+/// clearly separated from other TitleBar UI and interaction logic.
+///
+public partial class TitleBar
+{
+ private int _borderX;
+ private int _borderY;
+
+ private bool _borderXCached;
+ private bool _borderYCached;
+
+ private bool _systemParamsSubscribed;
+
+ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam)
+ {
+ if (!User32.GetWindowRect(hwnd, out RECT windowRect))
+ {
+ return (IntPtr)User32.WM_NCHITTEST.HTNOWHERE;
+ }
+
+ if (!_borderXCached || !_borderYCached)
+ {
+ ComputeAndCacheBorderSizes(hwnd);
+ }
+
+ long lp = lParam.ToInt64();
+
+ int x = (short)(lp & 0xFFFF);
+ int y = (short)((lp >> 16) & 0xFFFF);
+
+ uint hit = 0u;
+
+#pragma warning disable
+ if (x < windowRect.Left + _borderX)
+ hit |= 0b0001u; // left
+ if (x >= windowRect.Right - _borderX)
+ hit |= 0b0010u; // right
+ if (y < windowRect.Top + _borderY)
+ hit |= 0b0100u; // top
+ if (y >= windowRect.Bottom - _borderY)
+ hit |= 0b1000u; // bottom
+#pragma warning restore
+
+ return hit switch
+ {
+ 0b0101u => (IntPtr)User32.WM_NCHITTEST.HTTOPLEFT, // top + left (0b0100 | 0b0001)
+ 0b0110u => (IntPtr)User32.WM_NCHITTEST.HTTOPRIGHT, // top + right (0b0100 | 0b0010)
+ 0b1001u => (IntPtr)User32.WM_NCHITTEST.HTBOTTOMLEFT, // bottom + left (0b1000 | 0b0001)
+ 0b1010u => (IntPtr)User32.WM_NCHITTEST.HTBOTTOMRIGHT, // bottom + right (0b1000 | 0b0010)
+ 0b0100u => (IntPtr)User32.WM_NCHITTEST.HTTOP, // top
+ 0b0001u => (IntPtr)User32.WM_NCHITTEST.HTLEFT, // left
+ 0b1000u => (IntPtr)User32.WM_NCHITTEST.HTBOTTOM, // bottom
+ 0b0010u => (IntPtr)User32.WM_NCHITTEST.HTRIGHT, // right
+
+ // no match = HTNOWHERE (stop processing)
+ _ => (IntPtr)User32.WM_NCHITTEST.HTNOWHERE,
+ };
+ }
+
+ private void SubscribeToSystemParameters()
+ {
+ if (_systemParamsSubscribed)
+ {
+ return;
+ }
+
+ SystemParameters.StaticPropertyChanged += OnSystemParametersChanged;
+ _systemParamsSubscribed = true;
+ }
+
+ private void UnsubscribeToSystemParameters()
+ {
+ if (!_systemParamsSubscribed)
+ {
+ return;
+ }
+
+ SystemParameters.StaticPropertyChanged -= OnSystemParametersChanged;
+ _systemParamsSubscribed = false;
+ }
+
+ private void OnSystemParametersChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ InvalidateBorderCache();
+ }
+
+ private void InvalidateBorderCache()
+ {
+ _borderXCached = false;
+ _borderYCached = false;
+ }
+
+ private void ComputeAndCacheBorderSizes(IntPtr hwnd)
+ {
+ try
+ {
+ double dipBorderX;
+ double dipBorderY;
+
+ Window? win = null;
+ try
+ {
+ var src = HwndSource.FromHwnd(hwnd);
+ if (src?.RootVisual is DependencyObject dep)
+ {
+ win = Window.GetWindow(dep);
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+
+ // FluentWindow uses WindowChrome - get border from it first
+ WindowChrome? chrome = win is null ? null : WindowChrome.GetWindowChrome(win);
+
+ if (chrome is not null)
+ {
+ dipBorderX = Math.Max(chrome.ResizeBorderThickness.Left, chrome.ResizeBorderThickness.Right);
+ dipBorderY = Math.Max(chrome.ResizeBorderThickness.Top, chrome.ResizeBorderThickness.Bottom);
+ }
+ else
+ {
+ dipBorderX = SystemParameters.WindowResizeBorderThickness.Left;
+ dipBorderY = SystemParameters.WindowResizeBorderThickness.Top;
+ }
+
+ int borderX;
+ int borderY;
+
+ if (
+ TryComputeFromPresentationSource(win, dipBorderX, dipBorderY, out borderX, out borderY)
+ || TryComputeFromDpiApi(hwnd, dipBorderX, dipBorderY, out borderX, out borderY)
+ || TryGetFromSystemMetrics(out borderX, out borderY)
+ )
+ {
+ _borderX = borderX;
+ _borderY = borderY;
+ }
+ else
+ {
+ _borderX = 4;
+ _borderY = 4;
+ }
+ }
+ catch
+ {
+ _borderX = 4;
+ _borderY = 4;
+ }
+
+ _borderXCached = true;
+ _borderYCached = true;
+ }
+
+ // Try to compute border sizes from PresentationSource (per-monitor DPI-aware path).
+ private static bool TryComputeFromPresentationSource(
+ Window? win,
+ double dipBorderX,
+ double dipBorderY,
+ out int borderX,
+ out int borderY
+ )
+ {
+ if (win is not null)
+ {
+ try
+ {
+ PresentationSource? source = PresentationSource.FromVisual(win);
+
+ if (source?.CompositionTarget is not null)
+ {
+ Matrix m = source.CompositionTarget.TransformToDevice;
+
+ borderX = Math.Max(2, (int)Math.Ceiling(dipBorderX * m.M11));
+ borderY = Math.Max(2, (int)Math.Ceiling(dipBorderY * m.M22));
+
+ return true;
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ borderX = 0;
+ borderY = 0;
+
+ return false;
+ }
+
+ // Try to compute border sizes using GetDpiForWindow (if available).
+ private static bool TryComputeFromDpiApi(
+ IntPtr hwnd,
+ double dipBorderX,
+ double dipBorderY,
+ out int borderX,
+ out int borderY
+ )
+ {
+ try
+ {
+ uint dpi = User32.GetDpiForWindow(hwnd);
+ if (dpi == 0)
+ {
+ dpi = 96;
+ }
+
+ double scale = dpi / 96.0;
+
+ borderX = Math.Max(2, (int)Math.Ceiling(dipBorderX * scale));
+ borderY = Math.Max(2, (int)Math.Ceiling(dipBorderY * scale));
+
+ return true;
+ }
+ catch
+ {
+ borderX = 0;
+ borderY = 0;
+
+ return false;
+ }
+ }
+
+ // Try to compute border sizes using GetSystemMetrics as a safe fallback.
+ private static bool TryGetFromSystemMetrics(out int borderX, out int borderY)
+ {
+ try
+ {
+ int sx =
+ User32.GetSystemMetrics(User32.SM.CXSIZEFRAME)
+ + User32.GetSystemMetrics(User32.SM.CXPADDEDBORDER);
+ int sy =
+ User32.GetSystemMetrics(User32.SM.CYSIZEFRAME)
+ + User32.GetSystemMetrics(User32.SM.CXPADDEDBORDER);
+
+ borderX = Math.Max(2, sx);
+ borderY = Math.Max(2, sy);
+
+ return true;
+ }
+ catch
+ {
+ borderX = 0;
+ borderY = 0;
+
+ return false;
+ }
+ }
+}
diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
index 41b6073a3..7a7aede97 100644
--- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
+++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs
@@ -23,7 +23,7 @@ namespace Wpf.Ui.Controls;
[TemplatePart(Name = ElementMaximizeButton, Type = typeof(TitleBarButton))]
[TemplatePart(Name = ElementRestoreButton, Type = typeof(TitleBarButton))]
[TemplatePart(Name = ElementCloseButton, Type = typeof(TitleBarButton))]
-public class TitleBar : System.Windows.Controls.Control, IThemeControl
+public partial class TitleBar : System.Windows.Controls.Control, IThemeControl
{
private const string ElementIcon = "PART_Icon";
private const string ElementMainGrid = "PART_MainGrid";
@@ -452,8 +452,11 @@ protected virtual void OnLoaded(object sender, RoutedEventArgs e)
SetCurrentValue(IsMaximizedProperty, true);
_currentWindow.SetCurrentValue(Window.WindowStateProperty, WindowState.Maximized);
}
+
_currentWindow.StateChanged += OnParentWindowStateChanged;
_currentWindow.ContentRendered += OnWindowContentRendered;
+
+ SubscribeToSystemParameters();
}
private void OnUnloaded(object sender, RoutedEventArgs e)
@@ -462,6 +465,7 @@ private void OnUnloaded(object sender, RoutedEventArgs e)
Unloaded -= OnUnloaded;
Appearance.ApplicationThemeManager.Changed -= OnThemeChanged;
+ UnsubscribeToSystemParameters();
}
///
@@ -629,6 +633,12 @@ private IntPtr HwndSourceHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam
{
var message = (User32.WM)msg;
+ // Invalidate cached border size on DPI change message
+ if (message == User32.WM.DPICHANGED)
+ {
+ InvalidateBorderCache();
+ }
+
if (
message
is not (
@@ -668,22 +678,28 @@ or User32.WM.NCLBUTTONUP
}
bool isMouseOverHeaderContent = false;
+ IntPtr htResult = (IntPtr)User32.WM_NCHITTEST.HTNOWHERE;
- if (message == User32.WM.NCHITTEST && (TrailingContent is UIElement || Header is UIElement))
+ if (message == User32.WM.NCHITTEST)
{
- UIElement? headerLeftUIElement = Header as UIElement;
- UIElement? headerRightUiElement = TrailingContent as UIElement;
-
- if (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock)
- {
- isMouseOverHeaderContent =
- headerLeftUIElement.IsMouseOverElement(lParam)
- || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false);
- }
- else
+ if (TrailingContent is UIElement || Header is UIElement)
{
- isMouseOverHeaderContent = headerRightUiElement?.IsMouseOverElement(lParam) ?? false;
+ UIElement? headerLeftUIElement = Header as UIElement;
+ UIElement? headerRightUiElement = TrailingContent as UIElement;
+
+ if (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock)
+ {
+ isMouseOverHeaderContent =
+ headerLeftUIElement.IsMouseOverElement(lParam)
+ || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false);
+ }
+ else
+ {
+ isMouseOverHeaderContent = headerRightUiElement?.IsMouseOverElement(lParam) ?? false;
+ }
}
+
+ htResult = GetWindowBorderHitTestResult(hwnd, lParam);
}
switch (message)
@@ -692,6 +708,9 @@ or User32.WM.NCLBUTTONUP
// 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)User32.WM_NCHITTEST.HTSYSMENU;
+ case User32.WM.NCHITTEST when htResult != (IntPtr)User32.WM_NCHITTEST.HTNOWHERE:
+ handled = true;
+ return htResult;
case User32.WM.NCHITTEST when this.IsMouseOverElement(lParam) && !isMouseOverHeaderContent:
handled = true;
return (IntPtr)User32.WM_NCHITTEST.HTCAPTION;
diff --git a/src/Wpf.Ui/Interop/User32.cs b/src/Wpf.Ui/Interop/User32.cs
index 289c816da..2dca6dbfc 100644
--- a/src/Wpf.Ui/Interop/User32.cs
+++ b/src/Wpf.Ui/Interop/User32.cs
@@ -519,6 +519,15 @@ public enum WM
TABLET_DEFBASE = 0x02C0,
+ ///
+ /// The WM_DPICHANGED message is sent when the DPI of the window has changed.
+ ///
+ ///
+ /// **Supported clients:** Windows 8.1+ (Desktop apps)
+ /// **Supported servers:** Windows Server 2012 R2+ (Desktop apps)
+ ///
+ DPICHANGED = 0x02E0,
+
// WM_TABLET_MAXOFFSET = 0x20,
TABLET_ADDED = TABLET_DEFBASE + 8,
TABLET_DELETED = TABLET_DEFBASE + 9,
@@ -1359,7 +1368,7 @@ [In] IntPtr lParam
/// If the function succeeds, the return value is nonzero.
[DllImport(Libraries.User32, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
- public static extern bool GetWindowRect([In] IntPtr hWnd, [Out] out Rect lpRect);
+ public static extern bool GetWindowRect([In] IntPtr hWnd, [Out] out WinDef.RECT lpRect);
///
/// Determines the visibility state of the specified window.
diff --git a/tests/Wpf.Ui.Gallery.IntegrationTests/GlobalUsings.cs b/tests/Wpf.Ui.Gallery.IntegrationTests/GlobalUsings.cs
index ce6df80ca..c19cb733d 100644
--- a/tests/Wpf.Ui.Gallery.IntegrationTests/GlobalUsings.cs
+++ b/tests/Wpf.Ui.Gallery.IntegrationTests/GlobalUsings.cs
@@ -6,5 +6,5 @@
global using System.Reflection;
global using AwesomeAssertions;
global using FlaUI.Core.AutomationElements;
-global using Wpf.Ui.Gallery.IntegrationTests.Fixtures;
global using Wpf.Ui.FlaUI;
+global using Wpf.Ui.Gallery.IntegrationTests.Fixtures;