Compare commits

...

28 commits
1.4.0 ... main

Author SHA1 Message Date
Hydroxycarbamide
fff23c250e expose: disable togglespecialworkspace when hiding the workspace because movetoworkspacesilent is already doing it 2023-10-29 20:23:10 +01:00
fdev31
1653d383d3 Experimental logic chnge 2023-10-25 20:16:55 +02:00
Fabien Devaux
02de5fbc76
Update README.md 2023-10-23 20:45:33 +02:00
fdev31
efebc234b5 add a couple of logs 2023-10-23 20:40:34 +02:00
fdev31
8d443a541f README: add WIP items 2023-10-23 20:40:34 +02:00
fdev31
2a09103f10 more informative log 2023-10-23 20:40:34 +02:00
Fabien Devaux
cfe4995e32
Merge pull request #15 from iliayar/main
Scratchpads: Dynamic position and size, depending on monitor
2023-10-23 19:26:00 +02:00
fdev31
8993a62149 monitors: add relayout command 2023-10-23 15:24:43 +02:00
iliayar
2074554268
fix: remove poetry.lock 2023-10-17 03:53:46 +03:00
iliayar
8c7ad933ab
fix scratchapds: rework _convert_coords 2023-10-17 03:50:03 +03:00
fdev31
f4597f4fd4 add couple of logs & asserts
Also invert the zombie process logic
2023-10-16 00:19:25 +02:00
fdev31
4c8c570c7b Don't assume clients will be moved as expected, closes #17 2023-10-16 00:15:10 +02:00
fdev31
07e27e225d Add a nicer assertion for addresses 2023-10-15 23:16:40 +02:00
fdev31
9b8ba82d54 monitors plugin: don't fail if monitor is unknown 2023-10-15 22:50:54 +02:00
fdev31
e7acf8f538 Add poetry.lock, closes #16 2023-10-15 22:49:29 +02:00
iliayar
2b722a39b0
feat nix: Update nixpkgs to 23.05 2023-09-24 14:48:06 +03:00
iliayar
121c11c26b
feat scratchpads: Support monitor scale 2023-09-24 14:44:10 +03:00
fdev31
ea39db64db Version 1.4.1 2023-09-19 18:26:24 +02:00
fdev31
968c2241a0 1.4.1 2023-09-19 18:26:15 +02:00
fdev31
19b9741ec3 add some log in case of unexpected error 2023-09-14 18:42:17 +02:00
fdev31
43619bc1ca make some operations more robust 2023-09-14 18:39:21 +02:00
iliayar
d0fcbf123f
refactor scratchpads: size, position percentage 2023-08-06 17:09:50 +03:00
iliayar
ecfade18ab
feat scratchpads: adjust size, position for monitor 2023-08-04 20:17:47 +03:00
iliayar
5197977a26
feat nix: Add nix flake 2023-08-04 02:32:14 +03:00
fdev31
c710e76912 restore the module doc 2023-08-01 23:29:00 +02:00
fdev31
2eafea9862 sorted imports 2023-08-01 22:34:46 +02:00
fdev31
895e24b368 Mark release in Changelog 2023-08-01 18:26:24 +02:00
fdev31
beb607186d Version 1.4.0 2023-08-01 18:24:59 +02:00
19 changed files with 281 additions and 72 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Nix
result

View file

@ -6,7 +6,16 @@ Host process for multiple Hyprland plugins.
Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more information.
# Changelog
# 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
- 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

61
flake.lock generated Normal file
View file

@ -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
}

27
flake.nix Normal file
View file

@ -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;
};
}
);
}

7
poetry.lock generated Normal file
View file

@ -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"

View file

@ -1,16 +1,16 @@
#!/bin/env python
" Pyprland - an Hyprland companion app "
""" Pyprland - an Hyprland companion app (cli client & daemon) """
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 .ipc import get_event_stream, init as ipc_init
from .common import init_logger, get_logger, PyprError
from .common import PyprError, get_logger, init_logger
from .ipc import get_event_stream
from .ipc import init as ipc_init
from .plugins.interface import Plugin
CONTROL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.pyprland.sock'
@ -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
@ -82,11 +87,15 @@ class Pyprland:
async def read_events_loop(self):
"Consumes the event loop and calls corresponding handlers"
while not self.stopped:
data = (await self.event_reader.readline()).decode()
try:
data = (await self.event_reader.readline()).decode()
except UnicodeDecodeError:
self.log.error("Invalid unicode while reading events")
continue
if not data:
self.log.critical("Reader starved")
return
cmd, params = data.split(">>")
cmd, params = data.split(">>", 1)
full_name = f"event_{cmd}"
await self._callHandler(full_name, params)

