Initial commit, first version of the context manager
This commit is contained in:
commit
f450348769
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__
|
||||
9
README.md
Normal file
9
README.md
Normal file
@ -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.
|
||||
307
poetry.lock
generated
Normal file
307
poetry.lock
generated
Normal file
@ -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"
|
||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@ -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 <ezri@ezri.dev>"]
|
||||
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"
|
||||
0
sway_context_manager/__init__.py
Normal file
0
sway_context_manager/__init__.py
Normal file
25
sway_context_manager/client.py
Normal file
25
sway_context_manager/client.py
Normal file
@ -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()))
|
||||
115
sway_context_manager/interface.py
Normal file
115
sway_context_manager/interface.py
Normal file
@ -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()))
|
||||
30
sway_context_manager/server.py
Normal file
30
sway_context_manager/server.py
Normal file
@ -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()
|
||||
50
sway_context_manager/swayipc.py
Normal file
50
sway_context_manager/swayipc.py
Normal file
@ -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()
|
||||
24
sway_context_manager/utils.py
Normal file
24
sway_context_manager/utils.py
Normal file
@ -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
|
||||
387
sway_context_manager/workspace_tree.py
Normal file
387
sway_context_manager/workspace_tree.py
Normal file
@ -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."
|
||||
)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
x
Reference in New Issue
Block a user