Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

The Responder Chain and Keyboard Input

~16 min · appkit-swiftui, responder-chain, keyboard, nsevent, menus

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Arrow keys worked every time. The comma and period chords, bound only as menu shortcuts, read as missing."

How a Key Finds Its Handler

On the Mac, a key press travels a defined path. First, NSEvent local monitors installed by your app see it. Then the window gives views a chance at key equivalents through performKeyEquivalent(with:), walking down the view tree, before the menu bar tries to match a menu item's shortcut. Only after that does a plain key reach the first responder as keyDown(with:), and unhandled actions travel up the responder chain: the view, its superviews, the window, the window controller, the application, and the app delegate. A menu item with a nil target sends its action up that chain from the first responder.

Knowing that order is what fixes the family's keyboard bugs, because each one was a key arriving at a different step than expected.

Three Fixes, Three Steps of the Path

  • Menu shortcuts are not a reliable daily input path. A video player bound frame-step and loop chords (, . [ ] with no modifiers) only as SwiftUI menu key equivalents. They fired inconsistently and gave no feedback, while arrow keys handled by a local NSEvent monitor worked every time. The chords moved to the monitor, the menu items stayed as discoverable labels showing the same keys, and the monitor consumes the event so nothing double-fires.
  • Standard editing keys can be missing in a field. Text fields inside an NSAlert accessory in a file workbench got no ⌘V, ⌘C, ⌘X or ⌘A, because the app's Edit menu routed those to custom actions. Because the window offers key equivalents to views before the menu, an NSTextField subclass that handles them in performKeyEquivalent(with:) works regardless of menu state.
  • A library class you cannot override. A terminal view from a third-party library declared keyDown(with:) as public, not open, so subclassing to add a shortcut failed to compile. A local monitor that checks whether that view is the window's first responder handles the chord before the view ever sees it.

Two Quieter Traps

Selector names are a shared namespace. NSResponder already defines actions such as moveUp(_:) and moveDown(_:); an app action with the same name becomes an accidental override that the text system and arrow keys will call. Input methods change what "typing" means. With a Korean input source active, synthesized ASCII keystrokes arrive as Hangul, and composition happens as marked text before a character is committed. Keyboard behaviour in an editor or terminal is not verified until it has been exercised with an input method in the installed app.

Code

Handle editing keys in the view, and app-wide chords in a local monitor·swift
import AppKit

final class EditingKeyTextField: NSTextField {
    override func performKeyEquivalent(with event: NSEvent) -> Bool {
        guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
              let editor = currentEditor(),
              let key = event.charactersIgnoringModifiers?.lowercased() else {
            return super.performKeyEquivalent(with: event)
        }
        switch key {
        case "v": editor.paste(nil); return true
        case "c": editor.copy(nil); return true
        case "x": editor.cut(nil); return true
        case "a": editor.selectAll(nil); return true
        case "z":
            if event.modifierFlags.contains(.shift) { editor.undoManager?.redo() } else { editor.undoManager?.undo() }
            return true
        default: return super.performKeyEquivalent(with: event)
        }
    }
}

@MainActor
final class PlaybackKeys {
    private var monitor: Any?

    func install(onStep: @escaping @MainActor (Int) -> Void) {
        monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
            let flags = event.modifierFlags.intersection([.command, .option, .control, .shift])
            guard flags.isEmpty else { return event }
            switch event.charactersIgnoringModifiers {
            case ",": MainActor.assumeIsolated { onStep(-1) }; return nil   // consumed
            case ".": MainActor.assumeIsolated { onStep(+1) }; return nil
            default: return event                                        // let the chain have it
            }
        }
    }

    func uninstall() {
        if let monitor { NSEvent.removeMonitor(monitor) }
        monitor = nil
    }
}

External links

Exercise

In Spark's Mac app, add a menu item "Next Capture" with a bare . shortcut and a local monitor that also handles .. Log which one fires when a text field is focused and when it is not. Then show an NSAlert with an accessory NSTextField and check whether ⌘V works; replace the field with EditingKeyTextField and check again. Finally, switch your input source to a non-Latin keyboard and note what reaches your monitor.
Hint
Print event.charactersIgnoringModifiers and event.keyCode in the monitor. Key codes are layout-independent; characters are not — which is exactly why chord handling and text input need different code paths.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.