View file

@ -1,6 +1,6 @@
""" Shared utilities: logging """
import os
import logging
import os
__all__ = ["DEBUG", "get_logger", "init_logger"]

View file

@ -1,12 +1,12 @@
#!/bin/env python
""" Interact with hyprland using sockets """
import asyncio
from logging import Logger
from typing import Any
import json
import os
from logging import Logger
from typing import Any
from .common import get_logger, PyprError
from .common import PyprError, get_logger
log: Logger | None = None

View file

@ -2,9 +2,9 @@
toggle_minimized allows having an "expose" like selection of minimized windows
"""
from typing import Any, cast
from .interface import Plugin
from ..ipc import hyprctlJSON, hyprctl
from ..ipc import hyprctl, hyprctlJSON
from .interface import Plugin
class Extension(Plugin): # pylint: disable=missing-class-docstring
@ -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:

View file

@ -1,5 +1,6 @@
" Common plugin interface "
from typing import Any
from ..common import get_logger

View file

@ -0,0 +1,27 @@
" 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)

View file

@ -1,8 +1,8 @@
" Moves unreachable client windows to the currently focused workspace"
from typing import Any, cast
from .interface import Plugin
from ..ipc import hyprctlJSON, hyprctl
from ..ipc import hyprctl, hyprctlJSON
from .interface import Plugin
def contains(monitor, window):

View file

@ -1,7 +1,6 @@
" Toggles workspace zooming "
from .interface import Plugin
from ..ipc import hyprctl
from .interface import Plugin
class Extension(Plugin): # pylint: disable=missing-class-docstring

View file

@ -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,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

View file

@ -1,15 +1,12 @@
" Scratchpads addon "
import os
import asyncio
import os
from itertools import count
import subprocess
from typing import Any, cast
import logging
from ..ipc import (
hyprctl,
hyprctlJSON,
get_focused_monitor_props,
)
from ..ipc import get_focused_monitor_props, hyprctl, hyprctlJSON
from .interface import Plugin
DEFAULT_MARGIN = 60
@ -17,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:
@ -87,6 +85,7 @@ class Animations:
class Scratch:
"A scratchpad state including configuration & client state"
log = logging.getLogger("scratch")
def __init__(self, uid, opts):
self.uid = uid
@ -104,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:
@ -123,7 +122,14 @@ 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)
assert isinstance(client_info, dict)
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
self.client_info.update(client_info)
def __str__(self):
@ -194,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]
@ -211,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
@ -230,6 +238,7 @@ 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:
@ -242,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)
@ -292,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:
"""<name> hides scratchpad "name" """
@ -323,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:
@ -342,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:
"""<name> shows scratchpad "name" """
uid = uid.strip()
@ -360,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()
@ -379,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()
@ -394,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

View file

@ -1,8 +1,8 @@
" shift workspaces across monitors "
from typing import cast
from .interface import Plugin
from ..ipc import hyprctlJSON, hyprctl
from ..ipc import hyprctl, hyprctlJSON
from .interface import Plugin
class Extension(Plugin): # pylint: disable=missing-class-docstring

View file

@ -1,8 +1,8 @@
" Toggle monitors on or off "
from typing import Any, cast
from .interface import Plugin
from ..ipc import hyprctlJSON, hyprctl
from ..ipc import hyprctl, hyprctlJSON
from .interface import Plugin
class Extension(Plugin): # pylint: disable=missing-class-docstring

View file

@ -1,8 +1,8 @@
""" Force workspaces to follow the focus / mouse """
from typing import cast
from .interface import Plugin
from ..ipc import hyprctlJSON, hyprctl
from ..ipc import hyprctl, hyprctlJSON
from .interface import Plugin
class Extension(Plugin): # pylint: disable=missing-class-docstring

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "pyprland"
version = "1.3.1"
version = "1.4.1"
description = "An hyperland plugin system"
authors = ["fdev31 <fdev31@gmail.com>"]
license = "MIT"