Skip to content

Getting the Active Keyboard Layout in Linux Desktop Environments

RedBearAK edited this page Jun 14, 2026 · 1 revision

This article documents how Toshy finds the active keyboard layout across Linux desktop environments, and how it notices when the user switches layouts at runtime. It is written to stand on its own, because the underlying question turns out to be harder than it looks: "which keyboard layout is active right now, and tell me the moment it changes" has a different answer in every environment, and there is no portable API for it. The per-environment channels, the Wayland-specific obstacles, and the investigation method collected here should transfer to anyone solving the same problem, inside Toshy or not.


1. What Toshy is, and where the mismatch originates

Toshy is not itself a keymapper. It is a configuration layer and a set of helper components wrapped around an evdev-based keymapper (xwaykeyz). The keymapper grabs the keyboard device at the evdev / keycode level — below XKB and below the compositor — reads raw hardware keycodes, and re-emits transformed events. Working at that level is what lets one set of rules apply uniformly across X11 and Wayland, every desktop, and every application. Toshy proper is essentially that keymapper plus a config file authored to produce a macOS-style result, so when this article says "Toshy does X," the mechanism underneath is the keymapper acting on Toshy's config.

The keymapper identifies keys by the Linux kernel's keycode constants from input-event-codes.hKEY_Q is 16, KEY_A is 30, and so on. Those names, and a config written in terms of them, line up with a standard default-variant US layout. This is worth stating precisely, because it is the real origin of the mismatch: neither Toshy nor the keymapper chooses a US layout. The kernel's keycode labels are simply how Linux names physical key positions, and those labels happen to coincide with US. The config inherits that framing because it is written in the kernel's key names — there is no separate "use US" decision anywhere that could be turned off.

Evdev keycodes are positional. Keycode 16 is always the top-left letter key in hardware terms, on any keyboard; the loaded layout is what decides which symbol that position emits. On US, keycode 16 is q; on French AZERTY the same physical key is a. The keymapper sees only the keycode, never the symbol — which is where the trouble can enter.


2. When it works, and when it goes wrong

The point that is easy to miss is that the mismatch is not pervasive. Most input is unaffected, and that is exactly what makes the failures confusing when they do happen.

Why most things just work. A keycode the keymapper does not specifically transform passes straight through, and XKB downstream applies the user's actual layout — so ordinary typing produces the right characters on any layout. Many remapped shortcuts come out right too, because they are modifier substitutions (the virtualized Cmd key behaving like Ctrl) that still pass the original keycode through to XKB. On AZERTY, pressing Cmd with the key the user sees as Q — which sits at the US A position, keycode 30 — emits a Right Ctrl + keycode 30 combination; keycode 30 goes through XKB, becomes q, and the application receives Ctrl+Q. Quit works, and the user sees nothing wrong.

Where it breaks. The failure is narrow: it appears only when the keymapper matches a specific keycode and emits a specific transformed output, and it is most visible when that output is a fixed Linux action rather than a passthrough. Toshy maps the virtualized Cmd+Q to Alt+F4, because Linux has no universal "quit" and Alt+F4 reliably closes a window. That rule matches on keycode 16 (KEY_Q, the US Q position). On AZERTY the key the user sees as A sits at that same position (keycode 16). So an AZERTY user pressing Cmd plus their A key, intending Select All, matches the Cmd+Q rule and gets Alt+F4 — the window closes.

Notice the asymmetry, because it is the heart of the confusion. The problem is not that "Cmd+Q comes out as Ctrl+Q" — that would work fine. The problem is that a position-matched rule fires a different action than the symbol the user intended. And the same AZERTY user who actually wants to quit presses the key they see as Q (keycode 30); that does not match the close-window rule — it matches the Cmd+A → Ctrl+A rule, passes keycode 30 through XKB to q, and quits correctly. So on one keyboard, "quit" works by accident while "select all" destroys the window, and the misfires do not line up with the key labels. That is precisely why the cause is so hard to see.

The underlying position/symbol mismatch for AZERTY:

Physical key (evdev keycode) Symbol on US Symbol on AZERTY
keycode 16 (top-left letter) q a
keycode 17 w z
keycode 30 (home row, left) a q
keycode 44 (bottom row, left) z w

The fix: a correction map

The correction is a small dict of keycode -> keycode applied at the very front of the keymapper's pipeline, translating each incoming keycode to the US keycode that produces the same symbol on the active layout, so the existing position-based rules match what the user intended. For French AZERTY it has seven entries:

{16: 30, 17: 44, 30: 16, 39: 50, 44: 17, 50: 51, 51: 39}

That swaps A/Q (16 ↔ 30) and Z/W (17 ↔ 44) back into their US positions and rotates the M-and-punctuation cluster (39, 50, 51) that AZERTY also places differently. Pressing the AZERTY A key (keycode 16) is then read as keycode 30, so Cmd+A matches and Select All works. Because the map sits at the front and only corrects what is about to be matched, un-remapped keys still flow through to XKB exactly as before — so everything that already worked keeps working. The map is empty for US-like layouts, making the common case a zero-cost short-circuit.

Why not just swap the key definitions on the fly?

A tempting alternative is to redefine the keymapper's key constants per layout — make KEY_A resolve to keycode 16 when AZERTY is active, and so on — so the config's rules would automatically land on the right positions. It is not practical. Those constants are module-level names, resolved once when the config is exec()-ed into a large set of derived structures (modmaps, keymaps, pre-compiled match closures, cached state). Redefining them per switch would mean mutating globals shared across the whole keymapper and rebuilding the entire config on every layout change — a broad blast radius — and it would disturb the passthrough behavior that already makes most input correct. It also could not express the cross-layout symbol matching (variants, AltGr levels) that the correction actually needs, which has to be derived by compiling and comparing real keymaps rather than renaming constants. A flat keycode→keycode map inserted at the front of the pipeline is the surgical alternative: empty when unneeded, and inert for everything it does not touch.

Building that map requires knowing the active layout, and rebuilding it the moment the user switches — which is the rest of this article.


3. How layouts work

One more piece of background makes the detection problem legible. Section 1 established that keycodes are positional and the layout decides each position's symbol; the consequence is that correcting between layouts is symbol matching rather than a fixed positional shuffle, so the correction has to be derived from the active layout itself. The remaining piece is how a layout actually changes at runtime, and there are two distinct ways — a difference that is central to everything that follows:

  • Reconfigure. The configured set of layouts changes (for example, the user edits their layout list, or a tool sets a new one). XKB compiles a brand-new keymap.
  • Group toggle. Several layouts are compiled together into one keymap as XKB groups (capped at four). Each group is a (layout, variant) pair, held in parallel comma-separated lists. Switching layouts just changes which group is active — no recompilation, the keymap is unchanged.

Almost all runtime switching is the second kind. The familiar Super+Space and Alt+Shift shortcuts toggle the active group; they do not reconfigure. This distinction decides whether a given detection method will ever see the switch.

XKB groups are the same concept on X11 and Wayland — both sit on libxkbcommon — so "group" means the same thing throughout.


4. The Wayland constraint: input state is focus-gated

On X11 and on every desktop that exposes a settings channel, reading the active layout is a matter of finding the right property or signal (Section 5). Wayland adds a structural obstacle that deserves its own section, because it is the reason a single generic method cannot work everywhere.

Wayland deliberately withholds global input state from clients. A client only learns about input directed at its own surfaces. In the wl_keyboard interface, the enter, leave, key, and modifiers events all follow keyboard focus — and modifiers is the event that carries the active group. A background daemon that never holds focus therefore never sees a group toggle.

There is exactly one focus-independent event: keymap. It is sent when the keyboard is bound, so the startup layout is always readable. It may also be re-sent when the layout changes — but whether that re-send reaches an unfocused client is the compositor's choice, not a protocol guarantee.

This is the same design principle that shapes two other parts of Toshy. It is why the keymapper grabs evdev below the compositor instead of asking Wayland for keystrokes, and it is why detecting the active window on Wayland requires per-compositor providers rather than one portable call. Active-layout detection runs into the identical wall.

The practical consequence: a background client can always read the layout that was active at startup, but it can only track changes if the compositor chooses to re-send keymap to unfocused clients. Some do; some do not. Section 6 covers the split.


