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;