A macOS menu-bar app that watches the active keyboard layout and drives a WLED LED matrix in real time. Tested with M5Stack Atom Matrix (5×5 RGB LED matrix).
Switch between English and Russian — the indicator instantly changes from blue to red (or any shape and colour you configure).
- Real-time layout tracking via Carbon Text Input Sources API — event-driven, zero polling, zero CPU at idle
- Per-layout colour + 5×5 pattern — each layout has its own colour and pixel bitmap; configure both in Settings
- Matrix rotation — rotate the output 0°/90°/180°/270° to match how the device is mounted, without touching stored patterns
- Automatic setup on first launch:
- Detects all installed keyboard layouts and assigns preset colours
- Discovers WLED devices via mDNS/Bonjour (filters by hostname containing "wled" + "key")
- Preset colours for common layouts (all configurable):
- English (ABC / US / British / Dvorak / Colemak) → blue
- Russian / Ukrainian / Belarusian → red
- German → yellow · French → cyan · Spanish → orange
- Unknown layouts → grey (fallback)
- Real-time brightness slider — adjusts WLED brightness as you drag
- Fast transitions — 100 ms colour fade (smooth but snappy)
- Menu-bar only — no Dock icon, just a tinted grid icon showing current colour
- "Re-detect layouts & WLED device" — one-click reset to re-scan everything
- Launch at login via
SMAppService - Resilient networking — retry with exponential backoff (100 ms → 300 ms → 1 s), request coalescing, 2 s timeout
- Sleep / screensaver dimming — dims WLED to brightness 2 on sleep/screensaver, restores on wake
- Floating Settings window — always appears on top (required for agent apps without Dock presence)
- macOS 13 Ventura or newer
- Xcode 15+ (developed with Xcode 26.4 / Swift 6.3)
- Any ESP32 device running WLED firmware, reachable on your LAN by IP or
.localmDNS name - Tested with M5Stack Atom Matrix (5×5 = 25 SK6812 LEDs)
git clone https://github.com/serhuey/WLEDLayoutIndicator.git
cd WLEDLayoutIndicator
open WLEDLayoutIndicator.xcodeproj| Setting | Where | Value |
|---|---|---|
| Minimum Deployments | General | macOS 13.0 |
| Application is agent (UIElement) | Info | YES |
| App Sandbox | Signing & Capabilities | ON |
| Outgoing Connections (Client) | App Sandbox → Network | ON |
⌘R. The app icon appears in the menu bar. Nothing in the Dock.
On first launch the app:
- Scans installed keyboard layouts and creates a colour + pattern mapping
- Searches the local network for a WLED device (mDNS name must contain "wled" and "key")
- Starts sending commands on every layout switch
If auto-discovery doesn't find your device, open Settings → WLED device and enter the host manually.
- Set Segment id in Settings to match your WLED segment (check
GET /json/state→seg[].id). - If colours seem ignored but brightness changes, the segment likely has a non-default palette — the app forces
pal: 0on every request, so one layout switch will reset it. - For a 5×5 2D matrix, configure the matrix in WLED and leave
start/stopalone — the app does not send those fields.
⌘U in Xcode. Tests cover:
ColorMapperTests— mapping, fallback, pattern preservation, case sensitivitySettingsStoreTests— first-launch auto-detect, round-trip persistence, v1 config migrationWLEDClientTests— JSON payload shape (nestediarray), partial pattern, HTTP 5xx, debounce, pattern-change re-send
┌─────────────────┐ sourceID ┌──────────────────────────────┐ POST /json/state
│ LayoutMonitor │ ────────────▶ │ AppCoordinator │ ──────────────────▶ WLED
│ │ │ │
│ Carbon TIS + │ │ ColorMapper: │ ┌──────────────┐
│ DistributedNotif│ │ sourceID → LayoutEntry │──▶│ WLEDClient │
│ + wake listener │ │ (color + pattern) │ │ (actor) │
└─────────────────┘ │ │ │ retry + │
│ rotation applied here │ │ debounce + │
│ (pattern only, not stored) │ │ coalesce │
└──────────────┬───────────────┘ └──────────────┘
│ @EnvironmentObject
┌──────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ MenuBarExtra │ │ SettingsView │
│ (status icon) │ │ (SwiftUI form) │
└─────────────────┘ └──────────────────┘
- LayoutMonitor subscribes to
AppleSelectedInputSourcesChangedNotificationand emits the currentkTISPropertyInputSourceIDviaAsyncStream<String>. - AppCoordinator runs the ID through ColorMapper (
(sourceID, Config) → LayoutEntry), appliesmatrixRotationto the pattern, and hands it to WLEDClient. - WLEDClient (Swift actor) sends
POST /json/statewith per-pixel"i"array,pal: 0,fx: 0, and a 100 ms transition. Deduplicates by(LayoutEntry, brightness), retries on failure, coalesces rapid updates. - AppCoordinator also subscribes to
$config(Combine, 150 ms debounce) — dragging the brightness slider or editing a pattern immediately updates the device.
{
"on": true,
"bri": 128,
"transition": 1,
"seg": [{
"id": 0,
"on": true,
"col": [[0, 120, 255]],
"i": [[0,120,255],[0,120,255],...,[0,0,0],[0,0,0]],
"fx": 0,
"pal": 0
}]
}"i"— nested[[R,G,B]]array (one entry per LED). Pixels where the pattern is off receive[0,0,0]."col"— base colour (fallback for firmware that ignores"i")."pal": 0— forces default palette so our colours are not overridden.- No
start/stop— respects the device's own 2D matrix segment setup.
Stored in the app's sandboxed container:
~/Library/Containers/<bundle-id>/Data/Library/Application Support/WLEDLayoutIndicator/config.json
{
"wled": { "host": "wled-key-indicator.local", "brightness": 128, "segmentId": 0, "ledCount": 25 },
"mapping": {
"com.apple.keylayout.ABC": {
"color": { "r": 0, "g": 120, "b": 255 },
"pattern": { "pixels": [true, true, ..., true] }
}
},
"defaultEntry": { "color": { "r": 80, "g": 80, "b": 80 }, "pattern": { "pixels": [...] } },
"matrixRotation": 0,
"launchAtLogin": false
}Old v1 configs (with mapping: {String: RGB} and defaultColor) are automatically migrated on first launch.
WLEDLayoutIndicator/
├── WLEDLayoutIndicatorApp.swift # @main, NSApplicationDelegateAdaptor, MenuBarExtra
├── AppCoordinator.swift # monitor → mapper → rotation → client, sleep/wake dimming
├── Core/
│ ├── Models.swift # Config, RGB, Pattern, LayoutEntry, LinkStatus
│ ├── SettingsStore.swift # JSON persistence, first-launch auto-detect, v1 migration
│ ├── LayoutMonitor.swift # Carbon TIS + DistributedNotificationCenter
│ ├── ColorMapper.swift # Pure (sourceID, Config) → LayoutEntry
│ ├── WLEDClient.swift # Actor: URLSession, per-pixel "i" API, retry/debounce
│ └── WLEDDiscovery.swift # mDNS/Bonjour discovery via NWBrowser
├── UI/
│ ├── StatusBarIcon.swift # Menu-bar label (tinted grid or warning triangle)
│ ├── SettingsView.swift # SwiftUI Form: host, brightness, rotation, patterns
│ └── PatternEditor.swift # 5×5 clickable grid + fill/clear presets
└── Assets.xcassets/
WLEDLayoutIndicatorTests/
├── ColorMapperTests.swift
├── SettingsStoreTests.swift
└── WLEDClientTests.swift
Compiles cleanly under strict concurrency (Xcode 26.4):
- Domain types (
RGB,Pattern,LayoutEntry,Config,LinkStatus) arenonisolated struct/enum— usable from any actor withoutawait. WLEDClientis a Swiftactor— all mutable send state is actor-isolated.AppCoordinator,SettingsStore,LayoutMonitorare@MainActor.WLEDDiscoverybridgesNWBrowsercallbacks to@MainActorviaTask { @MainActor in }with pre-capturedself.
xcodebuild -scheme WLEDLayoutIndicator -configuration Release -derivedDataPath build
cp -R build/Build/Products/Release/WLEDLayoutIndicator.app /Applications/First launch: right-click → Open → confirm. Enable Launch at login in Settings.
- Heartbeat — periodic re-send to recover if WLED reboots and loses state
- Per-app layout tracking — macOS allows per-app input sources; could track them independently
- Developer ID signing / notarization — currently unsigned
MIT