From 6f7039d786c589797c30962bf7e2b838ccc07bcd Mon Sep 17 00:00:00 2001 From: GA Date: Sun, 25 Oct 2026 15:32:28 +0100 Subject: [PATCH] fix: make Space handling robust (Raw Input listener, per-key press state) Space could stop working until QuickLook was restarted, and releasing a held Space sometimes left the preview open. Causes found with a diagnostic log on a real machine: - The WH_KEYBOARD_LL hook ran on the UI thread; while a plugin loaded a large file, callbacks exceeded LowLevelHooksTimeout and Windows silently removed the hook. The hook is also not called for keys going to elevated windows, and other hooks (AutoHotkey, PowerToys) delayed or swallowed events, so key-ups got lost. - A lost Space key-up left _spaceIsDown set, and every later Space was treated as auto-repeat and ignored. - One shared _isPreviewRequest flag: any other valid key (e.g. an arrow) pressed while holding Space cancelled the hold-to-close. - The "typed another key within 1 s" filter also applied to key-ups, so a held Space was not closed on release; keystrokes injected by other software counted as typing. - Holds shorter than 750 ms were treated as taps even though the keyboard was already auto-repeating. - Releasing Space before the arrow key toggled (opened the newly selected file) instead of closing. Changes: - GlobalKeyboardHook listens through Raw Input (RIDEV_INPUTSINK) on its own thread instead of a low-level hook. QuickLook never blocks keys, and Raw Input cannot be removed for being slow nor delayed by other hooks; while the thread is busy, input is queued, not lost. Repeats and modifiers are tracked from the input stream itself. - KeystrokeDispatcher remembers per key whether its press was accepted; a release always completes exactly that press. The typing filter only applies to presses and ignores injected keystrokes. - A held Space counts as a hold once it auto-repeats (or after 750 ms), and releasing a hold closes the preview instead of toggling. - A watchdog replays a Space key-up if Windows reports the key released while QuickLook still considers it held. - Double-tapping Space (two presses within 300 ms) closes the preview and opens the selected file with the program associated with its "edit" verb, or its default program. New DoubleSpaceToEdit setting, on by default, documented in OPTIONS.md. - Space is ignored while the preview reports unsaved changes, and an Escape that only left the viewer's editor does not also close the preview. The preview window asks the viewer through public HasUnsavedChanges / ConsumeEscape() members (looked up by reflection, plugins share no interface for them); viewers without them are unaffected. - QuickLook.Keyboard.log in the data folder records QuickLook's own keys, modifiers, focus changes and anomalies (never typed text). Co-Authored-By: Claude Haiku 5.5 --- OPTIONS.md | 9 +- QuickLook.Common/NativeMethods/User32.cs | 3 + QuickLook/Helpers/GlobalKeyboardHook.cs | 171 +++++++++--- QuickLook/Helpers/KeyboardLog.cs | 102 +++++++ QuickLook/Helpers/RawKeyboardMonitor.cs | 178 ++++++++++++ QuickLook/KeystrokeDispatcher.cs | 331 ++++++++++++++++++++--- QuickLook/PipeServerManager.cs | 7 + QuickLook/ViewWindowManager.cs | 68 +++++ 8 files changed, 782 insertions(+), 87 deletions(-) create mode 100644 QuickLook/Helpers/KeyboardLog.cs create mode 100644 QuickLook/Helpers/RawKeyboardMonitor.cs diff --git a/OPTIONS.md b/OPTIONS.md index a5fd6b0cb..f253877c2 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -77,10 +77,17 @@ Example: ### `` - 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: - `False` to disable auto-close on Space release. +### `` +- 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: + - `False` to disable double-tap Space to edit. + ### `` - Default: `False` - Type: `Boolean` diff --git a/QuickLook.Common/NativeMethods/User32.cs b/QuickLook.Common/NativeMethods/User32.cs index 81271a8f4..d72b0fe53 100644 --- a/QuickLook.Common/NativeMethods/User32.cs +++ b/QuickLook.Common/NativeMethods/User32.cs @@ -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); diff --git a/QuickLook/Helpers/GlobalKeyboardHook.cs b/QuickLook/Helpers/GlobalKeyboardHook.cs index 226a2bdbb..44001f44d 100644 --- a/QuickLook/Helpers/GlobalKeyboardHook.cs +++ b/QuickLook/Helpers/GlobalKeyboardHook.cs @@ -1,4 +1,4 @@ -// Copyright © 2017-2026 QL-Win Contributors +// Copyright © 2017-2026 QL-Win Contributors // // This file is part of QuickLook program. // @@ -16,104 +16,193 @@ // along with this program. If not, see . 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; +/// +/// Global keyboard listener running on its own thread; KeyDown and KeyUp are raised on that thread. +/// +/// +/// 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. +/// 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 _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; + /// + /// Whether the event being raised was synthesized by software (SendInput) rather than typed. + /// Only meaningful inside KeyDown / KeyUp handlers. + /// + internal bool IsCurrentEventInjected { get; private set; } + + /// + /// Whether the key-down being raised is an auto-repeat of a key already held. + /// Only meaningful inside KeyDown handlers. + /// + internal bool IsCurrentEventRepeat { get; private set; } + internal static GlobalKeyboardHook GetInstance() { return _instance ??= new GlobalKeyboardHook(); } - private void Hook() + /// + /// 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. + /// + internal void Invoke(Action action) { - _callback = HookProc; + _context?.Send(_ => action(), null); + } + + /// + /// Whether the key is held, as tracked from the input stream and confirmed by Windows. + /// Must be called on the listener thread. + /// + 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; @@ -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) 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(); + } + } +} diff --git a/QuickLook/Helpers/RawKeyboardMonitor.cs b/QuickLook/Helpers/RawKeyboardMonitor.cs new file mode 100644 index 000000000..01cc771d6 --- /dev/null +++ b/QuickLook/Helpers/RawKeyboardMonitor.cs @@ -0,0 +1,178 @@ +// 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 System; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace QuickLook.Helpers; + +/// +/// Receives every keystroke of the session through Raw Input, whichever window has the focus. +/// Unlike a low-level keyboard hook, Raw Input is not part of the hook chain: Windows never removes it for +/// being slow, other hooks cannot delay or swallow it, and while this thread is busy input waits in its queue +/// instead of being lost. It only observes; keys always reach their target unchanged. +/// Must be created on a thread with a message loop; KeyInput is raised on that thread. +/// +internal sealed class RawKeyboardMonitor : NativeWindow, IDisposable +{ + private const int WM_INPUT = 0x00FF; + private const uint RID_INPUT = 0x10000003; + private const uint RIM_TYPEKEYBOARD = 1; + private const uint RIDEV_INPUTSINK = 0x00000100; + private const uint RIDEV_REMOVE = 0x00000001; + private const ushort RI_KEY_BREAK = 0x01; + private const ushort VK_FAKE = 0xFF; // part of an escaped scan code sequence, not a key + + public RawKeyboardMonitor() + { + CreateHandle(new CreateParams()); + + if (!Register(RIDEV_INPUTSINK, Handle)) + KeyboardLog.Write($"Raw: RegisterRawInputDevices failed, error {Marshal.GetLastWin32Error()}"); + } + + /// + /// Raised for every key-down (auto-repeat included) and key-up. + /// + public event Action KeyInput; + + public void Dispose() + { + Register(RIDEV_REMOVE, IntPtr.Zero); + DestroyHandle(); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_INPUT) + { + try + { + OnInput(m.LParam); + } + catch (Exception e) + { + KeyboardLog.Write($"Raw: {e.GetType().Name}: {e.Message}"); + } + } + + base.WndProc(ref m); + } + + private void OnInput(nint hRawInput) + { + var size = 0u; + var headerSize = (uint)Marshal.SizeOf(typeof(RAWINPUTHEADER)); + GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, headerSize); + if (size == 0) + return; + + var buffer = Marshal.AllocHGlobal((int)size); + try + { + if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, headerSize) != size) + return; + + var header = (RAWINPUTHEADER)Marshal.PtrToStructure(buffer, typeof(RAWINPUTHEADER)); + if (header.dwType != RIM_TYPEKEYBOARD) + return; + + var keyboard = (RAWKEYBOARD)Marshal.PtrToStructure(buffer + (int)headerSize, typeof(RAWKEYBOARD)); + if (keyboard.VKey == 0 || keyboard.VKey == VK_FAKE) + return; + + KeyInput?.Invoke(new RawKeyEvent( + (Keys)keyboard.VKey, + (keyboard.Flags & RI_KEY_BREAK) == 0, + header.hDevice, + keyboard.ExtraInformation)); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static bool Register(uint flags, nint target) + { + var device = new RAWINPUTDEVICE + { + usUsagePage = 0x01, // generic desktop + usUsage = 0x06, // keyboard + dwFlags = flags, + hwndTarget = target, + }; + + return RegisterRawInputDevices([device], 1, (uint)Marshal.SizeOf(typeof(RAWINPUTDEVICE))); + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort usUsagePage; + public ushort usUsage; + public uint dwFlags; + public nint hwndTarget; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTHEADER + { + public uint dwType; + public uint dwSize; + public nint hDevice; + public nint wParam; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWKEYBOARD + { + public ushort MakeCode; + public ushort Flags; + public ushort Reserved; + public ushort VKey; + public uint Message; + public uint ExtraInformation; + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool RegisterRawInputDevices(RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize); + + [DllImport("user32.dll")] + private static extern uint GetRawInputData(nint hRawInput, uint uiCommand, nint pData, ref uint pcbSize, uint cbSizeHeader); +} + +internal readonly struct RawKeyEvent(Keys key, bool isDown, nint device, uint extraInfo) +{ + public Keys Key { get; } = key; + + public bool IsDown { get; } = isDown; + + /// + /// Source keyboard; zero for input synthesized by software (SendInput). + /// + public nint Device { get; } = device; + + /// + /// The dwExtraInfo passed to SendInput, if any. + /// + public uint ExtraInfo { get; } = extraInfo; + + public bool IsInjected => Device == IntPtr.Zero; +} diff --git a/QuickLook/KeystrokeDispatcher.cs b/QuickLook/KeystrokeDispatcher.cs index e1f62f739..5cc8e6ca6 100644 --- a/QuickLook/KeystrokeDispatcher.cs +++ b/QuickLook/KeystrokeDispatcher.cs @@ -21,7 +21,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Threading; using System.Windows.Forms; +using Application = System.Windows.Application; namespace QuickLook; @@ -34,37 +36,57 @@ internal class KeystrokeDispatcher : IDisposable private GlobalKeyboardHook _hook; private nint _winEventHook; private User32.WinEventProc _winEventProc; // keep reference to prevent GC - private bool _isPreviewRequest; + private readonly HashSet _acceptedPresses = []; // keys whose current press started an action private bool _spaceIsDown; private long _spaceHoldTick; + private int _spaceRepeatCount; + private int _spaceReleasedChecks; private long _lastInvalidKeyPressTick; + private long _lastSpacePressTick; // last accepted Space press, to tell a double tap + private long _spaceSuppressedUntil; // after a double tap opened the editor, extra taps are ignored + private Keys _lastInvalidKey; // for the log only: which key made a valid key count as "typing" + private bool _lastInvalidKeyDown; + private System.Windows.Forms.Timer _spaceWatchdog; // created and ticking on the keyboard hook thread private const long HOLD_TO_PREVIEW_DURATION = TimeSpan.TicksPerMillisecond * 750; private const long VALID_KEY_PRESS_DELAY = TimeSpan.TicksPerSecond * 1; + private const int SPACE_WATCHDOG_INTERVAL = 100; + + // shorter than the double-click time, so two separate taps (open, then close) are not taken as one + private const long DOUBLE_TAP_INTERVAL = TimeSpan.TicksPerMillisecond * 300; + + // a quick third tap after a double tap must not reopen the preview while the editor starts + private const long AFTER_DOUBLE_TAP_QUIET = TimeSpan.TicksPerMillisecond * 1000; protected KeystrokeDispatcher() { - InstallKeyHook(KeyDownEventHandler, KeyUpEventHandler); - InstallForegroundWindowHook(); - + // before the listener starts: its thread may deliver a key right away _validKeys = [ Keys.Up, Keys.Down, Keys.Left, Keys.Right, Keys.Enter, Keys.Space, Keys.Escape, Keys.F5, Keys.F11, ]; + + InstallKeyHook(KeyDownEventHandler, KeyUpEventHandler); + + // on the listener thread, so its callback never races with the key handlers + _hook.Invoke(InstallForegroundWindowHook); } public void Dispose() { - _hook?.Dispose(); - _hook = null; - - if (_winEventHook != IntPtr.Zero) + _hook?.Invoke(() => { + if (_winEventHook == IntPtr.Zero) + return; + User32.UnhookWinEvent(_winEventHook); _winEventHook = IntPtr.Zero; - } + }); + + _hook?.Dispose(); + _hook = null; } private void KeyDownEventHandler(object sender, KeyEventArgs e) @@ -82,64 +104,226 @@ private void CallViewWindowManagerInvokeRoutine(KeyEventArgs e, bool isKeyDown) // skip invalid keys, but record the timestamp if (!_validKeys.Contains(e.KeyCode)) { - Debug.WriteLine($"Invalid keypress: key={e.KeyCode},down={isKeyDown}, time={_lastInvalidKeyPressTick}"); - _lastInvalidKeyPressTick = DateTime.Now.Ticks; - return; - } + // Keystrokes synthesized by other software (AutoHotkey, layout switchers, ...) are not the user + // typing, and must not suppress QuickLook's keys. Only these are logged, never real typing. + if (_hook?.IsCurrentEventInjected == true) + { + KeyboardLog.Write($"Injected {e.KeyCode} {(isKeyDown ? "down" : "up")}: not counted as typing"); + return; + } - // skip valid keys when modifiers are used - if (isKeyDown && e.Modifiers != Keys.None) - return; + Interlocked.Exchange(ref _lastInvalidKeyPressTick, DateTime.Now.Ticks); + _lastInvalidKey = e.KeyCode; + _lastInvalidKeyDown = isKeyDown; - // skip if key is valid but too close after pressing an invalid key - if (DateTime.Now.Ticks - _lastInvalidKeyPressTick < VALID_KEY_PRESS_DELAY) + // modifiers reveal nothing about what is typed, and explain "right after typing" decisions + if (IsModifierKey(e.KeyCode) && !(isKeyDown && _hook.IsCurrentEventRepeat)) + KeyboardLog.Write($"Modifier {e.KeyCode} {(isKeyDown ? "down" : "up")}"); return; - _lastInvalidKeyPressTick = 0L; + } + + var now = DateTime.Now.Ticks; // skip if user is holding Space (don't skip other valid keys) if (isKeyDown && e.KeyCode == Keys.Space) { - if (_spaceIsDown) + if (_hook.IsCurrentEventRepeat) + { + _spaceRepeatCount++; // auto-repeat, not logged return; + } - _spaceHoldTick = DateTime.Now.Ticks; + // a new press although the previous one was never released + if (_spaceIsDown) + Log(e, isKeyDown, "previous key-up was lost, starting a new press"); } - // check if the valid key is a preview request if (isKeyDown) + OnKeyPressed(e, now); + else + OnKeyReleased(e, now); + } + + // Decides whether a press starts an action. Each key remembers the outcome, so its release completes + // exactly that press, whatever other keys (or other software) did in between. + private void OnKeyPressed(KeyEventArgs e, long now) + { + _acceptedPresses.Remove(e.KeyCode); + + if (e.KeyCode == Keys.Space) { - _isPreviewRequest = NativeMethods.QuickLook.GetFocusedWindowType() != - NativeMethods.QuickLook.FocusedWindowType.Invalid; - _isPreviewRequest |= WindowHelper.IsForegroundWindowBelongToSelf(); - } // else (when isKeyDown is false), _isPreviewRequest retain its current state + // from now on its auto-repeat is swallowed, whether or not the press is accepted + _spaceIsDown = true; + _spaceHoldTick = now; + _spaceRepeatCount = 0; + StartSpaceWatchdog(); + } - // call InvokeRoutine only when user pressed a key in a valid window, or - // released a key which was pressed in a valid window, with an exception of Space which - // must be hold for 750ms before releasing. - if (_isPreviewRequest) + // skip valid keys when modifiers are used + if (e.Modifiers != Keys.None) { - if (isKeyDown || e.KeyCode != Keys.Space || - DateTime.Now.Ticks - _spaceHoldTick >= HOLD_TO_PREVIEW_DURATION) - { - InvokeRoutine(e.KeyCode, isKeyDown); + Log(e, true, "ignored: modifier held"); + return; + } - if (isKeyDown && e.KeyCode == Keys.Space) - _spaceIsDown = true; - } + if (e.KeyCode == Keys.Space && now < _spaceSuppressedUntil) + { + Log(e, true, "ignored: a double tap just opened the editor"); + return; + } + + // skip if key is valid but too close after typing another key + if (now - Interlocked.Read(ref _lastInvalidKeyPressTick) < VALID_KEY_PRESS_DELAY) + { + var ago = (now - Interlocked.Read(ref _lastInvalidKeyPressTick)) / TimeSpan.TicksPerMillisecond; + Log(e, true, $"ignored: right after typing another key ({DescribeKey(_lastInvalidKey)} " + + $"{(_lastInvalidKeyDown ? "down" : "up")} {ago} ms before)"); + return; + } + Interlocked.Exchange(ref _lastInvalidKeyPressTick, 0L); + + // only act when a file view (or QuickLook itself) has the focus + var focus = NativeMethods.QuickLook.GetFocusedWindowType(); + if (focus == NativeMethods.QuickLook.FocusedWindowType.Invalid && !WindowHelper.IsForegroundWindowBelongToSelf()) + { + Log(e, true, $"ignored: focus is {focus}"); + return; + } + + _acceptedPresses.Add(e.KeyCode); + + // a quick second Space edits the file instead of toggling the preview + var doubleTap = false; + if (e.KeyCode == Keys.Space) + { + doubleTap = now - _lastSpacePressTick <= DOUBLE_TAP_INTERVAL; + _lastSpacePressTick = doubleTap ? 0L : now; + if (doubleTap) + _spaceSuppressedUntil = now + AFTER_DOUBLE_TAP_QUIET; + } + + Log(e, true, doubleTap ? "invoke (double tap)" : "invoke"); + Dispatch(e.KeyCode, true, doubleTap); + } + + private void OnKeyReleased(KeyEventArgs e, long now) + { + var accepted = _acceptedPresses.Remove(e.KeyCode); + if (e.KeyCode == Keys.Space) + _spaceIsDown = false; + + if (!accepted) + { + if (e.KeyCode == Keys.Space) + Log(e, false, "no action: its press was not accepted"); + return; } - // when the key has been released, reset variables - if (!isKeyDown) + // Space toggles on press; releasing it only closes again after a hold (hold-to-preview). Once the + // keyboard auto-repeats, the user is holding the key, whatever their repeat delay setting is. + if (e.KeyCode == Keys.Space && _spaceRepeatCount == 0 && now - _spaceHoldTick < HOLD_TO_PREVIEW_DURATION) { - _isPreviewRequest = false; - _spaceIsDown = e.KeyCode != Keys.Space && _spaceIsDown; + Log(e, false, "no action: short press"); + return; } + + Log(e, false, "invoke"); + Dispatch(e.KeyCode, false); } - private void InvokeRoutine(Keys key, bool isKeyDown) + // Names the key behind a "right after typing" decision without revealing what was typed: keys that + // produce text are only reported as such. + private static string DescribeKey(Keys key) + { + var isText = key is >= Keys.A and <= Keys.Z or >= Keys.D0 and <= Keys.D9 + or >= Keys.NumPad0 and <= Keys.Divide + or >= Keys.OemSemicolon and <= Keys.Oem102; + return isText ? "a character key" : $"{key} (0x{(int)key:X2})"; + } + + private static bool IsModifierKey(Keys key) + { + return key is Keys.ShiftKey or Keys.LShiftKey or Keys.RShiftKey + or Keys.ControlKey or Keys.LControlKey or Keys.RControlKey + or Keys.Menu or Keys.LMenu or Keys.RMenu + or Keys.LWin or Keys.RWin; + } + + private void Dispatch(Keys key, bool isKeyDown, bool doubleTap = false) + { + // we are on the keyboard hook thread; InvokeRoutine reads settings, which is UI-thread only + Application.Current.Dispatcher.BeginInvoke(new Action(() => InvokeRoutine(key, isKeyDown, doubleTap))); + } + + private void Log(KeyEventArgs e, bool isKeyDown, string outcome) + { + var details = _hook?.IsCurrentEventInjected == true ? " [injected]" : string.Empty; + if (!isKeyDown && e.KeyCode == Keys.Space) + details += $" [held {(DateTime.Now.Ticks - _spaceHoldTick) / TimeSpan.TicksPerMillisecond} ms, {_spaceRepeatCount} repeats]"; + + KeyboardLog.Write($"{e.KeyCode} {(isKeyDown ? "down" : "up")}: {outcome}{details}"); + } + + // Last line of defence: the listener should never miss a key-up, but if one ever goes missing Space + // must not stay blocked. While Space is considered held, keep checking that it really is. + private void StartSpaceWatchdog() + { + if (_spaceWatchdog == null) + { + _spaceWatchdog = new System.Windows.Forms.Timer { Interval = SPACE_WATCHDOG_INTERVAL }; + _spaceWatchdog.Tick += (_, _) => CheckSpaceStillHeld(); + } + + _spaceWatchdog.Start(); + } + + private void CheckSpaceStillHeld() + { + // Windows' key state can change a moment before the matching input reaches us: only act when + // Space has been reported as released on two consecutive checks. + _spaceReleasedChecks = _spaceIsDown && !_hook.IsKeyHeld(Keys.Space) ? _spaceReleasedChecks + 1 : 0; + + if (_spaceReleasedChecks >= 2) + { + // Released while we were not looking: replay the key-up so a long hold still closes the preview. + KeyboardLog.Write("Space: key-up was not received, replaying it"); + _spaceReleasedChecks = 0; + CallViewWindowManagerInvokeRoutine(new KeyEventArgs(Keys.Space), false); + } + + // Note: a pause in Space's auto-repeat does not mean it was released: the keyboard only repeats the + // last key pressed, so pressing an arrow while holding Space stops Space's repeat for good. + + if (!_spaceIsDown) + _spaceWatchdog.Stop(); + } + + private void InvokeRoutine(Keys key, bool isKeyDown, bool doubleTap = false) { Debug.WriteLine($"InvokeRoutine: key={key},down={isKeyDown}"); + // let the user type in an editable text field of the viewer (e.g. the text editor) without + // Space closing the preview or Enter opening the file + if (key != Keys.F11 && WindowHelper.IsForegroundWindowBelongToSelf() && IsTypingInTextInput()) + { + KeyboardLog.Write($"{key} {(isKeyDown ? "down" : "up")}: ignored: typing in QuickLook"); + return; + } + + // this Escape only left the editor of the preview: keep the preview open + if (key == Keys.Escape && !isKeyDown && ViewWindowManager.GetInstance().ConsumeEscape()) + { + KeyboardLog.Write("Escape up: ignored: it left the editor"); + return; + } + + // Space would close the preview (or replace it) and lose the edits: ignore it until they are saved + if (key == Keys.Space && ViewWindowManager.GetInstance().HasUnsavedChanges()) + { + KeyboardLog.Write($"{key} {(isKeyDown ? "down" : "up")}: ignored: the preview has unsaved edits"); + return; + } + if (isKeyDown) { switch (key) @@ -149,7 +333,9 @@ private void InvokeRoutine(Keys key, bool isKeyDown) break; case Keys.Space: - PipeServerManager.SendMessage(PipeMessages.Toggle); + PipeServerManager.SendMessage(doubleTap && SettingHelper.Get("DoubleSpaceToEdit", true, "QuickLook") + ? PipeMessages.Edit + : PipeMessages.Toggle); break; case Keys.F5: @@ -177,13 +363,39 @@ private void InvokeRoutine(Keys key, bool isKeyDown) break; case Keys.Space: + // Releasing a held Space ends the peek. Close rather than toggle: if the selection changed + // while Space was held (arrow keys), a toggle would open the newly selected file instead. if (SettingHelper.Get("AutoCloseHolding", true, "QuickLook")) - PipeServerManager.SendMessage(PipeMessages.Toggle); + PipeServerManager.SendMessage(PipeMessages.Close); break; } } } + private static bool IsTypingInTextInput() + { + var element = System.Windows.Input.Keyboard.FocusedElement as System.Windows.DependencyObject; + while (element != null) + { + if (element is System.Windows.Controls.Primitives.TextBoxBase textBox) + return !textBox.IsReadOnly; + + // AvalonEdit's TextEditor lives in the plugins, so match it (or a subclass such as + // the TextViewerPanel) by name + for (var type = element.GetType(); type != null; type = type.BaseType) + { + if (type.FullName == "ICSharpCode.AvalonEdit.TextEditor") + return type.GetProperty("IsReadOnly")?.GetValue(element) is false; + } + + element = element is System.Windows.Media.Visual + ? System.Windows.Media.VisualTreeHelper.GetParent(element) + : System.Windows.LogicalTreeHelper.GetParent(element); + } + + return false; + } + private void InstallKeyHook(KeyEventHandler downHandler, KeyEventHandler upHandler) { _hook = GlobalKeyboardHook.GetInstance(); @@ -210,13 +422,42 @@ private void OnForegroundWindowChanged(nint hWinEventHook, uint eventType, nint // A different window is now in the foreground. Any invalid key presses that happened // before this switch (e.g. Alt+Tab keystrokes) belong to the previous context, // so they must not suppress valid keys in the new window. - _lastInvalidKeyPressTick = 0L; + Interlocked.Exchange(ref _lastInvalidKeyPressTick, 0L); + + KeyboardLog.Write($"Foreground: {DescribeProcess(hwnd)}"); #if false // The problem of requiring two spaces has been solved -- comment the test code first. Debug.WriteLine($"Foreground window changed to {hwnd:X}, invalid-key delay cleared."); #endif } + // Elevated windows and other apps grabbing the focus explain many "ignored: focus" decisions. + private static string DescribeProcess(nint hwnd) + { + try + { + User32.GetWindowThreadProcessId(hwnd, out var pid); + using var process = Process.GetProcessById((int)pid); + + string elevation; + try + { + _ = process.Handle; // needs PROCESS_ALL_ACCESS, denied for elevated processes + elevation = "normal"; + } + catch + { + elevation = "ELEVATED"; + } + + return $"{process.ProcessName} ({elevation})"; + } + catch (Exception e) + { + return $"unknown ({e.GetType().Name})"; + } + } + internal static KeystrokeDispatcher GetInstance() { return _instance ??= new KeystrokeDispatcher(); diff --git a/QuickLook/PipeServerManager.cs b/QuickLook/PipeServerManager.cs index 4654d5b93..e210a06ec 100644 --- a/QuickLook/PipeServerManager.cs +++ b/QuickLook/PipeServerManager.cs @@ -38,6 +38,7 @@ public static class PipeMessages public const string Quit = "QuickLook.App.PipeMessages.Quit"; public const string Fullscreen = "QuickLook.App.PipeMessages.Fullscreen"; public const string Reload = "QuickLook.App.PipeMessages.Reload"; + public const string Edit = "QuickLook.App.PipeMessages.Edit"; } public class PipeServerManager : IDisposable @@ -165,6 +166,12 @@ private bool MessageReceived(string pipeMessage, string path = null, string[] op DispatcherPriority.Normal); return false; + case PipeMessages.Edit: + _lastOperation = Application.Current.Dispatcher.BeginInvoke( + new Action(() => ViewWindowManager.GetInstance().EditAndClosePreview(path)), + DispatcherPriority.Normal); + return false; + case PipeMessages.Forget: Application.Current.Dispatcher.BeginInvoke( new Action(() => ViewWindowManager.GetInstance().ForgetCurrentWindow()), diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs index e18fc7004..f23e6edee 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -64,6 +64,70 @@ public void RunAndClosePreview() _viewerWindow.RunAndClose(); } + /// + /// True while the preview holds edits that are not saved yet. A plugin's view reports them through a + /// public bool HasUnsavedChanges property (plugins don't share an interface for it). + /// + internal bool HasUnsavedChanges() + { + if (!_viewerWindow.IsVisible) + return false; + + var content = _viewerWindow.ContextObject?.ViewerContent; + return content?.GetType().GetProperty("HasUnsavedChanges")?.GetValue(content) is true; + } + + /// + /// True when the last Escape only took the keyboard focus out of the preview's editor (the view + /// reports it through a public bool ConsumeEscape() method), so it must not also close the preview. + /// + internal bool ConsumeEscape() + { + if (!_viewerWindow.IsVisible) + return false; + + var content = _viewerWindow.ContextObject?.ViewerContent; + return content?.GetType().GetMethod("ConsumeEscape", Type.EmptyTypes)?.Invoke(content, null) is true; + } + + /// + /// Closes the preview and opens the selected file with the program associated with its "edit" verb, + /// or with its default program when it has none. + /// + public void EditAndClosePreview(string path = null) + { + if (string.IsNullOrEmpty(path)) + path = NativeMethods.QuickLook.GetCurrentSelection(); + if (string.IsNullOrEmpty(path)) + path = _invokedPath; + + ClosePreview(); + + if (string.IsNullOrEmpty(path) || Directory.Exists(path) || path.StartsWith("::")) + return; + + KeyboardLog.Write($"Edit: opening the selection ({Path.GetExtension(path)})"); + + foreach (var verb in new[] { "edit", null }) + { + try + { + using var _ = Process.Start(new ProcessStartInfo(path) + { + Verb = verb, + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(path), + }); + return; + } + catch (Exception e) + { + // no "edit" verb registered for this type: fall back to the default program + KeyboardLog.Write($"Edit: verb '{verb ?? "default"}' failed: {e.Message}"); + } + } + } + public void ClosePreview() { if (!_viewerWindow.IsVisible) @@ -78,6 +142,10 @@ public void TogglePreview(string path = null, string options = null) if (string.IsNullOrEmpty(path)) path = NativeMethods.QuickLook.GetCurrentSelection(); + KeyboardLog.Write(string.IsNullOrEmpty(path) + ? $"Toggle: no selection returned (visible={_viewerWindow.IsVisible})" + : $"Toggle: selection received (visible={_viewerWindow.IsVisible}, same={path == _invokedPath})"); + if (!string.IsNullOrEmpty(options)) InvokePreviewWithOption(path, options); else