Een taalwisselaar bouwen voor macOS

30 mei 2026

Een taalwisselaar bouwen voor macOS: wanneer het systeem je in de weg zit

Als je regelmatig in meerdere talen schrijft, ken je die stille frustratie. Je maakt een zin af in het Engels, begint in het Russisch te typen, en je vingers bewegen al voordat je hersenen eraan denken om de indeling te wisselen. Ik kijk niet naar het toetsenbord tijdens het typen, dus ik merk het meestal meteen op het scherm: de tekst staat ineens in het verkeerde schrift. Je drukt op de systeemsneltoets, wisselt, en typt opnieuw.

Vermenigvuldig dat nu met vijf talen.

Ik werk dagelijks in het Engels, Russisch, Oekraïens, Lets en Litouws. macOS geeft je sneltoetsen om naar de volgende of vorige invoerbron te schakelen, maar beide bewegen door dezelfde geordende lus. Met vijf indelingen houd je uiteindelijk een klein mentaal kaartje bij van waar de huidige taal in die lus staat en welke richting korter is. Als je nu op Lets staat en Engels wilt, moet je de volgorde onthouden voordat je zelfs maar weet hoe vaak je op de sneltoets moet drukken. Dit is geen workflow — het is een straf.

Dus bouwde ik een klein menubalk-appje genaamd LangboardDirect waarmee ik met één toetsaanslag direct naar elke taal kan springen. Dit is het verhaal van hoe het werkt en de interessante technische hoekjes die ik onderweg tegenkwam.

Wat macOS je biedt

Voordat ik iets bouwde, heb ik serieus gekeken naar wat er al is.

In Systeeminstellingen → Toetsenbord → Toetsenbordsneltoetsen → Invoerbronnen kun je één sneltoets toewijzen aan Selecteer de vorige invoerbron en een andere aan Selecteer de volgende invoerbron. Dat is alles. Er is geen manier om te zeggen ‘⌃⌥R betekent Russisch’. Het systeem kent alleen ‘vooruit’ en ‘achteruit’.

Er is ook de Globe/fn-toets in nieuwere macOS-versies, die je kunt instellen om de emoji-kiezer te tonen of de invoerbron te wijzigen. Maar opnieuw — één actie, geen doelgerichtheid.

De fn-toets kwam al vroeg naar voren als kandidaat voor aangepaste sneltoetsen. Hij staat fysiek los van andere modifiers en wordt zelden door apps onderschept. Het probleem: fn wordt in geen enkele standaard-macOS-API als modifier blootgesteld. Carbons RegisterEventHotKey, CGEventTap, NSEvent — geen van alle laten je fn combineren met lettertoetsen als globale sneltoets. Het systeem verwerkt het op een lager niveau en slokt het op voordat een app het kan zien. Doodlopend.

Het technische landschap

macOS biedt verschillende API's om toetsaanslagen te onderscheppen. Ze verschillen sterk in wat ze kunnen en wat ze kosten.

Carbons RegisterEventHotKey

De oudste en eenvoudigste aanpak. Je registreert een combinatie zoals ⌃⌥R bij het systeem, en je callback wordt geactiveerd zodra die wordt ingedrukt — globaal, in elke app, zonder extra rechten. De API is stokoud (Carbon-tijdperk) maar werkt nog prima in modern Swift via een bridging header.

De grote beperking: hij kan linker modifiers niet onderscheiden van rechter. Hij ziet .command maar niet welke Command-toets. Als je ⌘R als sneltoets registreert, activeren beide ⌘R-toetsen hem. Voor een taalwisselaar maakt dat veel uit — ⌘R is Vernieuwen in browsers, ⌘L opent de adresbalk. De standaard modifierset gebruiken betekent dat je met elke andere app in de clinch ligt.

NSEvent.addGlobalMonitorForEvents

Dit is de Swift-vriendelijke API voor het observeren van events. Je abonneert je app-breed op toetsevents en wordt op de hoogte gebracht. Het is netjes, werkt natuurlijk in Swift en vereist geen Toegankelijkheidsrechten voor alleen-luisteren.

Het probleem: het is alleen-luisteren. Je kunt events waarnemen maar niet consumeren. Als je ⌘R in je callback detecteert, krijgt de browser hem nog steeds ook. Voor een wisselaar wil je de toetsaanslag onderscheppen en opslokken — anders gaat de toets door naar de app die focus heeft en typt een teken of veroorzaakt een onbedoelde actie.

CGEventTap

De low-level Core Graphics-API om events te onderscheppen. Een event tap zit helemaal aan het begin van de event-pipeline — voordat events een applicatie, venster of responder bereiken. Met de modus .defaultTap kun je events inspecteren, wijzigen en nil teruggeven om ze volledig te consumeren.

Dit is degene. De prijs: hij vereist Toegankelijkheidsrechten van de gebruiker (Systeeminstellingen → Privacy en beveiliging → Toegankelijkheid), omdat een app die stil toetsaanslagen kan onderscheppen en onderdrukken een potentiële keylogger is. macOS doet daar terecht een slot op.

Het eerste ontwerp: ⌃⌥ + letter

Het oorspronkelijke plan was om Control + Option (⌃⌥) als modifierpaar te gebruiken. Het is vrijwel volledig onbezet — macOS zelf gebruikt het niet voor systeemsneltoetsen, en de meeste apps negeren het volledig. Gecombineerd met een letter per taal krijg je een schone, botsingsvrije set:

Dit zou met RegisterEventHotKey hebben gewerkt, zonder Toegankelijkheid. Maar er knaagde iets: ⌃⌥ is een combinatie van twee vingers waarbij je je linkerhand over zichzelf moet kruisen, of een ongemakkelijke vingerstand gebruikt. Het werkt, maar het is niet elegant.

De rechter ⌘-toets: een onderbenut goed

Hier is iets waar de meeste mensen niet bij stilstaan: Mac-toetsenborden hebben twee Command-toetsen, en ze zijn te onderscheiden.

Elke toets op een Mac-toetsenbord produceert een hardware-keycode — een getal dat de fysieke positie van de toets identificeert, onafhankelijk van indeling of modifierstatus. Linker Command is keycode 55. Rechter Command is keycode 54. De systeem-modifiervlaggen registreren .maskCommand voor beide, maar de keycode vertelt je precies welke is ingedrukt.

Standaard-API's zoals RegisterEventHotKey zien alleen de vlaggen, niet de keycode — dus ze kunnen links niet van rechts onderscheiden. Maar CGEventTap, dat op het ruwe event-niveau werkt, ziet beide: de vlaggen en de keycode.

Dit opent een volledig aparte modifier-naamruimte. Rechter ⌘ + letter-combinaties botsen met niets. Linker ⌘ blijft doen wat het altijd deed. Rechter ⌘ wordt de toegewijde laag voor taalwisselen.

De sneltoetsset wordt:

Toets Taal
⌘A (rechts) ABC
⌘R (rechts) Russisch
⌘U (rechts) Oekraïens
⌘L (rechts) Lets
⌘I (rechts) lItouws

Deze standaardletters zijn slechts een startpunt: de app laat je de sneltoetsletter voor elke taal ook opnieuw toewijzen.

De implementatie volgt de status van Rechter Command via flagsChanged-events gefilterd op keycode 54, en onderschept dan keyDown-events terwijl die status actief is:

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

    if type == .flagsChanged && keyCode == 54 { // Rechter 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 // consumeer het event
    }

    return Unmanaged.passRetained(event)
}

De aanroep TISSelectInputSource komt uit de Carbon Text Input Sources-API — hetzelfde framework dat macOS zelf gebruikt om toetsenbordindelingen te wisselen.

Dynamische toewijzing: niets hardcoderen

De eerste implementatie hardcodeerde de koppeling van taal naar toets. Dat is fragiel — wat als iemand andere talen heeft? Wat als er later een taal bij komt?

De betere aanpak: leid de sneltoets automatisch af uit de taalnaam.

Voor elke taal loop je de naam teken voor teken af en wijs je de eerste letter toe die nog niet bezet is. Engels → E (maar we gebruiken ‘ABC’ → A), Russisch → R, Oekraïens → U, Lets → L, Litouws → L is bezet → I (tweede teken).

De enige statische data die nodig is, is een tabel die QWERTY-letters koppelt aan fysieke keycodes:

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, ...
]

