diff --git a/dotfiles/config/hypr/hyprland.conf b/dotfiles/config/hypr/hyprland.conf index 6e2ba66a..28312bc5 100644 --- a/dotfiles/config/hypr/hyprland.conf +++ b/dotfiles/config/hypr/hyprland.conf @@ -444,8 +444,10 @@ bind = $mainMod CTRL, 8, workspace, 8 bind = $mainMod CTRL, 9, movetoworkspacesilent, 9 bind = $mainMod CTRL, 9, workspace, 9 -# Workspace cycling (like XMonad's cycleWorkspaceOnCurrentScreen) -bind = $mainMod, backslash, exec, ~/.config/hypr/scripts/workspace-back.sh +# Workspace cycling with monitor-local history and commit-on-release semantics. +bind = $mainMod, backslash, exec, ~/.config/hypr/scripts/workspace-back.sh cycle +bindr = , SUPER_L, exec, ~/.config/hypr/scripts/workspace-back.sh finalize +bindr = , SUPER_R, exec, ~/.config/hypr/scripts/workspace-back.sh finalize # Swap current workspace with another (like XMonad's swapWithCurrent) bind = $hyper, 5, exec, ~/.config/hypr/scripts/swap-workspaces.sh diff --git a/dotfiles/config/hypr/scripts/workspace-back.sh b/dotfiles/config/hypr/scripts/workspace-back.sh index 490d91b1..1e51a1c3 100755 --- a/dotfiles/config/hypr/scripts/workspace-back.sh +++ b/dotfiles/config/hypr/scripts/workspace-back.sh @@ -1,19 +1,71 @@ #!/usr/bin/env bash set -euo pipefail -max_ws="${HYPR_MAX_WORKSPACE:-9}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=workspace-history-common.sh +# shellcheck source-path=SCRIPTDIR +source "${script_dir}/workspace-history-common.sh" -runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" -state_dir="${runtime_dir}/hypr" -prev_file="${state_dir}/prev-workspace" +action="${1:-cycle}" -prev="$(cat "${prev_file}" 2>/dev/null || true)" -if [[ -z "${prev}" ]]; then - exit 0 +exec 9>"${lock_file}" +flock 9 + +state="$(wh_load_state)" +monitors_json="$(wh_monitors_json)" +state="$(wh_refresh_state_json "${state}" "${monitors_json}")" + +focused_monitor="$( + jq -r '.[] | select(.focused == true) | .name // empty' <<<"${monitors_json}" | head -n 1 +)" + +if [[ -z "${focused_monitor}" ]]; then + wh_save_state "${state}" + exit 0 fi -if [[ "${prev}" =~ ^[0-9]+$ ]] && (( prev < 1 || prev > max_ws )); then - exit 0 -fi +current_workspace="$(jq -r --arg monitor "${focused_monitor}" '.monitorCurrent[$monitor] // empty' <<<"${state}")" -hyprctl dispatch workspace "${prev}" >/dev/null 2>&1 || true +case "${action}" in +cycle) + next_workspace="$( + jq -r \ + --arg monitor "${focused_monitor}" \ + --arg current "${current_workspace}" \ + ' + (.monitorHistory[$monitor] // []) as $history + | if ($history | length) < 2 then + "" + else + ($history | index($current)) as $idx + | if $idx == null then + "" + else + $history[(($idx + 1) % ($history | length))] + end + end + ' <<<"${state}" + )" + + if [[ -z "${next_workspace}" || "${next_workspace}" == "${current_workspace}" ]]; then + wh_save_state "${state}" + exit 0 + fi + + state="$( + jq -c \ + --arg monitor "${focused_monitor}" \ + '.cycle = {"active": true, "monitor": $monitor}' <<<"${state}" + )" + wh_save_state "${state}" + hyprctl dispatch workspace "${next_workspace}" >/dev/null 2>&1 || true + ;; +finalize) + state="$(wh_finalize_cycle_json "${state}")" + wh_save_state "${state}" + ;; +*) + printf 'Unknown action: %s\n' "${action}" >&2 + exit 1 + ;; +esac diff --git a/dotfiles/config/hypr/scripts/workspace-history-common.sh b/dotfiles/config/hypr/scripts/workspace-history-common.sh new file mode 100644 index 00000000..f3f9e1d5 --- /dev/null +++ b/dotfiles/config/hypr/scripts/workspace-history-common.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +max_ws="${HYPR_MAX_WORKSPACE:-9}" + +runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +state_dir="${HYPR_WORKSPACE_STATE_DIR:-${runtime_dir}/hypr}" +state_file="${state_dir}/workspace-history.json" +# shellcheck disable=SC2034 # Sourced by sibling scripts that coordinate updates. +lock_file="${state_dir}/workspace-history.lock" + +mkdir -p "${state_dir}" + +wh_default_state() { + cat <<'EOF' +{"monitorCurrent":{},"monitorHistory":{},"cycle":{"active":false}} +EOF +} + +wh_load_state() { + local state + if [[ -s "${state_file}" ]] && state="$(jq -c '.' "${state_file}" 2>/dev/null)"; then + printf '%s\n' "${state}" + return + fi + + wh_default_state +} + +wh_save_state() { + local state="$1" + local tmp_file + + tmp_file="$(mktemp "${state_file}.XXXXXX")" + printf '%s\n' "${state}" >"${tmp_file}" + mv "${tmp_file}" "${state_file}" +} + +wh_normalize_workspace() { + local ws_id="${1:-}" + local ws_name="${2:-}" + + if [[ -n "${ws_name}" && "${ws_name}" != "null" && "${ws_name}" != special:* ]]; then + printf '%s\n' "${ws_name}" + return + fi + + if [[ -z "${ws_id}" || "${ws_id}" == "null" || "${ws_id}" =~ ^- ]]; then + return + fi + + if [[ "${ws_id}" =~ ^[0-9]+$ ]] && ((ws_id >= 1 && ws_id <= max_ws)); then + printf '%s\n' "${ws_id}" + fi +} + +wh_monitors_json() { + hyprctl -j monitors 2>/dev/null || printf '[]\n' +} + +wh_refresh_state_json() { + local state="$1" + local monitors_json="$2" + local row + local monitor + local ws_id + local ws_name + local workspace + + while IFS= read -r row; do + [[ -z "${row}" ]] && continue + + monitor="$(jq -r '.name // empty' <<<"${row}")" + ws_id="$(jq -r '.activeWorkspace.id // empty' <<<"${row}")" + ws_name="$(jq -r '.activeWorkspace.name // empty' <<<"${row}")" + workspace="$(wh_normalize_workspace "${ws_id}" "${ws_name}")" + + [[ -z "${monitor}" || -z "${workspace}" ]] && continue + + state="$( + jq -c \ + --arg monitor "${monitor}" \ + --arg workspace "${workspace}" \ + ' + .monitorCurrent[$monitor] = $workspace + | if .cycle.active == true and .cycle.monitor == $monitor then + . + else + .monitorHistory[$monitor] = + ([$workspace] + ((.monitorHistory[$monitor] // []) | map(select(. != $workspace)))) + end + ' <<<"${state}" + )" + done < <(jq -c '.[]' <<<"${monitors_json}") + + printf '%s\n' "${state}" +} + +wh_finalize_cycle_json() { + local state="$1" + local monitor + local current + + if [[ "$(jq -r '.cycle.active // false' <<<"${state}")" != "true" ]]; then + printf '%s\n' "${state}" + return + fi + + monitor="$(jq -r '.cycle.monitor // empty' <<<"${state}")" + current="$(jq -r --arg monitor "${monitor}" '.monitorCurrent[$monitor] // empty' <<<"${state}")" + + if [[ -n "${monitor}" && -n "${current}" ]]; then + state="$( + jq -c \ + --arg monitor "${monitor}" \ + --arg current "${current}" \ + ' + .monitorHistory[$monitor] = + ([$current] + ((.monitorHistory[$monitor] // []) | map(select(. != $current)))) + | .cycle = {"active": false} + ' <<<"${state}" + )" + else + state="$(jq -c '.cycle = {"active": false}' <<<"${state}")" + fi + + printf '%s\n' "${state}" +} diff --git a/dotfiles/config/hypr/scripts/workspace-history.sh b/dotfiles/config/hypr/scripts/workspace-history.sh index 47978758..77ab43e0 100755 --- a/dotfiles/config/hypr/scripts/workspace-history.sh +++ b/dotfiles/config/hypr/scripts/workspace-history.sh @@ -1,67 +1,46 @@ #!/usr/bin/env bash set -euo pipefail -max_ws="${HYPR_MAX_WORKSPACE:-9}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=workspace-history-common.sh +# shellcheck source-path=SCRIPTDIR +source "${script_dir}/workspace-history-common.sh" runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" sig="${HYPRLAND_INSTANCE_SIGNATURE:-}" if [[ -z "$sig" ]]; then - exit 0 + exit 0 fi sock="${runtime_dir}/hypr/${sig}/.socket2.sock" -state_dir="${runtime_dir}/hypr" -last_file="${state_dir}/last-workspace" -prev_file="${state_dir}/prev-workspace" -mkdir -p "${state_dir}" +with_history_lock() { + exec 9>"${lock_file}" + flock 9 + "$@" +} -# Initialize current workspace to avoid empty state. -if command -v hyprctl >/dev/null 2>&1; then - cur_id="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)" - if [[ -n "${cur_id}" && "${cur_id}" != "null" ]]; then - echo "${cur_id}" > "${last_file}" - fi -fi +refresh_history_state() { + local state + local monitors_json + + state="$(wh_load_state)" + monitors_json="$(wh_monitors_json)" + state="$(wh_refresh_state_json "${state}" "${monitors_json}")" + wh_save_state "${state}" +} + +with_history_lock refresh_history_state # Wait for the event socket to be ready. while [[ ! -S "${sock}" ]]; do - sleep 0.2 + sleep 0.2 done nc -U "${sock}" | while read -r line; do - case "${line}" in - workspace*">>"*) - payload="${line#*>>}" - # Handle workspacev2 payloads: id,name - if [[ "${payload}" == *","* ]]; then - ws_id="${payload%%,*}" - ws_name="${payload#*,}" - else - ws_id="${payload}" - ws_name="${payload}" - fi - - # Ignore special/negative workspaces. - if [[ "${ws_id}" =~ ^- ]] || [[ "${ws_name}" == special:* ]]; then - 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}" - fi - - prev="$(cat "${last_file}" 2>/dev/null || true)" - if [[ -n "${prev}" && "${ws_ident}" != "${prev}" ]]; then - echo "${prev}" > "${prev_file}" - fi - echo "${ws_ident}" > "${last_file}" - ;; - esac + case "${line}" in + workspace*">>"*) + with_history_lock refresh_history_state + ;; + esac done