feat(hyprlua): taskbar quad-takeover, columns layout, screen-absolute nav

astal-menu: the taskbar ⤢ toggle now collapses the compact strip and mounts
its workspace/window panel as a takeover overlay covering the 2x2 quads (with
a Back button), instead of a popover/inline reveal. QuadGrid gains a generic
takeover overlay; the menu window coordinates show/hide and resets on close.

hypr: new "Columns" layout (scrolling backend presented as independent columns)
with a menu dropdown to pick orientation — "Left / Right" or "Up / Down" — via a
new dir_labels manifest field. The layout menu now tracks direction dropdowns and
fit switches per-layout, so a second directional layout no longer clobbers the
first.

binds/scripts: focus & window-move keys (hjkl + arrows) route through a new
hypr-nav helper so they stay screen-absolute in the scrolling/columns layouts
(the layout otherwise resolves a perpendicular direction onto "scroll the tape",
rotating the keys with scrolling:direction). Adds explicit cross-monitor binds:
Super+,/. focus prev/next monitor, Super+Shift+,/. move the window there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
main
Amir Alexander Abdelbaki 2026-07-07 09:28:41 +02:00
parent 4196b84ad3
commit 6631405570
7 changed files with 327 additions and 54 deletions

View File

@ -59,8 +59,45 @@ class QuadGrid(Gtk.Overlay):
# is actually expanded, otherwise it paints over the 2x2 grid. # is actually expanded, otherwise it paints over the 2x2 grid.
self._expand_reveal.set_visible(False) 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) 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 --------------------------------------------------------- # -- expansion ---------------------------------------------------------
def request_expand(self, module_id: str) -> None: def request_expand(self, module_id: str) -> None:
card = self.cards.get(module_id) card = self.cards.get(module_id)

View File

@ -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 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 / * workspace/layout controls pick the current workspace's layout (scrolling /
dwindle / master / monocle, enumerated from ~/.cache/astal-menu/layouts.json, dwindle / master / monocle, enumerated from ~/.cache/astal-menu/layouts.json,
written by hypr/layouts) and, for directional layouts, its direction. Applied written by hypr/layouts) and, for directional layouts, its direction. Applied
@ -36,15 +38,27 @@ def _dispatch(lua: str) -> None:
class Taskbar(Gtk.Box): 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) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add_css_class("taskbar") self.add_css_class("taskbar")
self._on_activate = on_activate self._on_activate = on_activate
self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads
self._apps = AstalApps.Apps() self._apps = AstalApps.Apps()
self._wm_index = self._build_wm_index() self._wm_index = self._build_wm_index()
self._layouts = self._load_layouts() self._layouts = self._load_layouts()
self._active_ws = None self._active_ws = None
self._clients: list = [] 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() header = Gtk.CenterBox()
title = Gtk.Label(label="Open windows", xalign=0.0) 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 = Gtk.ToggleButton(label="") # nf-fa-expand
self._expand_btn.add_css_class("quad-action") self._expand_btn.add_css_class("quad-action")
self._expand_btn.set_tooltip_text("Workspace & window controls") 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) header.set_end_widget(self._expand_btn)
self.append(header) 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 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._row.add_css_class("taskbar-row") self._row.add_css_class("taskbar-row")
scroller = Gtk.ScrolledWindow( self._strip = Gtk.ScrolledWindow(
vscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.NEVER,
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
scroller.set_child(self._row) self._strip.set_child(self._row)
self.append(scroller) self.append(self._strip)
# pop-open panel: layout controls + detailed window list # -- expand-over-the-quads panel --------------------------------------
self._reveal = Gtk.Revealer( @property
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, def panel_widget(self) -> Gtk.Widget:
transition_duration=180, reveal_child=False) """The workspace/window panel body; the window mounts this over the quads."""
self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) return self._panel_scroll
self._panel.add_css_class("task-panel")
self._reveal.set_child(self._panel) def _toggle_panel(self, active: bool) -> None:
self.append(self._reveal) # 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 ----------------------------------------------------- # -- setup helpers -----------------------------------------------------
def _build_wm_index(self) -> dict: def _build_wm_index(self) -> dict:
@ -97,7 +124,11 @@ class Taskbar(Gtk.Box):
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
# fallback if hypr/layouts hasn't written the manifest yet # fallback if hypr/layouts hasn't written the manifest yet
return [{"name": "scrolling", "label": "Scrolling", "directional": True, 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": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
{"name": "master", "label": "Master", "directional": False, "dirs": []}, {"name": "master", "label": "Master", "directional": False, "dirs": []},
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}] {"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
@ -174,8 +205,8 @@ class Taskbar(Gtk.Box):
hdr.add_css_class("section-title") hdr.add_css_class("section-title")
self._panel.append(hdr) self._panel.append(hdr)
self._dir_dd = None self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values])
self._fit_sw = None self._fit_sws = {} # layout name -> Gtk.Switch
self._layout_stack = Gtk.Stack() self._layout_stack = Gtk.Stack()
for ly in self._layouts: for ly in self._layouts:
self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"]) 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"): if ly.get("dirs"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True)) 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) dd.connect("notify::selected", self._on_opt_change)
row.append(dd) row.append(dd)
page.append(row) 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"): if ly.get("fit_method"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True)) 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) sw.connect("state-set", self._on_fit_toggle)
row.append(sw) row.append(sw)
page.append(row) page.append(row)
self._fit_sw = sw self._fit_sws[ly["name"]] = sw
if not ly.get("dirs") and not ly.get("fit_method"): if not ly.get("dirs") and not ly.get("fit_method"):
page.append(Gtk.Label(label="No adjustable options", xalign=0.0, page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
css_classes=["net-ip"])) css_classes=["net-ip"]))
@ -276,30 +310,29 @@ class Taskbar(Gtk.Box):
self._syncing = False self._syncing = False
def _apply_dir_sel(self, ok, data) -> None: def _apply_dir_sel(self, ok, data) -> None:
if self._dir_dd is None: # scrolling:direction is global; reflect it in every directional layout's
return # 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
model = self._dir_dd.get_model() self._syncing = True
for i in range(model.get_n_items() if model else 0): for dd, values in self._dir_dds.values():
if model.get_string(i) == cur: if cur in values:
self._syncing = True dd.set_selected(values.index(cur))
self._dir_dd.set_selected(i) self._syncing = False
self._syncing = False
break
def _apply_fit_sel(self, ok, data) -> None: 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 val = data.get("int") if ok and isinstance(data, dict) else 0
self._syncing = True 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 self._syncing = False
def _cur_dir(self) -> str: 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 "" return ""
m = self._dir_dd.get_model() dd, values = entry
return m.get_string(self._dir_dd.get_selected()) if m else "" i = dd.get_selected()
return values[i] if 0 <= i < len(values) else ""
def _on_tab_switch(self, *_a) -> None: def _on_tab_switch(self, *_a) -> None:
if getattr(self, "_syncing", False) or self._active_ws is None: if getattr(self, "_syncing", False) or self._active_ws is None:

