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