From 0c6363c793e9382c64ae531d0859cf27050c6878 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 5 Feb 2026 12:03:16 -0800 Subject: [PATCH] hyprland: cap workspaces and add empty-workspace helpers - Introduce HYPR_MAX_WORKSPACE (default 9) and enforce it in scripts - Replace 'workspace empty' bindings with scripts that pick an empty id - Add scroll-to-workspace helper for mouse wheel binds --- dotfiles/config/hypr/hyprland.conf | 16 ++--- .../hypr/scripts/find-empty-workspace.sh | 72 +++++++++++++++++++ .../hypr/scripts/shift-to-empty-on-screen.sh | 18 +++-- .../config/hypr/scripts/swap-workspaces.sh | 9 ++- .../config/hypr/scripts/workspace-back.sh | 6 ++ .../hypr/scripts/workspace-goto-empty.sh | 16 +++++ .../config/hypr/scripts/workspace-history.sh | 7 ++ .../hypr/scripts/workspace-move-to-empty.sh | 16 +++++ .../config/hypr/scripts/workspace-scroll.sh | 42 +++++++++++ 9 files changed, 185 insertions(+), 17 deletions(-) create mode 100755 dotfiles/config/hypr/scripts/find-empty-workspace.sh create mode 100755 dotfiles/config/hypr/scripts/workspace-goto-empty.sh create mode 100755 dotfiles/config/hypr/scripts/workspace-move-to-empty.sh create mode 100755 dotfiles/config/hypr/scripts/workspace-scroll.sh diff --git a/dotfiles/config/hypr/hyprland.conf b/dotfiles/config/hypr/hyprland.conf index cb3f7274..faaa2280 100644 --- a/dotfiles/config/hypr/hyprland.conf +++ b/dotfiles/config/hypr/hyprland.conf @@ -27,6 +27,8 @@ $runMenu = rofi -show run # ============================================================================= env = XCURSOR_SIZE,24 env = QT_QPA_PLATFORMTHEME,qt5ct +# Used by ~/.config/hypr/scripts/* to keep workspace IDs bounded. +env = HYPR_MAX_WORKSPACE,9 # ============================================================================= # INPUT CONFIGURATION @@ -405,7 +407,7 @@ bind = $modAlt, Return, togglespecialworkspace, minimized # WORKSPACE CONTROL # ----------------------------------------------------------------------------- -# Switch workspaces (1-9, 0=10) +# Switch workspaces (1-9 only) bind = $mainMod, 1, workspace, 1 bind = $mainMod, 2, workspace, 2 bind = $mainMod, 3, workspace, 3 @@ -415,7 +417,6 @@ bind = $mainMod, 6, workspace, 6 bind = $mainMod, 7, workspace, 7 bind = $mainMod, 8, workspace, 8 bind = $mainMod, 9, workspace, 9 -bind = $mainMod, 0, workspace, 10 # Move window to workspace bind = $mainMod SHIFT, 1, movetoworkspace, 1 @@ -427,7 +428,6 @@ bind = $mainMod SHIFT, 6, movetoworkspace, 6 bind = $mainMod SHIFT, 7, movetoworkspace, 7 bind = $mainMod SHIFT, 8, movetoworkspace, 8 bind = $mainMod SHIFT, 9, movetoworkspace, 9 -bind = $mainMod SHIFT, 0, movetoworkspace, 10 # Move and follow to workspace (like XMonad's shiftThenView) bind = $mainMod CTRL, 1, movetoworkspacesilent, 1 @@ -448,8 +448,6 @@ bind = $mainMod CTRL, 8, movetoworkspacesilent, 8 bind = $mainMod CTRL, 8, workspace, 8 bind = $mainMod CTRL, 9, movetoworkspacesilent, 9 bind = $mainMod CTRL, 9, workspace, 9 -bind = $mainMod CTRL, 0, movetoworkspacesilent, 10 -bind = $mainMod CTRL, 0, workspace, 10 # Workspace cycling (like XMonad's cycleWorkspaceOnCurrentScreen) bind = $mainMod, backslash, exec, ~/.config/hypr/scripts/workspace-back.sh @@ -458,14 +456,14 @@ bind = $mainMod, backslash, exec, ~/.config/hypr/scripts/workspace-back.sh bind = $hyper, 5, exec, ~/.config/hypr/scripts/swap-workspaces.sh # Go to next empty workspace (like XMonad's moveTo Next emptyWS) -bind = $hyper, E, workspace, empty +bind = $hyper, E, exec, ~/.config/hypr/scripts/workspace-goto-empty.sh # Move to next screen (like XMonad's shiftToNextScreenX) bind = $mainMod, Z, focusmonitor, +1 bind = $mainMod SHIFT, Z, movewindow, mon:+1 # Shift to empty workspace and view (like XMonad's shiftToEmptyAndView) -bind = $mainMod SHIFT, H, movetoworkspace, empty +bind = $mainMod SHIFT, H, exec, ~/.config/hypr/scripts/workspace-move-to-empty.sh # ----------------------------------------------------------------------------- # WINDOW MANAGEMENT @@ -543,8 +541,8 @@ bindm = $mainMod, mouse:272, movewindow bindm = $mainMod, mouse:273, resizewindow # Scroll through workspaces -bind = $mainMod, mouse_down, workspace, e+1 -bind = $mainMod, mouse_up, workspace, e-1 +bind = $mainMod, mouse_down, exec, ~/.config/hypr/scripts/workspace-scroll.sh +1 +bind = $mainMod, mouse_up, exec, ~/.config/hypr/scripts/workspace-scroll.sh -1 # ============================================================================= # AUTOSTART diff --git a/dotfiles/config/hypr/scripts/find-empty-workspace.sh b/dotfiles/config/hypr/scripts/find-empty-workspace.sh new file mode 100755 index 00000000..dbd2745b --- /dev/null +++ b/dotfiles/config/hypr/scripts/find-empty-workspace.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Print an "empty" workspace id within 1..$HYPR_MAX_WORKSPACE (default 9). +# +# Preference order (lowest id wins within each tier): +# 1. Workspace exists on the target monitor and has 0 windows +# 2. Workspace id does not exist at all (will be created on dispatch) +# 3. Workspace exists (elsewhere) and has 0 windows +# +# Usage: +# find-empty-workspace.sh [monitor] [exclude_id] + +max_ws="${HYPR_MAX_WORKSPACE:-9}" + +monitor="${1:-}" +exclude_id="${2:-}" + +if [[ -z "${monitor}" ]]; then + monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)" +fi + +if [[ -z "${monitor}" || "${monitor}" == "null" ]]; then + exit 1 +fi + +workspaces_json="$(hyprctl workspaces -j 2>/dev/null || echo '[]')" + +unused_candidate="" +elsewhere_empty_candidate="" + +for i in $(seq 1 "${max_ws}"); do + if [[ -n "${exclude_id}" && "${i}" == "${exclude_id}" ]]; then + continue + fi + + exists="$(jq -r --argjson id "${i}" '[.[] | select(.id == $id)] | length' <<<"${workspaces_json}")" + if [[ "${exists}" == "0" ]]; then + if [[ -z "${unused_candidate}" ]]; then + unused_candidate="${i}" + fi + continue + fi + + windows="$(jq -r --argjson id "${i}" '([.[] | select(.id == $id) | .windows] | .[0]) // 0' <<<"${workspaces_json}")" + if [[ "${windows}" != "0" ]]; then + continue + fi + + ws_monitor="$(jq -r --argjson id "${i}" '([.[] | select(.id == $id) | .monitor] | .[0]) // ""' <<<"${workspaces_json}")" + if [[ "${ws_monitor}" == "${monitor}" ]]; then + printf '%s\n' "${i}" + exit 0 + fi + + if [[ -z "${elsewhere_empty_candidate}" ]]; then + elsewhere_empty_candidate="${i}" + fi +done + +if [[ -n "${unused_candidate}" ]]; then + printf '%s\n' "${unused_candidate}" + exit 0 +fi + +if [[ -n "${elsewhere_empty_candidate}" ]]; then + printf '%s\n' "${elsewhere_empty_candidate}" + exit 0 +fi + +exit 1 + diff --git a/dotfiles/config/hypr/scripts/shift-to-empty-on-screen.sh b/dotfiles/config/hypr/scripts/shift-to-empty-on-screen.sh index 19ceb3c1..5bf26318 100755 --- a/dotfiles/config/hypr/scripts/shift-to-empty-on-screen.sh +++ b/dotfiles/config/hypr/scripts/shift-to-empty-on-screen.sh @@ -6,6 +6,7 @@ set -euo pipefail DIRECTION="$1" +max_ws="${HYPR_MAX_WORKSPACE:-9}" # Track the current monitor so we can return ORIG_MONITOR=$(hyprctl activeworkspace -j | jq -r '.monitor') @@ -21,14 +22,17 @@ if [ "$MONITOR" = "$ORIG_MONITOR" ]; then exit 0 fi -# Find an empty workspace or create one -# First check if there's an empty workspace on this monitor -EMPTY_WS=$(hyprctl workspaces -j | jq -r ".[] | select(.windows == 0 and .monitor == \"$MONITOR\") | .id" | head -1) +# Find an empty workspace within 1..$HYPR_MAX_WORKSPACE. +EMPTY_WS="$(~/.config/hypr/scripts/find-empty-workspace.sh "${MONITOR}" 2>/dev/null || true)" +if [[ -z "${EMPTY_WS}" ]]; then + # No empty workspace available within the cap; restore focus and bail. + hyprctl dispatch focusmonitor "$ORIG_MONITOR" + exit 0 +fi -if [ -z "$EMPTY_WS" ]; then - # No empty workspace, find next available workspace number - MAX_WS=$(hyprctl workspaces -j | jq -r 'map(.id) | max') - EMPTY_WS=$((MAX_WS + 1)) +if (( EMPTY_WS < 1 || EMPTY_WS > max_ws )); then + hyprctl dispatch focusmonitor "$ORIG_MONITOR" + exit 0 fi # Ensure the workspace exists on the target monitor diff --git a/dotfiles/config/hypr/scripts/swap-workspaces.sh b/dotfiles/config/hypr/scripts/swap-workspaces.sh index cc044817..8b9dc21a 100755 --- a/dotfiles/config/hypr/scripts/swap-workspaces.sh +++ b/dotfiles/config/hypr/scripts/swap-workspaces.sh @@ -4,6 +4,8 @@ set -euo pipefail +max_ws="${HYPR_MAX_WORKSPACE:-9}" + CURRENT_WS="$(hyprctl activeworkspace -j | jq -r '.id')" if [[ -z "${CURRENT_WS}" || "${CURRENT_WS}" == "null" ]]; then exit 0 @@ -13,7 +15,7 @@ TARGET_WS="${1:-}" if [[ -z "${TARGET_WS}" ]]; then WS_LIST="$({ - seq 1 10 + seq 1 "${max_ws}" hyprctl workspaces -j | jq -r '.[].id' 2>/dev/null || true } | awk 'NF {print $1}' | awk '!seen[$0]++' | sort -n)" @@ -33,6 +35,11 @@ if ! [[ "${TARGET_WS}" =~ ^-?[0-9]+$ ]]; then exit 1 fi +if (( TARGET_WS < 1 || TARGET_WS > max_ws )); then + notify-send "Swap Workspace" "Workspace out of range (1-${max_ws}): ${TARGET_WS}" + exit 1 +fi + WINDOWS_CURRENT="$(hyprctl clients -j | jq -r --arg ws "${CURRENT_WS}" '.[] | select((.workspace.id|tostring) == $ws) | .address')" WINDOWS_TARGET="$(hyprctl clients -j | jq -r --arg ws "${TARGET_WS}" '.[] | select((.workspace.id|tostring) == $ws) | .address')" diff --git a/dotfiles/config/hypr/scripts/workspace-back.sh b/dotfiles/config/hypr/scripts/workspace-back.sh index 94caf7bd..490d91b1 100755 --- a/dotfiles/config/hypr/scripts/workspace-back.sh +++ b/dotfiles/config/hypr/scripts/workspace-back.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +max_ws="${HYPR_MAX_WORKSPACE:-9}" + runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" state_dir="${runtime_dir}/hypr" prev_file="${state_dir}/prev-workspace" @@ -10,4 +12,8 @@ if [[ -z "${prev}" ]]; then exit 0 fi +if [[ "${prev}" =~ ^[0-9]+$ ]] && (( prev < 1 || prev > max_ws )); then + exit 0 +fi + hyprctl dispatch workspace "${prev}" >/dev/null 2>&1 || true diff --git a/dotfiles/config/hypr/scripts/workspace-goto-empty.sh b/dotfiles/config/hypr/scripts/workspace-goto-empty.sh new file mode 100755 index 00000000..df19301b --- /dev/null +++ b/dotfiles/config/hypr/scripts/workspace-goto-empty.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +cur_ws="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)" +monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)" + +ws="$( + ~/.config/hypr/scripts/find-empty-workspace.sh "${monitor}" "${cur_ws}" 2>/dev/null || true +)" + +if [[ -z "${ws}" ]]; then + exit 0 +fi + +hyprctl dispatch workspace "${ws}" >/dev/null 2>&1 || true + diff --git a/dotfiles/config/hypr/scripts/workspace-history.sh b/dotfiles/config/hypr/scripts/workspace-history.sh index 396b4d6c..47978758 100755 --- a/dotfiles/config/hypr/scripts/workspace-history.sh +++ b/dotfiles/config/hypr/scripts/workspace-history.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +max_ws="${HYPR_MAX_WORKSPACE:-9}" + runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" sig="${HYPRLAND_INSTANCE_SIGNATURE:-}" if [[ -z "$sig" ]]; then @@ -45,6 +47,11 @@ nc -U "${sock}" | while read -r line; do continue fi + # Ignore workspaces outside the configured cap. + if [[ "${ws_id}" =~ ^[0-9]+$ ]] && (( ws_id < 1 || ws_id > max_ws )); then + continue + fi + ws_ident="${ws_name}" if [[ -z "${ws_ident}" ]]; then ws_ident="${ws_id}" diff --git a/dotfiles/config/hypr/scripts/workspace-move-to-empty.sh b/dotfiles/config/hypr/scripts/workspace-move-to-empty.sh new file mode 100755 index 00000000..fcae51bf --- /dev/null +++ b/dotfiles/config/hypr/scripts/workspace-move-to-empty.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +cur_ws="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)" +monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)" + +ws="$( + ~/.config/hypr/scripts/find-empty-workspace.sh "${monitor}" "${cur_ws}" 2>/dev/null || true +)" + +if [[ -z "${ws}" ]]; then + exit 0 +fi + +hyprctl dispatch movetoworkspace "${ws}" >/dev/null 2>&1 || true + diff --git a/dotfiles/config/hypr/scripts/workspace-scroll.sh b/dotfiles/config/hypr/scripts/workspace-scroll.sh new file mode 100755 index 00000000..11ddd2de --- /dev/null +++ b/dotfiles/config/hypr/scripts/workspace-scroll.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +max_ws="${HYPR_MAX_WORKSPACE:-9}" +delta="${1:-}" + +case "${delta}" in + +1|-1) ;; + next) delta="+1" ;; + prev) delta="-1" ;; + *) + exit 2 + ;; +esac + +cur="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)" +if ! [[ "${cur}" =~ ^[0-9]+$ ]]; then + exit 0 +fi + +if (( cur < 1 )); then + cur=1 +elif (( cur > max_ws )); then + cur="${max_ws}" +fi + +if [[ "${delta}" == "+1" ]]; then + if (( cur >= max_ws )); then + nxt=1 + else + nxt=$((cur + 1)) + fi +else + if (( cur <= 1 )); then + nxt="${max_ws}" + else + nxt=$((cur - 1)) + fi +fi + +hyprctl dispatch workspace "${nxt}" >/dev/null 2>&1 || true +