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();
+ }
+ }
+}