Skip to content

Make multiple displays just work: arrangement, scaling and per-display workspaces - #638

Open
oliverlukschander wants to merge 25 commits into
omacom:quattro-upstreamfrom
oliverlukschander:feat/multi-monitor-qu
Open

oliverlukschander wants to merge 25 commits into
omacom:quattro-upstreamfrom
oliverlukschander:feat/multi-monitor-qu

Conversation

@oliverlukschander

@oliverlukschander oliverlukschander commented Sep 27, 2026 •

Copy link
Copy Markdown

This is my suggestion for making multi-display setups on Omarchy Mac pleasant to use. It follows up on what we talked about on Discord. It's a proposal, not a finished decision, so I'm very happy to discuss any part of it, here, on Discord or by mail (oliver@lukschander.com), and to reshape or split it however suits you.

Summary

Plug in a display and it just works: it stays where you put it, it's sized to match, and each screen gets its own workspaces.

  • The arrangement is remembered for every combination of displays. Displays are recognised by their EDID (model and serial), so the port doesn't matter.
    • Plugging in or unplugging doesn't move the screens that stay on, except to close a gap.
    • A new display joins to the right of main, bottoms flush.
  • Monitor panel:
    • Drag displays to arrange them. A display dropped past the middle of another goes to that side, and an outline shows where it will land.
    • The ★ picks main, and clicking a display shows its number on it.
    • Every display has its own scale row, so the panel is the same on every screen.
    • A display turns from its row: Standard, 90°, 180° or 270°, remembered for that display.
  • Scale:
    • A new display gets the scale at which text is as many millimetres tall as on main.
    • Linked displays (the default): changing any display takes the others to the same real size, and pointing at a scale shows where they'll land.
    • Per display: each display keeps its own scale. The mode stays until it's changed.
    • Super + / and Super + Alt + / step the scale, and Super + Ctrl + / matches everything to main. GDK_SCALE follows main.
  • Workspaces per display:
    • Each display owns block * 10 + 1..10.
    • Super + 1..0, Super + Shift + 1..0, Super + Tab, the wheel and the swipe act on the focused display.
    • Super + D, then a number, sends the window to that display; Super + Ctrl + Alt + Arrow sends it in that direction.
    • With just the laptop, workspaces are 1–10 as before.
  • A display that goes away: its windows move to main's front workspace, and go home when it returns.

How

  • default/hypr/displays.lua does the work. displays/model.lua holds the pure arrangement and scale maths, tested headlessly, and displays/store.lua keeps ~/.local/state/omarchy/displays.json.
  • One hl.monitor rule per display: desc: for externals, the connector for the panel. Rules are diffed, so an unchanged display isn't modeset, and the layout is re-checked on monitor.layout_changed.
  • omarchy-hyprland-monitor-scaling and -clamshell hand scaling to the module while it's loaded.

Compatibility

  • Hand-written rules win. A display with its own hl.monitor rule in the user's config is left alone, and those rules are re-sent after the module's. The same goes for a user's GDK_SCALE.
  • Opt-outs:
    • omarchy-hyprland-toggle displays-off on turns the module off.
    • display-workspaces-off on keeps the arrangement and scaling, with global workspaces (for workspace plugins).
    • Mirroring turns the module off too.
  • Existing installs: migration 1790518456 comments out the stock GDK_SCALE lines of an unchanged monitors.lua.

Interplay with quattro-upstream

Since opening: what changed and why

