feat: python

A python library similar to the gjs and lua one.
This is moved from the astal-sh/libastal repo.
This commit is contained in:
Aylur
2024-09-08 00:48:26 +00:00
parent 4407152c08
commit b568cd6261
9 changed files with 398 additions and 0 deletions

1
core/python/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -0,0 +1,17 @@
import gi
gi.require_version("Astal", "0.1")
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "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, Gdk
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", "Gdk", "GLib", "Gio", "GObject"]

View File

@@ -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_request(self, msg: str, conn: Gio.SocketConnection) -> None:
if self.request_handler:
self.request_handler(
msg,
lambda response: Astal.write_sock(
conn,
str(response),
lambda _, res: Astal.write_sock_finish(res),
),
)
else:
super().do_request(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()

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,78 @@
from gi.repository import Astal, Gtk
from .binding import Binding, kebabify
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(kebabify(key.replace("on_", "")), value)
if setup:
setup(self)
return self
return widget
Box = astalify(Astal.Box),
Button = astalify(Astal.Button),
CenterBox = astalify(Astal.CenterBox),
# TODO: CircularProgress
DrawingArea = astalify(Gtk.DrawingArea),
Entry = astalify(Gtk.Entry),
EventBox = astalify(Astal.EventBox),
# TODO: Fixed
# TODO: FlowBox
Icon = astalify(Astal.Icon),
Label = astalify(Gtk.Label),
LevelBar = astalify(Astal.LevelBar),
# TODO: ListBox
Overlay = astalify(Astal.Overlay),
Revealer = astalify(Gtk.Revealer),
Scrollable = astalify(Astal.Scrollable),
Slider = astalify(Astal.Slider),
# TODO: Stack
Switch = astalify(Gtk.Switch),
Window = astalify(Astal.Window),

View File

@@ -0,0 +1,14 @@
[tool.poetry]
name = "astal"
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"

62
core/python/ruff.toml Normal file
View File

@@ -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"

31
core/python/sample.py Executable file
View File

@@ -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)