"""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()