Make multiple displays just work: arrangement, scaling and per-display workspaces - #638
Open
oliverlukschander wants to merge 25 commits into
Open
oliverlukschander wants to merge 25 commits into
oliverlukschander wants to merge 25 commits into
Conversation
oliverlukschander
force-pushed
the
feat/multi-monitor-qu
branch
from
September 27, 2026 20:28
b0f4fdf to
745fee2
Compare
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>
oliverlukschander
force-pushed
the
feat/multi-monitor-qu
branch
from
September 28, 2026 11:03
745fee2 to
5a35e78
Compare
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 ( |
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) |
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Super + /andSuper + Alt + /step the scale, andSuper + Ctrl + /matches everything to main.GDK_SCALEfollows main.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 + Arrowsends it in that direction.How
default/hypr/displays.luadoes the work.displays/model.luaholds the pure arrangement and scale maths, tested headlessly, anddisplays/store.luakeeps~/.local/state/omarchy/displays.json.hl.monitorrule 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 onmonitor.layout_changed.omarchy-hyprland-monitor-scalingand-clamshellhand scaling to the module while it's loaded.Compatibility
hl.monitorrule in the user's config is left alone, and those rules are re-sent after the module's. The same goes for a user'sGDK_SCALE.omarchy-hyprland-toggle displays-off onturns the module off.display-workspaces-off onkeeps the arrangement and scaling, with global workspaces (for workspace plugins).1790518456comments out the stockGDK_SCALElines of an unchangedmonitors.lua.Interplay with quattro-upstream
monitor-removal.luaand the Swipe into empty workspaces on Macs #633 swipe (workspace_swipe_use_r) step aside while workspaces are per display.Super + Shift + Alt + Arrows(move workspace to monitor) is dropped, in favour of the existingSuper + Ctrl + Alt + Arrows.GDK_SCALE, so none of them counts as the user's.Since opening: what changed and why
Using it turned up the following. Each is its own commit, 6 to 25:
ScreenMoveRemaphides a moved monitor's bar for a moment, and aModuleListwas active only while visible, so every widget on that bar was rebuilt. It now follows an explicitshown.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:
Hand-written
monitors.luarules and the two toggles are covered by the tests, but weren't tried on hardware.For this update, retested live after the fixes:
Tests:
test/shell.d/hyprland-displays-test.shruns the model, the store and the flows against a fake Hyprland.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
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