:#/ GSLF Journal
โ† :#/ GSLF Journal

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 all

For each display, record:

  • Connector name, such as eDP-1, DP-1, HDMI-A-1, or DP-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 Hyprland

Choose 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 / scale

For example:

Laptop:  2880 ร— 1800 at scale 2.0  -> 1440 ร— 900 logical pixels
Monitor: 3440 ร— 1440 at scale 1.0  -> 3440 ร— 1440 logical pixels

Typical starting scales are:

  • 1: normal-density external monitor
  • 1.25 or 1.5: high-resolution desktop monitor
  • 2: 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 height

External 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   = EH

External monitor below the laptop#

internal Y = 0
external Y = IH

Center both displays horizontally using the same canvas width calculation.

External monitor on the right#

internal X = 0
external X = IW

Center them vertically using canvas height = max(IH, EH).

External monitor on the left#

external X = 0
internal X = EW

4. 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.lua

Edit '~/.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 configerrors

hyprctl configerrors should print nothing. Then inspect the applied state:

hyprctl monitors all

Check:

  1. Both outputs use the requested modes and scales.
  2. Their x and y coordinates match the intended physical arrangement.
  3. The pointer crosses the shared edge in the expected direction.
  4. 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"
done

Hyprland 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 stop

Then 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 configerrors

Verify the active monitors report:

hardwareCursorsInUse: false

This 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 keyFirst monitorSecond monitor
1111
2212
.........
0 / 101020

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 })
end

Replace 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)
end

Do 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 workspaces

9. 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.workspaces

Omarchy 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 shell

Keep backup files outside ~/.config/omarchy/plugins/, because that directory is watched for plugin changes.