diff --git a/desktopenvs/hyprlua/hypr/layouts/columns.lua b/desktopenvs/hyprlua/hypr/layouts/columns.lua index 0f10855..429f3d3 100644 --- a/desktopenvs/hyprlua/hypr/layouts/columns.lua +++ b/desktopenvs/hyprlua/hypr/layouts/columns.lua @@ -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 diff --git a/desktopenvs/hyprlua/scripts/hypr-nav b/desktopenvs/hyprlua/scripts/hypr-nav index bf1a9d4..886e934 100755 --- a/desktopenvs/hyprlua/scripts/hypr-nav +++ b/desktopenvs/hyprlua/scripts/hypr-nav @@ -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 diff --git a/desktopenvs/hyprlua/scripts/monitor-adjacent b/desktopenvs/hyprlua/scripts/monitor-adjacent new file mode 100755 index 0000000..c0f54c4 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/monitor-adjacent @@ -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())