5. Per-environment detection reference

Each environment is handled by one backend that knows that environment's channel. The table summarizes them; the subsections give the detail that actually cost time to discover. A recurring theme: an identity signal (a layout id, or the keymap contents) is immune to list reordering, while an index must be re-resolved against the current list on every change, and some "current index" values cannot be trusted.

Environment Discovery of active layout Change subscription Identity vs index Verified on
KDE Plasma (X11 + Wayland) getLayout() index, resolved via ~/.config/kxkbrc D-Bus layoutChanged(uint) index Plasma 6, Wayland
GNOME / Mutter (X11 + Wayland) mru-sources[0] GSettings changed::mru-sources identity (source id) Fedora 43 GNOME, Wayland
Cinnamon (X11 + Wayland) GetInputSources() cache D-Bus CurrentInputSourceChanged(s) identity (source id) Cinnamon 6.6.7 / LMDE 7, Wayland
COSMIC / cosmic-comp (Wayland) read xkb_config file, element 0 D-Bus Changed(id, key) triggers a file read identity (file content) COSMIC 1.0.14 / Fedora 44, Wayland
Any X11 desktop XkbGetState active group + _XKB_RULES_NAMES libX11 XkbStateNotify index (group) Xorg / XFCE
wlroots / Smithay compositors surfaceless wl_keyboard.keymap, group 0 compositor re-sends keymap identity (keymap content) cosmic-comp 1.0.14

KDE Plasma

The service is org.kde.keyboard at path /Layouts, interface org.kde.KeyboardLayouts. getLayout() returns a uint index into the configured list. The index alone is not enough, because the D-Bus API does not cleanly expose the precise XKB variant codes; those are resolved by reading ~/.config/kxkbrc (LayoutList / VariantList). A cross-check of the config order against the D-Bus getLayoutsList short names guards against the two disagreeing. Changes arrive on the layoutChanged(uint) signal. Because it is index-based, the list must be re-resolved whenever layouts are reordered, and kxkbrc is the source of truth for variant codes.

GNOME / Mutter

The settings live in the org.gnome.desktop.input-sources GSettings schema, keys mru-sources and sources. The active layout is mru-sources[0]: GNOME keeps a most-recently-used ordering and moves the active layout to the front on every switch, so element 0 is reliably the current one — an identity, not a fragile index. The current key is deliberately not used; it is nominally an index into sources but in practice often sticks (commonly at 0) and does not track switches. The cold-start fallback, when mru-sources is empty because nothing has been switched yet this session, is sources[0]. Changes come from the GSettings changed::mru-sources notification (plus changed::sources for catalog edits), which fires for an unfocused observer — the property that makes it usable from a background daemon. GNOME source specs carry no human-readable description, so display names are composed from the codes.

Cinnamon

The service is org.Cinnamon at /org/Cinnamon, interface org.Cinnamon. GetInputSources() returns an array of structs (a(ssisssssssib)); the useful fields are type (xkb / ibus), id, description, xkb layout, xkb variant, and the active flag, cached as id -> spec. The struct also contains a subscript indicator label (us₁, us₂) that is best avoided. The CurrentInputSourceChanged(s) signal carries the joined id directly (us, us+mac, fr+azerty), which makes this path fully identity-based and reorder-immune; InputSourcesChanged() refreshes the cache when the catalog changes. Only xkb sources are handled; ibus (non-xkb) sources are out of scope.

COSMIC / cosmic-comp

The most unusual one. COSMIC persists no active-index field and exposes no D-Bus value reader — introspection shows the config object has only a Changed(id, key) signal, with no methods and no properties. So the backend is a config-watch hybrid: the D-Bus signal is the trigger, and a file is the value.

The value comes from ~/.config/cosmic/com.system76.CosmicComp/v1/xkb_config (in RON), whose parallel layout / variant CSV lists are parsed, taking element 0. This works because cosmic-comp atomically rewrites that file on every switch with the lists rotated so the active layout is element 0 (empty variant slots are preserved positionally). The trigger is the com.system76.CosmicSettingsDaemon.Config Changed("com.system76.CosmicComp", "xkb_config") signal; the backend self-registers with the settings daemon's WatchConfig method so it does not depend on any other component watching.

The gotcha here generalizes: the atomic write is a temp-file rename() into place, which replaces the inode and so defeats an inotify watch on the path. That is exactly why the trigger is the D-Bus signal and the value is a fresh read of the (newly written) file, rather than an inode watch.

Any X11 desktop

X11 needs no per-desktop handling. The active group index comes from libX11 XkbGetState (called through ctypes, since python-xlib has no XKB binding) and is mapped to a layout via the _XKB_RULES_NAMES root-window property. Changes arrive as XkbStateNotify events selected with XkbSelectEventDetails. This single backend covers every X11 desktop — XFCE, MATE, LXQt, Budgie, Pantheon, i3, Openbox, and the rest — because on X11 the XKB group is a universal, focus-independent signal and desktop identity barely matters. It is index-based, so the rules-names list is consulted to turn the group number into a layout.

Generic Wayland (compositor-agnostic fallback)

The most subtle backend, and the one Section 4 was building toward. It connects as a surfaceless Wayland client — wl_registrywl_seatwl_keyboard, with no surface ever created — and reads the wl_keyboard.keymap file descriptor (mmap; the payload is NUL-terminated and size includes the terminator). The active layout is group 0 of the compiled keymap, and the identity used downstream is the compiled keymap string itself, compiled again by the analyzer rather than resolved to (layout, variant) codes.

Being surfaceless is the whole trick. keymap is delivered on bind, so the startup layout is readable on any Wayland session. A second keymap can only arrive if the compositor re-sends to an unfocused client — and because this client owns no surface, it can never hold focus, so any re-send it receives is focus-independent by construction. The client thus self-describes its own capability: the first keymap is the startup layout (universal), and any later keymap is proof that this compositor tracks switches focus-independently.

Two caveats are load-bearing:

  1. "Active = group 0" is compositor-specific. It holds on rotate-and-resend compositors, which recompile with the active layout at the front on each switch. It is false on group-index compositors such as Mutter, where group 0 is merely the first configured layout and the active group changes via the focus-gated modifiers event. This is precisely why GNOME keeps its own backend instead of falling back to this one.
  2. Live tracking is only proven where the compositor re-sends focus-independently. On a compositor that re-sends only to the focused client, this backend reads the correct startup layout and then silently sits on it after a switch it was never told about. It is a genuine last resort.

Unlike the other backends, there is no synchronous one-shot read; the startup layout arrives asynchronously as the first keymap right after bind, through the same path as every later update, so there is no startup special case to get wrong.


6. Wayland compositor behavior

The generic backend works or does not work depending on a single compositor behavior, and the split is the main open frontier for this feature.

Rotate-and-resend compositors recompile the keymap on every switch and send a fresh keymap event, with the active layout rotated to group 0. A surfaceless client receives it and tracks the switch correctly. cosmic-comp does this, and it is confirmed working through the generic backend.