View File

@ -66,7 +66,8 @@ class MenuWindow(Gtk.ApplicationWindow):
self.root.append(self.statsbar_reveal) self.root.append(self.statsbar_reveal)
# taskbar (full-width, top) # 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( self.taskbar_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
transition_duration=160, reveal_child=True) transition_duration=160, reveal_child=True)
@ -76,6 +77,10 @@ class MenuWindow(Gtk.ApplicationWindow):
# quads # quads
specs = list(ordered_specs(settings)) specs = list(ordered_specs(settings))
self.grid = QuadGrid(specs, settings, services) 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( self.quad_reveal = Gtk.Revealer(
transition_type=Gtk.RevealerTransitionType.SLIDE_UP, transition_type=Gtk.RevealerTransitionType.SLIDE_UP,
transition_duration=200, reveal_child=True) 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) width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH)
self.root.set_size_request(width, -1) 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 ---------------------------------------------- # -- appdrawer expansion ----------------------------------------------
def _on_appdrawer_expand(self, expanded: bool) -> None: def _on_appdrawer_expand(self, expanded: bool) -> None:
self._drawer_expanded = expanded self._drawer_expanded = expanded
@ -177,6 +190,8 @@ class MenuWindow(Gtk.ApplicationWindow):
def hide_menu(self) -> None: def hide_menu(self) -> None:
self.grid.on_hide() self.grid.on_hide()
self.taskbar.collapse_panel()
self.grid.hide_takeover()
self.appdrawer.set_expanded(False) self.appdrawer.set_expanded(False)
self.set_visible(False) self.set_visible(False)

View File

@ -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,
},
},
}

View File

