Skip to content

Commit 18edd52

Browse files
MuyuanMSCopilotCopilot
authored
Fix Peek Ctrl+W shortcut not working after clicking preview (#48293)
## Summary Fixes #48274 When previewing a file with Peek, clicking inside the preview content (PDF, text/code, markdown, HTML) makes **Ctrl+W unable to close the window**. This happens because the preview controls (WebView2 for most file types, native shell handlers for others) capture keyboard focus in their own message loop, bypassing the XAML keyboard accelerator system entirely. ## Problem Peek defines keyboard shortcuts (Ctrl+W to close, Escape to close, arrow keys to navigate) as `KeyboardAccelerator` elements on the main XAML Grid. These only fire when keyboard input flows through the XAML input system. However: - **WebView2** (used for PDF, text/code via Monaco, markdown, HTML): Runs Chromium in a separate process that consumes all keyboard input when focused. Ctrl+W is particularly problematic because Chromium treats it as "close tab." - **Shell Preview Handlers** (native HWND): Run in a child Win32 window that handles keyboard messages independently. Once either of these controls gets focus via a mouse click, keyboard shortcuts stop working. ## Solution Added a **low-level keyboard hook** (`WH_KEYBOARD_LL`) that intercepts key events at the OS level, regardless of which control has focus: - **Ctrl+W** and **Escape** → close the Peek window - **Arrow keys** (Left/Right/Up/Down without Ctrl) → navigate between files The hook is installed only while the Peek window is visible and only acts when Peek is the foreground window, so it has no impact on other applications. As additional defense-in-depth, `AreBrowserAcceleratorKeysEnabled` is set to `false` on the WebView2 control, preventing Chromium from consuming browser-specific shortcuts like Ctrl+W. ## Changes | File | Change | |------|--------| | `Peek.UI/PeekXAML/MainWindow.xaml.cs` | Install/uninstall keyboard hook in Initialize/Uninitialize; hook callback handles Ctrl+W, Escape, and arrow keys | | `Peek.UI/Native/NativeMethods.cs` | P/Invoke declarations for `SetWindowsHookEx`, `UnhookWindowsHookEx`, `CallNextHookEx`, `GetAsyncKeyState` | | `Peek.FilePreviewer/Controls/BrowserControl.xaml.cs` | Disable browser accelerator keys on WebView2 | ## Validation - [x] Ctrl+W closes Peek after clicking inside PDF preview - [x] Ctrl+W closes Peek after clicking inside text/code preview (Monaco) - [x] Escape closes Peek after clicking inside preview - [x] Arrow keys navigate between files after clicking inside preview - [x] Normal typing/interaction inside previews still works (Ctrl+C, scrolling, etc.) - [x] Shortcuts still work without clicking (existing behavior preserved) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 3fd6a03 commit 18edd52

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ private async void PreviewWV2_Loaded(object sender, RoutedEventArgs e)
199199
PreviewBrowser.CoreWebView2.Settings.IsScriptEnabled = IsDevFilePreview;
200200
PreviewBrowser.CoreWebView2.Settings.IsWebMessageEnabled = false;
201201

202+
// Disable browser-level accelerator keys (Ctrl+W, Ctrl+T, F5, etc.) as a
203+
// defense-in-depth measure. This alone does not guarantee XAML KeyboardAccelerators
204+
// work when WebView2 has focus (a low-level keyboard hook in MainWindow handles
205+
// that), but it prevents WebView2 from consuming shortcuts like Ctrl+W internally.
206+
PreviewBrowser.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false;
207+
202208
if (IsDevFilePreview)
203209
{
204210
PreviewBrowser.CoreWebView2.SetVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName, Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.MonacoDirectory, CoreWebView2HostResourceAccessKind.Allow);

src/modules/peek/Peek.UI/Native/NativeMethods.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,36 @@ internal struct SHFILEOPSTRUCT
131131
/// contain string paths.
132132
/// </summary>
133133
internal const uint SHCNF_PATH = 0x0001;
134+
135+
// Low-level keyboard hook support
136+
internal const int WH_KEYBOARD_LL = 13;
137+
138+
internal delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
139+
140+
[StructLayout(LayoutKind.Sequential)]
141+
internal struct KBDLLHOOKSTRUCT
142+
{
143+
public uint vkCode;
144+
public uint scanCode;
145+
public uint flags;
146+
public uint time;
147+
public IntPtr dwExtraInfo;
148+
}
149+
150+
[DllImport("user32.dll", SetLastError = true)]
151+
internal static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
152+
153+
[DllImport("user32.dll", SetLastError = true)]
154+
[return: MarshalAs(UnmanagedType.Bool)]
155+
internal static extern bool UnhookWindowsHookEx(IntPtr hhk);
156+
157+
[DllImport("user32.dll")]
158+
internal static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
159+
160+
[DllImport("user32.dll")]
161+
internal static extern short GetAsyncKeyState(int vKey);
162+
163+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
164+
internal static extern IntPtr GetModuleHandle(string? lpModuleName);
134165
}
135166
}

