Building a Language Switcher for macOS

30th May, 2026

Building a Language Switcher for macOS: When the System Gets in Your Way

If you regularly write in multiple languages, you know the quiet frustration. You finish a sentence in English, start typing in Russian, and your fingers are already moving before your brain remembers to switch the layout. I do not look at the keyboard while typing, so I usually notice it immediately on the screen: the text is suddenly in the wrong script. You hit the system shortcut, switch, and retype.

Now multiply that by five languages.

I work daily in English, Russian, Ukrainian, Latvian, and Lithuanian. macOS gives you shortcuts for switching to the next or previous input source, but both of them move through the same ordered loop. With five layouts, you end up keeping a little mental map of where the current language sits in that loop and which direction is shorter. If you're currently on Latvian and want English, you have to remember the order before you even know how many times to press the shortcut. This is not a workflow — it's a punishment.

So I built a small menu bar app called LangboardDirect that lets me jump to any language directly with a single keystroke. This is the story of how it works, and the interesting technical corners I ran into along the way.

What macOS Gives You

Before building anything, I took a serious look at what's already there.

In System Settings → Keyboard → Keyboard Shortcuts → Input Sources, you can assign one shortcut to Select the previous input source and another to Select next input source. That's it. There's no way to say "⌃⌥R means Russian." The system only knows "go forward" and "go back."

There's also the Globe/fn key option in newer macOS versions, which you can configure to show the emoji picker or change input source. But again — one action, no targeting.

The fn key actually came up early as a candidate for custom shortcuts. It's physically separated from other modifiers and rarely intercepted by apps. The catch: fn is not exposed as a modifier in any standard macOS API. Carbon's RegisterEventHotKey, CGEventTap, NSEvent — none of them let you combine fn with letter keys as a global hotkey. The system handles it at a lower level and swallows it before any app can see it. Dead end.

The Technical Landscape

macOS offers several APIs for intercepting keystrokes. They differ significantly in what they can do and what they cost.

Carbon's RegisterEventHotKey

The oldest and simplest approach. You register a combination like ⌃⌥R with the system, and your callback fires whenever it's pressed — globally, in any app, with no extra permissions required. The API is ancient (Carbon-era) but still works fine in modern Swift via a bridging header.

The big limitation: it cannot distinguish left modifiers from right ones. It sees .command but not which Command key. If you register ⌘R as a hotkey, both ⌘R keys trigger it. For a language switcher this matters a lot — ⌘R is Refresh in browsers, ⌘L opens the address bar. Using the standard modifier set means you're fighting with every other app.

NSEvent.addGlobalMonitorForEvents

This is the Swift-friendly event observation API. You subscribe to key events app-wide and get notified. It's clean, works in Swift naturally, and doesn't require Accessibility permission for listen-only use.

The problem: it's listen-only. You can observe events but you can't consume them. If you detect ⌘R in your callback, the browser still gets it too. For a switcher you want to intercept and swallow the keystroke — otherwise the key goes through to whatever app is focused and types a character or triggers an unintended action.

CGEventTap

The low-level Core Graphics event interception API. An event tap sits at the very beginning of the event pipeline — before events reach any application, any window, any responder. With .defaultTap mode, you can inspect events, modify them, and return nil to consume them entirely.

This is the one. The cost: it requires Accessibility permission from the user (System Settings → Privacy & Security → Accessibility), because an app that can silently intercept and suppress keystrokes is a potential keylogger. macOS is right to gate this.

The First Design: ⌃⌥ + Letter

The initial plan was to use Control + Option (⌃⌥) as the modifier pair. It's almost entirely unoccupied — macOS itself doesn't use it for system shortcuts, and most apps ignore it entirely. Combined with a letter for each language, you get a clean, collision-free set:

This would have worked with RegisterEventHotKey, no Accessibility needed. But something nagged at it: ⌃⌥ is a two-finger combination that involves crossing your left hand over itself, or using awkward finger positioning. It works, but it's not elegant.

The Right ⌘ Key: An Underused Asset

Here's something most people don't think about: macOS keyboards have two Command keys, and they're distinguishable.

Every key on a Mac keyboard produces a hardware keycode — a number that identifies the physical position of the key, independent of any layout or modifier state. Left Command is keycode 55. Right Command is keycode 54. The system modifier flags register .maskCommand for both, but the keycode tells you exactly which one was pressed.

Standard APIs like RegisterEventHotKey only see the flags, not the keycode — so they can't tell left from right. But CGEventTap, operating at the raw event level, sees both: the flags and the keycode.

This opens up a completely separate modifier namespace. Right ⌘ + letter combinations don't conflict with anything. Left ⌘ keeps doing what it always did. Right ⌘ becomes the dedicated language-switching layer.

The shortcut set becomes:

