Compare commits
3 Commits
7a5c715da3
...
9d4cdc35bd
| Author | SHA1 | Date |
|---|---|---|
|
|
9d4cdc35bd | |
|
|
dc0436136b | |
|
|
052611abf5 |
|
|
@ -340,38 +340,81 @@ class Taskbar(Gtk.Box):
|
||||||
|
|
||||||
# -- sync + handlers ---------------------------------------------------
|
# -- sync + handlers ---------------------------------------------------
|
||||||
def _sync_layout_controls(self) -> None:
|
def _sync_layout_controls(self) -> None:
|
||||||
# reflect the current per-ws layout / direction / focus_fit in the tabs+options
|
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
|
||||||
|
#
|
||||||
|
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
|
||||||
|
# order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row
|
||||||
|
# (once from the clients query, once from the activeworkspace one) — each
|
||||||
|
# rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed:
|
||||||
|
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
|
||||||
|
# independently (True at start, False at end). Whichever resolved *first*
|
||||||
|
# dropped the guard while the others — in particular the tab-selection one,
|
||||||
|
# which programmatically flips the visible stack child — were still in
|
||||||
|
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
|
||||||
|
# had clicked and re-apply stale data via `layouts.set(...)`.
|
||||||
|
# 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could
|
||||||
|
# land while the first's callbacks were still pending, so a stale callback
|
||||||
|
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
|
||||||
|
# (possibly wrong-workspace) data into sync #2's freshly built widgets.
|
||||||
|
# A generation token invalidates any sync superseded by a newer one outright —
|
||||||
|
# if the user switches workspaces (or a redundant rebuild fires) mid-flight, only
|
||||||
|
# the latest call's callbacks are allowed to touch anything, and self._syncing
|
||||||
|
# only lifts once *that* generation's 3 calls have all resolved.
|
||||||
|
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
|
||||||
|
gen = self._sync_generation
|
||||||
|
ws = self._active_ws
|
||||||
self._syncing = True
|
self._syncing = True
|
||||||
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_tab_sel)
|
pending = [3]
|
||||||
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], self._apply_dir_sel)
|
|
||||||
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], self._apply_fit_sel)
|
def _done() -> None:
|
||||||
|
if gen != self._sync_generation:
|
||||||
|
return # superseded — a newer sync owns self._syncing now
|
||||||
|
pending[0] -= 1
|
||||||
|
if pending[0] <= 0:
|
||||||
|
self._syncing = False
|
||||||
|
|
||||||
|
def _tab_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_tab_sel(ws, ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
def _dir_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_dir_sel(ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
def _fit_cb(ok, data) -> None:
|
||||||
|
if gen == self._sync_generation:
|
||||||
|
self._apply_fit_sel(ok, data)
|
||||||
|
_done()
|
||||||
|
|
||||||
|
run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb)
|
||||||
|
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb)
|
||||||
|
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb)
|
||||||
# columns' own center-focused state lives in columns-state.json, not a hyprctl
|
# columns' own center-focused state lives in columns-state.json, not a hyprctl
|
||||||
# option — read it synchronously rather than round-tripping a subprocess.
|
# option — read it synchronously rather than round-tripping a subprocess.
|
||||||
columns_sw = self._fit_sws.get("columns")
|
columns_sw = self._fit_sws.get("columns")
|
||||||
if columns_sw is not None:
|
if columns_sw is not None:
|
||||||
columns_sw.set_active(self._read_center())
|
columns_sw.set_active(self._read_center())
|
||||||
|
|
||||||
def _apply_tab_sel(self, ok, data) -> None:
|
def _apply_tab_sel(self, ws, ok, data) -> None:
|
||||||
cur = data.get("str") if ok and isinstance(data, dict) else None
|
cur = data.get("str") if ok and isinstance(data, dict) else None
|
||||||
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set)
|
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set)
|
||||||
try:
|
try:
|
||||||
state = json.loads(_LAYOUTS_STATE.read_text())
|
state = json.loads(_LAYOUTS_STATE.read_text())
|
||||||
cur = state.get(str(self._active_ws), cur)
|
cur = state.get(str(ws), cur)
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
if cur and any(ly["name"] == cur for ly in self._layouts):
|
if cur and any(ly["name"] == cur for ly in self._layouts):
|
||||||
self._layout_stack.set_visible_child_name(cur)
|
self._layout_stack.set_visible_child_name(cur)
|
||||||
self._syncing = False
|
|
||||||
|
|
||||||
def _apply_dir_sel(self, ok, data) -> None:
|
def _apply_dir_sel(self, ok, data) -> None:
|
||||||
# scrolling:direction is global; reflect it in every directional layout's
|
# scrolling:direction is global; reflect it in every directional layout's
|
||||||
# dropdown whose value set contains it.
|
# dropdown whose value set contains it.
|
||||||
cur = data.get("str") if ok and isinstance(data, dict) else None
|
cur = data.get("str") if ok and isinstance(data, dict) else None
|
||||||
self._syncing = True
|
|
||||||
for dd, values in self._dir_dds.values():
|
for dd, values in self._dir_dds.values():
|
||||||
if cur in values:
|
if cur in values:
|
||||||
dd.set_selected(values.index(cur))
|
dd.set_selected(values.index(cur))
|
||||||
self._syncing = False
|
|
||||||
|
|
||||||
def _apply_fit_sel(self, ok, data) -> None:
|
def _apply_fit_sel(self, ok, data) -> None:
|
||||||
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
|
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
|
||||||
|
|
@ -380,9 +423,7 @@ class Taskbar(Gtk.Box):
|
||||||
val = data.get("int") if ok and isinstance(data, dict) else 0
|
val = data.get("int") if ok and isinstance(data, dict) else 0
|
||||||
sw = self._fit_sws.get("scrolling")
|
sw = self._fit_sws.get("scrolling")
|
||||||
if sw is not None:
|
if sw is not None:
|
||||||
self._syncing = True
|
|
||||||
sw.set_active(val == 1)
|
sw.set_active(val == 1)
|
||||||
self._syncing = False
|
|
||||||
|
|
||||||
def _cur_dir(self) -> str:
|
def _cur_dir(self) -> str:
|
||||||
entry = self._dir_dds.get(self._layout_stack.get_visible_child_name())
|
entry = self._dir_dds.get(self._layout_stack.get_visible_child_name())
|
||||||
|
|
|
||||||
|
|
@ -219,5 +219,9 @@ hl.device({
|
||||||
-- default. Switching a workspace's layout at runtime is done from the astal-menu
|
-- default. Switching a workspace's layout at runtime is done from the astal-menu
|
||||||
-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
||||||
require("layouts.init")
|
require("layouts.init")
|
||||||
layouts.set_default("scrolling")
|
layouts.set_default("columns")
|
||||||
|
-- special:magic defaults to a downward scrolling layout instead of the session
|
||||||
|
-- default; layouts.restore() below still wins if the user has since picked
|
||||||
|
-- something else for it via the astal-menu.
|
||||||
|
layouts.set("special:magic", "scrolling", "down")
|
||||||
layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them
|
layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them
|
||||||
|
|
|
||||||
|
|
@ -368,10 +368,12 @@ local function layout_msg(ctx, cmd)
|
||||||
if verb == "focus" then
|
if verb == "focus" then
|
||||||
if between(arg) then
|
if between(arg) then
|
||||||
local nc = fc + (decrease(arg) and -1 or 1)
|
local nc = fc + (decrease(arg) and -1 or 1)
|
||||||
if nc < 1 then
|
if nc < 1 or nc > s.ncols then
|
||||||
hl.dispatch(hl.dsp.focus({ monitor = "-1" })) -- off the left edge
|
-- Off the edge: jump to the monitor spatially in that screen direction
|
||||||
elseif nc > s.ncols then
|
-- (arg is already "l"/"r"/"u"/"d", i.e. the edge we just fell off of),
|
||||||
hl.dispatch(hl.dsp.focus({ monitor = "+1" })) -- off the right edge
|
-- 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
|
elseif cols[nc] and #cols[nc] > 0 then
|
||||||
local ni = math.min(fi, #cols[nc])
|
local ni = math.min(fi, #cols[nc])
|
||||||
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
|
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
|
||||||
|
|
@ -387,10 +389,10 @@ local function layout_msg(ctx, cmd)
|
||||||
if between(arg) then
|
if between(arg) then
|
||||||
unpair(foc) -- leaving the column un-parks it
|
unpair(foc) -- leaving the column un-parks it
|
||||||
local nc = fc + (decrease(arg) and -1 or 1)
|
local nc = fc + (decrease(arg) and -1 or 1)
|
||||||
if nc < 1 then
|
if nc < 1 or nc > s.ncols then
|
||||||
hl.dispatch(hl.dsp.window.move({ monitor = "-1" })) -- carry to prev monitor
|
-- Off the edge: carry the window to the monitor spatially in that screen
|
||||||
elseif nc > s.ncols then
|
-- direction — see the matching comment in the "focus" branch above.
|
||||||
hl.dispatch(hl.dsp.window.move({ monitor = "+1" })) -- carry to next monitor
|
hl.exec_cmd("~/.config/scripts/monitor-adjacent move " .. arg)
|
||||||
else
|
else
|
||||||
s.assign[foc] = nc
|
s.assign[foc] = nc
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ def dispatch(lua: str) -> None:
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
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:
|
def plain(kind: str, dir_word: str) -> None:
|
||||||
if kind == "focus":
|
if kind == "focus":
|
||||||
dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})')
|
dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})')
|
||||||
|
|
@ -183,8 +190,12 @@ def main() -> int:
|
||||||
|
|
||||||
if neighbour:
|
if neighbour:
|
||||||
plain(kind, dir_word) # genuine within-column step
|
plain(kind, dir_word) # genuine within-column step
|
||||||
# else: no neighbour that way — do nothing rather than let the layout scroll the
|
else:
|
||||||
# tape (that sideways/vertical "rotation" is exactly what we're suppressing).
|
# 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
|
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