Omarchy Hyprland - external monitor multi-GPU configuration
On this page
This guide explains how to configure an external monitor on Omarchy, which uses Hyprland with Lua configuration and the Omarchy shell. It include Multi-GPU fixes, software cursors, independent per-monitor workspaces, and a matching Omarchy bar widget.
I create this configuration on a very complex machine, a T2 MacBook that is always a pain in the ass for linux users. With all of this mods, it works pretty good.
1. Discover the connector names and supported modes#
Connect the monitor and run:
hyprctl monitors allFor each display, record:
- Connector name, such as
eDP-1,DP-1,HDMI-A-1, orDP-3 - Desired entry from
availableModes - Current scale
- Whether the output is enabled
In the examples below:
<INTERNAL> laptop connector, usually eDP-1
<EXTERNAL> external connector, such as DP-1 or HDMI-A-1
<INTERNAL_MODE> exact mode reported by Hyprland
<EXTERNAL_MODE> exact mode reported by HyprlandChoose a mode from availableModes. A monitor may accept a resolution that is not its native desktop mode, so do not select a mode only because its pixel count is larger. Prefer the panel's native aspect ratio and desired refresh rate.
2. Choose scales and calculate logical sizes#
Hyprland positions monitors in logical pixels, after scaling:
logical width = physical mode width / scale
logical height = physical mode height / scaleFor example:
Laptop: 2880 ร 1800 at scale 2.0 -> 1440 ร 900 logical pixels
Monitor: 3440 ร 1440 at scale 1.0 -> 3440 ร 1440 logical pixelsTypical starting scales are:
1: normal-density external monitor1.25or1.5: high-resolution desktop monitor2: HiDPI laptop panel
3. Calculate the monitor positions#
The position is the top-left coordinate of a monitor in Hyprland's virtual desktop. Two displays must touch along an edge for the pointer to cross naturally.
IW, IH = internal logical width and height
EW, EH = external logical width and heightExternal monitor above the laptop, centered#
Use a non-negative canvas so the wider display begins at x = 0:
canvas width = max(IW, EW)
internal X = (canvas width - IW) / 2
external X = (canvas width - EW) / 2
external Y = 0
internal Y = EHExternal monitor below the laptop#
internal Y = 0
external Y = IHCenter both displays horizontally using the same canvas width calculation.
External monitor on the right#
internal X = 0
external X = IWCenter them vertically using canvas height = max(IH, EH).
External monitor on the left#
external X = 0
internal X = EW4. Add the monitor rules#
Back up the current file:
mkdir -p ~/.config/omarchy/setup-backups/manual
cp ~/.config/hypr/monitors.lua ~/.config/omarchy/setup-backups/manual/monitors.luaEdit '~/.config/hypr/monitors.lua'
local internal_output = "<INTERNAL>"
local external_output = "<EXTERNAL>"
hl.monitor({
output = external_output,
mode = "<EXTERNAL_MODE>",
position = "<EXTERNAL_X>x0",
scale = <EXTERNAL_SCALE>,
})
hl.monitor({
output = internal_output,
mode = "<INTERNAL_MODE>",
position = "<INTERNAL_X>x<EXTERNAL_LOGICAL_HEIGHT>",
scale = <INTERNAL_SCALE>,
})A concrete generic example might be:
hl.monitor({ output = "DP-1", mode = "3440x1440@100", position = "0x0", scale = 1 })
hl.monitor({ output = "eDP-1", mode = "2880x1800@60", position = "1000x1440", scale = 2 })Here the laptop is 1440 logical pixels wide, so (3440 - 1440) / 2 = 1000.
If Hyprland exposes a disconnected or duplicate output that should never be used, disable only that exact connector:
hl.monitor({ output = "<UNUSED_OUTPUT>", disabled = true })5. Reload and validate the basic setup#
After every Hyprland Lua change, run:
hyprctl reload
hyprctl configerrorshyprctl configerrors should print nothing. Then inspect the applied state:
hyprctl monitors allCheck:
- Both outputs use the requested modes and scales.
- Their
xandycoordinates match the intended physical arrangement. - The pointer crosses the shared edge in the expected direction.
- Windows remain visible and correctly scaled.
6. Configure a hybrid or multi-GPU laptop#
An external port may be wired to a different GPU from the internal panel. Symptoms include:
- The external connector does not appear or cannot be enabled.
- One display is black.
- Rendering works on one GPU but the other monitor disappears.
- The pointer crosses onto the second display but is not rendered correctly.
Identify the GPUs:
lspci -nnk | grep -A4 -B1 -E 'VGA|3D|Display'
for node in /sys/class/drm/card[0-9]; do
printf '%s ' "$node"
readlink -f "$node/device/driver"
doneHyprland uses AQ_DRM_DEVICES as a colon-separated priority list. Every GPU that owns a required display connector must be included; the first entry is the primary renderer.
For UWSM, create or edit '~/.config/uwsm/env-hyprland'.
Add the paths found on your system:
export AQ_DRM_DEVICES="<PRIMARY_GPU_CARD>:<SECONDARY_GPU_CARD>"Do not assume Intel, AMD, or NVIDIA must always come first. The correct order depends on connector wiring, power requirements, and which renderer works reliably. Hyprland's multi-GPU documentation also warns that /dev/dri/cardN numbering can change and explains how to create stable udev symlinks.
UWSM reads this environment at graphical-session startup. Log out cleanly:
uwsm stopThen sign in again.
7. Fix an invisible cursor on one monitor#
If the pointer moves into a display but becomes invisible, test software cursors.
Edit '~/.config/hypr/looknfeel.lua'
Add:
hl.config({
cursor = {
no_hardware_cursors = 1,
},
})Reload and validate:
hyprctl reload
hyprctl configerrorsVerify the active monitors report:
hardwareCursorsInUse: falseThis is a workaround, not a requirement for ordinary monitor setup. Leave hardware cursors enabled when they work correctly.
8. Create independent numbered workspaces per monitor#
Hyprland workspace IDs are global. It cannot have two different workspaces both identified as global workspace 1.
If monitor A displays workspace 1 and monitor B tries to select that same ID, Hyprland may move or swap the existing workspace between monitors. To present local numbers 1โ10 on both screens, assign disjoint internal ranges:
| Local key | First monitor | Second monitor |
|---|---|---|
| 1 | 1 | 11 |
| 2 | 2 | 12 |
| ... | ... | ... |
| 0 / 10 | 10 | 20 |
Bind the ranges to the outputs#
Add to ~/.config/hypr/monitors.lua:
local internal_output = "<INTERNAL>"
local external_output = "<EXTERNAL>"
for workspace = 1, 10 do
hl.workspace_rule({ workspace = tostring(workspace), monitor = internal_output })
hl.workspace_rule({ workspace = tostring(workspace + 10), monitor = external_output })
endReplace the default numeric bindings#
Edit ~/.config/hypr/bindings.lua:
local external_output = "<EXTERNAL>"
local function local_workspace_id(slot)
local monitor = hl.get_active_monitor()
local offset = monitor and monitor.name == external_output and 10 or 0
return tostring(slot + offset)
end
for workspace = 1, 10 do
local slot = workspace
local key = "code:" .. tostring(slot + 9)
-- These keys are already assigned by Omarchy, so unbind them first.
hl.unbind("SUPER + " .. key)
hl.unbind("SUPER + SHIFT + " .. key)
hl.unbind("SUPER + SHIFT + ALT + " .. key)
o.bind("SUPER + " .. key, "Switch to local workspace " .. slot, function()
hl.dispatch(hl.dsp.focus({ workspace = local_workspace_id(slot) }))
end)
o.bind("SUPER + SHIFT + " .. key, "Move window to local workspace " .. slot, function()
hl.dispatch(hl.dsp.window.move({
workspace = local_workspace_id(slot),
follow = true,
}))
end)
o.bind("SUPER + SHIFT + ALT + " .. key, "Move window silently to local workspace " .. slot, function()
hl.dispatch(hl.dsp.window.move({
workspace = local_workspace_id(slot),
follow = false,
}))
end)
endDo not use on_current_monitor = true as a replacement for separate ID ranges. It changes where an existing global workspace is shown; it does not create duplicate local IDs.
Reload and verify:
hyprctl reload
hyprctl configerrors
hyprctl workspacerules
hyprctl workspaces9. Make the Omarchy bar monitor-aware#
The stock workspace widget follows globally focused workspace IDs. If you use separate internal ranges, the second monitor needs to display IDs 11โ20 as labels 1โ10 and highlight the workspace active on its own screen.
Clone the packaged widget instead of modifying /usr/share/omarchy/:
omarchy plugin clone omarchy.workspacesOmarchy creates a user-owned plugin similar to:
~/.config/omarchy/plugins/<USERNAME>.workspaces/It also replaces omarchy.workspaces in ~/.config/omarchy/shell.json with the cloned plugin ID.
Replace the cloned Workspaces.qml with the following complete file. Substitute <PLUGIN_ID> with the clone's manifest ID and <EXTERNAL> with the real connector name:
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "<PLUGIN_ID>"
readonly property var barWindow: root.QsWindow ? root.QsWindow.window : null
readonly property var shellScreen: barWindow ? barWindow.screen : null
readonly property var hyprMonitor: shellScreen ? Hyprland.monitorFor(shellScreen) : null
readonly property int workspaceOffset:
hyprMonitor && hyprMonitor.name === "<EXTERNAL>" ? 10 : 0
function workspaceById(id) {
var values = Hyprland.workspaces.values
for (var i = 0; i < values.length; i++) {
if (values[i].id === id) return values[i]
}
return null
}
function workspaceIds() {
var ids = []
for (var slot = 1; slot <= 5; slot++)
ids.push(root.workspaceOffset + slot)
var values = Hyprland.workspaces.values
var firstId = root.workspaceOffset + 1
var lastId = root.workspaceOffset + 10
for (var i = 0; i < values.length; i++) {
var id = values[i].id
if (id >= firstId && id <= lastId && ids.indexOf(id) === -1)
ids.push(id)
}
ids.sort(function(left, right) { return left - right })
return ids
}
function localLabel(id) {
var slot = id - root.workspaceOffset
return slot === 10 ? "0" : String(slot)
}
function focusWorkspace(id) {
if (!root.bar) return
root.bar.run("hyprctl dispatch " +
Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
}
readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
implicitWidth: grid.implicitWidth + trailingGap
implicitHeight: grid.implicitHeight
GridLayout {
id: grid
anchors.fill: parent
anchors.rightMargin: root.trailingGap
columns: root.vertical ? 1 : root.workspaceIds().length
columnSpacing: root.vertical ? 0 : Style.space(1)
rowSpacing: root.vertical ? Style.space(2) : 0
Repeater {
model: root.workspaceIds()
WidgetButton {
required property int modelData
readonly property var workspace: root.workspaceById(modelData)
readonly property bool occupied:
workspace !== null && workspace.toplevels.values.length > 0
readonly property bool focused:
workspace !== null && workspace.active
bar: root.bar
text: focused ? "\uDB85\uDCFB" : root.localLabel(modelData)
opacity: occupied || focused ? 1 : 0.5
horizontalMargin: 6
verticalPadding: 6
fixedWidth: root.vertical ? root.barSize : Style.space(20)
fixedHeight: root.barSize
onPressed: function() { root.focusWorkspace(modelData) }
}
}
}
}This file identifies the monitor that owns each bar, selects only that monitor's workspace range, converts internal IDs back to local labels, and highlights the workspace through its per-monitor active property.
If the cloned plugin does not reload cleanly:
omarchy restart shellKeep backup files outside ~/.config/omarchy/plugins/, because that directory is watched for plugin changes.