diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 5e8862c..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Nix -result diff --git a/README.md b/README.md index 4b168d9..f0ca08c 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,7 @@ 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 - -# 1.4.0 +# Changelog - Add [expose](https://github.com/hyprland-community/pyprland/wiki/Plugins#expose) addon - scratchpad: add [lazy](https://github.com/hyprland-community/pyprland/wiki/Plugins#lazy-optional) option diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 6067f6e..0000000 --- a/flake.lock +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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 deleted file mode 100644 index b60c2b8..0000000 --- a/flake.nix +++ /dev/null @@ -1,27 +0,0 @@ -{ - 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 deleted file mode 100644 index 022bbde..0000000 --- a/poetry.lock +++ /dev/null @@ -1,7 +0,0 @@ -# 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 17ffd7c..9ca0320 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -1,16 +1,16 @@ #!/bin/env python -""" Pyprland - an Hyprland companion app (cli client & daemon) """ +" Pyprland - an Hyprland companion app " import asyncio +from typing import cast +import json +import sys +import os import importlib import itertools -import json -import os -import sys -from typing import cast -from .common import PyprError, get_logger, init_logger -from .ipc import get_event_stream -from .ipc import init as ipc_init + +from .ipc import get_event_stream, init as ipc_init +from .common import init_logger, get_logger, PyprError from .plugins.interface import Plugin CONTROL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.pyprland.sock' @@ -73,11 +73,6 @@ 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 @@ -87,15 +82,11 @@ class Pyprland: async def read_events_loop(self): "Consumes the event loop and calls corresponding handlers" while not self.stopped: - try: - data = (await self.event_reader.readline()).decode() - except UnicodeDecodeError: - self.log.error("Invalid unicode while reading events") - continue + data = (await self.event_reader.readline()).decode() if not data: self.log.critical("Reader starved") return - cmd, params = data.split(">>", 1) + cmd, params = data.split(">>") full_name = f"event_{cmd}" await self._callHandler(full_name, params) diff --git a/pyprland/common.py b/pyprland/common.py index 8c5947f..8cd2ed3 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -1,6 +1,6 @@ """ Shared utilities: logging """ -import logging import os +import logging __all__ = ["DEBUG", "get_logger", "init_logger"] diff --git a/pyprland/ipc.py b/pyprland/ipc.py index ea269dd..e2e3208 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -1,12 +1,12 @@ #!/bin/env python """ Interact with hyprland using sockets """ import asyncio -import json -import os from logging import Logger from typing import Any +import json +import os -from .common import PyprError, get_logger +from .common import get_logger, PyprError log: Logger | None = None diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index ed01925..32213a5 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -2,10 +2,10 @@ toggle_minimized allows having an "expose" like selection of minimized windows """ from typing import Any, cast - -from ..ipc import hyprctl, hyprctlJSON from .interface import Plugin +from ..ipc import hyprctlJSON, hyprctl + class Extension(Plugin): # pylint: disable=missing-class-docstring exposed: list[dict] = [] @@ -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/interface.py b/pyprland/plugins/interface.py index 4613695..b48dc19 100644 --- a/pyprland/plugins/interface.py +++ b/pyprland/plugins/interface.py @@ -1,6 +1,5 @@ " Common plugin interface " from typing import Any - from ..common import get_logger diff --git a/pyprland/plugins/ironbar.py b/pyprland/plugins/ironbar.py deleted file mode 100644 index 219d0e8..0000000 --- a/pyprland/plugins/ironbar.py +++ /dev/null @@ -1,27 +0,0 @@ -" Ironbar Plugin " -import os -import json -import asyncio - -from .interface import Plugin - -SOCKET = f"/run/user/{os.getuid()}/ironbar-ipc.sock" - - -async def ipcCall(**params): - ctl_reader, ctl_writer = await asyncio.open_unix_connection(SOCKET) - ctl_writer.write(json.dumps(params).encode("utf-8")) - await ctl_writer.drain() - ret = await ctl_reader.read() - ctl_writer.close() - await ctl_writer.wait_closed() - return json.loads(ret) - - -class Extension(Plugin): - "Toggles ironbar on/off" - is_visible = True - - async def run_toggle_ironbar(self, bar_name: str): - self.is_visible = not self.is_visible - await ipcCall(type="set_visible", visible=self.is_visible, bar_name=bar_name) diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index 4556dfa..3b7c133 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -1,9 +1,9 @@ " Moves unreachable client windows to the currently focused workspace" from typing import Any, cast - -from ..ipc import hyprctl, hyprctlJSON from .interface import Plugin +from ..ipc import hyprctlJSON, hyprctl + def contains(monitor, window): "Tell if a window is visible in a monitor" diff --git a/pyprland/plugins/magnify.py b/pyprland/plugins/magnify.py index fe3a296..b3d9cc2 100644 --- a/pyprland/plugins/magnify.py +++ b/pyprland/plugins/magnify.py @@ -1,7 +1,8 @@ " Toggles workspace zooming " -from ..ipc import hyprctl from .interface import Plugin +from ..ipc import hyprctl + class Extension(Plugin): # pylint: disable=missing-class-docstring zoomed = False diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index 18556ed..da4821d 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -1,9 +1,9 @@ " The monitors plugin " import subprocess from typing import Any, cast +from .interface import Plugin from ..ipc import hyprctlJSON -from .interface import Plugin def configure_monitors(monitors, screenid: str, pos_x: int, pos_y: int) -> None: @@ -40,9 +40,6 @@ 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( @@ -85,10 +82,7 @@ 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(): - try: - ref = mon_by_name[other_mon_description] - except KeyError: - continue + ref = mon_by_name[other_mon_description] if ref: place = placement.lower() x: int = 0 diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index df1d466..e4da67f 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -1,12 +1,15 @@ " Scratchpads addon " -import asyncio import os -from itertools import count +import asyncio import subprocess from typing import Any, cast -import logging -from ..ipc import get_focused_monitor_props, hyprctl, hyprctlJSON +from ..ipc import ( + hyprctl, + hyprctlJSON, + get_focused_monitor_props, +) + from .interface import Plugin DEFAULT_MARGIN = 60 @@ -14,7 +17,6 @@ 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: @@ -85,7 +87,6 @@ class Animations: class Scratch: "A scratchpad state including configuration & client state" - log = logging.getLogger("scratch") def __init__(self, uid, opts): self.uid = uid @@ -103,7 +104,7 @@ class Scratch: for line in f.readlines(): if line.startswith("State"): state = line.split()[1] - return state not in "ZX" # not "Z (zombie)"or "X (dead)" + return state in "RSDTt" # not "Z (zombie)"or "X (dead)" return False def reset(self, pid: int) -> None: @@ -122,14 +123,7 @@ class Scratch: "update the internal client info property, if not provided, refresh based on the current address" if client_info is None: client_info = await get_client_props_by_address("0x" + self.address) - try: - assert isinstance(client_info, dict) - except AssertionError as e: - self.log.error( - f"client_info of {self.address} must be a dict: {client_info}" - ) - raise AssertionError(e) from e - + assert isinstance(client_info, dict) self.client_info.update(client_info) def __str__(self): @@ -200,8 +194,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring self.procs[name] = proc pid = proc.pid self.scratches[name].reset(pid) - self.scratches_by_pid[pid] = scratch - self.log.info(f"scratch {scratch.uid} has pid {pid}") + self.scratches_by_pid[proc.pid] = scratch if old_pid and old_pid in self.scratches_by_pid: del self.scratches_by_pid[old_pid] @@ -218,7 +211,6 @@ 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 @@ -238,7 +230,6 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring if not class_lookup_hack: return False self.log.debug("Lookup hack triggered") - # hack to update the client info from the provided class for client in await hyprctlJSON("clients"): assert isinstance(client, dict) for pending_scratch in class_lookup_hack: @@ -251,7 +242,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 self._respawned_scratches: + if wrkspc.startswith("special"): 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) @@ -301,21 +292,26 @@ 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, orig_scratch: Scratch | None = None) -> None: + async def updateScratchInfo(self, scratch: Scratch | None = None) -> None: """Update every scratchpads information if no `scratch` given, else update a specific scratchpad info""" - 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 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 if scratch: - self.scratches_by_address[client["address"][2:]] = scratch - if scratch: - await scratch.updateClientInfo(client) + 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 async def run_hide(self, uid: str, force=False, autohide=False) -> None: """ hides scratchpad "name" """ @@ -327,11 +323,8 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring if not scratch.visible and not force: self.log.warning("%s is already hidden", uid) return - scratch.visible = False - if not scratch.isAlive(): - await self.run_show(uid, force=True) - return self.log.info("Hiding %s", uid) + scratch.visible = False addr = "address:0x" + scratch.address animation_type: str = scratch.conf.get("animation", "").lower() if animation_type: @@ -349,27 +342,6 @@ 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() @@ -388,7 +360,18 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring return self.log.info("Showing %s", uid) - await self.ensure_alive(uid, item) + + 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) item.visible = True monitor = await get_focused_monitor_props() @@ -396,8 +379,6 @@ 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() @@ -413,47 +394,5 @@ 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/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index 2e6d91c..29990da 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -1,9 +1,9 @@ " shift workspaces across monitors " from typing import cast - -from ..ipc import hyprctl, hyprctlJSON from .interface import Plugin +from ..ipc import hyprctlJSON, hyprctl + class Extension(Plugin): # pylint: disable=missing-class-docstring monitors: list[str] = [] diff --git a/pyprland/plugins/toggle_dpms.py b/pyprland/plugins/toggle_dpms.py index a395519..86c6074 100644 --- a/pyprland/plugins/toggle_dpms.py +++ b/pyprland/plugins/toggle_dpms.py @@ -1,9 +1,9 @@ " Toggle monitors on or off " from typing import Any, cast - -from ..ipc import hyprctl, hyprctlJSON from .interface import Plugin +from ..ipc import hyprctlJSON, hyprctl + class Extension(Plugin): # pylint: disable=missing-class-docstring async def run_toggle_dpms(self): diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 24efe8b..29e2872 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -1,9 +1,9 @@ """ Force workspaces to follow the focus / mouse """ from typing import cast - -from ..ipc import hyprctl, hyprctlJSON from .interface import Plugin +from ..ipc import hyprctlJSON, hyprctl + class Extension(Plugin): # pylint: disable=missing-class-docstring workspace_list: list[int] = [] diff --git a/pyproject.toml b/pyproject.toml index a6adc29..e1ddb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.4.1" +version = "1.3.1" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT"