Skip to content

Commit 22ae8b5

Browse files
MuyuanMSCopilot
andcommitted
Fix Peek Ctrl+W shortcut not working after clicking preview
When the user clicks inside a file preview in Peek (PDF, text/code files, markdown, HTML), the WebView2 control or shell preview handler captures keyboard focus. Since these controls handle input in their own process/ message loop, the Ctrl+W KeyboardAccelerator defined on the parent XAML Grid never fires, making it impossible to close the Peek window with the keyboard shortcut. This fix adds a low-level keyboard hook (WH_KEYBOARD_LL) that intercepts Ctrl+W, Escape, and arrow navigation keys at the OS level when the Peek window is in the foreground. This works regardless of which child control has focus (WebView2, native shell preview handlers, etc.). Additionally, browser-level accelerator keys are disabled on the WebView2 control (AreBrowserAcceleratorKeysEnabled = false) as defense-in-depth, preventing Chromium from consuming shortcuts like Ctrl+W internally. Fixes #48274 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97f2868 commit 22ae8b5

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ 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.) so that
203+
// the WinUI WebView2 control forwards them to the XAML input system, allowing
204+
// KeyboardAccelerators on the parent window (e.g., Ctrl+W to close) to work
205+
// even when WebView2 content has focus. Monaco editor shortcuts (Ctrl+F, etc.)
206+
// still function because they are handled by JavaScript, not the browser.
207+
PreviewBrowser.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false;
208+
202209
if (IsDevFilePreview)
203210
{
204211
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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,33 @@ 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);
134162
}
135163
}

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

Lines changed: 99 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;
@@ -42,6 +44,9 @@ public sealed partial class MainWindow : WindowEx, IDisposable
4244
private bool _isDeleteInProgress;
4345
private bool _exitAfterClose;
4446

47+
private IntPtr _keyboardHookHandle;
48+
private NativeMethods.LowLevelKeyboardProc? _keyboardHookProc;
49+
4550
public MainWindow()
4651
{
4752
InitializeComponent();
@@ -209,13 +214,17 @@ private void Initialize(SelectedItem selectedItem)
209214
ViewModel.ScalingFactor = this.GetMonitorScale();
210215
this.Content.KeyUp += Content_KeyUp;
211216

217+
InstallKeyboardHook();
218+
212219
bootTime.Stop();
213220

214221
PowerToysTelemetry.Log.WriteEvent(new OpenedEvent() { FileExtension = ViewModel.CurrentItem?.Extension ?? string.Empty, HotKeyToVisibleTimeMs = bootTime.ElapsedMilliseconds });
215222
}
216223

217224
private void Uninitialize()
218225
{
226+
UninstallKeyboardHook();
227+
219228
this.Restore();
220229
this.Hide();
221230

@@ -309,6 +318,96 @@ private bool IsNewSingleSelectedItem(SelectedItem selectedItem)
309318
return false;
310319
}
311320

321+
private void InstallKeyboardHook()
322+
{
323+
if (_keyboardHookHandle != IntPtr.Zero)
324+
{
325+
return;
326+
}
327+
328+
_keyboardHookProc = LowLevelKeyboardHookCallback;
329+
_keyboardHookHandle = NativeMethods.SetWindowsHookEx(
330+
NativeMethods.WH_KEYBOARD_LL,
331+
_keyboardHookProc,
332+
IntPtr.Zero,
333+
0);
334+
}
335+
336+
private void UninstallKeyboardHook()
337+
{
338+
if (_keyboardHookHandle != IntPtr.Zero)
339+
{
340+
NativeMethods.UnhookWindowsHookEx(_keyboardHookHandle);
341+
_keyboardHookHandle = IntPtr.Zero;
342+
_keyboardHookProc = null;
343+
}
344+
}
345+
346+
private IntPtr LowLevelKeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
347+
{
348+
const int WM_KEYDOWN = 0x0100;
349+
const int WM_SYSKEYDOWN = 0x0104;
350+
const int VK_W = 0x57;
351+
const int VK_ESCAPE = 0x1B;
352+
const int VK_LEFT = 0x25;
353+
const int VK_UP = 0x26;
354+
const int VK_RIGHT = 0x27;
355+
const int VK_DOWN = 0x28;
356+
357+
if (nCode >= 0 && (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN))
358+
{
359+
var hookStruct = Marshal.PtrToStructure<NativeMethods.KBDLLHOOKSTRUCT>(lParam);
360+
bool ctrlPressed = (NativeMethods.GetAsyncKeyState(0x11) & 0x8000) != 0;
361+
362+
// Only handle when our window is in the foreground
363+
var foreground = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow();
364+
var ourWindow = new Windows.Win32.Foundation.HWND(this.GetWindowHandle());
365+
366+
if (foreground == ourWindow)
367+
{
368+
bool handled = false;
369+
370+
if (ctrlPressed && hookStruct.vkCode == VK_W)
371+
{
372+
DispatcherQueue.TryEnqueue(Uninitialize);
373+
handled = true;
374+
}
375+
else if (hookStruct.vkCode == VK_ESCAPE)
376+
{
377+
DispatcherQueue.TryEnqueue(Uninitialize);
378+
handled = true;
379+
}
380+
else if (!ctrlPressed && hookStruct.vkCode == VK_LEFT)
381+
{
382+
DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
383+
handled = true;
384+
}
385+
else if (!ctrlPressed && hookStruct.vkCode == VK_RIGHT)
386+
{
387+
DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
388+
handled = true;
389+
}
390+
else if (!ctrlPressed && hookStruct.vkCode == VK_UP)
391+
{
392+
DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
393+
handled = true;
394+
}
395+
else if (!ctrlPressed && hookStruct.vkCode == VK_DOWN)
396+
{
397+
DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
398+
handled = true;
399+
}
400+
401+
if (handled)
402+
{
403+
return (IntPtr)1;
404+
}
405+
}
406+
}
407+
408+
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
409+
}
410+
312411
public void Dispose()
313412
{
314413
themeListener?.Dispose();

0 commit comments

Comments
 (0)