Dit zijn hardwareconstanten — fysieke toetsposities op een standaardtoetsenbord, onafhankelijk van elke indeling. Voeg een omgekeerde map toe en je hebt alles wat je nodig hebt voor zowel toewijzing als detectie.

De bug met niet-Engelse indelingen

Er school een subtiele bug in de UI voor het opnieuw toewijzen van sneltoetsen.

De app bevat een paneel om toetsen handmatig opnieuw toe te wijzen. Wanneer je naast een taal op ‘Wijzigen’ klikt, wacht hij op je volgende toetsaanslag en registreert die. De oorspronkelijke implementatie gebruikte event.charactersIgnoringModifiers om het ingedrukte teken te krijgen.

Dit werkt prima als je actieve indeling ABC (Engels) is. Maar als je momenteel in het Russisch of Oekraïens zit en je drukt op de fysieke R-toets, geeft charactersIgnoringModifiers "к" terug — het Cyrillische teken op die positie. De app kon dat teken vervolgens niet vinden in zijn letterKeyCodes-tabel en weigerde de invoer stilletjes.

De oplossing was eenvoudig zodra de oorzaak duidelijk was: gebruik het teken helemaal niet. Gebruik de keycode rechtstreeks:

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

// Nu — indelingsonafhankelijk:
guard let letter = keyCodeToLetter[keyCode] else { return event }

keyCodeToLetter is gewoon het omgekeerde van letterKeyCodes — een [Int64: String]-dictionary die bij het opstarten wordt opgebouwd. Keycodes zijn fysieke posities, in elke indeling hetzelfde, dus dit werkt ongeacht welke invoerbron momenteel actief is.

Uitbrengen

De app is een standaard SwiftUI-schil rond een NSApplicationDelegate, draaiend als .accessory (geen Dock-icoon), met een statusbalk-item dat een toetsenbordsymbool toont. Het menu toont alle geïnstalleerde invoerbronnen, markeert de actieve met een vinkje (bij elke opening vernieuwd via NSMenuDelegate.menuWillOpen), en bevat de items ‘Sneltoetsen toewijzen…’ en ‘Starten bij inloggen’.

Voor distributie wordt de app genotariseerd met Apples notariseringsservice — een proces dat de binary indient bij Apples geautomatiseerde malwarescanner en, bij goedkeuring, een cryptografisch ticket aan de app-bundel hecht. Daarna laat macOS Gatekeeper hem zonder gedoe draaien. De hele notariseringsronde duurt ongeveer 90 seconden:

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

De broncode staat op GitHub. De release is een zip van 92 KB.

Wat ik heb geleerd

Een paar dingen die je tijd kunnen besparen:

Keycodes zijn fysiek, tekens zijn logisch. Wanneer je werkt met sneltoetsen die indelingswijzigingen moeten overleven, werk dan altijd met keycodes. Tekens zijn een interpretatie die afhangt van de actieve invoerbron.

Linker en rechter modifiertoetsen zijn op CGEventTap-niveau te onderscheiden. Dit is een onderbenut feit. Het verdubbelt in feite het aantal botsingsvrije modifiercombinaties dat je tot je beschikking hebt.

Toegankelijkheidsrechten zijn gekoppeld aan de codehandtekening van de binary. Als je overschakelt van een ontwikkel-gesigneerde build naar een distributie-gesigneerde build, behandelt macOS het als een andere app. De oude Toegankelijkheidsvermelding in Systeeminstellingen wijst naar de oude binary en doet niets voor de nieuwe. Het is de moeite waard dit duidelijk in de UI van je app te tonen.

SMAppService maakt Login Items triviaal. De moderne API (macOS 13+) is drie regels: controleer SMAppService.mainApp.status, roep .register() of .unregister() aan, klaar. Geen helper-bundels, geen daemon-registratie.

De app is klein — iets meer dan 300 regels Swift — maar loste een echte dagelijkse ergernis op. Soms is het juiste gereedschap dat wat je zelf bouwt.

Andrew Shitov
30 mei 2026
Amsterdam

Op mijn verzoek met AI geschreven na ontwikkeling met Codex.

Terug naar de blog