src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6+
using System.Runtime.InteropServices;
67
using System.Threading.Tasks;
78

89
using ManagedCommon;
@@ -20,6 +21,7 @@
2021
using Peek.UI.Extensions;
2122
using Peek.UI.Helpers;
2223
using Peek.UI.Models;
24+
using Peek.UI.Native;
2325
using Peek.UI.Telemetry.Events;
2426
using Windows.Foundation;
2527
using WinUIEx;
@@ -43,6 +45,10 @@ public sealed partial class MainWindow : WindowEx, IDisposable
4345
private bool _isDeleteInProgress;
4446
private bool _exitAfterClose;
4547

48+
private IntPtr _keyboardHookHandle;
49+
private NativeMethods.LowLevelKeyboardProc? _keyboardHookProc;
50+
private Windows.Win32.Foundation.HWND _cachedWindowHandle;
51+
4652
public MainWindow()
4753
{
4854
InitializeComponent();
@@ -221,8 +227,12 @@ private void Initialize(SelectedItem selectedItem)
221227
}
222228

223229
ViewModel.ScalingFactor = this.GetMonitorScale();
230+
this.Content.KeyUp -= Content_KeyUp;
224231
this.Content.KeyUp += Content_KeyUp;
225232

233+
_cachedWindowHandle = new Windows.Win32.Foundation.HWND(this.GetWindowHandle());
234+
InstallKeyboardHook();
235+
226236
bootTime.Stop();
227237

228238
PowerToysTelemetry.Log.WriteEvent(new OpenedEvent() { FileExtension = ViewModel.CurrentItem?.Extension ?? string.Empty, HotKeyToVisibleTimeMs = bootTime.ElapsedMilliseconds });
@@ -234,6 +244,7 @@ private void Uninitialize()
234244
{
235245
// Keep teardown best-effort: one failure must not skip later cleanup
236246
// or prevent the CLI/-FilePath exit-after-close contract.
247+
TryRunUninitializeStep(UninstallKeyboardHook, nameof(UninstallKeyboardHook));
237248
TryRunUninitializeStep(this.Restore, "Restore");
238249
TryRunUninitializeStep(() => this.Hide(), "Hide");
239250
TryRunUninitializeStep(ViewModel.Uninitialize, nameof(ViewModel.Uninitialize));
@@ -352,8 +363,164 @@ private bool IsNewSingleSelectedItem(SelectedItem selectedItem)
352363
return false;
353364
}
354365

