본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

리스폰더 체인과 키보드 입력

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

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"화살표 키는 매번 먹혔어. 메뉴 단축키로만 걸어둔 쉼표랑 마침표 조합은 없는 기능처럼 느껴졌고."

키가 자기 처리자를 찾아가는 길

Mac에서 키 입력은 정해진 길을 따라가. 먼저 앱이 설치한 NSEvent 로컬 모니터가 봐. 이어서 메뉴 막대가 메뉴 항목 단축키를 맞춰보기 전에, 창이 performKeyEquivalent(with:)로 뷰 트리를 내려가면서 뷰들에게 키 조합을 처리할 기회를 줘. 그 뒤에야 평범한 키가 keyDown(with:)으로 첫 번째 응답자에게 가. 처리되지 않은 액션은 뷰, 상위 뷰, 창, 창 컨트롤러, 앱, 앱 델리게이트 순으로 리스폰더 체인을 따라 올라가. 대상이 nil인 메뉴 항목은 첫 번째 응답자부터 그 체인을 따라 액션을 올려보내.

이 순서를 알면 가족의 키보드 버그가 풀려. 버그마다 키가 기대와 다른 단계에 도착했던 거거든.

고친 버그 셋, 걸린 단계도 셋

  • 메뉴 단축키는 매일 쓰는 입력으로 믿을 게 못 돼. 비디오 플레이어가 프레임 넘기기랑 구간 반복 조합(수정 키 없는 , . [ ])을 SwiftUI 메뉴 키 조합으로만 걸어뒀어. 이 조합은 먹혔다 안 먹혔다 하고 눈에 보이는 반응도 없었어. 반면 로컬 NSEvent 모니터가 처리하는 화살표 키는 매번 먹혔지. 그래서 조합 처리를 모니터로 옮겼어. 메뉴 항목은 같은 키를 알려주는 안내용 라벨로 남기고, 모니터가 이벤트를 소비하게 해서 두 번 실행되는 일도 막았어.
  • 텍스트 필드에서 기본 편집 키가 안 먹힐 수 있어. 파일 작업대의 NSAlert 보조 영역에 넣은 텍스트 필드에서 ⌘V, ⌘C, ⌘X, ⌘A가 안 먹혔어. 앱의 편집 메뉴가 그 키를 직접 만든 액션으로 보내고 있었거든. 창은 메뉴보다 먼저 뷰에게 키 조합을 주니까, performKeyEquivalent(with:)에서 그 키를 처리하는 NSTextField 서브클래스는 메뉴 상태와 상관없이 동작해.
  • 오버라이드 못 하는 라이브러리 클래스. 서드파티 라이브러리의 터미널 뷰가 keyDown(with:)open이 아니라 public으로 선언해서, 단축키를 추가하려고 서브클래싱하면 컴파일이 안 됐어. 대신 로컬 모니터가 그 뷰가 창의 첫 번째 응답자인지 확인하고, 뷰보다 먼저 조합을 가로채서 처리해.

조용한 함정 둘

셀렉터 이름은 모두가 같이 쓰는 이름 공간이야. NSResponder엔 이미 moveUp(_:), moveDown(_:) 같은 액션이 있어. 그래서 앱 액션에 같은 이름을 붙이면 의도치 않은 오버라이드가 되고, 텍스트 시스템과 화살표 키가 그 액션을 부르게 돼. 입력기는 "타이핑"의 뜻을 바꿔. 한국어 입력 소스가 켜져 있으면 합성한 ASCII 키 입력이 한글로 도착하고, 글자가 확정되기 전엔 조합 중인 글자로 먼저 들어와. 편집기나 터미널의 키보드 동작은 설치된 앱에서 입력기를 켜고 직접 눌러보기 전까진 확인된 게 아니야.

Code

편집 키는 뷰에서, 앱 전체 조합은 로컬 모니터에서·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

Spark Mac 앱에 수정 키 없는 . 단축키를 단 "다음 캡처" 메뉴 항목을 넣고, .을 처리하는 로컬 모니터도 넣어. 텍스트 필드에 포커스가 있을 때랑 없을 때 각각 뭐가 실행되는지 로그로 찍어. 이어서 보조 영역에 NSTextField를 넣은 NSAlert를 띄워서 ⌘V가 먹히는지 보고, 필드를 EditingKeyTextField로 바꿔서 다시 봐. 마지막으로 입력 소스를 라틴 문자가 아닌 키보드로 바꾸고 모니터에 뭐가 도착하는지 적어.
Hint
모니터에서 event.charactersIgnoringModifiersevent.keyCode를 찍어봐. 키 코드는 자판 배열과 무관하고, 문자는 자판 배열에 따라 달라져. 조합 처리랑 글자 입력에 코드 경로가 따로 필요한 이유가 정확히 그거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.