From c5e97866145d6f7b5ddfa3e3c5e19526e207801d Mon Sep 17 00:00:00 2001 From: Jonathan Haylett Date: Sun, 20 Apr 2025 00:25:42 +0100 Subject: [PATCH 1/4] Add types.py with models for config rules to help reuse code --- i3_resurrect/__init__.py | 3 +- i3_resurrect/types.py | 120 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 i3_resurrect/types.py diff --git a/i3_resurrect/__init__.py b/i3_resurrect/__init__.py index 3e64c2f..d9c0d20 100644 --- a/i3_resurrect/__init__.py +++ b/i3_resurrect/__init__.py @@ -1,8 +1,9 @@ -__all__ = ["config", "layout", "main", "programs", "treeutils", "util"] +__all__ = ["config", "layout", "main", "programs", "treeutils", "types", "util"] from . import config from . import layout from . import main from . import programs from . import treeutils +from . import types from . import util diff --git a/i3_resurrect/types.py b/i3_resurrect/types.py new file mode 100644 index 0000000..66e602b --- /dev/null +++ b/i3_resurrect/types.py @@ -0,0 +1,120 @@ +from abc import ABC +import json +import re +import shlex +from typing import Sequence, TypeVar + +from . import util + +# Window properties and value to add to score when match is found. +CRITERIA = { + "window_role": 1, + "class": 2, + "instance": 3, + "title": 10, +} + + +class Rule(ABC): + T = TypeVar("T", bound="Rule") + + def __init__(self, filters: dict): + self.filters = filters + + def get_match_score(self, window_properties: dict) -> int: + """ + Score window command mapping match based on which criteria match. + + Scoring is done based on which criteria are considered "more specific" and thus have higher + weight assigned. + """ + score = 0 + for criterion in CRITERIA: + if criterion in self.filters: + # Score is zero if there are any non-matching criteria. + if ( + criterion not in window_properties + or re.match(self.filters[criterion], window_properties[criterion]) + is None + ): + return 0 + score += CRITERIA[criterion] + return score + + @classmethod + def find_best_matching_rule( + cls: type[T], window_properties: dict, rules: Sequence[T] + ) -> T | None: + # Find the mapping that gets the highest score. + current_score = 0 + best_match = None + for rule in rules: + # Calculate score. + score = rule.get_match_score(window_properties) + + # Update best match. + if score > current_score: + current_score = score + best_match = rule + + return best_match + + +class WindowCommandMapping(Rule): + def __init__(self, filters: dict, command: str | list[str]): + super().__init__(filters) + self.command = command + + def format_cmdline(self, cmdline: list[str]) -> list[str]: + try: + if self.command is None: + return [] + if isinstance(self.command, list): + # If replacement command is array, substitute original args into each arg of replacement + # command template. + return [ + arg.format(*cmdline, args=" ".join(cmdline[1:])) + for arg in self.command + ] + else: + # If command mapping is string, just do the substitution once, then split into array so + # it's in a normalized form. + return shlex.split( + self.command.format(*cmdline, args=" ".join(cmdline[1:])) + ) + except IndexError: + util.eprint( + "IndexError occurred while processing command mapping:\n" + f" Mapping: {json.dumps(self)}\n" + f" Process cmdline: {cmdline}" + ) + return [] + + +class WindowSwallowMapping(Rule): + def __init__(self, filters: dict, swallow_criteria: dict[str, str] | list[str]): + super().__init__(filters) + self.swallow_criteria = swallow_criteria + + def get_swallow_values(self, window_properties: dict) -> dict: + if isinstance(self.swallow_criteria, list): + # For list-style swallow criteria, use values from window properties. + swallow_values = { + criteria: f"^{re.escape(window_properties[criteria])}$" + for criteria in self.swallow_criteria + } + + return swallow_values + else: + # Use swallow criteria dict values as defined by user. + swallow_values = self.swallow_criteria + + # But for swallow criteria where value is null or empty string, fall back to value from + # window properties. + for criteria, value in swallow_values.items(): + if value is None or value == "": + swallow_values[criteria] = ( + f"^{re.escape(window_properties[criteria])}$" + ) + + return swallow_values From 94673e75be53694d2d7343f12f7f5dc8c9b6d08f Mon Sep 17 00:00:00 2001 From: Jonathan Haylett Date: Sun, 20 Apr 2025 00:26:10 +0100 Subject: [PATCH 2/4] Update dependencies and add debugpy --- Pipfile | 1 + Pipfile.lock | 98 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/Pipfile b/Pipfile index f8e8a30..0a98fd6 100644 --- a/Pipfile +++ b/Pipfile @@ -10,6 +10,7 @@ coveralls = "*" black = "*" pylint = "*" jedi = "*" +debugpy = "*" [packages] click = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 5d03228..f1f68b2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "89493feb86064ad9807b9537c78cd1c0507212a3b4c4fb0f172125761ad30b94" + "sha256": "4935a9a201d418ad7bc7ac41591e28db28c18924edf3f2b856866908281fc14a" }, "pipfile-spec": 6, "requires": { @@ -85,6 +85,35 @@ "markers": "python_full_version >= '3.9.0'", "version": "==3.3.9" }, + "black": { + "hashes": [ + "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", + "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", + "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", + "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", + "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", + "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", + "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", + "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", + "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", + "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", + "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", + "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", + "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", + "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", + "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", + "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", + "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", + "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", + "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", + "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", + "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", + "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==25.1.0" + }, "cachetools": { "hashes": [ "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", @@ -207,6 +236,15 @@ "markers": "python_version >= '3.7'", "version": "==3.4.1" }, + "click": { + "hashes": [ + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==8.1.8" + }, "colorama": { "hashes": [ "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", @@ -296,6 +334,39 @@ "markers": "python_version < '3.13' and python_version >= '3.8'", "version": "==4.0.1" }, + "debugpy": { + "hashes": [ + "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", + "sha256:1b2ac8c13b2645e0b1eaf30e816404990fbdb168e193322be8f545e8c01644a9", + "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", + "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", + "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", + "sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79", + "sha256:413512d35ff52c2fb0fd2d65e69f373ffd24f0ecb1fac514c04a668599c5ce7f", + "sha256:4c9156f7524a0d70b7a7e22b2e311d8ba76a15496fb00730e46dcdeedb9e1eea", + "sha256:5349b7c3735b766a281873fbe32ca9cca343d4cc11ba4a743f84cb854339ff35", + "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", + "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", + "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", + "sha256:7118d462fe9724c887d355eef395fae68bc764fd862cdca94e70dcb9ade8a23d", + "sha256:7816acea4a46d7e4e50ad8d09d963a680ecc814ae31cdef3622eb05ccacf7b01", + "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", + "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", + "sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339", + "sha256:b1528cfee6c1b1c698eb10b6b096c598738a8238822d218173d21c3086de8123", + "sha256:b44985f97cc3dd9d52c42eb59ee9d7ee0c4e7ecd62bca704891f997de4cef23d", + "sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987", + "sha256:c99295c76161ad8d507b413cd33422d7c542889fbb73035889420ac1fad354f2", + "sha256:cf431c343a99384ac7eab2f763980724834f933a271e90496944195318c619e2", + "sha256:d235e4fa78af2de4e5609073972700523e372cf5601742449970110d565ca28c", + "sha256:d5582bcbe42917bc6bbe5c12db1bffdf21f6bfc28d4554b738bf08d50dc0c8c3", + "sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84", + "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.8.14" + }, "dill": { "hashes": [ "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", @@ -366,6 +437,14 @@ "markers": "python_version >= '3.6'", "version": "==0.7.0" }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, "packaging": { "hashes": [ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", @@ -382,6 +461,14 @@ "markers": "python_version >= '3.6'", "version": "==0.8.4" }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, "platformdirs": { "hashes": [ "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", @@ -464,15 +551,6 @@ ], "markers": "python_version >= '3.8'", "version": "==20.30.0" - }, - "yapf": { - "hashes": [ - "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", - "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==0.43.0" } } } From 4cadb9606c1d4633a8822331e449ac8d93552729 Mon Sep 17 00:00:00 2001 From: Jonathan Haylett Date: Sun, 20 Apr 2025 00:28:01 +0100 Subject: [PATCH 3/4] Refactor to use new types for command mapping rules --- i3_resurrect/main.py | 2 +- i3_resurrect/programs.py | 91 ++++---------------- tests/test_programs.py | 174 ++++++++++++++++++++++----------------- 3 files changed, 116 insertions(+), 151 deletions(-) diff --git a/i3_resurrect/main.py b/i3_resurrect/main.py index f72bd0f..ac1c0b8 100644 --- a/i3_resurrect/main.py +++ b/i3_resurrect/main.py @@ -1,5 +1,5 @@ -import sys from pathlib import Path +import sys import click import i3ipc diff --git a/i3_resurrect/programs.py b/i3_resurrect/programs.py index 35951f1..8ee19b1 100644 --- a/i3_resurrect/programs.py +++ b/i3_resurrect/programs.py @@ -1,13 +1,15 @@ import json +from pathlib import Path import shlex import shutil import subprocess import sys -from pathlib import Path import i3ipc import psutil +from i3_resurrect.types import WindowCommandMapping + from . import config from . import treeutils from . import util @@ -24,8 +26,6 @@ def save(workspace, numeric, directory, profile): filename = f"{profile}_programs.json" programs_file = Path(directory) / filename - window_command_mappings = config.get("window_command_mappings", []) - programs = get_programs(workspace, numeric) # Write list of commands to file as JSON. @@ -33,7 +33,7 @@ def save(workspace, numeric, directory, profile): f.write(json.dumps(programs, indent=2)) -def read(workspace, directory, profile): +def read(workspace: str, directory: Path, profile: str | None) -> list[dict]: """ Read saved programs file. """ @@ -55,7 +55,7 @@ def read(workspace, directory, profile): return programs -def restore(workspace_name, saved_programs): +def restore(workspace_name: str, saved_programs: list[dict]): """ Restore the running programs from an i3 workspace. """ @@ -94,7 +94,7 @@ def restore(workspace_name, saved_programs): i3.command(f'exec "cd \\"{working_directory}\\" && {command}"') -def get_programs(workspace, numeric): +def get_programs(workspace: str, numeric: bool) -> list[dict]: """ Get running programs in specified workspace. @@ -150,7 +150,7 @@ def get_programs(workspace, numeric): return programs -def windows_in_workspace(workspace, numeric): +def windows_in_workspace(workspace: str, numeric: bool): """ Generator to iterate over windows in a workspace. @@ -163,7 +163,7 @@ def windows_in_workspace(workspace, numeric): yield (con, pid) -def get_window_pid(con): +def get_window_pid(con) -> int: """ Get window PID using xprop. @@ -190,7 +190,9 @@ def get_window_pid(con): return pid -def get_window_command(window_properties, cmdline, exe): +def get_window_command( + window_properties: dict, cmdline: list[str], exe: str | None +) -> list[str]: """ Gets a window command. @@ -198,8 +200,6 @@ def get_window_command(window_properties, cmdline, exe): window mappings and scores each matching rule. The command mapping with the highest score is then returned. """ - window_command_mappings = config.get("window_command_mappings", []) - # Remove empty args from cmdline. cmdline = [arg for arg in cmdline if arg != ""] @@ -214,71 +214,12 @@ def get_window_command(window_properties, cmdline, exe): if exe is not None: cmdline[0] = exe - command = cmdline - - # If window command mappings is a dictionary in the config file, use the - # old way. - # TODO: Remove in 2.0.0 - if isinstance(window_command_mappings, dict): - window_class = window_properties["class"] - if window_class in window_command_mappings: - command = window_command_mappings[window_class] - return command - - # Find the mapping that gets the highest score. - current_score = 0 - best_match = None - for rule in window_command_mappings: - # Calculate score. - score = calc_rule_match_score(rule, window_properties) - - if score > current_score: - current_score = score - best_match = rule + best_match = WindowCommandMapping.find_best_matching_rule( + window_properties, config.get_window_command_mappings() + ) # If no match found, just use the original cmdline. if best_match is None: - return command + return cmdline - try: - if "command" not in best_match: - command = [] - elif isinstance(best_match["command"], list): - command = [arg.format(*cmdline) for arg in best_match["command"]] - else: - command = shlex.split(best_match["command"].format(*cmdline)) - except IndexError: - util.eprint( - "IndexError occurred while processing command mapping:\n" - f" Mapping: {best_match}\n" - f" Process cmdline: {cmdline}" - ) - - return command - - -def calc_rule_match_score(rule, window_properties): - """ - Score window command mapping match based on which criteria match. - - Scoring is done based on which criteria are considered "more specific". - """ - # Window properties and value to add to score when match is found. - criteria = { - "window_role": 1, - "class": 2, - "instance": 3, - "title": 10, - } - - score = 0 - for criterion in criteria: - if criterion in rule: - # Score is zero if there are any non-matching criteria. - if ( - criterion not in window_properties - or rule[criterion] != window_properties[criterion] - ): - return 0 - score += criteria[criterion] - return score + return best_match.format_cmdline(cmdline) diff --git a/tests/test_programs.py b/tests/test_programs.py index 358f0be..7774d6f 100644 --- a/tests/test_programs.py +++ b/tests/test_programs.py @@ -6,154 +6,178 @@ def test_get_window_command(monkeypatch): # Monkeypatch config. monkeypatch.setattr( config, - '_config', + "_config", { - 'window_command_mappings': [ + "window_command_mappings": [ { - 'class': 'Program1' + "filters": { + "class": "Program1", + }, }, { - 'class': 'Program1', - 'title': 'Main window title', - 'command': 'run_program1' + "filters": { + "class": "Program1", + "title": "Main window title", + }, + "command": "run_program1", }, { - 'title': 'Some arbitrary title', + "filters": { + "title": "Some arbitrary title", + } }, { - 'class': 'Program4', - 'command': 'run_program4 {1}' + "filters": { + "class": "Program4", + }, + "command": "run_program4 {1}", }, { - 'class': 'Program6', - 'command': 'chrome {1}' + "filters": { + "class": "Program6", + }, + "command": "chrome {1}", }, { - 'class': 'Program7', - 'command': ['/opt/Pulse SMS/pulse-sms'], - } + "filters": { + "class": "Program7", + }, + "command": ["/opt/Pulse SMS/pulse-sms"], + }, ], }, ) # Test class + title mapping. program1_main = { - 'class': 'Program1', - 'title': 'Main window title', + "class": "Program1", + "title": "Main window title", } assert programs.get_window_command( program1_main, - ['program1'], - '/usr/bin/program1', + ["program1"], + "/usr/bin/program1", ) == [ - 'run_program1', + "run_program1", ] # Test class only mapping. program1_secondary = { - 'class': 'Program1', - 'title': 'Blah random title', + "class": "Program1", + "title": "Blah random title", } - assert programs.get_window_command( - program1_secondary, - ['program1'], - '/usr/bin/program1', - ) == [] + assert ( + programs.get_window_command( + program1_secondary, + ["program1"], + "/usr/bin/program1", + ) + == [] + ) # Test with separate program window with matching title but not class. program2_main = { - 'class': 'Program2', - 'title': 'Main window title', + "class": "Program2", + "title": "Main window title", } assert programs.get_window_command( - program2_main, - ['program2'], - '/usr/bin/program2' - ) == ['/usr/bin/program2'] + program2_main, ["program2"], "/usr/bin/program2" + ) == ["/usr/bin/program2"] # Test that title only mapping matches any window with matching title. program3 = { - 'class': 'Program3', - 'title': 'Some arbitrary title', + "class": "Program3", + "title": "Some arbitrary title", } - assert programs.get_window_command( - program3, - ['program3'], - '/usr/bin/program3', - ) == [] + assert ( + programs.get_window_command( + program3, + ["program3"], + "/usr/bin/program3", + ) + == [] + ) # Test cmdline arg interpolation. program4 = { - 'class': 'Program4', - 'title': 'Blah random title', + "class": "Program4", + "title": "Blah random title", } assert programs.get_window_command( program4, - ['/opt/Program4/program4', '/tmp/test.txt'], - '/opt/Program4/program4', - ) == ['run_program4', '/tmp/test.txt'] + ["/opt/Program4/program4", "/tmp/test.txt"], + "/opt/Program4/program4", + ) == ["run_program4", "/tmp/test.txt"] # Test splitting of single arg command. program5 = { - 'class': 'Program5', - 'title': 'program 5 title', + "class": "Program5", + "title": "program 5 title", } assert programs.get_window_command( program5, - ['/opt/google/chrome/chrome --profile-directory=Default ' - '--app=http://instacalc.com --user-data-dir=.config'], - '/opt/google/chrome/chrome', + [ + "/opt/google/chrome/chrome --profile-directory=Default " + "--app=http://instacalc.com --user-data-dir=.config" + ], + "/opt/google/chrome/chrome", ) == [ - '/opt/google/chrome/chrome', - '--profile-directory=Default', - '--app=http://instacalc.com', - '--user-data-dir=.config', + "/opt/google/chrome/chrome", + "--profile-directory=Default", + "--app=http://instacalc.com", + "--user-data-dir=.config", ] # Test splitting of single arg command when used with mapping and cmdline # interpolation. program6 = { - 'class': 'Program6', + "class": "Program6", } assert programs.get_window_command( program6, - ['/opt/google/chrome/chrome --profile-directory=Default ' - '--app=http://instacalc.com --user-data-dir=.config'], - '/opt/google/chrome/chrome', + [ + "/opt/google/chrome/chrome --profile-directory=Default " + "--app=http://instacalc.com --user-data-dir=.config" + ], + "/opt/google/chrome/chrome", ) == [ - 'chrome', - '--profile-directory=Default', + "chrome", + "--profile-directory=Default", ] # Test single arg command with space in executable path. program7 = { - 'class': 'Program7', + "class": "Program7", } assert programs.get_window_command( - program7, - ['/opt/Pulse SMS/pulse-sms'], - None - ) == ['/opt/Pulse SMS/pulse-sms'] + program7, ["/opt/Pulse SMS/pulse-sms"], None + ) == ["/opt/Pulse SMS/pulse-sms"] # Test single arg command with space in executable path with exe available. program8 = { - 'class': 'Program8', + "class": "Program8", } assert programs.get_window_command( program8, - ['/opt/Pulse SMS/pulse-sms'], - '/opt/Pulse SMS/pulse-sms', - ) == ['/opt/Pulse SMS/pulse-sms', 'SMS/pulse-sms'] + ["/opt/Pulse SMS/pulse-sms"], + "/opt/Pulse SMS/pulse-sms", + ) == ["/opt/Pulse SMS/pulse-sms", "SMS/pulse-sms"] # Test cmdline with empty args is processed correctly. assert programs.get_window_command( program5, - ['/opt/google/chrome/chrome --profile-directory=Default ' - '--app=http://instacalc.com --user-data-dir=.config', '', '', '', ''], - '/opt/google/chrome/chrome', + [ + "/opt/google/chrome/chrome --profile-directory=Default " + "--app=http://instacalc.com --user-data-dir=.config", + "", + "", + "", + "", + ], + "/opt/google/chrome/chrome", ) == [ - '/opt/google/chrome/chrome', - '--profile-directory=Default', - '--app=http://instacalc.com', - '--user-data-dir=.config', + "/opt/google/chrome/chrome", + "--profile-directory=Default", + "--app=http://instacalc.com", + "--user-data-dir=.config", ] From ef58886f066712a1c3eed50946da3525d3aef4b0 Mon Sep 17 00:00:00 2001 From: Jonathan Haylett Date: Sun, 20 Apr 2025 00:29:01 +0100 Subject: [PATCH 4/4] Add support for more advanced window swallow overrides --- i3_resurrect/config.py | 56 +++- i3_resurrect/layout.py | 2 +- i3_resurrect/treeutils.py | 41 +-- tests/test_layout.py | 249 ++++------------- tests/test_treeutils.py | 544 +++++++++++++++----------------------- 5 files changed, 346 insertions(+), 546 deletions(-) diff --git a/i3_resurrect/config.py b/i3_resurrect/config.py index 6707111..3ec6df4 100644 --- a/i3_resurrect/config.py +++ b/i3_resurrect/config.py @@ -4,6 +4,11 @@ import json from pathlib import Path +from typing import Any + +from i3_resurrect.types import WindowCommandMapping, WindowSwallowMapping + +from . import util def create_default(): @@ -18,11 +23,25 @@ def create_default(): "directory": "~/.i3/i3-resurrect/", "window_command_mappings": [ { - "class": "^Gnome-terminal$", + "filters": { + "class": "^Gnome-terminal$", + }, "command": "gnome-terminal", }, ], - "window_swallow_criteria": {}, + "window_swallow_criteria": [ + { + "filters": { + "class": "^PCSX2$", + "title": "^Kingdom Hearts [0-9]*FPS - PCSX2$", + }, + "swallows": { + "class": "", + "instance": "", + "title": "^Kingdom Hearts [0-9]*FPS - PCSX2$", + }, + }, + ], "terminals": ["Gnome-terminal", "Alacritty"], } @@ -34,7 +53,7 @@ def create_default(): f.write(json.dumps(_config, indent=2)) -def get(key, default): +def get(key: str, default: Any) -> Any: """ Gets a config value. """ @@ -45,19 +64,42 @@ def get(key, default): try: _config = json.loads(_config_file.read_text()) except json.decoder.JSONDecodeError as e: - print(f'Error in config file: "{str(e)}"') + util.eprint(f'Error in config file: "{str(e)}"') exit(1) except PermissionError as e: - print(f"Could not read config file: {str(e)}") + util.eprint(f"Could not read config file: {str(e)}") exit(1) except FileNotFoundError: # Create default config if no config exists. create_default() + except Exception as e: + util.eprint(f"Unknown error") + + if _config is not None: + return _config.get(key, default) + + return None + + +def get_window_command_mappings() -> list[WindowCommandMapping]: + return [ + WindowCommandMapping( + command_mapping["filters"], command_mapping.get("command", None) + ) + for command_mapping in get("window_command_mappings", []) + ] + - return _config.get(key, default) +def get_window_swallow_mappings() -> list[WindowSwallowMapping]: + return [ + WindowSwallowMapping( + swallow_mapping.get("filters", {}), swallow_mapping.get("swallows", []) + ) + for swallow_mapping in get("window_swallow_criteria", []) + ] -_config = None +_config: dict | None = None _config_dir = Path("~/.config/i3-resurrect/").expanduser() _config_file = _config_dir / "config.json" diff --git a/i3_resurrect/layout.py b/i3_resurrect/layout.py index 43aaeee..1449031 100644 --- a/i3_resurrect/layout.py +++ b/i3_resurrect/layout.py @@ -1,9 +1,9 @@ import json +from pathlib import Path import shlex import subprocess import sys import tempfile -from pathlib import Path import i3ipc diff --git a/i3_resurrect/treeutils.py b/i3_resurrect/treeutils.py index 51982d3..2b68c02 100644 --- a/i3_resurrect/treeutils.py +++ b/i3_resurrect/treeutils.py @@ -3,6 +3,8 @@ import shlex import subprocess +from i3_resurrect.types import WindowSwallowMapping + from . import config # The tree node attributes that we want to save. @@ -24,7 +26,7 @@ ] -def process_node(original, swallow): +def process_node(original: dict, swallow: list[str]): """ Recursive function which traverses a layout tree and builds a new tree from it which can be restored using append_layout and only contains attributes @@ -51,21 +53,9 @@ def process_node(original, swallow): # Set swallow criteria if the node is a window. if "window_properties" in original: - processed["swallows"] = [{}] - # Local variable for swallow criteria. - swallow_criteria = swallow - # Get swallow criteria from config. - window_swallow_mappings = config.get("window_swallow_criteria", {}) - window_class = original["window_properties"].get("class", "") - # Swallow criteria from config override the command line parameters - # if present. - if window_class in window_swallow_mappings: - swallow_criteria = window_swallow_mappings[window_class] - for criterion in swallow_criteria: - if criterion in original["window_properties"]: - # Escape special characters in swallow criteria. - escaped = re.escape(original["window_properties"][criterion]) - processed["swallows"][0][criterion] = escaped + processed["swallows"] = get_window_swallow_values( + original["window_properties"], swallow + ) # Recurse over child nodes (normal and floating). for node_type in ["nodes", "floating_nodes"]: @@ -78,7 +68,22 @@ def process_node(original, swallow): return processed -def get_workspace_tree(workspace, numeric): +def get_window_swallow_values( + window_properties: dict, swallow: list[str] +) -> list[dict[str, str]]: + """ + Get swallow criteria for window + """ + # Look for matching swallow criteria mapping from config, or fall back to param from CLI if not + # found. + swallow_mapping = WindowSwallowMapping.find_best_matching_rule( + window_properties, config.get_window_swallow_mappings() + ) or WindowSwallowMapping({}, swallow) + + return [swallow_mapping.get_swallow_values(window_properties)] + + +def get_workspace_tree(workspace: str, numeric: bool): """ Get full workspace layout tree from i3. """ @@ -101,7 +106,7 @@ def get_workspace_tree(workspace, numeric): return {} -def get_leaves(container): +def get_leaves(container: dict | None): """ Recursive generator for retrieving a list of a container's leaf nodes. diff --git a/tests/test_layout.py b/tests/test_layout.py index bc05916..3754d75 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -6,11 +6,14 @@ def test_build_layout(monkeypatch): # Monkeypatch config. monkeypatch.setattr( config, - '_config', + "_config", { - 'window_swallow_criteria': { - 'Ario': ['class', 'instance'], - }, + "window_swallow_criteria": [ + { + "filters": {"class": "^Ario$"}, + "swallows": ["class", "instance"], + } + ], }, ) @@ -28,36 +31,13 @@ def test_build_layout(monkeypatch): "last_split_layout": "splith", "border": "normal", "current_border_width": -1, - "rect": { - "x": 1366, - "y": 0, - "width": 1920, - "height": 1048 - }, - "deco_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "window_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "geometry": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, + "rect": {"x": 1366, "y": 0, "width": 1920, "height": 1048}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, "name": "8", "num": 8, - "gaps": { - "inner": 0, - "outer": 0 - }, + "gaps": {"inner": 0, "outer": 0}, "window": None, "nodes": [ { @@ -74,30 +54,10 @@ def test_build_layout(monkeypatch): "last_split_layout": "splith", "border": "pixel", "current_border_width": 2, - "rect": { - "x": 1376, - "y": 10, - "width": 945, - "height": 1028 - }, - "deco_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "window_rect": { - "x": 2, - "y": 2, - "width": 941, - "height": 1024 - }, - "geometry": { - "x": 2049, - "y": 486, - "width": 553, - "height": 107 - }, + "rect": {"x": 1376, "y": 10, "width": 945, "height": 1028}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 1024}, + "geometry": {"x": 2049, "y": 486, "width": 553, "height": 107}, "name": "Ario", "title_format": " %title ", "window": 140509206, @@ -105,7 +65,7 @@ def test_build_layout(monkeypatch): "class": "Ario", "instance": "ario", "title": "Ario", - "transient_for": None + "transient_for": None, }, "nodes": [], "floating_nodes": [], @@ -113,7 +73,7 @@ def test_build_layout(monkeypatch): "fullscreen_mode": 0, "sticky": False, "floating": "auto_off", - "swallows": [] + "swallows": [], }, { "id": 94067986549632, @@ -129,30 +89,10 @@ def test_build_layout(monkeypatch): "last_split_layout": "splitv", "border": "normal", "current_border_width": -1, - "rect": { - "x": 2326, - "y": 0, - "width": 960, - "height": 1048 - }, - "deco_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "window_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "geometry": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, + "rect": {"x": 2326, "y": 0, "width": 960, "height": 1048}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, "name": None, "window": None, "nodes": [ @@ -170,30 +110,10 @@ def test_build_layout(monkeypatch): "last_split_layout": "splith", "border": "pixel", "current_border_width": 2, - "rect": { - "x": 2331, - "y": 10, - "width": 945, - "height": 509 - }, - "deco_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "window_rect": { - "x": 2, - "y": 2, - "width": 941, - "height": 505 - }, - "geometry": { - "x": 2331, - "y": 10, - "width": 941, - "height": 1024 - }, + "rect": {"x": 2331, "y": 10, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 2331, "y": 10, "width": 941, "height": 1024}, "name": "Faster Melee - Slippi (r18)", "title_format": " %title ", "window": 142606648, @@ -201,7 +121,7 @@ def test_build_layout(monkeypatch): "class": "Dolphin-emu", "instance": "dolphin-emu", "title": "Faster Melee - Slippi (r18)", - "transient_for": None + "transient_for": None, }, "nodes": [], "floating_nodes": [], @@ -209,7 +129,7 @@ def test_build_layout(monkeypatch): "fullscreen_mode": 0, "sticky": False, "floating": "auto_off", - "swallows": [] + "swallows": [], }, { "id": 94067986105456, @@ -225,30 +145,10 @@ def test_build_layout(monkeypatch): "last_split_layout": "splith", "border": "pixel", "current_border_width": 2, - "rect": { - "x": 2331, - "y": 529, - "width": 945, - "height": 509 - }, - "deco_rect": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, - "window_rect": { - "x": 2, - "y": 2, - "width": 941, - "height": 505 - }, - "geometry": { - "x": 2542, - "y": 344, - "width": 518, - "height": 356 - }, + "rect": {"x": 2331, "y": 529, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 2542, "y": 344, "width": 518, "height": 356}, "name": "Dolphin NetPlay Setup", "title_format": " %title ", "window": 142607489, @@ -256,7 +156,7 @@ def test_build_layout(monkeypatch): "class": "Dolphin-emu", "instance": "dolphin-emu", "title": "Dolphin NetPlay Setup", - "transient_for": None + "transient_for": None, }, "nodes": [], "floating_nodes": [], @@ -264,29 +164,23 @@ def test_build_layout(monkeypatch): "fullscreen_mode": 0, "sticky": False, "floating": "auto_off", - "swallows": [] - } + "swallows": [], + }, ], "floating_nodes": [], - "focus": [ - 94067986105456, - 94067986605168 - ], + "focus": [94067986105456, 94067986605168], "fullscreen_mode": 0, "sticky": False, "floating": "auto_off", - "swallows": [] - } + "swallows": [], + }, ], "floating_nodes": [], - "focus": [ - 94067986549632, - 94067985558992 - ], + "focus": [94067986549632, 94067985558992], "fullscreen_mode": 0, "sticky": False, "floating": "auto_off", - "swallows": [] + "swallows": [], } expected_tree = { "type": "workspace", @@ -300,12 +194,8 @@ def test_build_layout(monkeypatch): "current_border_width": -1, "floating": "auto_off", "fullscreen_mode": 0, - "geometry": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, + "output": "HDMI-1-1", + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, "name": "8", "nodes": [ { @@ -313,12 +203,7 @@ def test_build_layout(monkeypatch): "current_border_width": 2, "floating": "auto_off", "fullscreen_mode": 0, - "geometry": { - "x": 2049, - "y": 486, - "width": 553, - "height": 107 - }, + "geometry": {"x": 2049, "y": 486, "width": 553, "height": 107}, "layout": "splith", "name": "Ario", "orientation": "none", @@ -326,25 +211,15 @@ def test_build_layout(monkeypatch): "scratchpad_state": "none", "type": "con", "workspace_layout": "default", - "swallows": [ - { - "class": "^Ario$", - "instance": "^ario$" - } - ], - "sticky": False + "swallows": [{"class": "^Ario$", "instance": "^ario$"}], + "sticky": False, }, { "border": "normal", "current_border_width": -1, "floating": "auto_off", "fullscreen_mode": 0, - "geometry": { - "x": 0, - "y": 0, - "width": 0, - "height": 0 - }, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, "layout": "splitv", "name": None, "orientation": "vertical", @@ -359,12 +234,7 @@ def test_build_layout(monkeypatch): "current_border_width": 2, "floating": "auto_off", "fullscreen_mode": 0, - "geometry": { - "x": 2331, - "y": 10, - "width": 941, - "height": 1024 - }, + "geometry": {"x": 2331, "y": 10, "width": 941, "height": 1024}, "layout": "splith", "name": "Faster Melee - Slippi (r18)", "orientation": "none", @@ -377,21 +247,16 @@ def test_build_layout(monkeypatch): { "class": "^Dolphin\\-emu$", "instance": "^dolphin\\-emu$", - "title": "^Faster\\ Melee\\ \\-\\ Slippi\\ \\(r18\\)$" + "title": "^Faster\\ Melee\\ \\-\\ Slippi\\ \\(r18\\)$", } - ] + ], }, { "border": "pixel", "current_border_width": 2, "floating": "auto_off", "fullscreen_mode": 0, - "geometry": { - "x": 2542, - "y": 344, - "width": 518, - "height": 356 - }, + "geometry": {"x": 2542, "y": 344, "width": 518, "height": 356}, "layout": "splith", "name": "Dolphin NetPlay Setup", "orientation": "none", @@ -404,13 +269,13 @@ def test_build_layout(monkeypatch): { "class": "^Dolphin\\-emu$", "instance": "^dolphin\\-emu$", - "title": "^Dolphin\\ NetPlay\\ Setup$" + "title": "^Dolphin\\ NetPlay\\ Setup$", } - ] - } - ] - } - ] + ], + }, + ], + }, + ], } - tree = layout.build_layout(workspace_container, ['class', 'instance', 'title']) + tree = layout.build_layout(workspace_container, ["class", "instance", "title"]) assert tree == expected_tree diff --git a/tests/test_treeutils.py b/tests/test_treeutils.py index 4adc5e5..3dcb62a 100644 --- a/tests/test_treeutils.py +++ b/tests/test_treeutils.py @@ -3,346 +3,234 @@ def test_windows_in_container(): workspace_tree = { - 'id': 93860418230528, - 'type': 'workspace', - 'orientation': 'horizontal', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splith', - 'workspace_layout': 'default', - 'last_split_layout': 'splith', - 'border': 'normal', - 'current_border_width': -1, - 'rect': {'x': 1366, 'y': 0, 'width': 1920, 'height': 1048}, - 'deco_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'window_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'geometry': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'name': '2', - 'num': 2, - 'gaps': {'inner': 0, 'outer': 0}, - 'window': None, - 'nodes': [ + "id": 93860418230528, + "type": "workspace", + "orientation": "horizontal", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splith", + "workspace_layout": "default", + "last_split_layout": "splith", + "border": "normal", + "current_border_width": -1, + "rect": {"x": 1366, "y": 0, "width": 1920, "height": 1048}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, + "name": "2", + "num": 2, + "gaps": {"inner": 0, "outer": 0}, + "window": None, + "nodes": [ { - 'id': 93860418434672, - 'type': 'con', - 'orientation': 'vertical', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splitv', - 'workspace_layout': 'default', - 'last_split_layout': 'splitv', - 'border': 'normal', - 'current_border_width': -1, - 'rect': {'x': 1366, 'y': 0, 'width': 960, 'height': 1048}, - 'deco_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'window_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'geometry': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'name': None, - 'window': None, - 'nodes': [ + "id": 93860418434672, + "type": "con", + "orientation": "vertical", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splitv", + "workspace_layout": "default", + "last_split_layout": "splitv", + "border": "normal", + "current_border_width": -1, + "rect": {"x": 1366, "y": 0, "width": 960, "height": 1048}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, + "name": None, + "window": None, + "nodes": [ { - 'id': 93860418452384, - 'type': 'con', - 'orientation': 'none', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splith', - 'workspace_layout': 'default', - 'last_split_layout': 'splith', - 'border': 'pixel', - 'current_border_width': 2, - 'rect': { - 'x': 1376, - 'y': 10, - 'width': 945, - 'height': 509 - }, - 'deco_rect': { - 'x': 0, - 'y': 0, - 'width': 0, - 'height': 0 - }, - 'window_rect': { - 'x': 2, - 'y': 2, - 'width': 941, - 'height': 505 - }, - 'geometry': { - 'x': 0, - 'y': 0, - 'width': 724, - 'height': 412 - }, - 'name': '~/Projects', - 'title_format': ' %title ', - 'window': 52428803, - 'window_properties': { - 'class': 'Alacritty', - 'instance': 'Alacritty', - 'title': '~/Projects', - 'transient_for': None - }, - 'nodes': [ - - ], - 'floating_nodes':[ - - ], - 'focus':[ - - ], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[ - - ] + "id": 93860418452384, + "type": "con", + "orientation": "none", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splith", + "workspace_layout": "default", + "last_split_layout": "splith", + "border": "pixel", + "current_border_width": 2, + "rect": {"x": 1376, "y": 10, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 0, "y": 0, "width": 724, "height": 412}, + "name": "~/Projects", + "title_format": " %title ", + "window": 52428803, + "window_properties": { + "class": "Alacritty", + "instance": "Alacritty", + "title": "~/Projects", + "transient_for": None, + }, + "nodes": [], + "floating_nodes": [], + "focus": [], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], }, { - 'id': 93860418285248, - 'type': 'con', - 'orientation': 'none', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splith', - 'workspace_layout': 'default', - 'last_split_layout': 'splith', - 'border': 'pixel', - 'current_border_width': 2, - 'rect': { - 'x': 1376, - 'y': 529, - 'width': 945, - 'height': 509 - }, - 'deco_rect': { - 'x': 0, - 'y': 0, - 'width': 0, - 'height': 0 - }, - 'window_rect': { - 'x': 2, - 'y': 2, - 'width': 941, - 'height': 505 - }, - 'geometry': { - 'x': 0, - 'y': 0, - 'width': 724, - 'height': 412 - }, - 'name': '~/.dotfiles', - 'title_format': ' %title ', - 'window': 6291459, - 'window_properties': { - 'class': 'Alacritty', - 'instance': 'Alacritty', - 'title': '~/.dotfiles', - 'transient_for': None - }, - 'nodes': [ - - ], - 'floating_nodes':[ - - ], - 'focus':[ - - ], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[] - } + "id": 93860418285248, + "type": "con", + "orientation": "none", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splith", + "workspace_layout": "default", + "last_split_layout": "splith", + "border": "pixel", + "current_border_width": 2, + "rect": {"x": 1376, "y": 529, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 0, "y": 0, "width": 724, "height": 412}, + "name": "~/.dotfiles", + "title_format": " %title ", + "window": 6291459, + "window_properties": { + "class": "Alacritty", + "instance": "Alacritty", + "title": "~/.dotfiles", + "transient_for": None, + }, + "nodes": [], + "floating_nodes": [], + "focus": [], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], + }, ], - 'floating_nodes':[], - 'focus':[93860418285248, 93860418452384], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[ - - ] + "floating_nodes": [], + "focus": [93860418285248, 93860418452384], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], }, { - 'id': 93860418798800, - 'type': 'con', - 'orientation': 'vertical', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splitv', - 'workspace_layout': 'default', - 'last_split_layout': 'splitv', - 'border': 'normal', - 'current_border_width': -1, - 'rect': {'x': 2326, 'y': 0, 'width': 960, 'height': 1048}, - 'deco_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'window_rect': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'geometry': {'x': 0, 'y': 0, 'width': 0, 'height': 0}, - 'name': None, - 'window': None, - 'nodes': [ + "id": 93860418798800, + "type": "con", + "orientation": "vertical", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splitv", + "workspace_layout": "default", + "last_split_layout": "splitv", + "border": "normal", + "current_border_width": -1, + "rect": {"x": 2326, "y": 0, "width": 960, "height": 1048}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "geometry": {"x": 0, "y": 0, "width": 0, "height": 0}, + "name": None, + "window": None, + "nodes": [ { - 'id': 93860418425760, - 'type': 'con', - 'orientation': 'none', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splith', - 'workspace_layout': 'default', - 'last_split_layout': 'splith', - 'border': 'pixel', - 'current_border_width': 2, - 'rect': { - 'x': 2331, - 'y': 10, - 'width': 945, - 'height': 509 - }, - 'deco_rect': { - 'x': 0, - 'y': 0, - 'width': 0, - 'height': 0 - }, - 'window_rect': { - 'x': 2, - 'y': 2, - 'width': 941, - 'height': 505 - }, - 'geometry': { - 'x': 0, - 'y': 0, - 'width': 1366, - 'height': 736 - }, - 'name': 'System Monitor', - 'title_format': ' %title ', - 'window': 54525962, - 'window_properties': { - 'class': 'ksysguard', - 'instance': 'ksysguard', - 'window_role': 'MainWindow#1', - 'title': 'System Monitor', - 'transient_for': None - }, - 'nodes': [ - - ], - 'floating_nodes':[ - - ], - 'focus':[ - - ], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[ - - ] + "id": 93860418425760, + "type": "con", + "orientation": "none", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splith", + "workspace_layout": "default", + "last_split_layout": "splith", + "border": "pixel", + "current_border_width": 2, + "rect": {"x": 2331, "y": 10, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 0, "y": 0, "width": 1366, "height": 736}, + "name": "System Monitor", + "title_format": " %title ", + "window": 54525962, + "window_properties": { + "class": "ksysguard", + "instance": "ksysguard", + "window_role": "MainWindow#1", + "title": "System Monitor", + "transient_for": None, + }, + "nodes": [], + "floating_nodes": [], + "focus": [], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], }, { - 'id': 93860418808208, - 'type': 'con', - 'orientation': 'none', - 'scratchpad_state': 'none', - 'percent': 0.5, - 'urgent': False, - 'focused': False, - 'output': 'HDMI-1-1', - 'layout': 'splith', - 'workspace_layout': 'default', - 'last_split_layout': 'splith', - 'border': 'pixel', - 'current_border_width': 2, - 'rect': { - 'x': 2331, - 'y': 529, - 'width': 945, - 'height': 509 - }, - 'deco_rect': { - 'x': 0, - 'y': 0, - 'width': 0, - 'height': 0 - }, - 'window_rect': { - 'x': 2, - 'y': 2, - 'width': 941, - 'height': 505 - }, - 'geometry': { - 'x': 0, - 'y': 0, - 'width': 724, - 'height': 412 - }, - 'name': '~/.dotfiles', - 'title_format': ' %title ', - 'window': 50331651, - 'window_properties': { - 'class': 'Alacritty', - 'instance': 'Alacritty', - 'title': '~/.dotfiles', - 'transient_for': None - }, - 'nodes': [ - - ], - 'floating_nodes':[ - - ], - 'focus':[ - - ], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[ - - ] - } + "id": 93860418808208, + "type": "con", + "orientation": "none", + "scratchpad_state": "none", + "percent": 0.5, + "urgent": False, + "focused": False, + "output": "HDMI-1-1", + "layout": "splith", + "workspace_layout": "default", + "last_split_layout": "splith", + "border": "pixel", + "current_border_width": 2, + "rect": {"x": 2331, "y": 529, "width": 945, "height": 509}, + "deco_rect": {"x": 0, "y": 0, "width": 0, "height": 0}, + "window_rect": {"x": 2, "y": 2, "width": 941, "height": 505}, + "geometry": {"x": 0, "y": 0, "width": 724, "height": 412}, + "name": "~/.dotfiles", + "title_format": " %title ", + "window": 50331651, + "window_properties": { + "class": "Alacritty", + "instance": "Alacritty", + "title": "~/.dotfiles", + "transient_for": None, + }, + "nodes": [], + "floating_nodes": [], + "focus": [], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], + }, ], - 'floating_nodes':[], - 'focus':[93860418425760, 93860418808208], - 'fullscreen_mode':0, - 'sticky':False, - 'floating':'auto_off', - 'swallows':[] - } + "floating_nodes": [], + "focus": [93860418425760, 93860418808208], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], + }, ], - 'floating_nodes': [], - 'focus': [93860418434672, 93860418798800], - 'fullscreen_mode': 0, - 'sticky': False, - 'floating': 'auto_off', - 'swallows': [] + "floating_nodes": [], + "focus": [93860418434672, 93860418798800], + "fullscreen_mode": 0, + "sticky": False, + "floating": "auto_off", + "swallows": [], } windows = treeutils.get_leaves(workspace_tree) assert windows is not None