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)