Dotfiles/desktopenvs/hyprdrive/astro-menu/ui/taskbar.py

577 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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). 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/astro-menu/layouts.json,
written by hypr/layouts) and, for directional layouts, its direction. Applied
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].
"""
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, GLib # noqa: E402
import config
from lib.proc import run_json, run_text
from paths import CACHE_DIR
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
_COLUMNS_STATE = CACHE_DIR / "columns-state.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, 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 = {}
self._poll_id: int | None = None
# Content signatures, so a poll that found nothing new rebuilds nothing
# (see _build_panel_scaffold for why that matters). None = never built.
self._strip_sig: list | None = None
self._list_order: list | None = None
self._rows: dict = {} # window address -> row widgets, for in-place updates
# 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)
self._build_panel_scaffold()
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._toggle_panel(b.get_active()))
header.set_end_widget(self._expand_btn)
self.append(header)
# 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")
self._strip = Gtk.ScrolledWindow(
vscrollbar_policy=Gtk.PolicyType.NEVER,
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
self._strip.set_child(self._row)
self.append(self._strip)
# -- 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:
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:
layouts = json.loads(_LAYOUTS_MANIFEST.read_text())
except (FileNotFoundError, json.JSONDecodeError):
# fallback if hypr/layouts hasn't written the manifest yet
layouts = [{"name": "scrolling", "label": "Scrolling", "directional": True,
"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": []}]
return config.apply_layout_config(layouts)
# -- populate ----------------------------------------------------------
def refresh(self) -> None:
run_json(["hyprctl", "clients", "-j"], self._on_clients)
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:
if ok and isinstance(data, dict):
self._active_ws = data.get("id")
self._update_panel()
def _on_clients(self, ok: bool, data) -> None:
self._clients = data if ok and isinstance(data, list) else []
self._rebuild_strip()
self._update_panel()
# -- compact strip -----------------------------------------------------
def _rebuild_strip(self) -> None:
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)
# Same reasoning as the panel list: rebuild only when the strip's contents
# actually change, so a poll can't reset its horizontal scroll — or tear down
# a grouped app's instance popover while it is open. Keyed on addresses only:
# a retitled window doesn't change anything visible here (titles appear in the
# tooltip/popover, refreshed on the next real change).
sig = [(cls, tuple(w.get("address") for w in wins))
for cls, wins in sorted(groups.items())]
if sig == self._strip_sig:
return
self._strip_sig = sig
self._clear(self._row)
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 ---------------------
# The panel scaffolding is built ONCE, here, and every later refresh updates it in
# place. It used to be rebuilt from scratch on each refresh (_clear(self._panel) +
# fresh widgets, including a fresh ScrolledWindow for the window list) — and since
# start_polling() refreshes once a second while the menu is open, that made the
# list impossible to scroll: emptying the scrolled content collapses the scroll
# adjustment's upper bound, so its value is clamped back to 0 and the viewport
# snapped to the top roughly once a second.
def _build_panel_scaffold(self) -> None:
# 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. The tabs are the
# same for every workspace — only the *selection* is per-workspace, and that is
# what _sync_layout_controls() refreshes — so this is built once too.
self._ws_hdr = Gtk.Label(label="Workspace ? layout", xalign=0.0)
self._ws_hdr.add_css_class("section-title")
self._panel.append(self._ws_hdr)
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"])
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._panel.append(Gtk.Separator())
# per-window rows: [focus/jump] [pull here]
self._listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
self._list_scroll = Gtk.ScrolledWindow(
vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
self._list_scroll.set_child(self._listing)
self._panel.append(self._list_scroll)
def _update_panel(self) -> None:
ws = self._active_ws
self._ws_hdr.set_label(f"Workspace {ws if ws is not None else '?'} layout")
self._sync_layout_controls()
if getattr(self, "_cols_lbl", None) is not None:
self._cols_lbl.set_label(str(self._read_cols()))
self._update_window_list()
def _update_window_list(self) -> None:
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()))
order = [w.get("address") for w in wins]
if order == self._list_order:
# Same windows in the same order: refresh the row contents in place, so a
# retitled window (browser tab switch, shell cwd) costs no rebuild and
# therefore cannot move the scroll position.
for w in wins:
refs = self._rows.get(w.get("address"))
if refs is not None:
self._set_row_content(refs, w)
return
# The list really did change shape. Rebuild just the rows (not the scroller)
# and put the viewport back where the user left it, once the new rows have been
# allocated — until then the adjustment's upper bound is still stale. Both
# scrollers are saved: which one actually scrolls depends on how much height
# the mounted panel gets, and restoring an already-zero offset is a no-op.
saved = [(sw.get_vadjustment(), sw.get_vadjustment().get_value())
for sw in (self._panel_scroll, self._list_scroll)]
self._list_order = order
self._rows = {}
self._clear(self._listing)
if not wins:
self._listing.append(Gtk.Label(label="No open windows", xalign=0.0))
for w in wins:
self._listing.append(self._window_row(w))
GLib.idle_add(self._restore_offsets, saved)
@staticmethod
def _restore_offsets(saved) -> bool:
for adj, value in saved:
adj.set_value(min(value, max(0.0, adj.get_upper() - adj.get_page_size())))
return False
def _set_row_content(self, refs, w) -> None:
icon, title_lbl, ws_lbl = refs
title = w.get("title") or w.get("class") or "?"
if title_lbl.get_label() != title:
title_lbl.set_label(title)
ws_text = f"ws {w.get('workspace', {}).get('id')}"
if ws_lbl.get_label() != ws_text:
ws_lbl.set_label(ws_text)
icon_name = self._icon_for(w.get("class", ""))
if icon.get_icon_name() != icon_name:
icon.set_from_icon_name(icon_name)
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)
ws_lbl = Gtk.Label(label=f"ws {wsid}", xalign=1.0)
lbl.append(ws_lbl)
name.set_child(lbl)
# Keep the mutable bits reachable so a refresh can update this row instead of
# replacing it (the address is the row's identity and never changes).
self._rows[w.get("address")] = (icon, wl, ws_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))
# 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_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))
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
if ly["name"] == "columns":
sw.set_tooltip_text("Keep the focused window centred in its column as "
"it scrolls, instead of the minimal-movement default")
else:
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
"columns stay on-screen and tappable")
sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name))
row.append(sw)
page.append(row)
self._fit_sws[ly["name"]] = sw
if ly.get("stepper"): # Columns: (-)[N](+)
page.append(self._cols_stepper())
if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"):
page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
css_classes=["net-ip"]))
return page
# -- columns count stepper --------------------------------------------
def _cols_stepper(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True))
minus = Gtk.Button(label="")
minus.add_css_class("quad-action")
self._cols_lbl = Gtk.Label(label=str(self._read_cols()))
self._cols_lbl.add_css_class("stepper-value")
plus = Gtk.Button(label="+")
plus.add_css_class("quad-action")
minus.connect("clicked", lambda *_a: self._step_cols(-1))
plus.connect("clicked", lambda *_a: self._step_cols(1))
row.append(minus)
row.append(self._cols_lbl)
row.append(plus)
return row
def _read_cols(self) -> int:
try:
data = json.loads(_COLUMNS_STATE.read_text())
return int(data.get(str(self._active_ws), {}).get("cols", 2))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return 2
def _step_cols(self, delta: int) -> None:
try:
cur = int(self._cols_lbl.get_label())
except ValueError:
cur = self._read_cols()
new = max(1, cur + delta)
self._cols_lbl.set_label(str(new))
_eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))')
def _read_center(self) -> bool:
# columns.lua's own "center-focused" switch — global, published in the same
# cache file as the columns stepper's count (see columns.lua's publish()).
try:
data = json.loads(_COLUMNS_STATE.read_text())
return bool(data.get("_center", False))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return False
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None:
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
#
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
# order, and refresh() can trigger _update_panel() (hence this) twice in a row
# (once from the clients query, once from the activeworkspace one), so several
# syncs can be in flight at once. Two failure modes existed:
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
# independently (True at start, False at end). Whichever resolved *first*
# dropped the guard while the others — in particular the tab-selection one,
# which programmatically flips the visible stack child — were still in
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
# had clicked and re-apply stale data via `layouts.set(...)`.
# 2. A second `_sync_layout_controls()` call (from the redundant refresh) could
# land while the first's callbacks were still pending, so a stale callback
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
# (possibly wrong-workspace) data into sync #2's widgets.
# A generation token invalidates any sync superseded by a newer one outright —
# if the user switches workspaces (or a redundant refresh fires) mid-flight, only
# the latest call's callbacks are allowed to touch anything, and self._syncing
# only lifts once *that* generation's 3 calls have all resolved.
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
gen = self._sync_generation
ws = self._active_ws
self._syncing = True
pending = [3]
def _done() -> None:
if gen != self._sync_generation:
return # superseded — a newer sync owns self._syncing now
pending[0] -= 1
if pending[0] <= 0:
self._syncing = False
def _tab_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_tab_sel(ws, ok, data)
_done()
def _dir_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_dir_sel(ok, data)
_done()
def _fit_cb(ok, data) -> None:
if gen == self._sync_generation:
self._apply_fit_sel(ok, data)
_done()
run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb)
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb)
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb)
# columns' own center-focused state lives in columns-state.json, not a hyprctl
# option — read it synchronously rather than round-tripping a subprocess.
columns_sw = self._fit_sws.get("columns")
if columns_sw is not None:
columns_sw.set_active(self._read_center())
def _apply_tab_sel(self, ws, 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(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)
def _apply_dir_sel(self, ok, data) -> None:
# 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
for dd, values in self._dir_dds.values():
if cur in values:
dd.set_selected(values.index(cur))
def _apply_fit_sel(self, ok, data) -> None:
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
# columns' switch is synced separately from columns-state.json (see
# _sync_layout_controls) since it isn't backed by a hyprctl option at all.
val = data.get("int") if ok and isinstance(data, dict) else 0
sw = self._fit_sws.get("scrolling")
if sw is not None:
sw.set_active(val == 1)
def _cur_dir(self) -> str:
entry = self._dir_dds.get(self._layout_stack.get_visible_child_name())
if not entry:
return ""
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:
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, name) -> bool:
if not getattr(self, "_syncing", False):
if name == "columns":
_eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))')
else:
_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()