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 d23f207f3..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)
@@ -88,7 +93,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 +149,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.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
{
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/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..ce9f089b8 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 ...
@@ -514,6 +529,12 @@ protected override void OnClosing(CancelEventArgs e)
UnloadPlugin();
busyDecorator.Dispose();
+ // 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);
ProcessHelper.PerformAggressiveGC();
diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs
index 7eb190af7..431126316 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,8 @@ public partial class ViewerWindow : Window
private string _path = string.Empty;
private FileSystemWatcher _autoReloadWatcher;
private readonly bool _autoReload;
+ private HwndSource _windowHwndSource;
+ private HwndSourceHook _windowHook;
internal ViewerWindow()
{
@@ -67,6 +71,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 +197,91 @@ protected override void OnSourceInitialized(EventArgs e)
WindowHelper.RemoveWindowControls(this);
ApplyWindowBackgroundEffects();
+
+ // 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;
+
+ if (msg == WM_NCHITTEST)
+ {
+ // 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);
+
+ 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;
+ 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)
+ {
+ 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;
+ }
+
+ // 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;
}
protected override void OnContentRendered(EventArgs e)
@@ -464,6 +558,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)