Omarchy: Better Dual Monitor Setup

Two weeks ago I published the article 'Monitors and Workspaces'.I want to take this a step further and enable switching between dual and single monitor setup while preserving the workspaces.

Warning

Use the conf files and bash script with care. The script in particular is tailored to my specific setup. Make sure to adapt it to your own environment before using it.

How it works

This setup keeps two Hyprland workspace configs for the split-monitor-workspaces plugin—one for a single screen (8 workspaces) and one for dual screens (4 per monitor). Hyprland always reads the one the workspaces-current.conf symlink points to. A small script, hypr-switch, switches between home (dual), laptop (single), and presenter (mirrored): it updates the monitor layout via hyprctl, swaps the symlink, and remaps windows so your workspaces are preserved when collapsing to one screen and expanding back again; it also restarts Waybar/Hyprpaper. The script caches the current state to skip no-ops, supports --force to override, and can be bound to simple keyboard shortcuts.

Workspaces config

We need to make two separate config files: one for a single screen and one for dual screens. Note that the dual screen setup defines two additional shortcut keys for moving a window to the other monitor.

dual screen

Create a file named workspaces-dual.conf

# split-monitor-workspaces — dual (4 per monitor)
exec-once = hyprpm reload -n
plugin {
  split-monitor-workspaces {
    count = 4
    keep_focused = 1
    enable_persistent_workspaces = 1
    enable_notifications = 1
    enable_wrapping = 1
  }
}

# guardrails: disable Super+9/0 (and shift)
unbind = SUPER,       code:18
unbind = SUPER,       code:19
unbind = SUPER SHIFT, code:18
unbind = SUPER SHIFT, code:19

# Focus: WS1–4 on mon0, WS5–8 on mon1 (as local 1–4)
bind = SUPER, code:10, exec, hyprctl dispatch focusmonitor 0; hyprctl dispatch split-workspace 1
bind = SUPER, code:11, exec, hyprctl dispatch focusmonitor 0; hyprctl dispatch split-workspace 2
bind = SUPER, code:12, exec, hyprctl dispatch focusmonitor 0; hyprctl dispatch split-workspace 3
bind = SUPER, code:13, exec, hyprctl dispatch focusmonitor 0; hyprctl dispatch split-workspace 4
bind = SUPER, code:14, exec, hyprctl dispatch focusmonitor 1; hyprctl dispatch split-workspace 1
bind = SUPER, code:15, exec, hyprctl dispatch focusmonitor 1; hyprctl dispatch split-workspace 2
bind = SUPER, code:16, exec, hyprctl dispatch focusmonitor 1; hyprctl dispatch split-workspace 3
bind = SUPER, code:17, exec, hyprctl dispatch focusmonitor 1; hyprctl dispatch split-workspace 4

# Move windows
bind = SUPER SHIFT, code:10, exec, hyprctl dispatch movewindow mon:0; hyprctl dispatch focusmonitor 0; hyprctl dispatch split-movetoworkspace 1
bind = SUPER SHIFT, code:11, exec, hyprctl dispatch movewindow mon:0; hyprctl dispatch focusmonitor 0; hyprctl dispatch split-movetoworkspace 2
bind = SUPER SHIFT, code:12, exec, hyprctl dispatch movewindow mon:0; hyprctl dispatch focusmonitor 0; hyprctl dispatch split-movetoworkspace 3
bind = SUPER SHIFT, code:13, exec, hyprctl dispatch movewindow mon:0; hyprctl dispatch focusmonitor 0; hyprctl dispatch split-movetoworkspace 4
bind = SUPER SHIFT, code:14, exec, hyprctl dispatch movewindow mon:1; hyprctl dispatch focusmonitor 1; hyprctl dispatch split-movetoworkspace 1
bind = SUPER SHIFT, code:15, exec, hyprctl dispatch movewindow mon:1; hyprctl dispatch focusmonitor 1; hyprctl dispatch split-movetoworkspace 2
bind = SUPER SHIFT, code:16, exec, hyprctl dispatch movewindow mon:1; hyprctl dispatch focusmonitor 1; hyprctl dispatch split-movetoworkspace 3
bind = SUPER SHIFT, code:17, exec, hyprctl dispatch movewindow mon:1; hyprctl dispatch focusmonitor 1; hyprctl dispatch split-movetoworkspace 4

# Move window to other monitor
bind = CTRL SHIFT, left, exec, hyprctl dispatch movewindow mon:l
bind = CTRL SHIFT, right, exec, hyprctl dispatch movewindow mon:r

# Cycle per monitor
unbind = SUPER, TAB
unbind = SUPER SHIFT, TAB
bind = SUPER, TAB, split-workspace, +1
bind = SUPER SHIFT, TAB, split-workspace, -1

single screen

Create a file named workspaces-single.conf

# split-monitor-workspaces — single (8 on sole monitor)
exec-once = hyprpm reload -n
plugin {
  split-monitor-workspaces {
    count = 8
    keep_focused = 1
    enable_persistent_workspaces = 1
    enable_notifications = 1
    enable_wrapping = 1
  }
}

