Skip to content

AppKit F06 — keyboard & keymap

Scancode → keysym → text is three different questions, and AppKit answers them in three different places: the hardware keyCode, the keyboard-layout engine (UCKeyTranslate over the uchr data), and the text-input layer (interpretKeyEvents:insertText: / doCommandBySelector:). Per the F06 feature spec, the demo logs all three levels for every press, exercises the layout engine directly (including a dead-key compose and a US-vs-German layout split), and probes the two ways to inject a key — a synthetic NSEvent and a real-HID CGEventPost. The program is ./examples/f06-keyboard/app.d (with the shared instrument.d logger), built on the scaffold recipe plus -framework Carbon for the HIToolbox layout APIs.

Last reviewed: June 11, 2026

All run findings are A[ssh]: built and executed on mac-bsn (aarch64-darwin, macOS 26.3.1, LDC 1.41.0) over SSH with the console session locked. This distorts exactly one path and it is load-bearing here: input routing. A locked, non-frontmost app has no system key window, so events that travel through the WindowServer's session event tap (CGEventPost, route 2) are not routed to it — that is the finding below, but it is partly an artifact of the locked screen and is re-queued for an unlocked Tier-C run. The in-process path (-[NSWindow sendEvent:], route 1) and the synchronous layout-engine demonstration are unaffected by the lock — they never touch the WindowServer's routing.

MeasurementValue
Levels logged per eventscancode (keyCode), keysym (charactersIgnoringModifiers), text (characters)
Text routing without NSTextInputClientinterpretKeyEvents:legacy insertText: (text) + doCommandBySelector: (cmds)
Dead key via synthetic NSEventno composecharacters forwarded verbatim (´ then e), never é
Dead key via the layout engineUCKeyTranslate composes é (option-e dead acute, then e)
Layout dependence (same scancode)keyCode 6z (US) vs y (German QWERTZ)
Runtime layout switch (TISSelectInputSource)rejected, OSStatus −50 (paramErr) over SSH
Key-repeat contractkeyRepeatDelay 0.250 s, keyRepeatInterval 0.033 s (~30 Hz); isARepeat carried
CGEventPost under a locked screennot delivered (reached=0) — session tap not routed to a non-frontmost app
Exitclean 0 (loop_exit steps=11)

The three levels — who provides each A[ssh]

A custom KeyView (acceptsFirstResponder = YES, made first responder) logs every keyDown:/keyUp:/flagsChanged: as key code=<scancode> sym=<charsIgnoringMods> text=<characters> state=… repeat=… flags=…. The three columns come from three different owners:

LevelSource fieldWho computes it
scancode (code)-[NSEvent keyCode]Hardware/IOKit — a stable per-physical-key number (e.g. keyCode 6 is one key)
keysym (sym)charactersIgnoringModifiersThe active keyboard layout (uchr), applied by AppKit, ignoring shift/option
text (text)characters / insertText:Layout + modifier + dead-key/IME state, via the text-input system

The route-1 sequence makes the split concrete (synthetic events, [window sendEvent:]):

text
298714 APPKIT_F06 key code=0 sym=a text=a state=down repeat=0 flags=0x0
501675 APPKIT_F06 key code=18 sym=1 text=! state=down repeat=0 flags=0x20000
701252 APPKIT_F06 key code=123 sym= text= state=down repeat=0 flags=0xa00000

A plain a agrees on all three levels; shift-1 keeps sym=1 (the keysym is layout text ignoring modifiers) but produces text=! (modifier applied), the classic "vk and produced text differ" case; the left arrow (keyCode 123, flags=0xa00000 = Function | NumericPad) has an empty text level — it is a command key, not a text key, which is the hinge for the next section.


The interpretKeyEvents: / doCommandBySelector: boundary A[ssh]

Each keyDown: is routed through interpretKeyEvents:, and the view implements three sinks: the modern insertText:replacementRange: (the NSTextInputClient method), the legacy single-argument insertText:, and doCommandBySelector:. The finding is which one actually fires:

