fix(hyprlua): spatial (transform-aware) monitor-jump fallback
Root cause of "can jump one way across monitors but not back" on a
desk with a rotated monitor: both columns.lua's own edge-crossing and
the fallback this adds go through Hyprland's focusmonitor/movewindow
mon: -1/+1, which cycle through Hyprland's internal monitor *list
order* — not actual spatial position. A monitor's transform swaps its
logical width/height (same math as monitor-manager's logical_width/
logical_height) but doesn't reorder that list, so list order and
physical left-to-right order can silently diverge once one monitor is
rotated — confirmed with the reported layout (main 3840x2160 normal +
rotated 1920x1080 to its right): jumping right resolved correctly,
jumping back left did not.
scripts/monitor-adjacent replaces the index cycling with real spatial
adjacency: it sorts monitors by logical bounding-box center each time
and dispatches focusmonitor/movewindow to the actual next/prev monitor
in the requested screen direction. Verified against the exact reported
monitor layout (simulated, transform=1 on the right monitor) — both
directions now resolve to the correct monitor.
Wired in two places (Super+,/. is untouched, still index-cycling, by
request):
- columns.lua's edge-crossing (off the left/right edge of the column
strip already flowed onto the next monitor; now via spatial lookup)
- hypr-nav's scrolling-layout perpendicular "no neighbour" case, as a
new fallback: regular directional movement (Super+hjkl) that
previously just stopped at the edge of a monitor now spills onto
whichever monitor is spatially in that direction, same as columns.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLz6HWsXCwzQ97LrLt2em6
main
parent
dc0436136b
commit
9d4cdc35bd
|
|
@ -368,10 +368,12 @@ local function layout_msg(ctx, cmd)
|
|||
if verb == "focus" then
|
||||
if between(arg) then
|
||||
local nc = fc + (decrease(arg) and -1 or 1)
|
||||
if nc < 1 then
|
||||
hl.dispatch(hl.dsp.focus({ monitor = "-1" })) -- off the left edge
|
||||
elseif nc > s.ncols then
|
||||
hl.dispatch(hl.dsp.focus({ monitor = "+1" })) -- off the right edge
|
||||
if nc < 1 or nc > s.ncols then
|
||||
-- Off the edge: jump to the monitor spatially in that screen direction
|
||||
-- (arg is already "l"/"r"/"u"/"d", i.e. the edge we just fell off of),
|
||||
-- not Hyprland's own -1/+1 list-order cycling — see monitor-adjacent for
|
||||
-- why that distinction matters once a monitor has a transform.
|
||||
hl.exec_cmd("~/.config/scripts/monitor-adjacent focus " .. arg)
|
||||
elseif cols[nc] and #cols[nc] > 0 then
|
||||
local ni = math.min(fi, #cols[nc])
|
||||
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
|
||||
|
|
@ -387,10 +389,10 @@ local function layout_msg(ctx, cmd)
|
|||
if between(arg) then
|
||||
unpair(foc) -- leaving the column un-parks it
|
||||
local nc = fc + (decrease(arg) and -1 or 1)
|
||||
if nc < 1 then
|
||||
hl.dispatch(hl.dsp.window.move({ monitor = "-1" })) -- carry to prev monitor
|
||||
elseif nc > s.ncols then
|
||||
hl.dispatch(hl.dsp.window.move({ monitor = "+1" })) -- carry to next monitor
|
||||
if nc < 1 or nc > s.ncols then
|
||||
-- Off the edge: carry the window to the monitor spatially in that screen
|
||||
-- direction — see the matching comment in the "focus" branch above.
|
||||
hl.exec_cmd("~/.config/scripts/monitor-adjacent move " .. arg)
|
||||
else
|
||||
s.assign[foc] = nc
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ def dispatch(lua: str) -> None:
|
|||
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}" }})')
|
||||
|
|
@ -183,8 +190,12 @@ def main() -> int:
|
|||
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Focus/move-window to the monitor spatially adjacent to the currently focused one,
|
||||
in a literal screen direction (left/right/up/down) — unlike Hyprland's native
|
||||
`focusmonitor -1/+1` / `movewindow mon:-1/+1`, which cycle through Hyprland's internal
|
||||
monitor list *order* (roughly connection/config order), not actual position.
|
||||
|
||||
That distinction matters once a monitor is rotated: a 90°/270° `transform` swaps a
|
||||
monitor's *logical* width/height (see monitor-manager's `logical_width`/`logical_height`,
|
||||
the same math reused here), but doesn't reorder Hyprland's monitor list. On a 3-wide
|
||||
desk (e.g. a portrait-rotated monitor sitting to the right of a large landscape main
|
||||
one), that can make "-1/+1" jump correctly one way and land on the wrong monitor (or a
|
||||
no-op) the other way, because the list order and the physical left-to-right order have
|
||||
silently diverged. This instead sorts monitors by their actual logical bounding-box
|
||||
center each time, so "next"/"prev" always means the next monitor to the right/left (or
|
||||
below/above) on screen, regardless of transform or connection order.
|
||||
|
||||
Usage:
|
||||
monitor-adjacent focus l|r|u|d
|
||||
monitor-adjacent move l|r|u|d
|
||||
|
||||
Silently does nothing if there's only one monitor, no monitor is focused, or there's no
|
||||
monitor further in that direction (no wrap-around at the ends — see the note in
|
||||
_pick_target for why).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AXIS = {"l": ("x", -1), "r": ("x", 1), "u": ("y", -1), "d": ("y", 1)}
|
||||
|
||||
|
||||
def hypr_json(*args):
|
||||
try:
|
||||
return json.loads(subprocess.check_output(["hyprctl", *args, "-j"]))
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def _logical_size(m: dict) -> tuple[float, float]:
|
||||
scale = m.get("scale") or 1.0
|
||||
w, h = m.get("width", 0), m.get("height", 0)
|
||||
if (m.get("transform", 0) & 3) in (1, 3):
|
||||
w, h = h, w
|
||||
return w / scale, h / scale
|
||||
|
||||
|
||||
def _center(m: dict) -> tuple[float, float]:
|
||||
w, h = _logical_size(m)
|
||||
return m.get("x", 0) + w / 2, m.get("y", 0) + h / 2
|
||||
|
||||
|
||||
def _pick_target(monitors: list[dict], current: dict, axis: str, sign: int) -> dict | None:
|
||||
cur_c = _center(current)
|
||||
idx_c = 0 if axis == "x" else 1
|
||||
# Candidates strictly on the requested side of the current monitor's center.
|
||||
candidates = []
|
||||
for m in monitors:
|
||||
if m is current:
|
||||
continue
|
||||
c = _center(m)
|
||||
delta = (c[idx_c] - cur_c[idx_c]) * sign
|
||||
if delta > 1: # >1 logical px: ignore near-exact ties/float noise
|
||||
candidates.append((delta, m))
|
||||
if not candidates:
|
||||
# No wrap-around: at the physical edge of the desk, hjkl-style directional
|
||||
# movement should just stop, the same way it does within a single monitor's
|
||||
# windows. (Unlike Hyprland's index-based -1/+1, which does wrap — if you want
|
||||
# that cyclic behaviour, that's what the explicit Super+,/. binds are for.)
|
||||
return None
|
||||
candidates.sort(key=lambda t: t[0]) # closest first
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") or sys.argv[2] not in AXIS:
|
||||
print(__doc__)
|
||||
return 2
|
||||
kind, key = sys.argv[1], sys.argv[2]
|
||||
axis, sign = AXIS[key]
|
||||
|
||||
monitors = hypr_json("monitors")
|
||||
if not monitors or len(monitors) < 2:
|
||||
return 0
|
||||
current = next((m for m in monitors if m.get("focused")), None)
|
||||
if current is None:
|
||||
return 0
|
||||
|
||||
target = _pick_target(monitors, current, axis, sign)
|
||||
if target is None:
|
||||
return 0
|
||||
|
||||
name = target["name"]
|
||||
if kind == "focus":
|
||||
subprocess.run(["hyprctl", "dispatch", "focusmonitor", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
subprocess.run(["hyprctl", "dispatch", "movewindow", f"mon:{name}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue