213 lines
8.3 KiB
Python
Executable File
213 lines
8.3 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 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/astro-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
|
|
# A workspace the user has never explicitly switched via the menu has no entry
|
|
# in STATE (write_state()/M.set only fires on a menu pick) — that's every fresh
|
|
# workspace at session start, since it's just inheriting the global default. Fall
|
|
# back to the raw Hyprland option, which for a custom Lua layout is the "lua:<name>"
|
|
# backend string (see layouts/init.lua's M.set_default), not the registry name — strip
|
|
# the prefix so this still compares equal to COLUMNS_LAYOUT below.
|
|
opt = hypr_json("getoption", "general:layout")
|
|
raw = opt.get("str") if opt else None
|
|
if raw and raw.startswith("lua:"):
|
|
return raw[len("lua:"):]
|
|
return raw
|
|
|
|
|
|
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())
|