-
-
Notifications
You must be signed in to change notification settings - Fork 48
Getting the Active Keyboard Layout in Linux Desktop Environments
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.
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.h — KEY_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.
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 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.
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.
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.
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.
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 |
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.
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.
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.
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.
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.
The most subtle backend, and the one Section 4 was building toward. It connects
as a surfaceless Wayland client — wl_registry → wl_seat → wl_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:
-
"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
modifiersevent. This is precisely why GNOME keeps its own backend instead of falling back to this one. - 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.
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 protocol — wlr-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 viaswaymsg -t subscribeon theinputevent). -
Hyprland exposes it through
hyprctl devicesand theactivelayoutevent on itssocket2event 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.
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:
-
gsettings/dconf watchfor GNOME-family desktops — list theinput-sourcesschema and watch it change while you switch layouts. -
gdbus introspectplusdbus-monitor(orgdbus 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. -
wevorWAYLAND_DEBUG=1 <app>to watch rawwl_keyboardtraffic and see whetherkeymapis re-sent on a switch and whethermodifierscarries the group. -
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_seat → wl_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.
-
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'senter/leave/key/modifiersfollow focus and never reach a background client; onlykeymapis 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 aninotifywatch. 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.
| 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.