Linked workspaces for a dual-monitor Omarchy setup #8785
Chimmy89
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
omarchy-linked-workspaces-writeup.md
Linked workspaces for a dual-monitor Omarchy setup (+ two gotchas that took a while to track down)
I run two screens (a primary monitor + a secondary display) and wanted
SUPER+Nto flip both screens to "the same" workspace at once, instead of the stock 1–5 / 6–10 split where each screen owns its own fixed set. Hyprland doesn't support one workspace spanning two monitors natively (confirmed in hyprwm/Hyprland#10088 — "you can't do that"), so this fakes it by pairing two real workspaces and flipping them together. There's an existing simple version of this idea (Omarchy-Two-Monitor-Workspace-Switch-for-Hyprland), but it's fixed at 5 pairs, uses old-style.confbinds, and always ends focus on the second monitor regardless of which screen you were using. This version:SUPER+1..SUPER+0)That last point is the reason for the post — I hit two real bugs building this, both with symptoms that only show up under fast switching, and I couldn't find either documented anywhere. Writing them up in case they save someone else the debugging time.
The setup
1. Pin the raw workspaces to their monitors (
hypr/monitors.lua):2. The linking logic (
hypr/linked-workspaces.lua,required fromhyprland.lua):3. Optional: rename a pair by hand. A small
SUPER+SHIFT+ALT+Nbinding that pops agum inputprompt and renames both halves:If some pairs should always be visible in your bar regardless of whether they're occupied, give both their raw halves
persistent = truetoo, or Hyprland will tear down and recreate the empty half, dropping whatever name you gave it:4. Optional: a bar widget that shows pairs instead of raw workspace numbers. Clone
omarchy.workspaces(omarchy plugin clone omarchy.workspaces) and replace its logic with something like:Three gotchas, in order of how long they took to find
Gotcha #1:
hl.dsp.focus({monitor = name})isn't a safe way to "restore" focusMy first version of
sync_other_monitorcapturedhl.get_active_monitor()'s name before doing anything, moved the other monitor, then re-focused the captured name to "restore" where I was. Under fast switching this let the two screens end up on genuinely different pairs — caught it live: one screen on pair 7, the other still on pair 1.The capture reads live, mutable state (
hl.get_active_monitor()) that can be mid-flight from an overlapping call. Turns out it's also unnecessary: the caller's own final dispatch (show()'s last line) already refocusesn + offset, which is deterministically the correct monitor+workspace sinceoffsetwas captured once at the top of that call. Dropped the capture-and-restore step entirely; the deterministic restore was already doing the same job without reading anything racy.Related: if you have
input.follow_mouseon (Hyprland's default), also setcursor:no_warps = true. Everyhl.dsp.focus()call warps the cursor to its target by default, and with follow_mouse on, that warp immediately re-steals focus onto whatever's under the cursor there — corrupting the very next dispatch in the same sequence. This bit window moves specifically (SUPER+SHIFT+N), sending windows to the wrong screen or the wrong tile slot.Gotcha #2: a dispatch reporting "ok" doesn't mean the compositor's state has committed yet
This one was the strange one. Even with #1 fixed, fast switching (real key presses, not scripted) could still occasionally split the two monitors onto different pairs. I could not reproduce it reliably through
hyprctl evalcalls with any gap between them — only truly rapid, back-to-back calls triggered it.Stress-tested it directly: calling
show()for a list of pairs back-to-back with zero gap between calls failed ~90% of the time over dozens of trials. Every dispatch reportedok. Inserting a plain 10mssleepbetween calls — no other change — dropped that to 0/40 desyncs, including under a deliberately adversarial parallel-dispatch burst that had failed heavily before. A pure Lua-level reentrancy guard (preventing the same function from running twice at once) did not fix it on its own, which is what pointed at timing rather than call-stack overlap:hyprctl's own docs already note that dispatch calls are meant to be spaced out or batched (--batch) rather than spammed individually, for exactly this class of reason.Fix:
show()'s busy flag isn't really about preventing re-entrancy — it enforces a minimum real-time gap (20ms, 2x the empirical threshold, for margin) after each call before the next is allowed to run, usinghl.timer(fn, {timeout=20, type="oneshot"})(non-blocking — a plainsleephere would freeze the whole compositor for the duration, since this runs inside a keybind callback). Anything that arrives during that window gets coalesced intoswitch_pendingand replayed once the timer fires, so a fast burst just jumps straight to wherever you actually meant to end up.Real key-repeat is typically 25–50ms between events, comfortably above the 20ms window, so this shouldn't be noticeable in normal use — it only engages under bursts faster than a human can plausibly produce by hand.
Gotcha #3: in Quickshell/QML, prefer real properties over plain functions for anything reactive
After both of the above were fixed, the bar's highlight itself got stuck showing a stale pair — permanently, while the rest of the bar (clock, tray) kept updating fine. Confirmed with a screenshot that this was purely cosmetic: Hyprland's actual state was correct throughout.
The widget had
activePair()andpairIds()as plain JS functions, called from inside other property bindings (focused: root.activePair() === modelData). That's a binding depending on a function that itself reads a property — two layers removed from the actual reactive source (Hyprland.focusedWorkspace). QML's automatic dependency tracking through nested function calls is less reliable than direct property-to-property bindings; something about that chain let the binding permanently stop re-subscribing.Fix: converted both to real QML properties (
readonly property int activePair: {...},readonly property var pairIds: {...}) instead of functions, so Hyprland's own property-change notifications drive them directly. Verified with actual screenshots across multiple rounds of rapid switching, landing on a different pair each time — not just "looks fixed once."Happy to answer questions or help adapt this to a different monitor count/layout. The full config is above; swap in your own
hyprctl monitors -jdescriptor strings and you should be good to go.All reactions