@ -35,11 +35,14 @@ function M.write_manifest()
local s = M.registry[name] local s = M.registry[name]
local dirs = {} local dirs = {}
for _, d in ipairs(s.dirs or {}) do dirs[#dirs + 1] = jstr(d) end 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({ items[#items + 1] = table.concat({
"{", '"name":', jstr(s.name), "{", '"name":', jstr(s.name),
',"label":', jstr(s.label or s.name), ',"label":', jstr(s.label or s.name),
',"directional":', tostring(s.directional or false), ',"directional":', tostring(s.directional or false),
',"dirs":[', table.concat(dirs, ","), "]", ',"dirs":[', table.concat(dirs, ","), "]",
',"dir_labels":[', table.concat(dlabels, ","), "]",
',"default_dir":', jstr(s.default_dir or ""), ',"default_dir":', jstr(s.default_dir or ""),
',"fit_method":', tostring(s.fit_method or false), "}", ',"fit_method":', tostring(s.fit_method or false), "}",
}) })

View File

@ -8,6 +8,12 @@ local editor = "kitty nvim"
local menu = "vicinae toggle" local menu = "vicinae toggle"
local winswitch = "" -- TODO: define your window switcher command 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 ---- ---- LID SWITCH ----
-------------------- --------------------
@ -97,14 +103,14 @@ hl.bind(mainMod .. " + CTRL + B", hl.dsp.exec_cmd("eww reload"))
---- FOCUS --------- ---- FOCUS ---------
-------------------- --------------------
hl.bind(mainMod .. " + h", hl.dsp.focus({ direction = "left" })) hl.bind(mainMod .. " + h", hl.dsp.exec_cmd(nav .. " focus l"))
hl.bind(mainMod .. " + l", hl.dsp.focus({ direction = "right" })) hl.bind(mainMod .. " + l", hl.dsp.exec_cmd(nav .. " focus r"))
hl.bind(mainMod .. " + k", hl.dsp.focus({ direction = "up" })) hl.bind(mainMod .. " + k", hl.dsp.exec_cmd(nav .. " focus u"))
hl.bind(mainMod .. " + j", hl.dsp.focus({ direction = "down" })) hl.bind(mainMod .. " + j", hl.dsp.exec_cmd(nav .. " focus d"))
hl.bind(mainMod .. " + left", hl.dsp.focus({ direction = "left" })) hl.bind(mainMod .. " + left", hl.dsp.exec_cmd(nav .. " focus l"))
hl.bind(mainMod .. " + right", hl.dsp.focus({ direction = "right" })) hl.bind(mainMod .. " + right", hl.dsp.exec_cmd(nav .. " focus r"))
hl.bind(mainMod .. " + up", hl.dsp.focus({ direction = "up" })) hl.bind(mainMod .. " + up", hl.dsp.exec_cmd(nav .. " focus u"))
hl.bind(mainMod .. " + down", hl.dsp.focus({ direction = "down" })) hl.bind(mainMod .. " + down", hl.dsp.exec_cmd(nav .. " focus d"))
hl.bind(mainMod .. " + TAB", hl.dsp.window.cycle_next()) hl.bind(mainMod .. " + TAB", hl.dsp.window.cycle_next())
hl.bind(mainMod .. " + SHIFT + TAB", hl.dsp.exec_cmd(winswitch)) 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 --- ---- MOVE WINDOW ---
-------------------- --------------------
hl.bind(mainMod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" })) hl.bind(mainMod .. " + SHIFT + left", hl.dsp.exec_cmd(nav .. " move l"))
hl.bind(mainMod .. " + SHIFT + right", hl.dsp.window.move({ direction = "right" })) hl.bind(mainMod .. " + SHIFT + right", hl.dsp.exec_cmd(nav .. " move r"))
hl.bind(mainMod .. " + SHIFT + up", hl.dsp.window.move({ direction = "up" })) hl.bind(mainMod .. " + SHIFT + up", hl.dsp.exec_cmd(nav .. " move u"))
hl.bind(mainMod .. " + SHIFT + down", hl.dsp.window.move({ direction = "down" })) hl.bind(mainMod .. " + SHIFT + down", hl.dsp.exec_cmd(nav .. " move d"))
hl.bind(mainMod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" })) hl.bind(mainMod .. " + SHIFT + h", hl.dsp.exec_cmd(nav .. " move l"))
hl.bind(mainMod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" })) hl.bind(mainMod .. " + SHIFT + l", hl.dsp.exec_cmd(nav .. " move r"))
hl.bind(mainMod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" })) hl.bind(mainMod .. " + SHIFT + k", hl.dsp.exec_cmd(nav .. " move u"))
hl.bind(mainMod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" })) 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 -- mouse drag / resize
hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })

View File

@ -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())