Using it turned up the following. Each is its own commit, 6 to 25:

  • The Monitor panel closed whenever its display moved or rescaled. ScreenMoveRemap hides a moved monitor's bar for a moment, and a ModuleList was active only while visible, so every widget on that bar was rebuilt. It now follows an explicit shown.
  • Two displays side by side couldn't be swapped by dragging. The drop took the nearest free spot, and above or below the other display is always nearer than its far side. It now goes to the side the display is dragged past.
  • Linked / Per display flipped back when the panel closed, and changed with the desk. It was derived from which displays had been set by hand. It's now one remembered choice.
  • The panel depended on where it was opened. Linked had a scale row for that display only, and on an external without DDC there was no brightness at all. Now every display has a row, and brightness falls back to the built-in panel.
  • Rescaling one display made the others flicker and moved the pointer. The arrangement reflowed around main, so other displays moved along. Now only the display that's rescaled moves, and the pointer keeps its spot on its display.
  • Linked followed a laptop-to-desk factor learned from scales set by hand. Linked now means real size, and it comes out the same whichever display is set.
  • A display that came back at another scale lost its side. Layouts only stored positions. They now store sizes, and a layout is restored by side and alignment.
  • A display on its side couldn't be turned. The module already arranged and remembered a turned display, but only a hand-written rule could turn one, and that takes the display away from the module. A display now turns from its row in the panel, in place: it keeps its side, the others stay, and it comes back turned.
  • Also in the panel:
    • clicking one display identifies it alone;
    • pointing at a Linked scale previews where the other displays land;
    • each row highlights its display's current scale;
    • the panel grows with its displays instead of scrolling.

Testing

On hardware: a MacBook Pro 16" M2 Pro with a BenQ 27" 1440p on USB-C, Hyprland 0.56.2; for rotation, also a 27" 1080p BenQ standing on its side.

Before opening:

  • arranging by drag, and restoring per set;
  • Linked and Per display;
  • per-display workspaces and every binding;
  • the panel toggle and clamshell;
  • unplug and replug with windows on several workspaces;
  • a display coming back without EDID;
  • ten off/on cycles.

Hand-written monitors.lua rules and the two toggles are covered by the tests, but weren't tried on hardware.

For this update, retested live after the fixes:

  • the panel stays open while its own display rescales;
  • dragging swaps sides;
  • the mode survives closing the panel;
  • rescaling one display leaves the other steady and the pointer in place;
  • the preview matches the result, with the mouse and the keyboard;
  • Linked pairs are the same from either side (laptop 3 / BenQ 1.25, laptop 2.4 / BenQ 1.0);
  • the BenQ keeps its side through rescaling, and through unplug, rescale and replug;
  • Per display no longer changes Linked;
  • identify, and the brightness fallback;
  • turning the 1080p BenQ to 90° from its row, and its coming back turned.

Tests:

  • test/shell.d/hyprland-displays-test.sh runs the model, the store and the flows against a fake Hyprland.
  • Every fix has test cases. Those for the bar, the drop, the remembered mode, the brightness fallback, sizes in layouts and rotation were checked to fail without their fix.
  • The full suite ran when the PR was opened: 4669 checks against 4643 on an unmodified 351c9fb, with the same five files failing on both. On this head, CI's full shell suites pass, apart from files CI lists as known failures.

Known issue

After an unplug, Chromium windows from that display can draw at its old scale, in a corner of the window, until they're resized or toggled fullscreen.

Related kernel fixes (Aurora)

Open for discussion

  • Per-display workspaces: the default, Mac-only, or upstream in Omarchy too? Nothing in the module is Mac-specific.
  • Is the flag-file opt-out right for plugins, or would you prefer a Lua global?
  • The history: commits 7–25 can be folded into 2–5 if you'd rather review fewer commits.

Built with Claude Code over a longer session, with every behaviour tried on my own machine along the way. I can walk through any part of it in review.

🤖 Generated with Claude Code

