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_01SUN7gg6GGfnToghMijLm5Ymain
parent
4196b84ad3
commit
6631405570
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
for dd, values in self._dir_dds.values():
|
||||
if cur in values:
|
||||
dd.set_selected(values.index(cur))
|
||||
self._syncing = False
|
||||
break
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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), "}",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue