summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorAylur <[email protected]>2024-05-19 02:39:53 +0200
committerAylur <[email protected]>2024-05-19 02:39:53 +0200
commit1425b396b08f0e91d45bbd0f92b1309115c7c870 (patch)
tree8af1a899a14d8a01a9ef50e248c077b48aed25bc /python
init 0.1.0
Diffstat (limited to 'python')
-rw-r--r--python/.gitignore1
-rw-r--r--python/astal/__init__.py16
-rw-r--r--python/astal/application.py62
-rw-r--r--python/astal/binding.py33
-rw-r--r--python/astal/variable.py100
-rw-r--r--python/astal/widget.py65
-rw-r--r--python/pyproject.toml14
-rw-r--r--python/ruff.toml62
-rwxr-xr-xpython/sample.py31
9 files changed, 384 insertions, 0 deletions
diff --git a/python/.gitignore b/python/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/python/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/python/astal/__init__.py b/python/astal/__init__.py
new file mode 100644
index 0000000..c679c4a
--- /dev/null
+++ b/python/astal/__init__.py
@@ -0,0 +1,16 @@
+import gi
+
+gi.require_version("Astal", "0.1")
+gi.require_version("Gtk", "3.0")
+gi.require_version("GLib", "2.0")
+gi.require_version("Gio", "2.0")
+gi.require_version("GObject", "2.0")
+from gi.repository import Astal, Gtk, GLib, Gio, GObject
+from .application import App
+from .variable import Variable
+from .binding import Binding
+from . import widget as Widget
+
+bind = Binding
+
+__all__ = ["App", "Variable", "Widget" "bind", "Astal", "Gtk", "GLib", "Gio", "GObject"]
diff --git a/python/astal/application.py b/python/astal/application.py
new file mode 100644
index 0000000..3eb1a4f
--- /dev/null
+++ b/python/astal/application.py
@@ -0,0 +1,62 @@
+from collections.abc import Callable
+from gi.repository import Astal, Gio
+
+RequestHandler = Callable[[str, Callable[[str], None]], None]
+
+
+class _Application(Astal.Application):
+ def __init__(self) -> None:
+ super().__init__()
+ self.request_handler: RequestHandler | None = None
+
+ def do_response(self, msg: str, conn: Gio.SocketConnection) -> None:
+ if self.request_handler:
+ self.request_handler(
+ msg,
+ lambda response: Astal.write_sock(
+ conn,
+ response,
+ lambda _, res: Astal.write_sock_finish(res),
+ ),
+ )
+ else:
+ super().do_response(msg, conn)
+
+ def start(
+ self,
+ instance_name: str | None = None,
+ gtk_theme: str | None = None,
+ icon_theme: str | None = None,
+ cursor_theme: str | None = None,
+ css: str | None = None,
+ hold: bool | None = True,
+ request_handler: RequestHandler | None = None,
+ callback: Callable | None = None,
+ ) -> None:
+ if request_handler:
+ self.request_handler = request_handler
+ if hold:
+ self.hold()
+ if instance_name:
+ self.instance_name = instance_name
+ if gtk_theme:
+ self.gtk_theme = gtk_theme
+ if icon_theme:
+ self.icon_theme = icon_theme
+ if cursor_theme:
+ self.cursor_theme = icon_theme
+ if css:
+ self.apply_css(css, False)
+ if not self.acquire_socket():
+ print(f"Astal instance {self.instance_name} already running")
+ return
+
+ def on_activate(app):
+ if callback:
+ callback()
+
+ self.connect("activate", on_activate)
+ self.run()
+
+
+App = _Application()
diff --git a/python/astal/binding.py b/python/astal/binding.py
new file mode 100644
index 0000000..0fe1b6c
--- /dev/null
+++ b/python/astal/binding.py
@@ -0,0 +1,33 @@
+import re
+
+
+def kebabify(string):
+ return re.sub(r"([a-z])([A-Z])", r"\1-\2", string).replace("_", "-").lower()
+
+
+class Binding:
+ def __init__(self, emitter, prop=None):
+ self.emitter = emitter
+ self.prop = kebabify(prop) if prop else None
+ self.transform_fn = lambda v: v
+
+ def __str__(self):
+ return f"Binding<{self.emitter}{', ' + self.prop if self.prop else ''}>"
+
+ def as_(self, fn):
+ bind = Binding(self.emitter, self.prop)
+ bind.transform_fn = lambda v: fn(self.transform_fn(v))
+ return bind
+
+ def get(self):
+ if hasattr(self.emitter, "get") and callable(self.emitter.get):
+ return self.transform_fn(self.emitter.get())
+
+ return self.transform_fn(self.emitter[f"get_{self.prop}"]())
+
+ def subscribe(self, callback):
+ if hasattr(self.emitter, "subscribe") and callable(self.emitter.subscribe):
+ return self.emitter.subscribe(lambda _: callback(self.get()))
+
+ i = self.emitter.connect(f"notify::{self.prop}", lambda: callback(self.get()))
+ return lambda: self.emitter.disconnect(i)
diff --git a/python/astal/variable.py b/python/astal/variable.py
new file mode 100644
index 0000000..3b6a71d
--- /dev/null
+++ b/python/astal/variable.py
@@ -0,0 +1,100 @@
+from gi.repository import Astal
+
+from .binding import Binding
+
+
+class Variable:
+ def __init__(self, init):
+ v = Astal.Variable.new(init)
+ self._variable = v
+ self._err_handler = print
+ v.connect("error", lambda _, err: self._err_handler(err) if self._err_handler else None)
+
+ def __call__(self, transform=None):
+ if transform:
+ return Binding(self).as_(transform)
+
+ return Binding(self)
+
+ def __str__(self):
+ return f"Variable<{self.get()}>"
+
+ def get(self):
+ return self._variable.get_value()
+
+ def set(self, value):
+ return self._variable.set_value(value)
+
+ def watch(self, cmd):
+ if isinstance(cmd, str):
+ self._variable.watch(cmd)
+ elif isinstance(cmd, list):
+ self._variable.watchv(cmd)
+ return self
+
+ def poll(self, interval, cmd):
+ if isinstance(cmd, str):
+ self._variable.poll(interval, cmd)
+ elif isinstance(cmd, list):
+ self._variable.pollv(interval, cmd)
+ else:
+ self._variable.pollfn(interval, cmd)
+ return self
+
+ def start_watch(self):
+ self._variable.start_watch()
+
+ def start_poll(self):
+ self._variable.start_poll()
+
+ def stop_watch(self):
+ self._variable.stop_watch()
+
+ def stop_poll(self):
+ self._variable.stop_poll()
+
+ def drop(self):
+ self._variable.emit_dropped()
+ self._variable.run_dispose()
+
+ def on_dropped(self, callback):
+ self._variable.connect("dropped", lambda _: callback())
+ return self
+
+ def on_error(self, callback):
+ self._err_handler = None
+ self._variable.connect("error", lambda _, e: callback(e))
+ return self
+
+ def subscribe(self, callback):
+ s = self._variable.connect("changed", lambda _: callback(self.get()))
+ return lambda: self._variable.disconnect(s)
+
+ def observe(self, objs, sigOrFn, callback=None):
+ if callable(sigOrFn):
+ f = sigOrFn
+ elif callable(callback):
+ f = callback
+ else:
+ f = lambda *_: self.get()
+
+ def setter(_, *args):
+ self.set(f(*args))
+
+ if isinstance(objs, list):
+ for obj in objs:
+ obj[0].connect(obj[1], setter)
+ elif isinstance(sigOrFn, str):
+ objs.connect(sigOrFn, setter)
+
+ return self
+
+ @staticmethod
+ def derive(deps, fn):
+ def update():
+ return fn(*[d.get() for d in deps])
+
+ derived = Variable(update())
+ unsubs = [dep.subscribe(lambda _: derived.set(update())) for dep in deps]
+ derived.on_dropped(lambda: ([unsub() for unsub in unsubs]))
+ return derived
diff --git a/python/astal/widget.py b/python/astal/widget.py
new file mode 100644
index 0000000..7070e8f
--- /dev/null
+++ b/python/astal/widget.py
@@ -0,0 +1,65 @@
+from gi.repository import Astal, Gtk
+from .binding import Binding
+
+
+def set_child(self, child):
+ if isinstance(self, Gtk.Bin):
+ self.remove(self.get_child())
+ if isinstance(self, Gtk.Container):
+ self.add(child)
+
+
+def astalify(ctor):
+ ctor.set_css = Astal.widget_set_css
+ ctor.get_css = Astal.widget_get_css
+
+ ctor.set_class_name = lambda self, names: Astal.widget_set_class_names(self, names.split())
+ ctor.get_class_name = lambda self: " ".join(Astal.widget_set_class_names(self))
+
+ ctor.set_cursor = Astal.widget_set_cursor
+ ctor.get_cursor = Astal.widget_get_cursor
+
+ def widget(**kwargs):
+ args = {}
+ bindings = {}
+ handlers = {}
+ setup = None
+ if not hasattr(kwargs, "visible"):
+ kwargs["visible"] = True
+
+ for key, value in kwargs.items():
+ if key == "setup":
+ setup = value
+ if isinstance(value, Binding):
+ bindings[key] = value
+ if key.startswith("on_"):
+ handlers[key] = value
+ else:
+ args[key] = value
+
+ self = ctor(**args)
+
+ for key, value in bindings.items():
+ setter = getattr(self, f"set_{key}")
+ setter(value.get())
+ unsub = value.subscribe(setter)
+ self.connect("destroy", lambda _: unsub())
+
+ for key, value in handlers.items():
+ self.connect(key.replace("on_", ""), value)
+
+ if setup:
+ setup(self)
+
+ return self
+
+ return widget
+
+
+Window = astalify(Astal.Window)
+Box = astalify(Astal.Box)
+Button = astalify(Astal.Button)
+CenterBox = astalify(Astal.CenterBox)
+Label = astalify(Gtk.Label)
+Icon = astalify(Astal.Icon)
+EventBox = astalify(Astal.EventBox)
diff --git a/python/pyproject.toml b/python/pyproject.toml
new file mode 100644
index 0000000..2103286
--- /dev/null
+++ b/python/pyproject.toml
@@ -0,0 +1,14 @@
+[tool.poetry]
+name = "astal.py"
+version = "0.1.0"
+description = ""
+authors = []
+
+[tool.poetry.dependencies]
+python = "^3.11"
+gengir = "^1.0.2"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/python/ruff.toml b/python/ruff.toml
new file mode 100644
index 0000000..a6bedc2
--- /dev/null
+++ b/python/ruff.toml
@@ -0,0 +1,62 @@
+target-version = "py311"
+
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+]
+
+line-length = 100
+indent-width = 4
+
+[lint]
+select = ["ALL"]
+ignore = ["D", "ANN101", "ERA", "ANN"]
+
+# Allow fix for all enabled rules (when `--fix`) is provided.
+fixable = ["ALL"]
+unfixable = []
+
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+[format]
+quote-style = "double"
+indent-style = "space"
+
+# Like Black, respect magic trailing commas.
+skip-magic-trailing-comma = false
+
+# Like Black, automatically detect the appropriate line ending.
+line-ending = "auto"
+
+# Enable auto-formatting of code examples in docstrings. Markdown,
+# reStructuredText code/literal blocks and doctests are all supported.
+docstring-code-format = false
+
+# Set the line length limit used when formatting code snippets in
+# docstrings.
+docstring-code-line-length = "dynamic"
diff --git a/python/sample.py b/python/sample.py
new file mode 100755
index 0000000..af09ce2
--- /dev/null
+++ b/python/sample.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+import gi
+
+gi.require_version("Playerctl", "2.0")
+
+from gi.repository import Playerctl
+from astal import App, Astal, Variable, Widget, bind
+
+player = Playerctl.Player.new("spotify")
+v = Variable(player.get_title()).observe(player, "metadata", lambda *_: player.get_title())
+
+
+def Bar(monitor):
+ return Widget.Window(
+ anchor=Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT,
+ monitor=monitor,
+ exclusivity=Astal.Exclusivity.EXCLUSIVE,
+ child=Widget.CenterBox(
+ start_widget=Widget.Label(
+ label="Welcome to Astal.py!",
+ ),
+ end_widget=Widget.Label(label=v()),
+ ),
+ )
+
+
+def start():
+ Bar(0)
+
+
+App.start(callback=start)