# guardrails: disable Super+9/0 (and shift)
unbind = SUPER,       code:18
unbind = SUPER,       code:19
unbind = SUPER SHIFT, code:18
unbind = SUPER SHIFT, code:19

# Focus: WS1..8 (single screen)
bind = SUPER, code:10, exec, hyprctl dispatch split-workspace 1
bind = SUPER, code:11, exec, hyprctl dispatch split-workspace 2
bind = SUPER, code:12, exec, hyprctl dispatch split-workspace 3
bind = SUPER, code:13, exec, hyprctl dispatch split-workspace 4
bind = SUPER, code:14, exec, hyprctl dispatch split-workspace 5
bind = SUPER, code:15, exec, hyprctl dispatch split-workspace 6
bind = SUPER, code:16, exec, hyprctl dispatch split-workspace 7
bind = SUPER, code:17, exec, hyprctl dispatch split-workspace 8

# Move windows
bind = SUPER SHIFT, code:10, exec, hyprctl dispatch split-movetoworkspace 1
bind = SUPER SHIFT, code:11, exec, hyprctl dispatch split-movetoworkspace 2
bind = SUPER SHIFT, code:12, exec, hyprctl dispatch split-movetoworkspace 3
bind = SUPER SHIFT, code:13, exec, hyprctl dispatch split-movetoworkspace 4
bind = SUPER SHIFT, code:14, exec, hyprctl dispatch split-movetoworkspace 5
bind = SUPER SHIFT, code:15, exec, hyprctl dispatch split-movetoworkspace 6
bind = SUPER SHIFT, code:16, exec, hyprctl dispatch split-movetoworkspace 7
bind = SUPER SHIFT, code:17, exec, hyprctl dispatch split-movetoworkspace 8

# Cycle
unbind = SUPER, TAB
unbind = SUPER SHIFT, TAB
bind = SUPER, TAB, split-workspace, +1
bind = SUPER SHIFT, TAB, split-workspace, -1

active config

Now we need a symlinked file pointing to one of those configs, named workspaces-current.conf, which we will refer to in hyprland.conf.

cd ~/.config/hypr
ln -sf workspaces-dual.conf workspaces-current.conf

Assuming we are currently using a dual monitor setup, this sets the appropriate config as active. Otherwise, create a link to the single config.

hyprland.conf

Now update hyprland.conf to use the current config instead of the one we added two weeks ago.

# Plugins Configuration
source = ~/.config/hypr/workspaces-current.conf

bash file for switching modes

I created a script for the three modes I need:

  1. dual screen, in my case named home
  2. single screen, in my case named laptop
  3. mirrored screen, in my case named presenter. I use this when giving presentations.

You can use your own names, and leave out the presenter mode if you don't need it.

Create a file ~/bin/hypr-switch

#!/usr/bin/env bash
set -euo pipefail

HOST="${HOSTNAME:-$(hostname)}"
STATE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/hypr-switch"
STATE_FILE="$STATE_DIR/state"
mkdir -p "$STATE_DIR"

HYPR_DIR="$HOME/.config/hypr"
WORK_LINK="$HYPR_DIR/workspaces-current.conf"
WORK_SINGLE="$HYPR_DIR/workspaces-single.conf"
WORK_DUAL="$HYPR_DIR/workspaces-dual.conf"

FORCE=0
ACTION=""

case "$HOST" in
  laptop1)
    INTERNAL="eDP-1"; EXTERNAL="HDMI-A-1"
    LAPTOP_MODE="1920x1080@60"; HOME_EXT_MODE="1920x1080@60"; HOME_OFFSET="1920x0"   # external RIGHT
    ;;
  laptop2)
    INTERNAL="eDP-1"; EXTERNAL="HDMI-A-1"
    LAPTOP_MODE="1366x768@60"; HOME_EXT_MODE="1920x1080@60"; HOME_OFFSET="-1920x0"  # external LEFT
    ;;
  *) echo "Unknown host '$HOST'"; exit 1;;
esac
PRESENTER_MODE="$LAPTOP_MODE"

already_in_state(){ 
  [ "$FORCE" -eq 1 ] && return 1
  [[ -f "$STATE_FILE" ]] && grep -qx "$1" "$STATE_FILE"; 
}
write_state(){ printf "%s\n" "$1" >"$STATE_FILE"; }

swap_work_conf(){  # $1: single|dual
  ln -sf "$([[ $1 == single ]] && echo "$WORK_SINGLE" || echo "$WORK_DUAL")" "$WORK_LINK"
  hyprctl reload >/dev/null 2>&1 || true
}

clients_json(){ hyprctl -j clients; }

# mon1:WS1..4 -> global WS5..8 (collapse to single)
collapse_move_ws_to_single(){
  clients_json | jq -r --arg mon "$EXTERNAL" '
    .[] | select(.monitor==$mon and (.workspace.id>=1 and .workspace.id<=4)) |
    "\(.address) \(.workspace.id)"
  ' | while read -r addr ws; do
    tgt=$((ws+4))
    hyprctl dispatch focuswindow "address:$addr" >/dev/null 2>&1
    hyprctl dispatch movetoworkspacesilent "$tgt" >/dev/null 2>&1
  done
}

# WS5..8 -> mon:$EXTERNAL local 1..4 (expand to dual)
expand_move_ws_back_to_dual(){
  clients_json | jq -r '
    .[] | select(.workspace.id>=5 and .workspace.id<=8) |
    "\(.address) \(.workspace.id)"
  ' | while read -r addr ws; do
    local_i=$((ws-4))
    hyprctl dispatch focuswindow "address:$addr" >/dev/null 2>&1
    hyprctl dispatch movewindow "mon:$EXTERNAL" >/dev/null 2>&1
    hyprctl dispatch split-movetoworkspace "$local_i" >/dev/null 2>&1
  done
}

restart_waybar(){
  pkill -x -u "$USER" waybar >/dev/null 2>&1 || true
  sleep 0.2
  hyprctl dispatch exec "waybar >/dev/null 2>&1" >/dev/null 2>&1 || true
}
restart_wallpaper(){
  pkill -x -u "$USER" hyprpaper >/dev/null 2>&1 || true
  sleep 0.2
  hyprctl dispatch exec "hyprpaper -c $HOME/.config/hypr/hyprpaper.conf >/dev/null 2>&1" >/dev/null 2>&1 || \
  hyprctl dispatch exec "hyprpaper >/dev/null 2>&1" >/dev/null 2>&1
}

home(){
  already_in_state home && { echo "(hypr-switch) home: no-op"; return; }
  swap_work_conf dual
  hyprctl keyword monitor "$INTERNAL,${LAPTOP_MODE},0x0,1"
  hyprctl keyword monitor "$EXTERNAL,${HOME_EXT_MODE},${HOME_OFFSET},1"
  expand_move_ws_back_to_dual
  restart_waybar; restart_wallpaper
  write_state home
}
presenter(){
  already_in_state presenter && { echo "(hypr-switch) presenter: no-op"; return; }
  swap_work_conf single
  hyprctl keyword monitor "$EXTERNAL,disable"
  hyprctl keyword monitor "$INTERNAL,${PRESENTER_MODE},0x0,1"
  hyprctl keyword monitor "$EXTERNAL,${PRESENTER_MODE},auto,1,mirror,${INTERNAL}"
  collapse_move_ws_to_single
  restart_waybar; restart_wallpaper
  write_state presenter
}
laptop(){
  already_in_state laptop && { echo "(hypr-switch) laptop: no-op"; return; }
  swap_work_conf single
  hyprctl keyword monitor "$EXTERNAL,disable"
  hyprctl keyword monitor "$INTERNAL,preferred,auto,1"
  collapse_move_ws_to_single
  restart_waybar; restart_wallpaper
  write_state laptop
}

menu(){
  echo "Choose display mode:"
  echo "  1) Home (dual)   [default]"
  echo "  2) Presenter (mirror)"
  echo "  3) Laptop only (single)"
  read -rp "> " choice; choice="${choice:-1}"
  case "$choice" in 1) home ;; 2) presenter ;; 3) laptop ;; *) home ;; esac
}

FORCE=0
ACTION=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --force|-f) FORCE=1; shift ;;
    home|presenter|laptop) ACTION="$1"; shift ;;
    --help|-h)
      echo "Usage: $(basename "$0") [--force] <home|presenter|laptop>"
      exit 0
      ;;
    *)
      echo "Unknown arg: $1"
      echo "Usage: $(basename "$0") [--force] <home|presenter|laptop>"
      exit 2
      ;;
  esac
done

# If no mode was given, show the menu
if [[ -z "${ACTION:-}" ]]; then
  menu
  exit 0
fi

# Run the requested mode
case "$ACTION" in
  home) home ;;
  presenter) presenter ;;
  laptop) laptop ;;
esac

NOTA BENE: on line 16, I check the hostname of the computer to use the proper monitor setup. If you have one device only then simply set the variables to the values that apply to your situation. See your monitors.conf file.

Make this file executable or run it with the bash command.

This script saves the current state and does nothing if it's already in the requested mode. This can be overridden with the --force parameter. Launching it without a target mode will show a menu.

Keyboard shortcuts

The final step is to add a few keyboard shortcut for switching modes. Of course, you can choose your own preferred keys, but this is what I have done.

Add the following lines in ~/.config/hypr/bindings.conf

bindd = SUPER, F1, Single Screen, exec, bash ~/bin/hypr-switch laptop
bindd = SUPER, F2, Dual Screen, exec, bash ~/bin/hypr-switch home
bindd = SUPER, F3, Mirrored Screen, exec, bash ~/bin/hypr-switch presenter
bindd = SUPER SHIFT, F1, Force Single Screen, exec, bash ~/bin/hypr-switch laptop -f
bindd = SUPER SHIFT, F2, Force Dual Screen, exec, bash ~/bin/hypr-switch home -f
bindd = SUPER SHIFT, F3, Force Mirrored Screen, exec, bash ~/bin/hypr-switch presenter -f

Well, this was a long post, but I am happy with this new config.