oliverlukschander and others added 5 commits September 28, 2026 12:41
Clicking a display in the Monitor panel ran `hyprctl keyword monitor`,
which the Lua config refuses ("keyword can't work with non-legacy
parsers"), so nothing happened. The internal panel now goes through
omarchy-hyprland-monitor-internal, which persists the choice and which
clamshell respects; an external display gets a complete rule through
`hyprctl eval`. The connector name is checked before it is written into
Lua.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A display module (default/hypr/displays.lua) keeps the arrangement in one
piece, remembers it for every set of connected displays and puts it back
when the same set returns. Displays are recognised by their EDID
description, the internal panel by its connector. A new display joins to the
right of main, bottoms flush, at a scale derived from its pixel density so
text looks about as big as on main.

Scaling is linked by default: changing any display's scale moves the others
in proportion, the main display included. In per-display mode each display
is set by hand and the desk factor between laptop and desk monitor is
learned. GDK_SCALE follows main.

Each display also owns ten workspaces (block * 10 + 1..10). Windows of a
display that goes away move to main's workspace in front and go back when it
returns; monitor-removal.lua and the Mac swipe step aside while workspaces
are per display.

Hand-written hl.monitor rules and GDK_SCALE in monitors.lua are left alone
and kept the newest match. The displays-off and display-workspaces-off
toggles turn the module, or only its workspaces, off for users with their
own setup or a workspace plugin. A migration comments out the stock
GDK_SCALE lines of existing monitors.lua files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Super+1..0, Super+Shift+1..0, Super+Tab and the wheel act on the focused
display's own workspaces. Super+D, then a number, sends the window to that
display (numbered from the left). Super+/ and Super+Alt+/ step the scale,
Super+Ctrl+/ matches every display to main again. Super+Shift+Alt+arrows
are dropped in favour of the existing Super+Ctrl+Alt+arrows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Monitor panel gets a drag-to-arrange canvas that snaps displays edge to
edge, a star to choose main, Identify, Linked displays / Per display
scaling, and switches displays on and off through the module so they stay
put. Each display's bar shows that display's workspaces, the ones with
windows at full strength, with its number on a small monitor icon when
more than one display is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maralcbr

Copy link
Copy Markdown
Collaborator

Thanks, Oliver, this is a great proposal and clearly a lot of work. It overlaps the display contract that omacom#13362 has just introduced (displays.conf, platform files under /usr/share/omarchy-platform), so I'd like to work out together how it fits. Would you comment on omacom#13362 with how you'd see it slotting in?

@oliverlukschander

Copy link
Copy Markdown
Author

Thanks! I commented on omacom#13362 with how I'd see it slotting in, including a test on top of it: omacom#13362 (comment)

oliverlukschander and others added 17 commits September 28, 2026 20:40
ScreenMoveRemap hides a bar for a moment whenever its monitor moves, so the
compositor places it again. Items in a hidden Quickshell window read as
invisible, and a ModuleList was active only while visible, so every move
destroyed and rebuilt every widget on that bar, closing any panel open from
it. A display arrangement that reflows on each scale step made that the
Monitor panel closing after nearly every change it made.

A ModuleList now follows an explicit shown flag, which the center section
sets for the arrangement on show; the other one still builds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The arrangement put a dropped display at the free spot nearest its top-left
corner, and a spot above or below the other display is always about a
display height away while its far side is a whole width away. Two displays
side by side could hardly be swapped: dragging a BenQ over the laptop put it
below the laptop, and only a drag past the laptop entirely landed it left.
Distances now count in the two displays' sizes, so a display dragged past the
middle of another lands on its far side, and one dragged mostly up or down
lands above or below it.

While dragging, an outline shows where the display will land, and the tile
stays there after the drop instead of jumping back until the new arrangement
is read. Moves go through the panel's action queue, so one made while
another is still being applied is no longer dropped, and the panel reads the
arrangement again shortly after an action, once Hyprland has applied it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click on a display in the Monitor panel's arrangement shows that
display's number on it alone, and the tile is highlighted meanwhile, so
which tile is which screen is one click away. Identify still shows every
display's number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scale mode wasn't stored anywhere. It counted as Per display once any
connected display had been tuned by hand, and picking Per display in the
Monitor panel lasted only until the panel closed, so it flipped back, and
moved with the displays: a tuned monitor at one desk, an untuned one at
another.

The mode is now one remembered choice, scale_mode in displays.json, Linked
until Per display is picked. Linked, every display keeps in proportion to
main, whichever one changes. Per display, every display keeps the scale it
was given and comes back with it, main included; a display seen for the
first time still starts matched to main, and one set against main still
teaches the desk factor. Super+Ctrl+/ matches everything to main without
changing the mode; choosing Linked does the same. The tuned flag goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linked, the panel's one scale row was for the display it was opened on, so
setting another display's scale meant opening the panel over there; only
Per display listed a row for each display, and those rows couldn't be
reached from the keyboard. With the display module arranging two or more
displays, every display now has its own row, numbered like the arrangement,
in both modes, so the panel is the same whichever display it opens on. Each
row is a keyboard section: j/k move between displays, h/l between scales,
and the cursor starts on the row of the display the panel opened on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Monitor panel's brightness is the focused display's, and on a Mac an
external display can't be dimmed (there's no DDC), so opened on one the
panel said FIXED BRIGHTNESS and had no slider, while opened on the laptop it
had one. When the focused display can't be dimmed, omarchy-monitor-state
now reports the built-in panel's brightness, if it's on, and names the
display on a new last line; the panel sets that display and shows its number
beside BRIGHTNESS while there are two or more displays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scale change reflowed the arrangement around main, so rescaling main
moved every other display, and rescaling another display moved it along
with its neighbours. A display that moves has its bar and background
remapped, so it flickered, and Hyprland keeps the pointer's place in the
layout, so the pointer ended up elsewhere on the screen or was pushed off
into a gap.

