diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e8862c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Nix +result diff --git a/README.md b/README.md index 6bfc4d4..4b168d9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Host process for multiple Hyprland plugins. Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more information. +# 1.4.2 (WIP) + +- [two new options](https://github.com/hyprland-community/pyprland/wiki/Plugins#size-optional) for scratchpads: `position` and `size` - from @iliayar +- bugfixes + # 1.4.1 - minor bugfixes diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6067f6e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1689068808, + "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1695416179, + "narHash": "sha256-610o1+pwbSu+QuF3GE0NU5xQdTHM3t9wyYhB9l94Cd8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "715d72e967ec1dd5ecc71290ee072bcaf5181ed6", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b60c2b8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,27 @@ +{ + description = "pyprland"; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-23.05"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + packages = rec { + pyprland = pkgs.poetry2nix.mkPoetryApplication { + projectDir = ./.; + python = pkgs.python310; + }; + default = pyprland; + }; + } + ); +} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..022bbde --- /dev/null +++ b/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" diff --git a/pyprland/command.py b/pyprland/command.py index 9196718..17ffd7c 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -73,6 +73,11 @@ class Pyprland: self.log.debug("%s.%s%s", plugin.name, full_name, params) try: await getattr(plugin, full_name)(*params) + except AssertionError as e: + self.log.error( + "Bug detected, please report on https://github.com/fdev31/pyprland/issues" + ) + self.log.exception(e) except Exception as e: # pylint: disable=W0718 self.log.warning( "%s::%s(%s) failed:", plugin.name, full_name, params diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index 78f8e81..ed01925 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -42,7 +42,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await hyprctl( f"movetoworkspacesilent {client['workspace']['id']},address:{client['address']}" ) - await hyprctl("togglespecialworkspace exposed") + # await hyprctl("togglespecialworkspace exposed") await hyprctl(f"focuswindow address:{focused_addr}") self.exposed = [] else: diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index a047132..18556ed 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -40,6 +40,9 @@ def configure_monitors(monitors, screenid: str, pos_x: int, pos_y: int) -> None: class Extension(Plugin): # pylint: disable=missing-class-docstring async def load_config(self, config) -> None: await super().load_config(config) + await self.run_relayout() + + async def run_relayout(self): monitors = cast(list[dict], await hyprctlJSON("monitors")) for monitor in monitors: await self.event_monitoradded( @@ -82,7 +85,10 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring for mon_pattern, conf in self.config["placement"].items(): if mon_pattern in mon_description: for placement, other_mon_description in conf.items(): - ref = mon_by_name[other_mon_description] + try: + ref = mon_by_name[other_mon_description] + except KeyError: + continue if ref: place = placement.lower() x: int = 0 diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 43ff936..df1d466 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -1,6 +1,7 @@ " Scratchpads addon " import asyncio import os +from itertools import count import subprocess from typing import Any, cast import logging @@ -13,6 +14,7 @@ DEFAULT_MARGIN = 60 async def get_client_props_by_address(addr: str): "Returns client properties given its address" + assert len(addr) > 2, "Client address is invalid" for client in await hyprctlJSON("clients"): assert isinstance(client, dict) if client.get("address") == addr: @@ -101,7 +103,7 @@ class Scratch: for line in f.readlines(): if line.startswith("State"): state = line.split()[1] - return state in "RSDTt" # not "Z (zombie)"or "X (dead)" + return state not in "ZX" # not "Z (zombie)"or "X (dead)" return False def reset(self, pid: int) -> None: @@ -198,7 +200,8 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring self.procs[name] = proc pid = proc.pid self.scratches[name].reset(pid) - self.scratches_by_pid[proc.pid] = scratch + self.scratches_by_pid[pid] = scratch + self.log.info(f"scratch {scratch.uid} has pid {pid}") if old_pid and old_pid in self.scratches_by_pid: del self.scratches_by_pid[old_pid] @@ -215,6 +218,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring scratch.just_created = False else: for uid, scratch in self.scratches.items(): + self.log.info((scratch.address, addr)) if scratch.client_info and scratch.address != addr: if ( scratch.visible @@ -247,7 +251,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring async def event_openwindow(self, params) -> None: "open windows hook" addr, wrkspc, _kls, _title = params.split(",", 3) - if wrkspc.startswith("special"): + if self._respawned_scratches: item = self.scratches_by_address.get(addr) if not item and self._respawned_scratches: # hack for windows which aren't related to the process (see #8) @@ -297,26 +301,21 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring return # abort sequence await asyncio.sleep(0.2) # await for animation to finish - async def updateScratchInfo(self, scratch: Scratch | None = None) -> None: + async def updateScratchInfo(self, orig_scratch: Scratch | None = None) -> None: """Update every scratchpads information if no `scratch` given, else update a specific scratchpad info""" - if scratch is None: - for client in await hyprctlJSON("clients"): - assert isinstance(client, dict) - scratch = self.scratches_by_address.get(client["address"][2:]) - if not scratch: - scratch = self.scratches_by_pid.get(client["pid"]) - if scratch: - self.scratches_by_address[client["address"][2:]] = scratch + pid = orig_scratch.pid if orig_scratch else None + for client in await hyprctlJSON("clients"): + assert isinstance(client, dict) + if pid and pid != client["pid"]: + continue + scratch = self.scratches_by_address.get(client["address"][2:]) + if not scratch: + scratch = self.scratches_by_pid.get(client["pid"]) if scratch: - await scratch.updateClientInfo(client) - else: - add_to_address_book = ("address" not in scratch.client_info) or ( - scratch.address not in self.scratches_by_address - ) - await scratch.updateClientInfo() - if add_to_address_book: - self.scratches_by_address[scratch.client_info["address"][2:]] = scratch + self.scratches_by_address[client["address"][2:]] = scratch + if scratch: + await scratch.updateClientInfo(client) async def run_hide(self, uid: str, force=False, autohide=False) -> None: """ hides scratchpad "name" """ @@ -328,8 +327,11 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring if not scratch.visible and not force: self.log.warning("%s is already hidden", uid) return - self.log.info("Hiding %s", uid) scratch.visible = False + if not scratch.isAlive(): + await self.run_show(uid, force=True) + return + self.log.info("Hiding %s", uid) addr = "address:0x" + scratch.address animation_type: str = scratch.conf.get("animation", "").lower() if animation_type: @@ -347,6 +349,27 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring ) del self.focused_window_tracking[uid] + async def ensure_alive(self, uid, item=None): + if item is None: + item = self.scratches.get(uid) + + if not item.isAlive(): + self.log.info("%s is not running, restarting...", uid) + if uid in self.procs: + self.procs[uid].kill() + if item.pid in self.scratches_by_pid: + del self.scratches_by_pid[item.pid] + if item.address in self.scratches_by_address: + del self.scratches_by_address[item.address] + self.log.info(f"starting {uid}") + await self.start_scratch_command(uid) + self.log.info(f"{uid} started") + self.log.info("==> Wait for spawning") + loop_count = count() + while uid in self._respawned_scratches and next(loop_count) < 10: + await asyncio.sleep(0.05) + self.log.info(f"=> spawned {uid} as proc {item.pid}") + async def run_show(self, uid, force=False) -> None: """ shows scratchpad "name" """ uid = uid.strip() @@ -365,18 +388,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring return self.log.info("Showing %s", uid) - - if not item.isAlive(): - self.log.info("%s is not running, restarting...", uid) - if uid in self.procs: - self.procs[uid].kill() - if item.pid in self.scratches_by_pid: - del self.scratches_by_pid[item.pid] - if item.address in self.scratches_by_address: - del self.scratches_by_address[item.address] - await self.start_scratch_command(uid) - while uid in self._respawned_scratches: - await asyncio.sleep(0.05) + await self.ensure_alive(uid, item) item.visible = True monitor = await get_focused_monitor_props() @@ -384,6 +396,8 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await self.updateScratchInfo(item) + assert item.address, "No address !" + addr = "address:0x" + item.address animation_type = item.conf.get("animation", "").lower() @@ -399,5 +413,47 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await fn(monitor, item.client_info, addr, margin) await hyprctl(f"focuswindow {addr}") + + size = item.conf.get("size") + if size: + x_size, y_size = self._convert_coords(size, monitor) + await hyprctl(f"resizewindowpixel exact {x_size} {y_size},{addr}") + + position = item.conf.get("position") + if position: + x_pos, y_pos = self._convert_coords(position, monitor) + x_pos_abs, y_pos_abs = x_pos + monitor["x"], y_pos + monitor["y"] + await hyprctl(f"movewindowpixel exact {x_pos_abs} {y_pos_abs},{addr}") + await asyncio.sleep(0.2) # ensure some time for events to propagate self.transitioning_scratches.discard(uid) + + def _convert_coords(self, coords, monitor): + """ + Converts a string like "X Y" to coordinates relative to monitor + Supported formats for X, Y: + - Percentage: "V%". V in [0; 100] + + Example: + "10% 20%", monitor 800x600 => 80, 120 + """ + + assert coords, "coords must be non null" + + def convert(s, dim): + if s[-1] == "%": + p = int(s[:-1]) + if p < 0 or p > 100: + raise Exception(f"Percentage must be in range [0; 100], got {p}") + scale = float(monitor["scale"]) + return int(monitor[dim] / scale * p / 100) + else: + raise Exception(f"Unsupported format for dimension {dim} size, got {s}") + + try: + x_str, y_str = coords.split() + + return convert(x_str, "width"), convert(y_str, "height") + except Exception as e: + self.log.error(f"Failed to read coordinates: {e}") + raise e diff --git a/pyproject.toml b/pyproject.toml index 29eb7fe..a6adc29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.4.0" +version = "1.4.1" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT"