v0.1.11 — Multi-monitor handling: disable an auxiliary monitor before Gaming Mode

Reported on a Framework Desktop (AMD AI MAX 380) + Gigabyte M27Q +
LG DualUp setup: with both monitors attached, gamescope would either
land on the wrong screen or refuse to start, and writing
OUTPUT_CONNECTOR=DP-X alone wasn't enough to fix it. The workaround
shipped by the user was to manually patch /usr/share/gamescope-session-plus
to disable the other monitor before launching gamescope.

DeckShift now handles this natively, without touching the
gamescope-session-plus script (which is ChimeraOS's, not ours):

- New env var OUTPUT_CONNECTOR_TO_DISABLE (single connector or
  comma list) written to ~/.config/environment.d/gamescope-session-plus.conf
  alongside the other display keys.

- switch-to-gaming reads it and runs `hyprctl keyword monitor X,disable`
  for each listed connector BEFORE the SDDM restart, while Hyprland is
  still alive (hyprctl needs a live IPC socket). The disable is
  runtime-only — Hyprland's static config isn't touched — so when the
  user returns from Gaming Mode the new Hyprland reads its config fresh
  and the monitor comes back automatically. No re-enable step needed.

- Settings TUI exposes this as a "Hide monitor" main-menu item. The
  picker lists every connected monitor EXCEPT the gaming one (so a
  user can't accidentally pick the same connector they just set as
  OUTPUT_CONNECTOR) and includes a "(clear)" entry.

Also fixes a latent bug in v0.1.10's config-path shortening:
${CONF/#$HOME/~} was supposed to render the conf path with ~ but
bash applies tilde-expansion to the replacement side, re-expanding ~
to $HOME and making the substitution a no-op. Escaped as \~ now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-18 19:12:42 +01:00
parent aaa2f3d768
commit 52c883b780
3 changed files with 84 additions and 8 deletions

View file

@ -10,6 +10,13 @@ Lineage: forked from [Super-Shift-S-Omarchy-Deck-Mode](https://git.no-signal.uk/
## What's New ## What's New
### v0.1.11 — Multi-monitor handling: disable an auxiliary monitor before Gaming Mode
- New env var `OUTPUT_CONNECTOR_TO_DISABLE` (single connector or comma list). When set, `switch-to-gaming` runs `hyprctl keyword monitor <conn>,disable` for each listed connector *before* SDDM restart, while Hyprland is still alive. The disable is runtime-only — when the user returns from Gaming Mode, the new Hyprland reads its static config fresh and the monitor comes back automatically.
- Settings TUI exposes this as a **"Hide monitor"** option in the main menu and on the state panel. The picker lists every connected monitor *except* the gaming one, plus a "(clear)" entry to remove the override.
- Fixes a reported issue on multi-monitor setups (e.g. Framework Desktop + LG DualUp + Gigabyte M27Q) where gamescope would either land on the wrong screen or refuse to start when both monitors were attached. The previous workaround was to physically unplug the second monitor.
- Also fixes a latent bug from v0.1.10: the config-file path in the TUI was supposed to render with `~` instead of `/home/<user>` to fit the panel, but bash's tilde-expansion on the replacement side of `${var/#pat/~}` re-expanded `~` back to `$HOME`, making the substitution a no-op. The replacement is now escaped as `\~`.
### v0.1.10 — Settings TUI layout polish ### v0.1.10 — Settings TUI layout polish
- Banner, state panel, menu header, and menu items now share a single centred panel column rather than each block centring itself independently. The TUI feels visibly aligned in a Walker floating window of any width — no more drifting elements off to the left while the menu floats to the right. - Banner, state panel, menu header, and menu items now share a single centred panel column rather than each block centring itself independently. The TUI feels visibly aligned in a Walker floating window of any width — no more drifting elements off to the left while the menu floats to the right.

View file

@ -307,7 +307,7 @@ list_gpus() {
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
show_state() { show_state() {
local connector width height refresh vk_adapter dri_prime prime_offload mesa_vk_select local connector width height refresh vk_adapter dri_prime prime_offload mesa_vk_select disable_connector
connector=$(effective OUTPUT_CONNECTOR) connector=$(effective OUTPUT_CONNECTOR)
width=$(effective SCREEN_WIDTH) width=$(effective SCREEN_WIDTH)
height=$(effective SCREEN_HEIGHT) height=$(effective SCREEN_HEIGHT)
@ -322,6 +322,7 @@ show_state() {
dri_prime=$(effective DRI_PRIME) dri_prime=$(effective DRI_PRIME)
prime_offload=$(effective __NV_PRIME_RENDER_OFFLOAD) prime_offload=$(effective __NV_PRIME_RENDER_OFFLOAD)
mesa_vk_select=$(effective MESA_VK_DEVICE_SELECT) mesa_vk_select=$(effective MESA_VK_DEVICE_SELECT)
disable_connector=$(effective OUTPUT_CONNECTOR_TO_DISABLE)
# Single GPU-mode line — shows the active mode rather than half-empty rows, # Single GPU-mode line — shows the active mode rather than half-empty rows,
# since the modes are mutually exclusive. AMD hybrid is identified by both # since the modes are mutually exclusive. AMD hybrid is identified by both
@ -354,7 +355,7 @@ show_state() {
fi fi
# Replace $HOME with ~ so the config path fits the panel column. # Replace $HOME with ~ so the config path fits the panel column.
local conf_display="${CONF/#$HOME/~}" local conf_display="${CONF/#$HOME/\~}"
cat <<EOF cat <<EOF
Gaming Mode display settings${pending_label} Gaming Mode display settings${pending_label}
@ -363,6 +364,7 @@ Gaming Mode display settings${pending_label}
Resolution : ${resolution_label} Resolution : ${resolution_label}
Refresh rate : ${refresh:-<auto>} Hz Refresh rate : ${refresh:-<auto>} Hz
GPU mode : ${gpu_mode} GPU mode : ${gpu_mode}
Hide monitor : ${disable_connector:-<none>}
Config file : ${conf_display} Config file : ${conf_display}
EOF EOF
@ -396,6 +398,49 @@ choose_monitor() {
fi fi
} }
# choose_monitor_to_disable — picks a connector to physically disable (via
# `hyprctl keyword monitor X,disable`) before Gaming Mode launches.
#
# Why this exists: with multiple monitors connected, gamescope-session-plus
# can't reliably target a single output via OUTPUT_CONNECTOR alone — on some
# setups it picks the wrong screen, on others it fails to start at all
# (reported on a Framework Desktop + LG DualUp + Gigabyte M27Q setup).
# Disabling the auxiliary monitor right before SDDM restart guarantees
# gamescope only sees the gaming display.
#
# The disable is runtime-only (hyprctl keyword, not a config edit), so when
# the user returns from Gaming Mode the fresh Hyprland reads its static
# config and the monitor comes back automatically — no re-enable step needed.
choose_monitor_to_disable() {
local choice gaming_monitor
gaming_monitor=$(effective OUTPUT_CONNECTOR)
mapfile -t connected < <(list_connected_monitors)
local -a labels=()
local entry conn
for entry in "${connected[@]}"; do
conn="${entry%%|*}"
# Exclude the gaming monitor — disabling it would be self-defeating.
[[ "$conn" == "$gaming_monitor" ]] && continue
labels+=("$entry")
done
labels+=("(clear / don't hide any monitor)")
if (( ${#labels[@]} == 1 )); then
gum confirm "Only the gaming monitor is connected. Set a connector to disable manually?" || return 0
local connector
connector=$(ginput --prompt "Connector to disable (e.g. HDMI-A-1): ")
[[ -z "$connector" ]] && return 0
pending_set OUTPUT_CONNECTOR_TO_DISABLE "$connector"
return 0
fi
choice=$(cmenu "Select monitor to disable while gaming" "${labels[@]}")
[[ -z "$choice" ]] && return 0
if [[ "$choice" == "(clear"* ]]; then
pending_unset OUTPUT_CONNECTOR_TO_DISABLE
else
pending_set OUTPUT_CONNECTOR_TO_DISABLE "${choice%%|*}"
fi
}
choose_resolution() { choose_resolution() {
refresh_monitor_data refresh_monitor_data
local choice w h local choice w h
@ -699,13 +744,15 @@ main() {
"Resolution" \ "Resolution" \
"Refresh rate" \ "Refresh rate" \
"GPU" \ "GPU" \
"Hide monitor" \
"$save_label" \ "$save_label" \
"$cancel_label") "$cancel_label")
case "$action" in case "$action" in
"Monitor") choose_monitor ;; "Monitor") choose_monitor ;;
"Resolution") choose_resolution ;; "Resolution") choose_resolution ;;
"Refresh rate") choose_refresh_rate ;; "Refresh rate") choose_refresh_rate ;;
"GPU") choose_gpu ;; "GPU") choose_gpu ;;
"Hide monitor") choose_monitor_to_disable ;;
"Save and exit"*) "Save and exit"*)
if ! confirm_risky_save; then if ! confirm_risky_save; then
continue continue
@ -713,7 +760,7 @@ main() {
flush_pending flush_pending
clear clear
echo "" echo ""
gum style --foreground 212 "Settings saved to ${CONF/#$HOME/~}" | pad_block gum style --foreground 212 "Settings saved to ${CONF/#$HOME/\~}" | pad_block
gum style --foreground 244 "Changes apply next time you enter Gaming Mode (Super+Shift+S)." | pad_block gum style --foreground 244 "Changes apply next time you enter Gaming Mode (Super+Shift+S)." | pad_block
sleep 1 sleep 1
return 0 return 0

View file

@ -34,7 +34,7 @@ set -Euo pipefail
# -u: Treat unset variables as errors (catches typos in variable names) # -u: Treat unset variables as errors (catches typos in variable names)
# -o pipefail: A pipeline fails if ANY command in it fails, not just the last one # -o pipefail: A pipeline fails if ANY command in it fails, not just the last one
DECKSHIFT_VERSION="0.1.10" DECKSHIFT_VERSION="0.1.11"
# Resolve the directory this script lives in so we can find sibling files like # Resolve the directory this script lives in so we can find sibling files like
# bin/deckshift-settings and applications/deckshift-settings.desktop when # bin/deckshift-settings and applications/deckshift-settings.desktop when
@ -2219,6 +2219,28 @@ notify-send -u normal -t 2000 "Gaming Mode" "Switching to Gaming Mode..." 2>/dev
pkill -9 gamescope 2>/dev/null || true pkill -9 gamescope 2>/dev/null || true
pkill -9 -f gamescope-session 2>/dev/null || true pkill -9 -f gamescope-session 2>/dev/null || true
sleep 1 sleep 1
# Multi-monitor handling — gamescope-session-plus picks an output by env, but
# with two monitors connected it sometimes lands on the wrong one (or refuses
# to start). If OUTPUT_CONNECTOR_TO_DISABLE is set in the user's env conf,
# disable those connectors via hyprctl while Hyprland is still alive so
# gamescope only sees the gaming display. The disable is runtime-only (no
# config edit) so when the user returns from Gaming Mode the new Hyprland
# reads its static config fresh and the monitor comes back automatically.
ENV_CONF="$HOME/.config/environment.d/gamescope-session-plus.conf"
if [[ -f "$ENV_CONF" ]]; then
TO_DISABLE=$(awk -F= '$1=="OUTPUT_CONNECTOR_TO_DISABLE" { sub(/^[^=]*=/,""); v=$0 } END { print v }' "$ENV_CONF")
if [[ -n "$TO_DISABLE" ]]; then
IFS=',' read -ra DISABLE_LIST <<< "$TO_DISABLE"
for conn in "${DISABLE_LIST[@]}"; do
conn="${conn// /}"
[[ -z "$conn" ]] && continue
hyprctl keyword monitor "${conn},disable" 2>/dev/null || true
done
sleep 0.5
fi
fi
sudo -n chvt 2 2>/dev/null || true sudo -n chvt 2 2>/dev/null || true
sleep 0.3 sleep 0.3
sudo -n systemctl restart sddm sudo -n systemctl restart sddm