Drive your Omarchy desktop from a game controller. Buttons become keys, window
management, and commands — configured in Lua, the same way ~/.config/hypr is.
Tested against a Sony DualSense (PS5) and a Microsoft Xbox Series controller.
Runs with no elevated privileges: no sudo, no udev rule, no group change,
no uinput. Reading a controller is already permitted by systemd, and everything
it does to your desktop goes through Hyprland.
Status: Phase 3. Installable with
omarchy plugin add, supervised by the shell, with radial menus, a bar widget, dictation and pointer tuning. See Roadmap.
| File | For |
|---|---|
README.md |
Using it — install, configuration, the full ctrl API |
ARCHITECTURE.md |
How it works. Start here to read the source |
HARDWARE.md |
What was measured on real controllers, and the traps |
ROADMAP.md |
What is done, what is left, what was decided against |
CLAUDE.md |
Working agreement, and Omarchy plugin conventions |
- What works today
- Requirements
- Install
- Running it without installing
- The bar widget
- Controlling it from a terminal
- Pairing a controller
- Configuration
- API reference
- Default bindings
- How it works
- Mouse control
- Development
- Troubleshooting
- Uninstall
- Roadmap
- Security
- License
- Reading any gamepad the kernel exposes, hot-plugged or not
- Positional button names, so one config drives both pads
- Modifier layers — hold
r1, get a different set of bindings - Activators — tap, long press, double tap, release
- Leader sequences — tap the guide button, then a face button
- Radial menus on a held button, nested, with labels on screen
- Simultaneous chords, off unless you declare one
- Per-app bindings, layered over your base set
- Sending keys, running Hyprland dispatchers, running commands, arbitrary Lua
- Config hot reload, with rollback when the config is broken
- Installs as an Omarchy plugin, supervised by the shell and restarted if it dies
- A bar widget showing the connected pad and the layer being held
- Dictation through Omarchy's
voxtype, and pointer tuning throughhl.device
| Needs | Why |
|---|---|
| Hyprland 0.56+ | The embedded Lua VM and its IPC surface, which is how this reaches the desktop |
luajit |
The daemon. Already an Omarchy dependency |
| A controller | A DualSense needs no driver. An Xbox pad over Bluetooth wants xpadneo |
Nothing else is installed and nothing outside your home directory is written.
omarchy plugin add https://github.com/bruce-forte/omarchy-controller-support.git --enableThat clones it into ~/.config/omarchy/plugins/, validates the manifest, enables
it, and puts the widget on your bar. Then restart the shell so the service loads:
omarchy restart shellOn first run the daemon writes a starter config to
~/.config/omarchy/controller/ and picks up any connected pad. Press ✕ (or A)
and the focused window should receive Return.
The shell owns the daemon from then on — it starts with the shell, stops with it, and is restarted if it ever dies. There is no systemd unit to enable.
The daemon is a plain executable and runs fine from a checkout, which is the easier way to work on it:
git clone https://github.com/bruce-forte/omarchy-controller-support
cd omarchy-controller-support
./bin/omarchy-controllerdOnly one daemon may run at a time, so disable the plugin (or stop its daemon) before running your own — otherwise the second one refuses to start.
./bin/omarchy-controllerd # run in the foreground
./bin/omarchy-controllerd --status # is one running?
./bin/omarchy-controllerd --stop # stop it
./bin/omarchy-controllerd --helpSet the log level with OMARCHY_CONTROLLER_LOG — debug, info (the default),
warn, error. At debug every binding is logged as it fires, which is the
fastest way to tell "the binding did not match" from "the binding matched and its
target ignored it":
controllerd: debug fire r1 + south [tap] -> Go to app
A second daemon refuses to start rather than opening the pad twice and firing
every binding twice. The running pid is recorded at
$XDG_RUNTIME_DIR/omarchy-controllerd.pid, and a stale pidfile is ignored — the
process behind it is checked before it is believed.
Shows the connected controller, and — the useful half — the layer you are currently holding. Holding a modifier silently changes what every button does, and until the Phase 3 on-screen HUD this is the smallest answer to "which layer am I in".
| Shows | Means |
|---|---|
|
A controller is connected, base layer |
r1 |
You are holding r1 |
l1+r1 |
Both held |
! |
The config failed to load; your previous bindings are still live |
| hidden | No controller connected |
The tooltip names the pad and the number of bindings loaded. To keep the icon
visible with nothing connected, set showWhenDisconnected on the widget's entry
in ~/.config/omarchy/shell.json:
{ "id": "io.github.bruce-forte.controller-support", "showWhenDisconnected": true }The plugin registers an IPC target under its id, so it can be driven from a shell — or by a script, or by an agent:
omarchy-shell io.github.bruce-forte.controller-support status # pads, mode, layer, bindings, as JSON
omarchy-shell io.github.bruce-forte.controller-support running # is the daemon up?
omarchy-shell io.github.bruce-forte.controller-support reload # re-read the config
omarchy-shell io.github.bruce-forte.controller-support restart # bounce the daemonEvery Omarchy plugin does this, and the whole set is discoverable:
qs ipc -n -p "$OMARCHY_PATH/shell" showThat prints each target with its function signatures, which is how the
first-party plugins are driven too (omarchy-shell omarchy.audio toggle,
omarchy-shell omarchy.monitor brightness 40).
reload works by touching the config file the daemon already watches, so the
reload it triggers is exactly the one a save triggers — rollback included.
Omarchy has no menu entry for Bluetooth pairing. Use the Bluetooth widget in
the bar (omarchy.bluetooth); it scans automatically and lists devices under
discovered, where Enter connects.
| Pad | Pairing mode |
|---|---|
| DualSense | Start with the controller off. Hold Create (left of the touchpad) and PS together for 3–5 s, until the light bar flashes blue |
| Xbox Series | Press Xbox to power on, then hold the small Pair button on the top edge next to the USB-C port for ~3 s, until the logo flashes rapidly |
A DualSense also works over USB, which needs no pairing at all. Over Bluetooth the microphone uses the plugin's experimental raw-HID bridge rather than a standard Bluetooth audio profile.
~/.config/omarchy/controller/, laid out like ~/.config/hypr:
| File | Holds |
|---|---|
controller.lua |
Entry point. Pulls in Omarchy's defaults, then yours |
config.lua |
Timings, and which buttons are modifiers |
bindings.lua |
Your bindings |
apps/ |
Per-app profiles |
Saving any of them reloads the daemon immediately.
The daemon puts these on package.path, mirroring what Omarchy's own
bootstrap.lua does for Hyprland:
~/.config/omarchy/?.lua → require("controller.bindings")
<plugin>/?.lua → require("default.controller.bindings")
<plugin>/lua/?.lua → the daemon's own modules
So controller.* is yours and default.controller.* is ours, and your config
decides how much of ours to take:
-- ~/.config/omarchy/controller/controller.lua
require("default.controller.controller") -- Omarchy's defaults
require("controller.config") -- then yours, layered on top
require("controller.bindings")Drop the defaults wholesale with either flag, set before the require:
omarchy_controller_default_bindings = false -- no default bindings
omarchy_controller_default_apps = false -- no default app profilesOr surgically, with ctrl.unbind. If ~/.config/omarchy/controller/controller.lua
does not exist at all, the daemon loads its own defaults directly, so a pad still
works.
Buttons are named by position, not by the label printed on your pad, because the same physical button is PlayStation's ✕ and Xbox's A. Naming the position means one config drives both.
| Canonical | Aliases |
|---|---|
south |
cross, a |
east |
circle, b |
west |
square, x |
north |
triangle, y |
l1 r1 |
lb, rb |
l2 r2 |
lt, rt |
l3 r3 |
lstick, rstick |
dpad_up dpad_down dpad_left dpad_right |
up, down, left, right |
select |
create, share, back, view |
start |
options, menu |
guide |
ps, xbox, home, mode |
Names are case-insensitive.
⚠️ xandyare the Xbox labels. Xbox X sits west, not south. If you mean the PlayStation ✕ button, writecrossorsouth. This is the one genuinely ambiguous pair — PlayStation's official names are shapes, socross/circle/square/trianglenever collide witha/b/x/y. The daemon warns once per run when it seesxory, rather than silently doing something surprising.
ctrl.bind("south", "Confirm", ctrl.key("Return"))
ctrl.bind("r1 + south", "Go to app", ctrl.exec("omarchy-menu toggle apps"))
ctrl.unbind("select")Anything before the + is a modifier: a button that selects a layer and
never fires an action of its own.
That purity is the whole point. Because a modifier can never be an action, there is nothing to disambiguate — no timer, no window, no guessing whether you meant to tap or hold, and no latency. The cost is spending a few buttons, which a pad can afford.
The defaults are l1 r1 l2 r2. Change them with:
ctrl.modifiers({ "l1", "r1", "l2", "r2" })A button not bound in the active layer falls back to the base layer, so holding a modifier never makes the rest of the pad go dead.
The guide button is deliberately not a modifier — it is the escape hatch, and it works as a sequence leader instead, so nothing has to be held.
Modifiers combine. Holding l1 and r1 together selects the l1+r1 layer,
named in sorted order so "l1 + r1 + south" and "r1 + l1 + south" are the same
binding.
ctrl.bind("start", "Power menu", ctrl.exec("omarchy-menu toggle system"),
{ activator = "long" })tap (the default), long, double, release. A button may carry several.
You only pay for what you bind. A button carrying nothing but a tap fires the instant it goes down:
| Bound on a button | Fires | Added latency |
|---|---|---|
tap only |
on press | none |
tap + long |
on release, or at the long threshold | none on the long |
tap + double |
after the double window closes | ~200 ms on a single tap |
| named in a chord | after the settle window | ~50 ms on that button |
So a config with no double-taps and no chords adds no latency anywhere, and
adding one double-tap slows down only the button carrying it. This is worth
knowing before reaching for double on a button you press constantly.
release fires on release in addition to whatever else the button does.
Tap the leader, release it, then press the follower. Nothing is held.
ctrl.sequence("guide", "north", "Capture", ctrl.exec("omarchy-menu toggle capture"))The leader stays armed for sequence ms (800 by default), then disarms. If the
follower has no sequence bound, the leader disarms and that press is handled
normally.
A sequence leader is exclusive: while it is armed it consumes the next press.
Two or more buttons pressed together.
ctrl.chord({ "l3", "r3" }, "Lock screen", ctrl.exec("omarchy-lock-screen"))Off unless you declare one, because every button named in a chord must wait out the settle window on every press to see whether a partner is coming. Chord members need no binding of their own — a chord can be built from two otherwise unused buttons.
Releasing a chord member before the settle window closes cancels the chord and handles the press as a lone button.
ctrl.app({ class = "^firefox$" }, function()
ctrl.bind("r1 + dpad_left", "Back", ctrl.key("ALT + Left"))
ctrl.bind("r1 + dpad_right", "Forward", ctrl.key("ALT + Right"))
end)App bindings layer over the base set rather than replacing it, so a profile states only what differs and your navigation and modifier layers keep working. For a clean slate:
ctrl.app({ class = "^mpv$", replace = true }, function() ... end)class and title are Lua patterns, not globs. Focus changes arrive on
Hyprland's event socket, so this costs no polling.
Title matters more than it looks: a TUI is not a window. nvim, lazygit and btop run inside your terminal, so the window class is the terminal's and class alone cannot tell them apart. nvim sets the terminal title, which is what makes a TUI profile possible:
ctrl.app({ class = "^Alacritty$", title = "nvim" }, function()
ctrl.bind("west", "Jump back", ctrl.key("CTRL + o"))
end)The alternative — walking /proc for the foreground process under the window's
pid — is the only fully correct approach, and it was rejected: tmux and terminal
splits break it anyway, so it buys correctness that cannot actually be cashed.
Press a button and a wheel appears. Flick the left stick to highlight, press ✕ to run.
ctrl.radial("guide", {
{ "Apps", ctrl.exec("omarchy-menu toggle apps") },
{ "Capture", ctrl.exec("omarchy-menu toggle capture") },
{ "Style", {
{ "Theme", ctrl.exec("omarchy-menu toggle theme") },
{ "Background", ctrl.exec("omarchy-menu toggle background") },
} },
{ "Menu", ctrl.exec("omarchy-menu toggle root") },
})This is the answer to a pad having too few buttons. A chord solves the same problem by being memorised; a radial solves it by putting the labels on screen — and because a novice reading labels and an expert flicking blind perform the same motion, one turns into the other without relearning anything.
| Action | Does |
|---|---|
| The bound button | Opens the wheel — and closes it again |
| Flick the stick onto a leaf | Highlights it, and it stays highlighted |
Flick onto a branch (›) |
Walks into it |
| ✕ | Runs the highlighted leaf |
| ○ | Back one level; closes the wheel at the top |
The menu is modal: it stays up until something closes it, and while it is up every other button is swallowed, so nothing underneath fires.
Three things about it are deliberate, and each fixes a way this can be annoying:
- The highlight is sticky. A stick springs back to centre on its own, so a highlight that lived only while a direction was held would force one thumb onto the stick and another onto the button at the same time. Flick, let go, then press.
- The left stick by default, so the thumb that aims is not the thumb that
presses. Override with
{ stick = "right" }. - Moving can never run anything. Branches are walked into and leaves are only highlighted, so the stick explores freely and ✕ is the only thing that acts.
After walking into a branch, the stick is still deflected — and in the submenu that direction may be another branch. So descending re-arms only once the stick returns near the centre: flick, descend, relax, flick again. ○ disarms for the same reason, so backing out does not fall straight back in.
Items may be written positionally or by name; a bare list of items is a submenu:
{ "Apps", ctrl.exec("...") } -- an action
{ "Power", { ... } } -- a submenu
{ label = "Apps", action = ctrl.exec("...") } -- the same, namedOptions: stick ("left", the default, or "right"), deadzone (default
0.4), activate (default south), back (default east). Menus may nest
four deep.
A button bound to a radial cannot also be a modifier, since holding it would
select a layer instead; the daemon says so rather than silently preferring one.
activate and back must differ from each other and from the opening button.
Omarchy binds a radial to guide by default — the escape hatch, reachable from
anywhere.
All milliseconds.
ctrl.timings({
long = 300, -- hold this long for a long press
double = 200, -- window for the second press of a double tap
sequence = 800, -- how long a leader stays armed
chord = 50, -- how long to wait for a chord partner
})Delegated to voxtype, Omarchy's own
whisper.cpp dictation tool, rather than reimplemented — it already owns the
model, the audio device and the bar indicator.
ctrl.bind("l1 + start", "Toggle dictation", ctrl.dictation("toggle"))toggle, start or stop. The daemon models this explicitly: it starts in
control, enters dictating when recording starts, and returns to control
when recording stops or fails. passthrough is the third state reserved for a
future activation binding; no binding enters it yet.
Bound by default on l1 + start, but only when voxtype is installed — the
shipped default is guarded with ctrl.has("voxtype"), the same way Omarchy
guards its own voxtype keybindings.
Over USB, or with any ordinary system microphone, this remains a thin wrapper
around voxtype. For an original DualSense (054c:0ce6) over Bluetooth, the
daemon instead:
- transactionally grabs the pad, Motion Sensors and Touchpad evdev siblings so userspace cannot consume microphone bytes as phantom controls;
- enables the proprietary microphone stream over hidraw;
- decodes its 48 kHz mono Opus frames and publishes
omarchy_dualsense_micthrough PipeWire; - re-asserts hardware unmute ten times a second, because
hid-playstationreads a byte of every Opus frame as the microphone-mute button and would otherwise latch the microphone off mid-sentence. The pad's mute LED strobes while this is happening, which is the healthy state -- a solid light is the one that means the microphone is muted; - selects that temporary source, starts voxtype, then restores everything on stop, disconnect or daemon shutdown.
Wireless use requires libopus, pipewire-pulse/pactl, and hidraw access.
On Arch the last part is normally supplied by
sudo pacman -S steam-devices. Keep voxtype's
[audio] device = "default", because the source is selected transactionally
rather than written into its config.
While wireless dictation is active, other applications temporarily lose the physical gamepad and its sibling input devices; controllerd still reads the clean raw reports, so the dictation-stop binding continues to work. Removing that limitation needs the kernel-side report filter described in HARDWARE.md.
The clean-room transport and lifecycle are fixture-tested. Microphone activation, decoded audio and the unmute counter-measure were also verified on real Bluetooth hardware. If a session ever does record silence anyway, two seconds of it raises a notification rather than producing an empty transcription. Xbox controller microphones remain unsupported on Linux.
Normal stop, disconnect and SIGTERM paths disable the microphone before
releasing the evdev grab. If that disable write fails while the controller is
still connected, controllerd deliberately stays alive and retries once per
second; disconnecting the controller is the safe escape. SIGKILL cannot run
that policy, so reconnect the controller before giving it to a game after a
forced kill.
Mouse control needs nothing from this plugin — see Mouse control — because both pads already present a real pointer device that libinput drives. They are ordinary Hyprland input devices, so they are configured, not synthesised:
ctrl.device({
name = "dualsense-wireless-controller-touchpad",
sensitivity = 0.2,
natural_scroll = true,
})Find the name with hyprctl devices. Fields are passed to Hyprland's hl.device
untouched, so anything it accepts on a device works here. Applied when the config
loads, so it re-applies on every reload.
Everything below is a global ctrl.* function, available in any config file.
This is the controller's equivalent of the hl object in ~/.config/hypr —
except hl is defined by Hyprland inside its own Lua VM, and ctrl is defined
by this daemon.
| Call | Does |
|---|---|
ctrl.bind(spec, description, action, options) |
Bind a button. description is optional — passing the action second works too. options.activator is tap/long/double/release |
ctrl.unbind(spec, options) |
Remove a binding, or just one activator of it |
ctrl.modifiers({ ... }) |
Declare which buttons are pure modifiers |
ctrl.sequence(leader, follower, description, action) |
Leader-then-follower sequence |
ctrl.chord({ ... }, description, action) |
Simultaneous press |
ctrl.app(match, fn) |
Per-app profile. match takes class, title, replace |
ctrl.timings({ ... }) |
Override timings |
ctrl.radial(button, items, options) |
Radial menu toggled by a button. options: stick, deadzone, activate, back |
ctrl.device({ name = ..., ... }) |
Configure a Hyprland input device. Applied at config load, not bound to a button |
| Call | Does |
|---|---|
ctrl.key(spec) |
Send a key to the focused surface. "SUPER + SHIFT + K"; + or spaces separate |
ctrl.key_down(spec) / ctrl.key_up(spec) |
Hold or release a key |
ctrl.dispatch(expr) |
Run a Hyprland dispatcher. Takes what follows hl.dsp., so "window.close()" runs hl.dispatch(hl.dsp.window.close()) |
ctrl.exec(command) |
Run a command |
ctrl.launch(command) |
Run a GUI app the way Omarchy does (uwsm-app --), so it lands in its own systemd scope |
ctrl.notify(message) |
Send a low-urgency notification |
ctrl.callback(fn) |
Wrap a Lua function |
ctrl.dictation(what) |
toggle, start or stop, via voxtype |
| Call | Does |
|---|---|
ctrl.has(command) |
Is a command on PATH? Use it to guard bindings on optional tools |
ctrl.buttons |
Every canonical button name, as a list |
A binding also accepts a bare function or a bare string:
ctrl.bind("l3", "Say hello", function() os.execute("notify-send hi") end)
ctrl.bind("r3", "Screenshot", "omarchy-capture-screenshot") -- treated as execModifiers accepted by ctrl.key: SUPER (MOD, WIN, META, CMD), CTRL
(CONTROL), SHIFT, ALT (OPT).
A malformed binding is reported and skipped, not fatal — one bad line never costs you the rest of your config.
The base layer is navigation only, and instant. Everything that acts on windows lives behind a held modifier.
| Buttons | Does |
|---|---|
south / east |
Return / Escape |
north / west |
Tab / Shift+Tab |
| d-pad | arrow keys |
start |
Omarchy menu |
select |
Keybindings cheatsheet |
r1 + south |
Go to app |
r1 + east |
Close application |
r1 + north / west |
Full screen / toggle floating |
r1 + d-pad |
Focus window in that direction |
l1 + d-pad |
Previous / next / former workspace |
l1 + south / east |
Scratchpad / move window to scratchpad |
guide |
Radial menu: Apps, Capture, Windows, Style ›, Menu, System › |
l1 + start |
Toggle dictation — only bound when voxtype is installed |
Plus one app profile: in a terminal, r1 + d-pad up/down pages the scrollback,
and when the title says nvim, north/west jump forward and back.
An external daemon written in LuaJIT reads controllers from evdev and drives the desktop through Hyprland's IPC socket.
The daemon itself is deliberately not QML. Omarchy's plugin kinds are all QML, and QML cannot read evdev — so the input loop has to live outside the shell. The plugin therefore supervises the daemon rather than containing it:
Service.qmlrunsbin/omarchy-controllerdas a child process. It starts with the shell, dies with it, and comes back onomarchy restart shell— which is why there is no systemd unit to enable and no second install step. A daemon that dies immediately (a missingluajit, a broken module) backs off exponentially to 30 s rather than respawning in a tight loop.BarWidget.qmlshows the state.
The two do not talk to each other. Omarchy routes inter-plugin calls only to
panels and overlays, not to services, so the daemon mirrors its state to
$XDG_RUNTIME_DIR/omarchy-controller.state and the widget watches that file with
FileView. The file is written whole and renamed into place, so a watcher never
reads a half-written line, and only changes are written — an idle pad produces no
writes at all.
The same state goes to the daemon's stdout as one JSON line per change, which
is how Service.qml knows it is alive and talking. Logs go to stderr, so the two
never interleave.
The radial takes a different route. A wheel needs many frames a second while the stick moves, which is more than a watched file should carry, so it goes over a unix socket instead:
daemon --connect--> SocketServer in Service.qml --summon/callIfLoaded--> Overlay.qml
The daemon is the client and the shell is the server, which is the opposite
of what it looks like it should be. Quickshell provides SocketServer in a few
lines of QML, whereas listening from the daemon would mean bind/listen/
accept hand-written over the FFI — and connecting is something the daemon
already does to reach Hyprland. The socket is silent unless a radial is open.
The service summons the overlay on the first frame, passing that frame as the
payload so the wheel is right on its first paint, and forwards the rest with
callIfLoaded. That indirection exists because Omarchy loads overlays through
the shell rather than exposing them to other plugins.
The daemon decides everything; the overlay only draws. Which petal the stick is on, when to descend, what activate runs — all of it is in Lua, which keeps the input semantics in one place and makes the whole interaction testable with synthetic stick positions and no screen at all.
LuaJIT was chosen because omarchy plugin add is a git clone with no build
step — it validates a manifest and copies files, and never runs install hooks.
A compiled daemon would mean committing binaries or shipping a separate package.
Writing the daemon in Lua also collapses the config question: the config language
and the host language are the same, so ctrl is just a table the daemon leaves
global before loading your files. LuaJIT specifically, for its FFI — plain Lua
5.5 has no way to call ioctl.
| Path | Role |
|---|---|
CLAUDE.md |
Working agreement, and the Omarchy plugin conventions this repo follows |
manifest.json |
Plugin id, kinds, entry points, widget settings |
Service.qml |
Supervises the daemon, and hosts the radial socket |
BarWidget.qml |
Connected pad and the layer being held |
Overlay.qml |
Draws the radial menu |
bin/omarchy-controllerd |
Entry point and CLI |
lua/controllerd/evdev.lua |
FFI bindings to the Linux input ABI: input_event, EVIOCGABS, non-blocking reads, poll |
lua/controllerd/hidraw.lua |
Non-blocking raw HID transport for Bluetooth microphone reports |
lua/controllerd/discover.lua |
Finding pads and retaining the DualSense sibling set needed for safe dictation |
lua/controllerd/device.lua |
Selects evdev or raw DualSense input, with evdev fallback |
lua/controllerd/dualsense_protocol.lua |
Clean-room Bluetooth report validation, decoding and microphone gating |
lua/controllerd/dualsense.lua |
Raw DualSense controls, audio routing and temporary evdev isolation |
lua/controllerd/axes.lua |
Ranges, calibration, radial deadzone, response curve, trigger hysteresis |
lua/controllerd/codes.lua |
Positional names and brand aliases |
lua/controllerd/engine.lua |
Layers, activators, sequences, chords, per-app resolution |
lua/controllerd/mode.lua |
control / dictating / passthrough state machine |
lua/controllerd/dictation.lua |
Transaction joining the controller, PipeWire and voxtype |
lua/controllerd/opus.lua |
48 kHz mono microphone decoder through libopus |
lua/controllerd/pipe_source.lua |
Temporary PipeWire source, default restoration and crash lease |
lua/controllerd/actions.lua |
key / dispatch / exec and friends |
lua/controllerd/hypr.lua |
Hyprland IPC: request socket and event stream |
lua/controllerd/config.lua |
package.path, loading user Lua, starter generation, rollback |
lua/controllerd/api.lua |
The ctrl global |
lua/controllerd/watch.lua |
inotify, for hot reload |
lua/controllerd/clock.lua |
CLOCK_MONOTONIC, for deadlines |
lua/controllerd/radial.lua |
Wheel geometry and menu-tree walking |
lua/controllerd/ui.lua |
The radial socket client |
lua/controllerd/state.lua |
The JSON state channel, on stdout and in a watched file |
lua/controllerd/log.lua |
Levelled logging to stderr |
default/controller/ |
Shipped defaults |
starter/ |
Copied to ~/.config/omarchy/controller/ on first run |
tests/*_test.lua |
344 headless tests covering engine, UI state, protocol, audio and rollback |
Normal controller operation needs no extra permissions: reading a pad and driving the desktop both turn out to be free.
Reading. systemd's own 70-uaccess.rules already grants the logged-in seat
user an ACL on every joystick:
SUBSYSTEM=="input", ENV{ID_INPUT_JOYSTICK}=="?*", TAG+="uaccess"
Confirm it on your own pad:
getfacl /dev/input/event25
# user:you:rw-Writing. Output goes through the compositor rather than a synthetic input
device, so there is no /dev/uinput to unlock, no udev rule to install, and no
input group to join.
Wireless DualSense dictation is the exception. Bluetooth microphone frames are
carried in proprietary hidraw reports, so the daemon also needs read/write
access to the controller's /dev/hidraw* node. On Arch, the steam-devices
package normally installs the required udev rules:
sudo pacman -S steam-devices.
Note that only the pad itself is uaccess-granted. The DualSense Touchpad and
Motion Sensors devices, and xpadneo's Mouse and Keyboard devices, are
root:input 660 — unreadable by a default user. Normal control mode never reads
them: they belong to libinput, which is exactly what makes
mouse control work. Wireless DualSense dictation must
temporarily grab its Motion Sensors and Touchpad siblings as well as the pad, so
that path additionally requires input-group membership or equivalent ACLs. It
fails before enabling the microphone if any sibling cannot be isolated.
Hyprland 0.56 embeds a Lua VM and exposes it over IPC, so any process can run Lua inside the compositor:
printf 'eval hl.dispatch(hl.dsp.send_shortcut({ mods = "", key = "Escape" }))' \
| socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sockThat is the same hl global your ~/.config/hypr/bindings.lua uses. Two verbs
matter: eval runs Lua for its side effects, and repl runs Lua and returns the
result — which is how the daemon reads compositor state such as the focused
window.
The daemon talks to the socket directly rather than shelling out to hyprctl,
which keeps a keypress at roughly 50 µs instead of forking a process per event.
hl.dsp.send_shortcut delivers to whatever surface holds keyboard focus — not
just windows. Layer-shell surfaces (the Omarchy menu, the emoji picker, a
launcher) receive keys correctly, which is what lets the controller drive
Omarchy's own UI rather than only the apps inside it.
Focus changes arrive on .socket2.sock as activewindow>>class,title, so
per-app bindings need no polling.
ctrl.key sends keys to the focused application. It does not re-enter
Hyprland's keybind layer:
ctrl.bind("r1 + south", ctrl.key("SUPER + SPACE")) -- does NOT open the launcherSUPER + SPACE is delivered to the focused app, which ignores it. Your
bindings.lua is never consulted. (Verified: a SUPER+F13 bind registered and
visible in hyprctl binds does not fire when sent this way.)
Name the action directly instead. Omarchy's own binds are mostly omarchy-*
commands you can reuse verbatim:
ctrl.bind("r1 + south", ctrl.exec("omarchy-menu toggle apps")) -- works
ctrl.bind("r1 + east", ctrl.dispatch("window.close()")) -- worksUse ctrl.key for its real job: talking to the focused application — Return,
Escape, arrows in a TUI, CTRL + o in nvim.
A pad is any input device advertising BTN_GAMEPAD. That test matters. A
DualSense presents three devices and an Xbox pad under xpadneo presents four:
DualSense Wireless Controller ← BTN_GAMEPAD, bound
DualSense Wireless Controller Motion Sensors ← also has a js* node
DualSense Wireless Controller Touchpad ← libinput's
Xbox Wireless Controller ← BTN_GAMEPAD, bound
Xbox Wireless Controller Mouse ← libinput's
Xbox Wireless Controller Keyboard
Xbox Wireless Controller Consumer Control
Because the Motion Sensors device also carries a js* node, joystick-node
presence alone would match the wrong thing.
Discovery re-runs every 2 s. Bluetooth pads drop and reappear on their own, so scanning once at startup is not enough. When a pad vanishes mid-press its held buttons are cleared, so a modifier layer cannot be stranded on.
Absorbed in the device layer, so bindings never see them:
| DualSense | Xbox (xpadneo) | |
|---|---|---|
| Stick range | 0..255, unsigned |
signed 16-bit |
| Resting centre | not the midpoint — measured 129/127/127/132 | near centre |
| Kernel deadzone | none (flat=0) |
none |
| Digital triggers | yes (BTN_TL2/BTN_TR2) |
no — analogue only |
Consequences, all handled:
- Every axis is normalised against its own
EVIOCGABSrange. Nothing assumes a scale. - The resting value reported when the pad is opened is taken as centre, and the two sides are scaled independently. Assuming the midpoint leaves the cursor drifting forever.
- The kernel supplies no deadzone, so the daemon owns it. The deadzone is radial — applied to the stick vector, not per axis, so the dead area is a circle. A per-axis deadzone leaves a square hole that passes pure diagonals while blocking the same magnitude along an axis.
l2/r2are derived fromABS_Z/ABS_RZwith separate on and off thresholds on pads without digital triggers, so a trigger held near the edge cannot chatter. Pads that report them natively keep their own timing.
One poll() owns everything: the controller fds, Hyprland's event stream, and an
inotify fd for the config. The loop sleeps until something happens, so an idle
controller costs no wakeups.
The sleep is bounded by the nearest pending deadline — a long press, a double window, a sequence expiry — so timed activators still fire on a pad sitting perfectly still. With nothing pending, the only wakeup is the 2 s device rescan.
Deadlines use CLOCK_MONOTONIC. Wall-clock time would move under a clock
adjustment, and os.clock measures CPU time, which barely advances in a process
that spends its life blocked in poll.
An inotify watch on the config directory triggers a reload, debounced by 150 ms because editors write in bursts.
A reload evaluates into a fresh builder and a fresh set of Lua modules. If anything raises, the previously loaded bindings stay live and the error is logged. A typo must never leave you holding a dead controller — which matters more than usual when the controller may be how you are driving the machine.
The daemon's own modules are deliberately not reloaded; swapping code out from under the running loop is not worth the risk.
| Path | Cost |
|---|---|
| evdev read → engine | microseconds; non-blocking, drained until EAGAIN |
| engine → Hyprland | ~50 µs; one unix-socket round trip, no fork |
tap-only binding |
fires on key-down, no timer |
For comparison, forking hyprctl per event costs 5–15 ms, which is why the
daemon speaks to the socket itself.
It already works, and this plugin does not implement it. Both pads present a real pointer device that libinput and Hyprland consume directly:
- DualSense — the touchpad is a genuine clickable touchpad
(
INPUT_PROP_POINTER+INPUT_PROP_BUTTONPAD, withBTN_LEFT). Hyprland holds it asdualsense-wireless-controller-touchpad. - Xbox with xpadneo — a full mouse:
REL_X,REL_Y, both scroll axes, and eight buttons.
Check yours with hyprctl devices, and tune them with
ctrl.device, which passes straight through to Hyprland.
Synthesising pointer input was investigated and rejected. Hyprland can move the
cursor (hl.dsp.cursor.move) but cannot synthesise mouse buttons — verified
against both a window and a layer surface, with send_shortcut and
send_key_state, using mouse:272 and BTN_LEFT. Doing it ourselves would have
meant uinput, and with it a udev rule and a sudo step, to reimplement what
the kernel already provides.
for test in tests/*_test.lua; do luajit "$test" || exit 1; done
OMARCHY_CONTROLLER_LOG=debug ./bin/omarchy-controllerd # watch bindings fireThe tests drive the state machines with synthetic timestamps and feed packet fixtures into the raw adapter. Long presses, CRC rejection, Opus decoding and failure rollback are therefore testable without a controller or screen.
Useful while working:
# what pads does the kernel see?
for d in /sys/class/input/event*; do echo "$(basename $d) $(cat $d/device/name)"; done
# does this device advertise BTN_GAMEPAD (bit 304)?
cat /sys/class/input/event25/device/capabilities/key
# axis ranges, deadzones and resting values
luajit -e 'package.path="lua/?.lua;"..package.path
local e=require("controllerd.evdev"); local fd=e.open("/dev/input/event25")
for _,a in ipairs{0,1,2,3,4,5} do local i=e.absinfo(fd,a)
if i then print(a, i.minimum, i.maximum, i.flat, i.value) end end'
# talk to Hyprland the way the daemon does
printf 'repl return hl.get_active_window().class' \
| socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sockomarchy plugin add /path/to/omarchy-controller-support --enable --yes # a local path clones fine
omarchy plugin update io.github.bruce-forte.controller-support --yes # pull later commits
omarchy plugin remove io.github.bruce-forte.controller-support
omarchy-plugin-validate .
qmllint -I "$OMARCHY_PATH/shell" Service.qml BarWidget.qml
journalctl --user -f | grep controllerd # daemon logs, relayed by the serviceplugin add clones, so only committed work is installed.
The radial can be driven by hand, without a controller — useful when working on how it looks:
ID=io.github.bruce-forte.controller-support
FRAME='{"event":"radial","items":[{"label":"One","submenu":false},{"label":"Two","submenu":true}],"selected":1,"depth":0}'
omarchy-shell shell summon $ID "$FRAME" # draw a wheel
omarchy-shell shell call $ID applyFrame "$FRAME" # update it
omarchy-shell shell call $ID applyFrame '{"event":"radial-close"}'
# or push a frame straight down the socket the daemon uses
printf '%s\n' "$FRAME" | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/omarchy-controller-ui.sockReload rules.
| Edited | Takes effect |
|---|---|
~/.config/omarchy/controller/*.lua |
On save |
lua/ in a checkout |
--stop, then run again |
lua/ in the installed plugin |
Saving reloads the plugin, which restarts the daemon |
Service.qml / BarWidget.qml |
omarchy restart shell |
Note that qmllint cannot resolve the BarWidget base type, so it will not
catch a property clashing with one of QQuickItem's — layer is FINAL there,
and that only shows up as Cannot override FINAL property in the shell log at
runtime.
Nothing fails silently. A controller button has nowhere to print, so anything that goes wrong — a config that will not load, a binding skipped during a load, an action that fails, a dependency that is not running — is raised as a desktop notification naming the problem and, where there is one, the command that fixes it. Repeats are throttled per problem, so a failing binding pressed repeatedly cannot bury the desktop. A config that will not load leaves your previous bindings live, so the controller keeps working while you fix it.
| Symptom | Cause and fix |
|---|---|
| Nothing happens on any button | Run with OMARCHY_CONTROLLER_LOG=debug. No controller connected line means the pad was not found — see the next row |
controller connected but no fire lines |
The button is unbound in the active layer, or a modifier you are holding selected a layer where it is not bound. debug prints the layer with each fire |
fire appears but nothing happens |
The action ran and its target ignored it. Most often ctrl.key used for a window-manager action — see What ctrl.key does not do |
| Controller not detected | It must advertise BTN_GAMEPAD. List devices with the snippet above; if it is listed but unreadable, your seat lacks the uaccess ACL — check getfacl /dev/input/eventN |
| Xbox pad connects then drops, logo flashing | Bluetooth ERTM. Install xpadneo-dkms, then echo 'options bluetooth disable_ertm=1' | sudo tee /etc/modprobe.d/99-xpadneo-bluetooth.conf, reboot, re-pair |
x does something unexpected |
It is the Xbox X button, which sits west. Use cross or south for PlayStation's ✕ |
| Config changes ignored | The daemon logs config reloaded on every save. config reloaded failed means your previous bindings are still live and the message names the error |
already running as pid N |
A daemon is already up. --stop it first |
| Every binding fires twice | Two daemons. Should be impossible via the guard, but check --status and pgrep -x luajit |
| A trigger fires repeatedly | Only on pads with derived triggers. Widen the gap between the on and off thresholds |
| Buttons stuck after the pad disconnects | Should self-clear on the 2 s rescan. If not, restart the daemon |
| Widget missing from the bar | omarchy plugin list | grep controller — it must be enabled. The widget hides itself when no pad is connected unless showWhenDisconnected is set |
Cannot override FINAL property in the shell log |
A widget property collides with one of QQuickItem's (layer, state, data, …). Rename it; qmllint will not catch this |
| A binding does nothing | It should have notified — failed actions, broken config and skipped bindings all raise a desktop notification. If nothing appeared, the press never reached the daemon: check journalctl --user -f | grep controllerd |
| "Dictation is not available" | voxtype is installed but not running. omarchy-voxtype-install downloads the model and enables the service |
| Daemon keeps restarting | journalctl --user -f | grep controllerd. The service backs off to 30 s, so the log names the reason each time |
| Radial does not appear | journalctl --user -f | grep radial — the service logs when it opens. No line means the press never reached the daemon; a line but no wheel means the overlay failed to load |
| Radial appears but the highlight lags or jumps | The stick is evaluated once per evdev frame. If a petal is hard to hit, raise deadzone |
| Flicking straight through two submenus | Descending re-arms only at the centre, so this should not happen. If a petal is entered unintentionally, raise deadzone |
| Radial opens instead of the layer, or vice versa | A button cannot be both a modifier and a radial. Drop it from ctrl.modifiers |
| Widget shows a pad that is not connected | A stale state file from a daemon that was killed. The service clears it on exit; omarchy restart shell fixes it |
omarchy plugin remove io.github.bruce-forte.controller-support
rm -rf ~/.config/omarchy/controller # your config, if you want it goneRemoving the plugin stops the daemon with it and takes the widget off the bar.
There is no state directory to clean up: everything this plugin writes is either
your config (above) or lives in $XDG_RUNTIME_DIR and disappears with the
session. Plugins that keep caches, virtualenvs or build output put them in
${XDG_STATE_HOME:-~/.local/state}/<plugin-id>/ and would need that removed too;
this one has none.
Nothing else needs undoing. The daemon installs no system files, adds no udev
rules, changes no group memberships, and writes nothing outside
~/.config/omarchy/controller/ and a pidfile in $XDG_RUNTIME_DIR.
Phases 1–3 are done — the daemon, the shell plugin, and radial menus — along with the layer HUD from phase 4. What remains is rumble and lightbar feedback, daisywheel text entry, per-workspace profiles, and stick-as-mouse, the one item from the original brief that does not exist.
ROADMAP.md has the detail, including what was deliberately not
built and why, and what is built but not yet verified on hardware.
The daemon runs unsandboxed as your user, and your config is executed as Lua —
the same trust model as ~/.config/hypr/bindings.lua.
| Touches | What |
|---|---|
| Reads | /dev/input/event*, but only devices advertising BTN_GAMEPAD |
| Reads | /sys/class/input/*/device/{name,capabilities/key} for discovery |
| Connects | Hyprland's .socket.sock and .socket2.sock |
| Reads & writes | ~/.config/omarchy/controller/ |
| Writes | $XDG_RUNTIME_DIR/omarchy-controllerd.pid and omarchy-controller.state |
| Never writes | inside its own plugin directory — Omarchy watches that and reloads the shell on any write |
| Connects | $XDG_RUNTIME_DIR/omarchy-controller-ui.sock, the shell's radial socket |
| Never | opens a network connection, requires elevated privileges, or installs anything outside your home directory |
Because it speaks to Hyprland's Lua VM, a binding can run any command your user can. That is the point — it is exactly the power your Hyprland config already has — but it means a controller config written by someone else deserves the same reading you would give a shell script before running it.
Apache-2.0. See LICENSE.