# 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-declared commands.""" 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}") DESKTOP_BASES = (Path("/usr/share/applications"), Path.home() / ".local/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_icons(bases=DESKTOP_BASES) -> dict: """Window class -> candidate icon names, from the installed .desktop files.""" mapping = {} for base in bases: if not base.is_dir(): continue for entry in base.glob("*.desktop"): try: text = entry.read_text(errors="replace") except OSError: continue section = None icon = wmclass = None for line in text.splitlines(): line = line.strip() if line.startswith("[") and line.endswith("]"): section = line if section != "[Desktop Entry]": break continue if section != "[Desktop Entry]": continue if line.startswith("Icon="): icon = line.split("=", 1)[1].strip() elif line.startswith("StartupWMClass="): wmclass = line.split("=", 1)[1].strip() if not icon: continue candidates = [icon] if wmclass and wmclass != icon: candidates.append(wmclass) for key in (entry.stem, 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) launch = cfg.get("launch", {}) workspaces = cfg.get("windows", {}).get("workspaces", list(range(1, 11))) icons = _desktop_icons() def launch_spec(spec): if isinstance(spec, dict): return spec.get("command"), spec.get("icon"), spec.get("label") return spec, None, None 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): key = str(body.get("key", "")) command, _, _ = launch_spec(launch.get(key)) if command is None: raise ModuleError("unknown launch key") _popen(["sh", "-c", command] if isinstance(command, str) else command) 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 = [] for key, spec in launch.items(): _, icon, label = launch_spec(spec) launches.append({"key": key, "icon": icon, "label": label or key}) 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})