Group-index compositors compile the layouts once and switch by changing the active group, which is delivered through the focus-gated modifiers event. A surfaceless client never receives it. Mutter behaves this way (hence GNOME's own backend), and Sway has been confirmed to behave this way as well: in testing, a reconfigure was caught by the generic backend but a group toggle was missed entirely. On these compositors the generic backend reads the correct startup layout and then goes stale on the first toggle.

The natural question is whether one backend could cover the group-index compositors the way the generic backend covers the rotate-and-resend ones. It cannot, and the reason is instructive. Active-window detection on Wayland has a shared protocolwlr-foreign-toplevel-management — that many wlroots compositors implement, so a single provider serves all of them. Active-layout has no shared protocol at all. Each compositor only exposes the active layout through its own IPC, so a group-index compositor needs a bespoke backend:

  • Sway exposes the active layout through its IPC (swaymsg -t get_inputs, with live updates via swaymsg -t subscribe on the input event).
  • Hyprland exposes it through hyprctl devices and the activelayout event on its socket2 event socket.
  • niri exposes it through its own IPC.

In short: Wayland never standardized a way to ask which layout is active, so each compositor answers only in its own terms, and a small dedicated backend is needed per compositor that toggles by group. This is bounded and demand-driven — one backend per compositor that needs it — not an open-ended treadmill, but it is genuinely more work than the window-context case because there is no shared protocol to amortize across compositors.

A note on stacks: a compositor's behavior is not inherited from a family. cosmic-comp and niri are both Smithay-based but are separate codebases, and Hyprland uses its own rendering stack rather than pure wlroots, so each has to be tested on its own rather than assumed from a relative.


7. Investigating a new environment

The methodology generalizes even though the per-environment code does not. To find the channel in an environment not covered above, escalate roughly in order of effort:

  1. gsettings / dconf watch for GNOME-family desktops — list the input-sources schema and watch it change while you switch layouts.
  2. gdbus introspect plus dbus-monitor (or gdbus monitor) to find a property or signal — the route for KDE, Cinnamon, and the COSMIC settings daemon. Introspect the likely service, then monitor it across a switch to see what fires.
  3. wev or WAYLAND_DEBUG=1 <app> to watch raw wl_keyboard traffic and see whether keymap is re-sent on a switch and whether modifiers carries the group.
  4. The compositor's own IPC for wlroots/Smithay compositors that toggle by group — swaymsg, hyprctl, niri's IPC, and their event streams.

Two techniques are worth calling out specifically.

The surfaceless probe. To determine whether a compositor re-sends keymap focus-independently — the property that decides whether the generic backend can track it — write a minimal client that binds wl_seatwl_keyboard, never creates a surface, and counts keymap events while you switch layouts. One keymap means startup-only (no live tracking); a second keymap on switch proves focus-independent tracking. Because the client has no surface it can never be focused, so the result is unambiguous.

Self-identifying switch tests. When verifying that a backend catches both kinds of change, drive the two paths separately and with different layouts on each, so the observed result names which path fired. For example, trigger a reconfigure to French and a group toggle to German; if your watcher only ever reports French, the group toggle is being missed. Always restore the original layout on exit so a failed run does not leave the session in a strange state.


8. Lessons

  • Prefer identity over index. A layout id or the keymap content survives list reordering; an index must be re-resolved on every change, and some "current index" fields (GNOME's current) simply do not track switches. Where both are available, use the identity.
  • Focus dependence is the central Wayland constraint. wl_keyboard's enter / leave / key / modifiers follow focus and never reach a background client; only keymap is focus-independent, and only on bind plus whatever the compositor chooses to re-send. Test the re-send explicitly.
  • Atomic config writes defeat inode watches. A rename() into place swaps the inode out from under an inotify watch. When a config is updated that way, watch a signal for the trigger and re-read the path for the value.
  • Verify against primary sources, on real hardware. Every channel above was checked with gdbus, dbus-monitor, wev, WAYLAND_DEBUG, or a purpose-built probe, then confirmed on a live session — not inferred. Speculating about D-Bus shapes or compositor behavior and presenting it as fact is the failure mode to avoid.
  • Fail loudly rather than degrade silently. Missing binaries, unreadable config, and unexpected states should produce clear warnings, not quietly wrong behavior — with the deliberate exception that a non-blocking probe should not abort everything else.
  • The methodology transfers; the code does not. Each environment needs its own channel. Budget for a bespoke backend per environment, and reuse the approach rather than expecting to reuse the implementation.

9. Current status (as of June 2026)

Environment Detection Live switch tracking
KDE Plasma Done (D-Bus + kxkbrc) Confirmed
GNOME / Mutter Done (GSettings) Confirmed
Cinnamon Done (D-Bus) Confirmed
COSMIC Done (config-watch hybrid) Confirmed
X11 (all desktops) Done (XKB) Confirmed
Wayland, rotate-and-resend (e.g. cosmic-comp) Done (generic backend) Confirmed
Wayland, group-index (e.g. Sway) Startup only via generic backend Needs dedicated IPC backend
Sway IPC backend Designed Pending implementation
Hyprland IPC backend Planned Pending (testing environment not yet available)
niri IPC backend Planned Pending

The covered environments handle both reconfigure and group toggle. The remaining work is concentrated in group-index Wayland compositors, each of which needs a small dedicated backend built on its own IPC, following the pattern and the investigation method described above.

Clone this wiki locally