From aff2c9dab8c8e6fbfe56fb865ba5b5562d7d3467 Mon Sep 17 00:00:00 2001 From: GA Date: Sun, 25 Oct 2026 15:33:12 +0100 Subject: [PATCH] feat: keep Explorer info tips from covering the preview Explorer's tooltips are topmost windows, so a file info tip could be drawn on top of the preview window. Dismiss (TTM_POP) any Explorer tooltip that overlaps a visible QuickLook window, including those already showing when a preview opens. Tooltips elsewhere are untouched. Runs on its own thread so it reacts while the UI thread is busy. KeyboardLog.cs (the QuickLook.Keyboard.log writer) is included because the suppressor logs its failures there; it is identical to the copy added by the Raw Input keyboard change. Co-Authored-By: Claude Haiku 5.5 --- QuickLook/App.xaml.cs | 3 + .../Helpers/ExplorerTooltipSuppressor.cs | 213 ++++++++++++++++++ QuickLook/Helpers/KeyboardLog.cs | 102 +++++++++ 3 files changed, 318 insertions(+) create mode 100644 QuickLook/Helpers/ExplorerTooltipSuppressor.cs create mode 100644 QuickLook/Helpers/KeyboardLog.cs diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 838e774e7..e623b1d1e 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -51,6 +51,7 @@ public partial class App : Application private bool _cleanExit = true; private Mutex _isRunning; + private ExplorerTooltipSuppressor _tooltipSuppressor; static App() { @@ -268,6 +269,7 @@ protected override void OnExit(ExitEventArgs e) PipeServerManager.GetInstance().Dispose(); TrayIconManager.GetInstance().Dispose(); KeystrokeDispatcher.GetInstance().Dispose(); + _tooltipSuppressor?.Dispose(); ViewWindowManager.GetInstance().Dispose(); } @@ -362,6 +364,7 @@ private void RunListener(StartupEventArgs e) PluginManager.GetInstance(); ViewWindowManager.GetInstance(); KeystrokeDispatcher.GetInstance(); + _tooltipSuppressor = new ExplorerTooltipSuppressor(); PipeServerManager.GetInstance(); } } diff --git a/QuickLook/Helpers/ExplorerTooltipSuppressor.cs b/QuickLook/Helpers/ExplorerTooltipSuppressor.cs new file mode 100644 index 000000000..da2bfea4e --- /dev/null +++ b/QuickLook/Helpers/ExplorerTooltipSuppressor.cs @@ -0,0 +1,213 @@ +// 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.NativeMethods; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows.Forms; + +namespace QuickLook.Helpers; + +/// +/// Keeps Explorer's tooltips (file info tips) from covering the preview. +/// +/// +/// Explorer's tooltips are topmost windows, so an info tip scheduled for the file under the mouse shows up on +/// top of the preview that opened in the meantime. Whenever such a tooltip overlaps a visible QuickLook window, +/// it is dismissed; everywhere else Explorer's tooltips are left alone. Runs on its own thread so it reacts +/// immediately even while the UI thread is busy loading a preview. +/// +internal sealed class ExplorerTooltipSuppressor : IDisposable +{ + private const uint EVENT_OBJECT_SHOW = 0x8002; + private const int OBJID_WINDOW = 0; + private const int CHILDID_SELF = 0; + private const uint TTM_POP = 0x041C; + private const string TOOLTIP_CLASS = "tooltips_class32"; + + private static readonly uint CurrentProcessId = (uint)Process.GetCurrentProcess().Id; + + private SynchronizationContext _context; + + public ExplorerTooltipSuppressor() + { + using var ready = new ManualResetEventSlim(); + var thread = new Thread(() => Run(ready)) + { + IsBackground = true, + Name = "QuickLook.ExplorerTooltipSuppressor", + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(); + } + + public void Dispose() + { + _context?.Post(_ => Application.ExitThread(), null); + _context = null; + } + + private void Run(ManualResetEventSlim ready) + { + _context = new WindowsFormsSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(_context); + + User32.WinEventProc proc = OnWindowShown; // keep a reference so it is not collected + var hook = User32.SetWinEventHook(EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW, IntPtr.Zero, proc, + 0, 0, User32.WINEVENT_OUTOFCONTEXT); + + ready.Set(); + Application.Run(); + + User32.UnhookWinEvent(hook); + GC.KeepAlive(proc); + } + + private void OnWindowShown(nint hWinEventHook, uint eventType, nint hwnd, + int idObject, int idChild, uint idEventThread, uint dwmsEventTime) + { + if (idObject != OBJID_WINDOW || idChild != CHILDID_SELF || hwnd == IntPtr.Zero) + return; + + try + { + GetWindowThreadProcessId(hwnd, out var pid); + + if (pid == CurrentProcessId) + { + // a preview opened: dismiss the tooltips already showing underneath it + if (!IsTooltip(hwnd)) + DismissOverlappingTooltips(); + } + else if (IsTooltip(hwnd) && IsExplorer(pid) && OverlapsPreview(hwnd)) + { + Dismiss(hwnd); + } + } + catch (Exception e) + { + KeyboardLog.Write($"TooltipSuppressor: {e.GetType().Name}: {e.Message}"); + } + } + + private static void DismissOverlappingTooltips() + { + var tooltips = new List(); + EnumWindows((hwnd, _) => + { + if (IsWindowVisible(hwnd) && IsTooltip(hwnd)) + tooltips.Add(hwnd); + return true; + }, IntPtr.Zero); + + foreach (var tooltip in tooltips) + { + GetWindowThreadProcessId(tooltip, out var pid); + if (pid != CurrentProcessId && IsExplorer(pid) && OverlapsPreview(tooltip)) + Dismiss(tooltip); + } + } + + private static void Dismiss(nint tooltip) + { + PostMessage(tooltip, TTM_POP, IntPtr.Zero, IntPtr.Zero); + } + + private static bool OverlapsPreview(nint tooltip) + { + if (!GetWindowRect(tooltip, out var tip)) + return false; + + var overlaps = false; + EnumWindows((hwnd, _) => + { + GetWindowThreadProcessId(hwnd, out var pid); + if (pid == CurrentProcessId && IsWindowVisible(hwnd) && !IsTooltip(hwnd) && + GetWindowRect(hwnd, out var window) && Intersects(tip, window)) + { + overlaps = true; + return false; + } + return true; + }, IntPtr.Zero); + + return overlaps; + } + + private static bool Intersects(RECT a, RECT b) + { + return a.Left < b.Right && b.Left < a.Right && a.Top < b.Bottom && b.Top < a.Bottom; + } + + private static bool IsTooltip(nint hwnd) + { + var className = new StringBuilder(64); + GetClassName(hwnd, className, className.Capacity); + return className.ToString() == TOOLTIP_CLASS; + } + + private static bool IsExplorer(uint pid) + { + try + { + using var process = Process.GetProcessById((int)pid); + return string.Equals(process.ProcessName, "explorer", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; // exited meanwhile + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private delegate bool EnumWindowsProc(nint hwnd, nint lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc callback, nint lParam); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(nint hwnd, out uint processId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(nint hwnd, StringBuilder className, int maxCount); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(nint hwnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(nint hwnd, out RECT rect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PostMessage(nint hwnd, uint msg, nint wParam, nint lParam); +} diff --git a/QuickLook/Helpers/KeyboardLog.cs b/QuickLook/Helpers/KeyboardLog.cs new file mode 100644 index 000000000..b92d2d510 --- /dev/null +++ b/QuickLook/Helpers/KeyboardLog.cs @@ -0,0 +1,102 @@ +// 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 System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using System.Threading; + +namespace QuickLook.Helpers; + +/// +/// Diagnostic log for the keyboard pipeline, written to QuickLook.Keyboard.log in the data folder. +/// Only QuickLook's own keys and anomalies are logged, never other keystrokes. +/// +internal static class KeyboardLog +{ + private const long MAX_SIZE = 512 * 1024; + + /// Preview phases slower than this block switching to the next file and get logged. + private const long SLOW_PREVIEW_PHASE_MS = 100; + + private static readonly string LogPath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Keyboard.log"); + + // Bounded so that a stuck disk can never make the keyboard hook wait or grow memory. + private static readonly BlockingCollection Queue = new(new ConcurrentQueue(), 1000); + + static KeyboardLog() + { + new Thread(WriteLoop) + { + IsBackground = true, + Name = "QuickLook.KeyboardLog", + Priority = ThreadPriority.BelowNormal, + }.Start(); + } + + /// + /// Never blocks; safe to call from inside the low-level keyboard hook. + /// + public static void Write(string message) + { + Queue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId}] {message}"); + } + + /// + /// Logs a preview phase that ran on the UI thread only when it was slow enough to delay + /// the next selection change, naming the plugin and file type, never the file name. + /// + public static void WriteIfSlow(string phase, long elapsedMs) + { + if (elapsedMs >= SLOW_PREVIEW_PHASE_MS) + Write($"Slow preview {phase}: {elapsedMs} ms"); + } + + private static void WriteLoop() + { + var batch = new StringBuilder(); + + foreach (var line in Queue.GetConsumingEnumerable()) + { + batch.AppendLine(line); + while (Queue.TryTake(out var more)) + batch.AppendLine(more); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); + + var file = new FileInfo(LogPath); + if (file.Exists && file.Length > MAX_SIZE) + { + File.Delete(LogPath + ".old"); + File.Move(LogPath, LogPath + ".old"); + } + + File.AppendAllText(LogPath, batch.ToString()); + } + catch + { + // logging must never take QuickLook down + } + + batch.Clear(); + } + } +}