Why WKWebView swallows the Escape key in Double Commander — and the fix
The MarkdownView lister plugin renders Markdown inside Double Commander's F3 viewer using a WKWebView. It worked — except Escape wouldn't close the viewer. Curiously, if you first switched the viewer to plain-text mode (press 1) and then pressed Escape, it closed fine. That asymmetry was the whole clue.
The symptom
Double Commander's viewer is supposed to close on Escape. With the rendered (WebView) mode active, Escape did nothing; in text mode it worked. So the web view was consuming the key before Double Commander could act on it.
The wrong fix (that passed a test)
The obvious first attempt: subclass WKWebView and forward Escape up the responder chain in keyDown:.
// First attempt — WRONG for this host
- (void)keyDown:(NSEvent *)event {
if (event.keyCode == 53 /* Escape */) { [self.nextResponder keyDown:event]; return; }
[super keyDown:event];
}
A headless harness that synthesized a keyDown: and checked it reached the parent view passed. In the real application it still failed. That gap — green test, broken app — is the lesson of the whole exercise:
A passing test against a mock can hide a wrong mental model. If behavior depends on the host, you have to verify it in the host.
The root cause
Double Commander is a Lazarus / LCL application (Free Pascal's UI toolkit). On macOS, LCL does not rely on the normal Cocoa responder-chain keyDown: traversal for its keyboard shortcuts. Instead it intercepts events centrally in NSApplication's -sendEvent: and routes them to its own shortcut handling.
That explains everything:
- A
WKWebViewthat has first-responder focus consumes the Escape key as part of its own event handling, so it never bubbles to LCL's dispatcher. - Forwarding via
keyDown:up the Cocoa responder chain is the wrong channel — LCL isn't listening there; it's listening at-sendEvent:. - Switching to text mode removes the web view from focus, so the next Escape reaches LCL normally — exactly the manual workaround users found.
The fix
Mirror the workaround in code: when the web view sees Escape, move focus off the web view and re-post the Escape event into the application's event queue, so it goes through NSApplication -sendEvent: the way LCL expects.
@interface MDWebView : WKWebView @end
@implementation MDWebView
- (void)keyDown:(NSEvent *)event {
if (event.keyCode != 53 /* kVK_Escape */) { [super keyDown:event]; return; }
NSWindow *win = self.window;
if (!win) return;
// 1) Take focus away from the web view so it stops swallowing the key.
NSView *host = self.superview.superview; // Double Commander's viewer view
BOOL moved = [host isKindOfClass:[NSView class]] && [win makeFirstResponder:host];
if (!moved) moved = [win makeFirstResponder:win.contentView];
if (!moved) moved = [win makeFirstResponder:nil];
if (win.firstResponder == self) return; // refused to give up focus; bail
// 2) Re-inject Escape so it flows through NSApplication -sendEvent: (LCL's path).
NSEvent *esc = [NSEvent keyEventWithType:NSEventTypeKeyDown location:NSZeroPoint
modifierFlags:0 timestamp:event.timestamp windowNumber:win.windowNumber
context:nil characters:@"\x1b" charactersIgnoringModifiers:@"\x1b"
isARepeat:NO keyCode:53];
[NSApp postEvent:esc atStart:YES];
}
@end
Now the first Escape closes the viewer, just like the bundled behavior — verified in the real application, not a mock.
Takeaways
- Identify the host's event model before patching. A Lazarus/LCL host dispatches shortcuts at
NSApplication -sendEvent:, not via responder-chainkeyDown:. - Re-inject, don't forward.
[NSApp postEvent:atStart:]puts the key back on the path the host actually monitors. - Test the artifact in the host. The mock passed; the app didn't. Host-dependent behavior needs host-level verification.