Why WKWebView swallows the Escape key in Double Commander — and the fix

A debugging case study from the DC-plugins project · macOS · WLX lister plugin

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:

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

Source on GitHub Project wiki The plugins