#!/usr/bin/env python3 """Screen-absolute focus / window-move for the scrolling ("scrolling"/"columns") layouts, so the direction keys stop *rotating* with the scroller's orientation. Hyprland's scrolling layout resolves a direction that is *perpendicular* to the tape onto "next in tape" (verified: with a vertical tape, Left/Right scroll DOWN; with a horizontal tape, Up/Down jump to the next column). That makes h/j/k/l and the arrows mean different things depending on `scrolling:direction`. This wrapper keeps every key mapped to its literal on-screen direction: * Along the tape axis -> the layout's own move/focus (correct as-is). * Perpendicular with a real neighbour in the current column/strip -> a genuine within-column step (kept). * Perpendicular with no neighbour -> a no-op, instead of scrolling the tape. (Crossing to another monitor has its own explicit binds — Super+,/. and Super+Shift+,/. — so it isn't overloaded onto the arrows here.) For the custom "columns" layout every command is forwarded to that layout's own handler (focus/move drive its columns and flow across monitors at the edges, resize adjusts column-width / window-height weights, count changes the column count). For any other layout (dwindle/master/monocle) focus/move/resize is a straight pass-through, so nothing else changes. scroll travels along the focused strip: within the focused column for "columns", along the tape for the built-in scroller, and (unchanged) between workspaces everywhere else — so the mouse wheel means "next thing in this strip" wherever a strip exists. Usage: hypr-nav focus l|r|u|d hypr-nav move l|r|u|d hypr-nav resize l|r|u|d hypr-nav count h|j|k|l (columns layout only) hypr-nav scroll u|d """ from __future__ import annotations import json import os import subprocess import sys DIRS = {"l": ("x", -1, "left"), "r": ("x", 1, "right"), "u": ("y", -1, "up"), "d": ("y", 1, "down")} SCROLLING_LAYOUTS = {"scrolling"} # built-in scrolling tape COLUMNS_LAYOUT = "columns" # our custom independent-pan Lua layout STATE = os.path.expanduser("~/.cache/astal-menu/layouts-state.json") def hypr_json(*args): try: return json.loads(subprocess.check_output(["hyprctl", *args, "-j"])) except (subprocess.CalledProcessError, json.JSONDecodeError): return None def dispatch(lua: str) -> None: # `hyprctl dispatch` evaluates its argument as Lua here (the hl.dsp.* API). subprocess.run(["hyprctl", "dispatch", f"hl.dispatch({lua})"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def monitor_adjacent(kind: str, key: str) -> None: # Spatial (transform-aware) monitor jump, used as the edge-of-strip fallback below — # see scripts/monitor-adjacent for why this isn't just hl.dsp.focus({monitor=+-1}). script = os.path.expanduser("~/.config/scripts/monitor-adjacent") subprocess.run([script, kind, key], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def plain(kind: str, dir_word: str) -> None: if kind == "focus": dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})') elif kind == "move": dispatch(f'hl.dsp.window.move({{ direction = "{dir_word}" }})') else: # resize — native, screen-relative (10px step, matches the old ALT binds) axis, sign, _ = DIRS[{"left": "l", "right": "r", "up": "u", "down": "d"}[dir_word]] dx = 10 * sign if axis == "x" else 0 dy = 10 * sign if axis == "y" else 0 dispatch(f"hl.dsp.window.resize({{ x = {dx}, y = {dy}, relative = true }})") def active_layout_name(wsid) -> str | None: try: with open(STATE) as f: name = json.load(f).get(str(wsid)) if name: return name except (FileNotFoundError, json.JSONDecodeError, TypeError): pass opt = hypr_json("getoption", "general:layout") return opt.get("str") if opt else None def tape_axis() -> str: opt = hypr_json("getoption", "scrolling:direction") d = (opt.get("str") if opt else "down") or "down" return "y" if d in ("down", "up") else "x" def _idx(axis): return 0 if axis == "x" else 1 def _lo_hi(w, axis): i = _idx(axis) return w["at"][i], w["at"][i] + w["size"][i] def _center(w, axis): i = _idx(axis) return w["at"][i] + w["size"][i] / 2 def _overlap(a, b): return a[0] < b[1] and b[0] < a[1] def main() -> int: # focus/move/resize take a screen key l|r|u|d; count a vim key h|j|k|l; scroll u|d. if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "resize", "count", "scroll"): print(__doc__) return 2 kind, key = sys.argv[1], sys.argv[2] if kind == "count": valid = set("hjkl") elif kind == "scroll": valid = {"u", "d"} else: valid = set(DIRS) if key not in valid: print(__doc__) return 2 ws = hypr_json("activeworkspace") or {} layout = active_layout_name(ws.get("id")) # Custom "columns" layout owns all of these — forward to its layout_msg. scroll # travels within the focused column, i.e. focus up/down along that strip. if layout == COLUMNS_LAYOUT: if kind == "scroll": dispatch(f'hl.dsp.layout("focus {key}")') else: dispatch(f'hl.dsp.layout("{kind} {key}")') return 0 if kind == "count": return 0 # column count only means something in columns if kind == "scroll": if layout in SCROLLING_LAYOUTS: # travel along the tape: wheel-down = forward, wheel-up = backward. primary = tape_axis() fwd, back = ("down", "up") if primary == "y" else ("right", "left") plain("focus", fwd if key == "d" else back) else: # no strip here — keep the classic wheel-switches-workspace behaviour. dispatch(f'hl.dsp.focus({{ workspace = "{"r+1" if key == "d" else "r-1"}" }})') return 0 if kind == "resize": # native, screen-relative resize everywhere that isn't columns (incl. scrolling). plain("resize", DIRS[key][2]) return 0 axis, sign, dir_word = DIRS[key] if layout not in SCROLLING_LAYOUTS: plain(kind, dir_word) # dwindle/master/monocle: unchanged return 0 primary = tape_axis() if axis == primary: plain(kind, dir_word) # along the tape — already screen-correct return 0 win = hypr_json("activewindow") if not win or not win.get("address"): plain(kind, dir_word) return 0 # Perpendicular move: is there a real neighbour within this column/strip in the # requested screen direction? (Same column == overlapping along the tape's primary # axis; the requested `axis` is the secondary/stacking axis here.) wsid = win.get("workspace", {}).get("id") clients = [c for c in (hypr_json("clients") or []) if c.get("mapped", True) and c.get("workspace", {}).get("id") == wsid and c.get("address") != win["address"]] prange = _lo_hi(win, primary) my_c = _center(win, axis) neighbour = any(_overlap(prange, _lo_hi(c, primary)) and (_center(c, axis) - my_c) * sign > 1 for c in clients) if neighbour: plain(kind, dir_word) # genuine within-column step else: # No neighbour that way. Don't let the layout scroll the tape (that sideways/ # vertical "rotation" is exactly what we're suppressing) — instead fall back to # jumping to whichever monitor is spatially in that direction, the same as the # columns layout does at its own edges. No-ops if there isn't one that way. monitor_adjacent(kind, key) return 0 if __name__ == "__main__": raise SystemExit(main())