Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ Example:
### `<AutoCloseHolding>`
- Default: `True`
- Type: `Boolean`
- Description: Automatically close the preview window when the Space key is released after being held. When `True` (default), releasing Space after a long-press (≥750 ms) closes the preview. When `False`, the preview stays open on Space key release.
- Description: Automatically close the preview window when the Space key is released after being held. When `True` (default), releasing Space after a long-press (it auto-repeats, or ≥750 ms) closes the preview. When `False`, the preview stays open on Space key release.
- Example:
- `<AutoCloseHolding>False</AutoCloseHolding>` to disable auto-close on Space release.

### `<DoubleSpaceToEdit>`
- Default: `True`
- Type: `Boolean`
- Description: Double-tap Space (two presses within 300 ms) to close the preview and open the selected file with the program associated with its "edit" verb, or with its default program when it has none. When `False`, a second Space toggles the preview like any other press.
- Example:
- `<DoubleSpaceToEdit>False</DoubleSpaceToEdit>` to disable double-tap Space to edit.

### `<CloseOnLostFocus>`
- Default: `False`
- Type: `Boolean`
Expand Down
3 changes: 3 additions & 0 deletions QuickLook.Common/NativeMethods/User32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public static extern nint SetWindowsHookEx(int idHook, KeyboardHookProc callback
[DllImport("user32.dll")]
public static extern int CallNextHookEx(nint idHook, int nCode, int wParam, ref KeyboardHookStruct lParam);

[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);

[DllImport("user32.dll")]
public static extern nint SetWindowLong(nint hWnd, int nIndex, int dwNewLong);

Expand Down
171 changes: 130 additions & 41 deletions QuickLook/Helpers/GlobalKeyboardHook.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2017-2026 QL-Win Contributors
// Copyright © 2017-2026 QL-Win Contributors
//
// This file is part of QuickLook program.
//
Expand All @@ -16,104 +16,193 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Windows.Forms;
using System.Windows.Input;
using QuickLook.Common.NativeMethods;
using KeyEventArgs = System.Windows.Forms.KeyEventArgs;
using KeyEventHandler = System.Windows.Forms.KeyEventHandler;

namespace QuickLook.Helpers;

/// <summary>
/// Global keyboard listener running on its own thread; KeyDown and KeyUp are raised on that thread.
/// </summary>
/// <remarks>
/// It is built on Raw Input rather than a low-level keyboard hook (WH_KEYBOARD_LL). QuickLook only observes
/// keys and never blocks them, and a low-level hook proved unreliable for that: Windows silently removes it
/// when a callback is slow, it is not called for keys going to elevated windows (so key-ups get lost), and
/// other hooks in the chain (AutoHotkey, PowerToys, ...) delay or swallow events. Raw Input has none of these
/// problems: while this thread is busy, input waits in its queue instead of being lost.
/// </remarks>
internal partial class GlobalKeyboardHook : IDisposable
{
private const int SLOW_HANDLER_THRESHOLD = 200;

private static GlobalKeyboardHook _instance;

private User32.KeyboardHookProc _callback;
private nint _hHook = IntPtr.Zero;
// Keys currently held according to the input stream; only touched on the listener thread.
private readonly HashSet<Keys> _pressedKeys = [];
private SynchronizationContext _context;

protected GlobalKeyboardHook()
{
Hook();
using var ready = new ManualResetEventSlim();
var thread = new Thread(() => RunListenerThread(ready))
{
IsBackground = true,
Name = "QuickLook.GlobalKeyboardHook",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
ready.Wait();
}

public void Dispose()
{
GC.SuppressFinalize(this);

Unhook();
_context?.Post(_ => Application.ExitThread(), null);
_context = null;
}

internal event KeyEventHandler KeyDown;

internal event KeyEventHandler KeyUp;

/// <summary>
/// Whether the event being raised was synthesized by software (SendInput) rather than typed.
/// Only meaningful inside KeyDown / KeyUp handlers.
/// </summary>
internal bool IsCurrentEventInjected { get; private set; }

/// <summary>
/// Whether the key-down being raised is an auto-repeat of a key already held.
/// Only meaningful inside KeyDown handlers.
/// </summary>
internal bool IsCurrentEventRepeat { get; private set; }

internal static GlobalKeyboardHook GetInstance()
{
return _instance ??= new GlobalKeyboardHook();
}

private void Hook()
/// <summary>
/// Runs the action on the listener thread and waits for it, so that state shared with the
/// KeyDown / KeyUp handlers is only ever touched from one thread.
/// </summary>
internal void Invoke(Action action)
{
_callback = HookProc;
_context?.Send(_ => action(), null);
}

/// <summary>
/// Whether the key is held, as tracked from the input stream and confirmed by Windows.
/// Must be called on the listener thread.
/// </summary>
internal bool IsKeyHeld(Keys key)
{
if (!_pressedKeys.Contains(key))
return false;

// A key-up can still go missing, e.g. for a key held while the secure desktop (UAC, Ctrl+Alt+Del)
// was shown. Trust Windows when it says the key is up, so a stale entry heals by itself.
if (IsKeyDown(key))
return true;

var hInstance = Kernel32.LoadLibrary("user32.dll");
_hHook = User32.SetWindowsHookEx(User32.WH_KEYBOARD_LL, _callback, hInstance, 0);
_pressedKeys.Remove(key);
return false;
}

private void Unhook()
// WPF's Keyboard class is bound to the UI thread, so query the system-wide key state instead.
internal static bool IsKeyDown(Keys key)
{
if (_callback == null) return;
return (User32.GetAsyncKeyState((int)key) & 0x8000) != 0;
}

private void RunListenerThread(ManualResetEventSlim ready)
{
_context = new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(_context);

User32.UnhookWindowsHookEx(_hHook);
using var rawKeyboard = new RawKeyboardMonitor();
rawKeyboard.KeyInput += OnKeyInput;
KeyboardLog.Write("Listener: started (Raw Input)");

_callback = null;
ready.Set();
Application.Run();
}

private int HookProc(int code, int wParam, ref User32.KeyboardHookStruct lParam)
private void OnKeyInput(RawKeyEvent input)
{
if (code < 0)
return User32.CallNextHookEx(_hHook, code, wParam, ref lParam);
var key = NormalizeModifier(input.Key);

if (IsWindowsKeyPressed())
return User32.CallNextHookEx(_hHook, code, wParam, ref lParam);
var isRepeat = input.IsDown && _pressedKeys.Contains(key);
if (input.IsDown)
_pressedKeys.Add(key);
else
_pressedKeys.Remove(key);

// QuickLook itself may replay third-party hotkeys through SendInput.
// These events are tagged in dwExtraInfo and must be forwarded without
// entering QuickLook's hotkey pipeline, otherwise they can pollute state
// (for example invalid-key timing windows) and cause false toggles.
if (lParam.dwExtraInfo == QUICKLOOK_THIRD_PARTY_HOTKEY_REPLAY_EXTRA_INFO)
return User32.CallNextHookEx(_hHook, code, wParam, ref lParam);

var key = (Keys)lParam.vkCode;
key = AddModifiers(key);

var kea = new KeyEventArgs(key);

if (wParam == User32.WM_KEYDOWN || wParam == User32.WM_SYSKEYDOWN)
KeyDown?.Invoke(this, kea);
else if (wParam == User32.WM_KEYUP || wParam == User32.WM_SYSKEYUP)
KeyUp?.Invoke(this, kea);

return kea.Handled ? 1 : User32.CallNextHookEx(_hHook, code, wParam, ref lParam);
if (input.ExtraInfo == QUICKLOOK_THIRD_PARTY_HOTKEY_REPLAY_EXTRA_INFO)
return;

// Ignore Win+key presses, but always deliver releases: dropping a key-up would leave
// the dispatcher believing the key is still held.
if (input.IsDown && (IsKeyHeld(Keys.LWin) || IsKeyHeld(Keys.RWin)))
{
if (input.Key == Keys.Space && !isRepeat)
KeyboardLog.Write("Listener: Space down ignored, Windows key held");
return;
}

var kea = new KeyEventArgs(AddModifiers(input.Key));
IsCurrentEventInjected = input.IsInjected;
IsCurrentEventRepeat = isRepeat;
var start = Environment.TickCount;

try
{
if (input.IsDown)
KeyDown?.Invoke(this, kea);
else
KeyUp?.Invoke(this, kea);
}
catch (Exception e)
{
KeyboardLog.Write($"Listener: handler threw {e.GetType().Name}: {e.Message}");
}

var elapsed = Environment.TickCount - start;
if (elapsed > SLOW_HANDLER_THRESHOLD)
KeyboardLog.Write($"Listener: slow handler {elapsed} ms for {kea.KeyCode} {(input.IsDown ? "down" : "up")}");
}

private bool IsWindowsKeyPressed()
// Raw Input reports the generic VK_SHIFT / VK_CONTROL / VK_MENU; track them under one name each.
private static Keys NormalizeModifier(Keys key)
{
return Keyboard.IsKeyDown(Key.LWin) || Keyboard.IsKeyDown(Key.RWin);
return key switch
{
Keys.LShiftKey or Keys.RShiftKey => Keys.ShiftKey,
Keys.LControlKey or Keys.RControlKey => Keys.ControlKey,
Keys.LMenu or Keys.RMenu => Keys.Menu,
_ => key,
};
}

private Keys AddModifiers(Keys key)
{
// Ctrl
if ((Keyboard.Modifiers & ModifierKeys.Control) != 0)
if (IsKeyHeld(Keys.ControlKey))
key |= Keys.Control;

// Shift
if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
if (IsKeyHeld(Keys.ShiftKey))
key |= Keys.Shift;

// Alt
if ((Keyboard.Modifiers & ModifierKeys.Alt) != 0)
if (IsKeyHeld(Keys.Menu))
key |= Keys.Alt;

return key;
Expand All @@ -125,7 +214,7 @@ internal partial class GlobalKeyboardHook
// Marker written to INPUT::dwExtraInfo for synthetic hotkeys replayed by QuickLook
// through SendInput when integrating with third-party file managers.
//
// The low-level keyboard hook checks this value and immediately forwards the event,
// The keyboard listener checks this value and ignores the event,
// so QuickLook does not treat its own replayed keystrokes as real user input.
// This prevents false invalid-key suppression and accidental preview state changes.
// 0x514C5452 == 'QLTR' (QuickLook Third-party Replay)
Expand Down
102 changes: 102 additions & 0 deletions QuickLook/Helpers/KeyboardLog.cs
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

using QuickLook.Common.Helpers;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Threading;

namespace QuickLook.Helpers;

/// <summary>
/// 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.
/// </summary>
internal static class KeyboardLog
{
private const long MAX_SIZE = 512 * 1024;

/// <summary>Preview phases slower than this block switching to the next file and get logged.</summary>
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<string> Queue = new(new ConcurrentQueue<string>(), 1000);

static KeyboardLog()
{
new Thread(WriteLoop)
{
IsBackground = true,
Name = "QuickLook.KeyboardLog",
Priority = ThreadPriority.BelowNormal,
}.Start();
}

/// <summary>
/// Never blocks; safe to call from inside the low-level keyboard hook.
/// </summary>
public static void Write(string message)
{
Queue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId}] {message}");
}

/// <summary>
/// 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.
/// </summary>
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();
}
}
}
Loading