feat(hyprdrive): configurable/live-polled workspace layout picker in astro-menu

config.json gains a "layouts" key to narrow which hypr/layouts-discovered
layouts astro-menu offers (and reorder them), plus per-layout option
overrides (trim directions, hide fit_method/stepper), without touching the
.lua layout sources.

Also: the taskbar's workspace/layout panel now polls every second while the
menu is open, since switching workspaces doesn't notify (or get blocked by)
the menu and the panel previously only refreshed on open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-24 15:07:02 +02:00
parent 008e4d980c
commit 7ebff0d2a4
3 changed files with 74 additions and 13 deletions

View File

@ -4,6 +4,21 @@ Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately
separate from settings.py's Settings class: that one persists quad enable/ separate from settings.py's Settings class: that one persists quad enable/
feature toggles and favorites (module-scoped, read via ctx.feature()), while feature toggles and favorites (module-scoped, read via ctx.feature()), while
this is a single whole-window flag read once at startup. this is a single whole-window flag read once at startup.
"layouts" narrows/customises the workspace-layout picker in the taskbar panel
(ui/taskbar.py) on top of what hypr/layouts auto-discovers and writes to
~/.cache/astro-menu/layouts.json:
"enabled": list of layout names to offer, in that order (tab order follows
it) omit/null to show everything hypr/layouts discovered.
"overrides": {layout_name: {partial layout spec}} shallow-merged onto the
discovered spec for that layout e.g. trim "dirs" to fewer
directions, or set "fit_method"/"stepper" to false to hide
those controls for that layout, without touching its .lua file.
Example:
"layouts": {
"enabled": ["scrolling", "master", "monocle"],
"overrides": {"scrolling": {"dirs": ["down", "right"]}}
}
""" """
from __future__ import annotations from __future__ import annotations
@ -12,7 +27,7 @@ import json
from paths import CONFIG_FILE, ensure_dirs from paths import CONFIG_FILE, ensure_dirs
_DEFAULTS = {"hologram": True} _DEFAULTS = {"hologram": True, "layouts": {"enabled": None, "overrides": {}}}
def _load() -> dict: def _load() -> dict:
@ -29,3 +44,23 @@ def _load() -> dict:
def hologram_enabled() -> bool: def hologram_enabled() -> bool:
return bool(_load().get("hologram", True)) return bool(_load().get("hologram", True))
def apply_layout_config(layouts: list[dict]) -> list[dict]:
"""Filter/reorder/override the layouts hypr/layouts discovered, per the
"layouts" config key. `layouts` is the parsed layouts.json (or its
hardcoded fallback) see Taskbar._load_layouts."""
cfg = _load().get("layouts") or {}
enabled = cfg.get("enabled")
overrides = cfg.get("overrides") or {}
by_name = {ly["name"]: ly for ly in layouts}
names = enabled if enabled else [ly["name"] for ly in layouts]
out = []
for name in names:
ly = by_name.get(name)
if ly is None:
continue # config names a layout hypr/layouts never discovered
out.append({**ly, **overrides.get(name, {})})
return out

View File

@ -7,7 +7,9 @@ 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/astro-menu/layouts.json, dwindle / master / monocle, enumerated from ~/.cache/astro-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
live via `hyprctl eval 'layouts.set(ws, name, dir)'`. live via `hyprctl eval 'layouts.set(ws, name, dir)'`. The set of layouts
offered (and their per-layout options) can be narrowed/customised via
config.py's "layouts" key — see config.apply_layout_config.
* a per-window row list: [icon + title focus/jump] [ pull to this workspace]. * a per-window row list: [icon + title focus/jump] [ pull to this workspace].
""" """
@ -19,8 +21,9 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1") gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402 from gi.repository import AstalApps, Gtk, GLib # noqa: E402
import config
from lib.proc import run_json, run_text from lib.proc import run_json, run_text
from paths import CACHE_DIR from paths import CACHE_DIR
@ -51,6 +54,7 @@ class Taskbar(Gtk.Box):
self._clients: list = [] self._clients: list = []
self._dir_dds = {} self._dir_dds = {}
self._fit_sws = {} self._fit_sws = {}
self._poll_id: int | None = None
# The workspace/window panel. It is NOT appended here: the menu window mounts # 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 # `panel_widget` into the quad region so that expanding COLLAPSES this strip's
@ -121,24 +125,44 @@ class Taskbar(Gtk.Box):
@staticmethod @staticmethod
def _load_layouts() -> list: def _load_layouts() -> list:
try: try:
return json.loads(_LAYOUTS_MANIFEST.read_text()) layouts = json.loads(_LAYOUTS_MANIFEST.read_text())
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, layouts = [{"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}, "fit_method": True},
{"name": "columns", "label": "Columns", "directional": True, {"name": "columns", "label": "Columns", "directional": True,
"dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"], "dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"],
"default_dir": "right", "fit_method": True}, "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": []}]
return config.apply_layout_config(layouts)
# -- populate ---------------------------------------------------------- # -- populate ----------------------------------------------------------
def refresh(self) -> None: def refresh(self) -> None:
run_json(["hyprctl", "clients", "-j"], self._on_clients) run_json(["hyprctl", "clients", "-j"], self._on_clients)
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws) run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
# -- keep the panel live while the menu is open -------------------------
# Hyprland doesn't push workspace-switch events to us, and switching
# workspaces isn't blocked by the menu being open — so without this the
# "Workspace N layout" panel goes stale the moment you change workspace
# while astro-menu is still up. Poll instead; the window starts/stops
# this alongside show_menu/hide_menu.
def start_polling(self) -> None:
if self._poll_id is None:
self._poll_id = GLib.timeout_add(1000, self._on_poll)
def stop_polling(self) -> None:
if self._poll_id is not None:
GLib.source_remove(self._poll_id)
self._poll_id = None
def _on_poll(self) -> bool:
self.refresh()
return True # keep polling every second while the menu is open
def _on_ws(self, ok: bool, data) -> None: def _on_ws(self, ok: bool, data) -> None:
if ok and isinstance(data, dict): if ok and isinstance(data, dict):
self._active_ws = data.get("id") self._active_ws = data.get("id")

View File

@ -192,6 +192,7 @@ class MenuWindow(Gtk.ApplicationWindow):
def show_menu(self, focus_appdrawer: bool = False) -> None: def show_menu(self, focus_appdrawer: bool = False) -> None:
self.appdrawer.set_expanded(False) self.appdrawer.set_expanded(False)
self.taskbar.refresh() self.taskbar.refresh()
self.taskbar.start_polling()
self.grid.on_show() self.grid.on_show()
self.appdrawer.on_show() self.appdrawer.on_show()
self.set_visible(True) self.set_visible(True)
@ -204,6 +205,7 @@ class MenuWindow(Gtk.ApplicationWindow):
self._hologram.start_intro() self._hologram.start_intro()
def hide_menu(self) -> None: def hide_menu(self) -> None:
self.taskbar.stop_polling()
self.grid.on_hide() self.grid.on_hide()
self.taskbar.collapse_panel() self.taskbar.collapse_panel()
self.grid.hide_takeover() self.grid.hide_takeover()