Dotfiles/desktopenvs/hyprlua/scripts/hypr-nav

156 lines
5.4 KiB
Python
Executable File

#!/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())