Initial commit, first version of the context manager

This commit is contained in:
Ezri Brimhall 2024-10-22 15:06:28 -06:00
commit f450348769
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
12 changed files with 972 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

9
README.md Normal file
View 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
View 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
View 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"

View File

View 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()))

View 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()))

View 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()

View 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()

View 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

View 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
View File