# deck-it - windows module # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Hyprland windows, workspaces, and launching config-listed apps.""" import json import re import subprocess from pathlib import Path from ..module import Module, ModuleError ADDR = re.compile(r"0x[0-9a-f]{1,16}") # The user's own entries first, so they win over the system's, as in a launcher. DESKTOP_BASES = (Path.home() / ".local/share/applications", Path("/usr/share/applications")) def _run(cmd, timeout=2): try: return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout.strip() except (OSError, subprocess.TimeoutExpired): return "" def _popen(cmd): subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def _dispatch(cmd): """Run a hyprctl dispatch and wait: the two-step move depends on the order.""" try: subprocess.run(cmd, capture_output=True, timeout=2) except (OSError, subprocess.TimeoutExpired): pass def _desktop_entries(bases=DESKTOP_BASES) -> dict: """Desktop id -> {name, icon, wmclass}, from each [Desktop Entry] section.""" entries = {} for base in bases: if not base.is_dir(): continue for entry in base.glob("*.desktop"): if entry.stem in entries: continue try: text = entry.read_text(errors="replace") except OSError: continue found, inside = {}, False for line in text.splitlines(): line = line.strip() if line.startswith("["): if inside: break inside = line == "[Desktop Entry]" elif inside and "=" in line: key, value = line.split("=", 1) if key in ("Name", "Icon", "StartupWMClass"): found.setdefault(key, value.strip()) entries[entry.stem] = {"name": found.get("Name"), "icon": found.get("Icon"), "wmclass": found.get("StartupWMClass")} return entries def _desktop_icons(entries: dict) -> dict: """Window class -> candidate icon names, by desktop id and StartupWMClass.""" mapping = {} for stem, e in entries.items(): if not e["icon"]: continue candidates = [e["icon"]] if e["wmclass"] and e["wmclass"] != e["icon"]: candidates.append(e["wmclass"]) for key in (stem, e["wmclass"]): if key: mapping.setdefault(key.lower(), candidates) return mapping def hypr_lua(cfg: dict) -> bool: mode = cfg.get("hyprland", "auto") if mode == "auto": return (Path.home() / ".config/hypr/hyprland.lua").is_file() return mode == "lua" def workspace_cmd(n: int, lua: bool): if lua: return ["hyprctl", "eval", f'hl.dispatch(hl.dsp.focus({{workspace = "{n}"}}))'] return ["hyprctl", "dispatch", "workspace", str(n)] def window_cmd(verb: str, addr: str, lua: bool): if lua: dsp = "hl.dsp.focus" if verb == "focus" else "hl.dsp.window.close" return ["hyprctl", "eval", f'hl.dispatch({dsp}({{window = "address:{addr}"}}))'] return ["hyprctl", "dispatch", "focuswindow" if verb == "focus" else "closewindow", f"address:{addr}"] def _state(run): try: clients = json.loads(run(["hyprctl", "clients", "-j"]) or "[]") active = json.loads(run(["hyprctl", "activewindow", "-j"]) or "{}").get("address") workspaces = json.loads(run(["hyprctl", "workspaces", "-j"]) or "[]") except json.JSONDecodeError: return {"windows": [], "workspaces": [], "activeClass": None} wins = sorted( ({"address": c["address"], "class": c["class"], "title": c["title"], "workspace": c["workspace"]["id"], "active": c["address"] == active} for c in clients if c.get("mapped", True) and c["workspace"]["id"] > 0), key=lambda w: (w["workspace"], w["class"])) active_class = next((w["class"] for w in wins if w["active"]), None) return {"windows": wins, "workspaces": sorted(w["id"] for w in workspaces if w.get("id", 0) > 0), "activeClass": active_class} def make(cfg: dict) -> Module: run = cfg.get("run", _run) lua = hypr_lua(cfg) apps = [str(a) for a in cfg.get("launch", {}).get("apps", [])] workspaces = cfg.get("windows", {}).get("workspaces", list(range(1, 11))) entries = _desktop_entries() icons = _desktop_icons(entries) def workspace(body): n = body.get("n") if not isinstance(n, int) or not 1 <= n <= 99: raise ModuleError("workspace must be a number from 1 to 99") _popen(workspace_cmd(n, lua)) def focus(body): addr = str(body.get("address", "")) if not ADDR.fullmatch(addr): raise ModuleError("bad window address") _popen(window_cmd("focus", addr, lua)) def close(body): addr = str(body.get("address", "")) if not ADDR.fullmatch(addr): raise ModuleError("bad window address") _popen(window_cmd("close", addr, lua)) def move(body): addr = str(body.get("address", "")) n = body.get("n") if not ADDR.fullmatch(addr) or not isinstance(n, int) or not 1 <= n <= 99: raise ModuleError("bad window or workspace") if lua: # window.move acts on the focused window, so focus it first. _dispatch(["hyprctl", "eval", f'hl.dispatch(hl.dsp.focus({{window = "address:{addr}"}}))']) _dispatch(["hyprctl", "eval", f'hl.dispatch(hl.dsp.window.move({{workspace = "{n}"}}))']) else: _dispatch(["hyprctl", "dispatch", "movetoworkspacesilent", f"{n},address:{addr}"]) def launch_key(body): # Only a configured, installed desktop id; gtk-launch runs its Exec. key = str(body.get("key", "")) if key not in apps or key not in entries: raise ModuleError("unknown launch key") _popen(["gtk-launch", key]) def state(): st = _state(run) for win in st["windows"]: win["icons"] = icons.get(win["class"].lower(), [win["class"].lower()]) known = sorted(set(int(n) for n in workspaces) | set(st["workspaces"])) launches = [{"key": a, "icon": entries[a]["icon"], "label": entries[a]["name"] or a} for a in apps if a in entries] return {**st, "workspaces": known, "launches": launches} return Module(name="windows", title="Windows", state=state, actions={"workspace": workspace, "focus": focus, "close": close, "move": move, "launch": launch_key})