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/5] 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/5] 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)
From cea2036d012c33dbe0e2333cc7383b6520cbcc49 Mon Sep 17 00:00:00 2001
From: bobo198504 <32607316+bobo198504@users.noreply.github.com>
Date: Wed, 9 Sep 2026 08:59:17 +0800
Subject: [PATCH 3/5] Fix the real overflow: decode WM_NCHITTEST lParam without
IntPtr.ToInt32()
The OverflowException that remained on monitors above/left of the primary was in our
own WndProc, not WPF. IntPtr.ToInt32() compiles to conv.u8 + conv.ovf.i4; when a screen
coordinate is negative Windows sign-extends lParam's high 32 bits, so ToInt32() sees a
huge unsigned value and overflows. Decode the packed signed 16-bit mouse coordinates via
ToInt64() + an unchecked low-32-bit cast and sign extension, which never overflows.
Also make the WndProc hook re-attachable (for HwndSource recreation after a display
change) and detach it in OnClosing.
---
QuickLook/ViewerWindow.Actions.cs | 6 +++
QuickLook/ViewerWindow.xaml.cs | 83 +++++++++++++++++++------------
2 files changed, 57 insertions(+), 32 deletions(-)
diff --git a/QuickLook/ViewerWindow.Actions.cs b/QuickLook/ViewerWindow.Actions.cs
index 8d8f81b81..25349b7f1 100644
--- a/QuickLook/ViewerWindow.Actions.cs
+++ b/QuickLook/ViewerWindow.Actions.cs
@@ -529,6 +529,12 @@ protected override void OnClosing(CancelEventArgs e)
UnloadPlugin();
busyDecorator.Dispose();
+ // Detach the WndProc hook so the HwndSource can be released cleanly.
+ if (_windowHwndSource != null && _windowHook != null)
+ _windowHwndSource.RemoveHook(_windowHook);
+ _windowHwndSource = null;
+ _windowHook = null;
+
base.OnClosing(e);
ProcessHelper.PerformAggressiveGC();
diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs
index 4b20a6698..431126316 100644
--- a/QuickLook/ViewerWindow.xaml.cs
+++ b/QuickLook/ViewerWindow.xaml.cs
@@ -48,6 +48,7 @@ public partial class ViewerWindow : Window
private FileSystemWatcher _autoReloadWatcher;
private readonly bool _autoReload;
private HwndSource _windowHwndSource;
+ private HwndSourceHook _windowHook;
internal ViewerWindow()
{
@@ -197,41 +198,52 @@ protected override void OnSourceInitialized(EventArgs e)
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);
- }
+ // Handle WM_NCHITTEST ourselves and WM_DPICHANGED during cross-DPI dragging. The hook is
+ // attached after WindowChrome's own hook (base.OnSourceInitialized) so it runs first in the
+ // LIFO hook chain; re-attach it here via AttachWndProcHook which also guards against a stale
+ // HwndSource after a display configuration change.
+ AttachWndProcHook();
+ }
+
+ private void AttachWndProcHook()
+ {
+ var source = PresentationSource.FromVisual(this) as HwndSource
+ ?? HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
+
+ if (source == null || source.IsDisposed)
+ return;
+
+ // Remove any previously attached hook (e.g. after the HwndSource was recreated) so the hook
+ // stays at the tail of the delegate chain and is therefore called first.
+ if (_windowHwndSource != null && _windowHook != null)
+ _windowHwndSource.RemoveHook(_windowHook);
+
+ _windowHwndSource = source;
+ _windowHook = WndProc;
+ source.AddHook(_windowHook);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int WM_NCHITTEST = 0x0084;
const int WM_DPICHANGED = 0x02E0;
+ const int WM_DISPLAYCHANGE = 0x007E;
- // 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);
+ // Mouse position (screen, physical pixels) is packed into lParam as two signed 16-bit
+ // halves. Decode them with plain int math and sign extension. Note: do NOT use
+ // lParam.ToInt32() here - on 64-bit, a negative screen coordinate (monitor above/left
+ // of the primary) makes Windows sign-extend lParam's high 32 bits, and IntPtr.ToInt32
+ // (conv.u8 then conv.ovf.i4) then overflows. ToInt64 + unchecked low-32-bit cast is safe.
+ int v = unchecked((int)lParam.ToInt64());
+ int lo = v & 0xFFFF;
+ int hi = (v >> 16) & 0xFFFF;
+ int mx = (lo & 0x8000) != 0 ? lo - 0x10000 : lo;
+ int my = (hi & 0x8000) != 0 ? hi - 0x10000 : hi;
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;
@@ -239,15 +251,15 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b
bool bottom = my >= r.Bottom - border;
handled = true;
- 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 (top && left) return new IntPtr(13);
+ if (top && right) return new IntPtr(14);
+ if (bottom && left) return new IntPtr(16);
+ if (bottom && right) return new IntPtr(17);
+ if (left) return new IntPtr(10);
+ if (right) return new IntPtr(11);
+ if (top) return new IntPtr(12);
+ if (bottom) return new IntPtr(15);
+ return new IntPtr(1);
}
if (msg == WM_DPICHANGED && lParam != IntPtr.Zero)
@@ -262,6 +274,13 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b
return IntPtr.Zero;
}
+ // A display configuration change can recreate the HwndSource and drop (or reorder) hooks.
+ // Re-attach ours on the next dispatcher pass so WM_NCHITTEST keeps being short-circuited.
+ if (msg == WM_DISPLAYCHANGE)
+ {
+ Dispatcher.BeginInvoke(new Action(AttachWndProcHook), DispatcherPriority.Loaded);
+ }
+
return IntPtr.Zero;
}
From 04cd6380c558a5607945e0940320f3a70730aeff Mon Sep 17 00:00:00 2001
From: bobo198504 <32607316+bobo198504@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:17:35 +0800
Subject: [PATCH 4/5] Position the preview on the monitor under the cursor, not
the foreground window
GetCurrentDesktopRectInPixel and GetCurrentScaleFactor picked the monitor from
GetForegroundWindow(). When the selected file sits on the desktop, the foreground
window is the desktop (Progman), which always resolves to the primary monitor, so a
file on a secondary desktop would open its preview on the wrong screen. Use the cursor
position (Screen.FromPoint / MonitorFromPoint) instead, which is where the user is
looking when they press Space.
---
.../Helpers/DisplayDeviceHelper.cs | 41 ++++++++++++++++++-
QuickLook.Common/Helpers/WindowHelper.cs | 7 +++-
QuickLook.Common/NativeMethods/User32.cs | 13 ++++++
3 files changed, 59 insertions(+), 2 deletions(-)
diff --git a/QuickLook.Common/Helpers/DisplayDeviceHelper.cs b/QuickLook.Common/Helpers/DisplayDeviceHelper.cs
index ede4181e4..d758de447 100644
--- a/QuickLook.Common/Helpers/DisplayDeviceHelper.cs
+++ b/QuickLook.Common/Helpers/DisplayDeviceHelper.cs
@@ -38,7 +38,46 @@ public static ScaleFactor GetScaleFactorFromWindow(Window window)
public static ScaleFactor GetCurrentScaleFactor()
{
- return GetScaleFactorFromWindow(GetForegroundWindow());
+ // Use the cursor position rather than the foreground window to determine the monitor. The
+ // foreground window is the desktop (Progman) when a file sits on the desktop, and that always
+ // resolves to the primary monitor, which would place the preview on the wrong screen.
+ GetCursorPos(out var pt);
+ return GetScaleFactorFromPoint(pt);
+ }
+
+ public static ScaleFactor GetScaleFactorFromPoint(POINT point)
+ {
+ var dpiX = DefaultDpi;
+ var dpiY = DefaultDpi;
+
+ try
+ {
+ if (Environment.OSVersion.Version > new Version(6, 2)) // Windows 8.1 = 6.3.9200
+ {
+ var hMonitor = MonitorFromPoint(point, MonitorDefaults.TONEAREST);
+ GetDpiForMonitor(hMonitor, MonitorDpiType.EFFECTIVE_DPI, out dpiX, out dpiY);
+ }
+ else
+ {
+ using var g = Graphics.FromHwnd(IntPtr.Zero);
+ var desktop = g.GetHdc();
+ try
+ {
+ dpiX = GetDeviceCaps(desktop, DeviceCap.LOGPIXELSX);
+ dpiY = GetDeviceCaps(desktop, DeviceCap.LOGPIXELSY);
+ }
+ finally
+ {
+ g.ReleaseHdc(desktop);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ ProcessHelper.WriteLog(e.ToString());
+ }
+
+ return new ScaleFactor { Horizontal = (float)dpiX / DefaultDpi, Vertical = (float)dpiY / DefaultDpi };
}
public static ScaleFactor GetScaleFactorFromWindow(nint hwnd)
diff --git a/QuickLook.Common/Helpers/WindowHelper.cs b/QuickLook.Common/Helpers/WindowHelper.cs
index 32efa3675..34483db15 100644
--- a/QuickLook.Common/Helpers/WindowHelper.cs
+++ b/QuickLook.Common/Helpers/WindowHelper.cs
@@ -44,7 +44,12 @@ public static Size GetCurrentDesktopSize()
public static Rect GetCurrentDesktopRectInPixel()
{
- return GetDesktopRectFromWindowInPixel(User32.GetForegroundWindow());
+ // Use the cursor position to pick the monitor. The foreground window resolves to the
+ // desktop (Progman) when a file sits on the desktop, which always maps to the primary
+ // monitor; that would place the preview on the wrong screen.
+ var screen = Screen.FromPoint(System.Windows.Forms.Cursor.Position);
+ var area = screen.WorkingArea;
+ return new Rect(new Point(area.X, area.Y), new Size(area.Width, area.Height));
}
public static Rect GetDesktopRectFromWindowInPixel(Window window)
diff --git a/QuickLook.Common/NativeMethods/User32.cs b/QuickLook.Common/NativeMethods/User32.cs
index 81271a8f4..5b8016b3d 100644
--- a/QuickLook.Common/NativeMethods/User32.cs
+++ b/QuickLook.Common/NativeMethods/User32.cs
@@ -94,6 +94,12 @@ public static extern int SetWindowCompositionAttribute(nint hwnd,
[DllImport("user32.dll")]
public static extern nint MonitorFromWindow(nint hWnd, MonitorDefaults dwFlags);
+ [DllImport("user32.dll")]
+ public static extern nint MonitorFromPoint(POINT pt, MonitorDefaults dwFlags);
+
+ [DllImport("user32.dll")]
+ public static extern bool GetCursorPos(out POINT lpPoint);
+
[DllImport("user32.dll")]
public extern static bool GetMonitorInfo(nint hMonitor, ref MONITORINFOEX lpmi);
@@ -145,6 +151,13 @@ public struct RECT
public int Bottom;
}
+ [StructLayout(LayoutKind.Sequential)]
+ public struct POINT
+ {
+ public int X;
+ public int Y;
+ }
+
[StructLayout(LayoutKind.Sequential)]
public struct MONITORINFOEX
{
From 8904ebfbbfb6e7459d5ed6d9e3c44a82fdcf253e Mon Sep 17 00:00:00 2001
From: bobo198504 <32607316+bobo198504@users.noreply.github.com>
Date: Wed, 9 Sep 2026 10:56:51 +0800
Subject: [PATCH 5/5] Localize MediaInfo output and render it as an aligned
table; fix close-race overflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MediaInfoViewer:
- Set MediaInfo's output language from the UI culture so field labels render in the
user's language (e.g. Chinese), with a fallback to English.
- Translate PE/EXE field labels that the zh-CN language CSV is missing, and normalize
full-width fallback labels (Linker_Version → Linker_Version).
- Replace the fixed-width text block with a two-column grid (SharedSizeGroup) so every
label/value pair aligns pixel-perfectly regardless of CJK/ASCII mixing, with a bold
section header and a dedicated colon column.
ViewerWindow:
- Stop removing the WM_NCHITTEST hook in OnClosing. Removing it before base.OnClosing
leaves the still-alive window unguarded for an instant, so a WM_NCHITTEST (e.g. mouse
resting on the close button) can fall through to WPF's WindowChromeWorker and overflow.
The HwndSource releases the hook chain when the window actually closes.
---
.../MediaInfoTablePanel.cs | 160 ++++++++++++++++++
.../Plugin.cs | 113 ++++++++++++-
QuickLook/ViewerWindow.Actions.cs | 10 +-
3 files changed, 271 insertions(+), 12 deletions(-)
create mode 100644 QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/MediaInfoTablePanel.cs
diff --git a/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/MediaInfoTablePanel.cs b/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/MediaInfoTablePanel.cs
new file mode 100644
index 000000000..fa15705ec
--- /dev/null
+++ b/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/MediaInfoTablePanel.cs
@@ -0,0 +1,160 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using QuickLook.Common.Helpers;
+using QuickLook.Common.Plugin;
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace QuickLook.Plugin.MediaInfoViewer;
+
+///
+/// Renders MediaInfo output as a two-column table (field label | value) instead of a fixed-width
+/// text block. The label column width is shared across all rows (Grid SharedSizeGroup), so every
+/// colon-like separator lines up pixel-perfectly regardless of CJK/ASCII/full-width mixing, which
+/// padding with spaces can never achieve in a proportional/font-fallback situation.
+///
+public class MediaInfoTablePanel : UserControl
+{
+ private const string ColumnSeparator = " : ";
+
+ private readonly StackPanel _stack;
+ private string _plainText = string.Empty;
+
+ public string Text
+ {
+ get => _plainText;
+ set
+ {
+ _plainText = value ?? string.Empty;
+ Rebuild();
+ }
+ }
+
+ public MediaInfoTablePanel(string text, ContextObject context)
+ {
+ _ = context;
+
+ _stack = new StackPanel();
+ Grid.SetIsSharedSizeScope(_stack, true);
+
+ var scroll = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ PanningMode = PanningMode.VerticalFirst,
+ Content = _stack,
+ };
+
+ // UserControl does not render its Background by default; wrap it in a Border that mirrors it.
+ var border = new Border();
+ border.SetBinding(Border.BackgroundProperty, new Binding(nameof(Background)) { Source = this });
+ border.Child = scroll;
+ Content = border;
+
+ Margin = new Thickness(8d, 0d, 0d, 0d);
+ FontSize = 14d;
+ AllowDrop = true;
+
+ FontFamily = new FontFamily("Consolas, " + TranslationHelper.Get("Editor_FontFamily",
+ domain: "QuickLook.Plugin.TextViewer"));
+
+ var copy = new MenuItem
+ {
+ Header = TranslationHelper.Get("Editor_Copy", domain: "QuickLook.Plugin.TextViewer"),
+ };
+ copy.Click += (_, _) =>
+ {
+ try
+ {
+ Clipboard.SetText(_plainText);
+ }
+ catch
+ {
+ // Clipboard can be locked by another process; ignore.
+ }
+ };
+ ContextMenu = new ContextMenu();
+ ContextMenu.Items.Add(copy);
+
+ Text = text;
+ }
+
+ private void Rebuild()
+ {
+ _stack.Children.Clear();
+
+ foreach (var raw in _plainText.Replace("\r\n", "\n").Split('\n'))
+ {
+ var line = raw.TrimEnd();
+ if (line.Length == 0)
+ continue;
+
+ var sep = line.IndexOf(ColumnSeparator, StringComparison.Ordinal);
+ if (sep > 0)
+ _stack.Children.Add(BuildFieldRow(
+ line.Substring(0, sep).Trim(),
+ line.Substring(sep + ColumnSeparator.Length)));
+ else
+ _stack.Children.Add(BuildSectionHeader(line));
+ }
+ }
+
+ private static TextBlock BuildSectionHeader(string text) => new()
+ {
+ Text = text,
+ FontWeight = FontWeights.Bold,
+ Margin = new Thickness(0, 8, 0, 2),
+ };
+
+ private static FrameworkElement BuildFieldRow(string label, string value)
+ {
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto, SharedSizeGroup = "Label" });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1d, GridUnitType.Star) });
+
+ var labelText = new TextBlock
+ {
+ Text = label,
+ TextAlignment = TextAlignment.Left,
+ Margin = new Thickness(0, 0, 6, 0),
+ };
+ var colonText = new TextBlock
+ {
+ Text = ":",
+ Margin = new Thickness(0, 0, 6, 0),
+ };
+ var valueText = new TextBlock
+ {
+ Text = value,
+ TextWrapping = TextWrapping.Wrap,
+ };
+
+ Grid.SetColumn(labelText, 0);
+ Grid.SetColumn(colonText, 1);
+ Grid.SetColumn(valueText, 2);
+ grid.Children.Add(labelText);
+ grid.Children.Add(colonText);
+ grid.Children.Add(valueText);
+
+ return grid;
+ }
+}
diff --git a/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/Plugin.cs
index c034f9b60..9f61798b0 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/Plugin.cs
+++ b/QuickLook.Plugin/QuickLook.Plugin.MediaInfoViewer/Plugin.cs
@@ -19,10 +19,13 @@
using QuickLook.Common.Plugin;
using QuickLook.Common.Plugin.MoreMenu;
using QuickLook.MediaInfo;
+using QuickLook.MediaInfo.Core;
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
@@ -31,7 +34,7 @@ namespace QuickLook.Plugin.MediaInfoViewer;
public sealed partial class Plugin : IViewer, IMoreMenuExtended
{
- private TextViewerPanel _tvp;
+ private MediaInfoTablePanel _tvp;
public int Priority => int.MinValue;
@@ -56,8 +59,9 @@ public void View(string path, ContextObject context)
{
using MediaInfoNative lib = new();
lib.Open(path);
+ ApplyLanguage(lib);
- _tvp = new TextViewerPanel(lib.Inform(), context);
+ _tvp = new MediaInfoTablePanel(LocalizeExeFields(lib.Inform()), context);
AssignHighlightingManager(_tvp, context);
_tvp.Tag = context;
@@ -82,7 +86,8 @@ private void OnDrop(object sender, DragEventArgs e)
using MediaInfoNative lib = new();
lib.Open(path);
- _tvp!.Text = lib.Inform();
+ ApplyLanguage(lib);
+ _tvp!.Text = LocalizeExeFields(lib.Inform());
}
}
}
@@ -94,20 +99,114 @@ public void Cleanup()
_tvp = null!;
}
- private void AssignHighlightingManager(TextViewerPanel tvp, ContextObject context)
+ private static void ApplyLanguage(MediaInfoNative lib)
+ {
+ // Match MediaInfo's output language to QuickLook's UI language so field labels render in
+ // the user's language. Unsupported cultures fall back to MediaInfo's default (English).
+ var lang = CultureInfo.CurrentUICulture.Name.ToLowerInvariant() switch
+ {
+ "zh-cn" or "zh-hans" or "zh" => MediaInfoLanguage.ChineseSimplified,
+ "zh-tw" or "zh-hk" or "zh-hant" => MediaInfoLanguage.ChineseTraditional,
+ "ja" => MediaInfoLanguage.Japanese,
+ "ko" => MediaInfoLanguage.Korean,
+ "fr" => MediaInfoLanguage.French,
+ "de" => MediaInfoLanguage.German,
+ "es" => MediaInfoLanguage.Spanish,
+ "it" => MediaInfoLanguage.Italian,
+ "ru" => MediaInfoLanguage.Russian,
+ "pt-br" => MediaInfoLanguage.PortugueseBrazilian,
+ "pt" => MediaInfoLanguage.Portuguese,
+ "nl" => MediaInfoLanguage.Dutch,
+ "sv" => MediaInfoLanguage.Swedish,
+ "pl" => MediaInfoLanguage.Polish,
+ "cs" => MediaInfoLanguage.Czech,
+ "ar" => MediaInfoLanguage.Arabic,
+ _ => (MediaInfoLanguage?)null,
+ };
+
+ if (lang is MediaInfoLanguage value)
+ {
+ var csv = value.ToIso639();
+ if (!string.IsNullOrEmpty(csv))
+ lib.Option("Language", csv);
+ }
+ }
+
+ private const string ColumnSeparator = " : ";
+
+ ///
+ /// The zh-CN language CSV shipped with MediaInfo is missing several PE/EXE field labels, so the
+ /// library falls back to full-width Latin (e.g. Linker_Version). Normalize those
+ /// labels back to half-width (NFKC), translate the missing fields, then re-align the columns.
+ ///
+ private static readonly Dictionary MissingExeFieldTranslations = new()
+ {
+ ["Linker_Version"] = "链接器版本",
+ ["Subsystem_Name"] = "子系统名称",
+ ["Subsystem_Version"] = "子系统版本",
+ ["Machine_Type"] = "机器类型",
+ ["Entry_Point"] = "入口点",
+ ["Image_Version"] = "映像版本",
+ ["OS_Version"] = "操作系统版本",
+ ["Code_Size"] = "代码大小",
+ ["Initialized_Data_Size"] = "已初始化数据大小",
+ ["Uninitialized_Data_Size"] = "未初始化数据大小",
+ ["Time_Stamp"] = "时间戳",
+ ["TimeStamp"] = "时间戳",
+ };
+
+ private static string LocalizeExeFields(string inform)
+ {
+ if (string.IsNullOrEmpty(inform))
+ return inform;
+
+ var lines = inform.Replace("\r\n", "\n").Split('\n');
+ var sb = new StringBuilder(inform.Length);
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+ var sep = line.IndexOf(ColumnSeparator, StringComparison.Ordinal);
+ if (sep > 0)
+ {
+ var label = line.Substring(0, sep).TrimEnd();
+ var value = line.Substring(sep + ColumnSeparator.Length);
+
+ // Normalize full-width fallback labels (Linker_Version → Linker_Version).
+ var normalized = label.Normalize(NormalizationForm.FormKC);
+ // Translate missing PE field names, and the nested (Profile) token.
+ normalized = normalized.Replace("(Profile)", "(配置)");
+ if (MissingExeFieldTranslations.TryGetValue(normalized, out var translated))
+ normalized = translated;
+
+ sb.Append(normalized).Append(ColumnSeparator).Append(value);
+ }
+ else
+ {
+ sb.Append(line);
+ }
+
+ if (i < lines.Length - 1)
+ sb.Append('\n');
+ }
+
+ return sb.ToString();
+ }
+
+ private void AssignHighlightingManager(Control panel, ContextObject context)
{
var isDark = OSThemeHelper.AppsUseDarkTheme();
if (isDark)
{
context.Theme = Themes.Dark;
- tvp.Background = Brushes.Transparent;
- tvp.SetResourceReference(TextBlock.ForegroundProperty, "WindowTextForeground");
+ panel.Background = Brushes.Transparent;
+ panel.SetResourceReference(TextBlock.ForegroundProperty, "WindowTextForeground");
}
else
{
context.Theme = Themes.Light;
- tvp.Background = OSThemeHelper.AppsUseDarkTheme()
+ panel.Background = OSThemeHelper.AppsUseDarkTheme()
? new SolidColorBrush(Color.FromArgb(175, 255, 255, 255))
: Brushes.Transparent;
}
diff --git a/QuickLook/ViewerWindow.Actions.cs b/QuickLook/ViewerWindow.Actions.cs
index 25349b7f1..ce9f089b8 100644
--- a/QuickLook/ViewerWindow.Actions.cs
+++ b/QuickLook/ViewerWindow.Actions.cs
@@ -529,11 +529,11 @@ protected override void OnClosing(CancelEventArgs e)
UnloadPlugin();
busyDecorator.Dispose();
- // Detach the WndProc hook so the HwndSource can be released cleanly.
- if (_windowHwndSource != null && _windowHook != null)
- _windowHwndSource.RemoveHook(_windowHook);
- _windowHwndSource = null;
- _windowHook = null;
+ // Do NOT remove the WndProc hook here. Removing it leaves a window that still exists (until
+ // base.OnClosing runs) but is no longer guarded, so a WM_NCHITTEST arriving in that instant
+ // (e.g. the mouse resting on the close button) falls through to WPF's WindowChromeWorker
+ // and can overflow. The HwndSource disposes and releases the hook chain when the window
+ // actually closes, so leaving it attached is safe.
base.OnClosing(e);