Dotfiles/desktopenvs/hyprlua/astal-menu/ui/taskbar.py

328 lines
14 KiB
Python

"""Full-width taskbar strip: jump to any open window, plus a pop-open panel.
Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of
instances (grouped). Pop-open (the ⤢ button) reveals:
* 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
live via `hyprctl eval 'layouts.set(ws, name, dir)'`.
* a per-window row list: [icon + title → focus/jump] [⇤ pull to this workspace].
"""
from __future__ import annotations
import json
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402
from lib.proc import run_json, run_text
from paths import CACHE_DIR
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json"
def _eval(lua: str) -> None:
run_text(["hyprctl", "eval", lua], lambda *_a: None)
def _dispatch(lua: str) -> None:
# In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API).
run_text(["hyprctl", "dispatch", lua], lambda *_a: None)
class Taskbar(Gtk.Box):
def __init__(self, on_activate):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add_css_class("taskbar")
self._on_activate = on_activate
self._apps = AstalApps.Apps()
self._wm_index = self._build_wm_index()
self._layouts = self._load_layouts()
self._active_ws = None
self._clients: list = []
header = Gtk.CenterBox()
title = Gtk.Label(label="Open windows", xalign=0.0)
title.add_css_class("section-title")
header.set_start_widget(title)
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()))
header.set_end_widget(self._expand_btn)
self.append(header)
# compact icon strip
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._row.add_css_class("taskbar-row")
scroller = Gtk.ScrolledWindow(
vscrollbar_policy=Gtk.PolicyType.NEVER,
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
scroller.set_child(self._row)
self.append(scroller)
# 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)
# -- setup helpers -----------------------------------------------------
def _build_wm_index(self) -> dict:
idx = {}
for app in self._apps.get_list():
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
if key:
idx.setdefault(key.lower(), app)
return idx
def _icon_for(self, cls: str) -> str:
app = self._wm_index.get((cls or "").lower())
if app and app.get_icon_name():
return app.get_icon_name()
return (cls or "application-x-executable").lower()
@staticmethod
def _load_layouts() -> list:
try:
return json.loads(_LAYOUTS_MANIFEST.read_text())
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"},
{"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
{"name": "master", "label": "Master", "directional": False, "dirs": []},
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
# -- populate ----------------------------------------------------------
def refresh(self) -> None:
run_json(["hyprctl", "clients", "-j"], self._on_clients)
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
def _on_ws(self, ok: bool, data) -> None:
if ok and isinstance(data, dict):
self._active_ws = data.get("id")
self._rebuild_panel()
def _on_clients(self, ok: bool, data) -> None:
self._clients = data if ok and isinstance(data, list) else []
self._rebuild_strip()
self._rebuild_panel()
# -- compact strip -----------------------------------------------------
def _rebuild_strip(self) -> None:
self._clear(self._row)
groups: dict[str, list] = {}
for w in self._clients:
if not w.get("mapped", True) or not w.get("class"):
continue
groups.setdefault(w["class"], []).append(w)
if not groups:
self._row.append(Gtk.Label(label="No open windows"))
return
for cls, wins in sorted(groups.items()):
self._row.append(self._group_button(cls, wins))
def _group_button(self, cls: str, wins: list) -> Gtk.Widget:
icon = Gtk.Image.new_from_icon_name(self._icon_for(cls))
icon.set_pixel_size(32)
if len(wins) == 1:
btn = Gtk.Button()
btn.add_css_class("task-tile")
btn.set_child(icon)
btn.set_tooltip_text(wins[0].get("title") or cls)
btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w))
return btn
btn = Gtk.MenuButton()
btn.add_css_class("task-tile")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.append(icon)
badge = Gtk.Label(label=str(len(wins)))
badge.add_css_class("task-badge")
box.append(badge)
btn.set_child(box)
btn.set_tooltip_text(f"{cls} ({len(wins)})")
pop = Gtk.Popover()
pop.add_css_class("task-popover")
plist = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
for w in wins:
item = Gtk.Button(label=w.get("title") or cls)
item.add_css_class("task-window")
item.connect("clicked", lambda *_a, ww=w: (pop.popdown(), self._focus(ww)))
plist.append(item)
pop.set_child(plist)
btn.set_popover(pop)
return btn
# -- pop-open panel: layout controls + window list ---------------------
def _rebuild_panel(self) -> None:
self._clear(self._panel)
# layout controls for the active workspace
ctrl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
ws = self._active_ws
ctrl.append(Gtk.Label(label=f"Workspace {ws if ws is not None else '?'}", xalign=0.0))
names = [ly["label"] for ly in self._layouts]
self._layout_dd = Gtk.DropDown.new_from_strings(names or [""])
self._layout_dd.set_tooltip_text("Layout")
self._layout_dd.connect("notify::selected", self._on_layout_pick)
ctrl.append(self._layout_dd)
self._dir_dd = Gtk.DropDown.new_from_strings([""])
self._dir_dd.set_tooltip_text("Direction")
self._dir_dd.connect("notify::selected", self._on_dir_pick)
ctrl.append(self._dir_dd)
self._fit_btn = Gtk.ToggleButton(label="Centered")
self._fit_btn.add_css_class("quad-action")
self._fit_btn.set_tooltip_text(
"Centered follow: keep the focused column centred so the prev/next columns "
"stay on-screen and tappable (scrolling focus_fit_method)")
self._fit_btn.connect("toggled", self._on_fit_toggle)
ctrl.append(self._fit_btn)
self._panel.append(ctrl)
self._sync_layout_controls()
self._panel.append(Gtk.Separator())
# per-window rows: [focus/jump] [pull here]
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
(w.get("title") or w.get("class") or "").lower()))
if not wins:
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
return
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(listing)
for w in wins:
listing.append(self._window_row(w))
self._panel.append(scroller)
def _window_row(self, w) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.add_css_class("task-row")
icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", "")))
icon.set_pixel_size(22)
wsid = w.get("workspace", {}).get("id")
title = (w.get("title") or w.get("class") or "?")
name = Gtk.Button(hexpand=True)
name.add_css_class("task-name")
lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
lbl.append(icon)
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
lbl.append(wl)
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
name.set_child(lbl)
name.set_tooltip_text("Jump to window")
name.connect("clicked", lambda *_a: self._focus(w))
row.append(name)
pull = Gtk.Button(label="⇤ here")
pull.add_css_class("quad-action")
pull.set_tooltip_text("Pull this window to the current workspace")
pull.connect("clicked", lambda *_a: self._pull(w))
row.append(pull)
return row
# -- layout control sync + handlers ------------------------------------
def _sync_layout_controls(self) -> None:
# reflect the current general:layout / scrolling:direction / focus_fit in the UI
self._syncing = True
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_layout_sel)
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], self._apply_dir_sel)
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], self._apply_fit_sel)
def _apply_fit_sel(self, ok, data) -> None:
val = data.get("int") if ok and isinstance(data, dict) else 0
self._syncing = True
self._fit_btn.set_active(val == 1)
self._syncing = False
def _apply_layout_sel(self, ok, data) -> None:
cur = data.get("str") if ok and isinstance(data, dict) else None
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set), so
# the dropdown reflects THIS workspace, not just the global default.
try:
state = json.loads(_LAYOUTS_STATE.read_text())
cur = state.get(str(self._active_ws), cur)
except (FileNotFoundError, json.JSONDecodeError):
pass
idx = next((i for i, ly in enumerate(self._layouts) if ly["name"] == cur), 0)
self._layout_dd.set_selected(idx)
self._refresh_dir_dd(idx)
self._syncing = False
def _refresh_dir_dd(self, layout_idx: int) -> None:
ly = self._layouts[layout_idx] if layout_idx < len(self._layouts) else {}
dirs = ly.get("dirs") or []
self._dir_dd.set_model(Gtk.StringList.new(dirs or [""]))
self._dir_dd.set_sensitive(bool(dirs))
self._fit_btn.set_visible(bool(ly.get("fit_method")))
def _apply_dir_sel(self, ok, data) -> None:
cur = data.get("str") if ok and isinstance(data, dict) else None
model = self._dir_dd.get_model()
if model is not None:
for i in range(model.get_n_items()):
if model.get_string(i) == cur:
self._dir_dd.set_selected(i)
break
def _selected_layout(self) -> dict | None:
i = self._layout_dd.get_selected()
return self._layouts[i] if 0 <= i < len(self._layouts) else None
def _on_layout_pick(self, *_a) -> None:
if getattr(self, "_syncing", False):
return
ly = self._selected_layout()
if ly is None or self._active_ws is None:
return
self._refresh_dir_dd(self._layout_dd.get_selected())
dm = self._dir_dd.get_model()
d = dm.get_string(self._dir_dd.get_selected()) if (dm and ly.get("dirs")) else ""
_eval(f'layouts.set("{self._active_ws}", "{ly["name"]}", "{d}")')
def _on_fit_toggle(self, btn) -> None:
if getattr(self, "_syncing", False):
return
_eval(f"layouts.set_fit({1 if btn.get_active() else 0})")
def _on_dir_pick(self, *_a) -> None:
if getattr(self, "_syncing", False):
return
ly = self._selected_layout()
dm = self._dir_dd.get_model()
if ly is None or not ly.get("dirs") or dm is None or self._active_ws is None:
return
d = dm.get_string(self._dir_dd.get_selected())
_eval(f'layouts.set("{self._active_ws}", "{ly["name"]}", "{d}")')
# -- window actions ----------------------------------------------------
def _focus(self, w) -> None:
addr = w.get("address")
if addr:
_dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})')
self._on_activate()
def _pull(self, w) -> None:
addr = w.get("address")
if addr and self._active_ws is not None:
_dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", '
f'workspace = "{self._active_ws}" }})')
run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move
@staticmethod
def _clear(box: Gtk.Box) -> None:
child = box.get_first_child()
while child:
box.remove(child)
child = box.get_first_child()