feat(hyprlua): configurable/live-polled workspace layout picker in astal-menu
Ports hyprdrive's astro-menu update to astal-menu (hand-maintained separately, no regen script for this pair): a new config.py with a "layouts" key to narrow/reorder which hypr/layouts-discovered layouts are offered and override their per-layout options, plus 1s polling of the taskbar's workspace/layout panel while the menu is open so it doesn't go stale across workspace switches. No hologram code path here since astal-menu doesn't have one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>main
parent
7ebff0d2a4
commit
967af1c0f2
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Tiny user-editable config file: ~/.local/state/astal-menu/config.json.
|
||||||
|
|
||||||
|
"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/astal-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
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from paths import CONFIG_FILE, ensure_dirs
|
||||||
|
|
||||||
|
_DEFAULTS = {"layouts": {"enabled": None, "overrides": {}}}
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_FILE.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
merged = {**_DEFAULTS, **data}
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
ensure_dirs()
|
||||||
|
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -17,6 +17,7 @@ CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "as
|
||||||
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astal-menu"
|
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astal-menu"
|
||||||
|
|
||||||
SETTINGS_FILE = STATE_DIR / "settings.json"
|
SETTINGS_FILE = STATE_DIR / "settings.json"
|
||||||
|
CONFIG_FILE = STATE_DIR / "config.json"
|
||||||
|
|
||||||
APP_ID = "eu.abdelbaki.astalmenu"
|
APP_ID = "eu.abdelbaki.astalmenu"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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
|
||||||
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,10 +125,10 @@ 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,
|
||||||
|
|
@ -133,12 +137,32 @@ class Taskbar(Gtk.Box):
|
||||||
{"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 astal-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")
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,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)
|
||||||
|
|
@ -192,6 +193,7 @@ class MenuWindow(Gtk.ApplicationWindow):
|
||||||
self.appdrawer.set_expanded(True)
|
self.appdrawer.set_expanded(True)
|
||||||
|
|
||||||
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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue