Last active
January 18, 2026 12:15
-
-
Save svandragt/76f3844988e1cb02779093903f59fa49 to your computer and use it in GitHub Desktop.
Lower Active Window and Cycle Focus on the Same Monitor (X11)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| DEBUG="${DEBUG:-0}" | |
| LOG_FILE="${LOG_FILE:-$HOME/.cache/lower-cycle.log}" | |
| mkdir -p "$(dirname "$LOG_FILE")" | |
| ts() { date +'%H:%M:%S'; } | |
| log() { (( DEBUG >= 1 )) && printf '[%s] %s\n' "$(ts)" "$*" | tee -a "$LOG_FILE" >&2; } | |
| log2() { (( DEBUG >= 2 )) && printf '[%s] %s\n' "$(ts)" "$*" | tee -a "$LOG_FILE" >&2; } | |
| on_err() { | |
| local ec=$? | |
| printf '[%s] ERROR line=%s cmd=%q exit=%s\n' "$(ts)" "${BASH_LINENO[0]}" "$BASH_COMMAND" "$ec" | tee -a "$LOG_FILE" >&2 | |
| exit "$ec" | |
| } | |
| trap on_err ERR | |
| # --------------------------- | |
| # Monitor model | |
| # --------------------------- | |
| declare -a MON_NAME MON_X MON_Y MON_W MON_H | |
| load_monitors() { | |
| MON_NAME=(); MON_X=(); MON_Y=(); MON_W=(); MON_H=() | |
| while IFS= read -r line; do | |
| [[ -n "$line" ]] || continue | |
| local name geom w h x y | |
| name="$(awk '{print $2}' <<<"$line" | sed 's/^[+*]*//')" | |
| geom="$(awk '{print $3}' <<<"$line")" | |
| w="$(sed -n 's#^\([0-9]\+\)/.*#\1#p' <<<"$geom")" | |
| h="$(sed -n 's#^[0-9]\+/.*x\([0-9]\+\)/.*#\1#p' <<<"$geom")" | |
| x="$(sed -n 's#^.*+\([0-9]\+\)+\([0-9]\+\)$#\1#p' <<<"$geom")" | |
| y="$(sed -n 's#^.*+\([0-9]\+\)+\([0-9]\+\)$#\2#p' <<<"$geom")" | |
| if [[ -n "$name" && -n "$w" && -n "$h" && -n "$x" && -n "$y" ]]; then | |
| MON_NAME+=("$name"); MON_W+=("$w"); MON_H+=("$h"); MON_X+=("$x"); MON_Y+=("$y") | |
| fi | |
| done < <(xrandr --listmonitors 2>/dev/null | tail -n +2) | |
| if (( ${#MON_NAME[@]} == 0 )); then | |
| log "No monitors found via xrandr --listmonitors (monitor filtering disabled)." | |
| return 1 | |
| fi | |
| log "Monitors:" | |
| for i in "${!MON_NAME[@]}"; do | |
| log " [$i] ${MON_NAME[$i]}: ${MON_W[$i]}x${MON_H[$i]}+${MON_X[$i]}+${MON_Y[$i]}" | |
| done | |
| } | |
| monitor_for_point() { | |
| local px="$1" py="$2" | |
| for i in "${!MON_NAME[@]}"; do | |
| local x="${MON_X[$i]}" y="${MON_Y[$i]}" w="${MON_W[$i]}" h="${MON_H[$i]}" | |
| if (( px >= x && px < x + w && py >= y && py < y + h )); then | |
| printf '%s' "$i" | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| # --------------------------- | |
| # Window helpers | |
| # --------------------------- | |
| window_geom() { | |
| local wid_dec="$1" | |
| local out | |
| out="$(xdotool getwindowgeometry --shell "$wid_dec" 2>/dev/null || true)" | |
| [[ -n "$out" ]] || return 1 | |
| local X="" Y="" WIDTH="" HEIGHT="" | |
| # shellcheck disable=SC1090 | |
| source <(printf '%s\n' "$out") | |
| [[ -n "${X:-}" && -n "${Y:-}" && -n "${WIDTH:-}" && -n "${HEIGHT:-}" ]] || return 1 | |
| printf '%s %s %s %s\n' "$X" "$Y" "$WIDTH" "$HEIGHT" | |
| } | |
| monitor_for_window_center() { | |
| local wid_dec="$1" | |
| local gx gy gw gh | |
| read -r gx gy gw gh < <(window_geom "$wid_dec") | |
| local cx=$((gx + gw / 2)) | |
| local cy=$((gy + gh / 2)) | |
| monitor_for_point "$cx" "$cy" | |
| } | |
| is_skippable_window() { | |
| local wid_hex="$1" | |
| local t | |
| t="$(xprop -id "$wid_hex" _NET_WM_WINDOW_TYPE 2>/dev/null || true)" | |
| grep -qE '_NET_WM_WINDOW_TYPE_(DOCK|DESKTOP)' <<<"$t" | |
| } | |
| window_title() { | |
| local wid_hex="$1" | |
| xprop -id "$wid_hex" _NET_WM_NAME 2>/dev/null \ | |
| | sed -n 's/^.*= \(.*\)$/\1/p' \ | |
| | sed 's/^"//; s/"$//' | |
| } | |
| window_class() { | |
| local wid_hex="$1" | |
| xprop -id "$wid_hex" WM_CLASS 2>/dev/null \ | |
| | sed -n 's/^.*= \(.*\)$/\1/p' | |
| } | |
| # --------------------------- | |
| # Stacking helpers | |
| # --------------------------- | |
| stacking_list_hex() { | |
| local raw | |
| raw="$(xprop -root _NET_CLIENT_LIST_STACKING 2>/dev/null || true)" | |
| [[ -n "$raw" ]] || return 1 | |
| sed -n 's/^.*# //p' <<<"$raw" \ | |
| | tr -d ',' \ | |
| | tr ' ' '\n' \ | |
| | sed '/^$/d' | |
| } | |
| find_index_in_array() { | |
| local needle="$1"; shift | |
| local i=0 | |
| for item in "$@"; do | |
| [[ "$item" == "$needle" ]] && { printf '%s\n' "$i"; return 0; } | |
| ((i++)) | |
| done | |
| return 1 | |
| } | |
| pick_target_under_active_same_monitor() { | |
| local active_hex="$1" | |
| local active_mon_idx="${2:-}" | |
| mapfile -t stack_hex < <(stacking_list_hex) | |
| local idx | |
| idx="$(find_index_in_array "$active_hex" "${stack_hex[@]}")" | |
| log "Considering windows under active (nearest-under to bottom):" | |
| for ((j=idx-1; j>=0; j--)); do | |
| local cand_hex="${stack_hex[$j]}" | |
| local cand_dec="$((cand_hex))" | |
| local prefix="$cand_hex ($cand_dec)" | |
| if is_skippable_window "$cand_hex"; then | |
| log " $prefix -> skip: DOCK/DESKTOP" | |
| continue | |
| fi | |
| if is_hidden_window "$cand_hex"; then | |
| log " $prefix -> skip: hidden/minimized" | |
| continue | |
| fi | |
| # Workspace filter: only consider windows on current workspace, plus sticky | |
| if [[ -n "${curdesk:-}" ]]; then | |
| if ! same_workspace_or_sticky "$cand_hex" "$curdesk"; then | |
| log " $prefix -> skip: other workspace" | |
| continue | |
| fi | |
| fi | |
| if (( ${#MON_NAME[@]} > 0 )) && [[ -n "$active_mon_idx" ]]; then | |
| local cand_mon_idx="" | |
| cand_mon_idx="$(monitor_for_window_center "$cand_dec" 2>/dev/null || true)" | |
| if [[ -z "$cand_mon_idx" ]]; then | |
| log " $prefix -> skip: monitor unknown" | |
| continue | |
| fi | |
| if [[ "$cand_mon_idx" != "$active_mon_idx" ]]; then | |
| log " $prefix -> skip: other monitor (${MON_NAME[$cand_mon_idx]})" | |
| continue | |
| fi | |
| fi | |
| if (( DEBUG >= 2 )); then | |
| local d; d="$(window_desktop "$cand_hex" || true)" | |
| log2 " $prefix -> hidden state: $(window_net_wm_state "$cand_hex" | tr '\n' ' ') | $(window_wm_state "$cand_hex" | tr '\n' ' ')" | |
| log2 " $prefix -> ACCEPT desk=${d:-?} class=$(window_class "$cand_hex") title=$(window_title "$cand_hex")" | |
| else | |
| log " $prefix -> ACCEPT" | |
| fi | |
| printf '%s\n' "$cand_dec" | |
| return 0 | |
| done | |
| } | |
| window_net_wm_state() { | |
| # arg: window id in hex (0x...) | |
| xprop -id "$1" _NET_WM_STATE 2>/dev/null || true | |
| } | |
| window_wm_state() { | |
| # arg: window id in hex (0x...) | |
| xprop -id "$1" WM_STATE 2>/dev/null || true | |
| } | |
| is_hidden_window() { | |
| # arg: window id in hex (0x...) | |
| local s | |
| # EWMH: hidden/minimized | |
| s="$(window_net_wm_state "$1")" | |
| if grep -q '_NET_WM_STATE_HIDDEN' <<<"$s"; then | |
| return 0 | |
| fi | |
| # ICCCM fallback: IconicState == minimized | |
| s="$(window_wm_state "$1")" | |
| if grep -q 'IconicState' <<<"$s"; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| # --------------------------- | |
| # Workspace helpers | |
| # --------------------------- | |
| current_desktop() { | |
| # prints an integer like 0,1,2... | |
| xprop -root _NET_CURRENT_DESKTOP 2>/dev/null \ | |
| | awk -F' = ' '{print $2}' \ | |
| | tr -d '\n' | |
| } | |
| window_desktop() { | |
| # arg: window id in hex (0x...) | |
| # prints: integer desktop index, or -1 for sticky, or empty if missing | |
| xprop -id "$1" _NET_WM_DESKTOP 2>/dev/null \ | |
| | awk -F' = ' '{print $2}' \ | |
| | tr -d '\n' | |
| } | |
| same_workspace_or_sticky() { | |
| # args: cand_hex, curdesk | |
| local cand_hex="$1" curdesk="$2" | |
| local desk | |
| desk="$(window_desktop "$cand_hex" || true)" | |
| [[ -n "$desk" ]] || return 1 | |
| # Sticky windows often show as 0xFFFFFFFF or 4294967295; normalize to -1. | |
| if [[ "$desk" == "0xFFFFFFFF" || "$desk" == "4294967295" ]]; then | |
| return 0 | |
| fi | |
| [[ "$desk" == "$curdesk" ]] | |
| } | |
| main() { | |
| : > "$LOG_FILE" # truncate each run so it’s easy to inspect | |
| log "Starting (DEBUG=$DEBUG) PID=$$" | |
| local curdesk="" | |
| curdesk="$(current_desktop || true)" | |
| log "Current desktop: ${curdesk:-UNKNOWN}" | |
| local active_dec active_hex | |
| active_dec="$(xdotool getactivewindow)" | |
| active_hex="$(printf '0x%x' "$active_dec")" | |
| load_monitors || true | |
| local active_mon_idx="" | |
| if (( ${#MON_NAME[@]} > 0 )); then | |
| active_mon_idx="$(monitor_for_window_center "$active_dec" 2>/dev/null || true)" | |
| fi | |
| if [[ -n "$active_mon_idx" ]]; then | |
| log "Active window: $active_hex monitor=[${active_mon_idx}] ${MON_NAME[$active_mon_idx]}" | |
| else | |
| log "Active window: $active_hex monitor=UNKNOWN" | |
| fi | |
| local target_dec="" | |
| if xprop -root _NET_CLIENT_LIST_STACKING >/dev/null 2>&1; then | |
| target_dec="$(pick_target_under_active_same_monitor "$active_hex" "$active_mon_idx" || true)" | |
| else | |
| log "No _NET_CLIENT_LIST_STACKING available; will only lower." | |
| fi | |
| log "Lowering active window: $active_hex" | |
| xdo lower "$active_dec" | |
| if [[ -n "$target_dec" ]]; then | |
| log "Activating target window: $target_dec" | |
| xdotool windowactivate --sync "$target_dec" | |
| else | |
| log "No eligible target found; focus unchanged." | |
| fi | |
| } | |
| main "$@" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A robust X11 utility script that lowers the currently active window and automatically transfers focus to the next eligible window underneath it on the same physical monitor. The script uses the real X11 restack operation (xdo lower) to ensure correct stacking order, derives monitor boundaries from xrandr, and determines window-to-monitor assignment via window geometry rather than unreliable screen indices. It filters out non-interactive windows (docks, panels, desktops), guarantees repeatable cycling behavior, and includes optional debug logging to make all window-selection decisions transparent. Designed for multi-monitor setups and keyboard-driven workflows on X11-based Linux desktops. It's also workspace aware. It avoids switching to hidden windows.