v 1.0
This commit is contained in:
parent
041e76123b
commit
ce416f2a27
13 changed files with 859 additions and 2 deletions
|
@ -11,7 +11,7 @@ repos:
|
||||||
# hooks:
|
# hooks:
|
||||||
# - id: prettier
|
# - id: prettier
|
||||||
- repo: https://github.com/ambv/black
|
- repo: https://github.com/ambv/black
|
||||||
rev: "23.1.0"
|
rev: "23.3.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/lovesegfault/beautysh
|
- repo: https://github.com/lovesegfault/beautysh
|
||||||
|
@ -19,7 +19,7 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: beautysh
|
- id: beautysh
|
||||||
- repo: https://github.com/adrienverge/yamllint
|
- repo: https://github.com/adrienverge/yamllint
|
||||||
rev: "v1.29.0"
|
rev: "v1.31.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamllint
|
- id: yamllint
|
||||||
|
|
||||||
|
|
184
README.md
Normal file
184
README.md
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
# Extensions & tweaks for hyprland
|
||||||
|
|
||||||
|
Host process for multiple Hyprland plugins.
|
||||||
|
A single config file `~/.config/hypr/pyprland.json` is used, using the following syntax:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pyprland": {
|
||||||
|
"plugins": ["plugin_name"]
|
||||||
|
},
|
||||||
|
"plugin_name": {
|
||||||
|
"plugin_option": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in plugins are:
|
||||||
|
|
||||||
|
- `scratchpad` implements dropdowns & togglable poppups
|
||||||
|
- `monitors` allows relative placement of monitors depending on the model
|
||||||
|
- `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pyprland
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of plugins, each plugin may have its own configuration needs, eg:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pyprland": {
|
||||||
|
"plugins": [
|
||||||
|
"scratchpads",
|
||||||
|
"monitors",
|
||||||
|
"workspaces_follow_focus"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scratchpads": {
|
||||||
|
"term": {
|
||||||
|
"command": "kitty --class kitty-dropterm",
|
||||||
|
"class": "kitty-dropterm",
|
||||||
|
"animation": "fromTop",
|
||||||
|
"unfocus": "hide"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"command": "pavucontrol",
|
||||||
|
"class": "pavucontrol",
|
||||||
|
"unfocus": "hide",
|
||||||
|
"animation": "fromRight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"monitors": {
|
||||||
|
"placement": {
|
||||||
|
"BenQ PJ": {
|
||||||
|
"topOf": "eDP-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Configuring plugins
|
||||||
|
|
||||||
|
## `monitors`
|
||||||
|
|
||||||
|
Requires `wlr-randr`.
|
||||||
|
|
||||||
|
Allows relative placement of monitors depending on the model ("description" returned by `hyprctl monitors`).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Supported placements are:
|
||||||
|
|
||||||
|
- leftOf
|
||||||
|
- topOf
|
||||||
|
- rightOf
|
||||||
|
- bottomOf
|
||||||
|
|
||||||
|
## `workspaces_follow_focus`
|
||||||
|
|
||||||
|
Make non-visible workspaces follow the focused monitor.
|
||||||
|
Also provides commands to switch between workspaces wile preserving the current monitor assignments:
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
- `change_workspace` `<direction>`: changes the workspace of the focused monitor
|
||||||
|
|
||||||
|
Example usage in `hyprland.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bind = $mainMod, K, exec, pypr change_workspace +1
|
||||||
|
bind = $mainMod, J, exec, pypr change_workspace -1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
You can set the `max_workspaces` property, defaults to `10`.
|
||||||
|
|
||||||
|
## `scratchpads`
|
||||||
|
|
||||||
|
Check [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads".
|
||||||
|
|
||||||
|
As an example, defining two scratchpads:
|
||||||
|
|
||||||
|
- _term_ which would be a kitty terminal on upper part of the screen
|
||||||
|
- _volume_ which would be a pavucontrol window on the right part of the screen
|
||||||
|
|
||||||
|
In your `hyprland.conf` add something like this:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
exec-once = hpr-scratcher
|
||||||
|
|
||||||
|
# Repeat this for each scratchpad you need
|
||||||
|
bind = $mainMod,V,exec,hpr-scratcher toggle volume
|
||||||
|
windowrule = float,^(pavucontrol)$
|
||||||
|
windowrule = workspace special silent,^(pavucontrol)$
|
||||||
|
|
||||||
|
bind = $mainMod,A,exec,hpr-scratcher toggle term
|
||||||
|
$dropterm = ^(kitty-dropterm)$
|
||||||
|
windowrule = float,$dropterm
|
||||||
|
windowrule = workspace special silent,$dropterm
|
||||||
|
windowrule = size 75% 60%,$dropterm
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the configuration file, add something like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scratchpads": {
|
||||||
|
"term": {
|
||||||
|
"command": "kitty --class kitty-dropterm",
|
||||||
|
"animation": "fromTop",
|
||||||
|
"margin": 50,
|
||||||
|
"unfocus": "hide"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"command": "pavucontrol",
|
||||||
|
"animation": "fromRight"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And you'll be able to toggle pavucontrol with MOD + V.
|
||||||
|
|
||||||
|
### Command-line options
|
||||||
|
|
||||||
|
- `reload` : reloads the configuration file
|
||||||
|
- `toggle <scratchpad name>` : toggle the given scratchpad
|
||||||
|
- `show <scratchpad name>` : show the given scratchpad
|
||||||
|
- `hide <scratchpad name>` : hide the given scratchpad
|
||||||
|
|
||||||
|
Note: with no argument it runs the daemon (doesn't fork in the background)
|
||||||
|
|
||||||
|
### Scratchpad Options
|
||||||
|
|
||||||
|
#### command
|
||||||
|
|
||||||
|
This is the command you wish to run in the scratchpad.
|
||||||
|
For a nice startup you need to be able to identify this window in `hyprland.conf`, using `--class` is often a good idea.
|
||||||
|
|
||||||
|
#### animation
|
||||||
|
|
||||||
|
Type of animation to use
|
||||||
|
|
||||||
|
- `null` / `""` / not defined
|
||||||
|
- "fromTop"
|
||||||
|
- "fromBottom"
|
||||||
|
- "fromLeft"
|
||||||
|
- "fromRight"
|
||||||
|
|
||||||
|
#### offset (optional)
|
||||||
|
|
||||||
|
number of pixels for the animation.
|
||||||
|
|
||||||
|
#### unfocus (optional)
|
||||||
|
|
||||||
|
allow to hide the window when the focus is lost when set to "hide"
|
||||||
|
|
||||||
|
#### margin (optional)
|
||||||
|
|
||||||
|
number of pixels separating the scratchpad from the screen border
|
0
pyprland/__init__.py
Normal file
0
pyprland/__init__.py
Normal file
167
pyprland/command.py
Executable file
167
pyprland/command.py
Executable file
|
@ -0,0 +1,167 @@
|
||||||
|
#!/bin/env python
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
from .ipc import get_event_stream
|
||||||
|
from .common import DEBUG
|
||||||
|
from .plugins.interface import Plugin
|
||||||
|
|
||||||
|
CONTROL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.pyprland.sock'
|
||||||
|
|
||||||
|
CONFIG_FILE = "~/.config/hypr/pyprland.json"
|
||||||
|
|
||||||
|
|
||||||
|
class Pyprland:
|
||||||
|
server: asyncio.Server
|
||||||
|
event_reader: asyncio.StreamReader
|
||||||
|
stopped = False
|
||||||
|
name = "builtin"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.plugins: dict[str, Plugin] = {}
|
||||||
|
|
||||||
|
async def load_config(self):
|
||||||
|
self.config = json.loads(
|
||||||
|
open(os.path.expanduser(CONFIG_FILE), encoding="utf-8").read()
|
||||||
|
)
|
||||||
|
for name in self.config["pyprland"]["plugins"]:
|
||||||
|
if name not in self.plugins:
|
||||||
|
modname = name if "." in name else f"pyprland.plugins.{name}"
|
||||||
|
try:
|
||||||
|
plug = importlib.import_module(modname).Exported(name)
|
||||||
|
await plug.init()
|
||||||
|
self.plugins[name] = plug
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading plugin {name}: {e}")
|
||||||
|
if DEBUG:
|
||||||
|
traceback.print_exc()
|
||||||
|
await self.plugins[name].load_config(self.config)
|
||||||
|
|
||||||
|
async def _callHandler(self, full_name, *params):
|
||||||
|
for plugin in [self] + list(self.plugins.values()):
|
||||||
|
if hasattr(plugin, full_name):
|
||||||
|
try:
|
||||||
|
await getattr(plugin, full_name)(*params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{plugin.name}::{full_name}({params}) failed:")
|
||||||
|
if DEBUG:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
async def read_events_loop(self):
|
||||||
|
while not self.stopped:
|
||||||
|
data = (await self.event_reader.readline()).decode()
|
||||||
|
if not data:
|
||||||
|
print("Reader starved")
|
||||||
|
return
|
||||||
|
cmd, params = data.split(">>")
|
||||||
|
full_name = f"event_{cmd}"
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
print(f"EVT {full_name}({params.strip()})")
|
||||||
|
await self._callHandler(full_name, params)
|
||||||
|
|
||||||
|
async def read_command(self, reader, writer):
|
||||||
|
data = (await reader.readline()).decode()
|
||||||
|
if not data:
|
||||||
|
print("Server starved")
|
||||||
|
return
|
||||||
|
if data == "exit\n":
|
||||||
|
self.stopped = True
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
self.server.close()
|
||||||
|
return
|
||||||
|
args = data.split(None, 1)
|
||||||
|
if len(args) == 1:
|
||||||
|
cmd = args[0]
|
||||||
|
args = []
|
||||||
|
else:
|
||||||
|
cmd = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
|
||||||
|
full_name = f"run_{cmd}"
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
print(f"CMD: {full_name}({args})")
|
||||||
|
|
||||||
|
await self._callHandler(full_name, *args)
|
||||||
|
|
||||||
|
async def serve(self):
|
||||||
|
try:
|
||||||
|
async with self.server:
|
||||||
|
await self.server.serve_forever()
|
||||||
|
finally:
|
||||||
|
for plugin in self.plugins.values():
|
||||||
|
await plugin.exit()
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
await asyncio.gather(
|
||||||
|
asyncio.create_task(self.serve()),
|
||||||
|
asyncio.create_task(self.read_events_loop()),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_reload = load_config
|
||||||
|
|
||||||
|
|
||||||
|
async def run_daemon():
|
||||||
|
manager = Pyprland()
|
||||||
|
manager.server = await asyncio.start_unix_server(manager.read_command, CONTROL)
|
||||||
|
events_reader, events_writer = await get_event_stream()
|
||||||
|
manager.event_reader = events_reader
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.load_config() # ensure sockets are connected first
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(
|
||||||
|
f"No config file found, create one at {CONFIG_FILE} with a valid pyprland.plugins list"
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Interrupted")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print("Bye!")
|
||||||
|
finally:
|
||||||
|
events_writer.close()
|
||||||
|
await events_writer.wait_closed()
|
||||||
|
manager.server.close()
|
||||||
|
await manager.server.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_client():
|
||||||
|
if sys.argv[1] == "--help":
|
||||||
|
print(
|
||||||
|
"""Commands:
|
||||||
|
reload
|
||||||
|
show <scratchpad name>
|
||||||
|
hide <scratchpad name>
|
||||||
|
toggle <scratchpad name>
|
||||||
|
|
||||||
|
If arguments are ommited, runs the daemon which will start every configured command.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_, writer = await asyncio.open_unix_connection(CONTROL)
|
||||||
|
writer.write((" ".join(sys.argv[1:])).encode())
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
asyncio.run(run_daemon() if len(sys.argv) <= 1 else run_client())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
4
pyprland/common.py
Normal file
4
pyprland/common.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEBUG = os.environ.get("DEBUG", False)
|
||||||
|
CONFIG_FILE = os.path.expanduser("~/.config/hypr/scratchpads.json")
|
58
pyprland/ipc.py
Normal file
58
pyprland/ipc.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
#!/bin/env python
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .common import DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
HYPRCTL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket.sock'
|
||||||
|
EVENTS = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket2.sock'
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_stream():
|
||||||
|
return await asyncio.open_unix_connection(EVENTS)
|
||||||
|
|
||||||
|
|
||||||
|
async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]:
|
||||||
|
if DEBUG:
|
||||||
|
print("(JS)>>>", command)
|
||||||
|
ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL)
|
||||||
|
ctl_writer.write(f"-j/{command}".encode())
|
||||||
|
await ctl_writer.drain()
|
||||||
|
resp = await ctl_reader.read()
|
||||||
|
ctl_writer.close()
|
||||||
|
await ctl_writer.wait_closed()
|
||||||
|
return json.loads(resp)
|
||||||
|
|
||||||
|
|
||||||
|
async def hyprctl(command):
|
||||||
|
if DEBUG:
|
||||||
|
print(">>>", command)
|
||||||
|
ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL)
|
||||||
|
ctl_writer.write(f"/dispatch {command}".encode())
|
||||||
|
await ctl_writer.drain()
|
||||||
|
resp = await ctl_reader.read(100)
|
||||||
|
ctl_writer.close()
|
||||||
|
await ctl_writer.wait_closed()
|
||||||
|
if DEBUG:
|
||||||
|
print("<<<", resp)
|
||||||
|
return resp == b"ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_workspaces() -> list[dict[str, Any]]:
|
||||||
|
return await hyprctlJSON("workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_focused_monitor_props():
|
||||||
|
for monitor in await hyprctlJSON("monitors"):
|
||||||
|
assert isinstance(monitor, dict)
|
||||||
|
if monitor.get("focused") == True:
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client_props_by_pid(pid: int):
|
||||||
|
for client in await hyprctlJSON("clients"):
|
||||||
|
if client.get("pid") == pid:
|
||||||
|
return client
|
0
pyprland/plugins/__init__.py
Normal file
0
pyprland/plugins/__init__.py
Normal file
10
pyprland/plugins/experimental.py
Normal file
10
pyprland/plugins/experimental.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from .interface import Plugin
|
||||||
|
|
||||||
|
from ..ipc import hyprctlJSON, hyprctl, get_workspaces
|
||||||
|
|
||||||
|
|
||||||
|
class Experimental(Plugin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
Exported = Experimental
|
18
pyprland/plugins/interface.py
Normal file
18
pyprland/plugins/interface.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def exit(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
async def load_config(self, config: dict[str, Any]):
|
||||||
|
try:
|
||||||
|
self.config = config[self.name]
|
||||||
|
except KeyError:
|
||||||
|
self.config = {}
|
48
pyprland/plugins/monitors.py
Normal file
48
pyprland/plugins/monitors.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
from typing import Any
|
||||||
|
from .interface import Plugin
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..ipc import hyprctlJSON
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorLayout(Plugin):
|
||||||
|
async def event_monitoradded(self, screenid):
|
||||||
|
screenid = screenid.strip()
|
||||||
|
|
||||||
|
monitors: list[dict[str, Any]] = await hyprctlJSON("monitors")
|
||||||
|
for mon in monitors:
|
||||||
|
if mon["name"].startswith(screenid):
|
||||||
|
mon_name = mon["description"]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"Monitor {screenid} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
mon_by_name = {m["name"]: m for m in monitors}
|
||||||
|
|
||||||
|
newmon = mon_by_name[screenid]
|
||||||
|
|
||||||
|
for mon_pattern, conf in self.config["placement"].items():
|
||||||
|
if mon_pattern in mon_name:
|
||||||
|
for placement, mon_name in conf.items():
|
||||||
|
ref = mon_by_name[mon_name]
|
||||||
|
if ref:
|
||||||
|
place = placement.lower()
|
||||||
|
if place == "topof":
|
||||||
|
x: int = ref["x"]
|
||||||
|
y: int = ref["y"] - newmon["height"]
|
||||||
|
elif place == "bottomof":
|
||||||
|
x: int = ref["x"]
|
||||||
|
y: int = ref["y"] + ref["height"]
|
||||||
|
elif place == "leftof":
|
||||||
|
x: int = ref["x"] - newmon["width"]
|
||||||
|
y: int = ref["y"]
|
||||||
|
else: # rightof
|
||||||
|
x: int = ref["x"] + ref["width"]
|
||||||
|
y: int = ref["y"]
|
||||||
|
subprocess.call(
|
||||||
|
["wlr-randr", "--output", screenid, "--pos", f"{x},{y}"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Exported = MonitorLayout
|
293
pyprland/plugins/scratchpads.py
Normal file
293
pyprland/plugins/scratchpads.py
Normal file
|
@ -0,0 +1,293 @@
|
||||||
|
import subprocess
|
||||||
|
import asyncio
|
||||||
|
from ..ipc import (
|
||||||
|
hyprctl,
|
||||||
|
hyprctlJSON,
|
||||||
|
get_focused_monitor_props,
|
||||||
|
get_client_props_by_pid,
|
||||||
|
)
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .interface import Plugin
|
||||||
|
|
||||||
|
DEFAULT_MARGIN = 60
|
||||||
|
|
||||||
|
|
||||||
|
class Scratch:
|
||||||
|
def __init__(self, uid, opts):
|
||||||
|
self.uid = uid
|
||||||
|
self.pid = 0
|
||||||
|
self.conf = opts
|
||||||
|
self.visible = False
|
||||||
|
self.just_created = True
|
||||||
|
self.clientInfo = {}
|
||||||
|
|
||||||
|
def isAlive(self):
|
||||||
|
path = f"/proc/{self.pid}"
|
||||||
|
if os.path.exists(path):
|
||||||
|
for line in open(os.path.join(path, "status"), "r").readlines():
|
||||||
|
if line.startswith("State"):
|
||||||
|
state = line.split()[1]
|
||||||
|
return state in "RSDTt" # not "Z (zombie)"or "X (dead)"
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset(self, pid: int):
|
||||||
|
self.pid = pid
|
||||||
|
self.visible = False
|
||||||
|
self.just_created = True
|
||||||
|
self.clientInfo = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def address(self) -> str:
|
||||||
|
return str(self.clientInfo.get("address", ""))[2:]
|
||||||
|
|
||||||
|
async def updateClientInfo(self, clientInfo=None):
|
||||||
|
if clientInfo is None:
|
||||||
|
clientInfo = await get_client_props_by_pid(self.pid)
|
||||||
|
assert isinstance(clientInfo, dict)
|
||||||
|
self.clientInfo.update(clientInfo)
|
||||||
|
|
||||||
|
|
||||||
|
class ScratchpadManager(Plugin):
|
||||||
|
async def init(self):
|
||||||
|
self.procs: dict[str, subprocess.Popen] = {}
|
||||||
|
self.scratches: dict[str, Scratch] = {}
|
||||||
|
self.transitioning_scratches: set[str] = set()
|
||||||
|
self._respawned_scratches: set[str] = set()
|
||||||
|
self.scratches_by_address: dict[str, Scratch] = {}
|
||||||
|
self.scratches_by_pid: dict[int, Scratch] = {}
|
||||||
|
|
||||||
|
async def exit(self):
|
||||||
|
async def die_in_piece(scratch: Scratch):
|
||||||
|
proc = self.procs[scratch.uid]
|
||||||
|
proc.terminate()
|
||||||
|
for n in range(10):
|
||||||
|
if not scratch.isAlive():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
if scratch.isAlive():
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
*(die_in_piece(scratch) for scratch in self.scratches.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_config(self, config):
|
||||||
|
config = config["scratchpads"]
|
||||||
|
scratches = {k: Scratch(k, v) for k, v in config.items()}
|
||||||
|
|
||||||
|
is_updating = bool(self.scratches)
|
||||||
|
|
||||||
|
for name in scratches:
|
||||||
|
if name not in self.scratches:
|
||||||
|
self.scratches[name] = scratches[name]
|
||||||
|
else:
|
||||||
|
self.scratches[name].conf = scratches[name].conf
|
||||||
|
|
||||||
|
if is_updating:
|
||||||
|
await self.exit()
|
||||||
|
|
||||||
|
# not known yet
|
||||||
|
for name in self.scratches:
|
||||||
|
self.start_scratch_command(name)
|
||||||
|
|
||||||
|
def start_scratch_command(self, name: str):
|
||||||
|
self._respawned_scratches.add(name)
|
||||||
|
scratch = self.scratches[name]
|
||||||
|
old_pid = self.procs[name].pid if name in self.procs else 0
|
||||||
|
self.procs[name] = subprocess.Popen(
|
||||||
|
scratch.conf["command"],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
pid = self.procs[name].pid
|
||||||
|
self.scratches[name].reset(pid)
|
||||||
|
self.scratches_by_pid[self.procs[name].pid] = scratch
|
||||||
|
if old_pid:
|
||||||
|
del self.scratches_by_pid[old_pid]
|
||||||
|
|
||||||
|
# Events
|
||||||
|
async def event_activewindowv2(self, addr):
|
||||||
|
addr = addr.strip()
|
||||||
|
scratch = self.scratches_by_address.get(addr)
|
||||||
|
if scratch:
|
||||||
|
if scratch.just_created:
|
||||||
|
await self.run_hide(scratch.uid, force=True)
|
||||||
|
scratch.just_created = False
|
||||||
|
else:
|
||||||
|
for uid, scratch in self.scratches.items():
|
||||||
|
if scratch.clientInfo and scratch.address != addr:
|
||||||
|
if (
|
||||||
|
scratch.visible
|
||||||
|
and scratch.conf.get("unfocus") == "hide"
|
||||||
|
and scratch.uid not in self.transitioning_scratches
|
||||||
|
):
|
||||||
|
await self.run_hide(uid)
|
||||||
|
|
||||||
|
async def event_openwindow(self, params):
|
||||||
|
addr, wrkspc, kls, title = params.split(",", 3)
|
||||||
|
if wrkspc.startswith("special"):
|
||||||
|
item = self.scratches_by_address.get(addr)
|
||||||
|
if not item and self._respawned_scratches:
|
||||||
|
await self.updateScratchInfo()
|
||||||
|
item = self.scratches_by_address.get(addr)
|
||||||
|
if item and item.just_created:
|
||||||
|
self._respawned_scratches.discard(item.uid)
|
||||||
|
await self.run_hide(item.uid, force=True)
|
||||||
|
item.just_created = False
|
||||||
|
|
||||||
|
async def run_toggle(self, uid: str):
|
||||||
|
uid = uid.strip()
|
||||||
|
item = self.scratches.get(uid)
|
||||||
|
if not item:
|
||||||
|
print(f"{uid} is not configured")
|
||||||
|
return
|
||||||
|
if item.visible:
|
||||||
|
await self.run_hide(uid)
|
||||||
|
else:
|
||||||
|
await self.run_show(uid)
|
||||||
|
|
||||||
|
async def updateScratchInfo(self, scratch: Scratch | None = None):
|
||||||
|
if scratch is None:
|
||||||
|
for client in await hyprctlJSON("clients"):
|
||||||
|
assert isinstance(client, dict)
|
||||||
|
pid = client["pid"]
|
||||||
|
assert isinstance(pid, int)
|
||||||
|
scratch = self.scratches_by_pid.get(pid)
|
||||||
|
if scratch:
|
||||||
|
await scratch.updateClientInfo(client)
|
||||||
|
self.scratches_by_address[
|
||||||
|
scratch.clientInfo["address"][2:]
|
||||||
|
] = scratch
|
||||||
|
else:
|
||||||
|
add_to_address_book = ("address" not in scratch.clientInfo) or (
|
||||||
|
scratch.address not in self.scratches_by_address
|
||||||
|
)
|
||||||
|
await scratch.updateClientInfo()
|
||||||
|
if add_to_address_book:
|
||||||
|
self.scratches_by_address[scratch.clientInfo["address"][2:]] = scratch
|
||||||
|
|
||||||
|
async def run_hide(self, uid: str, force=False):
|
||||||
|
uid = uid.strip()
|
||||||
|
item = self.scratches.get(uid)
|
||||||
|
if not item:
|
||||||
|
print(f"{uid} is not configured")
|
||||||
|
return
|
||||||
|
if not item.visible and not force:
|
||||||
|
print(f"{uid} is already hidden")
|
||||||
|
return
|
||||||
|
item.visible = False
|
||||||
|
pid = "pid:%d" % item.pid
|
||||||
|
animation_type = item.conf.get("animation", "").lower()
|
||||||
|
if animation_type:
|
||||||
|
offset = item.conf.get("offset")
|
||||||
|
if offset is None:
|
||||||
|
if "size" not in item.clientInfo:
|
||||||
|
await self.updateScratchInfo(item)
|
||||||
|
|
||||||
|
offset = int(1.3 * item.clientInfo["size"][1])
|
||||||
|
|
||||||
|
if animation_type == "fromtop":
|
||||||
|
await hyprctl(f"movewindowpixel 0 -{offset},{pid}")
|
||||||
|
elif animation_type == "frombottom":
|
||||||
|
await hyprctl(f"movewindowpixel 0 {offset},{pid}")
|
||||||
|
elif animation_type == "fromleft":
|
||||||
|
await hyprctl(f"movewindowpixel -{offset} 0,{pid}")
|
||||||
|
elif animation_type == "fromright":
|
||||||
|
await hyprctl(f"movewindowpixel {offset} 0,{pid}")
|
||||||
|
|
||||||
|
if uid in self.transitioning_scratches:
|
||||||
|
return # abort sequence
|
||||||
|
await asyncio.sleep(0.2) # await for animation to finish
|
||||||
|
if uid not in self.transitioning_scratches:
|
||||||
|
await hyprctl(f"movetoworkspacesilent special:scratch,{pid}")
|
||||||
|
|
||||||
|
async def _animation_fromtop(self, monitor, client, client_uid, margin):
|
||||||
|
mon_x = monitor["x"]
|
||||||
|
mon_y = monitor["y"]
|
||||||
|
mon_width = monitor["width"]
|
||||||
|
|
||||||
|
client_width = client["size"][0]
|
||||||
|
margin_x = int((mon_width - client_width) / 2) + mon_x
|
||||||
|
await hyprctl(f"movewindowpixel exact {margin_x} {mon_y + margin},{client_uid}")
|
||||||
|
|
||||||
|
async def _animation_frombottom(self, monitor, client, client_uid, margin):
|
||||||
|
mon_x = monitor["x"]
|
||||||
|
mon_y = monitor["y"]
|
||||||
|
mon_width = monitor["width"]
|
||||||
|
mon_height = monitor["height"]
|
||||||
|
|
||||||
|
client_width = client["size"][0]
|
||||||
|
client_height = client["size"][1]
|
||||||
|
margin_x = int((mon_width - client_width) / 2) + mon_x
|
||||||
|
await hyprctl(
|
||||||
|
f"movewindowpixel exact {margin_x} {mon_y + mon_height - client_height - margin},{client_uid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _animation_fromleft(self, monitor, client, client_uid, margin):
|
||||||
|
mon_y = monitor["y"]
|
||||||
|
mon_height = monitor["height"]
|
||||||
|
|
||||||
|
client_height = client["size"][1]
|
||||||
|
margin_y = int((mon_height - client_height) / 2) + mon_y
|
||||||
|
|
||||||
|
await hyprctl(f"movewindowpixel exact {margin} {margin_y},{client_uid}")
|
||||||
|
|
||||||
|
async def _animation_fromright(self, monitor, client, client_uid, margin):
|
||||||
|
mon_y = monitor["y"]
|
||||||
|
mon_width = monitor["width"]
|
||||||
|
mon_height = monitor["height"]
|
||||||
|
|
||||||
|
client_width = client["size"][0]
|
||||||
|
client_height = client["size"][1]
|
||||||
|
margin_y = int((mon_height - client_height) / 2) + mon_y
|
||||||
|
await hyprctl(
|
||||||
|
f"movewindowpixel exact {mon_width - client_width - margin} {margin_y},{client_uid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_show(self, uid, force=False):
|
||||||
|
uid = uid.strip()
|
||||||
|
item = self.scratches.get(uid)
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
print(f"{uid} is not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
if item.visible and not force:
|
||||||
|
print(f"{uid} is already visible")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not item.isAlive():
|
||||||
|
print(f"{uid} is not running, restarting...")
|
||||||
|
self.procs[uid].kill()
|
||||||
|
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()
|
||||||
|
assert monitor
|
||||||
|
|
||||||
|
await self.updateScratchInfo(item)
|
||||||
|
|
||||||
|
pid = "pid:%d" % item.pid
|
||||||
|
|
||||||
|
animation_type = item.conf.get("animation", "").lower()
|
||||||
|
|
||||||
|
wrkspc = monitor["activeWorkspace"]["id"]
|
||||||
|
self.transitioning_scratches.add(uid)
|
||||||
|
await hyprctl(f"movetoworkspacesilent {wrkspc},{pid}")
|
||||||
|
if animation_type:
|
||||||
|
margin = item.conf.get("margin", DEFAULT_MARGIN)
|
||||||
|
fn = getattr(self, "_animation_%s" % animation_type)
|
||||||
|
await fn(monitor, item.clientInfo, pid, margin)
|
||||||
|
|
||||||
|
await hyprctl(f"focuswindow {pid}")
|
||||||
|
await asyncio.sleep(0.2) # ensure some time for events to propagate
|
||||||
|
self.transitioning_scratches.discard(uid)
|
||||||
|
|
||||||
|
|
||||||
|
Exported = ScratchpadManager
|
55
pyprland/plugins/workspaces_follow_focus.py
Normal file
55
pyprland/plugins/workspaces_follow_focus.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
from .interface import Plugin
|
||||||
|
|
||||||
|
from ..ipc import hyprctlJSON, hyprctl, get_workspaces
|
||||||
|
|
||||||
|
|
||||||
|
class Extension(Plugin):
|
||||||
|
async def load_config(self, config):
|
||||||
|
await super().load_config(config)
|
||||||
|
self.workspace_list = list(range(1, self.config.get("max_workspaces", 10)))
|
||||||
|
|
||||||
|
async def event_focusedmon(self, screenid_index):
|
||||||
|
monitor_id, workspace_id = screenid_index.split(",")
|
||||||
|
workspace_id = int(workspace_id)
|
||||||
|
# move every free wokrspace to the currently focused desktop
|
||||||
|
busy_workspaces = set(
|
||||||
|
mon["activeWorkspace"]["id"]
|
||||||
|
for mon in await hyprctlJSON("monitors")
|
||||||
|
if mon["name"] != monitor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
for n in self.workspace_list:
|
||||||
|
if n in busy_workspaces or n == workspace_id:
|
||||||
|
continue
|
||||||
|
await hyprctl(f"moveworkspacetomonitor {n} {monitor_id}")
|
||||||
|
await hyprctl(f"workspace {workspace_id}")
|
||||||
|
|
||||||
|
async def run_change_workspace(self, direction: str):
|
||||||
|
increment = int(direction)
|
||||||
|
# get focused screen info
|
||||||
|
monitors = await hyprctlJSON("monitors")
|
||||||
|
assert isinstance(monitors, list)
|
||||||
|
for monitor in monitors:
|
||||||
|
if monitor["focused"]:
|
||||||
|
break
|
||||||
|
assert isinstance(monitor, dict)
|
||||||
|
busy_workspaces = set(
|
||||||
|
m["activeWorkspace"]["id"] for m in monitors if m["id"] != monitor["id"]
|
||||||
|
)
|
||||||
|
# get workspaces info
|
||||||
|
workspaces = await get_workspaces()
|
||||||
|
assert isinstance(workspaces, list)
|
||||||
|
workspaces.sort(key=lambda x: x["id"])
|
||||||
|
cur_workspace = monitor["activeWorkspace"]["id"]
|
||||||
|
available_workspaces = [
|
||||||
|
i for i in self.workspace_list if i not in busy_workspaces
|
||||||
|
]
|
||||||
|
idx = available_workspaces.index(cur_workspace)
|
||||||
|
next_workspace = available_workspaces[
|
||||||
|
(idx + increment) % len(available_workspaces)
|
||||||
|
]
|
||||||
|
await hyprctl(f"moveworkspacetomonitor {next_workspace},{monitor['name']}")
|
||||||
|
await hyprctl(f"workspace {next_workspace}")
|
||||||
|
|
||||||
|
|
||||||
|
Exported = Extension
|
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "pyprland"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "An hyperland plugin system"
|
||||||
|
authors = ["fdev31 <fdev31@gmail.com>"]
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
packages = [{include = "pyprland"}]
|
||||||
|
homepage = "https://github.com/fdev31/pyprland/"
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
pypr = "pyprland.command:main"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
Loading…
Add table
Add a link
Reference in a new issue