diff --git a/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py b/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py index 355cd52..7eccdc6 100644 --- a/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py +++ b/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py @@ -59,8 +59,45 @@ class QuadGrid(Gtk.Overlay): # is actually expanded, otherwise it paints over the 2x2 grid. self._expand_reveal.set_visible(False) + # A second overlay: an external "takeover" widget (the taskbar's workspace/ + # window panel) that, when shown, covers the whole quad region. + self._takeover_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.CROSSFADE, + transition_duration=180, reveal_child=False) + self._takeover_reveal.set_halign(Gtk.Align.FILL) + self._takeover_reveal.set_valign(Gtk.Align.FILL) + self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._takeover_holder.add_css_class("quad-expanded") + self._takeover_reveal.set_child(bordered(self._takeover_holder, radius=16, fill_bg=True)) + self.add_overlay(self._takeover_reveal) + self._takeover_reveal.set_visible(False) + settings.subscribe(self._on_settings_changed) + # -- external takeover (taskbar panel) --------------------------------- + def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None: + """Mount an external widget (once) that will cover the quad region when shown. + A Back row is prepended so the covered quads can be restored.""" + header = Gtk.CenterBox() + header.add_css_class("expanded-header") + back = Gtk.Button(label=" Back") + back.add_css_class("quad-action") + back.connect("clicked", lambda *_: on_back()) + header.set_start_widget(back) + self._takeover_holder.append(header) + widget.set_vexpand(True) + self._takeover_holder.append(widget) + + def show_takeover(self) -> None: + self._takeover_reveal.set_visible(True) + self._takeover_reveal.set_reveal_child(True) + self.grid.add_css_class("dimmed") + + def hide_takeover(self) -> None: + self._takeover_reveal.set_reveal_child(False) + self._takeover_reveal.set_visible(False) + self.grid.remove_css_class("dimmed") + # -- expansion --------------------------------------------------------- def request_expand(self, module_id: str) -> None: card = self.cards.get(module_id) diff --git a/desktopenvs/hyprlua/astal-menu/ui/taskbar.py b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py index d01f04b..6a5d143 100644 --- a/desktopenvs/hyprlua/astal-menu/ui/taskbar.py +++ b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py @@ -1,7 +1,9 @@ -"""Full-width taskbar strip: jump to any open window, plus a pop-open panel. +"""Full-width taskbar strip: jump to any open window, plus an expand-over panel. Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of -instances (grouped). Pop-open (the ⤢ button) reveals: +instances (grouped). The ⤢ toggle collapses this compact strip and hands its panel +(exposed as `panel_widget`) to the menu window, which mounts it over the quad region +so it *covers* the 2x2 quads rather than pushing anything down. The panel holds: * workspace/layout controls — pick the current workspace's layout (scrolling / dwindle / master / monocle, enumerated from ~/.cache/astal-menu/layouts.json, written by hypr/layouts) and, for directional layouts, its direction. Applied @@ -36,15 +38,27 @@ def _dispatch(lua: str) -> None: class Taskbar(Gtk.Box): - def __init__(self, on_activate): + def __init__(self, on_activate, on_toggle_panel=None): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.add_css_class("taskbar") self._on_activate = on_activate + self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads self._apps = AstalApps.Apps() self._wm_index = self._build_wm_index() self._layouts = self._load_layouts() self._active_ws = None self._clients: list = [] + self._dir_dds = {} + self._fit_sws = {} + + # The workspace/window panel. It is NOT appended here: the menu window mounts + # `panel_widget` into the quad region so that expanding COLLAPSES this strip's + # body (below) and the panel COVERS the 2x2 quads instead of pushing anything. + self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._panel.add_css_class("task-panel") + self._panel_scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + self._panel_scroll.set_child(self._panel) header = Gtk.CenterBox() title = Gtk.Label(label="Open windows", xalign=0.0) @@ -53,27 +67,40 @@ class Taskbar(Gtk.Box): self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand self._expand_btn.add_css_class("quad-action") self._expand_btn.set_tooltip_text("Workspace & window controls") - self._expand_btn.connect("toggled", lambda b: self._reveal.set_reveal_child(b.get_active())) + self._expand_btn.connect("toggled", lambda b: self._toggle_panel(b.get_active())) header.set_end_widget(self._expand_btn) self.append(header) - # compact icon strip + # compact icon strip (hidden while the panel is expanded) self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self._row.add_css_class("taskbar-row") - scroller = Gtk.ScrolledWindow( + self._strip = Gtk.ScrolledWindow( vscrollbar_policy=Gtk.PolicyType.NEVER, hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) - scroller.set_child(self._row) - self.append(scroller) + self._strip.set_child(self._row) + self.append(self._strip) - # pop-open panel: layout controls + detailed window list - self._reveal = Gtk.Revealer( - transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, - transition_duration=180, reveal_child=False) - self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._panel.add_css_class("task-panel") - self._reveal.set_child(self._panel) - self.append(self._reveal) + # -- expand-over-the-quads panel -------------------------------------- + @property + def panel_widget(self) -> Gtk.Widget: + """The workspace/window panel body; the window mounts this over the quads.""" + return self._panel_scroll + + def _toggle_panel(self, active: bool) -> None: + # Collapse this strip's compact body; the window reveals/hides the panel that + # it has mounted over the quad region (via on_toggle_panel). + self._strip.set_visible(not active) + if self._on_toggle_panel: + self._on_toggle_panel(active) + if active: + self.refresh() + + def collapse_panel(self) -> None: + """Return to the compact strip (called on menu hide / from the panel's Back).""" + if self._expand_btn.get_active(): + self._expand_btn.set_active(False) # fires toggled -> _toggle_panel(False) + else: + self._strip.set_visible(True) # -- setup helpers ----------------------------------------------------- def _build_wm_index(self) -> dict: @@ -97,7 +124,11 @@ class Taskbar(Gtk.Box): except (FileNotFoundError, json.JSONDecodeError): # fallback if hypr/layouts hasn't written the manifest yet return [{"name": "scrolling", "label": "Scrolling", "directional": True, - "dirs": ["down", "up", "right", "left"], "default_dir": "down"}, + "dirs": ["down", "up", "right", "left"], "default_dir": "down", + "fit_method": True}, + {"name": "columns", "label": "Columns", "directional": True, + "dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"], + "default_dir": "right", "fit_method": True}, {"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []}, {"name": "master", "label": "Master", "directional": False, "dirs": []}, {"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}] @@ -174,8 +205,8 @@ class Taskbar(Gtk.Box): hdr.add_css_class("section-title") self._panel.append(hdr) - self._dir_dd = None - self._fit_sw = None + self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values]) + self._fit_sws = {} # layout name -> Gtk.Switch self._layout_stack = Gtk.Stack() for ly in self._layouts: self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"]) @@ -235,11 +266,14 @@ class Taskbar(Gtk.Box): if ly.get("dirs"): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True)) - dd = Gtk.DropDown.new_from_strings(ly["dirs"]) + # dir_labels (optional) are friendly labels shown in place of the raw + # scrolling:direction values (e.g. "Left / Right" for "right"). + labels = ly.get("dir_labels") or ly["dirs"] + dd = Gtk.DropDown.new_from_strings(labels) dd.connect("notify::selected", self._on_opt_change) row.append(dd) page.append(row) - self._dir_dd = dd # only scrolling declares dirs + self._dir_dds[ly["name"]] = (dd, list(ly["dirs"])) if ly.get("fit_method"): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True)) @@ -249,7 +283,7 @@ class Taskbar(Gtk.Box): sw.connect("state-set", self._on_fit_toggle) row.append(sw) page.append(row) - self._fit_sw = sw + self._fit_sws[ly["name"]] = sw if not ly.get("dirs") and not ly.get("fit_method"): page.append(Gtk.Label(label="No adjustable options", xalign=0.0, css_classes=["net-ip"])) @@ -276,30 +310,29 @@ class Taskbar(Gtk.Box): self._syncing = False def _apply_dir_sel(self, ok, data) -> None: - if self._dir_dd is None: - return + # scrolling:direction is global; reflect it in every directional layout's + # dropdown whose value set contains it. cur = data.get("str") if ok and isinstance(data, dict) else None - model = self._dir_dd.get_model() - for i in range(model.get_n_items() if model else 0): - if model.get_string(i) == cur: - self._syncing = True - self._dir_dd.set_selected(i) - self._syncing = False - break + self._syncing = True + for dd, values in self._dir_dds.values(): + if cur in values: + dd.set_selected(values.index(cur)) + self._syncing = False def _apply_fit_sel(self, ok, data) -> None: - if self._fit_sw is None: - return val = data.get("int") if ok and isinstance(data, dict) else 0 self._syncing = True - self._fit_sw.set_active(val == 1) + for sw in self._fit_sws.values(): + sw.set_active(val == 1) self._syncing = False def _cur_dir(self) -> str: - if self._dir_dd is None: + entry = self._dir_dds.get(self._layout_stack.get_visible_child_name()) + if not entry: return "" - m = self._dir_dd.get_model() - return m.get_string(self._dir_dd.get_selected()) if m else "" + dd, values = entry + i = dd.get_selected() + return values[i] if 0 <= i < len(values) else "" def _on_tab_switch(self, *_a) -> None: if getattr(self, "_syncing", False) or self._active_ws is None: diff --git a/desktopenvs/hyprlua/astal-menu/window.py b/desktopenvs/hyprlua/astal-menu/window.py index 63cd441..60bbd76 100644 --- a/desktopenvs/hyprlua/astal-menu/window.py +++ b/desktopenvs/hyprlua/astal-menu/window.py @@ -66,7 +66,8 @@ class MenuWindow(Gtk.ApplicationWindow): self.root.append(self.statsbar_reveal) # taskbar (full-width, top) - self.taskbar = Taskbar(on_activate=self.hide_menu) + self.taskbar = Taskbar(on_activate=self.hide_menu, + on_toggle_panel=self._on_taskbar_panel) self.taskbar_reveal = Gtk.Revealer( transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, transition_duration=160, reveal_child=True) @@ -76,6 +77,10 @@ class MenuWindow(Gtk.ApplicationWindow): # quads specs = list(ordered_specs(settings)) self.grid = QuadGrid(specs, settings, services) + # The taskbar's workspace/window panel is mounted over the quad region so that + # expanding it collapses the taskbar strip and covers the 2x2 quads. + self.grid.set_takeover_widget(self.taskbar.panel_widget, + on_back=self.taskbar.collapse_panel) self.quad_reveal = Gtk.Revealer( transition_type=Gtk.RevealerTransitionType.SLIDE_UP, transition_duration=200, reveal_child=True) @@ -152,6 +157,14 @@ class MenuWindow(Gtk.ApplicationWindow): width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH) self.root.set_size_request(width, -1) + # -- taskbar panel expansion ------------------------------------------ + def _on_taskbar_panel(self, expanded: bool) -> None: + # Cover the quads with the taskbar's workspace/window panel (the strip itself + # collapses inside the Taskbar). Collapse any expanded quad first. + if expanded and self.grid.is_expanded: + self.grid.request_collapse() + self.grid.show_takeover() if expanded else self.grid.hide_takeover() + # -- appdrawer expansion ---------------------------------------------- def _on_appdrawer_expand(self, expanded: bool) -> None: self._drawer_expanded = expanded @@ -177,6 +190,8 @@ class MenuWindow(Gtk.ApplicationWindow): def hide_menu(self) -> None: self.grid.on_hide() + self.taskbar.collapse_panel() + self.grid.hide_takeover() self.appdrawer.set_expanded(False) self.set_visible(False) diff --git a/desktopenvs/hyprlua/hypr/layouts/columns.lua b/desktopenvs/hyprlua/hypr/layouts/columns.lua new file mode 100644 index 0000000..21f50d3 --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/columns.lua @@ -0,0 +1,34 @@ +-- Columns: the scrolling engine, presented as independent columns. Each column is a +-- stack of windows; windows move up/down within their column and columns move +-- left/right — every column independently (this is exactly what the scrolling +-- backend's per-column tape already does). +-- +-- The one menu control is orientation — which way the tape scrolls — chosen from a +-- dropdown of two friendly options (see dir_labels): +-- * "Left / Right" -> columns sit side by side, the tape scrolls horizontally +-- (scrolling:direction = "right"). +-- * "Up / Down" -> columns are stacked as rows, the tape scrolls vertically +-- (scrolling:direction = "down"). +-- +-- Shares the scrolling backend and all scrolling:* options with the "scrolling" +-- layout; direction is a global Hyprland option, so switching orientation here +-- affects every scrolling/columns workspace (same limitation as scrolling.lua). +return { + name = "columns", + label = "Columns", + backend = "scrolling", -- hyprland general.layout value + directional = true, + dirs = { "right", "down" }, + dir_labels = { "Left / Right", "Up / Down" }, + default_dir = "right", + fit_method = true, -- exposes the "Centered follow" toggle in the menu + config = { + scrolling = { + direction = "right", + column_width = 0.5, + focus_fit_method = 1, + follow_focus = true, + fullscreen_on_one_column = true, + }, + }, +} diff --git a/desktopenvs/hyprlua/hypr/layouts/init.lua b/desktopenvs/hyprlua/hypr/layouts/init.lua index 7647b45..267c524 100644 --- a/desktopenvs/hyprlua/hypr/layouts/init.lua +++ b/desktopenvs/hyprlua/hypr/layouts/init.lua @@ -35,11 +35,14 @@ function M.write_manifest() local s = M.registry[name] local dirs = {} for _, d in ipairs(s.dirs or {}) do dirs[#dirs + 1] = jstr(d) end + local dlabels = {} + for _, d in ipairs(s.dir_labels or {}) do dlabels[#dlabels + 1] = jstr(d) end items[#items + 1] = table.concat({ "{", '"name":', jstr(s.name), ',"label":', jstr(s.label or s.name), ',"directional":', tostring(s.directional or false), ',"dirs":[', table.concat(dirs, ","), "]", + ',"dir_labels":[', table.concat(dlabels, ","), "]", ',"default_dir":', jstr(s.default_dir or ""), ',"fit_method":', tostring(s.fit_method or false), "}", }) diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index 4b035a8..ff61a8d 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -8,6 +8,12 @@ local editor = "kitty nvim" local menu = "vicinae toggle" local winswitch = "" -- TODO: define your window switcher command +-- Screen-absolute focus/move wrapper. In the scrolling/columns layouts, Hyprland +-- resolves a direction perpendicular to the tape onto "scroll the tape", so the same +-- key means different things depending on scrolling:direction. hypr-nav keeps every +-- key literal to its on-screen direction (and is a pass-through for dwindle/master). +local nav = "~/.config/scripts/hypr-nav" + -------------------- ---- LID SWITCH ---- -------------------- @@ -97,14 +103,14 @@ hl.bind(mainMod .. " + CTRL + B", hl.dsp.exec_cmd("eww reload")) ---- FOCUS --------- -------------------- -hl.bind(mainMod .. " + h", hl.dsp.focus({ direction = "left" })) -hl.bind(mainMod .. " + l", hl.dsp.focus({ direction = "right" })) -hl.bind(mainMod .. " + k", hl.dsp.focus({ direction = "up" })) -hl.bind(mainMod .. " + j", hl.dsp.focus({ direction = "down" })) -hl.bind(mainMod .. " + left", hl.dsp.focus({ direction = "left" })) -hl.bind(mainMod .. " + right", hl.dsp.focus({ direction = "right" })) -hl.bind(mainMod .. " + up", hl.dsp.focus({ direction = "up" })) -hl.bind(mainMod .. " + down", hl.dsp.focus({ direction = "down" })) +hl.bind(mainMod .. " + h", hl.dsp.exec_cmd(nav .. " focus l")) +hl.bind(mainMod .. " + l", hl.dsp.exec_cmd(nav .. " focus r")) +hl.bind(mainMod .. " + k", hl.dsp.exec_cmd(nav .. " focus u")) +hl.bind(mainMod .. " + j", hl.dsp.exec_cmd(nav .. " focus d")) +hl.bind(mainMod .. " + left", hl.dsp.exec_cmd(nav .. " focus l")) +hl.bind(mainMod .. " + right", hl.dsp.exec_cmd(nav .. " focus r")) +hl.bind(mainMod .. " + up", hl.dsp.exec_cmd(nav .. " focus u")) +hl.bind(mainMod .. " + down", hl.dsp.exec_cmd(nav .. " focus d")) hl.bind(mainMod .. " + TAB", hl.dsp.window.cycle_next()) hl.bind(mainMod .. " + SHIFT + TAB", hl.dsp.exec_cmd(winswitch)) @@ -113,14 +119,22 @@ hl.bind(mainMod .. " + SHIFT + TAB", hl.dsp.exec_cmd(winswitch)) ---- MOVE WINDOW --- -------------------- -hl.bind(mainMod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" })) -hl.bind(mainMod .. " + SHIFT + right", hl.dsp.window.move({ direction = "right" })) -hl.bind(mainMod .. " + SHIFT + up", hl.dsp.window.move({ direction = "up" })) -hl.bind(mainMod .. " + SHIFT + down", hl.dsp.window.move({ direction = "down" })) -hl.bind(mainMod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" })) -hl.bind(mainMod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" })) -hl.bind(mainMod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" })) -hl.bind(mainMod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" })) +hl.bind(mainMod .. " + SHIFT + left", hl.dsp.exec_cmd(nav .. " move l")) +hl.bind(mainMod .. " + SHIFT + right", hl.dsp.exec_cmd(nav .. " move r")) +hl.bind(mainMod .. " + SHIFT + up", hl.dsp.exec_cmd(nav .. " move u")) +hl.bind(mainMod .. " + SHIFT + down", hl.dsp.exec_cmd(nav .. " move d")) +hl.bind(mainMod .. " + SHIFT + h", hl.dsp.exec_cmd(nav .. " move l")) +hl.bind(mainMod .. " + SHIFT + l", hl.dsp.exec_cmd(nav .. " move r")) +hl.bind(mainMod .. " + SHIFT + k", hl.dsp.exec_cmd(nav .. " move u")) +hl.bind(mainMod .. " + SHIFT + j", hl.dsp.exec_cmd(nav .. " move d")) + +-- cross-monitor: focus / move the active window to the previous/next monitor. +-- Works in every layout (including scrolling, where the directional keys deliberately +-- do NOT jump monitors — see hypr-nav). +1/-1 cycle through the monitors. +hl.bind(mainMod .. " + comma", hl.dsp.focus({ monitor = "-1" })) +hl.bind(mainMod .. " + period", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mainMod .. " + SHIFT + comma", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mainMod .. " + SHIFT + period", hl.dsp.window.move({ monitor = "+1" })) -- mouse drag / resize hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) diff --git a/desktopenvs/hyprlua/scripts/hypr-nav b/desktopenvs/hyprlua/scripts/hypr-nav new file mode 100755 index 0000000..dfd634c --- /dev/null +++ b/desktopenvs/hyprlua/scripts/hypr-nav @@ -0,0 +1,137 @@ +#!/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 any non-scrolling layout (dwindle/master/monocle) it is a straight pass-through, so +nothing else changes. + +Usage: hypr-nav focus l|r|u|d + hypr-nav move l|r|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", "columns"} +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: + if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") \ + or sys.argv[2] not in DIRS: + print(__doc__) + return 2 + kind, key = sys.argv[1], sys.argv[2] + axis, sign, dir_word = DIRS[key] + + ws = hypr_json("activeworkspace") or {} + if active_layout_name(ws.get("id")) 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())