#!/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, count changes how many columns / windows-per- view). For any other layout (dwindle/master/monocle) focus/move is a straight pass-through, so nothing else changes. Usage: hypr-nav focus l|r|u|d hypr-nav move l|r|u|d hypr-nav count h|j|k|l (columns layout only) """ 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 plain(kind: str, dir_word: str) -> None: if kind == "focus": dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})') else: dispatch(f'hl.dsp.window.move({{ direction = "{dir_word}" }})') 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 take a screen key l|r|u|d; count takes a vim key h|j|k|l. if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "count"): print(__doc__) return 2 kind, key = sys.argv[1], sys.argv[2] valid = set("hjkl") if kind == "count" else 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 the command to its layout_msg. if layout == COLUMNS_LAYOUT: dispatch(f'hl.dsp.layout("{kind} {key}")') return 0 if kind == "count": return 0 # column/row count only means something in columns 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 — do nothing rather than let the layout scroll the # tape (that sideways/vertical "rotation" is exactly what we're suppressing). return 0 if __name__ == "__main__": raise SystemExit(main())