Key Swap
Per-keyboard modifier swapping for Omarchy — swap Alt and Super/Windows on one keyboard without touching the others.
Configured entirely from the Omarchy menu:
SUPER+Space → Setup → Key Swap → keyboard → mode
The active mode carries a ✓. Changes apply immediately and survive reboot.

| Setup | Keyboards | Modes |
|---|---|---|
![]() |
![]() |
![]() |
| The row appears only while the plugin is enabled | One entry per connected keyboard | The active mode carries a ✓ |
Nothing is written into ~/.config/hypr — the plugin owns its own persistence,
so uninstalling is deleting the plugin.
Install
omarchy plugin add git@github.com:wqsaali/omarchy-key-swap.git --enable
Then generate the menu rows once:
~/.config/omarchy/plugins/waqas.keyswap/bin/keyswap menu
Dependencies
jqhyprctlwith the Lua config parser (Omarchy Quattro)bash
All of these ship with Omarchy; nothing is downloaded at install or run time.
The menu rows have to live in
~/.config/omarchy/extensions/omarchy-menu.jsoncbecause the shell reads exactly one user extension file andprovidernames cannot be declared from JSONC. They go in a marked block that nothing else in that file is disturbed by, andkeyswap menu --removetakes them out cleanly.
Modes
| Mode | Effect |
|---|---|
| Standard | No swap (inherits the global setting) |
| Alt ↔ Super | Swap both the left and right pairs |
| Left pair | Swap Left Alt with Left Super only |
| Right pair | Swap Right Alt with Right Super only |
| Ctrl → Alt → Super | Rotate Ctrl into Alt, Alt into Super |
| Alt sends Super | Alt acts as Super as well as Alt |
| Menu → Super | Map the Menu key to Super |
Labels are deliberately short. The menu ellipsizes a row that overflows its
width, and the ✓ is appended to the label — so a long label loses its own
checkmark. Detail belongs in description, which renders as a subtitle.
The Fn key is not supported
Fn is handled in keyboard firmware or the laptop's embedded controller and
usually never emits a keycode the OS can see, so no compositor-level tool can
remap it — Windows/PowerToys can't either. Change it in BIOS setup (Fn Lock /
Function Key Behavior), or reflash the keyboard with QMK/VIA. For remapping
that XKB can't express at all (layers, tap-hold, arbitrary key→key), use
keyd instead.
How it works
Swaps are XKB altwin: options applied per device, so they take effect below
Hyprland and below applications — Alt+Tab in a browser, Alt+F4, and
readline's Alt+b/Alt+f all follow the swap, which a rebound keybinding
could not achieve.
- Applied live with
hyprctl eval(this Hyprland runs the Lua config parser, sohyprctl keywordis unavailable for device blocks). - Persisted by the plugin, not by your config. Nothing is written into
~/.config/hypr. The service reapplies the saved swaps whenever they would otherwise be lost:- at shell startup,
- on Hyprland's
configreloaded(a reload rebuilds the running config from files that do not carry device rules, so every reload drops the swaps), - when the set of connected keyboards changes.
- State is runtime state, not configuration:
~/.local/state/omarchy/keyswap.json, alongside the other generated files Omarchy keeps there. A state file left at the old~/.config/omarchy/keyswap.jsonpath is migrated on first read.
The trade-off of owning persistence in the plugin rather than in Hyprland's
config: swaps apply once the shell is up rather than the instant Hyprland parses
its config, so there is a brief window at login where the keyboard is unswapped.
In exchange, uninstalling is deleting the plugin -- there is no config edit to
undo, and nothing left behind to confuse a future omarchy update.
Per-device kb_options replace the global value rather than extending it, so
Omarchy's own options (compose:caps, shift:both_capslock_cancel) are carried
across on every write. Omitting them would silently disable Caps-as-compose.
One physical keyboard often registers as several Hyprland devices (kbd and
kbd-1); a swap is applied to all of them, or half the keypresses arrive
unswapped. Only the primary is listed in the menu; siblings follow along.
Plugin shape
Registered as a service plugin: it draws nothing and takes no bar slot. The
service exists to keep things honest over time —
- at startup it runs
keyswap sync, so a keyboard plugged in while the shell was down still gets its saved rule applied; - every 15s it runs
keyswap menu --if-needed, which rewrites the menu rows only when the set of connected keyboards actually changed. Hyprland raises no event when an input device arrives or leaves (the first-party keyboard-layout widget polls for the same reason), so this has to be asked for.
--if-needed compares what it would write against what is already in the file,
so the timer never touches the extension file for nothing, and a block deleted
by hand repairs itself on the next tick.
Menu rows
The rows live in a marked block inside
~/.config/omarchy/extensions/omarchy-menu.jsonc, because the Omarchy menu
reads exactly one user extension file and it cannot be a file of ours. Only
that block is rewritten; anything else in the file is preserved.
bin/keyswap menu # (re)generate the rows -- run after plugging in a keyboard
bin/keyswap menu --remove # take them out again
Every generated row carries "when": "keyswap enabled", not just the
Setup > Key Swap parent. Search matches rows on their own when rather than
their ancestors', so guarding only the parent hides it from Setup navigation
while leaving every device and mode row findable by typing "key" — which is
exactly the wrong behaviour. Guard all of them or none.
enabled reads the plugins array in shell.json, which is what
omarchy plugin enable/disable writes. It is pure bash with no subprocess,
because it runs once per generated row on every menu open. An unreadable file
leaves the rows hidden, which is the safe direction.
The guards are evaluated asynchronously after the menu opens, so a disabled plugin's rows can be briefly visible in search before the batch lands.
The ✓ marks come from checked shell conditions the menu re-evaluates on every
open (asynchronously, then it rebuilds the display), so the rows never need
regenerating just because a mode changed — only when the set of connected
keyboards does.
CLI
The menu is a front end to a helper that works standalone:
bin/keyswap list # keyboards + current mode (JSON)
bin/keyswap modes # available modes (JSON)
bin/keyswap set royuan-gaming-keyboard swap_alt_win
bin/keyswap clear royuan-gaming-keyboard
bin/keyswap sync # reapply after replugging
bin/keyswap menu [--remove] # regenerate/remove the menu rows
Uninstall
bin/keyswap menu --remove # drop the menu rows
omarchy plugin disable waqas.keyswap
rm -rf ~/.config/omarchy/plugins/waqas.keyswap
rm -f ~/.local/state/omarchy/keyswap.json ~/.local/state/omarchy/keyswap-devices
Swaps already applied stay live until the next Hyprland config reload, which
drops them back to the global setting. Nothing in ~/.config/hypr was ever
touched, so there is nothing to revert there.
Handling of device names
Device names come from libinput by way of hyprctl, which means a USB device
chooses its own name. They are treated as untrusted input, because they would
otherwise reach two contexts where syntax matters: the Lua string handed to
hyprctl eval, and the shell conditions and actions written into the menu.
- Names are validated at a single choke point. Everything entering the
program must match
^[A-Za-z0-9][A-Za-z0-9._:+-]*$and be at most 128 characters — no quotes, backslashes,$, backticks, whitespace, or control characters. A device whose name fails is dropped with a warning and never reaches any later stage. Names are read base64-encoded so one containing a newline cannot split into two shorter names that each look legitimate. - The menu never carries a device name. Generated rows address keyboards by
a slug derived from the name by replacing every non-alphanumeric run with a
dash, so a slug can only be
[A-Za-z0-9-]. The generatedcheckedandactionstrings interpolate only that slug, a mode literal from a fixed table, and the plugin's own path.get-slug/set-slugresolve the slug back against the devices actually connected. - Menu rows are encoded structurally. Every id and value is produced by
jq, not by string concatenation, so encoding is done by a JSON encoder. - The
hyprctl evalpayload is re-checked at the point of use. Both the device name and the composedkb_optionsare validated again immediately before the Lua string is built, rather than trusted from upstream. - Device names are never used as patterns. Sibling matching compares strings rather than building a regex from a name.
How files are written
Every write goes through one helper rather than a shell redirection.
> path is wrong here in two ways. It opens through a symlink, so another
local process able to write into these predictable state directories could point
one of them at any file this user owns and have the plugin overwrite it. And it
truncates the destination before the replacement exists, so an interrupted write
loses the previous contents.
Instead: the destination and its directory are rejected if either is a symlink,
the replacement is written to a temporary file created in the destination's own
directory with mode 600, and moved into place. Keeping the temporary beside the
target matters -- one in /tmp is usually on a different filesystem, where the
move degrades into a copy.
Files this plugin writes
| Path | What |
|---|---|
~/.local/state/omarchy/keyswap.json |
Saved mode per keyboard |
~/.local/state/omarchy/keyswap-devices |
Last-seen keyboard set, for change detection |
~/.config/omarchy/extensions/omarchy-menu.jsonc |
The menu rows, inside a marked block |
The extension file is the only one of these that is user-authored. Writes to it
are confined to the block between the >>> waqas.keyswap and <<< waqas.keyswap
markers, are diffed before writing so an unchanged block is left alone, and
keyswap menu --remove removes the block and nothing else. Everything outside
the markers -- your own rows, the comments Omarchy ships -- is preserved
verbatim.
Nothing is ever written into ~/.config/hypr.
License
MIT -- see LICENSE.


