diff --git a/scripts/bin/focus b/scripts/bin/focus new file mode 100755 index 0000000..0ede1e6 --- /dev/null +++ b/scripts/bin/focus @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# focus - small window helpers for xfwm4 +# focus next|prev move focus right/left through windows (stops at the ends) +# focus up|down focus the nearest window directly above/below, if any +# focus tile master/stack tiling: active window left, the rest stacked right +# (a single window is simply maximized) +# Deps: wmctrl xdotool xprop xwininfo +set -u + +# ---- config ----------------------------------------------------------------- +MASTER_RATIO=50 # master width as % of the usable width +GAP=0 # px between windows and around the screen edge +USE_WORKAREA=1 # 1 = ask xfwm4 for the usable area (already excludes panels) +PANEL_TOP=0 # extra px to keep free on each side; if USE_WORKAREA=0, +PANEL_BOTTOM=0 # put your panel sizes here instead +PANEL_LEFT=0 +PANEL_RIGHT=0 +SETTLE=0.05 # seconds to let xfwm4 finish un-maximizing before moving +VERIFY=1 # 1 = measure each window afterwards and fix pixel offsets +VERIFY_TRIES=2 # correction passes per window + +# ---- generic helpers -------------------------------------------------------- +die() { echo "${0##*/}: $*" >&2; exit 1; } +usage() { echo "usage: ${0##*/} next|prev|up|down|tile" >&2; exit 1; } + +# window geometry from wmctrl, keyed by id (filled by collect_windows) +declare -A GX GY GW GH + +need() { + local c + for c in "$@"; do command -v "$c" >/dev/null || die "missing dependency: $c"; done +} + +# Active window id in wmctrl's 0x%08x format (0x00000000 if none) +active_window() { + local d + d=$(xdotool getactivewindow 2>/dev/null) || d=0 + printf '0x%08x' "$d" +} + +# index_of ID -> prints index in WINS, or -1 +index_of() { + local i + for i in "${!WINS[@]}"; do + [[ ${WINS[i]} == "$1" ]] && { echo "$i"; return; } + done + echo -1 +} + +# collect_windows focus|tile -> fills WINS, ordered left to right on screen +# (then top to bottom), so "next" moves right and "prev" moves left. +# focus: normal windows + dialogs, minimized included (focusing restores them) +# vert: normal windows + dialogs, minimized skipped (they have no real position) +# tile: normal windows only, minimized skipped +collect_windows() { + local mode=$1 cur id desk x y w h props + local -a found=() + cur=$(xdotool get_desktop) || die "cannot read current desktop" + WINS=() + while read -r id desk x y w h _; do + [[ $desk == "$cur" || $desk == -1 ]] || continue # this desktop or sticky + props=$(xprop -id "$id" _NET_WM_WINDOW_TYPE _NET_WM_STATE 2>/dev/null) || continue + + if [[ $props == *"_NET_WM_WINDOW_TYPE(ATOM)"* ]]; then + if [[ $mode == tile ]]; then + [[ $props == *_NET_WM_WINDOW_TYPE_NORMAL* ]] || continue + else + [[ $props == *_NET_WM_WINDOW_TYPE_NORMAL* || $props == *_NET_WM_WINDOW_TYPE_DIALOG* ]] || continue + fi + fi + [[ $props == *_NET_WM_STATE_SKIP_TASKBAR* ]] && continue # panels, desktop, notifyd + [[ $mode != focus && $props == *_NET_WM_STATE_HIDDEN* ]] && continue + + GX[$id]=$x GY[$id]=$y GW[$id]=$w GH[$id]=$h + + found+=("$x $y $id") + done < <(wmctrl -lG) + + (( ${#found[@]} )) || return 0 + while read -r _ _ id; do WINS+=("$id"); done < <(printf '%s\n' "${found[@]}" | sort -n -k1,1 -k2,2) +} + +# ---- focus ------------------------------------------------------------------ +focus_cycle() { + local dir=$1 n idx + collect_windows focus + n=${#WINS[@]} + (( n > 0 )) || return 0 + + idx=$(index_of "$(active_window)") + if (( idx < 0 )); then + # nothing of ours focused (desktop/panel): enter at the matching end + [[ $dir == next ]] && idx=0 || idx=$(( n - 1 )) + elif [[ $dir == next ]]; then + (( idx < n - 1 )) || return 0 # already rightmost: stop, no wrap + (( idx += 1 )) + else + (( idx > 0 )) || return 0 # already leftmost: stop, no wrap + (( idx -= 1 )) + fi + wmctrl -ia "${WINS[idx]}" +} + +# focus_vertical up|down -- nearest window straight above/below the active one. +# "Straight above/below" = shares some horizontal span with it. No match: do nothing. +focus_vertical() { + local dir=$1 act id ax1 ax2 acy cy dy best="" best_dy=-1 + collect_windows vert + act=$(active_window) + [[ -n ${GX[$act]+x} ]] || return 0 + + ax1=${GX[$act]}; ax2=$(( ax1 + GW[$act] )) + acy=$(( GY[$act] + GH[$act] / 2 )) + + for id in "${WINS[@]}"; do + [[ $id == "$act" ]] && continue + (( GX[$id] < ax2 && GX[$id] + GW[$id] > ax1 )) || continue # no horizontal overlap + cy=$(( GY[$id] + GH[$id] / 2 )) + if [[ $dir == up ]]; then dy=$(( acy - cy )); else dy=$(( cy - acy )); fi + (( dy > 0 )) || continue # wrong side + if (( best_dy < 0 || dy < best_dy )); then best=$id best_dy=$dy; fi + done + + [[ -n $best ]] && wmctrl -ia "$best" + return 0 +} + +# ---- geometry helpers ------------------------------------------------------- +# workarea -> WA_X WA_Y WA_W WA_H (usable rectangle on this desktop) +workarea() { + local cur raw i + local -a a + cur=$(xdotool get_desktop) + raw=$(xprop -root _NET_WORKAREA 2>/dev/null) + if (( USE_WORKAREA )) && [[ $raw == *=* ]]; then + IFS=', ' read -r -a a <<< "${raw#*= }" + i=$(( cur * 4 )) + (( i + 3 < ${#a[@]} )) || i=0 + WA_X=${a[i]} WA_Y=${a[i+1]} WA_W=${a[i+2]} WA_H=${a[i+3]} + else + read -r WA_W WA_H < <(xdotool getdisplaygeometry) + WA_X=0 WA_Y=0 + fi + (( WA_X += PANEL_LEFT, WA_Y += PANEL_TOP, + WA_W -= PANEL_LEFT + PANEL_RIGHT, WA_H -= PANEL_TOP + PANEL_BOTTOM )) +} + +# frame_extents ID -> FL FR FT FB (decoration size; 0 if not reported) +frame_extents() { + local raw + FL=0 FR=0 FT=0 FB=0 + raw=$(xprop -id "$1" _NET_FRAME_EXTENTS 2>/dev/null) || return 0 + [[ $raw == *=* ]] && IFS=', ' read -r FL FR FT FB <<< "${raw#*= }" +} + +# client_position ID -> CX CY (real on-screen top-left of the client area) +client_position() { + read -r CX CY < <(xwininfo -id "$1" 2>/dev/null | awk ' + /Absolute upper-left X/ {x=$NF} /Absolute upper-left Y/ {y=$NF} + END {print x+0, y+0}') +} + +# ---- window actions --------------------------------------------------------- +# Drop states that make xfwm4 ignore move/resize (maximized, xfwm4 edge-tiling, fullscreen, shaded) +reset_state() { + wmctrl -ir "$1" -b remove,maximized_vert,maximized_horz + wmctrl -ir "$1" -b remove,fullscreen,shaded +} + +# maximize ID -- clear fullscreen/shaded, then set the WM's maximized state +maximize() { + wmctrl -ir "$1" -b remove,fullscreen,shaded + wmctrl -ir "$1" -b add,maximized_vert,maximized_horz + wmctrl -ia "$1" +} + +# place ID X Y W H -- X Y W H is the OUTER rectangle including decorations +place() { + local id=$1 x=$2 y=$3 w=$4 h=$5 cw ch + frame_extents "$id" + cw=$(( w - FL - FR )); (( cw < 1 )) && cw=1 + ch=$(( h - FT - FB )); (( ch < 1 )) && ch=1 + # gravity 1 (NorthWest): x,y = top-left of the frame, w,h = client size + wmctrl -ir "$id" -e "1,$x,$y,$cw,$ch" +} + +# verify ID X Y W H -- measure where the window really landed and nudge it if off +verify() { + local id=$1 x=$2 y=$3 w=$4 h=$5 try dx dy + for (( try = 0; try < VERIFY_TRIES; try++ )); do + frame_extents "$id" + client_position "$id" + dx=$(( CX - (x + FL) )) + dy=$(( CY - (y + FT) )) + (( dx == 0 && dy == 0 )) && return 0 + place "$id" $(( x - dx )) $(( y - dy )) "$w" "$h" + sleep "$SETTLE" + done +} + +# ---- layout ----------------------------------------------------------------- +# layout_master_stack N -> fills RECTS with "x y w h" outer rectangles, index 0 = master +# Integer remainders are handed out so the tiles cover the area exactly, no 1px gaps. +layout_master_stack() { + local n=$1 x0 y0 w0 h0 mw sw sx avail base rem i y hh + RECTS=() + x0=$(( WA_X + GAP )); y0=$(( WA_Y + GAP )) + w0=$(( WA_W - 2 * GAP )); h0=$(( WA_H - 2 * GAP )) + + if (( n == 1 )); then + RECTS+=("$x0 $y0 $w0 $h0") + return + fi + + mw=$(( (w0 - GAP) * MASTER_RATIO / 100 )) + sw=$(( w0 - GAP - mw )) + sx=$(( x0 + mw + GAP )) + RECTS+=("$x0 $y0 $mw $h0") + + (( n -= 1 )) + avail=$(( h0 - (n - 1) * GAP )) + base=$(( avail / n )); rem=$(( avail % n )) + y=$y0 + for (( i = 0; i < n; i++ )); do + hh=$(( base + (i < rem ? 1 : 0) )) + RECTS+=("$sx $y $sw $hh") + (( y += hh + GAP )) + done +} + +# ---- tile ------------------------------------------------------------------- +tile() { + local n idx master i + (( VERIFY )) && need xwininfo + collect_windows tile + n=${#WINS[@]} + (( n > 0 )) || return 0 + + # only one window: let xfwm4 maximize it (real maximized state, not fullscreen) + if (( n == 1 )); then + maximize "${WINS[0]}" + return 0 + fi + + # active window becomes master, the rest keep their order in the stack + idx=$(index_of "$(active_window)") + if (( idx > 0 )); then + master=${WINS[idx]} + WINS=("$master" "${WINS[@]:0:idx}" "${WINS[@]:idx+1}") + fi + + workarea + layout_master_stack "$n" + + for i in "${!WINS[@]}"; do reset_state "${WINS[i]}"; done + sleep "$SETTLE" + + for i in "${!WINS[@]}"; do + # shellcheck disable=SC2086 + place "${WINS[i]}" ${RECTS[i]} + done + + if (( VERIFY )); then + sleep "$SETTLE" + for i in "${!WINS[@]}"; do + # shellcheck disable=SC2086 + verify "${WINS[i]}" ${RECTS[i]} + done + fi + + wmctrl -ia "${WINS[0]}" +} + +# ---- main ------------------------------------------------------------------- +need wmctrl xdotool xprop +case ${1:-next} in + next|prev) focus_cycle "${1:-next}" ;; + up|down) focus_vertical "$1" ;; + tile) tile ;; + *) usage ;; +esac