From 22bf86e85c9e8c1f1b0ac0b976169915655c119e Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Apr 2023 23:46:01 +0200 Subject: [PATCH 01/76] Version 1.2.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e5eea89..8fb5b17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.2.0" +version = "1.2.1" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT" From 7ac81b8a9028b54258555d7df32fb308e70d40a7 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 00:21:14 +0200 Subject: [PATCH 02/76] Rework the README --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5f06f8f..4d84d08 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following ## Built-in plugins -- `scratchpad` implements dropdowns & togglable poppups +- `scratchpads` 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. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. - `lost_windows` brings lost floating windows to the current workspace @@ -90,7 +90,7 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of # Configuring plugins -## `magnify` plugin +## Plugin: `magnify` ### Command @@ -103,20 +103,40 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of Scaling factor to be used when no value is provided. -## `toggle_dpms` plugin +## PLugin: `toggle_dpms` ### Command - `toggle_dpms`: if any screen is powered on, turn them all off, else turn them all on -## `lost_windows` plugin +## Plugin: `lost_windows` ### Command - `attract_lost`: brings the lost windows to the current screen / workspace -## `monitors` plugin +## Plugin: `monitors` + +Syntax: +```json +"monitors": { + "placement": { + "": { + "placement type": "" + } + } +} +``` + +Example: +```json +"monitors": { + "Medion": { + "placement": "HDMI-1" + } +} +``` Requires `wlr-randr`. @@ -140,7 +160,7 @@ If set, runs the associated command for screens which aren't matching any of the **Note** this is supposed to be a short lived command which will block the rest of the process until closed. In other words no plugin will be processed while this command remains open. -## `workspaces_follow_focus` plugin +## Plugin: `workspaces_follow_focus` Make non-visible workspaces follow the focused monitor. Also provides commands to switch between workspaces wile preserving the current monitor assignments: @@ -160,7 +180,7 @@ bind = $mainMod, J, exec, pypr change_workspace -1 You can set the `max_workspaces` property, defaults to `10`. -## `scratchpads` plugin +## Plugin: `scratchpads` Check [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads". @@ -215,6 +235,31 @@ Note: with no argument it runs the daemon (doesn't fork in the background) ### Scratchpad Options +Syntax: +```json +"scratchpads": { + "": { + "command": "command to execute" + } +} +``` + +Example: +```json +"scratchpads": { + "term": { + "command": "kitty --class kitty-dropterm", + "animation": "fromTop", + "margin": 50, + "unfocus": "hide" + }, + "volume": { + "command": "pavucontrol", + "animation": "fromRight" + } +``` + + #### command This is the command you wish to run in the scratchpad. From beab4d432ed2e33aeaa7e8e3b04981321c8d23c9 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 00:22:41 +0200 Subject: [PATCH 03/76] README: Plugins are level 1 titles --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4d84d08..08a7d5c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of # Configuring plugins -## Plugin: `magnify` +# Plugin: `magnify` ### Command @@ -110,13 +110,13 @@ Scaling factor to be used when no value is provided. - `toggle_dpms`: if any screen is powered on, turn them all off, else turn them all on -## Plugin: `lost_windows` +# Plugin: `lost_windows` ### Command - `attract_lost`: brings the lost windows to the current screen / workspace -## Plugin: `monitors` +# Plugin: `monitors` Syntax: ```json @@ -160,7 +160,7 @@ If set, runs the associated command for screens which aren't matching any of the **Note** this is supposed to be a short lived command which will block the rest of the process until closed. In other words no plugin will be processed while this command remains open. -## Plugin: `workspaces_follow_focus` +# Plugin: `workspaces_follow_focus` Make non-visible workspaces follow the focused monitor. Also provides commands to switch between workspaces wile preserving the current monitor assignments: @@ -180,7 +180,7 @@ bind = $mainMod, J, exec, pypr change_workspace -1 You can set the `max_workspaces` property, defaults to `10`. -## Plugin: `scratchpads` +# Plugin: `scratchpads` Check [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads". From 0d2dec94fa07ec00acffa11b68c6da8c085c6c21 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 00:31:40 +0200 Subject: [PATCH 04/76] More work on README --- README.md | 119 ++++++++++++++++++++++++++---------------------------- 1 file changed, 57 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 08a7d5c..5f7ed9c 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,6 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of } ``` -# Configuring plugins - # Plugin: `magnify` ### Command @@ -99,11 +97,11 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of ### Configuration -#### `factor` +#### `factor` (optional, defaults to 2) Scaling factor to be used when no value is provided. -## PLugin: `toggle_dpms` +## Plugin: `toggle_dpms` ### Command @@ -124,7 +122,8 @@ Syntax: "placement": { "": { "placement type": "" - } + }, + "unknown": "" } } ``` @@ -132,8 +131,11 @@ Syntax: Example: ```json "monitors": { - "Medion": { - "placement": "HDMI-1" + "unknown": "notify-send 'Unknown monitor'", + "placement": { + "Sony": { + "topOf": "HDMI-1" + } } } ``` @@ -154,7 +156,7 @@ Supported placements are: - rightOf - bottomOf -#### `unknown` +#### `unknown` (optional) If set, runs the associated command for screens which aren't matching any of the provided placements (pattern isn't found in monitor description). @@ -165,6 +167,13 @@ If set, runs the associated command for screens which aren't matching any of the Make non-visible workspaces follow the focused monitor. Also provides commands to switch between workspaces wile preserving the current monitor assignments: +Syntax: +```json +"workspaces_follow_focus": { + "max_workspaces": +} +``` + ### Command - `change_workspace` ``: changes the workspace of the focused monitor @@ -182,13 +191,38 @@ You can set the `max_workspaces` property, defaults to `10`. # Plugin: `scratchpads` -Check [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads". +Defines commands that should run in dropdowns. Successor of [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads". + +Syntax: +```json +"scratchpads": { + "scratchpad name": { + "command": "command to run" + } +} +``` 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 +Example: +```json +"scratchpads": { + "term": { + "command": "kitty --class kitty-dropterm", + "animation": "fromTop", + "margin": 50, + "unfocus": "hide" + }, + "volume": { + "command": "pavucontrol", + "animation": "fromRight" + } +} +``` + In your `hyprland.conf` add something like this: ```ini @@ -206,23 +240,6 @@ 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. ### Commands @@ -233,57 +250,34 @@ And you'll be able to toggle pavucontrol with MOD + V. Note: with no argument it runs the daemon (doesn't fork in the background) -### Scratchpad Options - -Syntax: -```json -"scratchpads": { - "": { - "command": "command to execute" - } -} -``` - -Example: -```json -"scratchpads": { - "term": { - "command": "kitty --class kitty-dropterm", - "animation": "fromTop", - "margin": 50, - "unfocus": "hide" - }, - "volume": { - "command": "pavucontrol", - "animation": "fromRight" - } -``` -#### command +### Configuration + +#### `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 +#### `animation` (optional) Type of animation to use -- `null` / `""` / not defined -- "fromTop" -- "fromBottom" -- "fromLeft" -- "fromRight" +- `null` / `""` / not defined (no animation) +- "fromTop" (stays close to top screen border) +- "fromBottom" (stays close to bottom screen border) +- "fromLeft" (stays close to left screen border) +- "fromRight" (stays close to right screen border) -#### offset (optional) +#### `offset` (optional) number of pixels for the animation. -#### unfocus (optional) +#### `unfocus` (optional) allow to hide the window when the focus is lost when set to "hide" -#### margin (optional) +#### `margin` (optional) number of pixels separating the scratchpad from the screen border @@ -295,6 +289,8 @@ Plugins can be loaded with full python module path, eg: `"mymodule.pyprlandplugi Check the `interface.py` file to know the base methods, also have a look at the other plugins for working examples. +To get more details when an error is occurring, `export DEBUG=1` in your shell before running. + ## Creating a command Just add a method called `run_`, eg with "togglezoom" command: @@ -315,4 +311,3 @@ async def run_togglezoom(self, args): Similar as a command, implement some `event_` method. - From 2cbaaa9bb7549f439701ac24519f266c95e441c0 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 01:12:08 +0200 Subject: [PATCH 05/76] fix level of dpms --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f7ed9c..4f1f8f5 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of Scaling factor to be used when no value is provided. -## Plugin: `toggle_dpms` +# Plugin: `toggle_dpms` ### Command From 82a1795a9b1891d26a4ff0c0d186abc712786092 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 16:44:36 +0200 Subject: [PATCH 06/76] more address based --- pyprland/command.py | 3 +-- pyprland/plugins/scratchpads.py | 41 +++++++++++++++++---------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index 5676a90..28dde0c 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -51,8 +51,7 @@ class Pyprland: await getattr(plugin, full_name)(*params) except Exception as e: print(f"{plugin.name}::{full_name}({params}) failed:") - if DEBUG: - traceback.print_exc() + traceback.print_exc() async def read_events_loop(self): while not self.stopped: diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index d705946..608bf88 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -13,10 +13,10 @@ from .interface import Plugin DEFAULT_MARGIN = 60 -async def get_client_props_by_pid(pid: int): +async def get_client_props_by_address(addr: str): for client in await hyprctlJSON("clients"): assert isinstance(client, dict) - if client.get("pid") == pid: + if client.get("address") == addr: return client @@ -101,7 +101,7 @@ class Scratch: async def updateClientInfo(self, clientInfo=None) -> None: if clientInfo is None: - clientInfo = await get_client_props_by_pid(self.pid) + clientInfo = await get_client_props_by_address("0x" + self.address) assert isinstance(clientInfo, dict) self.clientInfo.update(clientInfo) @@ -163,7 +163,7 @@ class Extension(Plugin): pid = self.procs[name].pid self.scratches[name].reset(pid) self.scratches_by_pid[self.procs[name].pid] = scratch - if old_pid: + if old_pid and old_pid in self.scratches_by_pid: del self.scratches_by_pid[old_pid] # Events @@ -212,14 +212,13 @@ class Extension(Plugin): 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) + 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: 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 @@ -239,7 +238,7 @@ class Extension(Plugin): print(f"{uid} is already hidden") return item.visible = False - pid = "pid:%d" % item.pid + addr = "address:0x" + item.address animation_type: str = item.conf.get("animation", "").lower() if animation_type: offset = item.conf.get("offset") @@ -250,20 +249,20 @@ class Extension(Plugin): offset = int(1.3 * item.clientInfo["size"][1]) if animation_type == "fromtop": - await hyprctl(f"movewindowpixel 0 -{offset},{pid}") + await hyprctl(f"movewindowpixel 0 -{offset},{addr}") elif animation_type == "frombottom": - await hyprctl(f"movewindowpixel 0 {offset},{pid}") + await hyprctl(f"movewindowpixel 0 {offset},{addr}") elif animation_type == "fromleft": - await hyprctl(f"movewindowpixel -{offset} 0,{pid}") + await hyprctl(f"movewindowpixel -{offset} 0,{addr}") elif animation_type == "fromright": - await hyprctl(f"movewindowpixel {offset} 0,{pid}") + await hyprctl(f"movewindowpixel {offset} 0,{addr}") 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_{uid},{pid}") + await hyprctl(f"movetoworkspacesilent special:scratch_{uid},{addr}") if ( animation_type and uid in self.focused_window_tracking @@ -291,6 +290,8 @@ class Extension(Plugin): if not item.isAlive(): print(f"{uid} is not running, restarting...") self.procs[uid].kill() + del self.scratches_by_pid[self.procs[uid].pid] + del self.scratches_by_address[item.address] self.start_scratch_command(uid) while uid in self._respawned_scratches: await asyncio.sleep(0.05) @@ -301,7 +302,7 @@ class Extension(Plugin): await self.updateScratchInfo(item) - pid = "pid:%d" % item.pid + addr = "address:0x" + item.address animation_type = item.conf.get("animation", "").lower() @@ -309,12 +310,12 @@ class Extension(Plugin): self.transitioning_scratches.add(uid) await hyprctl(f"moveworkspacetomonitor special:scratch_{uid} {monitor['name']}") - await hyprctl(f"movetoworkspacesilent {wrkspc},{pid}") + await hyprctl(f"movetoworkspacesilent {wrkspc},{addr}") if animation_type: margin = item.conf.get("margin", DEFAULT_MARGIN) fn = getattr(Animations, animation_type) - await fn(monitor, item.clientInfo, pid, margin) + await fn(monitor, item.clientInfo, addr, margin) - await hyprctl(f"focuswindow {pid}") + await hyprctl(f"focuswindow {addr}") await asyncio.sleep(0.2) # ensure some time for events to propagate self.transitioning_scratches.discard(uid) From cb11493203de39a29f719c7aa8678871791327ba Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 22:19:08 +0200 Subject: [PATCH 07/76] workspaces_follow_focus: fix unknown workspaces handling --- pyprland/plugins/workspaces_follow_focus.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 41fa834..946425d 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -40,17 +40,17 @@ class Extension(Plugin): busy_workspaces = set( m["activeWorkspace"]["id"] for m in monitors if m["id"] != monitor["id"] ) - # get workspaces info - workspaces = await hyprctlJSON("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) - ] + try: + idx = available_workspaces.index(cur_workspace) + except ValueError: + next_workspace = available_workspaces[0] + else: + next_workspace = available_workspaces[ + (idx + increment) % len(available_workspaces) + ] await hyprctl(f"moveworkspacetomonitor {next_workspace},{monitor['name']}") await hyprctl(f"workspace {next_workspace}") From a7cc3cd4b83f5b34c46db734b80fb425183114b1 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 22:25:22 +0200 Subject: [PATCH 08/76] Fix max workspace number --- pyprland/plugins/workspaces_follow_focus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 946425d..0a9e2b2 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -7,7 +7,7 @@ from ..ipc import hyprctlJSON, hyprctl 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))) + self.workspace_list = list(range(1, self.config.get("max_workspaces", 10) + 1)) async def event_focusedmon(self, screenid_index): monitor_id, workspace_id = screenid_index.split(",") From 4a0cb5fb8f3f4a2d6901ea56827dea9ebe0ac40f Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 22:25:56 +0200 Subject: [PATCH 09/76] scratchpads: don't fail if proc already removed --- pyprland/plugins/scratchpads.py | 6 ++++-- pyprland/plugins/workspaces_follow_focus.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 608bf88..fd2e1a3 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -290,8 +290,10 @@ class Extension(Plugin): if not item.isAlive(): print(f"{uid} is not running, restarting...") self.procs[uid].kill() - del self.scratches_by_pid[self.procs[uid].pid] - del self.scratches_by_address[item.address] + 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.start_scratch_command(uid) while uid in self._respawned_scratches: await asyncio.sleep(0.05) diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 0a9e2b2..d9f840a 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -47,7 +47,7 @@ class Extension(Plugin): try: idx = available_workspaces.index(cur_workspace) except ValueError: - next_workspace = available_workspaces[0] + next_workspace = available_workspaces[0 if increment > 0 else -1] else: next_workspace = available_workspaces[ (idx + increment) % len(available_workspaces) From 96d5f23ea59bd133878383c7385bbdd24389aa32 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Apr 2023 23:23:39 +0200 Subject: [PATCH 10/76] fix a minor bug --- pyprland/plugins/scratchpads.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index fd2e1a3..ed95b29 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -267,10 +267,11 @@ class Extension(Plugin): if ( animation_type and uid in self.focused_window_tracking ): # focus got lost when animating - if not autohide: + if not autohide and "address" in self.focused_window_tracking[uid]: await hyprctl( f"focuswindow address:{self.focused_window_tracking[uid]['address']}" ) + del self.focused_window_tracking[uid] async def run_show(self, uid, force=False) -> None: """ shows scratchpad "name" """ From 3d790419926f34b0c97ad059f472f308d789e046 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 3 May 2023 22:03:18 +0200 Subject: [PATCH 11/76] Add the shift_monitors plugin --- README.md | 10 ++++++++++ pyprland/plugins/shift_monitors.py | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 pyprland/plugins/shift_monitors.py diff --git a/README.md b/README.md index 4f1f8f5..a86e9d1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following - `lost_windows` brings lost floating windows to the current workspace - `toggle_dpms` toggles the DPMS status of every plugged monitor - `magnify` toggles zooming of viewport or sets a specific scaling factor +- `shift_monitors` adds a self-configured "swapactiveworkspaces" command ## Installation @@ -88,6 +89,15 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of } ``` +# Plugin: `shift_monitors` + +Swaps the workspaces of every screen in the given direction. +Note the behavior can be hard to predict if you have more than 2 monitors, suggestions are welcome. + +### Command + +- `shift_monitors `: swaps every monitor in the given direction + # Plugin: `magnify` ### Command diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py new file mode 100644 index 0000000..caa179d --- /dev/null +++ b/pyprland/plugins/shift_monitors.py @@ -0,0 +1,24 @@ +from .interface import Plugin + +from ..ipc import hyprctlJSON, hyprctl + + +class Extension(Plugin): + async def init(self): + self.monitors = [mon["name"] for mon in await hyprctlJSON("monitors")] + + async def run_shift_monitors(self, arg: str): + direction: int = int(arg) + if direction > 0: + mon_list = self.monitors[:-1] + else: + mon_list = reversed(self.monitors[1:]) + + for i, mon in enumerate(mon_list): + await hyprctl(f"swapactiveworkspaces {mon} {self.monitors[i+direction]}") + + async def event_monitoradded(self, monitor): + self.monitors.append(monitor.strip()) + + async def event_monitorremoved(self, monitor): + self.monitors.remove(monitor.strip()) From b21406371359b3f735f03a0595bf61331674ba87 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 3 May 2023 22:05:27 +0200 Subject: [PATCH 12/76] Add some example --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a86e9d1..d340cb4 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,13 @@ Note the behavior can be hard to predict if you have more than 2 monitors, sugge ### Command -- `shift_monitors `: swaps every monitor in the given direction +- `shift_monitors `: swaps every monitor's workspace in the given direction + +Example usage in `hyprland.conf`: + +``` +bind = $mainMod SHIFT, O, exec, pypr shift_monitors +1 + ``` # Plugin: `magnify` From 12b2c2297d311e9bb6b6322251ee751b417fb1ae Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 3 May 2023 22:06:46 +0200 Subject: [PATCH 13/76] Version 1.3.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8fb5b17..574fc82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.2.1" +version = "1.3.0" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT" From d55b77db1e0e65cf839ef6dee9e7ea5fd0fb9268 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Thu, 4 May 2023 19:41:18 +0200 Subject: [PATCH 14/76] shift_monitors: add a docstring (for -h) --- pyprland/plugins/shift_monitors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index caa179d..f047da3 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -8,6 +8,7 @@ class Extension(Plugin): self.monitors = [mon["name"] for mon in await hyprctlJSON("monitors")] async def run_shift_monitors(self, arg: str): + """Swaps monitors' workspaces in the given direction""" direction: int = int(arg) if direction > 0: mon_list = self.monitors[:-1] From b117037c45ccfc11edd270a2afbd40ea51ca07f7 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Fri, 5 May 2023 17:42:03 +0200 Subject: [PATCH 15/76] monitors: trigger the rules on init & reload --- pyprland/plugins/monitors.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index 74e2dfa..33ba9c6 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -34,10 +34,22 @@ def configure_monitors(monitors, screenid: str, x: int, y: int) -> None: class Extension(Plugin): - async def event_monitoradded(self, screenid): + async def load_config(self, config) -> None: + await super().load_config(config) + monitors = await hyprctlJSON("monitors") + for monitor in monitors: + await self.event_monitoradded( + monitor["name"], noDefault=True, monitors=monitors + ) + + async def event_monitoradded( + self, screenid, noDefault=False, monitors: list | None = None + ) -> None: screenid = screenid.strip() - monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") + if not monitors: + monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") + for mon in monitors: if mon["name"].startswith(screenid): mon_name = mon["description"] @@ -71,6 +83,7 @@ class Extension(Plugin): configure_monitors(monitors, screenid, x, y) return - default_command = self.config.get("unknown") - if default_command: - subprocess.call(default_command, shell=True) + if not noDefault: + default_command = self.config.get("unknown") + if default_command: + subprocess.call(default_command, shell=True) From d340ae00c7c5e29793385c658877af4cdb0364ae Mon Sep 17 00:00:00 2001 From: fdev31 Date: Fri, 5 May 2023 17:43:05 +0200 Subject: [PATCH 16/76] Version 1.3.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 574fc82..e1ddb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.3.0" +version = "1.3.1" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT" From 1ecfa3443ccb2b4f19155a96fe0c74627e228134 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 18:30:36 +0200 Subject: [PATCH 17/76] add the expose plugin --- README.md | 25 +++++++++++++++++++ pyprland/plugins/expose.py | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 pyprland/plugins/expose.py diff --git a/README.md b/README.md index d340cb4..74c071c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following - `scratchpads` implements dropdowns & togglable poppups - `monitors` allows relative placement of monitors depending on the model +- `expose` easily switch between scratchpads and active workspace - `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. - `lost_windows` brings lost floating windows to the current workspace - `toggle_dpms` toggles the DPMS status of every plugged monitor @@ -89,6 +90,30 @@ Create a configuration file in `~/.config/hypr/pyprland.json` enabling a list of } ``` +# Plugin: `expose` + +Moves the focused window to some (hidden) special workspace and back with one command. + +### Command + +- `toggle_minimized [name]`: moves the focused window to the special workspace "name", or move it back to the active workspace. + If none set, special workspace "minimized" will be used. +- `expose`: expose every client on the active workspace. If expose is active restores everything and move to the focused window + +Example usage in `hyprland.conf`: + +``` +bind = $mainMod, N, exec, pypr toggle_minimized + ``` + +### Configuration + + +#### `include_special` (optional, defaults to false) + +Also include windows in the special workspaces during the expose. + + # Plugin: `shift_monitors` Swaps the workspaces of every screen in the given direction. diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py new file mode 100644 index 0000000..52897a7 --- /dev/null +++ b/pyprland/plugins/expose.py @@ -0,0 +1,51 @@ +from typing import Any +from .interface import Plugin + +from ..ipc import hyprctlJSON, hyprctl + + +class Extension(Plugin): + async def init(self) -> None: + self.exposed = False + + async def run_toggle_minimized(self, special_workspace="minimized"): + """[name] Toggles switching the focused window to the special workspace "name" (default: minimized)""" + aw: dict[str, Any] = await hyprctlJSON("activewindow") + wid = aw["workspace"]["id"] + assert isinstance(wid, int) + if wid < 1: # special workspace: unminimize + wrk = await hyprctlJSON("activeworkspace") + await hyprctl(f"togglespecialworkspace {special_workspace}") + await hyprctl(f"movetoworkspacesilent {wrk['id']},address:{aw['address']}") + await hyprctl(f"focuswindow address:{aw['address']}") + else: + await hyprctl( + f"movetoworkspacesilent special:{special_workspace},address:{aw['address']}" + ) + + @property + def exposed_clients(self): + if self.config.get("include_special", False): + return self.exposed + else: + return [c for c in self.exposed if c["workspace"]["id"] > 0] + + async def run_expose(self, arg=""): + """Expose every client on the active workspace. If expose is active restores everything and move to the focused window""" + if self.exposed: + aw: dict[str, Any] = await hyprctlJSON("activewindow") + focused_addr = aw["address"] + for client in self.exposed_clients: + await hyprctl( + f"movetoworkspacesilent {client['workspace']['id']},address:{client['address']}" + ) + await hyprctl("togglespecialworkspace exposed") + await hyprctl(f"focuswindow address:{focused_addr}") + self.exposed = False + else: + self.exposed = await hyprctlJSON("clients") + for client in self.exposed_clients: + await hyprctl( + f"movetoworkspacesilent special:exposed,address:{client['address']}" + ) + await hyprctl("togglespecialworkspace exposed") From 3f17a84ac63a3ddc69e549d2c4fb54b6de05be65 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 18:46:20 +0200 Subject: [PATCH 18/76] Add a changelog --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 74c071c..cdfa0d7 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,50 @@ allow to hide the window when the focus is lost when set to "hide" number of pixels separating the scratchpad from the screen border +# Changelog + +- Add `expose` addon + +## 1.3.1 + +- `monitors` triggers rules on startup (not only when a monitor is plugged) + +## 1.3.0 + +- Add `shift_monitors` addon +- Add `monitors` addon +- scratchpads: more reliable client tracking +- bugfixes + +## 1.2.1 + +- scratchpads have their own special workspaces now +- misc improvements + +## 1.2.0 + +- Add `magnify` addon +- focus fix when closing a scratchpad +- misc improvements + +## 1.1.0 + +- Add `lost_windows` addon +- Add `toggle_dpms` addon +- `workspaces_follow_focus` now requires hyprland 0.25.0 +- misc improvements + +## 1.0.1, 1.0.2 + +- bugfixes & improvements + +## 1.0 + +- First release, a modular hpr-scratcher (`scratchpads` plugin) +- Add `workspaces_follow_focus` addon + + + # Writing plugins You can start enabling a plugin called "experimental" and add code to `plugins/experimental.py`. From d6a519cbb6302f6a239b507ddcba7390bb85be25 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 21:01:22 +0200 Subject: [PATCH 19/76] add a demo video for expose --- README.md | 3 ++- pyprland/command.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cdfa0d7..166b6dc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following - `scratchpads` implements dropdowns & togglable poppups - `monitors` allows relative placement of monitors depending on the model -- `expose` easily switch between scratchpads and active workspace +- `expose` easily switch between scratchpads and active workspace : + [![demo video](https://img.youtube.com/vi/dxH8R2d01o8/0.jpg)](https://www.youtube.com/watch?v=dxH8R2d01o8) - `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. - `lost_windows` brings lost floating windows to the current workspace - `toggle_dpms` toggles the DPMS status of every plugged monitor diff --git a/pyprland/command.py b/pyprland/command.py index 28dde0c..f61ac97 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -86,6 +86,9 @@ class Pyprland: args = args[1:] full_name = f"run_{cmd}" + # Demos: + # run mako for notifications & uncomment this + # os.system(f"notify-send '{data}'") if DEBUG: print(f"CMD: {full_name}({args})") From 26bf6268172e242460ac53b5ef91fd531a26ffdc Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 21:23:09 +0200 Subject: [PATCH 20/76] add videos --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 166b6dc..e7d0724 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,15 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following ## Built-in plugins - `scratchpads` implements dropdowns & togglable poppups + [![demo video](https://img.youtube.com/vi/ZOhv59VYqkc/0.jpg)](https://www.youtube.com/watch?v=ZOhv59VYqkc) - `monitors` allows relative placement of monitors depending on the model - `expose` easily switch between scratchpads and active workspace : - [![demo video](https://img.youtube.com/vi/dxH8R2d01o8/0.jpg)](https://www.youtube.com/watch?v=dxH8R2d01o8) + [![demo video](https://img.youtube.com/vi/ce5HQZ3na8M/0.jpg)](https://www.youtube.com/watch?v=ce5HQZ3na8M) - `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. - `lost_windows` brings lost floating windows to the current workspace - `toggle_dpms` toggles the DPMS status of every plugged monitor - `magnify` toggles zooming of viewport or sets a specific scaling factor + [![demo video](https://img.youtube.com/vi/yN-mhh9aDuo/0.jpg)](https://www.youtube.com/watch?v=yN-mhh9aDuo) - `shift_monitors` adds a self-configured "swapactiveworkspaces" command ## Installation From 5999e8976caf0a4c0fa3ada4ef3cb3ed3a7be9a3 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 21:32:14 +0200 Subject: [PATCH 21/76] Add one more video --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e7d0724..9ac662d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ A single config file `~/.config/hypr/pyprland.json` is used, using the following - `monitors` allows relative placement of monitors depending on the model - `expose` easily switch between scratchpads and active workspace : [![demo video](https://img.youtube.com/vi/ce5HQZ3na8M/0.jpg)](https://www.youtube.com/watch?v=ce5HQZ3na8M) + [![demo video](https://img.youtube.com/vi/BNZCMqkwTOo/0.jpg)](https://www.youtube.com/watch?v=BNZCMqkwTOo) - `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. - `lost_windows` brings lost floating windows to the current workspace - `toggle_dpms` toggles the DPMS status of every plugged monitor From edb9334970d7dd98c8933a68754f0b724286e199 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 16 May 2023 21:37:15 +0200 Subject: [PATCH 22/76] get rid of stale code --- pyprland/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyprland/common.py b/pyprland/common.py index f09cc99..0c6d0f9 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -1,4 +1,3 @@ import os DEBUG = os.environ.get("DEBUG", False) -CONFIG_FILE = os.path.expanduser("~/.config/hypr/scratchpads.json") From 8b42c1dec296854241e024f731e5707cf997b9d5 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Fri, 19 May 2023 18:47:23 +0200 Subject: [PATCH 23/76] Fix old references to hpr-scratcher --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9ac662d..e0cfce8 100644 --- a/README.md +++ b/README.md @@ -271,14 +271,14 @@ Example: In your `hyprland.conf` add something like this: ```ini -exec-once = hpr-scratcher +exec-once = pypr # Repeat this for each scratchpad you need -bind = $mainMod,V,exec,hpr-scratcher toggle volume +bind = $mainMod,V,exec,pypr toggle volume windowrule = float,^(pavucontrol)$ windowrule = workspace special silent,^(pavucontrol)$ -bind = $mainMod,A,exec,hpr-scratcher toggle term +bind = $mainMod,A,exec,pypr toggle term $dropterm = ^(kitty-dropterm)$ windowrule = float,$dropterm windowrule = workspace special silent,$dropterm From 7bcd11b36df6a8d1f81243b042bf909712bffb03 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 12 Jul 2023 19:59:09 +0200 Subject: [PATCH 24/76] update pre-commit hooks --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac2bdc3..555942d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # hooks: # - id: prettier - repo: https://github.com/ambv/black - rev: "23.3.0" + rev: "23.7.0" hooks: - id: black - repo: https://github.com/lovesegfault/beautysh @@ -19,7 +19,7 @@ repos: hooks: - id: beautysh - repo: https://github.com/adrienverge/yamllint - rev: "v1.31.0" + rev: "v1.32.0" hooks: - id: yamllint From 647e8214d92753af1515a9ff99018cdf9d88b021 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 12 Jul 2023 19:59:17 +0200 Subject: [PATCH 25/76] scratchpads: add the "lazy" option (closes #5) --- README.md | 6 +++++- pyprland/plugins/scratchpads.py | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e0cfce8..28f69b4 100644 --- a/README.md +++ b/README.md @@ -320,12 +320,16 @@ number of pixels for the animation. #### `unfocus` (optional) -allow to hide the window when the focus is lost when set to "hide" +when set to `true`, 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 +#### `lazy` (optional) + +when set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead. + # Changelog - Add `expose` addon diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index ed95b29..10418a9 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -147,7 +147,8 @@ class Extension(Plugin): # not known yet for name in new_scratches: - self.start_scratch_command(name) + if not self.scratches[name].conf.get("lazy", False): + self.start_scratch_command(name) def start_scratch_command(self, name: str) -> None: self._respawned_scratches.add(name) @@ -290,7 +291,8 @@ class Extension(Plugin): if not item.isAlive(): print(f"{uid} is not running, restarting...") - self.procs[uid].kill() + 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: From 8b17403e8a8ae0b5f64e7e24641dec989af1a6b3 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 12 Jul 2023 19:59:17 +0200 Subject: [PATCH 26/76] scratchpads: add the "lazy" option (closes #5) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 28f69b4..bce4e32 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ when set to `true`, prevents the command from being started when pypr starts, it # Changelog - Add `expose` addon +- scratchpad: add "lazy" option ## 1.3.1 From 03d6e26c11b22c7f0a48e4dd37936b9e1ee4e786 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 26 Jul 2023 22:22:34 +0200 Subject: [PATCH 27/76] scratchpad: add support for scaled monitors Closes #9 --- pyprland/plugins/scratchpads.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 10418a9..9118f18 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -23,20 +23,23 @@ async def get_client_props_by_address(addr: str): class Animations: @classmethod async def fromtop(cls, monitor, client, client_uid, margin): + scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] - mon_width = monitor["width"] + mon_width = int(monitor["width"] / scale) 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}") @classmethod async def frombottom(cls, monitor, client, client_uid, margin): + scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] - mon_width = monitor["width"] - mon_height = monitor["height"] + mon_width = int(monitor["width"] / scale) + mon_height = int(monitor["height"] / scale) client_width = client["size"][0] client_height = client["size"][1] @@ -47,9 +50,10 @@ class Animations: @classmethod async def fromleft(cls, monitor, client, client_uid, margin): + scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] - mon_height = monitor["height"] + mon_height = int(monitor["height"] / scale) client_height = client["size"][1] margin_y = int((mon_height - client_height) / 2) + mon_y @@ -58,10 +62,11 @@ class Animations: @classmethod async def fromright(cls, monitor, client, client_uid, margin): + scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] - mon_width = monitor["width"] - mon_height = monitor["height"] + mon_width = int(monitor["width"] / scale) + mon_height = int(monitor["height"] / scale) client_width = client["size"][0] client_height = client["size"][1] From f031c68f8885e38094d825798d34e9bbdbb19fae Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 26 Jul 2023 23:01:13 +0200 Subject: [PATCH 28/76] update the changelog --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bce4e32..4b9434e 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ when set to `true`, prevents the command from being started when pypr starts, it - Add `expose` addon - scratchpad: add "lazy" option +- fix `scratchpads`'s position on monitors using scaling ## 1.3.1 From 47ea5db7c6df048d9bf05558added20da8fe4418 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Fri, 28 Jul 2023 21:55:02 +0200 Subject: [PATCH 29/76] Add retry in daemon --- pyprland/command.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyprland/command.py b/pyprland/command.py index f61ac97..9109570 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -4,6 +4,7 @@ import json import sys import os import importlib +import itertools import traceback @@ -113,8 +114,16 @@ class Pyprland: async def run_daemon(): manager = Pyprland() + err_count = itertools.count() manager.server = await asyncio.start_unix_server(manager.read_command, CONTROL) - events_reader, events_writer = await get_event_stream() + try: + events_reader, events_writer = await get_event_stream() + except Exception as e: + print("Failed to get event stream: %s" % e) + if next(err_count) > 10: + raise + await asyncio.sleep(1) + manager.event_reader = events_reader try: From b50f202f1adca4505e786141ed51032d37d1006f Mon Sep 17 00:00:00 2001 From: fdev31 Date: Fri, 28 Jul 2023 22:29:56 +0200 Subject: [PATCH 30/76] move most of the README to the WIKI --- README.md | 358 +----------------------------------------------------- 1 file changed, 1 insertion(+), 357 deletions(-) diff --git a/README.md b/README.md index 4b9434e..ce52de8 100644 --- a/README.md +++ b/README.md @@ -4,331 +4,7 @@ Host process for multiple Hyprland plugins. -- **tool**: `pypr` -- **config file**: `~/.config/hypr/pyprland.json` - -The `pypr` tool only have two built-in commands: - -- `reload` reads the configuration file and attempt to apply the changes -- `--help` lists available commands (including plugins commands) - -Other commands are added by adding 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 - -- `scratchpads` implements dropdowns & togglable poppups - [![demo video](https://img.youtube.com/vi/ZOhv59VYqkc/0.jpg)](https://www.youtube.com/watch?v=ZOhv59VYqkc) -- `monitors` allows relative placement of monitors depending on the model -- `expose` easily switch between scratchpads and active workspace : - [![demo video](https://img.youtube.com/vi/ce5HQZ3na8M/0.jpg)](https://www.youtube.com/watch?v=ce5HQZ3na8M) - [![demo video](https://img.youtube.com/vi/BNZCMqkwTOo/0.jpg)](https://www.youtube.com/watch?v=BNZCMqkwTOo) -- `workspaces_follow_focus` provides commands and handlers allowing a more flexible workspaces usage on multi-monitor setups. If you think the multi-screen behavior of hyprland is not usable or broken/unexpected, this is probably for you. -- `lost_windows` brings lost floating windows to the current workspace -- `toggle_dpms` toggles the DPMS status of every plugged monitor -- `magnify` toggles zooming of viewport or sets a specific scaling factor - [![demo video](https://img.youtube.com/vi/yN-mhh9aDuo/0.jpg)](https://www.youtube.com/watch?v=yN-mhh9aDuo) -- `shift_monitors` adds a self-configured "swapactiveworkspaces" command - -## Installation - -Use the python package manager: - -``` -pip install pyprland -``` - -If you run archlinux, you can also find it on AUR: `yay -S pyprland` - -Don't forget to start the process with hyprland, adding to `hyprland.conf`: - -``` -exec-once = pypr -``` - -## 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", - "animation": "fromTop", - "unfocus": "hide" - }, - "volume": { - "command": "pavucontrol", - "unfocus": "hide", - "animation": "fromRight" - } - }, - "monitors": { - "placement": { - "BenQ PJ": { - "topOf": "eDP-1" - } - } - "unknown": "wlrlui" - } -} -``` - -# Plugin: `expose` - -Moves the focused window to some (hidden) special workspace and back with one command. - -### Command - -- `toggle_minimized [name]`: moves the focused window to the special workspace "name", or move it back to the active workspace. - If none set, special workspace "minimized" will be used. -- `expose`: expose every client on the active workspace. If expose is active restores everything and move to the focused window - -Example usage in `hyprland.conf`: - -``` -bind = $mainMod, N, exec, pypr toggle_minimized - ``` - -### Configuration - - -#### `include_special` (optional, defaults to false) - -Also include windows in the special workspaces during the expose. - - -# Plugin: `shift_monitors` - -Swaps the workspaces of every screen in the given direction. -Note the behavior can be hard to predict if you have more than 2 monitors, suggestions are welcome. - -### Command - -- `shift_monitors `: swaps every monitor's workspace in the given direction - -Example usage in `hyprland.conf`: - -``` -bind = $mainMod SHIFT, O, exec, pypr shift_monitors +1 - ``` - -# Plugin: `magnify` - -### Command - -- `zoom [value]`: if no value, toggles magnification. If an integer is provided, it will set as scaling factor. - -### Configuration - - -#### `factor` (optional, defaults to 2) - -Scaling factor to be used when no value is provided. - -# Plugin: `toggle_dpms` - -### Command - -- `toggle_dpms`: if any screen is powered on, turn them all off, else turn them all on - - -# Plugin: `lost_windows` - -### Command - -- `attract_lost`: brings the lost windows to the current screen / workspace - -# Plugin: `monitors` - -Syntax: -```json -"monitors": { - "placement": { - "": { - "placement type": "" - }, - "unknown": "" - } -} -``` - -Example: -```json -"monitors": { - "unknown": "notify-send 'Unknown monitor'", - "placement": { - "Sony": { - "topOf": "HDMI-1" - } - } -} -``` - -Requires `wlr-randr`. - -Allows relative placement of monitors depending on the model ("description" returned by `hyprctl monitors`). - -### Configuration - - -#### `placement` - -Supported placements are: - -- leftOf -- topOf -- rightOf -- bottomOf - -#### `unknown` (optional) - -If set, runs the associated command for screens which aren't matching any of the provided placements (pattern isn't found in monitor description). - -**Note** this is supposed to be a short lived command which will block the rest of the process until closed. In other words no plugin will be processed while this command remains open. - -# Plugin: `workspaces_follow_focus` - -Make non-visible workspaces follow the focused monitor. -Also provides commands to switch between workspaces wile preserving the current monitor assignments: - -Syntax: -```json -"workspaces_follow_focus": { - "max_workspaces": -} -``` - -### Command - -- `change_workspace` ``: 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`. - -# Plugin: `scratchpads` - -Defines commands that should run in dropdowns. Successor of [hpr-scratcher](https://github.com/hyprland-community/hpr-scratcher), it's fully compatible, just put the configuration under "scratchpads". - -Syntax: -```json -"scratchpads": { - "scratchpad name": { - "command": "command to run" - } -} -``` - -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 - -Example: -```json -"scratchpads": { - "term": { - "command": "kitty --class kitty-dropterm", - "animation": "fromTop", - "margin": 50, - "unfocus": "hide" - }, - "volume": { - "command": "pavucontrol", - "animation": "fromRight" - } -} -``` - -In your `hyprland.conf` add something like this: - -```ini -exec-once = pypr - -# Repeat this for each scratchpad you need -bind = $mainMod,V,exec,pypr toggle volume -windowrule = float,^(pavucontrol)$ -windowrule = workspace special silent,^(pavucontrol)$ - -bind = $mainMod,A,exec,pypr toggle term -$dropterm = ^(kitty-dropterm)$ -windowrule = float,$dropterm -windowrule = workspace special silent,$dropterm -windowrule = size 75% 60%,$dropterm -``` - -And you'll be able to toggle pavucontrol with MOD + V. - -### Commands - -- `toggle ` : toggle the given scratchpad -- `show ` : show the given scratchpad -- `hide ` : hide the given scratchpad - -Note: with no argument it runs the daemon (doesn't fork in the background) - - - -### Configuration - -#### `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` (optional) - -Type of animation to use - -- `null` / `""` / not defined (no animation) -- "fromTop" (stays close to top screen border) -- "fromBottom" (stays close to bottom screen border) -- "fromLeft" (stays close to left screen border) -- "fromRight" (stays close to right screen border) - -#### `offset` (optional) - -number of pixels for the animation. - -#### `unfocus` (optional) - -when set to `true`, 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 - -#### `lazy` (optional) - -when set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead. +Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more information. # Changelog @@ -374,35 +50,3 @@ when set to `true`, prevents the command from being started when pypr starts, it - First release, a modular hpr-scratcher (`scratchpads` plugin) - Add `workspaces_follow_focus` addon - - -# Writing plugins - -You can start enabling a plugin called "experimental" and add code to `plugins/experimental.py`. -A better way is to copy this as a starting point and make your own python module. -Plugins can be loaded with full python module path, eg: `"mymodule.pyprlandplugin"`, the loaded module must provide an `Extension` interface. - -Check the `interface.py` file to know the base methods, also have a look at the other plugins for working examples. - -To get more details when an error is occurring, `export DEBUG=1` in your shell before running. - -## Creating a command - -Just add a method called `run_`, eg with "togglezoom" command: - -```python -async def init(self): - self.zoomed = False - -async def run_togglezoom(self, args): - if self.zoomed: - await hyprctl('misc:cursor_zoom_factor 1', 'keyword') - else: - await hyprctl('misc:cursor_zoom_factor 2', 'keyword') - self.zoomed = not self.zoomed -``` - -## Reacting to an event - -Similar as a command, implement some `event_` method. - From 9d0dc6df793f43b8e11359652a7bd56de2143eec Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 14:33:54 +0200 Subject: [PATCH 31/76] improve error handling & logging --- README.md | 1 + pyprland/command.py | 112 +++++++++++++++++++++----------- pyprland/common.py | 61 +++++++++++++++++ pyprland/ipc.py | 35 ++++++---- pyprland/plugins/interface.py | 2 + pyprland/plugins/monitors.py | 2 +- pyprland/plugins/scratchpads.py | 12 ++-- 7 files changed, 170 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ce52de8..3ad5ae0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more i - Add `expose` addon - scratchpad: add "lazy" option - fix `scratchpads`'s position on monitors using scaling +- improve error handling & logging, enable debug logs with `--debug ` ## 1.3.1 diff --git a/pyprland/command.py b/pyprland/command.py index 9109570..e36a61a 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -5,11 +5,10 @@ import sys import os import importlib import itertools -import traceback -from .ipc import get_event_stream -from .common import DEBUG +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' @@ -25,11 +24,19 @@ class Pyprland: def __init__(self): self.plugins: dict[str, Plugin] = {} + self.log = get_logger() async def load_config(self, init=True): - self.config = json.loads( - open(os.path.expanduser(CONFIG_FILE), encoding="utf-8").read() - ) + try: + self.config = json.loads( + open(os.path.expanduser(CONFIG_FILE), encoding="utf-8").read() + ) + except FileNotFoundError as e: + self.log.critical( + "No config file found, create one at ~/.config/hypr/pyprland.json with a valid pyprland.plugins list" + ) + raise PyprError() + for name in self.config["pyprland"]["plugins"]: if name not in self.plugins: modname = name if "." in name else f"pyprland.plugins.{name}" @@ -39,11 +46,16 @@ class Pyprland: await plug.init() self.plugins[name] = plug except Exception as e: - print(f"Error loading plugin {name}: {e}") - if DEBUG: - traceback.print_exc() + self.log.error(f"Error loading plugin {name}:", exc_info=True) + raise PyprError() if init: - await self.plugins[name].load_config(self.config) + try: + await self.plugins[name].load_config(self.config) + except PyprError: + raise + except Exception as e: + self.log.error(f"Error initializing plugin {name}:", exc_info=True) + raise PyprError() async def _callHandler(self, full_name, *params): for plugin in [self] + list(self.plugins.values()): @@ -51,26 +63,25 @@ class Pyprland: try: await getattr(plugin, full_name)(*params) except Exception as e: - print(f"{plugin.name}::{full_name}({params}) failed:") - traceback.print_exc() + self.log.warn(f"{plugin.name}::{full_name}({params}) failed:") + self.log.exception(e) async def read_events_loop(self): while not self.stopped: data = (await self.event_reader.readline()).decode() if not data: - print("Reader starved") + self.log.critical("Reader starved") return cmd, params = data.split(">>") full_name = f"event_{cmd}" - if DEBUG: - print(f"EVT {full_name}({params.strip()})") + self.log.debug(f"EVT {full_name}({params.strip()})") await self._callHandler(full_name, params) async def read_command(self, reader, writer) -> None: data = (await reader.readline()).decode() if not data: - print("Server starved") + self.log.critical("Server starved") return if data == "exit\n": self.stopped = True @@ -91,8 +102,7 @@ class Pyprland: # run mako for notifications & uncomment this # os.system(f"notify-send '{data}'") - if DEBUG: - print(f"CMD: {full_name}({args})") + self.log.debug(f"CMD: {full_name}({args})") await self._callHandler(full_name, *args) @@ -116,22 +126,31 @@ async def run_daemon(): manager = Pyprland() err_count = itertools.count() manager.server = await asyncio.start_unix_server(manager.read_command, CONTROL) - try: - events_reader, events_writer = await get_event_stream() - except Exception as e: - print("Failed to get event stream: %s" % e) - if next(err_count) > 10: - raise - await asyncio.sleep(1) + max_retry = 10 + while True: + attempt = next(err_count) + try: + events_reader, events_writer = await get_event_stream() + except Exception as e: + if attempt > max_retry: + manager.log.critical(f"Failed to open hyprland event stream: {e}.") + raise PyprError() + else: + manager.log.warn( + f"Failed to get event stream: {e}, retry {attempt}/{max_retry}..." + ) + await asyncio.sleep(1) + else: + break 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" - ) + except PyprError: + raise SystemExit(1) + except Exception as e: + manager.log.critical(f"Failed to load config.") raise SystemExit(1) try: @@ -139,7 +158,7 @@ async def run_daemon(): except KeyboardInterrupt: print("Interrupted") except asyncio.CancelledError: - print("Bye!") + manager.log.critical("cancelled") finally: events_writer.close() await events_writer.wait_closed() @@ -148,8 +167,8 @@ async def run_daemon(): async def run_client(): - if sys.argv[1] in ("--help", "-h"): - manager = Pyprland() + manager = Pyprland() + if sys.argv[1] in ("--help", "-h", "help"): await manager.load_config(init=False) print( """Syntax: pypr [command] @@ -171,18 +190,37 @@ Commands: return - _, writer = await asyncio.open_unix_connection(CONTROL) - writer.write((" ".join(sys.argv[1:])).encode()) - await writer.drain() - writer.close() - await writer.wait_closed() + try: + _, writer = await asyncio.open_unix_connection(CONTROL) + except FileNotFoundError: + manager.log.critical("Failed to open control socket, is pypr daemon running ?") + raise PyprError() + else: + writer.write((" ".join(sys.argv[1:])).encode()) + await writer.drain() + writer.close() + await writer.wait_closed() def main(): + if "--debug" in sys.argv: + i = sys.argv.index("--debug") + init_logger(filename=sys.argv[i + 1], force_debug=True) + del sys.argv[i : i + 2] + else: + init_logger() + ipc_init() + log = get_logger("startup") try: asyncio.run(run_daemon() if len(sys.argv) <= 1 else run_client()) except KeyboardInterrupt: pass + except PyprError as e: + log.critical(f"Command failed.") + except json.decoder.JSONDecodeError as e: + log.critical(f"Invalid JSON syntax in the config file: {e.args[0]}") + except Exception as e: + log.critical("Unhandled exception:", exc_info=True) if __name__ == "__main__": diff --git a/pyprland/common.py b/pyprland/common.py index 0c6d0f9..1d22a2d 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -1,3 +1,64 @@ import os +import logging + +__all__ = ["DEBUG", "get_logger", "init_logger"] DEBUG = os.environ.get("DEBUG", False) + + +class PyprError(Exception): + pass + + +class LogObjects: + handlers: list[logging.Handler] = [] + + +def init_logger(filename=None, force_debug=False): + global DEBUG + if force_debug: + DEBUG = True + + class ScreenLogFormatter(logging.Formatter): + LOG_FORMAT = ( + r"%(levelname)s:%(name)s - %(message)s // %(filename)s:%(lineno)d" + if DEBUG + else r"%(levelname)s: %(message)s" + ) + RESET_ANSI = "\x1b[0m" + + FORMATTERS = { + logging.DEBUG: logging.Formatter(LOG_FORMAT + RESET_ANSI), + logging.INFO: logging.Formatter(LOG_FORMAT + RESET_ANSI), + logging.WARNING: logging.Formatter("\x1b[33;20m" + LOG_FORMAT + RESET_ANSI), + logging.ERROR: logging.Formatter("\x1b[31;20m" + LOG_FORMAT + RESET_ANSI), + logging.CRITICAL: logging.Formatter("\x1b[31;1m" + LOG_FORMAT + RESET_ANSI), + } + + def format(self, record): + return self.FORMATTERS[record.levelno].format(record) + + logging.basicConfig() + if filename: + handler = logging.FileHandler(filename) + handler.setFormatter( + logging.Formatter( + fmt=r"%(asctime)s [%(levelname)s] %(name)s :: %(message)s :: %(filename)s:%(lineno)d" + ) + ) + LogObjects.handlers.append(handler) + handler = logging.StreamHandler() + handler.setFormatter(ScreenLogFormatter()) + LogObjects.handlers.append(handler) + + +def get_logger(name="pypr", level=None): + logger = logging.getLogger(name) + if level is None: + logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + else: + logger.setLevel(level) + logger.propagate = False + for handler in LogObjects.handlers: + logger.addHandler(handler) + return logger diff --git a/pyprland/ipc.py b/pyprland/ipc.py index 5094231..46eed0a 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -1,11 +1,13 @@ #!/bin/env python import asyncio +from logging import Logger from typing import Any import json import os -from .common import DEBUG +from .common import get_logger, PyprError +log: Logger = None HYPRCTL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket.sock' EVENTS = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket2.sock' @@ -17,9 +19,12 @@ async def get_event_stream(): async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]: """Run an IPC command and return the JSON output.""" - if DEBUG: - print("(JS)>>>", command) - ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) + log.debug(f"JS>> {command}") + try: + ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) + except FileNotFoundError: + log.critical("hyprctl socket not found! is it running ?") + raise PyprError() ctl_writer.write(f"-j/{command}".encode()) await ctl_writer.drain() resp = await ctl_reader.read() @@ -40,9 +45,13 @@ def _format_command(command_list, default_base_command): async def hyprctl(command, base_command="dispatch") -> bool: """Run an IPC command. Returns success value.""" - if DEBUG: - print(">>>", command) - ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) + log.debug(f"JS>> {command}") + try: + ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) + except FileNotFoundError: + log.critical("hyprctl socket not found! is it running ?") + raise PyprError() + if isinstance(command, list): ctl_writer.write( f"[[BATCH]] {' ; '.join(_format_command(command, base_command))}".encode() @@ -53,11 +62,10 @@ async def hyprctl(command, base_command="dispatch") -> bool: resp = await ctl_reader.read(100) ctl_writer.close() await ctl_writer.wait_closed() - if DEBUG: - print("<<<", resp) + log.debug(f"< dict[str, Any]: if monitor.get("focused") == True: return monitor raise RuntimeError("no focused monitor") + + +def init(): + global log + log = get_logger("ipc") diff --git a/pyprland/plugins/interface.py b/pyprland/plugins/interface.py index fb31597..e7e1d60 100644 --- a/pyprland/plugins/interface.py +++ b/pyprland/plugins/interface.py @@ -1,9 +1,11 @@ from typing import Any +from ..common import get_logger class Plugin: def __init__(self, name: str): self.name = name + self.log = get_logger(name) async def init(self): pass diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index 33ba9c6..f23a84f 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -55,7 +55,7 @@ class Extension(Plugin): mon_name = mon["description"] break else: - print(f"Monitor {screenid} not found") + self.log.info(f"Monitor {screenid} not found") return mon_by_name = {m["name"]: m for m in monitors} diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 9118f18..3eead9f 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -207,7 +207,7 @@ class Extension(Plugin): uid = uid.strip() item = self.scratches.get(uid) if not item: - print(f"{uid} is not configured") + self.log.warn(f"{uid} is not configured") return if item.visible: await self.run_hide(uid) @@ -238,10 +238,10 @@ class Extension(Plugin): uid = uid.strip() item = self.scratches.get(uid) if not item: - print(f"{uid} is not configured") + self.log.warn(f"{uid} is not configured") return if not item.visible and not force: - print(f"{uid} is already hidden") + self.log.warn(f"{uid} is already hidden") return item.visible = False addr = "address:0x" + item.address @@ -287,15 +287,15 @@ class Extension(Plugin): self.focused_window_tracking[uid] = await hyprctlJSON("activewindow") if not item: - print(f"{uid} is not configured") + self.log.warn(f"{uid} is not configured") return if item.visible and not force: - print(f"{uid} is already visible") + self.log.warn(f"{uid} is already visible") return if not item.isAlive(): - print(f"{uid} is not running, restarting...") + self.log.info(f"{uid} is not running, restarting...") if uid in self.procs: self.procs[uid].kill() if item.pid in self.scratches_by_pid: From dd335574d98e4b7721489a7ce9c4431276aa8158 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 16:13:17 +0200 Subject: [PATCH 32/76] add some logs --- pyprland/common.py | 1 + pyprland/plugins/scratchpads.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pyprland/common.py b/pyprland/common.py index 1d22a2d..319ae6e 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -61,4 +61,5 @@ def get_logger(name="pypr", level=None): logger.propagate = False for handler in LogObjects.handlers: logger.addHandler(handler) + logger.debug(f"Logger initialized for {name}") return logger diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 3eead9f..cc1cceb 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -110,6 +110,9 @@ class Scratch: assert isinstance(clientInfo, dict) self.clientInfo.update(clientInfo) + def __str__(self): + return f"{self.uid} {self.address} : {self.clientInfo} / {self.conf}" + class Extension(Plugin): async def init(self) -> None: @@ -125,7 +128,7 @@ class Extension(Plugin): async def die_in_piece(scratch: Scratch): proc = self.procs[scratch.uid] proc.terminate() - for n in range(10): + for _ in range(10): if not scratch.isAlive(): break await asyncio.sleep(0.1) @@ -243,6 +246,7 @@ class Extension(Plugin): if not item.visible and not force: self.log.warn(f"{uid} is already hidden") return + self.log.info(f"Hiding {uid}") item.visible = False addr = "address:0x" + item.address animation_type: str = item.conf.get("animation", "").lower() @@ -294,6 +298,8 @@ class Extension(Plugin): self.log.warn(f"{uid} is already visible") return + self.log.info(f"Showing {uid}") + if not item.isAlive(): self.log.info(f"{uid} is not running, restarting...") if uid in self.procs: From 4f34f0826cbb5acfb795eb1c5077b018c6a1c5cf Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 16:19:19 +0200 Subject: [PATCH 33/76] Add some class-based lookup for scratchpads --- pyprland/plugins/scratchpads.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index cc1cceb..3022d58 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -156,9 +156,9 @@ class Extension(Plugin): # not known yet for name in new_scratches: if not self.scratches[name].conf.get("lazy", False): - self.start_scratch_command(name) + await self.start_scratch_command(name) - def start_scratch_command(self, name: str) -> None: + async def start_scratch_command(self, name: str) -> None: self._respawned_scratches.add(name) scratch = self.scratches[name] old_pid = self.procs[name].pid if name in self.procs else 0 @@ -198,7 +198,25 @@ class Extension(Plugin): if wrkspc.startswith("special"): item = self.scratches_by_address.get(addr) if not item and self._respawned_scratches: - await self.updateScratchInfo() + # XXX: hack for windows which aren't related to the process + class_lookup_hack = [ + self.scratches[name] + for name in self._respawned_scratches + if self.scratches[name].conf.get("class") + ] + if class_lookup_hack: + self.log.debug("Lookup hack triggered") + for client in await hyprctlJSON("clients"): + assert isinstance(client, dict) + for pending_scratch in class_lookup_hack: + if pending_scratch.conf["class"] == client["class"]: + self.scratches_by_address[ + client["address"][2:] + ] = pending_scratch + self.log.debug(f"client class found: {client}") + await pending_scratch.updateClientInfo(client) + else: + await self.updateScratchInfo() item = self.scratches_by_address.get(addr) if item and item.just_created: self._respawned_scratches.discard(item.uid) @@ -308,7 +326,7 @@ class Extension(Plugin): del self.scratches_by_pid[item.pid] if item.address in self.scratches_by_address: del self.scratches_by_address[item.address] - self.start_scratch_command(uid) + await self.start_scratch_command(uid) while uid in self._respawned_scratches: await asyncio.sleep(0.05) From 258f2e0988aaaba237c5bf0446560992b70e2b35 Mon Sep 17 00:00:00 2001 From: Fabien Devaux Date: Sat, 29 Jul 2023 16:37:15 +0200 Subject: [PATCH 34/76] Create pylint.yml --- .github/workflows/pylint.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/pylint.yml diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..5abdb30 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,23 @@ +name: Pylint + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') From 14d8fb449f636f80d4b7237914325843c5d583f2 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 17:47:52 +0200 Subject: [PATCH 35/76] linting --- pyprland/command.py | 82 ++++++++++-------- pyprland/common.py | 10 ++- pyprland/ipc.py | 23 +++-- pyprland/plugins/experimental.py | 5 +- pyprland/plugins/expose.py | 5 +- pyprland/plugins/interface.py | 10 ++- pyprland/plugins/lost_windows.py | 2 +- pyprland/plugins/magnify.py | 5 +- pyprland/plugins/monitors.py | 15 ++-- pyprland/plugins/scratchpads.py | 93 +++++++++++++-------- pyprland/plugins/shift_monitors.py | 2 + pyprland/plugins/workspaces_follow_focus.py | 8 +- 12 files changed, 161 insertions(+), 99 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index e36a61a..621d259 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -1,4 +1,5 @@ #!/bin/env python +" Pyprland - an Hyprland companion app " import asyncio import json import sys @@ -17,25 +18,29 @@ CONFIG_FILE = "~/.config/hypr/pyprland.json" class Pyprland: + "Main app object" server: asyncio.Server event_reader: asyncio.StreamReader stopped = False name = "builtin" + config: dict[str, dict] = None def __init__(self): self.plugins: dict[str, Plugin] = {} self.log = get_logger() async def load_config(self, init=True): + """Loads the configuration + + if `init` is true, also initializes the plugins""" try: - self.config = json.loads( - open(os.path.expanduser(CONFIG_FILE), encoding="utf-8").read() - ) + with open(os.path.expanduser(CONFIG_FILE), encoding="utf-8") as f: + self.config = json.loads(f.read()) except FileNotFoundError as e: self.log.critical( "No config file found, create one at ~/.config/hypr/pyprland.json with a valid pyprland.plugins list" ) - raise PyprError() + raise PyprError() from e for name in self.config["pyprland"]["plugins"]: if name not in self.plugins: @@ -46,27 +51,31 @@ class Pyprland: await plug.init() self.plugins[name] = plug except Exception as e: - self.log.error(f"Error loading plugin {name}:", exc_info=True) - raise PyprError() + self.log.error("Error loading plugin %s:", name, exc_info=True) + raise PyprError() from e if init: try: await self.plugins[name].load_config(self.config) except PyprError: raise except Exception as e: - self.log.error(f"Error initializing plugin {name}:", exc_info=True) - raise PyprError() + self.log.error("Error initializing plugin %s:", name, exc_info=True) + raise PyprError() from e async def _callHandler(self, full_name, *params): + "Call an event handler with 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: - self.log.warn(f"{plugin.name}::{full_name}({params}) failed:") + except Exception as e: # pylint: disable=W0718 + self.log.warning( + "%s::%s(%s) failed:", plugin.name, full_name, params + ) self.log.exception(e) 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() if not data: @@ -75,10 +84,11 @@ class Pyprland: cmd, params = data.split(">>") full_name = f"event_{cmd}" - self.log.debug(f"EVT {full_name}({params.strip()})") + self.log.debug("EVT %s(%s)", full_name, params.strip()) await self._callHandler(full_name, params) async def read_command(self, reader, writer) -> None: + "Receives a socket command" data = (await reader.readline()).decode() if not data: self.log.critical("Server starved") @@ -102,11 +112,12 @@ class Pyprland: # run mako for notifications & uncomment this # os.system(f"notify-send '{data}'") - self.log.debug(f"CMD: {full_name}({args})") + self.log.debug("CMD: %s(%s)", full_name, args) await self._callHandler(full_name, *args) async def serve(self): + "Runs the server" try: async with self.server: await self.server.serve_forever() @@ -114,6 +125,7 @@ class Pyprland: await asyncio.gather(*(plugin.exit() for plugin in self.plugins.values())) async def run(self): + "Runs the server and the event listener" await asyncio.gather( asyncio.create_task(self.serve()), asyncio.create_task(self.read_events_loop()), @@ -123,6 +135,7 @@ class Pyprland: async def run_daemon(): + "Runs the server / daemon" manager = Pyprland() err_count = itertools.count() manager.server = await asyncio.start_unix_server(manager.read_command, CONTROL) @@ -131,14 +144,13 @@ async def run_daemon(): attempt = next(err_count) try: events_reader, events_writer = await get_event_stream() - except Exception as e: + except Exception as e: # pylint: disable=W0718 if attempt > max_retry: - manager.log.critical(f"Failed to open hyprland event stream: {e}.") - raise PyprError() - else: - manager.log.warn( - f"Failed to get event stream: {e}, retry {attempt}/{max_retry}..." - ) + manager.log.critical("Failed to open hyprland event stream: %s.", e) + raise PyprError() from e + manager.log.warning( + "Failed to get event stream: %s}, retry %s/%s...", e, attempt, max_retry + ) await asyncio.sleep(1) else: break @@ -147,11 +159,11 @@ async def run_daemon(): try: await manager.load_config() # ensure sockets are connected first - except PyprError: - raise SystemExit(1) + except PyprError as e: + raise SystemExit(1) from e except Exception as e: - manager.log.critical(f"Failed to load config.") - raise SystemExit(1) + manager.log.critical("Failed to load config.") + raise SystemExit(1) from e try: await manager.run() @@ -167,6 +179,7 @@ async def run_daemon(): async def run_client(): + "Runs the client (CLI)" manager = Pyprland() if sys.argv[1] in ("--help", "-h", "help"): await manager.load_config(init=False) @@ -192,17 +205,18 @@ Commands: try: _, writer = await asyncio.open_unix_connection(CONTROL) - except FileNotFoundError: + except FileNotFoundError as e: manager.log.critical("Failed to open control socket, is pypr daemon running ?") - raise PyprError() - else: - writer.write((" ".join(sys.argv[1:])).encode()) - await writer.drain() - writer.close() - await writer.wait_closed() + raise PyprError() from e + + writer.write((" ".join(sys.argv[1:])).encode()) + await writer.drain() + writer.close() + await writer.wait_closed() def main(): + "runs the command" if "--debug" in sys.argv: i = sys.argv.index("--debug") init_logger(filename=sys.argv[i + 1], force_debug=True) @@ -215,11 +229,11 @@ def main(): asyncio.run(run_daemon() if len(sys.argv) <= 1 else run_client()) except KeyboardInterrupt: pass - except PyprError as e: - log.critical(f"Command failed.") + except PyprError: + log.critical("Command failed.") except json.decoder.JSONDecodeError as e: - log.critical(f"Invalid JSON syntax in the config file: {e.args[0]}") - except Exception as e: + log.critical("Invalid JSON syntax in the config file: %s", e.args[0]) + except Exception: # pylint: disable=W0718 log.critical("Unhandled exception:", exc_info=True) diff --git a/pyprland/common.py b/pyprland/common.py index 319ae6e..af2546e 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -1,3 +1,4 @@ +""" Shared utilities: logging """ import os import logging @@ -7,19 +8,23 @@ DEBUG = os.environ.get("DEBUG", False) class PyprError(Exception): - pass + """Used for errors which already triggered logging""" class LogObjects: + """Reusable objects for loggers""" + handlers: list[logging.Handler] = [] def init_logger(filename=None, force_debug=False): + """initializes the logging system""" global DEBUG if force_debug: DEBUG = True class ScreenLogFormatter(logging.Formatter): + "A custom formatter, adding colors" LOG_FORMAT = ( r"%(levelname)s:%(name)s - %(message)s // %(filename)s:%(lineno)d" if DEBUG @@ -53,6 +58,7 @@ def init_logger(filename=None, force_debug=False): def get_logger(name="pypr", level=None): + "Returns a logger for `name`" logger = logging.getLogger(name) if level is None: logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) @@ -61,5 +67,5 @@ def get_logger(name="pypr", level=None): logger.propagate = False for handler in LogObjects.handlers: logger.addHandler(handler) - logger.debug(f"Logger initialized for {name}") + logger.debug("Logger initialized for %s", name) return logger diff --git a/pyprland/ipc.py b/pyprland/ipc.py index 46eed0a..78ef791 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -1,4 +1,5 @@ #!/bin/env python +""" Interact with hyprland using sockets """ import asyncio from logging import Logger from typing import Any @@ -14,17 +15,18 @@ EVENTS = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket2.sock async def get_event_stream(): + "Returns a new event socket connection" return await asyncio.open_unix_connection(EVENTS) async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]: """Run an IPC command and return the JSON output.""" - log.debug(f"JS>> {command}") + log.debug("JS>> %s", command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) - except FileNotFoundError: + except FileNotFoundError as e: log.critical("hyprctl socket not found! is it running ?") - raise PyprError() + raise PyprError() from e ctl_writer.write(f"-j/{command}".encode()) await ctl_writer.drain() resp = await ctl_reader.read() @@ -36,6 +38,7 @@ async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]: def _format_command(command_list, default_base_command): + "helper function to format BATCH commands" for command in command_list: if isinstance(command, str): yield f"{default_base_command} {command}" @@ -45,12 +48,12 @@ def _format_command(command_list, default_base_command): async def hyprctl(command, base_command="dispatch") -> bool: """Run an IPC command. Returns success value.""" - log.debug(f"JS>> {command}") + log.debug("JS>> %s", command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) - except FileNotFoundError: + except FileNotFoundError as e: log.critical("hyprctl socket not found! is it running ?") - raise PyprError() + raise PyprError() from e if isinstance(command, list): ctl_writer.write( @@ -62,21 +65,23 @@ async def hyprctl(command, base_command="dispatch") -> bool: resp = await ctl_reader.read(100) ctl_writer.close() await ctl_writer.wait_closed() - log.debug(f"< dict[str, Any]: + "Returns focused monitor data" for monitor in await hyprctlJSON("monitors"): assert isinstance(monitor, dict) - if monitor.get("focused") == True: + if monitor.get("focused"): return monitor raise RuntimeError("no focused monitor") def init(): + "initialize logging" global log log = get_logger("ipc") diff --git a/pyprland/plugins/experimental.py b/pyprland/plugins/experimental.py index 02d6f39..b7cd2ab 100644 --- a/pyprland/plugins/experimental.py +++ b/pyprland/plugins/experimental.py @@ -1,7 +1,8 @@ +" Plugin template " from .interface import Plugin -from ..ipc import hyprctlJSON, hyprctl +# from ..ipc import hyprctlJSON, hyprctl class Extension(Plugin): - pass + "Sample plugin template" diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index 52897a7..b4c086f 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -5,8 +5,7 @@ from ..ipc import hyprctlJSON, hyprctl class Extension(Plugin): - async def init(self) -> None: - self.exposed = False + exposed = False async def run_toggle_minimized(self, special_workspace="minimized"): """[name] Toggles switching the focused window to the special workspace "name" (default: minimized)""" @@ -30,7 +29,7 @@ class Extension(Plugin): else: return [c for c in self.exposed if c["workspace"]["id"] > 0] - async def run_expose(self, arg=""): + async def run_expose(self): """Expose every client on the active workspace. If expose is active restores everything and move to the focused window""" if self.exposed: aw: dict[str, Any] = await hyprctlJSON("activewindow") diff --git a/pyprland/plugins/interface.py b/pyprland/plugins/interface.py index e7e1d60..b48dc19 100644 --- a/pyprland/plugins/interface.py +++ b/pyprland/plugins/interface.py @@ -1,19 +1,25 @@ +" Common plugin interface " from typing import Any from ..common import get_logger class Plugin: + "Base plugin class, handles logger and config" + def __init__(self, name: str): + "create a new plugin `name` and the matching logger" self.name = name self.log = get_logger(name) + self.config: dict[str, Any] = {} async def init(self): - pass + "empty init function" async def exit(self): - return + "empty exit function" async def load_config(self, config: dict[str, Any]): + "Loads the configuration section from the passed `config`" try: self.config = config[self.name] except KeyError: diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index 3f115f3..d6c5a54 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -18,7 +18,7 @@ def contains(monitor, window): class Extension(Plugin): - async def run_attract_lost(self, *args): + async def run_attract_lost(self): """Brings lost floating windows to the current workspace""" monitors = await hyprctlJSON("monitors") windows = await hyprctlJSON("clients") diff --git a/pyprland/plugins/magnify.py b/pyprland/plugins/magnify.py index f8052aa..2589614 100644 --- a/pyprland/plugins/magnify.py +++ b/pyprland/plugins/magnify.py @@ -1,11 +1,10 @@ from .interface import Plugin -from ..ipc import hyprctlJSON, hyprctl +from ..ipc import hyprctl class Extension(Plugin): - async def init(self): - self.zoomed = False + zoomed = False async def run_zoom(self, *args): """[factor] zooms to "factor" or toggles zoom level ommited""" diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index f23a84f..fa6cc86 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -1,11 +1,13 @@ +" The monitors plugin " +import subprocess from typing import Any from .interface import Plugin -import subprocess from ..ipc import hyprctlJSON def configure_monitors(monitors, screenid: str, x: int, y: int) -> None: + "Apply the configuration change" x_offset = -x if x < 0 else 0 y_offset = -y if y < 0 else 0 @@ -33,18 +35,19 @@ def configure_monitors(monitors, screenid: str, x: int, y: int) -> None: subprocess.call(command) -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring async def load_config(self, config) -> None: await super().load_config(config) monitors = await hyprctlJSON("monitors") for monitor in monitors: await self.event_monitoradded( - monitor["name"], noDefault=True, monitors=monitors + monitor["name"], no_default=True, monitors=monitors ) async def event_monitoradded( - self, screenid, noDefault=False, monitors: list | None = None + self, screenid, no_default=False, monitors: list | None = None ) -> None: + "Triggers when a monitor is plugged" screenid = screenid.strip() if not monitors: @@ -55,7 +58,7 @@ class Extension(Plugin): mon_name = mon["description"] break else: - self.log.info(f"Monitor {screenid} not found") + self.log.info("Monitor %s not found", screenid) return mon_by_name = {m["name"]: m for m in monitors} @@ -83,7 +86,7 @@ class Extension(Plugin): configure_monitors(monitors, screenid, x, y) return - if not noDefault: + if not no_default: default_command = self.config.get("unknown") if default_command: subprocess.call(default_command, shell=True) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 3022d58..c93f689 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -1,12 +1,14 @@ +" Scratchpads addon " +import os +import asyncio import subprocess from typing import Any -import asyncio + from ..ipc import ( hyprctl, hyprctlJSON, get_focused_monitor_props, ) -import os from .interface import Plugin @@ -14,6 +16,7 @@ DEFAULT_MARGIN = 60 async def get_client_props_by_address(addr: str): + "Returns client properties given its address" for client in await hyprctlJSON("clients"): assert isinstance(client, dict) if client.get("address") == addr: @@ -21,8 +24,11 @@ async def get_client_props_by_address(addr: str): class Animations: + "Animation store" + @classmethod async def fromtop(cls, monitor, client, client_uid, margin): + "Slide from/to top" scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] @@ -35,6 +41,7 @@ class Animations: @classmethod async def frombottom(cls, monitor, client, client_uid, margin): + "Slide from/to bottom" scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] @@ -50,6 +57,7 @@ class Animations: @classmethod async def fromleft(cls, monitor, client, client_uid, margin): + "Slide from/to left" scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] @@ -62,6 +70,7 @@ class Animations: @classmethod async def fromright(cls, monitor, client, client_uid, margin): + "Slide from/to right" scale = float(monitor["scale"]) mon_x = monitor["x"] mon_y = monitor["y"] @@ -77,54 +86,62 @@ class Animations: class Scratch: + "A scratchpad state including configuration & client state" + def __init__(self, uid, opts): self.uid = uid self.pid = 0 self.conf = opts self.visible = False self.just_created = True - self.clientInfo = {} + self.client_info = {} def isAlive(self) -> bool: + "is the process running ?" 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)" + with open(os.path.join(path, "status"), "r", encoding="utf-8") as f: + for line in f.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) -> None: + "clear the object" self.pid = pid self.visible = False self.just_created = True - self.clientInfo = {} + self.client_info = {} @property def address(self) -> str: - return str(self.clientInfo.get("address", ""))[2:] + "Returns the client address" + return str(self.client_info.get("address", ""))[2:] async def updateClientInfo(self, clientInfo=None) -> None: + "update the internal client info property, if not provided, refresh based on the current address" if clientInfo is None: clientInfo = await get_client_props_by_address("0x" + self.address) assert isinstance(clientInfo, dict) - self.clientInfo.update(clientInfo) + self.client_info.update(clientInfo) def __str__(self): - return f"{self.uid} {self.address} : {self.clientInfo} / {self.conf}" + return f"{self.uid} {self.address} : {self.client_info} / {self.conf}" class Extension(Plugin): - async def init(self) -> None: - 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] = {} - self.focused_window_tracking = dict() + procs: dict[str, subprocess.Popen] = {} + scratches: dict[str, Scratch] = {} + transitioning_scratches: set[str] = set() + _respawned_scratches: set[str] = set() + scratches_by_address: dict[str, Scratch] = {} + scratches_by_pid: dict[int, Scratch] = {} + focused_window_tracking: dict[str, dict] = {} async def exit(self) -> None: + "exit hook" + async def die_in_piece(scratch: Scratch): proc = self.procs[scratch.uid] proc.terminate() @@ -141,6 +158,7 @@ class Extension(Plugin): ) async def load_config(self, config) -> None: + "config loader" config: dict[str, dict[str, Any]] = config["scratchpads"] scratches = {k: Scratch(k, v) for k, v in config.items()} @@ -159,6 +177,7 @@ class Extension(Plugin): await self.start_scratch_command(name) async def start_scratch_command(self, name: str) -> None: + "spawns a given scratchpad's process" self._respawned_scratches.add(name) scratch = self.scratches[name] old_pid = self.procs[name].pid if name in self.procs else 0 @@ -177,6 +196,7 @@ class Extension(Plugin): # Events async def event_activewindowv2(self, addr) -> None: + "active windows hook" addr = addr.strip() scratch = self.scratches_by_address.get(addr) if scratch: @@ -185,7 +205,7 @@ class Extension(Plugin): scratch.just_created = False else: for uid, scratch in self.scratches.items(): - if scratch.clientInfo and scratch.address != addr: + if scratch.client_info and scratch.address != addr: if ( scratch.visible and scratch.conf.get("unfocus") == "hide" @@ -194,7 +214,8 @@ class Extension(Plugin): await self.run_hide(uid, autohide=True) async def event_openwindow(self, params) -> None: - addr, wrkspc, kls, title = params.split(",", 3) + "open windows hook" + 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: @@ -213,7 +234,7 @@ class Extension(Plugin): self.scratches_by_address[ client["address"][2:] ] = pending_scratch - self.log.debug(f"client class found: {client}") + self.log.debug("client class found: %s", client) await pending_scratch.updateClientInfo(client) else: await self.updateScratchInfo() @@ -228,7 +249,7 @@ class Extension(Plugin): uid = uid.strip() item = self.scratches.get(uid) if not item: - self.log.warn(f"{uid} is not configured") + self.log.warning("%s is not configured", uid) return if item.visible: await self.run_hide(uid) @@ -236,6 +257,8 @@ class Extension(Plugin): await self.run_show(uid) async def updateScratchInfo(self, 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) @@ -247,34 +270,34 @@ class Extension(Plugin): if scratch: await scratch.updateClientInfo(client) else: - add_to_address_book = ("address" not in scratch.clientInfo) or ( + 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.clientInfo["address"][2:]] = scratch + 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" """ uid = uid.strip() item = self.scratches.get(uid) if not item: - self.log.warn(f"{uid} is not configured") + self.log.warning("%s is not configured", uid) return if not item.visible and not force: - self.log.warn(f"{uid} is already hidden") + self.log.warning("%s is already hidden", uid) return - self.log.info(f"Hiding {uid}") + self.log.info("Hiding %s", uid) item.visible = False addr = "address:0x" + item.address animation_type: str = item.conf.get("animation", "").lower() if animation_type: offset = item.conf.get("offset") if offset is None: - if "size" not in item.clientInfo: + if "size" not in item.client_info: await self.updateScratchInfo(item) - offset = int(1.3 * item.clientInfo["size"][1]) + offset = int(1.3 * item.client_info["size"][1]) if animation_type == "fromtop": await hyprctl(f"movewindowpixel 0 -{offset},{addr}") @@ -309,17 +332,17 @@ class Extension(Plugin): self.focused_window_tracking[uid] = await hyprctlJSON("activewindow") if not item: - self.log.warn(f"{uid} is not configured") + self.log.warning("%s is not configured", uid) return if item.visible and not force: - self.log.warn(f"{uid} is already visible") + self.log.warning("%s is already visible", uid) return - self.log.info(f"Showing {uid}") + self.log.info("Showing %s", uid) if not item.isAlive(): - self.log.info(f"{uid} is not running, restarting...") + 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: @@ -348,7 +371,7 @@ class Extension(Plugin): if animation_type: margin = item.conf.get("margin", DEFAULT_MARGIN) fn = getattr(Animations, animation_type) - await fn(monitor, item.clientInfo, addr, margin) + await fn(monitor, item.client_info, addr, margin) await hyprctl(f"focuswindow {addr}") await asyncio.sleep(0.2) # ensure some time for events to propagate diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index f047da3..8205bf3 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -4,6 +4,8 @@ from ..ipc import hyprctlJSON, hyprctl class Extension(Plugin): + monitors: list[str] = [] + async def init(self): self.monitors = [mon["name"] for mon in await hyprctlJSON("monitors")] diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index d9f840a..7435ce9 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -1,15 +1,19 @@ -import asyncio +""" Force workspaces to follow the focus / mouse """ from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring + workspace_list: list[int] = [] + async def load_config(self, config): + "loads the config" await super().load_config(config) self.workspace_list = list(range(1, self.config.get("max_workspaces", 10) + 1)) async def event_focusedmon(self, screenid_index): + "reacts to monitor changes" monitor_id, workspace_id = screenid_index.split(",") workspace_id = int(workspace_id) # move every free workspace to the currently focused desktop From 64cd25de16ace70ed12432800e8d00790b11c5e0 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 18:00:06 +0200 Subject: [PATCH 36/76] rework logs format --- pyprland/common.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyprland/common.py b/pyprland/common.py index af2546e..c869049 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -26,9 +26,9 @@ def init_logger(filename=None, force_debug=False): class ScreenLogFormatter(logging.Formatter): "A custom formatter, adding colors" LOG_FORMAT = ( - r"%(levelname)s:%(name)s - %(message)s // %(filename)s:%(lineno)d" + r"%(name)25s - %(message)s // %(filename)s:%(lineno)d" if DEBUG - else r"%(levelname)s: %(message)s" + else r"%(message)s" ) RESET_ANSI = "\x1b[0m" @@ -67,5 +67,5 @@ def get_logger(name="pypr", level=None): logger.propagate = False for handler in LogObjects.handlers: logger.addHandler(handler) - logger.debug("Logger initialized for %s", name) + logger.info("Logger initialized for %s", name) return logger From 2a532ee1022f8b42965798052269eb01a0efb44d Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sat, 29 Jul 2023 23:39:43 +0200 Subject: [PATCH 37/76] only show warnings & errors by default --- pyprland/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyprland/common.py b/pyprland/common.py index c869049..7aec706 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -61,7 +61,7 @@ def get_logger(name="pypr", level=None): "Returns a logger for `name`" logger = logging.getLogger(name) if level is None: - logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + logger.setLevel(logging.DEBUG if DEBUG else logging.WARNING) else: logger.setLevel(level) logger.propagate = False From 1a9ce4d8141373ad421db8577b0399ac7313241c Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:06:12 +0200 Subject: [PATCH 38/76] Fix some ambiguity between new & closed clients --- pyprland/plugins/scratchpads.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index c93f689..97474ed 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -134,6 +134,7 @@ class Extension(Plugin): procs: dict[str, subprocess.Popen] = {} scratches: dict[str, Scratch] = {} transitioning_scratches: set[str] = set() + _new_scratches: set[str] = set() _respawned_scratches: set[str] = set() scratches_by_address: dict[str, Scratch] = {} scratches_by_pid: dict[int, Scratch] = {} @@ -174,10 +175,12 @@ class Extension(Plugin): # not known yet for name in new_scratches: if not self.scratches[name].conf.get("lazy", False): - await self.start_scratch_command(name) + await self.start_scratch_command(name, is_new=True) - async def start_scratch_command(self, name: str) -> None: + async def start_scratch_command(self, name: str, is_new=False) -> None: "spawns a given scratchpad's process" + if is_new: + self._new_scratches.add(name) self._respawned_scratches.add(name) scratch = self.scratches[name] old_pid = self.procs[name].pid if name in self.procs else 0 @@ -240,8 +243,10 @@ class Extension(Plugin): await self.updateScratchInfo() item = self.scratches_by_address.get(addr) if item and item.just_created: + if item.uid in self._new_scratches: + await self.run_hide(item.uid, force=True) + self._new_scratches.discard(item.uid) 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) -> None: From 43261bd11f7735955ca6fd377ad8916977e77666 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:06:45 +0200 Subject: [PATCH 39/76] Add some logs --- pyprland/plugins/scratchpads.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 97474ed..5c480dd 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -204,6 +204,7 @@ class Extension(Plugin): scratch = self.scratches_by_address.get(addr) if scratch: if scratch.just_created: + self.log.debug("Hiding just created scratch %s", scratch.uid) await self.run_hide(scratch.uid, force=True) scratch.just_created = False else: @@ -214,6 +215,7 @@ class Extension(Plugin): and scratch.conf.get("unfocus") == "hide" and scratch.uid not in self.transitioning_scratches ): + self.log.debug("hide %s because another client is active", uid) await self.run_hide(uid, autohide=True) async def event_openwindow(self, params) -> None: @@ -256,6 +258,7 @@ class Extension(Plugin): if not item: self.log.warning("%s is not configured", uid) return + self.log.debug("%s is visible = %s", uid, item.visible) if item.visible: await self.run_hide(uid) else: From ca11f373d520ed14195811f3d3a0924f3bdebd06 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:09:00 +0200 Subject: [PATCH 40/76] add bug ref in comment --- pyprland/plugins/scratchpads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 5c480dd..97a26bd 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -224,7 +224,7 @@ class Extension(Plugin): if wrkspc.startswith("special"): item = self.scratches_by_address.get(addr) if not item and self._respawned_scratches: - # XXX: hack for windows which aren't related to the process + # hack for windows which aren't related to the process (see #8) class_lookup_hack = [ self.scratches[name] for name in self._respawned_scratches From d48a3f415480a3b41bddc01137718a4f79359cde Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:14:56 +0200 Subject: [PATCH 41/76] tiny code simplification --- pyprland/plugins/expose.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index b4c086f..449dad9 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -26,8 +26,7 @@ class Extension(Plugin): def exposed_clients(self): if self.config.get("include_special", False): return self.exposed - else: - return [c for c in self.exposed if c["workspace"]["id"] > 0] + return [c for c in self.exposed if c["workspace"]["id"] > 0] async def run_expose(self): """Expose every client on the active workspace. If expose is active restores everything and move to the focused window""" From 348017c1c8fdb616caa5adeed3f7fbdeea838456 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:18:55 +0200 Subject: [PATCH 42/76] style lint --- pyprland/plugins/lost_windows.py | 6 +++--- pyprland/plugins/monitors.py | 14 ++++++++------ pyprland/plugins/scratchpads.py | 10 +++++----- pyprland/plugins/toggle_dpms.py | 4 ++-- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index d6c5a54..84ae53b 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -29,14 +29,14 @@ class Extension(Plugin): ] focused = [mon for mon in monitors if mon["focused"]][0] interval = focused["width"] / (1 + len(lost)) - intervalY = focused["height"] / (1 + len(lost)) + interval_y = focused["height"] / (1 + len(lost)) batch = [] workspace: int = focused["activeWorkspace"]["id"] margin = interval // 2 - marginY = intervalY // 2 + margin_y = interval_y // 2 for i, window in enumerate(lost): batch.append(f'movetoworkspacesilent {workspace},pid:{window["pid"]}') batch.append( - f'movewindowpixel exact {int(margin + focused["x"] + i*interval)} {int(marginY + focused["y"] + i*intervalY)},pid:{window["pid"]}' + f'movewindowpixel exact {int(margin + focused["x"] + i*interval)} {int(margin_y + focused["y"] + i*interval_y)},pid:{window["pid"]}' ) await hyprctl(batch) diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index fa6cc86..dfaf40f 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -6,13 +6,13 @@ from .interface import Plugin from ..ipc import hyprctlJSON -def configure_monitors(monitors, screenid: str, x: int, y: int) -> None: +def configure_monitors(monitors, screenid: str, pos_x: int, pos_y: int) -> None: "Apply the configuration change" - x_offset = -x if x < 0 else 0 - y_offset = -y if y < 0 else 0 + x_offset = -pos_x if pos_x < 0 else 0 + y_offset = -pos_y if pos_y < 0 else 0 - min_x = x - min_y = y + min_x = pos_x + min_y = pos_y command = ["wlr-randr"] other_monitors = [mon for mon in monitors if mon["name"] != screenid] @@ -31,7 +31,9 @@ def configure_monitors(monitors, screenid: str, x: int, y: int) -> None: ] ) - command.extend(["--output", screenid, "--pos", f"{x+x_offset},{y+y_offset}"]) + command.extend( + ["--output", screenid, "--pos", f"{pos_x+x_offset},{pos_y+y_offset}"] + ) subprocess.call(command) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 97a26bd..b10c1a5 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -119,12 +119,12 @@ class Scratch: "Returns the client address" return str(self.client_info.get("address", ""))[2:] - async def updateClientInfo(self, clientInfo=None) -> None: + async def updateClientInfo(self, client_info=None) -> None: "update the internal client info property, if not provided, refresh based on the current address" - if clientInfo is None: - clientInfo = await get_client_props_by_address("0x" + self.address) - assert isinstance(clientInfo, dict) - self.client_info.update(clientInfo) + if client_info is None: + client_info = await get_client_props_by_address("0x" + self.address) + assert isinstance(client_info, dict) + self.client_info.update(client_info) def __str__(self): return f"{self.uid} {self.address} : {self.client_info} / {self.conf}" diff --git a/pyprland/plugins/toggle_dpms.py b/pyprland/plugins/toggle_dpms.py index ece811b..1f7483e 100644 --- a/pyprland/plugins/toggle_dpms.py +++ b/pyprland/plugins/toggle_dpms.py @@ -7,8 +7,8 @@ class Extension(Plugin): async def run_toggle_dpms(self): """toggles dpms on/off for every monitor""" monitors = await hyprctlJSON("monitors") - poweredOff = any(m["dpmsStatus"] for m in monitors) - if not poweredOff: + powered_off = any(m["dpmsStatus"] for m in monitors) + if not powered_off: await hyprctl("dpms on") else: await hyprctl("dpms off") From 6d4834989e08a8a6af51a26975f0b22c875b85de Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:40:37 +0200 Subject: [PATCH 43/76] only show handlers having listeners --- pyprland/command.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyprland/command.py b/pyprland/command.py index 621d259..22c6c85 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -64,6 +64,8 @@ class Pyprland: async def _callHandler(self, full_name, *params): "Call an event handler with params" + + have_listeners = False for plugin in [self] + list(self.plugins.values()): if hasattr(plugin, full_name): try: @@ -73,6 +75,10 @@ class Pyprland: "%s::%s(%s) failed:", plugin.name, full_name, params ) self.log.exception(e) + else: + have_listeners = True + if have_listeners: + self.log.debug("%s%s", full_name, params) async def read_events_loop(self): "Consumes the event loop and calls corresponding handlers" @@ -84,7 +90,6 @@ class Pyprland: cmd, params = data.split(">>") full_name = f"event_{cmd}" - self.log.debug("EVT %s(%s)", full_name, params.strip()) await self._callHandler(full_name, params) async def read_command(self, reader, writer) -> None: From 9c15ce42e282f4b0a1a7bc55aa1d792bb4454b14 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 00:55:30 +0200 Subject: [PATCH 44/76] some logging rework --- pyprland/command.py | 6 +----- pyprland/ipc.py | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index 22c6c85..3bf24ff 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -65,9 +65,9 @@ class Pyprland: async def _callHandler(self, full_name, *params): "Call an event handler with params" - have_listeners = False for plugin in [self] + list(self.plugins.values()): if hasattr(plugin, full_name): + self.log.debug("%s.%s%s", plugin.name, full_name, params) try: await getattr(plugin, full_name)(*params) except Exception as e: # pylint: disable=W0718 @@ -75,10 +75,6 @@ class Pyprland: "%s::%s(%s) failed:", plugin.name, full_name, params ) self.log.exception(e) - else: - have_listeners = True - if have_listeners: - self.log.debug("%s%s", full_name, params) async def read_events_loop(self): "Consumes the event loop and calls corresponding handlers" diff --git a/pyprland/ipc.py b/pyprland/ipc.py index 78ef791..2e804ef 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -21,7 +21,7 @@ async def get_event_stream(): async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]: """Run an IPC command and return the JSON output.""" - log.debug("JS>> %s", command) + log.debug(command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) except FileNotFoundError as e: @@ -48,7 +48,7 @@ def _format_command(command_list, default_base_command): async def hyprctl(command, base_command="dispatch") -> bool: """Run an IPC command. Returns success value.""" - log.debug("JS>> %s", command) + log.debug(command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) except FileNotFoundError as e: @@ -65,7 +65,6 @@ async def hyprctl(command, base_command="dispatch") -> bool: resp = await ctl_reader.read(100) ctl_writer.close() await ctl_writer.wait_closed() - log.debug("< Date: Sun, 30 Jul 2023 13:14:51 +0200 Subject: [PATCH 45/76] more linting --- pyprland/common.py | 12 +- pyprland/ipc.py | 4 +- pyprland/plugins/expose.py | 9 +- pyprland/plugins/lost_windows.py | 15 ++- pyprland/plugins/magnify.py | 3 +- pyprland/plugins/monitors.py | 45 +++++--- pyprland/plugins/scratchpads.py | 118 +++++++++++--------- pyprland/plugins/shift_monitors.py | 5 +- pyprland/plugins/toggle_dpms.py | 6 +- pyprland/plugins/workspaces_follow_focus.py | 3 + 10 files changed, 129 insertions(+), 91 deletions(-) diff --git a/pyprland/common.py b/pyprland/common.py index 7aec706..8cd2ed3 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -45,16 +45,16 @@ def init_logger(filename=None, force_debug=False): logging.basicConfig() if filename: - handler = logging.FileHandler(filename) - handler.setFormatter( + file_handler = logging.FileHandler(filename) + file_handler.setFormatter( logging.Formatter( fmt=r"%(asctime)s [%(levelname)s] %(name)s :: %(message)s :: %(filename)s:%(lineno)d" ) ) - LogObjects.handlers.append(handler) - handler = logging.StreamHandler() - handler.setFormatter(ScreenLogFormatter()) - LogObjects.handlers.append(handler) + LogObjects.handlers.append(file_handler) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(ScreenLogFormatter()) + LogObjects.handlers.append(stream_handler) def get_logger(name="pypr", level=None): diff --git a/pyprland/ipc.py b/pyprland/ipc.py index 2e804ef..e2e3208 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -8,7 +8,7 @@ import os from .common import get_logger, PyprError -log: Logger = None +log: Logger | None = None HYPRCTL = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket.sock' EVENTS = f'/tmp/hypr/{ os.environ["HYPRLAND_INSTANCE_SIGNATURE"] }/.socket2.sock' @@ -21,6 +21,7 @@ async def get_event_stream(): async def hyprctlJSON(command) -> list[dict[str, Any]] | dict[str, Any]: """Run an IPC command and return the JSON output.""" + assert log log.debug(command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) @@ -48,6 +49,7 @@ def _format_command(command_list, default_base_command): async def hyprctl(command, base_command="dispatch") -> bool: """Run an IPC command. Returns success value.""" + assert log log.debug(command) try: ctl_reader, ctl_writer = await asyncio.open_unix_connection(HYPRCTL) diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index 449dad9..e68c7db 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -1,10 +1,13 @@ +""" expose Brings every client window to screen for selection +toggle_minimized allows having an "expose" like selection of minimized windows +""" from typing import Any from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring exposed = False async def run_toggle_minimized(self, special_workspace="minimized"): @@ -24,12 +27,14 @@ class Extension(Plugin): @property def exposed_clients(self): + "Returns the list of clients currently using exposed mode" if self.config.get("include_special", False): return self.exposed return [c for c in self.exposed if c["workspace"]["id"] > 0] async def run_expose(self): - """Expose every client on the active workspace. If expose is active restores everything and move to the focused window""" + """Expose every client on the active workspace. + If expose is active restores everything and move to the focused window""" if self.exposed: aw: dict[str, Any] = await hyprctlJSON("activewindow") focused_addr = aw["address"] diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index 84ae53b..34cbb5d 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -1,9 +1,12 @@ +" Moves unreachable client windows to the currently focused workspace" +from typing import Any from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl def contains(monitor, window): + "Tell if a window is visible in a monitor" if not ( window["at"][0] > monitor["x"] and window["at"][0] < monitor["x"] + monitor["width"] @@ -17,17 +20,17 @@ def contains(monitor, window): return True -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring async def run_attract_lost(self): """Brings lost floating windows to the current workspace""" - monitors = await hyprctlJSON("monitors") + monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") windows = await hyprctlJSON("clients") lost = [ win for win in windows if win["floating"] and not any(contains(mon, win) for mon in monitors) ] - focused = [mon for mon in monitors if mon["focused"]][0] + focused: dict[str, Any] = [mon for mon in monitors if mon["focused"]][0] interval = focused["width"] / (1 + len(lost)) interval_y = focused["height"] / (1 + len(lost)) batch = [] @@ -35,8 +38,8 @@ class Extension(Plugin): margin = interval // 2 margin_y = interval_y // 2 for i, window in enumerate(lost): + pos_x = int(margin + focused["x"] + i * interval) + pos_y = {int(margin_y + focused["y"] + i * interval_y)} batch.append(f'movetoworkspacesilent {workspace},pid:{window["pid"]}') - batch.append( - f'movewindowpixel exact {int(margin + focused["x"] + i*interval)} {int(margin_y + focused["y"] + i*interval_y)},pid:{window["pid"]}' - ) + batch.append(f'movewindowpixel exact {pos_x} {pos_y},pid:{window["pid"]}') await hyprctl(batch) diff --git a/pyprland/plugins/magnify.py b/pyprland/plugins/magnify.py index 2589614..b3d9cc2 100644 --- a/pyprland/plugins/magnify.py +++ b/pyprland/plugins/magnify.py @@ -1,9 +1,10 @@ +" Toggles workspace zooming " from .interface import Plugin from ..ipc import hyprctl -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring zoomed = False async def run_zoom(self, *args): diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index dfaf40f..7c51de3 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -47,30 +47,42 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring ) async def event_monitoradded( - self, screenid, no_default=False, monitors: list | None = None + self, monitor_name, no_default=False, monitors: list | None = None ) -> None: "Triggers when a monitor is plugged" - screenid = screenid.strip() + monitor_name = monitor_name.strip() if not monitors: - monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") + monitors = await hyprctlJSON("monitors") + + assert monitors for mon in monitors: - if mon["name"].startswith(screenid): - mon_name = mon["description"] + if mon["name"].startswith(monitor_name): + mon_description = mon["description"] break else: - self.log.info("Monitor %s not found", screenid) + self.log.info("Monitor %s not found", monitor_name) return + if self._place_monitors(monitor_name, mon_description, monitors): + return + + if not no_default: + default_command = self.config.get("unknown") + if default_command: + subprocess.call(default_command, shell=True) + + def _place_monitors( + self, monitor_name: str, mon_description: str, monitors: list[dict[str, Any]] + ): + "place a given monitor according to config" mon_by_name = {m["name"]: m for m in monitors} - - newmon = mon_by_name[screenid] - + newmon = mon_by_name[monitor_name] 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 mon_pattern in mon_description: + for placement, other_mon_description in conf.items(): + ref = mon_by_name[other_mon_description] if ref: place = placement.lower() if place == "topof": @@ -86,9 +98,6 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring x: int = ref["x"] + ref["width"] y: int = ref["y"] - configure_monitors(monitors, screenid, x, y) - return - if not no_default: - default_command = self.config.get("unknown") - if default_command: - subprocess.call(default_command, shell=True) + configure_monitors(monitors, monitor_name, x, y) + return True + return False diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index b10c1a5..80c0fd5 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -26,8 +26,8 @@ async def get_client_props_by_address(addr: str): class Animations: "Animation store" - @classmethod - async def fromtop(cls, monitor, client, client_uid, margin): + @staticmethod + async def fromtop(monitor, client, client_uid, margin): "Slide from/to top" scale = float(monitor["scale"]) mon_x = monitor["x"] @@ -39,8 +39,8 @@ class Animations: await hyprctl(f"movewindowpixel exact {margin_x} {mon_y + margin},{client_uid}") - @classmethod - async def frombottom(cls, monitor, client, client_uid, margin): + @staticmethod + async def frombottom(monitor, client, client_uid, margin): "Slide from/to bottom" scale = float(monitor["scale"]) mon_x = monitor["x"] @@ -55,8 +55,8 @@ class Animations: f"movewindowpixel exact {margin_x} {mon_y + mon_height - client_height - margin},{client_uid}" ) - @classmethod - async def fromleft(cls, monitor, client, client_uid, margin): + @staticmethod + async def fromleft(monitor, client, client_uid, margin): "Slide from/to left" scale = float(monitor["scale"]) mon_x = monitor["x"] @@ -68,8 +68,8 @@ class Animations: await hyprctl(f"movewindowpixel exact {margin + mon_x} {margin_y},{client_uid}") - @classmethod - async def fromright(cls, monitor, client, client_uid, margin): + @staticmethod + async def fromright(monitor, client, client_uid, margin): "Slide from/to right" scale = float(monitor["scale"]) mon_x = monitor["x"] @@ -130,7 +130,7 @@ class Scratch: return f"{self.uid} {self.address} : {self.client_info} / {self.conf}" -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring procs: dict[str, subprocess.Popen] = {} scratches: dict[str, Scratch] = {} transitioning_scratches: set[str] = set() @@ -184,16 +184,18 @@ class Extension(Plugin): 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( + proc = subprocess.Popen( scratch.conf["command"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True, ) - pid = self.procs[name].pid + self.procs[name] = proc + pid = proc.pid self.scratches[name].reset(pid) - self.scratches_by_pid[self.procs[name].pid] = scratch + 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,6 +220,25 @@ class Extension(Plugin): self.log.debug("hide %s because another client is active", uid) await self.run_hide(uid, autohide=True) + async def _alternative_lookup(self): + "if class attribute is defined, use class matching and return True" + class_lookup_hack = [ + self.scratches[name] + for name in self._respawned_scratches + if self.scratches[name].conf.get("class") + ] + if not class_lookup_hack: + return False + self.log.debug("Lookup hack triggered") + for client in await hyprctlJSON("clients"): + assert isinstance(client, dict) + for pending_scratch in class_lookup_hack: + if pending_scratch.conf["class"] == client["class"]: + self.scratches_by_address[client["address"][2:]] = pending_scratch + self.log.debug("client class found: %s", client) + await pending_scratch.updateClientInfo(client) + return True + async def event_openwindow(self, params) -> None: "open windows hook" addr, wrkspc, _kls, _title = params.split(",", 3) @@ -225,23 +246,7 @@ class Extension(Plugin): 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) - class_lookup_hack = [ - self.scratches[name] - for name in self._respawned_scratches - if self.scratches[name].conf.get("class") - ] - if class_lookup_hack: - self.log.debug("Lookup hack triggered") - for client in await hyprctlJSON("clients"): - assert isinstance(client, dict) - for pending_scratch in class_lookup_hack: - if pending_scratch.conf["class"] == client["class"]: - self.scratches_by_address[ - client["address"][2:] - ] = pending_scratch - self.log.debug("client class found: %s", client) - await pending_scratch.updateClientInfo(client) - else: + if not await self._alternative_lookup(): await self.updateScratchInfo() item = self.scratches_by_address.get(addr) if item and item.just_created: @@ -264,6 +269,29 @@ class Extension(Plugin): else: await self.run_show(uid) + async def _anim_hide(self, animation_type, scratch): + "animate hiding a scratchpad" + addr = "address:0x" + scratch.address + offset = scratch.conf.get("offset") + if offset is None: + if "size" not in scratch.client_info: + await self.updateScratchInfo(scratch) + + offset = int(1.3 * scratch.client_info["size"][1]) + + if animation_type == "fromtop": + await hyprctl(f"movewindowpixel 0 -{offset},{addr}") + elif animation_type == "frombottom": + await hyprctl(f"movewindowpixel 0 {offset},{addr}") + elif animation_type == "fromleft": + await hyprctl(f"movewindowpixel -{offset} 0,{addr}") + elif animation_type == "fromright": + await hyprctl(f"movewindowpixel {offset} 0,{addr}") + + if scratch.uid in self.transitioning_scratches: + return # abort sequence + await asyncio.sleep(0.2) # await for animation to finish + async def updateScratchInfo(self, scratch: Scratch | None = None) -> None: """Update every scratchpads information if no `scratch` given, else update a specific scratchpad info""" @@ -288,37 +316,19 @@ class Extension(Plugin): async def run_hide(self, uid: str, force=False, autohide=False) -> None: """ hides scratchpad "name" """ uid = uid.strip() - item = self.scratches.get(uid) - if not item: + scratch = self.scratches.get(uid) + if not scratch: self.log.warning("%s is not configured", uid) return - if not item.visible and not force: + if not scratch.visible and not force: self.log.warning("%s is already hidden", uid) return self.log.info("Hiding %s", uid) - item.visible = False - addr = "address:0x" + item.address - animation_type: str = item.conf.get("animation", "").lower() + scratch.visible = False + addr = "address:0x" + scratch.address + animation_type: str = scratch.conf.get("animation", "").lower() if animation_type: - offset = item.conf.get("offset") - if offset is None: - if "size" not in item.client_info: - await self.updateScratchInfo(item) - - offset = int(1.3 * item.client_info["size"][1]) - - if animation_type == "fromtop": - await hyprctl(f"movewindowpixel 0 -{offset},{addr}") - elif animation_type == "frombottom": - await hyprctl(f"movewindowpixel 0 {offset},{addr}") - elif animation_type == "fromleft": - await hyprctl(f"movewindowpixel -{offset} 0,{addr}") - elif animation_type == "fromright": - await hyprctl(f"movewindowpixel {offset} 0,{addr}") - - if uid in self.transitioning_scratches: - return # abort sequence - await asyncio.sleep(0.2) # await for animation to finish + await self._anim_hide(animation_type, scratch) if uid not in self.transitioning_scratches: await hyprctl(f"movetoworkspacesilent special:scratch_{uid},{addr}") diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index 8205bf3..6006857 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -1,9 +1,10 @@ +" shift workspaces across monitors " from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring monitors: list[str] = [] async def init(self): @@ -21,7 +22,9 @@ class Extension(Plugin): await hyprctl(f"swapactiveworkspaces {mon} {self.monitors[i+direction]}") async def event_monitoradded(self, monitor): + "keep track of monitors" self.monitors.append(monitor.strip()) async def event_monitorremoved(self, monitor): + "keep track of monitors" self.monitors.remove(monitor.strip()) diff --git a/pyprland/plugins/toggle_dpms.py b/pyprland/plugins/toggle_dpms.py index 1f7483e..0212ed5 100644 --- a/pyprland/plugins/toggle_dpms.py +++ b/pyprland/plugins/toggle_dpms.py @@ -1,12 +1,14 @@ +" Toggle monitors on or off " +from typing import Any from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl -class Extension(Plugin): +class Extension(Plugin): # pylint: disable=missing-class-docstring async def run_toggle_dpms(self): """toggles dpms on/off for every monitor""" - monitors = await hyprctlJSON("monitors") + monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") powered_off = any(m["dpmsStatus"] for m in monitors) if not powered_off: await hyprctl("dpms on") diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 7435ce9..2b3ebe0 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -40,6 +40,9 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring for monitor in monitors: if monitor["focused"]: break + else: + self.log.error("Can not find a focused monitor") + return assert isinstance(monitor, dict) busy_workspaces = set( m["activeWorkspace"]["id"] for m in monitors if m["id"] != monitor["id"] From 34baafc96398288107b7f770d5289a168474c83a Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 13:26:53 +0200 Subject: [PATCH 46/76] add pylintrc --- .pylintrc | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..4eeae7c --- /dev/null +++ b/.pylintrc @@ -0,0 +1,209 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + + +# Add to the black list. It should be a base name, not a +# path. You may set this option multiple times. +ignore=.hg + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + + +[MESSAGES CONTROL] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). +disable=R0903,W0603 + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html +output-format=text +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (R0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + + + +[BASIC] + + +# Regular expression which should only match correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression which should only match correct module level names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log(_.*)?)$ + +# Regular expression which should only match correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression which should only match correct function names +function-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ + +# Regular expression which should only match correct method names +method-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{,30}$ + +# Regular expression which should only match correct list comprehension / +# generator expression variable names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata,pdb + +# Regular expression which should only match functions or classes name which do +# not require a docstring +no-docstring-rgx=__.*__ + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject + + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. +generated-members=REQUEST,acl_users,aq_parent + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching names used for dummy variables (i.e. not used). +dummy-variables-rgx=_|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +#additional-builtins= +additional-builtins = _,DBG + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=7 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=10 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,string,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= From b219b0208111c151409fbf48b668a7e1ba4c4374 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 13:30:10 +0200 Subject: [PATCH 47/76] add links in recent changelog --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ad5ae0..f0ca08c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more i # Changelog -- Add `expose` addon -- scratchpad: add "lazy" option +- 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 - fix `scratchpads`'s position on monitors using scaling - improve error handling & logging, enable debug logs with `--debug ` From a0f3ca2a833c00cc9c266316a155617b478e4ce4 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 30 Jul 2023 19:12:21 +0200 Subject: [PATCH 48/76] more linting --- pyprland/command.py | 8 +++++-- pyprland/plugins/expose.py | 14 ++++++------ pyprland/plugins/lost_windows.py | 6 +++--- pyprland/plugins/monitors.py | 24 +++++++++++---------- pyprland/plugins/scratchpads.py | 12 ++++++----- pyprland/plugins/shift_monitors.py | 7 ++++-- pyprland/plugins/toggle_dpms.py | 4 ++-- pyprland/plugins/workspaces_follow_focus.py | 9 ++++++-- 8 files changed, 50 insertions(+), 34 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index 3bf24ff..b70e63f 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -1,6 +1,7 @@ #!/bin/env python " Pyprland - an Hyprland companion app " import asyncio +from typing import cast import json import sys import os @@ -23,7 +24,7 @@ class Pyprland: event_reader: asyncio.StreamReader stopped = False name = "builtin" - config: dict[str, dict] = None + config: None | dict[str, dict] = None def __init__(self): self.plugins: dict[str, Plugin] = {} @@ -33,6 +34,7 @@ class Pyprland: """Loads the configuration if `init` is true, also initializes the plugins""" + assert isinstance(self.config, dict) try: with open(os.path.expanduser(CONFIG_FILE), encoding="utf-8") as f: self.config = json.loads(f.read()) @@ -42,7 +44,9 @@ class Pyprland: ) raise PyprError() from e - for name in self.config["pyprland"]["plugins"]: + assert self.config + + for name in cast(dict, self.config["pyprland"]["plugins"]): if name not in self.plugins: modname = name if "." in name else f"pyprland.plugins.{name}" try: diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index e68c7db..32213a5 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -1,22 +1,22 @@ """ expose Brings every client window to screen for selection toggle_minimized allows having an "expose" like selection of minimized windows """ -from typing import Any +from typing import Any, cast from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl class Extension(Plugin): # pylint: disable=missing-class-docstring - exposed = False + exposed: list[dict] = [] async def run_toggle_minimized(self, special_workspace="minimized"): """[name] Toggles switching the focused window to the special workspace "name" (default: minimized)""" - aw: dict[str, Any] = await hyprctlJSON("activewindow") + aw = cast(dict, await hyprctlJSON("activewindow")) wid = aw["workspace"]["id"] assert isinstance(wid, int) if wid < 1: # special workspace: unminimize - wrk = await hyprctlJSON("activeworkspace") + wrk = cast(dict, await hyprctlJSON("activeworkspace")) await hyprctl(f"togglespecialworkspace {special_workspace}") await hyprctl(f"movetoworkspacesilent {wrk['id']},address:{aw['address']}") await hyprctl(f"focuswindow address:{aw['address']}") @@ -36,7 +36,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring """Expose every client on the active workspace. If expose is active restores everything and move to the focused window""" if self.exposed: - aw: dict[str, Any] = await hyprctlJSON("activewindow") + aw: dict[str, Any] = cast(dict, await hyprctlJSON("activewindow")) focused_addr = aw["address"] for client in self.exposed_clients: await hyprctl( @@ -44,9 +44,9 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring ) await hyprctl("togglespecialworkspace exposed") await hyprctl(f"focuswindow address:{focused_addr}") - self.exposed = False + self.exposed = [] else: - self.exposed = await hyprctlJSON("clients") + self.exposed = cast(list, await hyprctlJSON("clients")) for client in self.exposed_clients: await hyprctl( f"movetoworkspacesilent special:exposed,address:{client['address']}" diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index 34cbb5d..3b7c133 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -1,5 +1,5 @@ " Moves unreachable client windows to the currently focused workspace" -from typing import Any +from typing import Any, cast from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl @@ -23,8 +23,8 @@ def contains(monitor, window): class Extension(Plugin): # pylint: disable=missing-class-docstring async def run_attract_lost(self): """Brings lost floating windows to the current workspace""" - monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") - windows = await hyprctlJSON("clients") + monitors = cast(list, await hyprctlJSON("monitors")) + windows = cast(list, await hyprctlJSON("clients")) lost = [ win for win in windows diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index 7c51de3..da4821d 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -1,6 +1,6 @@ " The monitors plugin " import subprocess -from typing import Any +from typing import Any, cast from .interface import Plugin from ..ipc import hyprctlJSON @@ -40,7 +40,7 @@ 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) - monitors = await hyprctlJSON("monitors") + monitors = cast(list[dict], await hyprctlJSON("monitors")) for monitor in monitors: await self.event_monitoradded( monitor["name"], no_default=True, monitors=monitors @@ -53,7 +53,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring monitor_name = monitor_name.strip() if not monitors: - monitors = await hyprctlJSON("monitors") + monitors = cast(list, await hyprctlJSON("monitors")) assert monitors @@ -85,18 +85,20 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring ref = mon_by_name[other_mon_description] if ref: place = placement.lower() + x: int = 0 + y: int = 0 if place == "topof": - x: int = ref["x"] - y: int = ref["y"] - newmon["height"] + x = ref["x"] + y = ref["y"] - newmon["height"] elif place == "bottomof": - x: int = ref["x"] - y: int = ref["y"] + ref["height"] + x = ref["x"] + y = ref["y"] + ref["height"] elif place == "leftof": - x: int = ref["x"] - newmon["width"] - y: int = ref["y"] + x = ref["x"] - newmon["width"] + y = ref["y"] else: # rightof - x: int = ref["x"] + ref["width"] - y: int = ref["y"] + x = ref["x"] + ref["width"] + y = ref["y"] configure_monitors(monitors, monitor_name, x, y) return True diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 80c0fd5..e4da67f 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -2,7 +2,7 @@ import os import asyncio import subprocess -from typing import Any +from typing import Any, cast from ..ipc import ( hyprctl, @@ -158,10 +158,10 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring *(die_in_piece(scratch) for scratch in self.scratches.values()) ) - async def load_config(self, config) -> None: + async def load_config(self, config: dict[str, Any]) -> None: "config loader" - config: dict[str, dict[str, Any]] = config["scratchpads"] - scratches = {k: Scratch(k, v) for k, v in config.items()} + my_config: dict[str, dict[str, Any]] = config["scratchpads"] + scratches = {k: Scratch(k, v) for k, v in my_config.items()} new_scratches = set() @@ -347,7 +347,9 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring uid = uid.strip() item = self.scratches.get(uid) - self.focused_window_tracking[uid] = await hyprctlJSON("activewindow") + self.focused_window_tracking[uid] = cast( + dict[str, Any], await hyprctlJSON("activewindow") + ) if not item: self.log.warning("%s is not configured", uid) diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index 6006857..29990da 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -1,4 +1,5 @@ " shift workspaces across monitors " +from typing import cast from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl @@ -8,7 +9,9 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring monitors: list[str] = [] async def init(self): - self.monitors = [mon["name"] for mon in await hyprctlJSON("monitors")] + self.monitors: list[str] = [ + mon["name"] for mon in cast(list[dict], await hyprctlJSON("monitors")) + ] async def run_shift_monitors(self, arg: str): """Swaps monitors' workspaces in the given direction""" @@ -16,7 +19,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring if direction > 0: mon_list = self.monitors[:-1] else: - mon_list = reversed(self.monitors[1:]) + mon_list = list(reversed(self.monitors[1:])) for i, mon in enumerate(mon_list): await hyprctl(f"swapactiveworkspaces {mon} {self.monitors[i+direction]}") diff --git a/pyprland/plugins/toggle_dpms.py b/pyprland/plugins/toggle_dpms.py index 0212ed5..86c6074 100644 --- a/pyprland/plugins/toggle_dpms.py +++ b/pyprland/plugins/toggle_dpms.py @@ -1,5 +1,5 @@ " Toggle monitors on or off " -from typing import Any +from typing import Any, cast from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl @@ -8,7 +8,7 @@ from ..ipc import hyprctlJSON, hyprctl class Extension(Plugin): # pylint: disable=missing-class-docstring async def run_toggle_dpms(self): """toggles dpms on/off for every monitor""" - monitors: list[dict[str, Any]] = await hyprctlJSON("monitors") + monitors = cast(list[dict[str, Any]], await hyprctlJSON("monitors")) powered_off = any(m["dpmsStatus"] for m in monitors) if not powered_off: await hyprctl("dpms on") diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 2b3ebe0..29e2872 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -1,4 +1,5 @@ """ Force workspaces to follow the focus / mouse """ +from typing import cast from .interface import Plugin from ..ipc import hyprctlJSON, hyprctl @@ -19,10 +20,14 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring # move every free workspace to the currently focused desktop busy_workspaces = set( mon["activeWorkspace"]["id"] - for mon in await hyprctlJSON("monitors") + for mon in cast(list[dict], await hyprctlJSON("monitors")) if mon["name"] != monitor_id ) - workspaces = [w["id"] for w in await hyprctlJSON("workspaces") if w["id"] > 0] + workspaces = [ + w["id"] + for w in cast(list[dict], await hyprctlJSON("workspaces")) + if w["id"] > 0 + ] batch: list[str | list[str]] = [] for n in workspaces: From 60997fafa7ebdcbe71a06ac9439517009c0c9b97 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 31 Jul 2023 18:28:50 +0200 Subject: [PATCH 49/76] fix config loading (mistake from previous commits) --- pyprland/command.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index b70e63f..9ca0320 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -34,7 +34,6 @@ class Pyprland: """Loads the configuration if `init` is true, also initializes the plugins""" - assert isinstance(self.config, dict) try: with open(os.path.expanduser(CONFIG_FILE), encoding="utf-8") as f: self.config = json.loads(f.read()) @@ -167,7 +166,7 @@ async def run_daemon(): except PyprError as e: raise SystemExit(1) from e except Exception as e: - manager.log.critical("Failed to load config.") + manager.log.critical("Failed to load config.", exc_info=True) raise SystemExit(1) from e try: From beb607186d1466e84c0b22a71ffdc018ada37cfd Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 1 Aug 2023 18:24:59 +0200 Subject: [PATCH 50/76] Version 1.4.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1ddb73..29eb7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyprland" -version = "1.3.1" +version = "1.4.0" description = "An hyperland plugin system" authors = ["fdev31 "] license = "MIT" From 895e24b368fb064790e4190a16348da79b91beaf Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 1 Aug 2023 18:26:24 +0200 Subject: [PATCH 51/76] Mark release in Changelog --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f0ca08c..a4851a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Host process for multiple Hyprland plugins. Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more information. -# Changelog +# 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 From 2eafea986255b303df28673252a4be54dfd6217d Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 1 Aug 2023 22:34:46 +0200 Subject: [PATCH 52/76] sorted imports --- pyprland/command.py | 15 +++++++-------- pyprland/common.py | 2 +- pyprland/ipc.py | 6 +++--- pyprland/plugins/expose.py | 4 ++-- pyprland/plugins/interface.py | 1 + pyprland/plugins/lost_windows.py | 4 ++-- pyprland/plugins/magnify.py | 3 +-- pyprland/plugins/monitors.py | 2 +- pyprland/plugins/scratchpads.py | 10 +++------- pyprland/plugins/shift_monitors.py | 4 ++-- pyprland/plugins/toggle_dpms.py | 4 ++-- pyprland/plugins/workspaces_follow_focus.py | 4 ++-- 12 files changed, 27 insertions(+), 32 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index 9ca0320..6f4df52 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -1,16 +1,15 @@ #!/bin/env python -" 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 .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' diff --git a/pyprland/common.py b/pyprland/common.py index 8cd2ed3..8c5947f 100644 --- a/pyprland/common.py +++ b/pyprland/common.py @@ -1,6 +1,6 @@ """ Shared utilities: logging """ -import os import logging +import os __all__ = ["DEBUG", "get_logger", "init_logger"] diff --git a/pyprland/ipc.py b/pyprland/ipc.py index e2e3208..ea269dd 100644 --- a/pyprland/ipc.py +++ b/pyprland/ipc.py @@ -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 diff --git a/pyprland/plugins/expose.py b/pyprland/plugins/expose.py index 32213a5..78f8e81 100644 --- a/pyprland/plugins/expose.py +++ b/pyprland/plugins/expose.py @@ -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 diff --git a/pyprland/plugins/interface.py b/pyprland/plugins/interface.py index b48dc19..4613695 100644 --- a/pyprland/plugins/interface.py +++ b/pyprland/plugins/interface.py @@ -1,5 +1,6 @@ " Common plugin interface " from typing import Any + from ..common import get_logger diff --git a/pyprland/plugins/lost_windows.py b/pyprland/plugins/lost_windows.py index 3b7c133..4556dfa 100644 --- a/pyprland/plugins/lost_windows.py +++ b/pyprland/plugins/lost_windows.py @@ -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): diff --git a/pyprland/plugins/magnify.py b/pyprland/plugins/magnify.py index b3d9cc2..fe3a296 100644 --- a/pyprland/plugins/magnify.py +++ b/pyprland/plugins/magnify.py @@ -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 diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index da4821d..a047132 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: diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index e4da67f..e7b37f3 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -1,15 +1,10 @@ " Scratchpads addon " -import os import asyncio +import os import subprocess from typing import Any, cast -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 @@ -230,6 +225,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: diff --git a/pyprland/plugins/shift_monitors.py b/pyprland/plugins/shift_monitors.py index 29990da..2e6d91c 100644 --- a/pyprland/plugins/shift_monitors.py +++ b/pyprland/plugins/shift_monitors.py @@ -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 diff --git a/pyprland/plugins/toggle_dpms.py b/pyprland/plugins/toggle_dpms.py index 86c6074..a395519 100644 --- a/pyprland/plugins/toggle_dpms.py +++ b/pyprland/plugins/toggle_dpms.py @@ -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 diff --git a/pyprland/plugins/workspaces_follow_focus.py b/pyprland/plugins/workspaces_follow_focus.py index 29e2872..24efe8b 100644 --- a/pyprland/plugins/workspaces_follow_focus.py +++ b/pyprland/plugins/workspaces_follow_focus.py @@ -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 From c710e7691203fe4d7e310a1e5b56020734913390 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 1 Aug 2023 23:29:00 +0200 Subject: [PATCH 53/76] restore the module doc --- pyprland/command.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyprland/command.py b/pyprland/command.py index 6f4df52..95ed3ee 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -1,4 +1,5 @@ #!/bin/env python +""" Pyprland - an Hyprland companion app (cli client & daemon) """ import asyncio import importlib import itertools From 5197977a26f03a5fe72c98f47078d4171723bc57 Mon Sep 17 00:00:00 2001 From: iliayar Date: Fri, 4 Aug 2023 02:32:14 +0300 Subject: [PATCH 54/76] feat nix: Add nix flake --- .gitignore | 2 ++ flake.lock | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 27 ++++++++++++++++++++++++ poetry.lock | 8 +++++++ 4 files changed, 98 insertions(+) create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 poetry.lock 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/flake.lock b/flake.lock new file mode 100644 index 0000000..8d25f52 --- /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": 1685573264, + "narHash": "sha256-Zffu01pONhs/pqH07cjlF10NnMDLok8ix5Uk4rhOnZQ=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "380be19fbd2d9079f677978361792cb25e8a3635", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-22.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..ee0ead0 --- /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-22.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..2ddd539 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,8 @@ +package = [] + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" + +[metadata.files] From ecfade18ab0e94a85b554f06536f833057d2b777 Mon Sep 17 00:00:00 2001 From: iliayar Date: Fri, 4 Aug 2023 20:17:47 +0300 Subject: [PATCH 55/76] feat scratchpads: adjust size, position for monitor --- pyprland/plugins/scratchpads.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index e7b37f3..65242a8 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -381,6 +381,9 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring wrkspc = monitor["activeWorkspace"]["id"] + size = item.conf.get("size") + position = item.conf.get("position") + self.transitioning_scratches.add(uid) await hyprctl(f"moveworkspacetomonitor special:scratch_{uid} {monitor['name']}") await hyprctl(f"movetoworkspacesilent {wrkspc},{addr}") @@ -390,5 +393,23 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await fn(monitor, item.client_info, addr, margin) await hyprctl(f"focuswindow {addr}") + + if size: + # NOTE: Format for size is "X_SIZE Y_SIZE" + # X_SIZE, Y_SIZE is percentage of monitor size + x_size_p, y_size_p = map(int, size.split()) + x_size, y_size = int(monitor["width"] * x_size_p / 100), int(monitor["height"] * y_size_p / 100) + + await hyprctl(f"resizewindowpixel exact {x_size} {y_size},{addr}") + + if position: + # NOTE: Format for position is "X_POS Y_POS" + # X_POS, Y_POS is percentage of monitor size from top left corner + x_pos_p, y_pos_p = map(int, position.split()) + x_pos, y_pos = int(monitor["width"] * x_pos_p / 100), int(monitor["height"] * y_pos_p / 100) + 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) From d0fcbf123fd0ce40f995bebde7108300a5fe38c8 Mon Sep 17 00:00:00 2001 From: iliayar Date: Sun, 6 Aug 2023 17:09:50 +0300 Subject: [PATCH 56/76] refactor scratchpads: size, position percentage --- pyprland/plugins/scratchpads.py | 47 ++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 65242a8..c203b4b 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -381,9 +381,6 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring wrkspc = monitor["activeWorkspace"]["id"] - size = item.conf.get("size") - position = item.conf.get("position") - self.transitioning_scratches.add(uid) await hyprctl(f"moveworkspacetomonitor special:scratch_{uid} {monitor['name']}") await hyprctl(f"movetoworkspacesilent {wrkspc},{addr}") @@ -394,22 +391,46 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await hyprctl(f"focuswindow {addr}") + size = self._convert_coords(item.conf.get("size"), monitor) if size: - # NOTE: Format for size is "X_SIZE Y_SIZE" - # X_SIZE, Y_SIZE is percentage of monitor size - x_size_p, y_size_p = map(int, size.split()) - x_size, y_size = int(monitor["width"] * x_size_p / 100), int(monitor["height"] * y_size_p / 100) - + x_size, y_size = size await hyprctl(f"resizewindowpixel exact {x_size} {y_size},{addr}") + position = self._convert_coords(item.conf.get("position"), monitor) if position: - # NOTE: Format for position is "X_POS Y_POS" - # X_POS, Y_POS is percentage of monitor size from top left corner - x_pos_p, y_pos_p = map(int, position.split()) - x_pos, y_pos = int(monitor["width"] * x_pos_p / 100), int(monitor["height"] * y_pos_p / 100) + x_pos, y_pos = position 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 + """ + + if not coords: + return None + + 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}") + return int(monitor[dim] * 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}") + return None From 43619bc1ca01c788bab88d39ef60a8630ba724a5 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Thu, 14 Sep 2023 18:39:21 +0200 Subject: [PATCH 57/76] make some operations more robust --- pyprland/command.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyprland/command.py b/pyprland/command.py index 95ed3ee..9196718 100755 --- a/pyprland/command.py +++ b/pyprland/command.py @@ -82,11 +82,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) From 19b9741ec33eee2d9f436efda05b8e9a6a240af4 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Thu, 14 Sep 2023 18:42:17 +0200 Subject: [PATCH 58/76] add some log in case of unexpected error --- pyprland/plugins/ironbar.py | 27 +++++++++++++++++++++++++++ pyprland/plugins/scratchpads.py | 11 ++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 pyprland/plugins/ironbar.py diff --git a/pyprland/plugins/ironbar.py b/pyprland/plugins/ironbar.py new file mode 100644 index 0000000..219d0e8 --- /dev/null +++ b/pyprland/plugins/ironbar.py @@ -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) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index e7b37f3..43ff936 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -3,6 +3,7 @@ import asyncio import os import subprocess from typing import Any, cast +import logging from ..ipc import get_focused_monitor_props, hyprctl, hyprctlJSON from .interface import Plugin @@ -82,6 +83,7 @@ class Animations: class Scratch: "A scratchpad state including configuration & client state" + log = logging.getLogger("scratch") def __init__(self, uid, opts): self.uid = uid @@ -118,7 +120,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): From 968c2241a064dcf4b44bd2e06016ec99b6b67e8e Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 19 Sep 2023 18:26:15 +0200 Subject: [PATCH 59/76] 1.4.1 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a4851a2..6bfc4d4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Host process for multiple Hyprland plugins. Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more information. +# 1.4.1 + +- minor bugfixes + # 1.4.0 - Add [expose](https://github.com/hyprland-community/pyprland/wiki/Plugins#expose) addon From ea39db64db7563037aabb9503ca3ea9395240a39 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Tue, 19 Sep 2023 18:26:24 +0200 Subject: [PATCH 60/76] Version 1.4.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 121c11c26bf6e5dba97711049c3e32fcff42ab21 Mon Sep 17 00:00:00 2001 From: iliayar Date: Sun, 24 Sep 2023 14:44:10 +0300 Subject: [PATCH 61/76] feat scratchpads: Support monitor scale --- pyprland/plugins/scratchpads.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index c203b4b..ddcb6f1 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -423,7 +423,8 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring p = int(s[:-1]) if p < 0 or p > 100: raise Exception(f"Percentage must be in range [0; 100], got {p}") - return int(monitor[dim] * p / 100) + scale = float(monitor["scale"]) + return int(monitor[dim] / scale * p / 100) else: raise Exception(f"Unsupported format for dimension {dim} size, got {s}") From 2b722a39b098c99180c4e99031cc2aae459c7ec6 Mon Sep 17 00:00:00 2001 From: iliayar Date: Sun, 24 Sep 2023 14:48:06 +0300 Subject: [PATCH 62/76] feat nix: Update nixpkgs to 23.05 --- flake.lock | 8 ++++---- flake.nix | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flake.lock b/flake.lock index 8d25f52..6067f6e 100644 --- a/flake.lock +++ b/flake.lock @@ -20,16 +20,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1685573264, - "narHash": "sha256-Zffu01pONhs/pqH07cjlF10NnMDLok8ix5Uk4rhOnZQ=", + "lastModified": 1695416179, + "narHash": "sha256-610o1+pwbSu+QuF3GE0NU5xQdTHM3t9wyYhB9l94Cd8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "380be19fbd2d9079f677978361792cb25e8a3635", + "rev": "715d72e967ec1dd5ecc71290ee072bcaf5181ed6", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixos-22.05", + "ref": "nixos-23.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index ee0ead0..b60c2b8 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,7 @@ inputs = { flake-utils.url = "github:numtide/flake-utils"; - nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-23.05"; }; outputs = { self, nixpkgs, flake-utils, ... }: From e7acf8f5382ee7c0ef8701f14504b477d8fd0d81 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 15 Oct 2023 22:49:29 +0200 Subject: [PATCH 63/76] Add poetry.lock, closes #16 --- poetry.lock | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 poetry.lock 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" From 9b8ba82d545c4d8c3162228861703f7a44359aa4 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 15 Oct 2023 22:50:54 +0200 Subject: [PATCH 64/76] monitors plugin: don't fail if monitor is unknown --- pyprland/plugins/monitors.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index a047132..d40b753 100644 --- a/pyprland/plugins/monitors.py +++ b/pyprland/plugins/monitors.py @@ -82,7 +82,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 From 07e27e225dc7421551083df2ee58f737587aa0d9 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Sun, 15 Oct 2023 23:16:40 +0200 Subject: [PATCH 65/76] Add a nicer assertion for addresses --- pyprland/plugins/scratchpads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index 43ff936..f05d6de 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -13,6 +13,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: From 4c8c570c7b941f5800d422937c2fa315451d710a Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 16 Oct 2023 00:15:10 +0200 Subject: [PATCH 66/76] Don't assume clients will be moved as expected, closes #17 --- pyprland/plugins/scratchpads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index f05d6de..bf2a4d7 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -248,7 +248,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) From f4597f4fd46c8dc1b4baeeb71188703469ca1f5b Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 16 Oct 2023 00:19:25 +0200 Subject: [PATCH 67/76] add couple of logs & asserts Also invert the zombie process logic --- pyprland/command.py | 5 +++++ pyprland/plugins/scratchpads.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) 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/scratchpads.py b/pyprland/plugins/scratchpads.py index bf2a4d7..841a9a8 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -102,7 +102,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: @@ -199,7 +199,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] @@ -375,9 +376,13 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring 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") while uid in self._respawned_scratches: await asyncio.sleep(0.05) + self.log.info("<== spawned!") item.visible = True monitor = await get_focused_monitor_props() @@ -385,6 +390,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() From 8c7ad933ab112729a3334db96249551ed2d8387b Mon Sep 17 00:00:00 2001 From: iliayar Date: Tue, 17 Oct 2023 03:50:03 +0300 Subject: [PATCH 68/76] fix scratchapds: rework _convert_coords --- pyprland/plugins/scratchpads.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index ddcb6f1..5c8ba0b 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -391,14 +391,14 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring await hyprctl(f"focuswindow {addr}") - size = self._convert_coords(item.conf.get("size"), monitor) + size = item.conf.get("size") if size: - x_size, y_size = size + x_size, y_size = self._convert_coords(size, monitor) await hyprctl(f"resizewindowpixel exact {x_size} {y_size},{addr}") - position = self._convert_coords(item.conf.get("position"), monitor) + position = item.conf.get("position") if position: - x_pos, y_pos = 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}") @@ -415,8 +415,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring "10% 20%", monitor 800x600 => 80, 120 """ - if not coords: - return None + assert coords, "coords must be non null" def convert(s, dim): if s[-1] == "%": @@ -434,4 +433,4 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring return convert(x_str, "width"), convert(y_str, "height") except Exception as e: self.log.error(f"Failed to read coordinates: {e}") - return None + raise e From 2074554268098f951359fbba9075b9a10771a741 Mon Sep 17 00:00:00 2001 From: iliayar Date: Tue, 17 Oct 2023 03:53:46 +0300 Subject: [PATCH 69/76] fix: remove poetry.lock --- poetry.lock | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 poetry.lock diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 2ddd539..0000000 --- a/poetry.lock +++ /dev/null @@ -1,8 +0,0 @@ -package = [] - -[metadata] -lock-version = "1.1" -python-versions = "^3.10" -content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" - -[metadata.files] From 8993a6214963f0e38364a6b131dcf3942fccc747 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 23 Oct 2023 15:24:43 +0200 Subject: [PATCH 70/76] monitors: add relayout command --- pyprland/plugins/monitors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyprland/plugins/monitors.py b/pyprland/plugins/monitors.py index d40b753..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( From 2a09103f1087e632d271514df44163a8f6ac238e Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 23 Oct 2023 20:30:21 +0200 Subject: [PATCH 71/76] more informative log --- pyprland/plugins/scratchpads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index e788771..df3a96f 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -382,7 +382,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring self.log.info("==> Wait for spawning") while uid in self._respawned_scratches: await asyncio.sleep(0.05) - self.log.info("<== spawned!") + self.log.info(f"=> spawned {uid} as proc {item.pid}") item.visible = True monitor = await get_focused_monitor_props() From 8d443a541fdc1439079d8d26b9a1c01afdd1b2bd Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 23 Oct 2023 20:30:29 +0200 Subject: [PATCH 72/76] README: add WIP items --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6bfc4d4..3de406c 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` +- bugfixes + # 1.4.1 - minor bugfixes From efebc234b557d80876091f398ba8cda210a61c06 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Mon, 23 Oct 2023 20:39:26 +0200 Subject: [PATCH 73/76] add a couple of logs --- pyprland/plugins/scratchpads.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index df3a96f..d6a7775 100644 --- a/pyprland/plugins/scratchpads.py +++ b/pyprland/plugins/scratchpads.py @@ -303,6 +303,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring """Update every scratchpads information if no `scratch` given, else update a specific scratchpad info""" if scratch is None: + self.log.info("update from None") for client in await hyprctlJSON("clients"): assert isinstance(client, dict) scratch = self.scratches_by_address.get(client["address"][2:]) @@ -316,6 +317,7 @@ class Extension(Plugin): # pylint: disable=missing-class-docstring add_to_address_book = ("address" not in scratch.client_info) or ( scratch.address not in self.scratches_by_address ) + self.log.info(f"update from something, adding: {add_to_address_book}") await scratch.updateClientInfo() if add_to_address_book: self.scratches_by_address[scratch.client_info["address"][2:]] = scratch From 02de5fbc7643b52369775060e697c75730448dd7 Mon Sep 17 00:00:00 2001 From: Fabien Devaux Date: Mon, 23 Oct 2023 20:45:33 +0200 Subject: [PATCH 74/76] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3de406c..4b168d9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Check the [wiki](https://github.com/hyprland-community/pyprland/wiki) for more i # 1.4.2 (WIP) -- [two new options](https://github.com/hyprland-community/pyprland/wiki/Plugins#size-optional) for scratchpads: `position` and `size` +- [two new options](https://github.com/hyprland-community/pyprland/wiki/Plugins#size-optional) for scratchpads: `position` and `size` - from @iliayar - bugfixes # 1.4.1 From 1653d383d363062f49769f67d0dc27c3b3b1c735 Mon Sep 17 00:00:00 2001 From: fdev31 Date: Wed, 25 Oct 2023 19:44:04 +0200 Subject: [PATCH 75/76] Experimental logic chnge --- pyprland/plugins/scratchpads.py | 76 +++++++++++++++++---------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/pyprland/plugins/scratchpads.py b/pyprland/plugins/scratchpads.py index d6a7775..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 @@ -217,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 @@ -299,28 +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: - self.log.info("update from 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 - ) - self.log.info(f"update from something, adding: {add_to_address_book}") - 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" """ @@ -332,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: @@ -351,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() @@ -369,22 +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] - self.log.info(f"starting {uid}") - await self.start_scratch_command(uid) - self.log.info(f"{uid} started") - self.log.info("==> Wait for spawning") - while uid in self._respawned_scratches: - await asyncio.sleep(0.05) - self.log.info(f"=> spawned {uid} as proc {item.pid}") + await self.ensure_alive(uid, item) item.visible = True monitor = await get_focused_monitor_props() From fff23c250e6d701029fd7491c8373e52d73fd5c2 Mon Sep 17 00:00:00 2001 From: Hydroxycarbamide Date: Sun, 29 Oct 2023 20:23:10 +0100 Subject: [PATCH 76/76] expose: disable togglespecialworkspace when hiding the workspace because movetoworkspacesilent is already doing it --- pyprland/plugins/expose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: