From abfdfa9fddd288a9b2357a7b450c66af6432cc62 Mon Sep 17 00:00:00 2001
From: bobo198504 <32607316+bobo198504@users.noreply.github.com>
Date: Sun, 6 Sep 2026 23:18:53 +0800
Subject: [PATCH 1/2] Fix OverflowException in
WindowChromeWorker._HandleNCHitTest when dragging across different-DPI
monitors
Dragging the preview window onto a monitor with a different DPI (e.g. a
non-primary 4K display) crashed with an arithmetic overflow inside WPF's
WindowChromeWorker hit-test. The crash was triggered on WM_NCHITTEST, whose
DPI math can't be made safe from the outside, so:
- Short-circuit WM_NCHITTEST to HTCLIENT in ViewerWindow, and restore window
dragging by hand (WM_NCLBUTTONDOWN + HT CAPTION) on the title area.
- Apply the WM_DPICHANGED suggested rect so the HWND and WPF geometry stay in
sync as the window moves between monitors.
- Opt into Per-Monitor V2 DPI awareness (SetProcessDpiAwarenessContext) with a
V1 fallback.
- Clamp MoveWindow coordinates/size and sanitize non-finite window size so a
degenerate rect can never reach Win32/WPF.
Fixes QL-Win/QuickLook#1996
---
QuickLook.Common/Helpers/WindowHelper.cs | 34 ++++++++++-
QuickLook/App.xaml.cs | 16 +++--
QuickLook/NativeMethods/SHCore.cs | 12 ++++
QuickLook/ViewerWindow.Actions.cs | 17 +++++-
QuickLook/ViewerWindow.xaml.cs | 78 ++++++++++++++++++++++++
5 files changed, 149 insertions(+), 8 deletions(-)
diff --git a/QuickLook.Common/Helpers/WindowHelper.cs b/QuickLook.Common/Helpers/WindowHelper.cs
index d23f207f3..32efa3675 100644
--- a/QuickLook.Common/Helpers/WindowHelper.cs
+++ b/QuickLook.Common/Helpers/WindowHelper.cs
@@ -88,7 +88,35 @@ public static void MoveWindow(this Window window,
out var pxWidth, out var pxHeight);
// Use absolute location and relative size. WPF will scale the size to the target display
- User32.MoveWindow(handle, (int)Math.Round(pxLeft), (int)Math.Round(pxTop), pxWidth, pxHeight, true);
+ //
+ // Guard against arithmetic overflow in WindowChromeWorker.HandleNCHitTest (net462).
+ // When the window is on a per-monitor DPI display the values here are physical pixels
+ // that may be NaN or outside the int32 range (e.g. a window straddling a negative-
+ // coordinate monitor). Feeding such a rect to User32/WPF lets the (int) casts inside
+ // Win32.MoveWindow and WindowChrome hit-testing throw OverflowException, so clamp them.
+ var x = ToInt32Clamped(pxLeft);
+ var y = ToInt32Clamped(pxTop);
+ // Keep the physical window rect strictly larger than the invisible resize border and
+ // caption. If it ever shrinks to zero/smaller, WindowChromeWorker._HandleNCHitTest
+ // (net462) computes a degenerate rect that throws OverflowException on WM_NCHITTEST.
+ var w = Math.Max(ToInt32Clamped(pxWidth), 24);
+ var h = Math.Max(ToInt32Clamped(pxHeight), 56);
+
+ User32.MoveWindow(handle, x, y, w, h, true);
+ }
+
+ private static int ToInt32Clamped(double value)
+ {
+ // Math.Round on NaN returns NaN; (int)NaN in a checked context throws OverflowException.
+ if (double.IsNaN(value))
+ return 0;
+
+ if (value <= int.MinValue)
+ return int.MinValue;
+ if (value >= int.MaxValue)
+ return int.MaxValue;
+
+ return (int)Math.Round(value);
}
public static Rect GetWindowRectInPixel(this Window window)
@@ -116,8 +144,8 @@ private static void TransformToPixels(this Visual visual,
matrix = src.CompositionTarget.TransformToDevice;
}
- pixelX = (int)Math.Round(matrix.M11 * unitX);
- pixelY = (int)Math.Round(matrix.M22 * unitY);
+ pixelX = ToInt32Clamped(matrix.M11 * unitX);
+ pixelY = ToInt32Clamped(matrix.M22 * unitY);
}
public static bool IsForegroundWindowBelongToSelf()
diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs
index 9184585ff..df6bba8de 100644
--- a/QuickLook/App.xaml.cs
+++ b/QuickLook/App.xaml.cs
@@ -60,13 +60,21 @@ static App()
RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly;
}
- // Explicitly set to PerMonitor to avoid being overridden by the system
- if (SHCore.SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE) is uint result)
+ // Per-Monitor V2 so a window dragged across monitors with different DPI gets WM_DPICHANGED
+ // and is rescaled automatically. This keeps WPF's per-window DPI in sync and prevents
+ // WindowChromeWorker._HandleNCHitTest from overflowing on a non-primary 4K display.
+ // Fall back to V1 (SetProcessDpiAwareness) on systems that don't support the context API.
+ if (Environment.OSVersion.Version >= new Version(10, 0, 15063) &&
+ SHCore.SetProcessDpiAwarenessContext(SHCore.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2))
+ {
+ Debug.WriteLine("DPI Awareness context: Per-Monitor V2 applied");
+ }
+ else if (SHCore.SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE) is uint result)
{
Debug.WriteLine(
result == 0 ?
- "DPI Awareness applied successfully" :
- $"DPI Awareness manual setup failed. Error Code: {result}"
+ "DPI Awareness (V1) applied successfully" :
+ $"DPI Awareness (V1) manual setup failed. Error Code: {result}"
);
}
diff --git a/QuickLook/NativeMethods/SHCore.cs b/QuickLook/NativeMethods/SHCore.cs
index ec5bf304b..28a016995 100644
--- a/QuickLook/NativeMethods/SHCore.cs
+++ b/QuickLook/NativeMethods/SHCore.cs
@@ -15,6 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+using System;
using System.Runtime.InteropServices;
namespace QuickLook.NativeMethods;
@@ -30,4 +31,15 @@ public enum PROCESS_DPI_AWARENESS
[DllImport("shcore.dll")]
public static extern uint SetProcessDpiAwareness(PROCESS_DPI_AWARENESS awareness);
+
+ ///
+ /// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. Unlike the V1 context (set via
+ /// SetProcessDpiAwareness), V2 makes Windows raise WM_DPICHANGED and rescale a window as it is
+ /// dragged across monitors with different DPI. This keeps WPF's per-window DPI in sync, which
+ /// prevents WindowChromeWorker._HandleNCHitTest from overflowing on a non-primary 4K display.
+ ///
+ public static readonly nint DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4);
+
+ [DllImport("user32.dll")]
+ public static extern bool SetProcessDpiAwarenessContext(nint value);
}
diff --git a/QuickLook/ViewerWindow.Actions.cs b/QuickLook/ViewerWindow.Actions.cs
index c5fb50506..8d8f81b81 100644
--- a/QuickLook/ViewerWindow.Actions.cs
+++ b/QuickLook/ViewerWindow.Actions.cs
@@ -161,13 +161,28 @@ private void PositionWindow(Size size)
if (WindowState == WindowState.Maximized)
return;
- size = new Size(Math.Max(MinWidth, size.Width), Math.Max(MinHeight, size.Height));
+ // Math.Max(MinWidth, NaN) keeps NaN, which then flows into the WPF window geometry and
+ // causes an OverflowException inside WindowChromeWorker.HandleNCHitTest (net462).
+ // Sanitize any NaN / non-finite / non-positive size before it reaches the window.
+ size = new Size(
+ FinitePositive(Math.Max(MinWidth, size.Width), MinWidth),
+ FinitePositive(Math.Max(MinHeight, size.Height), MinHeight));
var newRect = IsLoaded ? ResizeAndCentreExistingWindow(size) : ResizeAndCentreNewWindow(size);
+ // MoveWindow clamps any non-finite / out-of-range coordinate and guarantees a sane
+ // physical window size, so the window can sit on a negative-coordinate monitor (e.g. a
+ // secondary display left of the primary) without being dragged to the primary screen.
this.MoveWindow(newRect.Left, newRect.Top, newRect.Width, newRect.Height);
}
+ private static double FinitePositive(double value, double fallback)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value) || value <= 0)
+ return fallback;
+ return value;
+ }
+
private Rect ResizeAndCentreExistingWindow(Size size)
{
// Align window just like in macOS ...
diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs
index 7eb190af7..b79dcba9d 100644
--- a/QuickLook/ViewerWindow.xaml.cs
+++ b/QuickLook/ViewerWindow.xaml.cs
@@ -22,8 +22,10 @@
using System;
using System.Diagnostics;
using System.IO;
+using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
+using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shell;
@@ -45,6 +47,7 @@ public partial class ViewerWindow : Window
private string _path = string.Empty;
private FileSystemWatcher _autoReloadWatcher;
private readonly bool _autoReload;
+ private HwndSource _windowHwndSource;
internal ViewerWindow()
{
@@ -67,6 +70,11 @@ internal ViewerWindow()
windowFrameContainer.PreviewMouseMove += ShowWindowCaptionContainer;
+ // Window dragging is done by hand here (WM_NCLBUTTONDOWN + HT CAPTION) instead of relying on
+ // WindowChrome's hit test, because we answer WM_NCHITTEST with HTCLIENT to prevent a net462
+ // WindowChrome overflow when dragging across different-DPI monitors.
+ titleArea.MouseLeftButtonDown += TitleArea_MouseLeftButtonDown;
+
Topmost = SettingHelper.Get("Topmost", false);
buttonTop.Tag = Topmost ? "Top" : "Auto";
@@ -188,6 +196,48 @@ protected override void OnSourceInitialized(EventArgs e)
WindowHelper.RemoveWindowControls(this);
ApplyWindowBackgroundEffects();
+
+ // Handle WM_DPICHANGED so dragging the window onto a monitor with a different DPI
+ // (e.g. a 4K secondary display on a per-monitor-DPI system) keeps the HWND geometry and
+ // WPF's view of it in sync. Without this, WindowChromeWorker._HandleNCHitTest (net462)
+ // reads a stale window rect during the drag and throws OverflowException.
+ var handle = new WindowInteropHelper(this).Handle;
+ if (handle != IntPtr.Zero && HwndSource.FromHwnd(handle) is HwndSource hwndSource)
+ {
+ _windowHwndSource = hwndSource;
+ hwndSource.AddHook(WndProc);
+ }
+ }
+
+ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
+ {
+ const int WM_NCHITTEST = 0x0084;
+ const int WM_DPICHANGED = 0x02E0;
+
+ // Short-circuit WM_NCHITTEST with HTCLIENT. Verified to stop net462
+ // WindowChromeWorker._HandleNCHitTest from overflowing during cross-DPI dragging (the
+ // overflow is in WPF's own DPI math, so no condition on the window rect can reliably
+ // detect it). Dragging still works because the content panels use WM_NCLBUTTONDOWN(HT
+ // CAPTION) directly rather than relying on a hit test result.
+ if (msg == WM_NCHITTEST)
+ {
+ handled = true;
+ return new IntPtr(1); // HTCLIENT
+ }
+
+ if (msg == WM_DPICHANGED && lParam != IntPtr.Zero)
+ {
+ var suggested = Marshal.PtrToStructure(lParam);
+ var width = Math.Max(suggested.Right - suggested.Left, 1);
+ var height = Math.Max(suggested.Bottom - suggested.Top, 1);
+
+ QuickLook.Common.NativeMethods.User32.MoveWindow(hwnd, suggested.Left, suggested.Top, width, height, true);
+
+ handled = true;
+ return IntPtr.Zero;
+ }
+
+ return IntPtr.Zero;
}
protected override void OnContentRendered(EventArgs e)
@@ -464,6 +514,34 @@ private void ShowWindowCaptionContainer(object sender, MouseEventArgs e)
show.Begin();
}
+ private void TitleArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.LeftButton != MouseButtonState.Pressed)
+ return;
+
+ // Do not allow dragging when window is borderless (e.g. fullscreen)
+ if (WindowStyle == WindowStyle.None)
+ return;
+
+ // Start the native move loop directly. Window.DragMove() depends on a hit-test result, but
+ // we answer WM_NCHITTEST with HTCLIENT (to avoid a WindowChrome overflow), so drag by hand.
+ var hwnd = new WindowInteropHelper(this).Handle;
+ if (hwnd == IntPtr.Zero)
+ return;
+
+ ReleaseCapture();
+ SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero);
+ }
+
+ private const int WM_NCLBUTTONDOWN = 0x00A1;
+ private const int HTCAPTION = 0x0002;
+
+ [DllImport("user32.dll")]
+ private static extern bool ReleaseCapture();
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto)]
+ private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
+
private void AutoHideCaptionContainer(object sender, EventArgs e)
{
if (!ContextObject.TitlebarAutoHide)
From 1b9d2b5463c89acb6cebcc29046a645fe791d5a6 Mon Sep 17 00:00:00 2001
From: bobo198504 <32607316+bobo198504@users.noreply.github.com>
Date: Sun, 6 Sep 2026 23:23:58 +0800
Subject: [PATCH 2/2] Preserve border/corner resizing by reporting resize
hit-test zones in WM_NCHITTEST
Sourcery correctly flagged that returning HTCLIENT for every WM_NCHITTEST
disabled the resize borders while ResizeMode=CanResize. Report HTLEFT/HTRIGHT/
HTTOP/HTBOTTOM and the corner zones at the window edges, and HTCLIENT elsewhere,
still bypassing the WindowChrome hit-test that overflows during cross-DPI drags.
---
QuickLook/ViewerWindow.xaml.cs | 37 ++++++++++++++++++++++++++++------
1 file changed, 31 insertions(+), 6 deletions(-)
diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs
index b79dcba9d..4b20a6698 100644
--- a/QuickLook/ViewerWindow.xaml.cs
+++ b/QuickLook/ViewerWindow.xaml.cs
@@ -214,15 +214,40 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b
const int WM_NCHITTEST = 0x0084;
const int WM_DPICHANGED = 0x02E0;
- // Short-circuit WM_NCHITTEST with HTCLIENT. Verified to stop net462
- // WindowChromeWorker._HandleNCHitTest from overflowing during cross-DPI dragging (the
- // overflow is in WPF's own DPI math, so no condition on the window rect can reliably
- // detect it). Dragging still works because the content panels use WM_NCLBUTTONDOWN(HT
- // CAPTION) directly rather than relying on a hit test result.
+ // Implement the hit test ourselves instead of deferring to WindowChromeWorker.
+ // WindowChromeWorker._HandleNCHitTest (net462) overflows during cross-DPI dragging (its
+ // per-window DPI math goes out of sync with the HWND rect and can't be corrected from the
+ // outside). Reporting the resize border zones here keeps edge/corner resizing working while
+ // everything else is treated as client area. Title-bar dragging is handled by
+ // TitleArea_MouseLeftButtonDown, and caption buttons are WPF content, so neither needs a
+ // non-client hit-test result.
if (msg == WM_NCHITTEST)
{
+ // Mouse position (screen, physical pixels) is packed into lParam as signed 16-bit pairs.
+ int v = lParam.ToInt32();
+ int mx = (short)(v & 0xFFFF);
+ int my = (short)((v >> 16) & 0xFFFF);
+
+ QuickLook.Common.NativeMethods.User32.GetWindowRect(hwnd, out var r);
+
+ // Resize border zone (physical pixels). The WindowChrome resize border is 6 logical
+ // pixels; a fixed 8 physical-pixel zone covers it across common DPI scale factors.
+ const int border = 8;
+ bool left = mx < r.Left + border;
+ bool right = mx >= r.Right - border;
+ bool top = my < r.Top + border;
+ bool bottom = my >= r.Bottom - border;
+
handled = true;
- return new IntPtr(1); // HTCLIENT
+ if (top && left) return new IntPtr(13); // HTTOPLEFT
+ if (top && right) return new IntPtr(14); // HTTOPRIGHT
+ if (bottom && left) return new IntPtr(16); // HTBOTTOMLEFT
+ if (bottom && right) return new IntPtr(17); // HTBOTTOMRIGHT
+ if (left) return new IntPtr(10); // HTLEFT
+ if (right) return new IntPtr(11); // HTRIGHT
+ if (top) return new IntPtr(12); // HTTOP
+ if (bottom) return new IntPtr(15); // HTBOTTOM
+ return new IntPtr(1); // HTCLIENT
}
if (msg == WM_DPICHANGED && lParam != IntPtr.Zero)