commit f450348769e82e9c2b2749e9f03a36641e64d1ea Author: Ezri Brimhall Date: Tue Oct 22 15:06:28 2024 -0600 Initial commit, first version of the context manager diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f26cd3f --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Sway Context Manager + +This is a workspace context manager for the Sway compositor. It is designed to sit between user interaction and the Sway IPC interface in order to translate commands that deal with workspaces. + +The original concept behind this was to have muliple "contexts" (i.e. personal, work, etc.) that would contain workspaces, and control which workspaces were exposed to keybinds and status bars. + +This has evolved into something to track dynamic display configurations and reconfigure Sway and EWW on the fly to handle them properly. + +Communication with this process occurs over D-Bus. It provides EWW with JSON data via a D-Bus signal. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..9c22e41 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,307 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "executing" +version = "2.1.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +files = [ + {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, + {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "i3ipc" +version = "2.2.1" +description = "An improved Python library to control i3wm and sway" +optional = false +python-versions = ">=3.4.0" +files = [ + {file = "i3ipc-2.2.1-py3-none-any.whl", hash = "sha256:c0b898223d50d42c90c818deb5033d1304c582755547dee7d15df3e3781bc690"}, + {file = "i3ipc-2.2.1.tar.gz", hash = "sha256:e880d7d7147959ead5cb34764f08b97b41385b36eb8256e8af1ce163dbcccce8"}, +] + +[package.dependencies] +python-xlib = "*" + +[[package]] +name = "ipython" +version = "8.28.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.28.0-py3-none-any.whl", hash = "sha256:530ef1e7bb693724d3cdc37287c80b07ad9b25986c007a53aa1857272dac3f35"}, + {file = "ipython-8.28.0.tar.gz", hash = "sha256:0d0d15ca1e01faeb868ef56bc7ee5a0de5bd66885735682e8a322ae289a13d1a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "python-xlib" +version = "0.33" +description = "Python X Library" +optional = false +python-versions = "*" +files = [ + {file = "python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32"}, + {file = "python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398"}, +] + +[package.dependencies] +six = ">=1.10.0" + +[[package]] +name = "sdbus" +version = "0.12.0" +description = "Modern Python D-Bus library. Based on sd-bus from libsystemd." +optional = false +python-versions = ">=3.7" +files = [ + {file = "sdbus-0.12.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d918c8ad14ef00e589d6752ac0f2b6540a20c625e85000c152376945fca14209"}, + {file = "sdbus-0.12.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d545e65637536a63898e2366b2a74617aa1a2215b1c16b23475f912e3407838c"}, + {file = "sdbus-0.12.0.tar.gz", hash = "sha256:c3692d75704438a78adc1439350bc32f30d6b38ad344cfc94773db89c6ce4a89"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "8a614d458afd106177bcc01d92e0fa81508bbde310b172ecd879451341907021" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e61c6f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[tool.poetry] +name = "sway-context-manager" +version = "0.1.0" +description = "Workspace context manager for the Sway compositor" +authors = ["Ezri Brimhall "] +readme = "README.md" + +[tool.poetry.scripts] +sway_context_manager = 'sway_context_manager.server:main' +sway_listen = 'sway_context_manager.client:main' +sway_get_focused_workspace = 'sway_context_manager.client:get_workspace_definition' + +[tool.poetry.dependencies] +python = "^3.10" +i3ipc = "^2.2.1" +sdbus = "^0.12.0" + + +[tool.poetry.group.dev.dependencies] +ipython = "^8.28.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/sway_context_manager/__init__.py b/sway_context_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sway_context_manager/client.py b/sway_context_manager/client.py new file mode 100644 index 0000000..dbae22f --- /dev/null +++ b/sway_context_manager/client.py @@ -0,0 +1,25 @@ +from .interface import ContextMngrInterface +import asyncio + + +class ContextMngrProxy(ContextMngrInterface): + def __init__(self): + self._proxify("dev.ezri.sway", "/ContextManager") + + +async def listen(manager: ContextMngrProxy): + print(await manager.get_workspace_data(), flush=True) + async for event in manager.tree_changed: + print(event, flush=True) + + +def main(): + loop = asyncio.new_event_loop() + manager = ContextMngrProxy() + loop.run_until_complete(listen(manager)) + + +def get_workspace_definition(): + loop = asyncio.new_event_loop() + manager = ContextMngrProxy() + print(loop.run_until_complete(manager.get_workspace_definition())) diff --git a/sway_context_manager/interface.py b/sway_context_manager/interface.py new file mode 100644 index 0000000..4d6ee70 --- /dev/null +++ b/sway_context_manager/interface.py @@ -0,0 +1,115 @@ +from sdbus import ( + DbusInterfaceCommonAsync, + dbus_method_async, + dbus_property_async, + dbus_signal_async, +) +import os +from .workspace_tree import WorkspaceTree +from i3ipc.aio import Connection +import json + + +class ContextMngrInterface( + DbusInterfaceCommonAsync, + interface_name="dev.ezri.sway.ContextManager", +): + + workspace_tree: WorkspaceTree = None + connection: Connection = None + + def __init__( + self, workspace_tree: WorkspaceTree, connection: Connection, *args, **kwargs + ): + super().__init__(*args, **kwargs) + self.workspace_tree = workspace_tree + self.connection = connection + + @dbus_method_async(input_signature="s", result_signature="s") + async def request_context(self, context: str) -> str: + """Request a context switch. This will fail if the current monitor configuration is not compatible with the requested context.""" + print("requesting context", context) + try: + self.workspace_tree.switch_context(context) + return "OK" + except Exception as e: + return str(e) + + @dbus_method_async(input_signature="", result_signature="s") + async def get_current_context(self) -> str: + """Get the current context.""" + return self.workspace_tree.current_context.name + + @dbus_method_async(input_signature="y", result_signature="b") + async def focus_workspace(self, user_index: int) -> bool: + """Focus a workspace by its 1-indexed ID in its group.""" + print("focusing workspace", user_index) + workspace = self.workspace_tree.get_workspace(user_index) + if workspace is None: + return False + await workspace.focus(self.connection) + return True + + @dbus_method_async(input_signature="y", result_signature="b") + async def move_container(self, user_index: int) -> bool: + """Move the focused container to a workspace by its 1-indexed ID in its group.""" + print("Moving focused container to workspace", user_index) + workspace = self.workspace_tree.get_workspace(user_index) + if workspace is None: + return False + await workspace.move_container(self.connection) + return True + + @dbus_method_async(input_signature="", result_signature="b") + async def launch_default(self) -> bool: + """Launch the default application for the current workspace.""" + print("launching default") + await self.workspace_tree.current_context.active_workspace.launch_default( + self.connection + ) + return True + + @dbus_method_async(input_signature="y", result_signature="s") + async def get_workspace(self, user_index: int) -> bool: + """Get a workspace by its 1-indexed ID in its group.""" + workspace = self.workspace_tree.get_workspace(user_index) + if workspace is None: + return "" + return json.dumps(workspace.__json__()) + + def __get_workspace_data(self) -> dict: + """Get the workspace tree.""" + try: + return { + "ws": self.workspace_tree.__json__(), + "current": self.workspace_tree.current_context.active_workspace.__json__(), + "context": self.workspace_tree.current_context.name, + "visible": { + name: ws.__json__() + for name, ws in self.workspace_tree.current_context.visible_workspaces.items() + }, + } + except Exception as e: + print(e) + return {} + + @dbus_method_async(input_signature="", result_signature="s") + async def get_workspace_data(self) -> str: + """Get the workspace tree.""" + data = self.__get_workspace_data() + return json.dumps(data) + + @dbus_method_async(input_signature="", result_signature="s") + async def get_workspace_definition(self) -> str: + """Get the definition of the currently focused workspace.""" + workspace = self.workspace_tree.current_context.active_workspace + return json.dumps(workspace.definition) + + @dbus_signal_async("s") + async def tree_changed(self): + """Signal emitted when the workspace tree changes.""" + raise NotImplementedError + + async def on_tree_changed(self, workspace_tree: str): + print("tree changed, emitting signal") + self.tree_changed.emit(json.dumps(self.__get_workspace_data())) diff --git a/sway_context_manager/server.py b/sway_context_manager/server.py new file mode 100644 index 0000000..6803c48 --- /dev/null +++ b/sway_context_manager/server.py @@ -0,0 +1,30 @@ +import asyncio +from .workspace_tree import WorkspaceTree +from .interface import ContextMngrInterface +from .swayipc import SwayConnection +import os +from sdbus import request_default_bus_name_async, sd_bus_open_user, set_default_bus + +WORKSPACES_FILE = os.environ.get( + "WORKSPACES_FILE", os.path.expanduser("~/.config/sway/workspaces.json") +) + + +def main(): + set_default_bus(sd_bus_open_user()) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + tree = WorkspaceTree(WORKSPACES_FILE) + sway = SwayConnection(tree) + interface = ContextMngrInterface(tree, sway.connection) + sway.on_change(interface.on_tree_changed) + + loop.run_until_complete(request_default_bus_name_async("dev.ezri.sway")) + interface.export_to_dbus("/ContextManager") + loop.create_task(sway.run()) + loop.run_forever() + + +if __name__ == "__main__": + main() diff --git a/sway_context_manager/swayipc.py b/sway_context_manager/swayipc.py new file mode 100644 index 0000000..9b935d3 --- /dev/null +++ b/sway_context_manager/swayipc.py @@ -0,0 +1,50 @@ +from i3ipc.aio import Connection +from i3ipc import Event +from .workspace_tree import WorkspaceTree +from .utils import async_debounce + + +class SwayConnection: + + def __init__(self, workspace_tree: WorkspaceTree): + self.connection = Connection(auto_reconnect=True) + self.workspace_tree = workspace_tree + self.on_change_callbacks = [] + + async def on_workspace(self, connection=None, _=None): + new_data = {ws.name: ws for ws in await self.connection.get_workspaces()} + self.workspace_tree.update_workspaces(new_data) + for callback in self.on_change_callbacks: + await callback(self.workspace_tree) + + @async_debounce(2) + async def on_output(self, connection, event): + """On output event, after 2 seconds, update the workspace tree""" + self.workspace_tree.update_context(connection) + + async def on_mode(self, event): + """On mode change event, do something. Not sure what yet.""" + pass + + async def run(self): + # Connect to sway + await self.connection.connect() + + # Register event listeners + self.connection.on(Event.WORKSPACE, self.on_workspace) + self.connection.on(Event.OUTPUT, self.on_output) + self.connection.on(Event.MODE, self.on_mode) + + # Update the workspace tree + await self.workspace_tree.update_context(self.connection) + await self.on_workspace() + + # Wait for events + await self.connection.main() + + def on_change(self, callback): + """Register a callback to be called when the workspace tree changes. The callback should be a coroutine, and should take the workspace tree as an argument.""" + self.on_change_callbacks.append(callback) + + async def close(self): + await self.connection.close() diff --git a/sway_context_manager/utils.py b/sway_context_manager/utils.py new file mode 100644 index 0000000..12cd8b6 --- /dev/null +++ b/sway_context_manager/utils.py @@ -0,0 +1,24 @@ +import asyncio +from functools import wraps + + +def async_debounce(wait): + def decorator(func): + task: asyncio.Task | None = None + + @wraps(func) + async def debounced(*args, **kwargs): + nonlocal task + if task and not task.done(): + return + + async def call_func(): + await asyncio.sleep(wait) + await func(*args, **kwargs) + + task = asyncio.create_task(call_func()) + return task + + return debounced + + return decorator diff --git a/sway_context_manager/workspace_tree.py b/sway_context_manager/workspace_tree.py new file mode 100644 index 0000000..57234af --- /dev/null +++ b/sway_context_manager/workspace_tree.py @@ -0,0 +1,387 @@ +from typing import Any +import json +from i3ipc.replies import OutputReply +from i3ipc.aio import Connection +import asyncio +import subprocess + +DEFAULT_TERMINAL = "alacritty" + + +class Workspace: + """A class representing a Sway workspace in the format we use. + + Attributes: + index: The index of the workspace, as a string. This is the Sway workspace name, and is never displayed to the user. + name: The name of the workspace, as a string. This is the name that is displayed to the user, as defined in `workspaces.json`. + """ + + index: int = None + name: str = None + + active: bool = False + visible: bool = False + focused: bool = False + alerted: bool = False + + def __init__(self, dictionary: dict[str, str | int | dict[str, str]]): + self.index = dictionary.get("index") + self.name = dictionary.get("name") + + self.definition = dictionary + + def update_state(self, state_update: Any): + self.active = True + self.visible = state_update.visible + self.focused = state_update.focused + self.alerted = state_update.urgent + + def deactivate(self): + self.active = False + self.visible = False + self.focused = False + self.alerted = False + + def __dict__(self): + return { + "index": str(self.index), + "name": self.name, + "active": self.active, + "visible": self.visible, + "focused": self.focused, + "alerted": self.alerted, + } + + def __json__(self): + return self.__dict__() + + def __repr__(self): + return f"Workspace({self.index}, '{self.name}')" + + async def focus(self, i3: Connection): + """Focus the workspace in Sway.""" + await i3.command(f"workspace {self.index}") + + async def move_container(self, i3: Connection): + """Move the focused container to the workspace.""" + await i3.command(f"move container to workspace {self.index}") + + +class WorkspaceGroup: + """A class representing a group of workspaces. Generally maps to a monitor.""" + + name: str = None + workspaces: list[Workspace] = None + reverse: bool = False + + def __init__(self, output_data: dict[str, str]): + self.name = output_data["group"] + self.make = output_data.get("make", None) + self.model = output_data.get("model", None) + self.serial = output_data.get("serial", None) + self.position = output_data.get("position", [0, 0]) + self.mode = output_data.get("mode", None) + self.transform = output_data.get("transform", None) + self.eww_windows = output_data.get("eww_windows", []) + self.workspaces = [] + + def add_workspace(self, workspace: Workspace): + """Add a workspace to the group.""" + self.workspaces.append(workspace) + + def __iter__(self): + return iter(self.workspaces) + + def __json__(self): + if self.reverse: + return [workspace.__json__() for workspace in reversed(self.workspaces)] + return [workspace.__json__() for workspace in self.workspaces] + + def __repr__(self): + return f"WorkspaceGroup({self.name}, {repr(self.workspaces)})" + + @property + def active_workspace(self) -> Workspace: + """Returns the active workspace in the group.""" + return next( + (workspace for workspace in self.workspaces if workspace.visible), + None, + ) + + @property + def active(self) -> bool: + """Returns whether the group is active.""" + return any(workspace.focused for workspace in self.workspaces) + + async def configure(self, i3: Connection): + """Configure the group output in Sway.""" + transform = "" + mode = "" + selector = "" + if self.transform: + transform = f"transform {self.transform}" + if self.mode: + mode = f"mode {self.mode}" + if self.make and self.model and self.serial: + selector = f'"{self.make} {self.model} {self.serial}"' + await i3.command( + f"output {selector} position {self.position[0]} {self.position[1]} {mode} {transform}" + ) + + async def get_output_name(self, i3: Connection) -> str: + """Get the name of the output in Sway.""" + outputs = await i3.get_outputs() + for output in outputs: + if ( + output.make == self.make + and output.model == self.model + and output.serial == self.serial + ): + return output.name + return None + + +class WorkspaceContext: + """A class representing a context, containing all workspaces and groups within the context.""" + + name: str = None + groups: list[WorkspaceGroup] = None + + def __init__( + self, + name: str, + data: dict[str, list[dict[str, list[int]]] | list[dict[str, str]]], + workspaces: list[Workspace], + ): + self.groups = [WorkspaceGroup(output) for output in data["outputs"]] + self.name = name + for group in self.groups: + group_object = data["groups"][group.name] + for workspace in group_object["workspaces"]: + workspace_obj = next( + (w for w in workspaces if w.index == workspace), + None, + ) + if workspace_obj: + group.add_workspace(workspace_obj) + else: + raise Exception( + f"Error: undefined workspace {workspace} referenced in context {name} group {group.name}" + ) + + def add_group(self, group: WorkspaceGroup): + """Add a group to the context.""" + self.groups.append(group) + + async def activate(self, i3: Connection): + """Activate the context in Sway.""" + defined_displays = [ + f"{group.make} {group.model} {group.serial}" for group in self.groups + ] + # First, disable all displays not defined in the context + for output in await i3.get_outputs(): + if f"{output.make} {output.model} {output.serial}" not in defined_displays: + print("Disabling", output.name) + await i3.command(f"output {output.name} disable") + + # Next, configure all displays defined in the context + for group in self.groups: + await group.configure(i3) + + # Then, close all EWW windows + proc = await asyncio.create_subprocess_exec("eww", "close-all") + await proc.wait() + + # Then, open all EWW windows defined in the context on the appropriate windows + for group in self.groups: + for window in group.eww_windows: + proc = await asyncio.create_subprocess_exec( + "eww", + "open", + window, + "--screen", + await group.get_output_name(i3), + ) + await proc.wait() + + @property + def active_group(self) -> WorkspaceGroup: + """Returns the active group in the context.""" + return next( + (group for group in self.groups if group.active), + None, + ) + + @property + def active_workspace(self) -> Workspace: + """Returns the active workspace in the context.""" + try: + return self.active_group.active_workspace + except AttributeError: + # A context may not have any active workspaces, in which case we return None + return None + + @property + def visible_workspaces(self) -> dict[str, Workspace]: + """Returns a dictionary of all visible workspaces in the context.""" + return {group.name: group.active_workspace for group in self.groups} + + def __iter__(self): + return iter(self.groups) + + def __repr__(self): + return f"WorkspaceContext({self.name}, {repr(self.groups)})" + + def __json__(self): + return {group.name: group.__json__() for group in self.groups} + + +class WorkspaceTree: + + current_context: WorkspaceContext = None + workspaces: list[Workspace] = None + + def __init__(self, filename: str): + + with open(filename, "r") as file: + initial: dict = json.load(file) + + self.contexts: list[WorkspaceContext] = [] + self.workspaces = [Workspace(workspace) for workspace in initial["workspaces"]] + + for context, context_dict in initial["contexts"].items(): + context_obj = WorkspaceContext(context, context_dict, self.workspaces) + self.contexts.append(context_obj) + if initial.get("default_context", "personal") == context: + self.current_context = context_obj + + def __dict__(self): + return { + "current_context": self.current_context, + "contexts": self.contexts, + } + + def __repr__(self): + return f"WorkspaceTree({self.current_context}, {repr(self.contexts)})" + + def __json__(self): + return {context.name: context.__json__() for context in self.contexts} + + def get_workspace(self, user_index: int) -> Workspace: + """Returns a workspace object based on the user index.""" + if user_index < 1: + raise IndexError("Workspace index must be greater than or equal to 1.") + # First, find the active workspace + active_workspace = self.current_context.active_workspace + # Next, get its index within its group, and the active group index + active_index = ( + self.current_context.active_group.workspaces.index(active_workspace) + 1 + ) + group_index = self.current_context.groups.index( + self.current_context.active_group + ) + + # If the user index is the same as the active index, or is outside the range for this group, start searching other groups + if user_index == active_index or user_index > len( + self.current_context.active_group.workspaces + ): + counter = 0 + while counter < len(self.current_context.groups): + group = self.current_context.groups[ + (group_index + counter) % len(self.current_context.groups) + ] + if ( + user_index > len(group.workspaces) + or group == self.current_context.active_group + ): + # Group doesn't have enough workspaces, or is the active group + counter += 1 + + continue + return group.workspaces[user_index - 1] + # If we didn't find a workspace, return None + return None + else: + # If the user index is different from the active index, return the workspace in the active group + return self.current_context.active_group.workspaces[user_index - 1] + + def update_workspaces(self, ws_sway_dict: dict): + """Updates the workspaces in the tree based on the return from sway IPC.""" + touched = set() + + for index, ws_data in ws_sway_dict.items(): + # Udpate data for all active workspaces + ws_state: Workspace = next( + (ws for ws in self.workspaces if str(ws.index) == index), + None, + ) + if ws_state: + touched.add(ws_state) + ws_state.update_state(ws_data) + else: + print(f"Warning: workspace {index} not found in tree.") + + # Deactivate any workspaces that weren't touched + for ws in self.workspaces: + if ws not in touched: + ws.deactivate() + + async def update_context(self, i3: Connection): + """Activates a new context in Sway based on the current display configuration.""" + # First, get the current display configuration + outputs = await i3.get_outputs() + active_outputs = [ + f'"{output.make} {output.model} {output.serial}"' for output in outputs + ] + + # Next, find the context that matches the current display configuration. + # We first want to find an exact match, and if that fails, we want to find one where + # all of its required outputs are present (so extra outputs are fine, they'll be disabled). + # We will not return a partial match except for the case noted above. + + for context in self.contexts: + # First pass, look for exact matches + context_outputs = [ + f'"{group.make} {group.model} {group.serial}"' + for group in context.groups + ] + if all(output in active_outputs for output in context_outputs) and all( + output in context_outputs for output in active_outputs + ): + self.current_context = context + await context.activate(i3) + return + + for context in self.contexts: + context_outputs = [ + f'"{group.make} {group.model} {group.serial}"' + for group in context.groups + ] + if all(output in active_outputs for output in context_outputs): + self.current_context = context + await context.activate(i3) + return + + async def activate_context(self, i3: Connection, name: str): + """Activates a context by name. This will fail if the current display configuration is incompatible.""" + context = next( + (context for context in self.contexts if context.name == name), + None, + ) + + outputs = await i3.get_outputs() + active_outputs = [ + f'"{output.make} {output.model} {output.serial}"' for output in outputs + ] + + context_outputs = [ + f'"{group.make} {group.model} {group.serial}"' for group in context.groups + ] + + if all(output in active_outputs for output in context_outputs): + self.current_context = context + await context.activate(i3) + else: + raise ValueError( + f"Context {name} is incompatible with the current display configuration." + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29