The arrangement now reflows around a display that keeps its size, the one
under the pointer first, then main. Only the displays whose scale changes
move, and those are modeset anyway. When the display under the pointer
moves or changes size itself, the pointer is put back on the same spot of
it once the new rules have landed; that also keeps the pointer on the
Monitor panel while its own display is dragged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scale row reaches its delegate through the Repeater's modelData, which
hands its list of presets over as a QML list rather than a JS array, and
closestScaleIndex takes only arrays, so no row showed its display's current
scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The number in front of each scale row was smaller than the one in the
display list below and sat further left. It's now the same size, centred
in the same column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel was capped at 560 points, set before it listed an arrangement and
a scale row per display, so with two displays it already scrolled, and its
scroll bar lay over the scale buttons. Like the Bluetooth and network
panels, it now grows to its content up to the screen's height; a screen too
short for it still scrolls, with the content moved clear of the scroll bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Linked displays, picking a scale on one display moves the others, and
the Monitor panel only showed where they ended up afterwards. The display
module now has linked_preview(), what every display gets for each preset of
each display, worked out the way set_scale does it. The panel reads it with
its other state, and while a scale is under the pointer or the keyboard
cursor, each other row marks the scale its display would land on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The buttons in each scale row came right after the display's number, closer
than the names in the display list below come after theirs. They now start
in the same column as those names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside the BRIGHTNESS heading the display's number sat on the heading's line
box, higher than its capitals and much taller than them. It now stands in
front of the slider, in the column the scale rows keep their numbers in,
with the slider starting where their scales do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linked displays matched a display to main through a desk factor, which
started where macOS does (things about 15% larger on a desk monitor) and was
learned from every scale set per display. So what Linked kept depended on
what had last been set by hand, and on which display was changed: main was
the reference, and setting another display moved main to whatever would
derive it, rounded twice.

Linked now means real size: every display shows things as many millimetres
tall as the one being set, worked out from both displays' pixels per inch
and matched to it directly, whichever display that is. A display seen for
the first time starts at the real size of main. Per display is where a desk
monitor gets things larger, and it no longer teaches anything; the desk
factor, its learning and its field in displays.json go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A remembered arrangement held each display's top-left spot but not its
size. A display coming back at another scale, matched to a main display
rescaled meanwhile, no longer fitted that spot: bigger, it overlapped its
neighbour, and it went where a display seen for the first time goes, right
of main, losing its side.

Layouts now keep each display's size with its spot. Restoring a set, or
seating a display that joins, takes the stored arrangement at its stored
sizes and seats each display at its size now on the side and alignment it
had, the way a scale step already keeps them. A spot stored without its size
is taken at the size now, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The display module already sized, arranged and remembered a display by its
rotation, but nothing set one: only a hand-written monitor rule, which takes
the display away from the module altogether, or hyprctl.

omarchy_displays.set_rotation(name, transform) turns a display that's on to
standard, 90°, 180° or 270°, the way a scale step resizes one: the display
keeps its side and alignment, the others stay where they are, and the turn
is remembered for that display, so it comes back turned on any port and
after a reload. Flipped transforms aren't offered. status() shows a turned
display's transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oliverlukschander and others added 2 commits October 1, 2026 10:22
Each display row gets its rotation next to the star: quiet while the display
stands normally, with the angle once it's turned. A click opens Standard,
90°, 180° and 270° with the current one marked, and picking one turns the
display through the display module. The menu opens above the row where the
panel would cut it off below.

From the keyboard, r on a display row opens the menu, j/k and Enter pick,
and Escape closes it. Every display that's on can turn, the built-in one
included, while the module arranges them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants