#!/bin/bash # ============================================================================== # deckshift-settings — Gaming Mode settings TUI # # Launched from Walker (Super+Space → "DeckShift Settings"). Lets the user # adjust which monitor, GPU, resolution, and refresh rate Gaming Mode uses, # without editing ~/.config/environment.d/gamescope-session-plus.conf by hand. # # Selections are buffered in memory — nothing is written until the user picks # "Save and exit". "Cancel" discards changes. The on-disk file is rewritten in # place, preserving any keys the TUI doesn't manage (ADAPTIVE_SYNC, HDR, etc). # ============================================================================== # -u (nounset) trips on empty associative arrays even with `${arr[k]+x}` # guards, so we run with -eo pipefail only. set -eo pipefail CONF="${HOME}/.config/environment.d/gamescope-session-plus.conf" # Theme — match the "unsaved changes" amber across all gum prompts so the # cursor / highlighted line is readable regardless of terminal palette. ACCENT=214 # amber, also used for unsaved-changes label HEADER_COLOR=212 export GUM_CHOOSE_CURSOR_FOREGROUND="$ACCENT" export GUM_CHOOSE_SELECTED_FOREGROUND="$ACCENT" export GUM_CHOOSE_HEADER_FOREGROUND="$HEADER_COLOR" export GUM_CONFIRM_SELECTED_FOREGROUND="$ACCENT" export GUM_CONFIRM_PROMPT_FOREGROUND="$HEADER_COLOR" export GUM_INPUT_PROMPT_FOREGROUND="$ACCENT" # Buffered pending changes — flushed only on Save and exit. # PENDING_SET[KEY]=VALUE → write KEY=VALUE # PENDING_UNSET[KEY]=1 → delete KEY from the conf declare -A PENDING_SET declare -A PENDING_UNSET die() { echo "[!] $*" >&2; exit 1; } command -v gum >/dev/null || die "gum is required (install with: omarchy-pkg-add gum)" # Wrappers — gum choose / gum input exit non-zero on Escape/Ctrl-C, which # would trip `set -e` and kill the TUI. These swallow that so a cancelled # prompt simply returns an empty string, letting the caller treat it as # "user backed out" and return to the main menu. gchoose() { gum choose "$@" || true; } ginput() { gum input "$@" || true; } command -v lspci >/dev/null || die "lspci is required" command -v jq >/dev/null || die "jq is required (install with: omarchy-pkg-add jq)" command -v hyprctl >/dev/null || die "hyprctl is required (this TUI is for Hyprland sessions)" # ------------------------------------------------------------------------------ # Layout — everything renders inside a single centred "panel" column so the # banner, state, menu, and toasts all share a left edge regardless of terminal # width. PANEL_WIDTH is adaptive: target 60 cols, capped at the terminal width # minus margin, floored so it stays usable on narrow windows. # ------------------------------------------------------------------------------ # Live terminal width — refreshed each main loop iteration so a window resize # is picked up between menus. Reads via `stty size` first (kernel-reported, # always reflects the live window) because `tput cols` in a freshly-spawned # floating terminal sometimes returns the terminfo default (80) before the # compositor has applied its size. # # Every `$(...)` here ends with `|| true` because under `set -eo pipefail` a # failing pipeline inside a command substitution propagates out and trips -e # on the enclosing assignment. COLS=80 PANEL_WIDTH=60 LEFT_MARGIN=0 LEFT_PAD="" refresh_cols() { local raw raw=$(stty size 2>/dev/null 0 )); then COLS=${BASH_REMATCH[1]} else raw=$(tput cols 2>/dev/null || true) if [[ "$raw" =~ ^[0-9]+$ ]] && (( raw > 0 )); then COLS=$raw else COLS=80 fi fi PANEL_WIDTH=60 (( PANEL_WIDTH > COLS - 6 )) && PANEL_WIDTH=$(( COLS - 6 )) (( PANEL_WIDTH < 40 )) && PANEL_WIDTH=40 LEFT_MARGIN=$(( (COLS - PANEL_WIDTH) / 2 )) (( LEFT_MARGIN < 0 )) && LEFT_MARGIN=0 LEFT_PAD="" (( LEFT_MARGIN > 0 )) && printf -v LEFT_PAD '%*s' "$LEFT_MARGIN" '' } # Prepend N spaces to every line of stdin. Used to position blocks at the # panel's left edge so banner, state, menu, and toasts share one column. pad_block() { local n="${1:-$LEFT_MARGIN}" local pad="" (( n > 0 )) && printf -v pad '%*s' "$n" '' local line while IFS= read -r line; do printf '%s%s\n' "$pad" "$line" done } # Centred wrapper around gum choose: positions the menu horizontally so it # sits inside the panel column. gum draws at column 0 with no alignment flag, # but it reserves the cursor string's width as the gutter for unselected rows, # so baking padding into the cursor prefix shifts the whole rendered block. # Header is padded separately so it sits above the items at the same offset. # # Usage: cmenu "Header text" "Item 1" "Item 2" ... # Cancellation (Esc/Ctrl-C) returns empty just like gchoose. cmenu() { refresh_cols local header="$1"; shift # Left-align the menu at the panel's left edge so the "> " cursor and item # labels sit in the same column as the state panel's section headers and # rows above. Centering items within the panel would put them in the middle # of the panel — visually disconnected from the state panel column. local pad="$LEFT_PAD" gchoose --cursor "${pad}> " --header "${pad}${header}" "$@" } # ------------------------------------------------------------------------------ # Conf helpers — read straight from disk; writes go through pending_* below so # nothing hits the file until the user explicitly saves. # ------------------------------------------------------------------------------ conf_get() { local key="$1" [[ -f "$CONF" ]] || return 0 # awk (not grep|tail|cut) so a missing key yields empty + exit 0 rather than # tripping pipefail and killing the TUI under `set -e`. awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/,""); v=$0 } END { print v }' "$CONF" } # Effective value = pending change if any, else what's on disk. Used by the # UI to show what the conf will look like after Save. effective() { local key="$1" if [[ -n "${PENDING_UNSET[$key]:-}" ]]; then echo "" elif [[ -n "${PENDING_SET[$key]+x}" ]]; then echo "${PENDING_SET[$key]}" else conf_get "$key" fi } pending_set() { local key="$1" value="$2" PENDING_SET["$key"]="$value" unset 'PENDING_UNSET[$key]' } pending_unset() { local key="$1" PENDING_UNSET["$key"]=1 unset 'PENDING_SET[$key]' } has_pending_changes() { (( ${#PENDING_SET[@]} > 0 )) || (( ${#PENDING_UNSET[@]} > 0 )) } # Write all buffered changes to disk in one pass. Preserves the order/contents # of keys we don't manage. flush_pending() { has_pending_changes || return 0 mkdir -p "$(dirname "$CONF")" touch "$CONF" for key in "${!PENDING_UNSET[@]}"; do sed -i "/^${key}=/d" "$CONF" done for key in "${!PENDING_SET[@]}"; do local value="${PENDING_SET[$key]}" if grep -qE "^${key}=" "$CONF"; then sed -i "s|^${key}=.*|${key}=${value}|" "$CONF" else echo "${key}=${value}" >> "$CONF" fi done # environment.d/*.conf is parsed by `systemd --user` at user-manager startup, # so edits made here don't reach gamescope-session-plus@.service until the # next login unless we nudge systemd. import-environment pulls the keys we # just wrote into the running user manager scope so the next Gaming Mode # launch sees them without a re-login. if [[ ${#PENDING_SET[@]} -gt 0 ]]; then # shellcheck disable=SC2046 systemctl --user import-environment $(printf '%s ' "${!PENDING_SET[@]}") 2>/dev/null || true fi if [[ ${#PENDING_UNSET[@]} -gt 0 ]]; then systemctl --user unset-environment "${!PENDING_UNSET[@]}" 2>/dev/null || true fi } # ------------------------------------------------------------------------------ # Hardware detection # ------------------------------------------------------------------------------ # Cached hyprctl data — populated by refresh_monitor_data, used by all menus. # HYPR_MODES = newline-separated WIDTHxHEIGHT@RATEHz strings for the active # connector. HYPR_NATIVE / HYPR_MAX_REFRESH = monitor's max capability. HYPR_MODES="" HYPR_NATIVE="" HYPR_MAX_REFRESH="" HYPR_ACTIVE_CONNECTOR="" # List every connected monitor as "NAME|WIDTHxHEIGHT". hyprctl monitors all # returns every connected output (enabled or not), which is what we want. list_connected_monitors() { hyprctl monitors all -j 2>/dev/null \ | jq -r '.[] | select(.disabled==false or .disabled==true) | "\(.name)|\((.availableModes // [])[0] // "" | sub("@.*"; ""))"' } # Refresh the cached mode data for the connector we're targeting. Falls back # to the first connected output if no OUTPUT_CONNECTOR is set in the buffer/conf. refresh_monitor_data() { local target target=$(effective OUTPUT_CONNECTOR) if [[ -z "$target" ]]; then target=$(hyprctl monitors -j 2>/dev/null | jq -r '.[0].name // empty') fi HYPR_ACTIVE_CONNECTOR="$target" if [[ -z "$target" ]]; then HYPR_MODES=""; HYPR_NATIVE=""; HYPR_MAX_REFRESH="" return fi HYPR_MODES=$(hyprctl monitors all -j 2>/dev/null \ | jq -r --arg n "$target" '.[] | select(.name==$n) | .availableModes[]?') # Configured connector might not currently be plugged in (e.g. external # display unplugged). Bail out cleanly so the rest of the function doesn't # run grep against empty input and trip pipefail under set -e. if [[ -z "$HYPR_MODES" ]]; then HYPR_NATIVE=""; HYPR_MAX_REFRESH="" return fi HYPR_NATIVE=$(echo "$HYPR_MODES" | head -1 | sed 's/@.*//') # Max refresh AT NATIVE resolution, rounded to integer Hz for display. # (Reporting the max across all modes would mislead — e.g. a 4K@60 panel # might show 100Hz at 1024x768, which isn't useful info for Gaming Mode.) HYPR_MAX_REFRESH=$(echo "$HYPR_MODES" \ | grep "^${HYPR_NATIVE}@" \ | awk -F'@' '{ sub(/Hz$/,"",$2); printf "%d\n", $2 + 0.5 }' \ | sort -un | tail -1) } # Unique list of resolutions supported by the active connector, ordered # largest first. list_supported_resolutions() { echo "$HYPR_MODES" | sed 's/@.*//' | awk '!seen[$0]++' } # Refresh rates supported at a given resolution (or all rates if no res given). # Output is integer Hz, deduplicated (60.00 and 59.94 both round to 60). list_supported_refresh_rates() { local res="${1:-}" if [[ -n "$res" ]]; then echo "$HYPR_MODES" | grep -E "^${res}@" else echo "$HYPR_MODES" fi | awk -F'@' '{ sub(/Hz$/,"",$2); printf "%d\n", $2 + 0.5 }' \ | sort -un } # Validation — does the requested mode fit the monitor's reported limits? resolution_supported() { local w="$1" h="$2" [[ -z "$HYPR_NATIVE" ]] && return 0 # unknown → don't block local nw=${HYPR_NATIVE%%x*} local nh=${HYPR_NATIVE##*x} (( w <= nw )) && (( h <= nh )) } refresh_rate_supported() { local rate="$1" [[ -z "$HYPR_MODES" ]] && return 0 echo "$HYPR_MODES" \ | awk -F'@' -v r="$rate" '{ sub(/Hz$/,"",$2); if (int($2+0.5) == int(r+0.5)) found=1 } END { exit !found }' } list_gpus() { local pci_slot vendor_dev kind label /usr/bin/lspci -nn | grep -iE 'vga|3d|display' | while read -r line; do pci_slot=$(awk '{print $1}' <<<"$line") vendor_dev=$(grep -oP '\[\K[0-9a-fA-F]{4}:[0-9a-fA-F]{4}(?=\])' <<<"$line" | tail -1) if grep -qi nvidia <<<"$line"; then kind="nvidia" elif grep -qiE 'amd|advanced micro|radeon' <<<"$line"; then kind="amd" elif grep -qi intel <<<"$line"; then kind="intel" else kind="other" fi label=$(sed -E 's/.*: //; s/ \[[0-9a-f:]+\] \(rev [0-9a-f]+\)$//; s/ \[[0-9a-f:]+\]$//' <<<"$line") echo "${pci_slot}|${vendor_dev}|${kind}|${label}" done } # ------------------------------------------------------------------------------ # Display — show current/effective values, marking pending edits. # ------------------------------------------------------------------------------ show_state() { local connector width height refresh vk_adapter dri_prime prime_offload mesa_vk_select disable_connector connector=$(effective OUTPUT_CONNECTOR) width=$(effective SCREEN_WIDTH) height=$(effective SCREEN_HEIGHT) refresh=$(effective CUSTOM_REFRESH_RATES) # Stored as a comma list (e.g. "60,165") so gamescope has a safe 60 Hz # fallback. The user only cares about the rate they picked — the highest # member of the list. if [[ "$refresh" == *,* ]]; then refresh=$(tr ',' '\n' <<<"$refresh" | sort -nr | head -1) fi vk_adapter=$(effective VULKAN_ADAPTER) dri_prime=$(effective DRI_PRIME) prime_offload=$(effective __NV_PRIME_RENDER_OFFLOAD) 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, # since the modes are mutually exclusive. AMD hybrid is identified by both # DRI_PRIME and MESA_VK_DEVICE_SELECT being set (the TUI sets both together); # plain DRI_PRIME without MESA_VK_DEVICE_SELECT is "AMD/Intel direct". local gpu_mode="" if [[ -n "$prime_offload" ]]; then gpu_mode="Hybrid PRIME offload (NVIDIA via iGPU)" elif [[ -n "$dri_prime" && -n "$mesa_vk_select" ]]; then gpu_mode="Hybrid PRIME offload (AMD via iGPU → ${mesa_vk_select})" elif [[ -n "$vk_adapter" ]]; then gpu_mode="NVIDIA direct (${vk_adapter})" elif [[ -n "$dri_prime" ]]; then gpu_mode="AMD/Intel direct (${dri_prime})" fi local pending_label="" has_pending_changes && pending_label=" $(gum style --foreground 214 '(unsaved changes)')" local monitor_label="${connector:-}" if [[ -n "$HYPR_ACTIVE_CONNECTOR" && -n "$HYPR_NATIVE" ]]; then monitor_label="${monitor_label} (max ${HYPR_NATIVE} @ ${HYPR_MAX_REFRESH:-?}Hz)" fi # Build resolution label so an unset value renders as "" (matching the # other unset placeholders) rather than "?x?". local resolution_label="" if [[ -n "$width" && -n "$height" ]]; then resolution_label="${width}x${height}" fi # Replace $HOME with ~ so the config path fits the panel column. local conf_display="${CONF/#$HOME/\~}" cat <} Hz GPU mode : ${gpu_mode} Hide monitor : ${disable_connector:-} Config file : ${conf_display} EOF } # ------------------------------------------------------------------------------ # Setters — each one updates the in-memory pending buffer only. # ------------------------------------------------------------------------------ choose_monitor() { local choice connector mapfile -t connected < <(list_connected_monitors) local -a labels=() for entry in "${connected[@]}"; do labels+=("$entry") done labels+=("(clear / let gamescope auto-pick)") if (( ${#connected[@]} == 0 )); then gum confirm "No connected monitors detected. Set OUTPUT_CONNECTOR manually?" || return 0 connector=$(ginput --prompt "Connector (e.g. DP-1, HDMI-A-1): ") [[ -z "$connector" ]] && return 0 pending_set OUTPUT_CONNECTOR "$connector" return 0 fi choice=$(cmenu "Select monitor for Gaming Mode" "${labels[@]}") [[ -z "$choice" ]] && return 0 if [[ "$choice" == "(clear"* ]]; then pending_unset OUTPUT_CONNECTOR else pending_set OUTPUT_CONNECTOR "${choice%%|*}" 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() { refresh_monitor_data local choice w h local -a options=() # 1. Monitor-supported resolutions (top of the list, always safe) if [[ -n "$HYPR_MODES" ]]; then while read -r res; do [[ -z "$res" ]] && continue options+=("$res (supported by ${HYPR_ACTIVE_CONNECTOR})") done < <(list_supported_resolutions) fi # 2. Common presets — mark anything that exceeds the detected max local -a presets=("1280x720" "1920x1080" "2560x1440" "3840x2160") local -A already_listed=() while read -r r; do already_listed["$r"]=1; done < <(list_supported_resolutions) for preset in "${presets[@]}"; do [[ -n "${already_listed[$preset]:-}" ]] && continue local pw=${preset%%x*} ph=${preset##*x} if resolution_supported "$pw" "$ph"; then options+=("$preset (preset)") else options+=("$preset (preset — [unsupported by monitor])") fi done options+=("Custom…") choice=$(cmenu "Select launch resolution (max: ${HYPR_NATIVE:-unknown})" "${options[@]}") [[ -z "$choice" ]] && return 0 if [[ "$choice" == "Custom…" ]]; then w=$(ginput --prompt "Width: " --placeholder "2560") h=$(ginput --prompt "Height: " --placeholder "1440") else local mode=${choice%% *} w=${mode%%x*} h=${mode##*x} fi [[ -z "$w" || -z "$h" ]] && return 0 pending_set SCREEN_WIDTH "$w" pending_set SCREEN_HEIGHT "$h" } choose_refresh_rate() { refresh_monitor_data local choice rate # Use the buffered/saved resolution to filter rates if set, otherwise # show all rates this monitor supports across any resolution. local current_w current_h res_filter="" current_w=$(effective SCREEN_WIDTH) current_h=$(effective SCREEN_HEIGHT) if [[ -n "$current_w" && -n "$current_h" ]]; then res_filter="${current_w}x${current_h}" fi local -a options=() local -A supported_set=() # 1. Rates supported at the active resolution (top of list) while read -r r; do [[ -z "$r" ]] && continue supported_set["$r"]=1 if [[ -n "$res_filter" ]]; then options+=("$r (supported at ${res_filter})") else options+=("$r (supported by ${HYPR_ACTIVE_CONNECTOR})") fi done < <(list_supported_refresh_rates "$res_filter") # 2. Common presets — mark unsupported local -a presets=(60 75 100 120 144 165 240) for preset in "${presets[@]}"; do [[ -n "${supported_set[$preset]:-}" ]] && continue if refresh_rate_supported "$preset"; then options+=("$preset (preset)") else options+=("$preset (preset — [unsupported by monitor])") fi done options+=("Custom…") choice=$(cmenu "Select refresh rate (Hz, max: ${HYPR_MAX_REFRESH:-unknown})" "${options[@]}") [[ -z "$choice" ]] && return 0 if [[ "$choice" == "Custom…" ]]; then rate=$(ginput --prompt "Rate (Hz): " --placeholder "144") else rate=${choice%% *} fi [[ -z "$rate" ]] && return 0 # gamescope-session-plus passes this env value to gamescope as # --custom-refresh-rates, which is a list of *switchable* rates the user can # cycle between in Steam Big Picture. Writing a single value (e.g. "165") can # leave gamescope without a safe 60 Hz fallback if the monitor's EDID # preferred mode is 60 Hz and the high-rate mode isn't enumerated on first # launch — most visibly on NVIDIA HDMI outputs. Always include 60 as the # floor so Steam can fall back and the user can switch up to their pick. local rate_list="$rate" [[ "$rate" != "60" ]] && rate_list="60,${rate}" pending_set CUSTOM_REFRESH_RATES "$rate_list" } # Keys this menu owns. Cleared at the start of every selection so switching # between modes never leaves stale flags behind. GPU_MODE_KEYS=( VULKAN_ADAPTER GBM_BACKEND DRI_PRIME __NV_PRIME_RENDER_OFFLOAD __VK_LAYER_NV_optimus __GLX_VENDOR_LIBRARY_NAME MESA_VK_DEVICE_SELECT MESA_VK_DEVICE_SELECT_FORCE_DEFAULT_DEVICE ) # Convert a PCI slot like "01:00.0" or "0000:c3:00.0" to the DRI_PRIME tag # format ("pci-0000_01_00_0"). Used by AMD/Intel paths. _pci_slot_to_dri_tag() { local slot="$1" local tag="pci-$(echo "$slot" | sed 's/[:.]/_/g')" [[ "$tag" != pci-0000* ]] && tag="pci-0000_${tag#pci-}" echo "$tag" } choose_gpu() { local choice mapfile -t gpus < <(list_gpus) if (( ${#gpus[@]} == 0 )); then gum style --foreground 196 "No GPUs detected via lspci" return 1 fi # Hybrid detection. Two patterns: # - hybrid-nvidia: NVIDIA dGPU + (AMD or Intel) iGPU. eDP usually on iGPU. # Games offload to NVIDIA via __NV_PRIME_RENDER_OFFLOAD env vars. # - hybrid-amd: 2+ non-NVIDIA GPUs (AMD+AMD or AMD+Intel). User picks which # is the dGPU; games offload via DRI_PRIME + MESA_VK_DEVICE_SELECT. local has_nvidia=false has_igpu=false local non_nvidia_count=0 local entry slot vendor_dev kind label for entry in "${gpus[@]}"; do IFS='|' read -r slot vendor_dev kind label <<<"$entry" case "$kind" in nvidia) has_nvidia=true ;; amd|intel|other) has_igpu=true non_nvidia_count=$((non_nvidia_count + 1)) ;; esac done local hybrid_nvidia_available=false hybrid_amd_available=false $has_nvidia && $has_igpu && hybrid_nvidia_available=true (( non_nvidia_count >= 2 )) && hybrid_amd_available=true local -a labels=() $hybrid_nvidia_available && labels+=("[hybrid-nvidia] NVIDIA render-offload via iGPU — for hybrid laptops (eDP on iGPU)") $hybrid_amd_available && labels+=("[hybrid-amd] AMD/Intel render-offload — pick which GPU to offload games to") for entry in "${gpus[@]}"; do IFS='|' read -r slot vendor_dev kind label <<<"$entry" labels+=("[$kind] $label ($vendor_dev @ $slot)") done labels+=("(clear GPU override — let system decide)") choice=$(cmenu "Select GPU for Gaming Mode" "${labels[@]}") [[ -z "$choice" ]] && return 0 # Wipe every GPU-mode key first; each branch sets only what it needs. local k for k in "${GPU_MODE_KEYS[@]}"; do pending_unset "$k"; done case "$choice" in "[hybrid-nvidia]"*) pending_set __NV_PRIME_RENDER_OFFLOAD 1 pending_set __VK_LAYER_NV_optimus NVIDIA_only pending_set __GLX_VENDOR_LIBRARY_NAME nvidia return 0 ;; "[hybrid-amd]"*) # Sub-menu — pick which AMD/Intel GPU is the render target (dGPU). local -a t_labels=() t_entries=() for entry in "${gpus[@]}"; do IFS='|' read -r slot vendor_dev kind label <<<"$entry" case "$kind" in amd|intel|other) t_labels+=("[$kind] $label ($vendor_dev @ $slot)") t_entries+=("$entry") ;; esac done local t_choice t_entry="" t_choice=$(cmenu "Pick dGPU (render target) — usually the discrete one" "${t_labels[@]}") [[ -z "$t_choice" ]] && return 0 local i for ((i=0; i<${#t_labels[@]}; i++)); do if [[ "${t_labels[$i]}" == "$t_choice" ]]; then t_entry="${t_entries[$i]}" break fi done [[ -z "$t_entry" ]] && return 0 IFS='|' read -r slot vendor_dev kind label <<<"$t_entry" pending_set DRI_PRIME "$(_pci_slot_to_dri_tag "$slot")" pending_set MESA_VK_DEVICE_SELECT "$vendor_dev" pending_set MESA_VK_DEVICE_SELECT_FORCE_DEFAULT_DEVICE 1 return 0 ;; "(clear"*) return 0 ;; esac # Direct GPU selection — match the chosen label back to its gpus[] entry. local selected="" for entry in "${gpus[@]}"; do IFS='|' read -r slot vendor_dev kind label <<<"$entry" if [[ "[$kind] $label ($vendor_dev @ $slot)" == "$choice" ]]; then selected="$entry" break fi done [[ -z "$selected" ]] && return 0 IFS='|' read -r slot vendor_dev kind label <<<"$selected" case "$kind" in nvidia) pending_set VULKAN_ADAPTER "$vendor_dev" pending_set GBM_BACKEND nvidia-drm ;; amd|intel|other) pending_set DRI_PRIME "$(_pci_slot_to_dri_tag "$slot")" ;; esac } # Pre-save validation. Compares the about-to-be-saved values against the # active monitor's reported capabilities (via hyprctl) and asks for explicit # confirmation if anything looks risky. Returns 0 = ok to save, 1 = cancelled. confirm_risky_save() { refresh_monitor_data [[ -z "$HYPR_MODES" ]] && return 0 local w h rate w=$(effective SCREEN_WIDTH) h=$(effective SCREEN_HEIGHT) rate=$(effective CUSTOM_REFRESH_RATES) # Check the user's actual pick (highest of the comma list) against monitor # support — not the bare list, which won't match a single mode line. if [[ "$rate" == *,* ]]; then rate=$(tr ',' '\n' <<<"$rate" | sort -nr | head -1) fi local -a warnings=() if [[ -n "$w" && -n "$h" ]] && ! resolution_supported "$w" "$h"; then warnings+=("• Resolution ${w}x${h} exceeds detected monitor max ${HYPR_NATIVE}") fi if [[ -n "$rate" ]] && ! refresh_rate_supported "$rate"; then warnings+=("• Refresh rate ${rate}Hz is not in the monitor's supported list (max: ${HYPR_MAX_REFRESH:-?}Hz)") fi (( ${#warnings[@]} == 0 )) && return 0 refresh_cols clear echo "" gum style --foreground 196 --bold "Warning — selected values may not work:" | pad_block echo "" printf ' %s\n' "${warnings[@]}" | pad_block echo "" gum style --foreground 244 "If Gaming Mode shows a black screen, press Super+Shift+R to return to desktop." | pad_block echo "" gum confirm "Save anyway?" --default=false } # ------------------------------------------------------------------------------ # Main loop — buffer until Save and exit / Cancel. # ------------------------------------------------------------------------------ main() { while true; do refresh_cols refresh_monitor_data clear echo "" gum style \ --border double --padding "1 0" --border-foreground 212 \ --width "$PANEL_WIDTH" --align center \ "DECKSHIFT — Gaming Mode Settings" \ | pad_block echo "" show_state | pad_block echo "" local save_label="Save and exit" local cancel_label="Cancel (exit)" if has_pending_changes; then save_label="Save and exit ★" cancel_label="Discard changes and exit" fi local action action=$(cmenu "What do you want to change?" \ "Monitor" \ "Resolution" \ "Refresh rate" \ "GPU" \ "Hide monitor" \ "$save_label" \ "$cancel_label") case "$action" in "Monitor") choose_monitor ;; "Resolution") choose_resolution ;; "Refresh rate") choose_refresh_rate ;; "GPU") choose_gpu ;; "Hide monitor") choose_monitor_to_disable ;; "Save and exit"*) if ! confirm_risky_save; then continue fi flush_pending clear echo "" 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 sleep 1 return 0 ;; "Cancel (exit)"|"Discard changes and exit") if has_pending_changes; then gum confirm "Discard unsaved changes?" --default=true || continue fi clear echo "" gum style --foreground 244 "No changes saved." | pad_block sleep 1 return 0 ;; "") # Esc / Ctrl-C on the main menu — stay on main rather than exit. # User must pick "Cancel" or "Save and exit" explicitly to leave. continue ;; esac done } main "$@"