Key Language
⌘A (Right) ABC
⌘R (Right) Russian
⌘U (Right) Ukrainian
⌘L (Right) Latvian
⌘I (Right) lIthuanian

These default letters are only a starting point: the app also lets you reassign the shortcut letter for each language.

The implementation tracks Right Command state through flagsChanged events filtered by keycode 54, then intercepts keyDown events while that state is active:

private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
    let keyCode = event.getIntegerValueField(.keyboardEventKeycode)

    if type == .flagsChanged && keyCode == 54 { // Right Command
        isRightCommandDown = event.flags.contains(.maskCommand)
        return Unmanaged.passRetained(event)
    }

    if type == .keyDown && isRightCommandDown,
       let name = keyCodeToName[keyCode],
       let source = sourcesByName[name] {
        TISSelectInputSource(source)
        return nil // consume the event
    }

    return Unmanaged.passRetained(event)
}

The TISSelectInputSource call is from the Carbon Text Input Sources API — the same framework macOS itself uses to switch keyboard layouts.

Dynamic Assignment: No Hardcoding

The initial implementation hardcoded the language-to-key mapping. That's fragile — what if someone has different languages? What if a language is added later?

The better approach: derive the shortcut from the language name automatically.

For each language, walk its name character by character and assign the first letter that hasn't been taken yet. English → E (but we use "ABC" → A), Russian → R, Ukrainian → U, Latvian → L, Lithuanian → L is taken → I (second character).

The only static data needed is a table mapping QWERTY letters to physical key codes:

private let letterKeyCodes: [Character: Int64] = [
    "q": 12, "w": 13, "e": 14, "r": 15, "t": 17, ...
    "a":  0, "s":  1, "d":  2, "f":  3, "g":  5, ...
]

These are hardware constants — physical key positions on a standard keyboard, independent of any layout. Add a reverse map and you have everything you need for both assignment and detection.

The Non-English Layout Bug

There was a subtle bug hiding in the shortcut reassignment UI.

The app includes a panel for manually reassigning keys. When you click "Change" next to a language, it waits for your next keypress and records it. The original implementation used event.charactersIgnoringModifiers to get the pressed character.

This works fine if your active layout is ABC (English). But if you're currently in Russian or Ukrainian and you press the physical R key, charactersIgnoringModifiers returns "к" — the Cyrillic character at that position. The app would then fail to find that character in its letterKeyCodes table and silently reject the input.

The fix was straightforward once the cause was clear: don't use the character at all. Use the keycode directly:

// Before — layout-dependent:
let letter = event.charactersIgnoringModifiers?.lowercased() ?? ""
guard let char = letter.first, letterKeyCodes[char] != nil else { return event }

// After — layout-independent:
guard let letter = keyCodeToLetter[keyCode] else { return event }

keyCodeToLetter is just the reverse of letterKeyCodes — a [Int64: String] dictionary built at startup. Key codes are physical positions, the same in every layout, so this works regardless of what input source is currently active.

Shipping It

The app is a standard SwiftUI shell around an NSApplicationDelegate, running as a .accessory (no Dock icon), with a status bar item showing a keyboard symbol. The menu lists all installed input sources, marks the active one with a checkmark (refreshed on every open via NSMenuDelegate.menuWillOpen), and includes the "Assign Shortcuts…" and "Launch at Login" items.

For distribution, the app is notarized with Apple's notary service — a process that submits the binary to Apple's automated malware scanner and, on passing, attaches a cryptographic ticket to the app bundle. After that, macOS Gatekeeper lets it run without any friction. The whole notarization round-trip takes about 90 seconds:

xcrun notarytool submit LangboardDirect.zip --keychain-profile "MyProfile" --wait
xcrun stapler staple LangboardDirect.app

The source is on GitHub. The release is a 92KB zip.

What I Learned

A few things that might save you time:

Key codes are physical, characters are logical. Whenever you're working with keyboard shortcuts that need to survive layout changes, always work with key codes. Characters are an interpretation that depends on the active input source.

Left and right modifier keys are distinguishable at the CGEventTap level. This is an underused fact. It effectively doubles the number of non-conflicting modifier combinations available to you.

Accessibility permission is tied to the binary's code signature. If you switch from a development-signed build to a distribution-signed build, macOS treats it as a different app. The old Accessibility entry in System Settings points to the old binary and does nothing for the new one. Worth surfacing clearly in your app's UI.

SMAppService makes Login Items trivial. The modern API (macOS 13+) is three lines: check SMAppService.mainApp.status, call .register() or .unregister(), done. No helper bundles, no daemon registration.

The app is small — just over 300 lines of Swift — but it solved a real daily annoyance. Sometimes the right tool is the one you build yourself.

Andrew Shitov
30th May, 2026
Amsterdam

Written at my request with AI after development with Codex.

Back to Blog