text
299440 APPKIT_F06 insert_text variant=legacy text=a
501786 APPKIT_F06 insert_text variant=legacy text=!
701366 APPKIT_F06 do_command selector=moveLeft:
  • Text keys land on the legacy insertText:, not insertText:replacementRange:. Because KeyView does not conform to NSTextInputClient (that is F07's job), the text system falls back to the pre-NSTextInputClientNSResponder path. So the modern marked-text method is never called here — the boundary is exactly the NSTextInputClient conformance line.
  • Command keys are dispatched by selector: the left arrow becomes doCommandBySelector: moveLeft:. AppKit's NSResponder key-binding machinery turns the function key into a standard editing command without any text, which is why its characters level was empty above. This insertText:-vs- doCommandBySelector: fork is how AppKit separates "insert this string" from "perform this action", and it works for synthetic events with no extra setup.

Dead keys: the layout engine composes; the synthetic event chain does not A[ssh]

This is the boundary that feeds F07. Two ways to ask "option-e then e":

Through the layout engine (UCKeyTranslate, synchronous, no injection). The demo pulls the current uchr and translates directly. Option-e sets a non-zero dead-key state and emits no text; the following e consumes that state and composes é:

text
97610 APPKIT_F06 compose layout=current state=dead_set code=14 mods=option dead_state=1 text_len=0 text=
97617 APPKIT_F06 compose layout=current state=composed code=14 mods=none dead_state=65536 text=é

Through the synthetic-event chain (interpretKeyEvents:). The same two keystrokes, built as NSEvents carrying the spacing acute ´ and routed through the responder:

text
903404 APPKIT_F06 key code=14 sym=e text=´ state=down repeat=0 flags=0x80000
903507 APPKIT_F06 insert_text variant=legacy text=´
1103459 APPKIT_F06 insert_text variant=legacy text=e

interpretKeyEvents: forwards the characters we supplied verbatim´ then e, never é. It does not re-run the layout's dead-key state machine off the keyCode, and with no NSTextInputClient there is no marked-text channel for a pending dead key to live in. So dead-key composition is owned by the layout (uchr) engine, reachable two ways: a real keystroke whose HID event the WindowServer translates before the app sees it, or an app that implements NSTextInputClient marked text and lets the input context drive UCKeyTranslate. A synthetic NSEvent fed to interpretKeyEvents: gets neither — the exact gap F07 closes.


Layout dependence and runtime switching A[ssh]

The same scancode means different text under a different layout. The demo enumerates installed input sources, finds com.apple.keylayout.German, and pulls its uchrwithout switching the system source, then translates keyCode 6 under both:

text
97603 APPKIT_F06 uckey layout=current code=6 mods=none text=z
98056 APPKIT_F06 uckey layout=german  code=6 mods=none text=y

keyCode 6 is z on the US layout and y on German QWERTZ — same physical key, layout decides the text. The dead-key mapping is layout-specific too: on German, option-e is not a dead acute but the euro sign, and emits text immediately (no dead state):

text
106931 APPKIT_F06 compose layout=german state=dead_set code=14 mods=option dead_state=0 text_len=1 text=€

Runtime switching, however, was refused over SSH:

text
98172 APPKIT_F06 layout_switch target=german status=-50 note=rejected

TISSelectInputSource returned −50 (paramErr) — the German source came from the all-installed list but is not an enabled/selectable source, and a locked SSH session cannot enable it. The robust technique is therefore the one the demo uses for the comparison: read a layout's uchr directly and translate against it, which needs no privilege and no system-state change. Demonstrating layout dependence does not require selecting the layout.


Key repeat and isARepeat A[ssh]

The system repeat contract comes from NSEvent class properties, logged at startup:

text
87325 APPKIT_F06 repeat_info delay_s=0.250 interval_s=0.033

keyRepeatDelay is 0.250 s before the first repeat; keyRepeatInterval is 0.033 s (~30 Hz) between repeats. macOS repeats server-side — the app does not run a repeat timer (the opposite of Wayland, where repeat is the client's job); it only reads these for display/UI purposes. A synthetic keyDown built with isARepeat:YES carries the flag through unchanged:

text
1303437 APPKIT_F06 key code=0 sym=a text=a state=down repeat=1 flags=0x0

so repeat=1 round-trips, and the demo confirms the property values a real key-hold would honor.


CGEventPost under a locked screen A[ssh]

Route 2 posts a real HID-style key with CGEventCreateKeyboardEvent + CGEventPost to kCGSessionEventTap, then waits to see whether it reaches KeyView.keyDown: (the view watches a distinct keyCode 2):

text
1503278 APPKIT_F06 step name=cgevent_post tap=session code=2
2103266 APPKIT_F06 cgevent_result tap=session code=2 reached=0 note=blocked_or_not_routed

It did not arrive. With the console locked and the app non-frontmost, the session event tap delivers to the active session's key window — which is the lock screen, not our process. (Under an unlocked session this can additionally require Accessibility/TCC permission for the posting process.) Either way, CGEventPost is not a reliable agent-over-SSH injection path; route 1 ([window sendEvent:]) is the workhorse because it bypasses WindowServer routing entirely and drives the app-side chain in-process. The unlocked-session CGEventPost behavior (and any TCC prompt) is queued for a Tier-C manual run.


Findings summary (for event-sequences.md)

  • Three-level ownership: scancode = keyCode (hardware), keysym = charactersIgnoringModifiers (layout, modifier-independent), text = characters / insertText: (layout + modifiers + dead-key/IME). Same scancode → different text per layout (keyCode 6: z US / y German).
  • Text routing: without NSTextInputClient, interpretKeyEvents: calls the legacy insertText: for text keys and doCommandBySelector: (e.g. moveLeft:) for command keys — the modern insertText:replacementRange: is never reached. That conformance line is the F07 boundary.
  • Dead-key/compose ownership: the uchr layout engine (UCKeyTranslate) — notinterpretKeyEvents:. A synthetic NSEvent forwards its characters verbatim and never composes; composition needs real HID translation or NSTextInputClient marked text.
  • Repeat contract: server-side; keyRepeatDelay 0.250 s, keyRepeatInterval 0.033 s; the app only reads them. isARepeat round-trips through synthetic events.
  • Injection: synthetic NSEvent + [window sendEvent:] is in-process and reliable over SSH; CGEventPost to the session tap is not delivered to a locked, non-frontmost app. Runtime TISSelectInputSource switching is refused (paramErr) over SSH; reading a layout's uchr directly sidesteps that.

Sources