# deck-it - the virtual monitor setup # 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. """The virtual monitor half: a headless Hyprland output sized to the tablet.""" import json import os import re import shutil import signal import subprocess import sys import time from pathlib import Path 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 tablet_resolution(cfg: dict, adb=None) -> str: res = cfg.get("display", {}).get("resolution", "auto") if res != "auto": return res read = adb or (lambda: run(["adb", "shell", "wm", "size"])) m = re.search(r"(\d+)x(\d+)", read()) if not m: sys.exit("Could not read the tablet resolution; set display.resolution in the config") w, h = sorted(map(int, m.groups()), reverse=True) return f"{w}x{h}" def below_position(ref: dict, resolution: str, scale=1) -> str: """Center a monitor below `ref` (a hyprctl monitor dict).""" width = round(int(re.match(r"(\d+)x", resolution).group(1)) / scale) return f"{ref['x'] + (ref['width'] - width) // 2}x{ref['y'] + ref['height']}" 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 _swaybg_for(output: str, proc: Path = Path("/proc")): """(pid, image, mode) of the swaybg process painting `output`, from /proc.""" for cmdline in proc.glob("[0-9]*/cmdline"): try: argv = cmdline.read_bytes().decode(errors="replace").split("\0") except OSError: continue if not argv or not argv[0].endswith("swaybg"): continue out = image = None mode = "fill" for i, arg in enumerate(argv): if i + 1 >= len(argv): break if arg == "-o": out = argv[i + 1] elif arg == "-i": image = argv[i + 1] elif arg == "-m": mode = argv[i + 1] if out == output and image: return int(cmdline.parent.name), image, mode return None def setup_display(cfg: dict, run_cmd=None, popen=None) -> None: run_cmd = run_cmd or run popen = popen or (lambda cmd: subprocess.Popen( cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) d = cfg.get("display", {}) mon = d.get("name", "TAB") monitors = json.loads(run_cmd(["hyprctl", "monitors", "-j"]) or "[]") if not any(m["name"] == mon for m in monitors): run_cmd(["hyprctl", "output", "create", "headless", mon]) time.sleep(1) # Creating the output can re-flow the layout, so read it again. monitors = json.loads(run_cmd(["hyprctl", "monitors", "-j"]) or "[]") mode = f"{tablet_resolution(cfg)}@{d.get('refresh', 60)}" pos = d.get("position", "auto-right") scale = d.get("scale", 1) if pos in ("auto-below", "below"): ref = next((m for m in monitors if m["name"] != mon), monitors[0]) pos = below_position(ref, mode, scale) if hypr_lua(cfg): run_cmd(["hyprctl", "eval", f'hl.monitor({{output="{mon}", mode="{mode}", position="{pos}", scale={scale}}})']) else: run_cmd(["hyprctl", "keyword", "monitor", f"{mon},{mode},{pos},{scale}"]) if d.get("wallpaper", True): others = [m["name"] for m in monitors if m["name"] != mon] tool = shutil.which("awww") or shutil.which("swww") if tool: for line in run_cmd([tool, "query"]).splitlines(): if others and others[0] in line and "image: " in line: run_cmd([tool, "img", "-o", mon, line.split("image: ", 1)[1]], timeout=10) break elif shutil.which("swaybg"): # swaybg runs one process per output; copy the first monitor's image. source = next((found for name in others if (found := _swaybg_for(name))), None) if source: existing = _swaybg_for(mon) if existing: try: os.kill(existing[0], signal.SIGTERM) except OSError: pass popen(["swaybg", "-o", mon, "-i", source[1], "-m", source[2]]) print(f"{mon}: {mode} at {pos}")