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

343 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)
# tabbed layout selector for the active workspace: a tab per layout, and a
# per-layout options page underneath that switches with the tab. Selecting a
# tab applies that layout to the currently focused workspace.
ws = self._active_ws
hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
hdr.add_css_class("section-title")
self._panel.append(hdr)
self._dir_dd = None
self._fit_sw = None
self._layout_stack = Gtk.Stack()
for ly in self._layouts:
self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"])
switcher = Gtk.StackSwitcher(stack=self._layout_stack)
switcher.add_css_class("net-switcher") # reuse the tab pill styling
self._panel.append(switcher)
self._panel.append(self._layout_stack)
self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
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
# -- per-layout options page -------------------------------------------
def _layout_page(self, ly: dict) -> Gtk.Widget:
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
page.add_css_class("layout-page")
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"])
dd.connect("notify::selected", self._on_opt_change)
row.append(dd)
page.append(row)
self._dir_dd = dd # only scrolling declares 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))
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
"columns stay on-screen and tappable")
sw.connect("state-set", self._on_fit_toggle)
row.append(sw)
page.append(row)
self._fit_sw = 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"]))
return page
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None:
# reflect the current per-ws layout / direction / focus_fit in the tabs+options
self._syncing = True
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_tab_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_tab_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)
try:
state = json.loads(_LAYOUTS_STATE.read_text())
cur = state.get(str(self._active_ws), cur)
except (FileNotFoundError, json.JSONDecodeError):
pass
if cur and any(ly["name"] == cur for ly in self._layouts):
self._layout_stack.set_visible_child_name(cur)
self._syncing = False
def _apply_dir_sel(self, ok, data) -> None:
if self._dir_dd is None:
return
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)
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)
self._syncing = False
def _cur_dir(self) -> str:
if self._dir_dd is None:
return ""
m = self._dir_dd.get_model()
return m.get_string(self._dir_dd.get_selected()) if m else ""
def _on_tab_switch(self, *_a) -> None:
if getattr(self, "_syncing", False) or self._active_ws is None:
return
name = self._layout_stack.get_visible_child_name()
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_opt_change(self, *_a) -> None:
if getattr(self, "_syncing", False) or self._active_ws is None:
return
name = self._layout_stack.get_visible_child_name()
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_fit_toggle(self, _sw, state) -> bool:
if not getattr(self, "_syncing", False):
_eval(f"layouts.set_fit({1 if state else 0})")
return False
# -- 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()