Arrange monitors on Omarchy Quattro the way macOS Displays > Arrange does it.
Displays is a normal window. Each monitor is a rectangle. Drag one to where it sits on your desk and it snaps flush against its neighbor. Pick a resolution, refresh rate, and scale per monitor. Press Apply, then answer Keep these settings? within 15 seconds or the old arrangement comes back on its own.
| Step | What happens | What is written |
|---|---|---|
| Open | Reads hyprctl monitors all -j and monitors.lua |
Nothing |
| Drag, pick scale or mode | Edits the arrangement in the window only | Nothing |
| Preview | Shows the exact Lua for apply, revert, and save | Nothing |
| Apply | Starts the revert watchdog, then hyprctl eval with the new rules |
Nothing |
| Keep | Saves the rules, then stops the watchdog | monitors.lua plus a backup |
| Revert or timeout | hyprctl reload, then waits until hyprctl monitors shows the old layout |
Nothing |
| Closing the window while a layout is live | The watchdog reverts at once | Nothing |
Apply is live-only. ~/.config/hypr/monitors.lua is not touched until you
press Keep, so hyprctl reload or logging out always returns to the saved
arrangement.
Because monitors.lua is untouched until Keep, a revert is
hyprctl reload. That restores every option your rules set, not just mode,
position, and scale. Only if the reload fails does Displays fall back to
hyprctl eval with the pre-Apply layout by connector name.
A revert is reported only after it ran and hyprctl monitors shows the
layout from before Apply (it waits up to 5 seconds). If the reloaded layout
differs from what was live before Apply, the window says what differs.
The 15-second revert does not depend on the window. Before the layout
changes, Displays starts a small detached sh process with the same
reload-then-eval revert. It fires 17 seconds after Apply unless Keep has
saved the file or the window already reverted. If the new layout takes down
the screen, the panel, or the whole shell, the watchdog still reverts.
While a watchdog from an earlier Apply is still armed, Apply waits.
Its token files live in $XDG_RUNTIME_DIR/omarchy-displays/. That folder is
owner-only (mode 700). Displays refuses it if it is a symlink or not yours.
Each token file is created with a private temp name and renamed into place,
so a symlink someone left at the token path is replaced, never written
through.
hyprctl eval output is read. Any error line reverts at once and is
shown in the window. A clean eval does not prove Hyprland took each mode,
so Displays also compares hyprctl monitors with the layout it asked for
and shows any difference while you decide.
Keep adds one managed block to the end of monitors.lua, in the same
hl.monitor({ ... }) format Omarchy uses:
-- omarchy-displays: begin
-- Written by the Displays plugin. Edits inside this block are overwritten.
-- Delete the whole block to fall back to the rules above it.
hl.monitor({ output = "desc:Dell Inc. DELL U2719D", mode = "2560x1440@60", position = "0x0", scale = 1 })
hl.monitor({ output = "desc:Samsung Electric Company U28H75x", mode = "3840x2160@30", position = "2560x0", scale = 1.5 })
-- omarchy-displays: end- Everything outside the block is kept byte for byte: your comments,
variables, workspace rules, and
GDK_SCALE. - The block goes last because Hyprland lets the last matching monitor rule win.
- Saving again replaces the block. It never stacks.
- A refresh rate your own rule writes is kept when it is within 0.1 Hz of
the chosen mode:
@60stays@60on a 59.95 Hz panel. - Displays are matched by panel (
desc:), not connector, so a display that moves fromDP-1toDP-2keeps its place. If your own rules already name a selector for a display, the block reuses it. Identical twin displays fall back to connector names. - The previous file is copied to
monitors.lua.bak.<unix time>first. The newest 5 backups are kept. - If the file changed after Displays read it, or the save fails for any reason, nothing is saved, the watchdog stays armed, and the window asks again: Revert, or keep the layout live without saving.
- Keep only ever writes
~/.config/hypr/monitors.lua. A summon payload cannot point it at another file. - The new file is written to a private temp file next to the target and renamed over it. The old file's permissions are kept.
- Files over 120 KiB are not read or saved (the Linux single-argument limit is 128 KiB).
- A symlinked
monitors.luastays a symlink. Its target must be a regular.luafile you own, or nothing is saved.
- Omarchy Quattro with the Quickshell shell and Hyprland (Lua config).
- No sudo or pkexec is required. No network access.
- Runs
hyprctl monitors all -j(read),hyprctl eval(on Apply, and as the fallback revert), andhyprctl reload(the revert). - Writes
monitors.luaand its backups on Keep, and short-lived token files under$XDG_RUNTIME_DIR/omarchy-displays/. Nothing else. - Disabled and mirrored outputs are listed but never changed.
- Rotation (
transform) is preserved, not editable.
omarchy plugin add https://github.com/stevederico/omarchy-displays.gitThe plugin lands disabled. Read the code and try it the safe way (below)
before trusting it with your real monitors.lua. Then:
omarchy plugin enable io.github.stevederico.omarchy-displaysEnabling loads nothing. The plugin only runs while its window is open.
Local checkout (copy the repository root, not a symlink):
PLUGIN_ID="io.github.stevederico.omarchy-displays"
PLUGIN_DIR="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
mkdir -p "$PLUGIN_DIR"
cp -a ~/Projects/omarchy-displays/manifest.json \
~/Projects/omarchy-displays/plugin \
~/Projects/omarchy-displays/README.md \
~/Projects/omarchy-displays/LICENSE \
"$PLUGIN_DIR/"
omarchy plugin validate "$PLUGIN_DIR"
omarchy plugin enable "$PLUGIN_ID"- Have a way back that needs no screen, like an SSH session:
hyprctl reloadrestoresmonitors.lua - Open in dry run on a scratch copy. Apply only shows the plan
- Reopen without dry run and try a small move. Let the countdown expire once, press Revert once
- Keep. Your file is copied to
monitors.lua.bak.<unix time>first, so compare against that
omarchy-shell shell summon io.github.stevederico.omarchy-displaysDry run. Apply shows the plan and changes nothing:
omarchy-shell shell summon io.github.stevederico.omarchy-displays '{"dryRun":true}'Dry run against a scratch copy instead of the real monitors.lua.
monitorsFile is honored only together with dryRun. Without it, Displays
always uses ~/.config/hypr/monitors.lua:
cp ~/.config/hypr/monitors.lua ~/monitors-try.lua
omarchy-shell shell summon io.github.stevederico.omarchy-displays \
'{"dryRun":true,"monitorsFile":"'"$HOME"'/monitors-try.lua"}'Print the current plan from a terminal while the window is open:
omarchy-shell shell call io.github.stevederico.omarchy-displays planText ''| Control | Action |
|---|---|
| Drag a display | Move it. It snaps to the nearest free edge |
| Click a display | Select it for the settings below |
| Left of / Right of / Above / Below | Place the selected display beside another |
| Reset | Back to the arrangement that is live now |
| Preview | Show the exact Lua without applying |
| Apply | Apply live and start the 15-second countdown |
| Return | Keep, while the countdown runs |
| Esc | Revert while the countdown runs, otherwise close |
To add a Setup → Displays menu row, merge the setup.displays object from
extra/omarchy-menu-displays.jsonc into your existing
~/.config/omarchy/extensions/omarchy-menu.jsonc. Do not replace that file.
To add an application-launcher entry:
cp extra/sd.displays.desktop ~/.local/share/applications/To float the window instead of tiling it, merge
extra/omarchy-displays-window.lua into ~/.config/hypr/hyprland.lua.
The window opens on the active workspace of the focused monitor, like any
new window. If it lands somewhere else, a hook in your Hyprland config is
moving new windows off that workspace. The same file has an
is_displays_window(win) check to skip it there.
Delete the omarchy-displays block from ~/.config/hypr/monitors.lua, or
restore a backup:
ls ~/.config/hypr/monitors.lua.bak.*
cp ~/.config/hypr/monitors.lua.bak.<unix time> ~/.config/hypr/monitors.luaHyprland reloads on save.
PLUGIN_ID="io.github.stevederico.omarchy-displays"
omarchy-shell shell hide "$PLUGIN_ID"
omarchy plugin disable "$PLUGIN_ID"
omarchy plugin remove "$PLUGIN_ID"If installed, manually remove the optional menu object, desktop file, window
rule, and the managed block in monitors.lua.
node --test tests/test_displays.js
omarchy plugin validate .The tests never talk to Hyprland. Every script that would call hyprctl is
run against a stub that only records its arguments, and every file write
happens in a temp folder.
qmllint needs an import path where the shell is reachable as qs:
lint_dir=$(mktemp -d) && ln -s "$OMARCHY_PATH/shell" "$lint_dir/qs"
/usr/lib/qt6/bin/qmllint -I "$lint_dir" -I "$OMARCHY_PATH/shell" \
plugin/sd.displays/run/Displays.qmlThe tests exercise the same plugin/sd.displays/run/DisplaysLogic.js module
the window imports. tests/fixtures/ holds a hyprctl monitors all -j
capture (serial numbers replaced) and a monitors.lua.
MIT