366+
private void InstallKeyboardHook()
367+
{
368+
if (_keyboardHookHandle != IntPtr.Zero)
369+
{
370+
return;
371+
}
372+
373+
_keyboardHookProc = LowLevelKeyboardHookCallback;
374+
using var process = System.Diagnostics.Process.GetCurrentProcess();
375+
var moduleHandle = NativeMethods.GetModuleHandle(process.MainModule?.ModuleName);
376+
377+
_keyboardHookHandle = NativeMethods.SetWindowsHookEx(
378+
NativeMethods.WH_KEYBOARD_LL,
379+
_keyboardHookProc!,
380+
moduleHandle,
381+
0);
382+
383+
if (_keyboardHookHandle == IntPtr.Zero)
384+
{
385+
var error = Marshal.GetLastWin32Error();
386+
_keyboardHookProc = null;
387+
Logger.LogError($"Failed to install keyboard hook for Peek window. Win32 error: {error}");
388+
}
389+
}
390+
391+
private void UninstallKeyboardHook()
392+
{
393+
if (_keyboardHookHandle != IntPtr.Zero)
394+
{
395+
if (NativeMethods.UnhookWindowsHookEx(_keyboardHookHandle))
396+
{
397+
_keyboardHookHandle = IntPtr.Zero;
398+
_keyboardHookProc = null;
399+
}
400+
else
401+
{
402+
var error = Marshal.GetLastWin32Error();
403+
Logger.LogError($"Failed to uninstall keyboard hook for Peek window. Win32 error: {error}");
404+
}
405+
}
406+
}
407+
408+
private IntPtr LowLevelKeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
409+
{
410+
const int WM_KEYDOWN = 0x0100;
411+
const uint VK_W = 0x57;
412+
const uint VK_ESCAPE = 0x1B;
413+
const uint VK_LEFT = 0x25;
414+
const uint VK_UP = 0x26;
415+
const uint VK_RIGHT = 0x27;
416+
const uint VK_DOWN = 0x28;
417+
const int VK_CONTROL = 0x11;
418+
const int VK_ALT = 0x12;
419+
const int VK_SHIFT = 0x10;
420+
const int VK_LWIN = 0x5B;
421+
const int VK_RWIN = 0x5C;
422+
const int KEY_PRESSED_MASK = 0x8000;
423+
424+
try
425+
{
426+
if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN && lParam != IntPtr.Zero)
427+
{
428+
// Only handle when our window is in the foreground (cheap check before marshaling)
429+
var foreground = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow();
430+
if (foreground != _cachedWindowHandle)
431+
{
432+
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
433+
}
434+
435+
var hookStruct = Marshal.PtrToStructure<NativeMethods.KBDLLHOOKSTRUCT>(lParam);
436+
437+
// Fast-path: skip keys we never handle
438+
if (hookStruct.vkCode != VK_W && hookStruct.vkCode != VK_ESCAPE &&
439+
hookStruct.vkCode != VK_LEFT && hookStruct.vkCode != VK_RIGHT &&
440+
hookStruct.vkCode != VK_UP && hookStruct.vkCode != VK_DOWN)
441+
{
442+
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
443+
}
444+
445+
bool ctrlPressed = (NativeMethods.GetAsyncKeyState(VK_CONTROL) & KEY_PRESSED_MASK) != 0;
446+
bool altPressed = (NativeMethods.GetAsyncKeyState(VK_ALT) & KEY_PRESSED_MASK) != 0;
447+
bool shiftPressed = (NativeMethods.GetAsyncKeyState(VK_SHIFT) & KEY_PRESSED_MASK) != 0;
448+
bool winPressed = (NativeMethods.GetAsyncKeyState(VK_LWIN) & KEY_PRESSED_MASK) != 0 ||
449+
(NativeMethods.GetAsyncKeyState(VK_RWIN) & KEY_PRESSED_MASK) != 0;
450+
bool handled = false;
451+
452+
// Pass keys through while delete confirmation dialog is showing
453+
if (_isDeleteInProgress)
454+
{
455+
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
456+
}
457+
458+
if (ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_W)
459+
{
460+
handled = DispatcherQueue.TryEnqueue(() =>
461+
{
462+
if (!_isDeleteInProgress)
463+
{
464+
Uninitialize();
465+
}
466+
});
467+
}
468+
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_ESCAPE)
469+
{
470+
handled = DispatcherQueue.TryEnqueue(() =>
471+
{
472+
if (!_isDeleteInProgress)
473+
{
474+
Uninitialize();
475+
}
476+
});
477+
}
478+
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_LEFT)
479+
{
480+
if (ViewModel.DisplayItemCount > 1)
481+
{
482+
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
483+
}
484+
}
485+
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_RIGHT)
486+
{
487+
if (ViewModel.DisplayItemCount > 1)
488+
{
489+
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
490+
}
491+
}
492+
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_UP)
493+
{
494+
if (ViewModel.DisplayItemCount > 1)
495+
{
496+
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
497+
}
498+
}
499+
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_DOWN)
500+
{
501+
if (ViewModel.DisplayItemCount > 1)
502+
{
503+
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
504+
}
505+
}
506+
507+
if (handled)
508+
{
509+
return (IntPtr)1;
510+
}
511+
}
512+
}
513+
catch (Exception ex)
514+
{
515+
Logger.LogError("Unhandled exception in Peek keyboard hook.", ex);
516+
}
517+
518+
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
519+
}
520+
355521
public void Dispose()
356522
{
523+
UninstallKeyboardHook();
357524
themeListener?.Dispose();
358525
userSettings.Changed -= UpdateWindowBySettings;
359526
}

0 commit comments

Comments
 (0)